From 0d735e43c2a7694ceddd020f0ef4e9fcec73346c Mon Sep 17 00:00:00 2001 From: gavindiaz Date: Sun, 12 Jul 2026 04:18:40 +0800 Subject: [PATCH] first commit --- .editorconfig | 34 + .gitattributes | 41 + .gitignore | 62 + Cargo.lock | 2124 +++++++++++++++++ Cargo.toml | 79 + INDICATORS.md | 619 +++++ LICENSE-APACHE | 201 ++ LICENSE-MIT | 21 + LICENSES/Apache-2.0.txt | 201 ++ LICENSES/MIT.txt | 21 + README.md | 196 ++ crates/wickra-bench/Cargo.toml | 22 + crates/wickra-bench/benches/cross_lib.rs | 699 ++++++ crates/wickra-bench/src/lib.rs | 6 + crates/wickra-core/Cargo.toml | 34 + crates/wickra-core/src/calendar.rs | 203 ++ crates/wickra-core/src/cross_section.rs | 387 +++ crates/wickra-core/src/derivatives.rs | 321 +++ crates/wickra-core/src/error.rs | 73 + .../src/indicators/abandoned_baby.rs | 247 ++ crates/wickra-core/src/indicators/abcd.rs | 154 ++ .../src/indicators/absolute_breadth_index.rs | 144 ++ .../src/indicators/acceleration_bands.rs | 277 +++ .../src/indicators/accelerator_oscillator.rs | 204 ++ .../src/indicators/ad_oscillator.rs | 243 ++ .../src/indicators/ad_volume_line.rs | 157 ++ .../src/indicators/adaptive_cci.rs | 245 ++ .../src/indicators/adaptive_cycle.rs | 143 ++ .../indicators/adaptive_laguerre_filter.rs | 344 +++ .../src/indicators/adaptive_rsi.rs | 296 +++ crates/wickra-core/src/indicators/adl.rs | 193 ++ .../src/indicators/advance_block.rs | 192 ++ .../src/indicators/advance_decline.rs | 168 ++ .../src/indicators/advance_decline_ratio.rs | 151 ++ crates/wickra-core/src/indicators/adx.rs | 345 +++ crates/wickra-core/src/indicators/adxr.rs | 246 ++ .../wickra-core/src/indicators/alligator.rs | 223 ++ crates/wickra-core/src/indicators/alma.rs | 335 +++ crates/wickra-core/src/indicators/alpha.rs | 220 ++ .../src/indicators/amihud_illiquidity.rs | 239 ++ .../src/indicators/anchored_rsi.rs | 284 +++ .../src/indicators/anchored_vwap.rs | 207 ++ .../src/indicators/andrews_pitchfork.rs | 353 +++ crates/wickra-core/src/indicators/apo.rs | 183 ++ crates/wickra-core/src/indicators/aroon.rs | 194 ++ .../src/indicators/aroon_oscillator.rs | 200 ++ crates/wickra-core/src/indicators/atr.rs | 437 ++++ .../wickra-core/src/indicators/atr_bands.rs | 214 ++ .../wickra-core/src/indicators/atr_ratchet.rs | 279 +++ .../src/indicators/atr_trailing_stop.rs | 279 +++ crates/wickra-core/src/indicators/auto_fib.rs | 191 ++ .../src/indicators/autocorrelation.rs | 224 ++ .../indicators/autocorrelation_periodogram.rs | 340 +++ .../src/indicators/average_daily_range.rs | 231 ++ .../src/indicators/average_drawdown.rs | 211 ++ .../wickra-core/src/indicators/avg_price.rs | 92 + .../src/indicators/awesome_oscillator.rs | 157 ++ .../awesome_oscillator_histogram.rs | 231 ++ .../src/indicators/balance_of_power.rs | 175 ++ .../src/indicators/bandpass_filter.rs | 243 ++ crates/wickra-core/src/indicators/bat.rs | 154 ++ .../wickra-core/src/indicators/belt_hold.rs | 211 ++ crates/wickra-core/src/indicators/beta.rs | 242 ++ .../src/indicators/beta_neutral_spread.rs | 261 ++ .../src/indicators/better_volume.rs | 254 ++ .../src/indicators/bipower_variation.rs | 281 +++ .../src/indicators/body_size_pct.rs | 193 ++ .../wickra-core/src/indicators/bollinger.rs | 520 ++++ .../src/indicators/bollinger_bandwidth.rs | 212 ++ .../wickra-core/src/indicators/bomar_bands.rs | 259 ++ .../src/indicators/breadth_thrust.rs | 165 ++ .../wickra-core/src/indicators/breakaway.rs | 250 ++ .../src/indicators/bullish_percent_index.rs | 147 ++ .../wickra-core/src/indicators/burke_ratio.rs | 218 ++ .../wickra-core/src/indicators/butterfly.rs | 154 ++ .../src/indicators/calendar_spread.rs | 141 ++ .../src/indicators/calmar_ratio.rs | 202 ++ .../src/indicators/camarilla_pivots.rs | 197 ++ .../src/indicators/candle_volume.rs | 231 ++ crates/wickra-core/src/indicators/cci.rs | 177 ++ .../src/indicators/center_of_gravity.rs | 204 ++ .../src/indicators/central_pivot_range.rs | 171 ++ crates/wickra-core/src/indicators/cfo.rs | 176 ++ .../src/indicators/chaikin_oscillator.rs | 242 ++ .../src/indicators/chaikin_volatility.rs | 232 ++ .../src/indicators/chande_kroll_stop.rs | 260 ++ .../src/indicators/chandelier_exit.rs | 242 ++ .../src/indicators/choppiness_index.rs | 229 ++ .../src/indicators/classic_pivots.rs | 202 ++ .../src/indicators/close_vs_open.rs | 157 ++ .../src/indicators/closing_marubozu.rs | 177 ++ crates/wickra-core/src/indicators/cmf.rs | 280 +++ crates/wickra-core/src/indicators/cmo.rs | 233 ++ .../indicators/coefficient_of_variation.rs | 187 ++ .../src/indicators/cointegration.rs | 461 ++++ .../src/indicators/common_sense_ratio.rs | 248 ++ .../src/indicators/composite_profile.rs | 347 +++ .../src/indicators/concealing_baby_swallow.rs | 278 +++ .../indicators/conditional_value_at_risk.rs | 221 ++ .../wickra-core/src/indicators/connors_rsi.rs | 307 +++ crates/wickra-core/src/indicators/coppock.rs | 254 ++ .../indicators/correlation_trend_indicator.rs | 258 ++ .../src/indicators/counterattack.rs | 245 ++ crates/wickra-core/src/indicators/crab.rs | 154 ++ .../src/indicators/cumulative_volume_index.rs | 163 ++ .../src/indicators/cup_and_handle.rs | 176 ++ crates/wickra-core/src/indicators/cvd.rs | 127 + .../src/indicators/cybernetic_cycle.rs | 240 ++ crates/wickra-core/src/indicators/cypher.rs | 152 ++ .../src/indicators/day_of_week_profile.rs | 202 ++ crates/wickra-core/src/indicators/decycler.rs | 213 ++ .../src/indicators/decycler_oscillator.rs | 182 ++ crates/wickra-core/src/indicators/dema.rs | 143 ++ .../src/indicators/demand_index.rs | 242 ++ .../src/indicators/demark_pivots.rs | 192 ++ .../wickra-core/src/indicators/depth_slope.rs | 258 ++ .../src/indicators/derivative_oscillator.rs | 218 ++ .../src/indicators/detrended_std_dev.rs | 224 ++ .../src/indicators/disparity_index.rs | 169 ++ .../src/indicators/distance_ssd.rs | 249 ++ crates/wickra-core/src/indicators/doji.rs | 316 +++ .../wickra-core/src/indicators/doji_star.rs | 213 ++ .../wickra-core/src/indicators/dollar_bars.rs | 224 ++ crates/wickra-core/src/indicators/donchian.rs | 181 ++ .../src/indicators/donchian_stop.rs | 218 ++ .../src/indicators/double_bollinger.rs | 261 ++ .../src/indicators/double_top_bottom.rs | 188 ++ .../indicators/downside_gap_three_methods.rs | 210 ++ crates/wickra-core/src/indicators/dpo.rs | 224 ++ .../src/indicators/dragonfly_doji.rs | 163 ++ .../src/indicators/drawdown_duration.rs | 174 ++ .../src/indicators/dumpling_top.rs | 215 ++ crates/wickra-core/src/indicators/dx.rs | 232 ++ .../src/indicators/dynamic_momentum_index.rs | 301 +++ .../src/indicators/ease_of_movement.rs | 289 +++ .../src/indicators/effective_spread.rs | 157 ++ .../src/indicators/ehlers_stochastic.rs | 224 ++ crates/wickra-core/src/indicators/ehma.rs | 202 ++ .../src/indicators/elder_impulse.rs | 243 ++ .../wickra-core/src/indicators/elder_ray.rs | 192 ++ .../src/indicators/elder_safezone.rs | 360 +++ crates/wickra-core/src/indicators/ema.rs | 441 ++++ .../empirical_mode_decomposition.rs | 266 +++ .../wickra-core/src/indicators/engulfing.rs | 193 ++ .../wickra-core/src/indicators/equivolume.rs | 235 ++ .../indicators/estimated_leverage_ratio.rs | 155 ++ .../src/indicators/even_better_sinewave.rs | 269 +++ .../src/indicators/evening_doji_star.rs | 259 ++ crates/wickra-core/src/indicators/evwma.rs | 238 ++ .../src/indicators/ewma_volatility.rs | 264 ++ .../wickra-core/src/indicators/expectancy.rs | 211 ++ .../src/indicators/falling_three_methods.rs | 235 ++ crates/wickra-core/src/indicators/fama.rs | 174 ++ crates/wickra-core/src/indicators/fib_arcs.rs | 198 ++ .../wickra-core/src/indicators/fib_channel.rs | 192 ++ .../src/indicators/fib_confluence.rs | 196 ++ .../src/indicators/fib_extension.rs | 186 ++ crates/wickra-core/src/indicators/fib_fan.rs | 180 ++ .../src/indicators/fib_projection.rs | 180 ++ .../src/indicators/fib_retracement.rs | 216 ++ .../src/indicators/fib_time_zones.rs | 196 ++ .../src/indicators/fibonacci_pivots.rs | 195 ++ .../wickra-core/src/indicators/fisher_rsi.rs | 186 ++ .../src/indicators/fisher_transform.rs | 199 ++ .../src/indicators/flag_pennant.rs | 162 ++ .../wickra-core/src/indicators/footprint.rs | 259 ++ .../wickra-core/src/indicators/force_index.rs | 198 ++ .../src/indicators/fractal_chaos_bands.rs | 271 +++ crates/wickra-core/src/indicators/frama.rs | 259 ++ .../src/indicators/fry_pan_bottom.rs | 218 ++ .../src/indicators/funding_basis.rs | 137 ++ .../src/indicators/funding_implied_apr.rs | 164 ++ .../src/indicators/funding_rate.rs | 131 + .../src/indicators/funding_rate_mean.rs | 181 ++ .../src/indicators/funding_rate_zscore.rs | 201 ++ .../src/indicators/gain_loss_ratio.rs | 184 ++ .../src/indicators/gain_to_pain_ratio.rs | 229 ++ .../src/indicators/gap_side_by_side_white.rs | 238 ++ crates/wickra-core/src/indicators/garch11.rs | 325 +++ .../src/indicators/garman_klass.rs | 286 +++ crates/wickra-core/src/indicators/gartley.rs | 157 ++ .../src/indicators/gator_oscillator.rs | 205 ++ .../src/indicators/generalized_dema.rs | 222 ++ .../src/indicators/geometric_ma.rs | 275 +++ .../src/indicators/golden_pocket.rs | 190 ++ .../src/indicators/granger_causality.rs | 350 +++ .../src/indicators/gravestone_doji.rs | 163 ++ crates/wickra-core/src/indicators/hammer.rs | 168 ++ .../wickra-core/src/indicators/hanging_man.rs | 159 ++ crates/wickra-core/src/indicators/harami.rs | 194 ++ .../src/indicators/harami_cross.rs | 208 ++ .../indicators/hasbrouck_information_share.rs | 265 ++ .../src/indicators/head_and_shoulders.rs | 191 ++ .../wickra-core/src/indicators/heikin_ashi.rs | 198 ++ .../src/indicators/heikin_ashi_oscillator.rs | 231 ++ .../src/indicators/high_low_index.rs | 162 ++ .../src/indicators/high_low_range.rs | 174 ++ .../src/indicators/high_low_volume_nodes.rs | 307 +++ .../wickra-core/src/indicators/high_wave.rs | 160 ++ .../src/indicators/highpass_filter.rs | 215 ++ crates/wickra-core/src/indicators/hikkake.rs | 198 ++ .../src/indicators/hikkake_modified.rs | 196 ++ .../src/indicators/hilbert_dominant_cycle.rs | 272 +++ .../src/indicators/hilo_activator.rs | 264 ++ .../src/indicators/historical_volatility.rs | 309 +++ crates/wickra-core/src/indicators/hma.rs | 181 ++ .../src/indicators/holt_winters.rs | 315 +++ .../src/indicators/homing_pigeon.rs | 169 ++ .../wickra-core/src/indicators/ht_dcphase.rs | 303 +++ .../wickra-core/src/indicators/ht_phasor.rs | 240 ++ .../src/indicators/ht_trendmode.rs | 379 +++ .../src/indicators/hurst_channel.rs | 236 ++ .../src/indicators/hurst_exponent.rs | 302 +++ crates/wickra-core/src/indicators/ichimoku.rs | 434 ++++ .../src/indicators/identical_three_crows.rs | 230 ++ .../src/indicators/imbalance_bars.rs | 263 ++ crates/wickra-core/src/indicators/in_neck.rs | 191 ++ crates/wickra-core/src/indicators/inertia.rs | 179 ++ .../src/indicators/information_ratio.rs | 187 ++ .../src/indicators/initial_balance.rs | 254 ++ .../src/indicators/instantaneous_trendline.rs | 213 ++ .../src/indicators/intraday_intensity.rs | 184 ++ .../src/indicators/intraday_momentum_index.rs | 228 ++ .../indicators/intraday_volatility_profile.rs | 236 ++ .../indicators/inverse_fisher_transform.rs | 164 ++ .../src/indicators/inverted_hammer.rs | 161 ++ .../wickra-core/src/indicators/jarque_bera.rs | 263 ++ crates/wickra-core/src/indicators/jma.rs | 286 +++ .../src/indicators/jump_indicator.rs | 291 +++ crates/wickra-core/src/indicators/k_ratio.rs | 239 ++ .../wickra-core/src/indicators/kagi_bars.rs | 283 +++ .../src/indicators/kalman_hedge_ratio.rs | 293 +++ crates/wickra-core/src/indicators/kama.rs | 195 ++ .../src/indicators/kase_devstop.rs | 341 +++ .../indicators/kase_permission_stochastic.rs | 234 ++ .../src/indicators/kelly_criterion.rs | 202 ++ crates/wickra-core/src/indicators/keltner.rs | 244 ++ .../wickra-core/src/indicators/kendall_tau.rs | 309 +++ crates/wickra-core/src/indicators/kicking.rs | 189 ++ .../src/indicators/kicking_by_length.rs | 197 ++ crates/wickra-core/src/indicators/kst.rs | 303 +++ crates/wickra-core/src/indicators/kurtosis.rs | 206 ++ crates/wickra-core/src/indicators/kvo.rs | 263 ++ .../src/indicators/kyles_lambda.rs | 281 +++ .../src/indicators/ladder_bottom.rs | 207 ++ .../src/indicators/laguerre_rsi.rs | 283 +++ .../indicators/lead_lag_cross_correlation.rs | 340 +++ crates/wickra-core/src/indicators/linreg.rs | 297 +++ .../src/indicators/linreg_angle.rs | 177 ++ .../src/indicators/linreg_channel.rs | 269 +++ .../src/indicators/linreg_intercept.rs | 173 ++ .../src/indicators/linreg_slope.rs | 273 +++ .../src/indicators/liquidation_features.rs | 179 ++ .../wickra-core/src/indicators/log_return.rs | 218 ++ .../src/indicators/long_legged_doji.rs | 157 ++ .../wickra-core/src/indicators/long_line.rs | 234 ++ .../src/indicators/long_short_ratio.rs | 138 ++ .../wickra-core/src/indicators/m2_measure.rs | 232 ++ .../wickra-core/src/indicators/ma_envelope.rs | 216 ++ crates/wickra-core/src/indicators/macd.rs | 453 ++++ crates/wickra-core/src/indicators/macd_ext.rs | 286 +++ crates/wickra-core/src/indicators/macd_fix.rs | 117 + .../src/indicators/macd_histogram.rs | 184 ++ crates/wickra-core/src/indicators/mama.rs | 381 +++ .../indicators/market_facilitation_index.rs | 185 ++ .../src/indicators/martin_ratio.rs | 220 ++ crates/wickra-core/src/indicators/marubozu.rs | 214 ++ .../wickra-core/src/indicators/mass_index.rs | 234 ++ crates/wickra-core/src/indicators/mat_hold.rs | 308 +++ .../src/indicators/matching_low.rs | 166 ++ .../src/indicators/max_drawdown.rs | 245 ++ .../src/indicators/mcclellan_oscillator.rs | 204 ++ .../indicators/mcclellan_summation_index.rs | 160 ++ .../src/indicators/mcginley_dynamic.rs | 224 ++ .../indicators/median_absolute_deviation.rs | 204 ++ .../src/indicators/median_channel.rs | 240 ++ .../wickra-core/src/indicators/median_ma.rs | 205 ++ .../src/indicators/median_price.rs | 128 + crates/wickra-core/src/indicators/mfi.rs | 247 ++ .../wickra-core/src/indicators/microprice.rs | 170 ++ .../wickra-core/src/indicators/mid_point.rs | 147 ++ .../wickra-core/src/indicators/mid_price.rs | 165 ++ crates/wickra-core/src/indicators/minus_di.rs | 199 ++ crates/wickra-core/src/indicators/minus_dm.rs | 195 ++ crates/wickra-core/src/indicators/mod.rs | 1711 +++++++++++++ .../src/indicators/modified_ma_stop.rs | 238 ++ crates/wickra-core/src/indicators/mom.rs | 182 ++ .../src/indicators/morning_doji_star.rs | 259 ++ .../src/indicators/morning_evening_star.rs | 218 ++ .../src/indicators/murrey_math_lines.rs | 272 +++ .../wickra-core/src/indicators/naked_poc.rs | 318 +++ crates/wickra-core/src/indicators/natr.rs | 216 ++ .../src/indicators/new_highs_new_lows.rs | 142 ++ .../src/indicators/new_price_lines.rs | 234 ++ crates/wickra-core/src/indicators/nrtr.rs | 259 ++ crates/wickra-core/src/indicators/nvi.rs | 240 ++ .../src/indicators/ob_imbalance_full.rs | 157 ++ .../src/indicators/ob_imbalance_top1.rs | 176 ++ .../src/indicators/ob_imbalance_topn.rs | 186 ++ crates/wickra-core/src/indicators/obv.rs | 190 ++ crates/wickra-core/src/indicators/oi_delta.rs | 142 ++ .../src/indicators/oi_price_divergence.rs | 193 ++ .../src/indicators/oi_to_volume_ratio.rs | 154 ++ .../wickra-core/src/indicators/oi_weighted.rs | 151 ++ .../wickra-core/src/indicators/omega_ratio.rs | 194 ++ crates/wickra-core/src/indicators/on_neck.rs | 189 ++ .../src/indicators/open_interest_momentum.rs | 221 ++ .../src/indicators/opening_marubozu.rs | 178 ++ .../src/indicators/opening_range.rs | 270 +++ .../src/indicators/order_flow_imbalance.rs | 242 ++ .../src/indicators/ou_half_life.rs | 260 ++ .../src/indicators/overnight_gap.rs | 191 ++ .../indicators/overnight_intraday_return.rs | 225 ++ .../wickra-core/src/indicators/pain_index.rs | 171 ++ .../src/indicators/pair_spread_zscore.rs | 299 +++ .../src/indicators/pairwise_beta.rs | 292 +++ .../wickra-core/src/indicators/parkinson.rs | 276 +++ .../src/indicators/pattern_swing.rs | 542 +++++ .../src/indicators/pearson_correlation.rs | 260 ++ .../src/indicators/percent_above_ma.rs | 146 ++ .../wickra-core/src/indicators/percent_b.rs | 203 ++ .../indicators/percentage_trailing_stop.rs | 224 ++ .../src/indicators/perpetual_premium_index.rs | 139 ++ crates/wickra-core/src/indicators/pgo.rs | 219 ++ .../src/indicators/piercing_dark_cloud.rs | 204 ++ crates/wickra-core/src/indicators/pin.rs | 223 ++ .../src/indicators/pivot_reversal.rs | 293 +++ crates/wickra-core/src/indicators/plus_di.rs | 201 ++ crates/wickra-core/src/indicators/plus_dm.rs | 197 ++ crates/wickra-core/src/indicators/pmo.rs | 244 ++ .../src/indicators/point_and_figure_bars.rs | 296 +++ .../polarized_fractal_efficiency.rs | 246 ++ crates/wickra-core/src/indicators/ppo.rs | 233 ++ .../src/indicators/ppo_histogram.rs | 230 ++ .../src/indicators/profile_shape.rs | 285 +++ .../src/indicators/profit_factor.rs | 179 ++ .../src/indicators/projection_bands.rs | 253 ++ .../src/indicators/projection_oscillator.rs | 168 ++ crates/wickra-core/src/indicators/psar.rs | 356 +++ crates/wickra-core/src/indicators/pvi.rs | 228 ++ crates/wickra-core/src/indicators/qqe.rs | 358 +++ crates/wickra-core/src/indicators/qstick.rs | 168 ++ .../src/indicators/quartile_bands.rs | 197 ++ .../src/indicators/quoted_spread.rs | 153 ++ .../wickra-core/src/indicators/r_squared.rs | 219 ++ .../wickra-core/src/indicators/range_bars.rs | 227 ++ .../src/indicators/realized_spread.rs | 204 ++ .../src/indicators/realized_volatility.rs | 240 ++ .../src/indicators/recovery_factor.rs | 212 ++ .../src/indicators/rectangle_range.rs | 155 ++ crates/wickra-core/src/indicators/reflex.rs | 233 ++ .../src/indicators/regime_label.rs | 307 +++ .../src/indicators/relative_strength_ab.rs | 233 ++ .../wickra-core/src/indicators/renko_bars.rs | 267 +++ .../src/indicators/renko_trailing_stop.rs | 236 ++ .../src/indicators/rickshaw_man.rs | 167 ++ .../src/indicators/rising_three_methods.rs | 235 ++ crates/wickra-core/src/indicators/rmi.rs | 276 +++ crates/wickra-core/src/indicators/roc.rs | 183 ++ crates/wickra-core/src/indicators/rocp.rs | 162 ++ crates/wickra-core/src/indicators/rocr.rs | 157 ++ crates/wickra-core/src/indicators/rocr100.rs | 161 ++ .../src/indicators/rogers_satchell.rs | 288 +++ .../src/indicators/roll_measure.rs | 210 ++ .../src/indicators/rolling_correlation.rs | 289 +++ .../src/indicators/rolling_covariance.rs | 257 ++ .../wickra-core/src/indicators/rolling_iqr.rs | 189 ++ .../src/indicators/rolling_min_max_scaler.rs | 261 ++ .../src/indicators/rolling_percentile_rank.rs | 194 ++ .../src/indicators/rolling_quantile.rs | 233 ++ .../src/indicators/roofing_filter.rs | 202 ++ crates/wickra-core/src/indicators/rsi.rs | 531 +++++ crates/wickra-core/src/indicators/rsx.rs | 291 +++ crates/wickra-core/src/indicators/run_bars.rs | 257 ++ crates/wickra-core/src/indicators/rvi.rs | 217 ++ .../src/indicators/rvi_volatility.rs | 338 +++ crates/wickra-core/src/indicators/rwi.rs | 347 +++ .../src/indicators/sample_entropy.rs | 337 +++ crates/wickra-core/src/indicators/sar_ext.rs | 418 ++++ .../src/indicators/seasonal_z_score.rs | 232 ++ .../src/indicators/separating_lines.rs | 218 ++ .../src/indicators/session_high_low.rs | 226 ++ .../src/indicators/session_range.rs | 248 ++ .../src/indicators/session_vwap.rs | 199 ++ .../src/indicators/shannon_entropy.rs | 261 ++ crates/wickra-core/src/indicators/shark.rs | 155 ++ .../src/indicators/sharpe_ratio.rs | 220 ++ .../src/indicators/shooting_star.rs | 159 ++ .../wickra-core/src/indicators/short_line.rs | 233 ++ .../src/indicators/signed_volume.rs | 128 + .../wickra-core/src/indicators/sine_wave.rs | 230 ++ .../src/indicators/sine_weighted_ma.rs | 273 +++ .../src/indicators/single_prints.rs | 253 ++ crates/wickra-core/src/indicators/skewness.rs | 205 ++ crates/wickra-core/src/indicators/sma.rs | 421 ++++ crates/wickra-core/src/indicators/smi.rs | 285 +++ crates/wickra-core/src/indicators/smma.rs | 198 ++ .../src/indicators/smoothed_heikin_ashi.rs | 252 ++ .../src/indicators/sortino_ratio.rs | 197 ++ .../src/indicators/spearman_correlation.rs | 327 +++ .../src/indicators/spinning_top.rs | 221 ++ .../src/indicators/spread_ar1_coefficient.rs | 266 +++ .../src/indicators/spread_bollinger_bands.rs | 281 +++ .../src/indicators/spread_hurst.rs | 286 +++ .../src/indicators/stalled_pattern.rs | 237 ++ .../src/indicators/standard_error.rs | 247 ++ .../src/indicators/standard_error_bands.rs | 281 +++ .../wickra-core/src/indicators/starc_bands.rs | 229 ++ crates/wickra-core/src/indicators/stc.rs | 329 +++ crates/wickra-core/src/indicators/std_dev.rs | 216 ++ .../src/indicators/step_trailing_stop.rs | 228 ++ .../src/indicators/sterling_ratio.rs | 216 ++ .../src/indicators/stick_sandwich.rs | 213 ++ .../wickra-core/src/indicators/stoch_rsi.rs | 242 ++ .../wickra-core/src/indicators/stochastic.rs | 352 +++ .../src/indicators/stochastic_cci.rs | 231 ++ .../src/indicators/super_smoother.rs | 217 ++ .../wickra-core/src/indicators/super_trend.rs | 326 +++ crates/wickra-core/src/indicators/t3.rs | 278 +++ .../wickra-core/src/indicators/tail_ratio.rs | 224 ++ .../src/indicators/taker_buy_sell_ratio.rs | 137 ++ crates/wickra-core/src/indicators/takuri.rs | 167 ++ .../wickra-core/src/indicators/tasuki_gap.rs | 278 +++ .../src/indicators/td_camouflage.rs | 171 ++ crates/wickra-core/src/indicators/td_clop.rs | 174 ++ .../wickra-core/src/indicators/td_clopwin.rs | 177 ++ crates/wickra-core/src/indicators/td_combo.rs | 373 +++ .../src/indicators/td_countdown.rs | 355 +++ .../wickra-core/src/indicators/td_demarker.rs | 246 ++ .../src/indicators/td_differential.rs | 206 ++ crates/wickra-core/src/indicators/td_dwave.rs | 295 +++ crates/wickra-core/src/indicators/td_lines.rs | 340 +++ .../src/indicators/td_moving_average.rs | 229 ++ crates/wickra-core/src/indicators/td_open.rs | 187 ++ .../wickra-core/src/indicators/td_pressure.rs | 240 ++ .../src/indicators/td_propulsion.rs | 167 ++ .../src/indicators/td_range_projection.rs | 169 ++ crates/wickra-core/src/indicators/td_rei.rs | 286 +++ .../src/indicators/td_risk_level.rs | 331 +++ .../src/indicators/td_sequential.rs | 430 ++++ crates/wickra-core/src/indicators/td_setup.rs | 277 +++ crates/wickra-core/src/indicators/td_trap.rs | 186 ++ crates/wickra-core/src/indicators/tema.rs | 132 + .../src/indicators/term_structure_basis.rs | 139 ++ .../src/indicators/three_drives.rs | 155 ++ .../src/indicators/three_inside.rs | 202 ++ .../src/indicators/three_line_break.rs | 241 ++ .../src/indicators/three_line_break_bars.rs | 305 +++ .../src/indicators/three_line_strike.rs | 215 ++ .../src/indicators/three_outside.rs | 201 ++ .../src/indicators/three_soldiers_or_crows.rs | 204 ++ .../src/indicators/three_stars_in_south.rs | 239 ++ .../wickra-core/src/indicators/thrusting.rs | 194 ++ .../wickra-core/src/indicators/tick_bars.rs | 209 ++ .../wickra-core/src/indicators/tick_index.rs | 148 ++ crates/wickra-core/src/indicators/tii.rs | 269 +++ .../src/indicators/time_based_stop.rs | 192 ++ .../indicators/time_of_day_return_profile.rs | 226 ++ .../src/indicators/tower_top_bottom.rs | 239 ++ .../wickra-core/src/indicators/tpo_profile.rs | 314 +++ .../src/indicators/trade_imbalance.rs | 193 ++ .../indicators/trade_sign_autocorrelation.rs | 217 ++ .../src/indicators/trade_volume_index.rs | 235 ++ .../wickra-core/src/indicators/trend_label.rs | 209 ++ .../src/indicators/trend_strength_index.rs | 221 ++ .../wickra-core/src/indicators/trendflex.rs | 234 ++ .../src/indicators/treynor_ratio.rs | 224 ++ crates/wickra-core/src/indicators/triangle.rs | 181 ++ crates/wickra-core/src/indicators/trima.rs | 191 ++ crates/wickra-core/src/indicators/trin.rs | 171 ++ .../src/indicators/triple_top_bottom.rs | 160 ++ crates/wickra-core/src/indicators/tristar.rs | 204 ++ crates/wickra-core/src/indicators/trix.rs | 170 ++ .../wickra-core/src/indicators/true_range.rs | 158 ++ crates/wickra-core/src/indicators/tsf.rs | 172 ++ .../src/indicators/tsf_oscillator.rs | 209 ++ crates/wickra-core/src/indicators/tsi.rs | 238 ++ crates/wickra-core/src/indicators/tsv.rs | 205 ++ .../wickra-core/src/indicators/ttm_squeeze.rs | 302 +++ .../wickra-core/src/indicators/ttm_trend.rs | 167 ++ .../src/indicators/turn_of_month.rs | 275 +++ crates/wickra-core/src/indicators/tweezer.rs | 223 ++ .../src/indicators/twiggs_money_flow.rs | 314 +++ .../wickra-core/src/indicators/two_crows.rs | 186 ++ .../src/indicators/typical_price.rs | 128 + .../wickra-core/src/indicators/ulcer_index.rs | 341 +++ .../src/indicators/ultimate_oscillator.rs | 328 +++ .../src/indicators/unique_three_river.rs | 256 ++ .../src/indicators/universal_oscillator.rs | 254 ++ .../src/indicators/up_down_volume_ratio.rs | 143 ++ .../indicators/upside_gap_three_methods.rs | 210 ++ .../src/indicators/upside_gap_two_crows.rs | 189 ++ .../src/indicators/upside_potential_ratio.rs | 226 ++ .../wickra-core/src/indicators/value_area.rs | 430 ++++ .../src/indicators/value_at_risk.rs | 227 ++ crates/wickra-core/src/indicators/variance.rs | 195 ++ .../src/indicators/variance_ratio.rs | 282 +++ .../indicators/vertical_horizontal_filter.rs | 214 ++ crates/wickra-core/src/indicators/vidya.rs | 193 ++ .../src/indicators/volatility_cone.rs | 381 +++ .../indicators/volatility_of_volatility.rs | 333 +++ .../src/indicators/volatility_ratio.rs | 285 +++ .../wickra-core/src/indicators/volty_stop.rs | 274 +++ .../wickra-core/src/indicators/volume_bars.rs | 217 ++ .../src/indicators/volume_by_time_profile.rs | 198 ++ .../src/indicators/volume_oscillator.rs | 206 ++ .../src/indicators/volume_profile.rs | 333 +++ .../wickra-core/src/indicators/volume_rsi.rs | 265 ++ .../src/indicators/volume_weighted_macd.rs | 270 +++ .../src/indicators/volume_weighted_sr.rs | 281 +++ crates/wickra-core/src/indicators/vortex.rs | 271 +++ crates/wickra-core/src/indicators/vpin.rs | 262 ++ crates/wickra-core/src/indicators/vpt.rs | 203 ++ crates/wickra-core/src/indicators/vwap.rs | 315 +++ .../src/indicators/vwap_stddev_bands.rs | 251 ++ crates/wickra-core/src/indicators/vwma.rs | 254 ++ crates/wickra-core/src/indicators/vzo.rs | 218 ++ crates/wickra-core/src/indicators/wad.rs | 206 ++ crates/wickra-core/src/indicators/wave_pm.rs | 212 ++ .../wickra-core/src/indicators/wave_trend.rs | 334 +++ crates/wickra-core/src/indicators/wedge.rs | 157 ++ .../src/indicators/weighted_close.rs | 127 + .../wickra-core/src/indicators/wick_ratio.rs | 192 ++ .../src/indicators/williams_fractals.rs | 242 ++ .../wickra-core/src/indicators/williams_r.rs | 199 ++ crates/wickra-core/src/indicators/win_rate.rs | 194 ++ crates/wickra-core/src/indicators/wma.rs | 270 +++ .../src/indicators/woodie_pivots.rs | 192 ++ .../wickra-core/src/indicators/yang_zhang.rs | 406 ++++ .../wickra-core/src/indicators/yoyo_exit.rs | 269 +++ crates/wickra-core/src/indicators/z_score.rs | 198 ++ .../src/indicators/zero_lag_macd.rs | 226 ++ crates/wickra-core/src/indicators/zig_zag.rs | 289 +++ crates/wickra-core/src/indicators/zlema.rs | 204 ++ crates/wickra-core/src/lib.rs | 186 ++ crates/wickra-core/src/microstructure.rs | 467 ++++ crates/wickra-core/src/ohlcv.rs | 327 +++ crates/wickra-core/src/traits.rs | 423 ++++ crates/wickra-core/tests/invariants.rs | 1075 +++++++++ crates/wickra-data/Cargo.toml | 69 + crates/wickra-data/src/aggregator.rs | 614 +++++ crates/wickra-data/src/csv.rs | 346 +++ crates/wickra-data/src/error.rs | 44 + crates/wickra-data/src/lib.rs | 25 + crates/wickra-data/src/live.rs | 5 + crates/wickra-data/src/live/binance.rs | 915 +++++++ crates/wickra-data/src/live/binance_rest.rs | 322 +++ crates/wickra-data/src/resample.rs | 224 ++ crates/wickra/Cargo.toml | 42 + crates/wickra/benches/data_layer.rs | 119 + crates/wickra/benches/indicators.rs | 455 ++++ crates/wickra/src/lib.rs | 21 + crates/wickra/tests/example_data.rs | 68 + docs/README.md | 14 + scripts/gen_indicators_index.py | 165 ++ 555 files changed, 131932 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 INDICATORS.md create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/MIT.txt create mode 100644 README.md create mode 100644 crates/wickra-bench/Cargo.toml create mode 100644 crates/wickra-bench/benches/cross_lib.rs create mode 100644 crates/wickra-bench/src/lib.rs create mode 100644 crates/wickra-core/Cargo.toml create mode 100644 crates/wickra-core/src/calendar.rs create mode 100644 crates/wickra-core/src/cross_section.rs create mode 100644 crates/wickra-core/src/derivatives.rs create mode 100644 crates/wickra-core/src/error.rs create mode 100644 crates/wickra-core/src/indicators/abandoned_baby.rs create mode 100644 crates/wickra-core/src/indicators/abcd.rs create mode 100644 crates/wickra-core/src/indicators/absolute_breadth_index.rs create mode 100644 crates/wickra-core/src/indicators/acceleration_bands.rs create mode 100644 crates/wickra-core/src/indicators/accelerator_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/ad_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/ad_volume_line.rs create mode 100644 crates/wickra-core/src/indicators/adaptive_cci.rs create mode 100644 crates/wickra-core/src/indicators/adaptive_cycle.rs create mode 100644 crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs create mode 100644 crates/wickra-core/src/indicators/adaptive_rsi.rs create mode 100644 crates/wickra-core/src/indicators/adl.rs create mode 100644 crates/wickra-core/src/indicators/advance_block.rs create mode 100644 crates/wickra-core/src/indicators/advance_decline.rs create mode 100644 crates/wickra-core/src/indicators/advance_decline_ratio.rs create mode 100644 crates/wickra-core/src/indicators/adx.rs create mode 100644 crates/wickra-core/src/indicators/adxr.rs create mode 100644 crates/wickra-core/src/indicators/alligator.rs create mode 100644 crates/wickra-core/src/indicators/alma.rs create mode 100644 crates/wickra-core/src/indicators/alpha.rs create mode 100644 crates/wickra-core/src/indicators/amihud_illiquidity.rs create mode 100644 crates/wickra-core/src/indicators/anchored_rsi.rs create mode 100644 crates/wickra-core/src/indicators/anchored_vwap.rs create mode 100644 crates/wickra-core/src/indicators/andrews_pitchfork.rs create mode 100644 crates/wickra-core/src/indicators/apo.rs create mode 100644 crates/wickra-core/src/indicators/aroon.rs create mode 100644 crates/wickra-core/src/indicators/aroon_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/atr.rs create mode 100644 crates/wickra-core/src/indicators/atr_bands.rs create mode 100644 crates/wickra-core/src/indicators/atr_ratchet.rs create mode 100644 crates/wickra-core/src/indicators/atr_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/auto_fib.rs create mode 100644 crates/wickra-core/src/indicators/autocorrelation.rs create mode 100644 crates/wickra-core/src/indicators/autocorrelation_periodogram.rs create mode 100644 crates/wickra-core/src/indicators/average_daily_range.rs create mode 100644 crates/wickra-core/src/indicators/average_drawdown.rs create mode 100644 crates/wickra-core/src/indicators/avg_price.rs create mode 100644 crates/wickra-core/src/indicators/awesome_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs create mode 100644 crates/wickra-core/src/indicators/balance_of_power.rs create mode 100644 crates/wickra-core/src/indicators/bandpass_filter.rs create mode 100644 crates/wickra-core/src/indicators/bat.rs create mode 100644 crates/wickra-core/src/indicators/belt_hold.rs create mode 100644 crates/wickra-core/src/indicators/beta.rs create mode 100644 crates/wickra-core/src/indicators/beta_neutral_spread.rs create mode 100644 crates/wickra-core/src/indicators/better_volume.rs create mode 100644 crates/wickra-core/src/indicators/bipower_variation.rs create mode 100644 crates/wickra-core/src/indicators/body_size_pct.rs create mode 100644 crates/wickra-core/src/indicators/bollinger.rs create mode 100644 crates/wickra-core/src/indicators/bollinger_bandwidth.rs create mode 100644 crates/wickra-core/src/indicators/bomar_bands.rs create mode 100644 crates/wickra-core/src/indicators/breadth_thrust.rs create mode 100644 crates/wickra-core/src/indicators/breakaway.rs create mode 100644 crates/wickra-core/src/indicators/bullish_percent_index.rs create mode 100644 crates/wickra-core/src/indicators/burke_ratio.rs create mode 100644 crates/wickra-core/src/indicators/butterfly.rs create mode 100644 crates/wickra-core/src/indicators/calendar_spread.rs create mode 100644 crates/wickra-core/src/indicators/calmar_ratio.rs create mode 100644 crates/wickra-core/src/indicators/camarilla_pivots.rs create mode 100644 crates/wickra-core/src/indicators/candle_volume.rs create mode 100644 crates/wickra-core/src/indicators/cci.rs create mode 100644 crates/wickra-core/src/indicators/center_of_gravity.rs create mode 100644 crates/wickra-core/src/indicators/central_pivot_range.rs create mode 100644 crates/wickra-core/src/indicators/cfo.rs create mode 100644 crates/wickra-core/src/indicators/chaikin_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/chaikin_volatility.rs create mode 100644 crates/wickra-core/src/indicators/chande_kroll_stop.rs create mode 100644 crates/wickra-core/src/indicators/chandelier_exit.rs create mode 100644 crates/wickra-core/src/indicators/choppiness_index.rs create mode 100644 crates/wickra-core/src/indicators/classic_pivots.rs create mode 100644 crates/wickra-core/src/indicators/close_vs_open.rs create mode 100644 crates/wickra-core/src/indicators/closing_marubozu.rs create mode 100644 crates/wickra-core/src/indicators/cmf.rs create mode 100644 crates/wickra-core/src/indicators/cmo.rs create mode 100644 crates/wickra-core/src/indicators/coefficient_of_variation.rs create mode 100644 crates/wickra-core/src/indicators/cointegration.rs create mode 100644 crates/wickra-core/src/indicators/common_sense_ratio.rs create mode 100644 crates/wickra-core/src/indicators/composite_profile.rs create mode 100644 crates/wickra-core/src/indicators/concealing_baby_swallow.rs create mode 100644 crates/wickra-core/src/indicators/conditional_value_at_risk.rs create mode 100644 crates/wickra-core/src/indicators/connors_rsi.rs create mode 100644 crates/wickra-core/src/indicators/coppock.rs create mode 100644 crates/wickra-core/src/indicators/correlation_trend_indicator.rs create mode 100644 crates/wickra-core/src/indicators/counterattack.rs create mode 100644 crates/wickra-core/src/indicators/crab.rs create mode 100644 crates/wickra-core/src/indicators/cumulative_volume_index.rs create mode 100644 crates/wickra-core/src/indicators/cup_and_handle.rs create mode 100644 crates/wickra-core/src/indicators/cvd.rs create mode 100644 crates/wickra-core/src/indicators/cybernetic_cycle.rs create mode 100644 crates/wickra-core/src/indicators/cypher.rs create mode 100644 crates/wickra-core/src/indicators/day_of_week_profile.rs create mode 100644 crates/wickra-core/src/indicators/decycler.rs create mode 100644 crates/wickra-core/src/indicators/decycler_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/dema.rs create mode 100644 crates/wickra-core/src/indicators/demand_index.rs create mode 100644 crates/wickra-core/src/indicators/demark_pivots.rs create mode 100644 crates/wickra-core/src/indicators/depth_slope.rs create mode 100644 crates/wickra-core/src/indicators/derivative_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/detrended_std_dev.rs create mode 100644 crates/wickra-core/src/indicators/disparity_index.rs create mode 100644 crates/wickra-core/src/indicators/distance_ssd.rs create mode 100644 crates/wickra-core/src/indicators/doji.rs create mode 100644 crates/wickra-core/src/indicators/doji_star.rs create mode 100644 crates/wickra-core/src/indicators/dollar_bars.rs create mode 100644 crates/wickra-core/src/indicators/donchian.rs create mode 100644 crates/wickra-core/src/indicators/donchian_stop.rs create mode 100644 crates/wickra-core/src/indicators/double_bollinger.rs create mode 100644 crates/wickra-core/src/indicators/double_top_bottom.rs create mode 100644 crates/wickra-core/src/indicators/downside_gap_three_methods.rs create mode 100644 crates/wickra-core/src/indicators/dpo.rs create mode 100644 crates/wickra-core/src/indicators/dragonfly_doji.rs create mode 100644 crates/wickra-core/src/indicators/drawdown_duration.rs create mode 100644 crates/wickra-core/src/indicators/dumpling_top.rs create mode 100644 crates/wickra-core/src/indicators/dx.rs create mode 100644 crates/wickra-core/src/indicators/dynamic_momentum_index.rs create mode 100644 crates/wickra-core/src/indicators/ease_of_movement.rs create mode 100644 crates/wickra-core/src/indicators/effective_spread.rs create mode 100644 crates/wickra-core/src/indicators/ehlers_stochastic.rs create mode 100644 crates/wickra-core/src/indicators/ehma.rs create mode 100644 crates/wickra-core/src/indicators/elder_impulse.rs create mode 100644 crates/wickra-core/src/indicators/elder_ray.rs create mode 100644 crates/wickra-core/src/indicators/elder_safezone.rs create mode 100644 crates/wickra-core/src/indicators/ema.rs create mode 100644 crates/wickra-core/src/indicators/empirical_mode_decomposition.rs create mode 100644 crates/wickra-core/src/indicators/engulfing.rs create mode 100644 crates/wickra-core/src/indicators/equivolume.rs create mode 100644 crates/wickra-core/src/indicators/estimated_leverage_ratio.rs create mode 100644 crates/wickra-core/src/indicators/even_better_sinewave.rs create mode 100644 crates/wickra-core/src/indicators/evening_doji_star.rs create mode 100644 crates/wickra-core/src/indicators/evwma.rs create mode 100644 crates/wickra-core/src/indicators/ewma_volatility.rs create mode 100644 crates/wickra-core/src/indicators/expectancy.rs create mode 100644 crates/wickra-core/src/indicators/falling_three_methods.rs create mode 100644 crates/wickra-core/src/indicators/fama.rs create mode 100644 crates/wickra-core/src/indicators/fib_arcs.rs create mode 100644 crates/wickra-core/src/indicators/fib_channel.rs create mode 100644 crates/wickra-core/src/indicators/fib_confluence.rs create mode 100644 crates/wickra-core/src/indicators/fib_extension.rs create mode 100644 crates/wickra-core/src/indicators/fib_fan.rs create mode 100644 crates/wickra-core/src/indicators/fib_projection.rs create mode 100644 crates/wickra-core/src/indicators/fib_retracement.rs create mode 100644 crates/wickra-core/src/indicators/fib_time_zones.rs create mode 100644 crates/wickra-core/src/indicators/fibonacci_pivots.rs create mode 100644 crates/wickra-core/src/indicators/fisher_rsi.rs create mode 100644 crates/wickra-core/src/indicators/fisher_transform.rs create mode 100644 crates/wickra-core/src/indicators/flag_pennant.rs create mode 100644 crates/wickra-core/src/indicators/footprint.rs create mode 100644 crates/wickra-core/src/indicators/force_index.rs create mode 100644 crates/wickra-core/src/indicators/fractal_chaos_bands.rs create mode 100644 crates/wickra-core/src/indicators/frama.rs create mode 100644 crates/wickra-core/src/indicators/fry_pan_bottom.rs create mode 100644 crates/wickra-core/src/indicators/funding_basis.rs create mode 100644 crates/wickra-core/src/indicators/funding_implied_apr.rs create mode 100644 crates/wickra-core/src/indicators/funding_rate.rs create mode 100644 crates/wickra-core/src/indicators/funding_rate_mean.rs create mode 100644 crates/wickra-core/src/indicators/funding_rate_zscore.rs create mode 100644 crates/wickra-core/src/indicators/gain_loss_ratio.rs create mode 100644 crates/wickra-core/src/indicators/gain_to_pain_ratio.rs create mode 100644 crates/wickra-core/src/indicators/gap_side_by_side_white.rs create mode 100644 crates/wickra-core/src/indicators/garch11.rs create mode 100644 crates/wickra-core/src/indicators/garman_klass.rs create mode 100644 crates/wickra-core/src/indicators/gartley.rs create mode 100644 crates/wickra-core/src/indicators/gator_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/generalized_dema.rs create mode 100644 crates/wickra-core/src/indicators/geometric_ma.rs create mode 100644 crates/wickra-core/src/indicators/golden_pocket.rs create mode 100644 crates/wickra-core/src/indicators/granger_causality.rs create mode 100644 crates/wickra-core/src/indicators/gravestone_doji.rs create mode 100644 crates/wickra-core/src/indicators/hammer.rs create mode 100644 crates/wickra-core/src/indicators/hanging_man.rs create mode 100644 crates/wickra-core/src/indicators/harami.rs create mode 100644 crates/wickra-core/src/indicators/harami_cross.rs create mode 100644 crates/wickra-core/src/indicators/hasbrouck_information_share.rs create mode 100644 crates/wickra-core/src/indicators/head_and_shoulders.rs create mode 100644 crates/wickra-core/src/indicators/heikin_ashi.rs create mode 100644 crates/wickra-core/src/indicators/heikin_ashi_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/high_low_index.rs create mode 100644 crates/wickra-core/src/indicators/high_low_range.rs create mode 100644 crates/wickra-core/src/indicators/high_low_volume_nodes.rs create mode 100644 crates/wickra-core/src/indicators/high_wave.rs create mode 100644 crates/wickra-core/src/indicators/highpass_filter.rs create mode 100644 crates/wickra-core/src/indicators/hikkake.rs create mode 100644 crates/wickra-core/src/indicators/hikkake_modified.rs create mode 100644 crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs create mode 100644 crates/wickra-core/src/indicators/hilo_activator.rs create mode 100644 crates/wickra-core/src/indicators/historical_volatility.rs create mode 100644 crates/wickra-core/src/indicators/hma.rs create mode 100644 crates/wickra-core/src/indicators/holt_winters.rs create mode 100644 crates/wickra-core/src/indicators/homing_pigeon.rs create mode 100644 crates/wickra-core/src/indicators/ht_dcphase.rs create mode 100644 crates/wickra-core/src/indicators/ht_phasor.rs create mode 100644 crates/wickra-core/src/indicators/ht_trendmode.rs create mode 100644 crates/wickra-core/src/indicators/hurst_channel.rs create mode 100644 crates/wickra-core/src/indicators/hurst_exponent.rs create mode 100644 crates/wickra-core/src/indicators/ichimoku.rs create mode 100644 crates/wickra-core/src/indicators/identical_three_crows.rs create mode 100644 crates/wickra-core/src/indicators/imbalance_bars.rs create mode 100644 crates/wickra-core/src/indicators/in_neck.rs create mode 100644 crates/wickra-core/src/indicators/inertia.rs create mode 100644 crates/wickra-core/src/indicators/information_ratio.rs create mode 100644 crates/wickra-core/src/indicators/initial_balance.rs create mode 100644 crates/wickra-core/src/indicators/instantaneous_trendline.rs create mode 100644 crates/wickra-core/src/indicators/intraday_intensity.rs create mode 100644 crates/wickra-core/src/indicators/intraday_momentum_index.rs create mode 100644 crates/wickra-core/src/indicators/intraday_volatility_profile.rs create mode 100644 crates/wickra-core/src/indicators/inverse_fisher_transform.rs create mode 100644 crates/wickra-core/src/indicators/inverted_hammer.rs create mode 100644 crates/wickra-core/src/indicators/jarque_bera.rs create mode 100644 crates/wickra-core/src/indicators/jma.rs create mode 100644 crates/wickra-core/src/indicators/jump_indicator.rs create mode 100644 crates/wickra-core/src/indicators/k_ratio.rs create mode 100644 crates/wickra-core/src/indicators/kagi_bars.rs create mode 100644 crates/wickra-core/src/indicators/kalman_hedge_ratio.rs create mode 100644 crates/wickra-core/src/indicators/kama.rs create mode 100644 crates/wickra-core/src/indicators/kase_devstop.rs create mode 100644 crates/wickra-core/src/indicators/kase_permission_stochastic.rs create mode 100644 crates/wickra-core/src/indicators/kelly_criterion.rs create mode 100644 crates/wickra-core/src/indicators/keltner.rs create mode 100644 crates/wickra-core/src/indicators/kendall_tau.rs create mode 100644 crates/wickra-core/src/indicators/kicking.rs create mode 100644 crates/wickra-core/src/indicators/kicking_by_length.rs create mode 100644 crates/wickra-core/src/indicators/kst.rs create mode 100644 crates/wickra-core/src/indicators/kurtosis.rs create mode 100644 crates/wickra-core/src/indicators/kvo.rs create mode 100644 crates/wickra-core/src/indicators/kyles_lambda.rs create mode 100644 crates/wickra-core/src/indicators/ladder_bottom.rs create mode 100644 crates/wickra-core/src/indicators/laguerre_rsi.rs create mode 100644 crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs create mode 100644 crates/wickra-core/src/indicators/linreg.rs create mode 100644 crates/wickra-core/src/indicators/linreg_angle.rs create mode 100644 crates/wickra-core/src/indicators/linreg_channel.rs create mode 100644 crates/wickra-core/src/indicators/linreg_intercept.rs create mode 100644 crates/wickra-core/src/indicators/linreg_slope.rs create mode 100644 crates/wickra-core/src/indicators/liquidation_features.rs create mode 100644 crates/wickra-core/src/indicators/log_return.rs create mode 100644 crates/wickra-core/src/indicators/long_legged_doji.rs create mode 100644 crates/wickra-core/src/indicators/long_line.rs create mode 100644 crates/wickra-core/src/indicators/long_short_ratio.rs create mode 100644 crates/wickra-core/src/indicators/m2_measure.rs create mode 100644 crates/wickra-core/src/indicators/ma_envelope.rs create mode 100644 crates/wickra-core/src/indicators/macd.rs create mode 100644 crates/wickra-core/src/indicators/macd_ext.rs create mode 100644 crates/wickra-core/src/indicators/macd_fix.rs create mode 100644 crates/wickra-core/src/indicators/macd_histogram.rs create mode 100644 crates/wickra-core/src/indicators/mama.rs create mode 100644 crates/wickra-core/src/indicators/market_facilitation_index.rs create mode 100644 crates/wickra-core/src/indicators/martin_ratio.rs create mode 100644 crates/wickra-core/src/indicators/marubozu.rs create mode 100644 crates/wickra-core/src/indicators/mass_index.rs create mode 100644 crates/wickra-core/src/indicators/mat_hold.rs create mode 100644 crates/wickra-core/src/indicators/matching_low.rs create mode 100644 crates/wickra-core/src/indicators/max_drawdown.rs create mode 100644 crates/wickra-core/src/indicators/mcclellan_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/mcclellan_summation_index.rs create mode 100644 crates/wickra-core/src/indicators/mcginley_dynamic.rs create mode 100644 crates/wickra-core/src/indicators/median_absolute_deviation.rs create mode 100644 crates/wickra-core/src/indicators/median_channel.rs create mode 100644 crates/wickra-core/src/indicators/median_ma.rs create mode 100644 crates/wickra-core/src/indicators/median_price.rs create mode 100644 crates/wickra-core/src/indicators/mfi.rs create mode 100644 crates/wickra-core/src/indicators/microprice.rs create mode 100644 crates/wickra-core/src/indicators/mid_point.rs create mode 100644 crates/wickra-core/src/indicators/mid_price.rs create mode 100644 crates/wickra-core/src/indicators/minus_di.rs create mode 100644 crates/wickra-core/src/indicators/minus_dm.rs create mode 100644 crates/wickra-core/src/indicators/mod.rs create mode 100644 crates/wickra-core/src/indicators/modified_ma_stop.rs create mode 100644 crates/wickra-core/src/indicators/mom.rs create mode 100644 crates/wickra-core/src/indicators/morning_doji_star.rs create mode 100644 crates/wickra-core/src/indicators/morning_evening_star.rs create mode 100644 crates/wickra-core/src/indicators/murrey_math_lines.rs create mode 100644 crates/wickra-core/src/indicators/naked_poc.rs create mode 100644 crates/wickra-core/src/indicators/natr.rs create mode 100644 crates/wickra-core/src/indicators/new_highs_new_lows.rs create mode 100644 crates/wickra-core/src/indicators/new_price_lines.rs create mode 100644 crates/wickra-core/src/indicators/nrtr.rs create mode 100644 crates/wickra-core/src/indicators/nvi.rs create mode 100644 crates/wickra-core/src/indicators/ob_imbalance_full.rs create mode 100644 crates/wickra-core/src/indicators/ob_imbalance_top1.rs create mode 100644 crates/wickra-core/src/indicators/ob_imbalance_topn.rs create mode 100644 crates/wickra-core/src/indicators/obv.rs create mode 100644 crates/wickra-core/src/indicators/oi_delta.rs create mode 100644 crates/wickra-core/src/indicators/oi_price_divergence.rs create mode 100644 crates/wickra-core/src/indicators/oi_to_volume_ratio.rs create mode 100644 crates/wickra-core/src/indicators/oi_weighted.rs create mode 100644 crates/wickra-core/src/indicators/omega_ratio.rs create mode 100644 crates/wickra-core/src/indicators/on_neck.rs create mode 100644 crates/wickra-core/src/indicators/open_interest_momentum.rs create mode 100644 crates/wickra-core/src/indicators/opening_marubozu.rs create mode 100644 crates/wickra-core/src/indicators/opening_range.rs create mode 100644 crates/wickra-core/src/indicators/order_flow_imbalance.rs create mode 100644 crates/wickra-core/src/indicators/ou_half_life.rs create mode 100644 crates/wickra-core/src/indicators/overnight_gap.rs create mode 100644 crates/wickra-core/src/indicators/overnight_intraday_return.rs create mode 100644 crates/wickra-core/src/indicators/pain_index.rs create mode 100644 crates/wickra-core/src/indicators/pair_spread_zscore.rs create mode 100644 crates/wickra-core/src/indicators/pairwise_beta.rs create mode 100644 crates/wickra-core/src/indicators/parkinson.rs create mode 100644 crates/wickra-core/src/indicators/pattern_swing.rs create mode 100644 crates/wickra-core/src/indicators/pearson_correlation.rs create mode 100644 crates/wickra-core/src/indicators/percent_above_ma.rs create mode 100644 crates/wickra-core/src/indicators/percent_b.rs create mode 100644 crates/wickra-core/src/indicators/percentage_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/perpetual_premium_index.rs create mode 100644 crates/wickra-core/src/indicators/pgo.rs create mode 100644 crates/wickra-core/src/indicators/piercing_dark_cloud.rs create mode 100644 crates/wickra-core/src/indicators/pin.rs create mode 100644 crates/wickra-core/src/indicators/pivot_reversal.rs create mode 100644 crates/wickra-core/src/indicators/plus_di.rs create mode 100644 crates/wickra-core/src/indicators/plus_dm.rs create mode 100644 crates/wickra-core/src/indicators/pmo.rs create mode 100644 crates/wickra-core/src/indicators/point_and_figure_bars.rs create mode 100644 crates/wickra-core/src/indicators/polarized_fractal_efficiency.rs create mode 100644 crates/wickra-core/src/indicators/ppo.rs create mode 100644 crates/wickra-core/src/indicators/ppo_histogram.rs create mode 100644 crates/wickra-core/src/indicators/profile_shape.rs create mode 100644 crates/wickra-core/src/indicators/profit_factor.rs create mode 100644 crates/wickra-core/src/indicators/projection_bands.rs create mode 100644 crates/wickra-core/src/indicators/projection_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/psar.rs create mode 100644 crates/wickra-core/src/indicators/pvi.rs create mode 100644 crates/wickra-core/src/indicators/qqe.rs create mode 100644 crates/wickra-core/src/indicators/qstick.rs create mode 100644 crates/wickra-core/src/indicators/quartile_bands.rs create mode 100644 crates/wickra-core/src/indicators/quoted_spread.rs create mode 100644 crates/wickra-core/src/indicators/r_squared.rs create mode 100644 crates/wickra-core/src/indicators/range_bars.rs create mode 100644 crates/wickra-core/src/indicators/realized_spread.rs create mode 100644 crates/wickra-core/src/indicators/realized_volatility.rs create mode 100644 crates/wickra-core/src/indicators/recovery_factor.rs create mode 100644 crates/wickra-core/src/indicators/rectangle_range.rs create mode 100644 crates/wickra-core/src/indicators/reflex.rs create mode 100644 crates/wickra-core/src/indicators/regime_label.rs create mode 100644 crates/wickra-core/src/indicators/relative_strength_ab.rs create mode 100644 crates/wickra-core/src/indicators/renko_bars.rs create mode 100644 crates/wickra-core/src/indicators/renko_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/rickshaw_man.rs create mode 100644 crates/wickra-core/src/indicators/rising_three_methods.rs create mode 100644 crates/wickra-core/src/indicators/rmi.rs create mode 100644 crates/wickra-core/src/indicators/roc.rs create mode 100644 crates/wickra-core/src/indicators/rocp.rs create mode 100644 crates/wickra-core/src/indicators/rocr.rs create mode 100644 crates/wickra-core/src/indicators/rocr100.rs create mode 100644 crates/wickra-core/src/indicators/rogers_satchell.rs create mode 100644 crates/wickra-core/src/indicators/roll_measure.rs create mode 100644 crates/wickra-core/src/indicators/rolling_correlation.rs create mode 100644 crates/wickra-core/src/indicators/rolling_covariance.rs create mode 100644 crates/wickra-core/src/indicators/rolling_iqr.rs create mode 100644 crates/wickra-core/src/indicators/rolling_min_max_scaler.rs create mode 100644 crates/wickra-core/src/indicators/rolling_percentile_rank.rs create mode 100644 crates/wickra-core/src/indicators/rolling_quantile.rs create mode 100644 crates/wickra-core/src/indicators/roofing_filter.rs create mode 100644 crates/wickra-core/src/indicators/rsi.rs create mode 100644 crates/wickra-core/src/indicators/rsx.rs create mode 100644 crates/wickra-core/src/indicators/run_bars.rs create mode 100644 crates/wickra-core/src/indicators/rvi.rs create mode 100644 crates/wickra-core/src/indicators/rvi_volatility.rs create mode 100644 crates/wickra-core/src/indicators/rwi.rs create mode 100644 crates/wickra-core/src/indicators/sample_entropy.rs create mode 100644 crates/wickra-core/src/indicators/sar_ext.rs create mode 100644 crates/wickra-core/src/indicators/seasonal_z_score.rs create mode 100644 crates/wickra-core/src/indicators/separating_lines.rs create mode 100644 crates/wickra-core/src/indicators/session_high_low.rs create mode 100644 crates/wickra-core/src/indicators/session_range.rs create mode 100644 crates/wickra-core/src/indicators/session_vwap.rs create mode 100644 crates/wickra-core/src/indicators/shannon_entropy.rs create mode 100644 crates/wickra-core/src/indicators/shark.rs create mode 100644 crates/wickra-core/src/indicators/sharpe_ratio.rs create mode 100644 crates/wickra-core/src/indicators/shooting_star.rs create mode 100644 crates/wickra-core/src/indicators/short_line.rs create mode 100644 crates/wickra-core/src/indicators/signed_volume.rs create mode 100644 crates/wickra-core/src/indicators/sine_wave.rs create mode 100644 crates/wickra-core/src/indicators/sine_weighted_ma.rs create mode 100644 crates/wickra-core/src/indicators/single_prints.rs create mode 100644 crates/wickra-core/src/indicators/skewness.rs create mode 100644 crates/wickra-core/src/indicators/sma.rs create mode 100644 crates/wickra-core/src/indicators/smi.rs create mode 100644 crates/wickra-core/src/indicators/smma.rs create mode 100644 crates/wickra-core/src/indicators/smoothed_heikin_ashi.rs create mode 100644 crates/wickra-core/src/indicators/sortino_ratio.rs create mode 100644 crates/wickra-core/src/indicators/spearman_correlation.rs create mode 100644 crates/wickra-core/src/indicators/spinning_top.rs create mode 100644 crates/wickra-core/src/indicators/spread_ar1_coefficient.rs create mode 100644 crates/wickra-core/src/indicators/spread_bollinger_bands.rs create mode 100644 crates/wickra-core/src/indicators/spread_hurst.rs create mode 100644 crates/wickra-core/src/indicators/stalled_pattern.rs create mode 100644 crates/wickra-core/src/indicators/standard_error.rs create mode 100644 crates/wickra-core/src/indicators/standard_error_bands.rs create mode 100644 crates/wickra-core/src/indicators/starc_bands.rs create mode 100644 crates/wickra-core/src/indicators/stc.rs create mode 100644 crates/wickra-core/src/indicators/std_dev.rs create mode 100644 crates/wickra-core/src/indicators/step_trailing_stop.rs create mode 100644 crates/wickra-core/src/indicators/sterling_ratio.rs create mode 100644 crates/wickra-core/src/indicators/stick_sandwich.rs create mode 100644 crates/wickra-core/src/indicators/stoch_rsi.rs create mode 100644 crates/wickra-core/src/indicators/stochastic.rs create mode 100644 crates/wickra-core/src/indicators/stochastic_cci.rs create mode 100644 crates/wickra-core/src/indicators/super_smoother.rs create mode 100644 crates/wickra-core/src/indicators/super_trend.rs create mode 100644 crates/wickra-core/src/indicators/t3.rs create mode 100644 crates/wickra-core/src/indicators/tail_ratio.rs create mode 100644 crates/wickra-core/src/indicators/taker_buy_sell_ratio.rs create mode 100644 crates/wickra-core/src/indicators/takuri.rs create mode 100644 crates/wickra-core/src/indicators/tasuki_gap.rs create mode 100644 crates/wickra-core/src/indicators/td_camouflage.rs create mode 100644 crates/wickra-core/src/indicators/td_clop.rs create mode 100644 crates/wickra-core/src/indicators/td_clopwin.rs create mode 100644 crates/wickra-core/src/indicators/td_combo.rs create mode 100644 crates/wickra-core/src/indicators/td_countdown.rs create mode 100644 crates/wickra-core/src/indicators/td_demarker.rs create mode 100644 crates/wickra-core/src/indicators/td_differential.rs create mode 100644 crates/wickra-core/src/indicators/td_dwave.rs create mode 100644 crates/wickra-core/src/indicators/td_lines.rs create mode 100644 crates/wickra-core/src/indicators/td_moving_average.rs create mode 100644 crates/wickra-core/src/indicators/td_open.rs create mode 100644 crates/wickra-core/src/indicators/td_pressure.rs create mode 100644 crates/wickra-core/src/indicators/td_propulsion.rs create mode 100644 crates/wickra-core/src/indicators/td_range_projection.rs create mode 100644 crates/wickra-core/src/indicators/td_rei.rs create mode 100644 crates/wickra-core/src/indicators/td_risk_level.rs create mode 100644 crates/wickra-core/src/indicators/td_sequential.rs create mode 100644 crates/wickra-core/src/indicators/td_setup.rs create mode 100644 crates/wickra-core/src/indicators/td_trap.rs create mode 100644 crates/wickra-core/src/indicators/tema.rs create mode 100644 crates/wickra-core/src/indicators/term_structure_basis.rs create mode 100644 crates/wickra-core/src/indicators/three_drives.rs create mode 100644 crates/wickra-core/src/indicators/three_inside.rs create mode 100644 crates/wickra-core/src/indicators/three_line_break.rs create mode 100644 crates/wickra-core/src/indicators/three_line_break_bars.rs create mode 100644 crates/wickra-core/src/indicators/three_line_strike.rs create mode 100644 crates/wickra-core/src/indicators/three_outside.rs create mode 100644 crates/wickra-core/src/indicators/three_soldiers_or_crows.rs create mode 100644 crates/wickra-core/src/indicators/three_stars_in_south.rs create mode 100644 crates/wickra-core/src/indicators/thrusting.rs create mode 100644 crates/wickra-core/src/indicators/tick_bars.rs create mode 100644 crates/wickra-core/src/indicators/tick_index.rs create mode 100644 crates/wickra-core/src/indicators/tii.rs create mode 100644 crates/wickra-core/src/indicators/time_based_stop.rs create mode 100644 crates/wickra-core/src/indicators/time_of_day_return_profile.rs create mode 100644 crates/wickra-core/src/indicators/tower_top_bottom.rs create mode 100644 crates/wickra-core/src/indicators/tpo_profile.rs create mode 100644 crates/wickra-core/src/indicators/trade_imbalance.rs create mode 100644 crates/wickra-core/src/indicators/trade_sign_autocorrelation.rs create mode 100644 crates/wickra-core/src/indicators/trade_volume_index.rs create mode 100644 crates/wickra-core/src/indicators/trend_label.rs create mode 100644 crates/wickra-core/src/indicators/trend_strength_index.rs create mode 100644 crates/wickra-core/src/indicators/trendflex.rs create mode 100644 crates/wickra-core/src/indicators/treynor_ratio.rs create mode 100644 crates/wickra-core/src/indicators/triangle.rs create mode 100644 crates/wickra-core/src/indicators/trima.rs create mode 100644 crates/wickra-core/src/indicators/trin.rs create mode 100644 crates/wickra-core/src/indicators/triple_top_bottom.rs create mode 100644 crates/wickra-core/src/indicators/tristar.rs create mode 100644 crates/wickra-core/src/indicators/trix.rs create mode 100644 crates/wickra-core/src/indicators/true_range.rs create mode 100644 crates/wickra-core/src/indicators/tsf.rs create mode 100644 crates/wickra-core/src/indicators/tsf_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/tsi.rs create mode 100644 crates/wickra-core/src/indicators/tsv.rs create mode 100644 crates/wickra-core/src/indicators/ttm_squeeze.rs create mode 100644 crates/wickra-core/src/indicators/ttm_trend.rs create mode 100644 crates/wickra-core/src/indicators/turn_of_month.rs create mode 100644 crates/wickra-core/src/indicators/tweezer.rs create mode 100644 crates/wickra-core/src/indicators/twiggs_money_flow.rs create mode 100644 crates/wickra-core/src/indicators/two_crows.rs create mode 100644 crates/wickra-core/src/indicators/typical_price.rs create mode 100644 crates/wickra-core/src/indicators/ulcer_index.rs create mode 100644 crates/wickra-core/src/indicators/ultimate_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/unique_three_river.rs create mode 100644 crates/wickra-core/src/indicators/universal_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/up_down_volume_ratio.rs create mode 100644 crates/wickra-core/src/indicators/upside_gap_three_methods.rs create mode 100644 crates/wickra-core/src/indicators/upside_gap_two_crows.rs create mode 100644 crates/wickra-core/src/indicators/upside_potential_ratio.rs create mode 100644 crates/wickra-core/src/indicators/value_area.rs create mode 100644 crates/wickra-core/src/indicators/value_at_risk.rs create mode 100644 crates/wickra-core/src/indicators/variance.rs create mode 100644 crates/wickra-core/src/indicators/variance_ratio.rs create mode 100644 crates/wickra-core/src/indicators/vertical_horizontal_filter.rs create mode 100644 crates/wickra-core/src/indicators/vidya.rs create mode 100644 crates/wickra-core/src/indicators/volatility_cone.rs create mode 100644 crates/wickra-core/src/indicators/volatility_of_volatility.rs create mode 100644 crates/wickra-core/src/indicators/volatility_ratio.rs create mode 100644 crates/wickra-core/src/indicators/volty_stop.rs create mode 100644 crates/wickra-core/src/indicators/volume_bars.rs create mode 100644 crates/wickra-core/src/indicators/volume_by_time_profile.rs create mode 100644 crates/wickra-core/src/indicators/volume_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/volume_profile.rs create mode 100644 crates/wickra-core/src/indicators/volume_rsi.rs create mode 100644 crates/wickra-core/src/indicators/volume_weighted_macd.rs create mode 100644 crates/wickra-core/src/indicators/volume_weighted_sr.rs create mode 100644 crates/wickra-core/src/indicators/vortex.rs create mode 100644 crates/wickra-core/src/indicators/vpin.rs create mode 100644 crates/wickra-core/src/indicators/vpt.rs create mode 100644 crates/wickra-core/src/indicators/vwap.rs create mode 100644 crates/wickra-core/src/indicators/vwap_stddev_bands.rs create mode 100644 crates/wickra-core/src/indicators/vwma.rs create mode 100644 crates/wickra-core/src/indicators/vzo.rs create mode 100644 crates/wickra-core/src/indicators/wad.rs create mode 100644 crates/wickra-core/src/indicators/wave_pm.rs create mode 100644 crates/wickra-core/src/indicators/wave_trend.rs create mode 100644 crates/wickra-core/src/indicators/wedge.rs create mode 100644 crates/wickra-core/src/indicators/weighted_close.rs create mode 100644 crates/wickra-core/src/indicators/wick_ratio.rs create mode 100644 crates/wickra-core/src/indicators/williams_fractals.rs create mode 100644 crates/wickra-core/src/indicators/williams_r.rs create mode 100644 crates/wickra-core/src/indicators/win_rate.rs create mode 100644 crates/wickra-core/src/indicators/wma.rs create mode 100644 crates/wickra-core/src/indicators/woodie_pivots.rs create mode 100644 crates/wickra-core/src/indicators/yang_zhang.rs create mode 100644 crates/wickra-core/src/indicators/yoyo_exit.rs create mode 100644 crates/wickra-core/src/indicators/z_score.rs create mode 100644 crates/wickra-core/src/indicators/zero_lag_macd.rs create mode 100644 crates/wickra-core/src/indicators/zig_zag.rs create mode 100644 crates/wickra-core/src/indicators/zlema.rs create mode 100644 crates/wickra-core/src/lib.rs create mode 100644 crates/wickra-core/src/microstructure.rs create mode 100644 crates/wickra-core/src/ohlcv.rs create mode 100644 crates/wickra-core/src/traits.rs create mode 100644 crates/wickra-core/tests/invariants.rs create mode 100644 crates/wickra-data/Cargo.toml create mode 100644 crates/wickra-data/src/aggregator.rs create mode 100644 crates/wickra-data/src/csv.rs create mode 100644 crates/wickra-data/src/error.rs create mode 100644 crates/wickra-data/src/lib.rs create mode 100644 crates/wickra-data/src/live.rs create mode 100644 crates/wickra-data/src/live/binance.rs create mode 100644 crates/wickra-data/src/live/binance_rest.rs create mode 100644 crates/wickra-data/src/resample.rs create mode 100644 crates/wickra/Cargo.toml create mode 100644 crates/wickra/benches/data_layer.rs create mode 100644 crates/wickra/benches/indicators.rs create mode 100644 crates/wickra/src/lib.rs create mode 100644 crates/wickra/tests/example_data.rs create mode 100644 docs/README.md create mode 100644 scripts/gen_indicators_index.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2701ced --- /dev/null +++ b/.editorconfig @@ -0,0 +1,34 @@ +# EditorConfig: https://editorconfig.org +# Keeps indentation and line-endings consistent across IDEs. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +# Rust + Python + most config files use 4-space indents. +[*.{rs,py,toml}] +indent_size = 4 + +# JS / TS / JSON / YAML / Markdown use 2-space indents per ecosystem conventions. +[*.{js,ts,jsx,tsx,json,yml,yaml,md}] +indent_size = 2 + +# Markdown allows trailing whitespace as a hard line break — keep it intact. +[*.md] +trim_trailing_whitespace = false + +# Makefiles must use tabs. +[Makefile] +indent_style = tab + +# Generated files are not authored by humans; leave them alone. +[bindings/node/index.{js,d.ts}] +indent_style = unset +indent_size = unset +trim_trailing_whitespace = unset +insert_final_newline = unset diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..404a031 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,41 @@ +# Shell scripts must keep LF line endings so they run on Linux/macOS CI and +# local shells regardless of the committer's platform autocrlf setting. +*.sh text eol=lf + +# The cbindgen-generated C header is committed; pin it to LF so its CI drift +# check (regenerate + `git diff`) never trips on a CRLF normalization. +bindings/c/include/wickra.h text eol=lf + +# C# sources (including the generated binding) are pinned to LF so the committed +# files stay stable regardless of the committer's autocrlf setting. +*.cs text eol=lf + +# Go sources (including the generated binding) are pinned to LF so gofmt's CI +# check never trips on a CRLF checkout on Windows. The vendored C ABI header is +# a committed copy of bindings/c/include/wickra.h (the parent dir is outside the +# Go module), pinned to LF so the drift check never trips on CRLF. +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf +bindings/go/include/wickra.h text eol=lf + +# Java sources (including the generated binding) are pinned to LF so the +# committed files stay stable regardless of the committer's autocrlf setting. +*.java text eol=lf + +# R binding: `R CMD check` requires LF in sources, Makevars and shell scripts. +*.R text eol=lf +*.Rd text eol=lf +bindings/r/configure text eol=lf +bindings/r/configure.win text eol=lf +bindings/r/src/Makevars.in text eol=lf +bindings/r/src/Makevars.win text eol=lf +bindings/r/src/wickra.c text eol=lf + +# Golden fixtures are replayed byte-for-byte by every binding's parity test. Pin +# them to LF so a Windows `core.autocrlf=true` checkout doesn't rewrite them as +# CRLF — which silently broke the Node reader (`Number('inf\r')` is NaN, and a +# blank "no-bar" row gained a stray `\r`), failing only on Windows runners. The +# tolerant readers (Python `splitlines`/`float`) hid the same hazard. +testdata/golden/** text eol=lf + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caa57e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Rust +/target/ +**/target/ +Cargo.lock.bak +*.pdb + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.pyd +*.egg-info/ +.pytest_cache/ +.venv/ +venv/ +.python-version +dist/ +build/ +wheels/ +*.whl + +# Editor / OS +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db + +# Maturin +target/wheels/ + +# Benchmarks +criterion/ + +# Coverage +*.profraw +lcov.info +tarpaulin-report.html + +# Local config that should not be committed +*.local.toml + +# Node binding artifacts +**/node_modules/ +bindings/node/*.node +bindings/node/npm-debug.log* +# index.js + index.d.ts are generated by `napi build` but committed (a matched +# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires +# regenerating both when a binding's public API changes. +# package-lock.json is committed for the tracked Node packages — bindings/node/ +# and examples/node/ — so contributors get reproducible npm installs. There is +# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local. +# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown. + +# WASM build output +bindings/wasm/pkg/ +bindings/wasm/pkg-node/ + +# Python venv +.venv/ +venv/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..044b51b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2124 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kand" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1f41590bd014ef6c3dd815b45f07deb4c3198e355a4319bb7521b6a3a6aeb5" +dependencies = [ + "num_enum", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "napi" +version = "3.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83" +dependencies = [ + "bitflags", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "3.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +dependencies = [ + "libloading", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.5.4+3.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pyo3" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ta" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "native-tls", + "once_cell", + "url", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wickra" +version = "0.9.9" +dependencies = [ + "approx", + "criterion", + "proptest", + "wickra-core", + "wickra-data", +] + +[[package]] +name = "wickra-bench" +version = "0.9.9" +dependencies = [ + "criterion", + "kand", + "ta", + "wickra", + "wickra-data", + "yata", +] + +[[package]] +name = "wickra-c" +version = "0.9.9" +dependencies = [ + "tokio", + "wickra-core", + "wickra-data", +] + +[[package]] +name = "wickra-core" +version = "0.9.9" +dependencies = [ + "approx", + "proptest", + "rayon", + "thiserror", +] + +[[package]] +name = "wickra-data" +version = "0.9.9" +dependencies = [ + "approx", + "csv", + "futures-util", + "native-tls", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-tungstenite", + "ureq", + "url", + "wickra-core", +] + +[[package]] +name = "wickra-examples" +version = "0.9.9" +dependencies = [ + "serde_json", + "tokio", + "wickra", + "wickra-data", +] + +[[package]] +name = "wickra-node" +version = "0.9.9" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "tokio", + "wickra-core", + "wickra-data", +] + +[[package]] +name = "wickra-python" +version = "0.9.9" +dependencies = [ + "bytemuck", + "pyo3", + "tokio", + "wickra-core", + "wickra-data", +] + +[[package]] +name = "wickra-wasm" +version = "0.9.9" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", + "wasm-bindgen-test", + "wickra-core", + "wickra-data", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yata" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4ef8ddfa3ccd93454262c0e60a43a2bbf403d404174e1815f7581d5028229f" +dependencies = [ + "serde", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1557ba8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,79 @@ +[workspace] +resolver = "2" +members = [ + "crates/wickra-core", + "crates/wickra", + "crates/wickra-data", + "bindings/python", + "bindings/wasm", + "bindings/node", + "bindings/c", + "examples/rust", + "crates/wickra-bench", +] +exclude = ["fuzz"] + +[workspace.package] +version = "0.9.9" +authors = ["kingchenc "] +edition = "2021" +rust-version = "1.86" +license = "MIT OR Apache-2.0" +repository = "https://github.com/wickra-lib/wickra" +homepage = "https://github.com/wickra-lib/wickra" +readme = "README.md" +keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"] +categories = ["finance", "mathematics", "science"] + +[workspace.dependencies] +wickra-core = { path = "crates/wickra-core", version = "0.9.9" } +wickra-data = { path = "crates/wickra-data", version = "0.9.9" } + +thiserror = "2" +rayon = "1.10" + +# Testing +proptest = "1.5" +approx = "0.5" +criterion = { version = "0.8", features = ["html_reports"] } + +# Python binding +pyo3 = { version = "0.29", features = ["extension-module", "abi3-py39"] } +numpy = "0.29" + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +unreachable_pub = "warn" +unused_must_use = "deny" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +# Pedantic exceptions for an indicator library that uses floats and small functions everywhere. +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +cast_precision_loss = "allow" +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +similar_names = "allow" +float_cmp = "allow" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +opt-level = 3 +lto = "fat" +codegen-units = 1 +debug = false + +[profile.dev] +opt-level = 0 +debug = true diff --git a/INDICATORS.md b/INDICATORS.md new file mode 100644 index 0000000..40903c9 --- /dev/null +++ b/INDICATORS.md @@ -0,0 +1,619 @@ +# Wickra Indicators + +Compact index of all **514** indicators in `crates/wickra-core/src/indicators/`, grouped by `FAMILIES` in `crates/wickra-core/src/indicators/mod.rs`. Each row links to the full implementation file. + +> AI agents: skim family headers, then `Read` the linked `.rs` file for the canonical formula. For QuantDinger, fetch the live contract with `quantdinger_get_indicator_authoring_contract` before writing code. + +## Families + +- [Moving Averages](#moving-averages) (26) +- [Momentum Oscillators](#momentum-oscillators) (34) +- [Trend & Directional](#trend-directional) (28) +- [Price Oscillators](#price-oscillators) (14) +- [Volatility & Bands](#volatility-bands) (25) +- [Bands & Channels](#bands-channels) (16) +- [Trailing Stops](#trailing-stops) (19) +- [Volume](#volume) (26) +- [Price Statistics](#price-statistics) (55) +- [Ehlers / Cycle (DSP)](#ehlers-cycle-dsp) (29) +- [Pivots & S/R](#pivots-s-r) (12) +- [DeMark](#demark) (19) +- [Ichimoku & Charts](#ichimoku-charts) (7) +- [Candlestick Patterns](#candlestick-patterns) (66) +- [Microstructure](#microstructure) (20) +- [Derivatives](#derivatives) (17) +- [Market Profile](#market-profile) (10) +- [Risk / Performance](#risk-performance) (28) +- [Alt-Chart Bars](#alt-chart-bars) (10) +- [Market Breadth](#market-breadth) (15) +- [Seasonality & Session](#seasonality-session) (12) +- [Chart Patterns](#chart-patterns) (8) +- [Harmonic Patterns](#harmonic-patterns) (8) +- [Fibonacci](#fibonacci) (10) + +## Moving Averages (26) + +- `AdaptiveLaguerreFilter` — Ehlers' Adaptive Laguerre Filter · [`adaptive_laguerre_filter.rs`](crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs) +- `Alligator` — Bill Williams' Alligator indicator · [`alligator.rs`](crates/wickra-core/src/indicators/alligator.rs) +- `Alma` — Arnaud Legoux Moving Average (ALMA) · [`alma.rs`](crates/wickra-core/src/indicators/alma.rs) +- `Dema` — Double Exponential Moving Average (DEMA) · [`dema.rs`](crates/wickra-core/src/indicators/dema.rs) +- `Ehma` — Exponential Hull Moving Average (EHMA) · [`ehma.rs`](crates/wickra-core/src/indicators/ehma.rs) +- `Ema` — Exponential Moving Average · [`ema.rs`](crates/wickra-core/src/indicators/ema.rs) +- `Evwma` — Elastic Volume-Weighted Moving Average (EVWMA) · [`evwma.rs`](crates/wickra-core/src/indicators/evwma.rs) +- `Frama` — Fractal Adaptive Moving Average (FRAMA) · [`frama.rs`](crates/wickra-core/src/indicators/frama.rs) +- `GeneralizedDema` — Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA · [`generalized_dema.rs`](crates/wickra-core/src/indicators/generalized_dema.rs) +- `GeometricMa` — Geometric Moving Average (GMA) · [`geometric_ma.rs`](crates/wickra-core/src/indicators/geometric_ma.rs) +- `Hma` — Hull Moving Average (HMA) · [`hma.rs`](crates/wickra-core/src/indicators/hma.rs) +- `HoltWinters` — Holt's linear (double exponential) smoothing · [`holt_winters.rs`](crates/wickra-core/src/indicators/holt_winters.rs) +- `Jma` — Jurik Moving Average (JMA) · [`jma.rs`](crates/wickra-core/src/indicators/jma.rs) +- `Kama` — Kaufman's Adaptive Moving Average (KAMA) · [`kama.rs`](crates/wickra-core/src/indicators/kama.rs) +- `McGinleyDynamic` — `McGinley` Dynamic — self-adjusting moving average · [`mcginley_dynamic.rs`](crates/wickra-core/src/indicators/mcginley_dynamic.rs) +- `MedianMa` — Median Moving Average · [`median_ma.rs`](crates/wickra-core/src/indicators/median_ma.rs) +- `SineWeightedMa` — Sine-Weighted Moving Average (SWMA) · [`sine_weighted_ma.rs`](crates/wickra-core/src/indicators/sine_weighted_ma.rs) +- `Sma` — Simple Moving Average · [`sma.rs`](crates/wickra-core/src/indicators/sma.rs) +- `Smma` — Smoothed Moving Average (Wilder's RMA) · [`smma.rs`](crates/wickra-core/src/indicators/smma.rs) +- `T3` — Tillson T3 Moving Average · [`t3.rs`](crates/wickra-core/src/indicators/t3.rs) +- `Tema` — Triple Exponential Moving Average (TEMA) · [`tema.rs`](crates/wickra-core/src/indicators/tema.rs) +- `Trima` — Triangular Moving Average · [`trima.rs`](crates/wickra-core/src/indicators/trima.rs) +- `Vidya` — Variable Index Dynamic Average (VIDYA) · [`vidya.rs`](crates/wickra-core/src/indicators/vidya.rs) +- `Vwma` — Volume-Weighted Moving Average · [`vwma.rs`](crates/wickra-core/src/indicators/vwma.rs) +- `Wma` — Weighted Moving Average (linear weights) · [`wma.rs`](crates/wickra-core/src/indicators/wma.rs) +- `Zlema` — Zero-Lag Exponential Moving Average · [`zlema.rs`](crates/wickra-core/src/indicators/zlema.rs) + +## Momentum Oscillators (34) + +- `AnchoredRsi` — Anchored Relative Strength Index · [`anchored_rsi.rs`](crates/wickra-core/src/indicators/anchored_rsi.rs) +- `AwesomeOscillator` — Awesome Oscillator (Bill Williams) · [`awesome_oscillator.rs`](crates/wickra-core/src/indicators/awesome_oscillator.rs) +- `Cci` — Commodity Channel Index (CCI) · [`cci.rs`](crates/wickra-core/src/indicators/cci.rs) +- `Cmo` — Chande Momentum Oscillator · [`cmo.rs`](crates/wickra-core/src/indicators/cmo.rs) +- `ConnorsRsi` — Connors RSI (CRSI) · [`connors_rsi.rs`](crates/wickra-core/src/indicators/connors_rsi.rs) +- `DerivativeOscillator` — Derivative Oscillator (Constance Brown) · [`derivative_oscillator.rs`](crates/wickra-core/src/indicators/derivative_oscillator.rs) +- `DisparityIndex` — Disparity Index · [`disparity_index.rs`](crates/wickra-core/src/indicators/disparity_index.rs) +- `DynamicMomentumIndex` — Dynamic Momentum Index (Chande's volatility-adaptive RSI) · [`dynamic_momentum_index.rs`](crates/wickra-core/src/indicators/dynamic_momentum_index.rs) +- `ElderRay` — Elder Ray — Bull Power and Bear Power · [`elder_ray.rs`](crates/wickra-core/src/indicators/elder_ray.rs) +- `FisherRsi` — Fisher-transformed RSI · [`fisher_rsi.rs`](crates/wickra-core/src/indicators/fisher_rsi.rs) +- `Inertia` — Inertia (Donald Dorsey) · [`inertia.rs`](crates/wickra-core/src/indicators/inertia.rs) +- `IntradayMomentumIndex` — Intraday Momentum Index (IMI) · [`intraday_momentum_index.rs`](crates/wickra-core/src/indicators/intraday_momentum_index.rs) +- `Kst` — Know Sure Thing (KST) · [`kst.rs`](crates/wickra-core/src/indicators/kst.rs) +- `LaguerreRsi` — Ehlers' Laguerre RSI · [`laguerre_rsi.rs`](crates/wickra-core/src/indicators/laguerre_rsi.rs) +- `Mfi` — Money Flow Index (MFI) · [`mfi.rs`](crates/wickra-core/src/indicators/mfi.rs) +- `Mom` — Momentum (absolute price change over a fixed lookback) · [`mom.rs`](crates/wickra-core/src/indicators/mom.rs) +- `Pgo` — Pretty Good Oscillator (PGO) · [`pgo.rs`](crates/wickra-core/src/indicators/pgo.rs) +- `Pmo` — Price Momentum Oscillator (`DecisionPoint`) · [`pmo.rs`](crates/wickra-core/src/indicators/pmo.rs) +- `Qqe` — QQE — Quantitative Qualitative Estimation · [`qqe.rs`](crates/wickra-core/src/indicators/qqe.rs) +- `Rmi` — Relative Momentum Index (RMI) · [`rmi.rs`](crates/wickra-core/src/indicators/rmi.rs) +- `Roc` — Rate of Change (ROC) · [`roc.rs`](crates/wickra-core/src/indicators/roc.rs) +- `Rocp` — Rate of Change Percentage (ROCP) · [`rocp.rs`](crates/wickra-core/src/indicators/rocp.rs) +- `Rocr` — Rate of Change Ratio (ROCR) · [`rocr.rs`](crates/wickra-core/src/indicators/rocr.rs) +- `Rocr100` — Rate of Change Ratio scaled by 100 (ROCR100) · [`rocr100.rs`](crates/wickra-core/src/indicators/rocr100.rs) +- `Rsi` — Relative Strength Index using Wilder's smoothing · [`rsi.rs`](crates/wickra-core/src/indicators/rsi.rs) +- `Rsx` — RSX — Jurik-style smoothed RSI · [`rsx.rs`](crates/wickra-core/src/indicators/rsx.rs) +- `Rvi` — Relative Vigor Index (RVI) · [`rvi.rs`](crates/wickra-core/src/indicators/rvi.rs) +- `Smi` — Stochastic Momentum Index (SMI) · [`smi.rs`](crates/wickra-core/src/indicators/smi.rs) +- `Stochastic` — Stochastic Oscillator (%K and %D) · [`stochastic.rs`](crates/wickra-core/src/indicators/stochastic.rs) +- `StochasticCci` — Stochastic CCI — a stochastic oscillator applied to the CCI · [`stochastic_cci.rs`](crates/wickra-core/src/indicators/stochastic_cci.rs) +- `StochRsi` — Stochastic RSI · [`stoch_rsi.rs`](crates/wickra-core/src/indicators/stoch_rsi.rs) +- `Tsi` — True Strength Index · [`tsi.rs`](crates/wickra-core/src/indicators/tsi.rs) +- `UltimateOscillator` — Ultimate Oscillator · [`ultimate_oscillator.rs`](crates/wickra-core/src/indicators/ultimate_oscillator.rs) +- `WilliamsR` — Williams %R · [`williams_r.rs`](crates/wickra-core/src/indicators/williams_r.rs) + +## Trend & Directional (28) + +- `Adx` — Average Directional Index (ADX) with +DI / -DI components · [`adx.rs`](crates/wickra-core/src/indicators/adx.rs) +- `Adxr` — Average Directional Movement Index Rating (ADXR) · [`adxr.rs`](crates/wickra-core/src/indicators/adxr.rs) +- `Aroon` — Aroon Up / Down indicator · [`aroon.rs`](crates/wickra-core/src/indicators/aroon.rs) +- `AroonOscillator` — Aroon Oscillator · [`aroon_oscillator.rs`](crates/wickra-core/src/indicators/aroon_oscillator.rs) +- `ChoppinessIndex` — Choppiness Index · [`choppiness_index.rs`](crates/wickra-core/src/indicators/choppiness_index.rs) +- `Dx` — Directional Movement Index (DX), Wilder-smoothed · [`dx.rs`](crates/wickra-core/src/indicators/dx.rs) +- `GatorOscillator` — Bill Williams' Gator Oscillator (derived from the Alligator) · [`gator_oscillator.rs`](crates/wickra-core/src/indicators/gator_oscillator.rs) +- `KasePermissionStochastic` — Kase Permission Stochastic — a double-smoothed stochastic used as a · [`kase_permission_stochastic.rs`](crates/wickra-core/src/indicators/kase_permission_stochastic.rs) +- `MacdExt` — MACD with selectable moving-average types (MACDEXT) · [`macd_ext.rs`](crates/wickra-core/src/indicators/macd_ext.rs) +- `MacdFix` — MACD with fixed 12/26 periods (MACDFIX) · [`macd_fix.rs`](crates/wickra-core/src/indicators/macd_fix.rs) +- `MacdIndicator` — Moving Average Convergence Divergence (MACD) · [`macd.rs`](crates/wickra-core/src/indicators/macd.rs) +- `MassIndex` — Mass Index · [`mass_index.rs`](crates/wickra-core/src/indicators/mass_index.rs) +- `MinusDi` — Minus Directional Indicator (-DI), Wilder-smoothed · [`minus_di.rs`](crates/wickra-core/src/indicators/minus_di.rs) +- `MinusDm` — Minus Directional Movement (-DM), Wilder-smoothed · [`minus_dm.rs`](crates/wickra-core/src/indicators/minus_dm.rs) +- `PlusDi` — Plus Directional Indicator (+DI), Wilder-smoothed · [`plus_di.rs`](crates/wickra-core/src/indicators/plus_di.rs) +- `PlusDm` — Plus Directional Movement (+DM), Wilder-smoothed · [`plus_dm.rs`](crates/wickra-core/src/indicators/plus_dm.rs) +- `PolarizedFractalEfficiency` — Polarized Fractal Efficiency (PFE) · [`polarized_fractal_efficiency.rs`](crates/wickra-core/src/indicators/polarized_fractal_efficiency.rs) +- `Qstick` — Qstick — Tushar Chande's measure of buying vs. selling pressure · [`qstick.rs`](crates/wickra-core/src/indicators/qstick.rs) +- `Rwi` — Random Walk Index (RWI) · [`rwi.rs`](crates/wickra-core/src/indicators/rwi.rs) +- `Tii` — Trend Intensity Index (TII) · [`tii.rs`](crates/wickra-core/src/indicators/tii.rs) +- `TrendLabel` — Trend Label — the sign of the rolling least-squares slope · [`trend_label.rs`](crates/wickra-core/src/indicators/trend_label.rs) +- `TrendStrengthIndex` — Trend Strength Index — the signed coefficient of determination of a linear · [`trend_strength_index.rs`](crates/wickra-core/src/indicators/trend_strength_index.rs) +- `Trix` — TRIX: triple-smoothed EMA percent rate of change · [`trix.rs`](crates/wickra-core/src/indicators/trix.rs) +- `TtmTrend` — TTM Trend — John Carter's bar-coloring trend filter · [`ttm_trend.rs`](crates/wickra-core/src/indicators/ttm_trend.rs) +- `VerticalHorizontalFilter` — Vertical Horizontal Filter · [`vertical_horizontal_filter.rs`](crates/wickra-core/src/indicators/vertical_horizontal_filter.rs) +- `Vortex` — Vortex Indicator · [`vortex.rs`](crates/wickra-core/src/indicators/vortex.rs) +- `WavePm` — Wave PM — Cynthia Kase's peak-momentum statistic (Wickra reconstruction) · [`wave_pm.rs`](crates/wickra-core/src/indicators/wave_pm.rs) +- `WaveTrend` — Wave Trend Oscillator (`LazyBear`) · [`wave_trend.rs`](crates/wickra-core/src/indicators/wave_trend.rs) + +## Price Oscillators (14) + +- `AcceleratorOscillator` — Accelerator Oscillator (Bill Williams) · [`accelerator_oscillator.rs`](crates/wickra-core/src/indicators/accelerator_oscillator.rs) +- `Apo` — Absolute Price Oscillator (APO) · [`apo.rs`](crates/wickra-core/src/indicators/apo.rs) +- `AwesomeOscillatorHistogram` — Awesome Oscillator Histogram · [`awesome_oscillator_histogram.rs`](crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs) +- `BalanceOfPower` — Balance of Power · [`balance_of_power.rs`](crates/wickra-core/src/indicators/balance_of_power.rs) +- `Cfo` — Chande Forecast Oscillator (CFO) · [`cfo.rs`](crates/wickra-core/src/indicators/cfo.rs) +- `Coppock` — Coppock Curve · [`coppock.rs`](crates/wickra-core/src/indicators/coppock.rs) +- `Dpo` — Detrended Price Oscillator · [`dpo.rs`](crates/wickra-core/src/indicators/dpo.rs) +- `ElderImpulse` — Elder Impulse System · [`elder_impulse.rs`](crates/wickra-core/src/indicators/elder_impulse.rs) +- `MacdHistogram` — MACD Histogram (standalone) · [`macd_histogram.rs`](crates/wickra-core/src/indicators/macd_histogram.rs) +- `Ppo` — Percentage Price Oscillator · [`ppo.rs`](crates/wickra-core/src/indicators/ppo.rs) +- `PpoHistogram` — Percentage Price Oscillator Histogram · [`ppo_histogram.rs`](crates/wickra-core/src/indicators/ppo_histogram.rs) +- `Stc` — Schaff Trend Cycle (STC) · [`stc.rs`](crates/wickra-core/src/indicators/stc.rs) +- `TsfOscillator` — Time Series Forecast Oscillator (TSF Oscillator) · [`tsf_oscillator.rs`](crates/wickra-core/src/indicators/tsf_oscillator.rs) +- `ZeroLagMacd` — Zero-Lag MACD — MACD computed on `ZLEMA` instead of `EMA` · [`zero_lag_macd.rs`](crates/wickra-core/src/indicators/zero_lag_macd.rs) + +## Volatility & Bands (25) + +- `Atr` — Average True Range (Wilder) · [`atr.rs`](crates/wickra-core/src/indicators/atr.rs) +- `BipowerVariation` — Realized Bipower Variation — a jump-robust quadratic-variation estimator · [`bipower_variation.rs`](crates/wickra-core/src/indicators/bipower_variation.rs) +- `BollingerBands` — Bollinger Bands · [`bollinger.rs`](crates/wickra-core/src/indicators/bollinger.rs) +- `BollingerBandwidth` — Bollinger Bandwidth · [`bollinger_bandwidth.rs`](crates/wickra-core/src/indicators/bollinger_bandwidth.rs) +- `ChaikinVolatility` — Chaikin Volatility · [`chaikin_volatility.rs`](crates/wickra-core/src/indicators/chaikin_volatility.rs) +- `Donchian` — Donchian Channels · [`donchian.rs`](crates/wickra-core/src/indicators/donchian.rs) +- `EwmaVolatility` — EWMA Volatility — `RiskMetrics` exponentially-weighted volatility · [`ewma_volatility.rs`](crates/wickra-core/src/indicators/ewma_volatility.rs) +- `Garch11` — GARCH(1,1) — conditional volatility with a long-run-variance anchor · [`garch11.rs`](crates/wickra-core/src/indicators/garch11.rs) +- `GarmanKlassVolatility` — Garman-Klass Volatility (OHLC estimator) · [`garman_klass.rs`](crates/wickra-core/src/indicators/garman_klass.rs) +- `HistoricalVolatility` — Historical Volatility · [`historical_volatility.rs`](crates/wickra-core/src/indicators/historical_volatility.rs) +- `JumpIndicator` — Jump Indicator — detects return outliers relative to trailing volatility · [`jump_indicator.rs`](crates/wickra-core/src/indicators/jump_indicator.rs) +- `Keltner` — Keltner Channels · [`keltner.rs`](crates/wickra-core/src/indicators/keltner.rs) +- `Natr` — Normalized Average True Range · [`natr.rs`](crates/wickra-core/src/indicators/natr.rs) +- `ParkinsonVolatility` — Parkinson Volatility (high-low estimator) · [`parkinson.rs`](crates/wickra-core/src/indicators/parkinson.rs) +- `PercentB` — Bollinger %b · [`percent_b.rs`](crates/wickra-core/src/indicators/percent_b.rs) +- `RegimeLabel` — Regime Label — volatility-quantile classification of the current bar · [`regime_label.rs`](crates/wickra-core/src/indicators/regime_label.rs) +- `RogersSatchellVolatility` — Rogers-Satchell Volatility (drift-free OHLC estimator) · [`rogers_satchell.rs`](crates/wickra-core/src/indicators/rogers_satchell.rs) +- `RviVolatility` — Relative Volatility Index (Donald Dorsey) · [`rvi_volatility.rs`](crates/wickra-core/src/indicators/rvi_volatility.rs) +- `StdDev` — Rolling population standard deviation · [`std_dev.rs`](crates/wickra-core/src/indicators/std_dev.rs) +- `TrueRange` — True Range · [`true_range.rs`](crates/wickra-core/src/indicators/true_range.rs) +- `UlcerIndex` — Ulcer Index · [`ulcer_index.rs`](crates/wickra-core/src/indicators/ulcer_index.rs) +- `VolatilityCone` — Volatility Cone — current realized volatility within its historical envelope · [`volatility_cone.rs`](crates/wickra-core/src/indicators/volatility_cone.rs) +- `VolatilityOfVolatility` — Volatility of Volatility — the dispersion of a rolling volatility series · [`volatility_of_volatility.rs`](crates/wickra-core/src/indicators/volatility_of_volatility.rs) +- `VolatilityRatio` — Schwager's Volatility Ratio — today's true range versus its typical level · [`volatility_ratio.rs`](crates/wickra-core/src/indicators/volatility_ratio.rs) +- `YangZhangVolatility` — Yang-Zhang Volatility (drift- and gap-robust OHLC estimator) · [`yang_zhang.rs`](crates/wickra-core/src/indicators/yang_zhang.rs) + +## Bands & Channels (16) + +- `AccelerationBands` — Acceleration Bands (Price Headley) · [`acceleration_bands.rs`](crates/wickra-core/src/indicators/acceleration_bands.rs) +- `AtrBands` — ATR Bands · [`atr_bands.rs`](crates/wickra-core/src/indicators/atr_bands.rs) +- `BomarBands` — Bomar Bands — adaptive percentage bands that contain a target fraction of · [`bomar_bands.rs`](crates/wickra-core/src/indicators/bomar_bands.rs) +- `DoubleBollinger` — Double Bollinger Bands (Kathy Lien) · [`double_bollinger.rs`](crates/wickra-core/src/indicators/double_bollinger.rs) +- `FractalChaosBands` — Fractal Chaos Bands (Bill Williams Fractals) · [`fractal_chaos_bands.rs`](crates/wickra-core/src/indicators/fractal_chaos_bands.rs) +- `HurstChannel` — Hurst Channel (Brian Millard / Hurst-cycle channel) · [`hurst_channel.rs`](crates/wickra-core/src/indicators/hurst_channel.rs) +- `LinRegChannel` — Linear Regression Channel — OLS endpoint ± k · stddev of residuals · [`linreg_channel.rs`](crates/wickra-core/src/indicators/linreg_channel.rs) +- `MaEnvelope` — Moving Average Envelope · [`ma_envelope.rs`](crates/wickra-core/src/indicators/ma_envelope.rs) +- `MedianChannel` — Median Channel — a robust median ± MAD envelope · [`median_channel.rs`](crates/wickra-core/src/indicators/median_channel.rs) +- `ProjectionBands` — Projection Bands (Mel Widner) — a high/low linear-regression projection · [`projection_bands.rs`](crates/wickra-core/src/indicators/projection_bands.rs) +- `ProjectionOscillator` — Projection Oscillator (Mel Widner) — the close's position inside the · [`projection_oscillator.rs`](crates/wickra-core/src/indicators/projection_oscillator.rs) +- `QuartileBands` — Quartile Bands — rolling 25th / 50th / 75th percentile envelope · [`quartile_bands.rs`](crates/wickra-core/src/indicators/quartile_bands.rs) +- `StandardErrorBands` — Standard Error Bands · [`standard_error_bands.rs`](crates/wickra-core/src/indicators/standard_error_bands.rs) +- `StarcBands` — STARC Bands (Stoller Average Range Channel) · [`starc_bands.rs`](crates/wickra-core/src/indicators/starc_bands.rs) +- `TtmSqueeze` — TTM Squeeze (John Carter) · [`ttm_squeeze.rs`](crates/wickra-core/src/indicators/ttm_squeeze.rs) +- `VwapStdDevBands` — VWAP Standard-Deviation Bands · [`vwap_stddev_bands.rs`](crates/wickra-core/src/indicators/vwap_stddev_bands.rs) + +## Trailing Stops (19) + +- `AtrRatchet` — ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar · [`atr_ratchet.rs`](crates/wickra-core/src/indicators/atr_ratchet.rs) +- `AtrTrailingStop` — ATR Trailing Stop · [`atr_trailing_stop.rs`](crates/wickra-core/src/indicators/atr_trailing_stop.rs) +- `ChandeKrollStop` — Chande Kroll Stop · [`chande_kroll_stop.rs`](crates/wickra-core/src/indicators/chande_kroll_stop.rs) +- `ChandelierExit` — Chandelier Exit · [`chandelier_exit.rs`](crates/wickra-core/src/indicators/chandelier_exit.rs) +- `DonchianStop` — Donchian Channel Stop (Turtle) · [`donchian_stop.rs`](crates/wickra-core/src/indicators/donchian_stop.rs) +- `ElderSafeZone` — Elder `SafeZone` Stop — a trailing stop set by the average noise penetration · [`elder_safezone.rs`](crates/wickra-core/src/indicators/elder_safezone.rs) +- `HiLoActivator` — `HiLo` Activator (Crabel) · [`hilo_activator.rs`](crates/wickra-core/src/indicators/hilo_activator.rs) +- `KaseDevStop` — Kase `DevStop` — a volatility trailing stop on the standard deviation of the · [`kase_devstop.rs`](crates/wickra-core/src/indicators/kase_devstop.rs) +- `ModifiedMaStop` — Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA) · [`modified_ma_stop.rs`](crates/wickra-core/src/indicators/modified_ma_stop.rs) +- `Nrtr` — NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop · [`nrtr.rs`](crates/wickra-core/src/indicators/nrtr.rs) +- `PercentageTrailingStop` — Percentage Trailing Stop · [`percentage_trailing_stop.rs`](crates/wickra-core/src/indicators/percentage_trailing_stop.rs) +- `Psar` — Parabolic SAR (Wilder) · [`psar.rs`](crates/wickra-core/src/indicators/psar.rs) +- `RenkoTrailingStop` — Renko Trailing Stop · [`renko_trailing_stop.rs`](crates/wickra-core/src/indicators/renko_trailing_stop.rs) +- `SarExt` — Parabolic SAR Extended (SAREXT) · [`sar_ext.rs`](crates/wickra-core/src/indicators/sar_ext.rs) +- `StepTrailingStop` — Step Trailing Stop · [`step_trailing_stop.rs`](crates/wickra-core/src/indicators/step_trailing_stop.rs) +- `SuperTrend` — `SuperTrend` · [`super_trend.rs`](crates/wickra-core/src/indicators/super_trend.rs) +- `TimeBasedStop` — Time-Based Stop — a holding-period timer that fires after a fixed bar count · [`time_based_stop.rs`](crates/wickra-core/src/indicators/time_based_stop.rs) +- `VoltyStop` — Volty Stop (Volatility Stop, Kase) · [`volty_stop.rs`](crates/wickra-core/src/indicators/volty_stop.rs) +- `YoyoExit` — Yo-Yo Exit · [`yoyo_exit.rs`](crates/wickra-core/src/indicators/yoyo_exit.rs) + +## Volume (26) + +- `Adl` — Accumulation/Distribution Line · [`adl.rs`](crates/wickra-core/src/indicators/adl.rs) +- `AdOscillator` — Williams A/D Oscillator (ADOSC) · [`ad_oscillator.rs`](crates/wickra-core/src/indicators/ad_oscillator.rs) +- `AnchoredVwap` — Anchored Volume-Weighted Average Price · [`anchored_vwap.rs`](crates/wickra-core/src/indicators/anchored_vwap.rs) +- `BetterVolume` — Better Volume (VSA) — a streaming effort-versus-result oscillator · [`better_volume.rs`](crates/wickra-core/src/indicators/better_volume.rs) +- `ChaikinMoneyFlow` — Chaikin Money Flow (CMF) · [`cmf.rs`](crates/wickra-core/src/indicators/cmf.rs) +- `ChaikinOscillator` — Chaikin Oscillator · [`chaikin_oscillator.rs`](crates/wickra-core/src/indicators/chaikin_oscillator.rs) +- `DemandIndex` — Demand Index (James Sibbet) · [`demand_index.rs`](crates/wickra-core/src/indicators/demand_index.rs) +- `EaseOfMovement` — Ease of Movement (Arms) · [`ease_of_movement.rs`](crates/wickra-core/src/indicators/ease_of_movement.rs) +- `ForceIndex` — Force Index (Elder) · [`force_index.rs`](crates/wickra-core/src/indicators/force_index.rs) +- `IntradayIntensity` — Intraday Intensity (Bostian) — the per-bar volume-weighted close-location · [`intraday_intensity.rs`](crates/wickra-core/src/indicators/intraday_intensity.rs) +- `Kvo` — Klinger Volume Oscillator · [`kvo.rs`](crates/wickra-core/src/indicators/kvo.rs) +- `MarketFacilitationIndex` — Market Facilitation Index (Bill Williams) · [`market_facilitation_index.rs`](crates/wickra-core/src/indicators/market_facilitation_index.rs) +- `Nvi` — Negative Volume Index · [`nvi.rs`](crates/wickra-core/src/indicators/nvi.rs) +- `Obv` — On-Balance Volume · [`obv.rs`](crates/wickra-core/src/indicators/obv.rs) +- `Pvi` — Positive Volume Index · [`pvi.rs`](crates/wickra-core/src/indicators/pvi.rs) +- `RollingVwap` — Volume-Weighted Average Price (VWAP) · [`vwap.rs`](crates/wickra-core/src/indicators/vwap.rs) +- `TradeVolumeIndex` — Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule · [`trade_volume_index.rs`](crates/wickra-core/src/indicators/trade_volume_index.rs) +- `Tsv` — Time Segmented Volume (Worden) · [`tsv.rs`](crates/wickra-core/src/indicators/tsv.rs) +- `TwiggsMoneyFlow` — Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator · [`twiggs_money_flow.rs`](crates/wickra-core/src/indicators/twiggs_money_flow.rs) +- `VolumeOscillator` — Volume Oscillator · [`volume_oscillator.rs`](crates/wickra-core/src/indicators/volume_oscillator.rs) +- `VolumePriceTrend` — Volume-Price Trend · [`vpt.rs`](crates/wickra-core/src/indicators/vpt.rs) +- `VolumeRsi` — Volume RSI — Wilder's RSI applied to the volume stream · [`volume_rsi.rs`](crates/wickra-core/src/indicators/volume_rsi.rs) +- `VolumeWeightedMacd` — Volume-Weighted MACD — MACD built on volume-weighted moving averages · [`volume_weighted_macd.rs`](crates/wickra-core/src/indicators/volume_weighted_macd.rs) +- `Vwap` — Volume-Weighted Average Price (VWAP) · [`vwap.rs`](crates/wickra-core/src/indicators/vwap.rs) +- `Vzo` — Volume Zone Oscillator (Walid Khalil) · [`vzo.rs`](crates/wickra-core/src/indicators/vzo.rs) +- `Wad` — Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line · [`wad.rs`](crates/wickra-core/src/indicators/wad.rs) + +## Price Statistics (55) + +- `Autocorrelation` — Rolling lag-`k` autocorrelation · [`autocorrelation.rs`](crates/wickra-core/src/indicators/autocorrelation.rs) +- `AvgPrice` — Average Price (AVGPRICE) · [`avg_price.rs`](crates/wickra-core/src/indicators/avg_price.rs) +- `Beta` — Rolling Beta — sensitivity of an asset to a benchmark · [`beta.rs`](crates/wickra-core/src/indicators/beta.rs) +- `BetaNeutralSpread` — Beta-neutral spread: the rolling OLS regression residual of two series · [`beta_neutral_spread.rs`](crates/wickra-core/src/indicators/beta_neutral_spread.rs) +- `BodySizePct` — Body Size Percent — candle body as a fraction of its range · [`body_size_pct.rs`](crates/wickra-core/src/indicators/body_size_pct.rs) +- `CloseVsOpen` — Close vs Open — the signed relative body of a bar · [`close_vs_open.rs`](crates/wickra-core/src/indicators/close_vs_open.rs) +- `CoefficientOfVariation` — Rolling Coefficient of Variation (`StdDev / Mean`) · [`coefficient_of_variation.rs`](crates/wickra-core/src/indicators/coefficient_of_variation.rs) +- `Cointegration` — Cointegration — rolling Engle–Granger hedge ratio plus an ADF stationarity test · [`cointegration.rs`](crates/wickra-core/src/indicators/cointegration.rs) +- `DetrendedStdDev` — Population standard deviation of residuals from a rolling OLS detrend · [`detrended_std_dev.rs`](crates/wickra-core/src/indicators/detrended_std_dev.rs) +- `DistanceSsd` — Gatev distance (sum of squared deviations) between two normalised series · [`distance_ssd.rs`](crates/wickra-core/src/indicators/distance_ssd.rs) +- `GrangerCausality` — Granger causality F-statistic: does series `b` help predict series `a`? · [`granger_causality.rs`](crates/wickra-core/src/indicators/granger_causality.rs) +- `HighLowRange` — High-Low Range — the bar range as a fraction of close · [`high_low_range.rs`](crates/wickra-core/src/indicators/high_low_range.rs) +- `HurstExponent` — Rolling Hurst Exponent via simplified R/S analysis · [`hurst_exponent.rs`](crates/wickra-core/src/indicators/hurst_exponent.rs) +- `JarqueBera` — Jarque-Bera — a normality-test statistic on a rolling window · [`jarque_bera.rs`](crates/wickra-core/src/indicators/jarque_bera.rs) +- `KalmanHedgeRatio` — Kalman-filter dynamic hedge ratio between two series · [`kalman_hedge_ratio.rs`](crates/wickra-core/src/indicators/kalman_hedge_ratio.rs) +- `KendallTau` — Kendall's tau-b — rank correlation by concordant vs. discordant pairs · [`kendall_tau.rs`](crates/wickra-core/src/indicators/kendall_tau.rs) +- `Kurtosis` — Rolling excess kurtosis (Pearson's fourth standardised central moment − 3) · [`kurtosis.rs`](crates/wickra-core/src/indicators/kurtosis.rs) +- `LeadLagCrossCorrelation` — Lead–Lag Cross-Correlation — which of two assets leads the other, and by how much · [`lead_lag_cross_correlation.rs`](crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs) +- `LinearRegression` — Linear Regression (rolling least-squares endpoint) · [`linreg.rs`](crates/wickra-core/src/indicators/linreg.rs) +- `LinRegAngle` — Linear Regression Angle · [`linreg_angle.rs`](crates/wickra-core/src/indicators/linreg_angle.rs) +- `LinRegIntercept` — Linear Regression Intercept (`LINEARREG_INTERCEPT`) · [`linreg_intercept.rs`](crates/wickra-core/src/indicators/linreg_intercept.rs) +- `LinRegSlope` — Linear Regression Slope · [`linreg_slope.rs`](crates/wickra-core/src/indicators/linreg_slope.rs) +- `LogReturn` — Logarithmic Return over a fixed lag · [`log_return.rs`](crates/wickra-core/src/indicators/log_return.rs) +- `MedianAbsoluteDeviation` — Rolling Median Absolute Deviation (MAD), a robust dispersion estimator · [`median_absolute_deviation.rs`](crates/wickra-core/src/indicators/median_absolute_deviation.rs) +- `MedianPrice` — Median Price · [`median_price.rs`](crates/wickra-core/src/indicators/median_price.rs) +- `MidPoint` — Midpoint (MIDPOINT) over a rolling window of a scalar series · [`mid_point.rs`](crates/wickra-core/src/indicators/mid_point.rs) +- `MidPrice` — Midpoint Price (MIDPRICE) over a rolling window of high/low extremes · [`mid_price.rs`](crates/wickra-core/src/indicators/mid_price.rs) +- `OuHalfLife` — Ornstein–Uhlenbeck half-life of mean reversion for the spread of two series · [`ou_half_life.rs`](crates/wickra-core/src/indicators/ou_half_life.rs) +- `PairSpreadZScore` — Pair Spread Z-Score — the standardised log-spread of two cointegrated assets · [`pair_spread_zscore.rs`](crates/wickra-core/src/indicators/pair_spread_zscore.rs) +- `PairwiseBeta` — Pairwise Beta — rolling OLS slope of one asset's log-returns on another's · [`pairwise_beta.rs`](crates/wickra-core/src/indicators/pairwise_beta.rs) +- `PearsonCorrelation` — Rolling Pearson correlation between two synchronised series · [`pearson_correlation.rs`](crates/wickra-core/src/indicators/pearson_correlation.rs) +- `RealizedVolatility` — Realized Volatility from the sum of squared log returns · [`realized_volatility.rs`](crates/wickra-core/src/indicators/realized_volatility.rs) +- `RelativeStrengthAB` — Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI · [`relative_strength_ab.rs`](crates/wickra-core/src/indicators/relative_strength_ab.rs) +- `RollingCorrelation` — Rolling Pearson correlation of the period-over-period *returns* of two series · [`rolling_correlation.rs`](crates/wickra-core/src/indicators/rolling_correlation.rs) +- `RollingCovariance` — Rolling covariance of the period-over-period *returns* of two series · [`rolling_covariance.rs`](crates/wickra-core/src/indicators/rolling_covariance.rs) +- `RollingIqr` — Rolling Interquartile Range (IQR) over a trailing window · [`rolling_iqr.rs`](crates/wickra-core/src/indicators/rolling_iqr.rs) +- `RollingMinMaxScaler` — Rolling Min-Max Scaler — normalises the latest value to `[0, 1]` over a window · [`rolling_min_max_scaler.rs`](crates/wickra-core/src/indicators/rolling_min_max_scaler.rs) +- `RollingPercentileRank` — Rolling Percentile Rank of the latest value within its trailing window · [`rolling_percentile_rank.rs`](crates/wickra-core/src/indicators/rolling_percentile_rank.rs) +- `RollingQuantile` — Rolling Quantile over a trailing window · [`rolling_quantile.rs`](crates/wickra-core/src/indicators/rolling_quantile.rs) +- `RSquared` — Coefficient of determination R² for the rolling OLS fit · [`r_squared.rs`](crates/wickra-core/src/indicators/r_squared.rs) +- `SampleEntropy` — Sample Entropy (`SampEn`) — the regularity / predictability of a window · [`sample_entropy.rs`](crates/wickra-core/src/indicators/sample_entropy.rs) +- `ShannonEntropy` — Shannon Entropy — the information content of a price window's distribution · [`shannon_entropy.rs`](crates/wickra-core/src/indicators/shannon_entropy.rs) +- `Skewness` — Rolling Pearson skewness (third standardised central moment) · [`skewness.rs`](crates/wickra-core/src/indicators/skewness.rs) +- `SpearmanCorrelation` — Rolling Spearman rank correlation between two synchronised series · [`spearman_correlation.rs`](crates/wickra-core/src/indicators/spearman_correlation.rs) +- `SpreadAr1Coefficient` — AR(1) autoregression coefficient of the spread of two series · [`spread_ar1_coefficient.rs`](crates/wickra-core/src/indicators/spread_ar1_coefficient.rs) +- `SpreadBollingerBands` — Bollinger bands on the spread of two series, for pairs mean-reversion trading · [`spread_bollinger_bands.rs`](crates/wickra-core/src/indicators/spread_bollinger_bands.rs) +- `SpreadHurst` — Hurst exponent of the spread of two series, for pairs-trading regime detection · [`spread_hurst.rs`](crates/wickra-core/src/indicators/spread_hurst.rs) +- `StandardError` — Standard Error of the rolling least-squares regression · [`standard_error.rs`](crates/wickra-core/src/indicators/standard_error.rs) +- `Tsf` — Time Series Forecast (TSF) · [`tsf.rs`](crates/wickra-core/src/indicators/tsf.rs) +- `TypicalPrice` — Typical Price · [`typical_price.rs`](crates/wickra-core/src/indicators/typical_price.rs) +- `Variance` — Rolling population variance · [`variance.rs`](crates/wickra-core/src/indicators/variance.rs) +- `VarianceRatio` — Lo–MacKinlay variance-ratio test on the spread of two series · [`variance_ratio.rs`](crates/wickra-core/src/indicators/variance_ratio.rs) +- `WeightedClose` — Weighted Close · [`weighted_close.rs`](crates/wickra-core/src/indicators/weighted_close.rs) +- `WickRatio` — Wick Ratio — the shadow imbalance of a bar · [`wick_ratio.rs`](crates/wickra-core/src/indicators/wick_ratio.rs) +- `ZScore` — Z-Score · [`z_score.rs`](crates/wickra-core/src/indicators/z_score.rs) + +## Ehlers / Cycle (DSP) (29) + +- `AdaptiveCci` — Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio · [`adaptive_cci.rs`](crates/wickra-core/src/indicators/adaptive_cci.rs) +- `AdaptiveCycle` — Ehlers Adaptive Cycle period estimator (for adaptive oscillators) · [`adaptive_cycle.rs`](crates/wickra-core/src/indicators/adaptive_cycle.rs) +- `AdaptiveRsi` — Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio · [`adaptive_rsi.rs`](crates/wickra-core/src/indicators/adaptive_rsi.rs) +- `AutocorrelationPeriodogram` — Ehlers Autocorrelation Periodogram — estimates the dominant market cycle · [`autocorrelation_periodogram.rs`](crates/wickra-core/src/indicators/autocorrelation_periodogram.rs) +- `BandpassFilter` — Ehlers Bandpass Filter — isolates the cyclic component around a target period · [`bandpass_filter.rs`](crates/wickra-core/src/indicators/bandpass_filter.rs) +- `CenterOfGravity` — Ehlers Center of Gravity Oscillator · [`center_of_gravity.rs`](crates/wickra-core/src/indicators/center_of_gravity.rs) +- `CorrelationTrendIndicator` — Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time · [`correlation_trend_indicator.rs`](crates/wickra-core/src/indicators/correlation_trend_indicator.rs) +- `CyberneticCycle` — Ehlers Cybernetic Cycle Component · [`cybernetic_cycle.rs`](crates/wickra-core/src/indicators/cybernetic_cycle.rs) +- `Decycler` — Ehlers Decycler (single-pole high-pass complement) · [`decycler.rs`](crates/wickra-core/src/indicators/decycler.rs) +- `DecyclerOscillator` — Ehlers Decycler Oscillator (difference of two decyclers) · [`decycler_oscillator.rs`](crates/wickra-core/src/indicators/decycler_oscillator.rs) +- `EhlersStochastic` — Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input · [`ehlers_stochastic.rs`](crates/wickra-core/src/indicators/ehlers_stochastic.rs) +- `EmpiricalModeDecomposition` — Ehlers Empirical Mode Decomposition (bandpass + envelope) · [`empirical_mode_decomposition.rs`](crates/wickra-core/src/indicators/empirical_mode_decomposition.rs) +- `EvenBetterSinewave` — Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1] · [`even_better_sinewave.rs`](crates/wickra-core/src/indicators/even_better_sinewave.rs) +- `Fama` — Ehlers Following Adaptive Moving Average (FAMA) · [`fama.rs`](crates/wickra-core/src/indicators/fama.rs) +- `FisherTransform` — Ehlers Fisher Transform · [`fisher_transform.rs`](crates/wickra-core/src/indicators/fisher_transform.rs) +- `HighpassFilter` — Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles · [`highpass_filter.rs`](crates/wickra-core/src/indicators/highpass_filter.rs) +- `HilbertDominantCycle` — Ehlers Hilbert Transform Dominant Cycle period estimator · [`hilbert_dominant_cycle.rs`](crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs) +- `HtDcPhase` — Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`) · [`ht_dcphase.rs`](crates/wickra-core/src/indicators/ht_dcphase.rs) +- `HtPhasor` — Ehlers Hilbert Transform Phasor components (`HT_PHASOR`) · [`ht_phasor.rs`](crates/wickra-core/src/indicators/ht_phasor.rs) +- `HtTrendMode` — Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`) · [`ht_trendmode.rs`](crates/wickra-core/src/indicators/ht_trendmode.rs) +- `InstantaneousTrendline` — Ehlers Instantaneous Trendline (ITrend) · [`instantaneous_trendline.rs`](crates/wickra-core/src/indicators/instantaneous_trendline.rs) +- `InverseFisherTransform` — Inverse Fisher Transform (Ehlers) · [`inverse_fisher_transform.rs`](crates/wickra-core/src/indicators/inverse_fisher_transform.rs) +- `Mama` — Ehlers MESA Adaptive Moving Average (MAMA) and its follower (FAMA) · [`mama.rs`](crates/wickra-core/src/indicators/mama.rs) +- `Reflex` — Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter · [`reflex.rs`](crates/wickra-core/src/indicators/reflex.rs) +- `RoofingFilter` — Ehlers Roofing Filter (high-pass followed by SuperSmoother) · [`roofing_filter.rs`](crates/wickra-core/src/indicators/roofing_filter.rs) +- `SineWave` — Ehlers Sine Wave indicator · [`sine_wave.rs`](crates/wickra-core/src/indicators/sine_wave.rs) +- `SuperSmoother` — Ehlers SuperSmoother filter · [`super_smoother.rs`](crates/wickra-core/src/indicators/super_smoother.rs) +- `Trendflex` — Ehlers Trendflex — a trend-sensitive sibling of Reflex · [`trendflex.rs`](crates/wickra-core/src/indicators/trendflex.rs) +- `UniversalOscillator` — Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle · [`universal_oscillator.rs`](crates/wickra-core/src/indicators/universal_oscillator.rs) + +## Pivots & S/R (12) + +- `AndrewsPitchfork` — Andrews Pitchfork — median line and parallels off the last three swing pivots · [`andrews_pitchfork.rs`](crates/wickra-core/src/indicators/andrews_pitchfork.rs) +- `Camarilla` — Camarilla Pivot Points (Nick Stott) · [`camarilla_pivots.rs`](crates/wickra-core/src/indicators/camarilla_pivots.rs) +- `CentralPivotRange` — Central Pivot Range (CPR) — the pivot plus its two central levels · [`central_pivot_range.rs`](crates/wickra-core/src/indicators/central_pivot_range.rs) +- `ClassicPivots` — Classic (Floor-Trader) Pivot Points · [`classic_pivots.rs`](crates/wickra-core/src/indicators/classic_pivots.rs) +- `DemarkPivots` — `DeMark` Pivot Points · [`demark_pivots.rs`](crates/wickra-core/src/indicators/demark_pivots.rs) +- `FibonacciPivots` — Fibonacci Pivot Points · [`fibonacci_pivots.rs`](crates/wickra-core/src/indicators/fibonacci_pivots.rs) +- `MurreyMathLines` — Murrey Math Lines — the eighths grid over the recent trading range · [`murrey_math_lines.rs`](crates/wickra-core/src/indicators/murrey_math_lines.rs) +- `PivotReversal` — Pivot Reversal — a breakout signal off the most recent confirmed swing pivots · [`pivot_reversal.rs`](crates/wickra-core/src/indicators/pivot_reversal.rs) +- `VolumeWeightedSr` — Volume-Weighted Support/Resistance — a volume-weighted high/low band · [`volume_weighted_sr.rs`](crates/wickra-core/src/indicators/volume_weighted_sr.rs) +- `WilliamsFractals` — Williams Fractals (Bill Williams) · [`williams_fractals.rs`](crates/wickra-core/src/indicators/williams_fractals.rs) +- `WoodiePivots` — Woodie Pivot Points (Tom Williams) · [`woodie_pivots.rs`](crates/wickra-core/src/indicators/woodie_pivots.rs) +- `ZigZag` — `ZigZag` — percentage-threshold swing detector · [`zig_zag.rs`](crates/wickra-core/src/indicators/zig_zag.rs) + +## DeMark (19) + +- `TdCamouflage` — Tom DeMark TD Camouflage — a hidden-strength/weakness 1-bar reversal pattern · [`td_camouflage.rs`](crates/wickra-core/src/indicators/td_camouflage.rs) +- `TdClop` — Tom DeMark TD Clop — a 2-bar open/close engulfing reversal · [`td_clop.rs`](crates/wickra-core/src/indicators/td_clop.rs) +- `TdClopwin` — Tom DeMark TD Clopwin — a 2-bar "close/open within" inside-body pattern · [`td_clopwin.rs`](crates/wickra-core/src/indicators/td_clopwin.rs) +- `TdCombo` — Tom DeMark TD Combo — an aggressive variant of TD Countdown · [`td_combo.rs`](crates/wickra-core/src/indicators/td_combo.rs) +- `TdCountdown` — Tom DeMark TD Countdown (standalone 13-bar countdown) · [`td_countdown.rs`](crates/wickra-core/src/indicators/td_countdown.rs) +- `TdDeMarker` — Tom DeMark DeMarker (TD DeMarker) — bounded [0, 1] oscillator built from · [`td_demarker.rs`](crates/wickra-core/src/indicators/td_demarker.rs) +- `TdDifferential` — Tom DeMark TD Differential — 2-bar momentum-divergence reversal pattern · [`td_differential.rs`](crates/wickra-core/src/indicators/td_differential.rs) +- `TdDWave` — Tom DeMark TD D-Wave — a simplified Elliott-style swing-wave counter · [`td_dwave.rs`](crates/wickra-core/src/indicators/td_dwave.rs) +- `TdLines` — Tom DeMark TD Lines (TDST — TD Setup Trend Support / Resistance levels) · [`td_lines.rs`](crates/wickra-core/src/indicators/td_lines.rs) +- `TdMovingAverage` — Tom DeMark TD Moving Averages — the ST1 (fast) and ST2 (slow) trend ribbon · [`td_moving_average.rs`](crates/wickra-core/src/indicators/td_moving_average.rs) +- `TdOpen` — Tom DeMark TD Open — open-vs-prior-range gap-reversal signal · [`td_open.rs`](crates/wickra-core/src/indicators/td_open.rs) +- `TdPressure` — Tom DeMark TD Pressure — volume-weighted buying / selling pressure · [`td_pressure.rs`](crates/wickra-core/src/indicators/td_pressure.rs) +- `TdPropulsion` — Tom DeMark TD Propulsion — a 2-bar trend-continuation thrust signal · [`td_propulsion.rs`](crates/wickra-core/src/indicators/td_propulsion.rs) +- `TdRangeProjection` — Tom DeMark TD Range Projection — next-bar high/low projection from the · [`td_range_projection.rs`](crates/wickra-core/src/indicators/td_range_projection.rs) +- `TdRei` — Tom DeMark Range Expansion Index (TD REI) · [`td_rei.rs`](crates/wickra-core/src/indicators/td_rei.rs) +- `TdRiskLevel` — Tom DeMark TD Risk Level — protective-stop levels derived from setup · [`td_risk_level.rs`](crates/wickra-core/src/indicators/td_risk_level.rs) +- `TdSequential` — Tom DeMark TD Sequential (Setup + Countdown) · [`td_sequential.rs`](crates/wickra-core/src/indicators/td_sequential.rs) +- `TdSetup` — Tom DeMark TD Setup (9-bar buy / sell setup) · [`td_setup.rs`](crates/wickra-core/src/indicators/td_setup.rs) +- `TdTrap` — Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout · [`td_trap.rs`](crates/wickra-core/src/indicators/td_trap.rs) + +## Ichimoku & Charts (7) + +- `CandleVolume` — CandleVolume — candlestick body with a volume-scaled width · [`candle_volume.rs`](crates/wickra-core/src/indicators/candle_volume.rs) +- `Equivolume` — Equivolume — the price box height and its volume-scaled width · [`equivolume.rs`](crates/wickra-core/src/indicators/equivolume.rs) +- `HeikinAshi` — Heikin-Ashi candle transform · [`heikin_ashi.rs`](crates/wickra-core/src/indicators/heikin_ashi.rs) +- `HeikinAshiOscillator` — Heikin-Ashi Oscillator — the (smoothed) Heikin-Ashi candle body as a zero-line oscillator · [`heikin_ashi_oscillator.rs`](crates/wickra-core/src/indicators/heikin_ashi_oscillator.rs) +- `Ichimoku` — Ichimoku Kinko Hyo — the five-line cloud chart · [`ichimoku.rs`](crates/wickra-core/src/indicators/ichimoku.rs) +- `SmoothedHeikinAshi` — Smoothed Heikin-Ashi — Heikin-Ashi computed on EMA-smoothed OHLC · [`smoothed_heikin_ashi.rs`](crates/wickra-core/src/indicators/smoothed_heikin_ashi.rs) +- `ThreeLineBreak` — Three Line Break — the close-driven line-break chart trend, as a direction · [`three_line_break.rs`](crates/wickra-core/src/indicators/three_line_break.rs) + +## Candlestick Patterns (66) + +- `AbandonedBaby` — Abandoned Baby candlestick pattern · [`abandoned_baby.rs`](crates/wickra-core/src/indicators/abandoned_baby.rs) +- `AdvanceBlock` — Advance Block candlestick pattern · [`advance_block.rs`](crates/wickra-core/src/indicators/advance_block.rs) +- `BeltHold` — Belt-hold candlestick pattern · [`belt_hold.rs`](crates/wickra-core/src/indicators/belt_hold.rs) +- `Breakaway` — Breakaway candlestick pattern · [`breakaway.rs`](crates/wickra-core/src/indicators/breakaway.rs) +- `ClosingMarubozu` — Closing Marubozu candlestick pattern · [`closing_marubozu.rs`](crates/wickra-core/src/indicators/closing_marubozu.rs) +- `ConcealingBabySwallow` — Concealing Baby Swallow candlestick pattern · [`concealing_baby_swallow.rs`](crates/wickra-core/src/indicators/concealing_baby_swallow.rs) +- `Counterattack` — Counterattack candlestick pattern · [`counterattack.rs`](crates/wickra-core/src/indicators/counterattack.rs) +- `Doji` — Doji candlestick pattern · [`doji.rs`](crates/wickra-core/src/indicators/doji.rs) +- `DojiStar` — Doji Star candlestick pattern · [`doji_star.rs`](crates/wickra-core/src/indicators/doji_star.rs) +- `DownsideGapThreeMethods` — Downside Gap Three Methods candlestick pattern · [`downside_gap_three_methods.rs`](crates/wickra-core/src/indicators/downside_gap_three_methods.rs) +- `DragonflyDoji` — Dragonfly Doji candlestick pattern · [`dragonfly_doji.rs`](crates/wickra-core/src/indicators/dragonfly_doji.rs) +- `DumplingTop` — Dumpling Top — a rounded top (dome) confirmed by a breakdown · [`dumpling_top.rs`](crates/wickra-core/src/indicators/dumpling_top.rs) +- `Engulfing` — Bullish / Bearish Engulfing candlestick pattern · [`engulfing.rs`](crates/wickra-core/src/indicators/engulfing.rs) +- `EveningDojiStar` — Evening Doji Star candlestick pattern · [`evening_doji_star.rs`](crates/wickra-core/src/indicators/evening_doji_star.rs) +- `FallingThreeMethods` — Falling Three Methods candlestick pattern · [`falling_three_methods.rs`](crates/wickra-core/src/indicators/falling_three_methods.rs) +- `FryPanBottom` — Frying Pan Bottom — a rounded bottom (U) confirmed by recovery · [`fry_pan_bottom.rs`](crates/wickra-core/src/indicators/fry_pan_bottom.rs) +- `GapSideBySideWhite` — Gap Side-by-Side White Lines candlestick pattern · [`gap_side_by_side_white.rs`](crates/wickra-core/src/indicators/gap_side_by_side_white.rs) +- `GravestoneDoji` — Gravestone Doji candlestick pattern · [`gravestone_doji.rs`](crates/wickra-core/src/indicators/gravestone_doji.rs) +- `Hammer` — Hammer candlestick pattern · [`hammer.rs`](crates/wickra-core/src/indicators/hammer.rs) +- `HangingMan` — Hanging Man candlestick pattern · [`hanging_man.rs`](crates/wickra-core/src/indicators/hanging_man.rs) +- `Harami` — Bullish / Bearish Harami candlestick pattern · [`harami.rs`](crates/wickra-core/src/indicators/harami.rs) +- `HaramiCross` — Harami Cross — a Harami whose second candle is a Doji · [`harami_cross.rs`](crates/wickra-core/src/indicators/harami_cross.rs) +- `HighWave` — High-Wave candlestick pattern · [`high_wave.rs`](crates/wickra-core/src/indicators/high_wave.rs) +- `Hikkake` — Hikkake candlestick pattern · [`hikkake.rs`](crates/wickra-core/src/indicators/hikkake.rs) +- `HikkakeModified` — Modified Hikkake candlestick pattern · [`hikkake_modified.rs`](crates/wickra-core/src/indicators/hikkake_modified.rs) +- `HomingPigeon` — Homing Pigeon candlestick pattern · [`homing_pigeon.rs`](crates/wickra-core/src/indicators/homing_pigeon.rs) +- `IdenticalThreeCrows` — Identical Three Crows candlestick pattern · [`identical_three_crows.rs`](crates/wickra-core/src/indicators/identical_three_crows.rs) +- `InNeck` — In-Neck candlestick pattern · [`in_neck.rs`](crates/wickra-core/src/indicators/in_neck.rs) +- `InvertedHammer` — Inverted Hammer candlestick pattern · [`inverted_hammer.rs`](crates/wickra-core/src/indicators/inverted_hammer.rs) +- `Kicking` — Kicking candlestick pattern · [`kicking.rs`](crates/wickra-core/src/indicators/kicking.rs) +- `KickingByLength` — Kicking-by-Length candlestick pattern · [`kicking_by_length.rs`](crates/wickra-core/src/indicators/kicking_by_length.rs) +- `LadderBottom` — Ladder Bottom candlestick pattern · [`ladder_bottom.rs`](crates/wickra-core/src/indicators/ladder_bottom.rs) +- `LongLeggedDoji` — Long-Legged Doji candlestick pattern · [`long_legged_doji.rs`](crates/wickra-core/src/indicators/long_legged_doji.rs) +- `LongLine` — Long Line candlestick pattern · [`long_line.rs`](crates/wickra-core/src/indicators/long_line.rs) +- `Marubozu` — Marubozu candlestick pattern · [`marubozu.rs`](crates/wickra-core/src/indicators/marubozu.rs) +- `MatchingLow` — Matching Low candlestick pattern · [`matching_low.rs`](crates/wickra-core/src/indicators/matching_low.rs) +- `MatHold` — Mat Hold candlestick pattern · [`mat_hold.rs`](crates/wickra-core/src/indicators/mat_hold.rs) +- `MorningDojiStar` — Morning Doji Star candlestick pattern · [`morning_doji_star.rs`](crates/wickra-core/src/indicators/morning_doji_star.rs) +- `MorningEveningStar` — Morning Star / Evening Star candlestick pattern · [`morning_evening_star.rs`](crates/wickra-core/src/indicators/morning_evening_star.rs) +- `NewPriceLines` — New Price Lines — the "eight/ten new price lines" exhaustion count · [`new_price_lines.rs`](crates/wickra-core/src/indicators/new_price_lines.rs) +- `OnNeck` — On-Neck candlestick pattern · [`on_neck.rs`](crates/wickra-core/src/indicators/on_neck.rs) +- `OpeningMarubozu` — Opening Marubozu candlestick pattern · [`opening_marubozu.rs`](crates/wickra-core/src/indicators/opening_marubozu.rs) +- `PiercingDarkCloud` — Piercing Line / Dark Cloud Cover candlestick pattern · [`piercing_dark_cloud.rs`](crates/wickra-core/src/indicators/piercing_dark_cloud.rs) +- `RickshawMan` — Rickshaw Man candlestick pattern · [`rickshaw_man.rs`](crates/wickra-core/src/indicators/rickshaw_man.rs) +- `RisingThreeMethods` — Rising Three Methods candlestick pattern · [`rising_three_methods.rs`](crates/wickra-core/src/indicators/rising_three_methods.rs) +- `SeparatingLines` — Separating Lines candlestick pattern · [`separating_lines.rs`](crates/wickra-core/src/indicators/separating_lines.rs) +- `ShootingStar` — Shooting Star candlestick pattern · [`shooting_star.rs`](crates/wickra-core/src/indicators/shooting_star.rs) +- `ShortLine` — Short Line candlestick pattern · [`short_line.rs`](crates/wickra-core/src/indicators/short_line.rs) +- `SpinningTop` — Spinning Top candlestick pattern · [`spinning_top.rs`](crates/wickra-core/src/indicators/spinning_top.rs) +- `StalledPattern` — Stalled Pattern (Deliberation) candlestick pattern · [`stalled_pattern.rs`](crates/wickra-core/src/indicators/stalled_pattern.rs) +- `StickSandwich` — Stick Sandwich candlestick pattern · [`stick_sandwich.rs`](crates/wickra-core/src/indicators/stick_sandwich.rs) +- `Takuri` — Takuri candlestick pattern · [`takuri.rs`](crates/wickra-core/src/indicators/takuri.rs) +- `TasukiGap` — Tasuki Gap candlestick pattern · [`tasuki_gap.rs`](crates/wickra-core/src/indicators/tasuki_gap.rs) +- `ThreeInside` — Three Inside Up / Down candlestick pattern · [`three_inside.rs`](crates/wickra-core/src/indicators/three_inside.rs) +- `ThreeLineStrike` — Three Line Strike candlestick pattern · [`three_line_strike.rs`](crates/wickra-core/src/indicators/three_line_strike.rs) +- `ThreeOutside` — Three Outside Up / Down candlestick pattern · [`three_outside.rs`](crates/wickra-core/src/indicators/three_outside.rs) +- `ThreeSoldiersOrCrows` — Three White Soldiers / Three Black Crows candlestick pattern · [`three_soldiers_or_crows.rs`](crates/wickra-core/src/indicators/three_soldiers_or_crows.rs) +- `ThreeStarsInSouth` — Three Stars in the South candlestick pattern · [`three_stars_in_south.rs`](crates/wickra-core/src/indicators/three_stars_in_south.rs) +- `Thrusting` — Thrusting candlestick pattern · [`thrusting.rs`](crates/wickra-core/src/indicators/thrusting.rs) +- `TowerTopBottom` — Tower Top / Tower Bottom — a tall bar, a pause, then a tall opposite bar · [`tower_top_bottom.rs`](crates/wickra-core/src/indicators/tower_top_bottom.rs) +- `Tristar` — Tristar — a three-doji reversal pattern · [`tristar.rs`](crates/wickra-core/src/indicators/tristar.rs) +- `Tweezer` — Tweezer Top / Bottom candlestick pattern · [`tweezer.rs`](crates/wickra-core/src/indicators/tweezer.rs) +- `TwoCrows` — Two Crows candlestick pattern · [`two_crows.rs`](crates/wickra-core/src/indicators/two_crows.rs) +- `UniqueThreeRiver` — Unique Three River candlestick pattern · [`unique_three_river.rs`](crates/wickra-core/src/indicators/unique_three_river.rs) +- `UpsideGapThreeMethods` — Upside Gap Three Methods candlestick pattern · [`upside_gap_three_methods.rs`](crates/wickra-core/src/indicators/upside_gap_three_methods.rs) +- `UpsideGapTwoCrows` — Upside Gap Two Crows candlestick pattern · [`upside_gap_two_crows.rs`](crates/wickra-core/src/indicators/upside_gap_two_crows.rs) + +## Microstructure (20) + +- `AmihudIlliquidity` — Amihud Illiquidity — average price impact per unit traded value · [`amihud_illiquidity.rs`](crates/wickra-core/src/indicators/amihud_illiquidity.rs) +- `CumulativeVolumeDelta` — Cumulative Volume Delta — running sum of signed trade volume · [`cvd.rs`](crates/wickra-core/src/indicators/cvd.rs) +- `DepthSlope` — Depth Slope — how fast resting liquidity accumulates away from the mid · [`depth_slope.rs`](crates/wickra-core/src/indicators/depth_slope.rs) +- `EffectiveSpread` — Effective Spread — the realised cost of a single trade in basis points · [`effective_spread.rs`](crates/wickra-core/src/indicators/effective_spread.rs) +- `Footprint` — Footprint — buy/sell volume profile per price bucket within a bar · [`footprint.rs`](crates/wickra-core/src/indicators/footprint.rs) +- `HasbrouckInformationShare` — Hasbrouck Information Share — each venue's contribution to price discovery · [`hasbrouck_information_share.rs`](crates/wickra-core/src/indicators/hasbrouck_information_share.rs) +- `KylesLambda` — Kyle's Lambda — rolling price impact per unit of signed order flow · [`kyles_lambda.rs`](crates/wickra-core/src/indicators/kyles_lambda.rs) +- `Microprice` — Microprice — size-weighted fair value of the top of book · [`microprice.rs`](crates/wickra-core/src/indicators/microprice.rs) +- `OrderBookImbalanceFull` — Order-Book Imbalance over the full visible depth · [`ob_imbalance_full.rs`](crates/wickra-core/src/indicators/ob_imbalance_full.rs) +- `OrderBookImbalanceTop1` — Order-Book Imbalance at the top of book · [`ob_imbalance_top1.rs`](crates/wickra-core/src/indicators/ob_imbalance_top1.rs) +- `OrderBookImbalanceTopN` — Order-Book Imbalance over the top-N levels · [`ob_imbalance_topn.rs`](crates/wickra-core/src/indicators/ob_imbalance_topn.rs) +- `OrderFlowImbalance` — Order Flow Imbalance (OFI) from best-level order-book changes · [`order_flow_imbalance.rs`](crates/wickra-core/src/indicators/order_flow_imbalance.rs) +- `Pin` — PIN — Probability of Informed Trading (single-window EKOP estimate) · [`pin.rs`](crates/wickra-core/src/indicators/pin.rs) +- `QuotedSpread` — Quoted Spread — top-of-book spread in basis points · [`quoted_spread.rs`](crates/wickra-core/src/indicators/quoted_spread.rs) +- `RealizedSpread` — Realized Spread — the post-trade liquidity revenue of a trade in basis · [`realized_spread.rs`](crates/wickra-core/src/indicators/realized_spread.rs) +- `RollMeasure` — Roll Measure — effective spread implied by serial covariance of price changes · [`roll_measure.rs`](crates/wickra-core/src/indicators/roll_measure.rs) +- `SignedVolume` — Signed Volume — per-trade volume signed by aggressor side · [`signed_volume.rs`](crates/wickra-core/src/indicators/signed_volume.rs) +- `TradeImbalance` — Trade Imbalance — rolling buy/sell volume imbalance over a trade window · [`trade_imbalance.rs`](crates/wickra-core/src/indicators/trade_imbalance.rs) +- `TradeSignAutocorrelation` — Trade-Sign Autocorrelation — lag-1 persistence of the trade-aggressor side · [`trade_sign_autocorrelation.rs`](crates/wickra-core/src/indicators/trade_sign_autocorrelation.rs) +- `Vpin` — VPIN — Volume-Synchronised Probability of Informed Trading · [`vpin.rs`](crates/wickra-core/src/indicators/vpin.rs) + +## Derivatives (17) + +- `CalendarSpread` — Calendar Spread — the dated future's relative premium to the perpetual · [`calendar_spread.rs`](crates/wickra-core/src/indicators/calendar_spread.rs) +- `EstimatedLeverageRatio` — Estimated Leverage Ratio — open interest per unit of aggregate position size · [`estimated_leverage_ratio.rs`](crates/wickra-core/src/indicators/estimated_leverage_ratio.rs) +- `FundingBasis` — Funding Basis — the perpetual mark's relative premium to the spot index · [`funding_basis.rs`](crates/wickra-core/src/indicators/funding_basis.rs) +- `FundingImpliedApr` — Funding-Implied APR — the per-interval funding rate annualised · [`funding_implied_apr.rs`](crates/wickra-core/src/indicators/funding_implied_apr.rs) +- `FundingRate` — Funding Rate — the current perpetual funding rate · [`funding_rate.rs`](crates/wickra-core/src/indicators/funding_rate.rs) +- `FundingRateMean` — Funding Rate Rolling Mean — average funding rate over a trailing window · [`funding_rate_mean.rs`](crates/wickra-core/src/indicators/funding_rate_mean.rs) +- `FundingRateZScore` — Funding Rate Z-Score — how extreme the latest funding rate is versus its · [`funding_rate_zscore.rs`](crates/wickra-core/src/indicators/funding_rate_zscore.rs) +- `LiquidationFeatures` — Liquidation Features — per-tick long/short liquidation breakdown · [`liquidation_features.rs`](crates/wickra-core/src/indicators/liquidation_features.rs) +- `LongShortRatio` — Long/Short Ratio — aggregate long size relative to short size · [`long_short_ratio.rs`](crates/wickra-core/src/indicators/long_short_ratio.rs) +- `OIPriceDivergence` — Open-Interest / Price Divergence — relative OI change minus relative price · [`oi_price_divergence.rs`](crates/wickra-core/src/indicators/oi_price_divergence.rs) +- `OiToVolumeRatio` — OI-to-Volume Ratio — open interest relative to traded volume · [`oi_to_volume_ratio.rs`](crates/wickra-core/src/indicators/oi_to_volume_ratio.rs) +- `OIWeighted` — Open-Interest-Weighted Price — cumulative mark price weighted by open · [`oi_weighted.rs`](crates/wickra-core/src/indicators/oi_weighted.rs) +- `OpenInterestDelta` — Open-Interest Delta — the tick-over-tick change in open interest · [`oi_delta.rs`](crates/wickra-core/src/indicators/oi_delta.rs) +- `OpenInterestMomentum` — Open-Interest Momentum — the rate of change of open interest over a lookback · [`open_interest_momentum.rs`](crates/wickra-core/src/indicators/open_interest_momentum.rs) +- `PerpetualPremiumIndex` — Perpetual Premium Index — the perp mark price relative to spot · [`perpetual_premium_index.rs`](crates/wickra-core/src/indicators/perpetual_premium_index.rs) +- `TakerBuySellRatio` — Taker Buy/Sell Ratio — aggressive buy volume relative to aggressive sell · [`taker_buy_sell_ratio.rs`](crates/wickra-core/src/indicators/taker_buy_sell_ratio.rs) +- `TermStructureBasis` — Term-Structure Basis — the dated future's relative premium to spot · [`term_structure_basis.rs`](crates/wickra-core/src/indicators/term_structure_basis.rs) + +## Market Profile (10) + +- `CompositeProfile` — Composite Profile — POC and value area over a long composite window · [`composite_profile.rs`](crates/wickra-core/src/indicators/composite_profile.rs) +- `HighLowVolumeNodes` — High/Low Volume Nodes (HVN / LVN) — the busiest and quietest price levels · [`high_low_volume_nodes.rs`](crates/wickra-core/src/indicators/high_low_volume_nodes.rs) +- `InitialBalance` — Initial Balance (IB): the high / low established over the first N bars of · [`initial_balance.rs`](crates/wickra-core/src/indicators/initial_balance.rs) +- `NakedPoc` — Naked POC — the nearest prior-session point of control price has not yet revisited · [`naked_poc.rs`](crates/wickra-core/src/indicators/naked_poc.rs) +- `OpeningRange` — Opening Range (OR): high / low of the first N session bars plus the · [`opening_range.rs`](crates/wickra-core/src/indicators/opening_range.rs) +- `ProfileShape` — Profile Shape — classifies the volume profile as b-shape, P-shape, or D/normal · [`profile_shape.rs`](crates/wickra-core/src/indicators/profile_shape.rs) +- `SinglePrints` — Single Prints — count of price levels touched by exactly one bar (low acceptance) · [`single_prints.rs`](crates/wickra-core/src/indicators/single_prints.rs) +- `TpoProfile` — TPO Profile — the Time-Price-Opportunity (market-profile letter) distribution · [`tpo_profile.rs`](crates/wickra-core/src/indicators/tpo_profile.rs) +- `ValueArea` — Value Area (Point of Control + Value Area High / Low) · [`value_area.rs`](crates/wickra-core/src/indicators/value_area.rs) +- `VolumeProfile` — Volume Profile — the full per-bin volume distribution over a rolling window · [`volume_profile.rs`](crates/wickra-core/src/indicators/volume_profile.rs) + +## Risk / Performance (28) + +- `Alpha` — Rolling Jensen's Alpha (CAPM) · [`alpha.rs`](crates/wickra-core/src/indicators/alpha.rs) +- `AverageDrawdown` — Rolling Average Drawdown · [`average_drawdown.rs`](crates/wickra-core/src/indicators/average_drawdown.rs) +- `BurkeRatio` — Burke Ratio — mean return over the square root of the summed squared drawdowns · [`burke_ratio.rs`](crates/wickra-core/src/indicators/burke_ratio.rs) +- `CalmarRatio` — Rolling Calmar Ratio — return over max drawdown · [`calmar_ratio.rs`](crates/wickra-core/src/indicators/calmar_ratio.rs) +- `CommonSenseRatio` — Common Sense Ratio (Schwager / Carver) — profit factor multiplied by the tail ratio · [`common_sense_ratio.rs`](crates/wickra-core/src/indicators/common_sense_ratio.rs) +- `ConditionalValueAtRisk` — Rolling Conditional Value-at-Risk (`CVaR` / Expected Shortfall) · [`conditional_value_at_risk.rs`](crates/wickra-core/src/indicators/conditional_value_at_risk.rs) +- `DrawdownDuration` — Drawdown Duration — bars since the last all-time peak ("time under water") · [`drawdown_duration.rs`](crates/wickra-core/src/indicators/drawdown_duration.rs) +- `Expectancy` — Expectancy — expected return per unit of average loss (R-multiple) · [`expectancy.rs`](crates/wickra-core/src/indicators/expectancy.rs) +- `GainLossRatio` — Rolling Gain/Loss Ratio · [`gain_loss_ratio.rs`](crates/wickra-core/src/indicators/gain_loss_ratio.rs) +- `GainToPainRatio` — Gain-to-Pain Ratio (Schwager) — sum of returns over the sum of losses · [`gain_to_pain_ratio.rs`](crates/wickra-core/src/indicators/gain_to_pain_ratio.rs) +- `InformationRatio` — Rolling Information Ratio · [`information_ratio.rs`](crates/wickra-core/src/indicators/information_ratio.rs) +- `KellyCriterion` — Rolling Kelly Criterion · [`kelly_criterion.rs`](crates/wickra-core/src/indicators/kelly_criterion.rs) +- `KRatio` — K-Ratio (Kestner) — slope of the cumulative-return curve over the standard error of that slope · [`k_ratio.rs`](crates/wickra-core/src/indicators/k_ratio.rs) +- `M2Measure` — M² / Modigliani–Modigliani measure — Sharpe expressed in benchmark return units · [`m2_measure.rs`](crates/wickra-core/src/indicators/m2_measure.rs) +- `MartinRatio` — Martin Ratio (Ulcer Performance Index) — mean return over the Ulcer Index · [`martin_ratio.rs`](crates/wickra-core/src/indicators/martin_ratio.rs) +- `MaxDrawdown` — Maximum Drawdown over a rolling window · [`max_drawdown.rs`](crates/wickra-core/src/indicators/max_drawdown.rs) +- `OmegaRatio` — Rolling Omega Ratio — gain-to-loss ratio above a threshold · [`omega_ratio.rs`](crates/wickra-core/src/indicators/omega_ratio.rs) +- `PainIndex` — Rolling Pain Index — mean depth of drawdowns · [`pain_index.rs`](crates/wickra-core/src/indicators/pain_index.rs) +- `ProfitFactor` — Rolling Profit Factor · [`profit_factor.rs`](crates/wickra-core/src/indicators/profit_factor.rs) +- `RecoveryFactor` — Recovery Factor — cumulative net return over max drawdown · [`recovery_factor.rs`](crates/wickra-core/src/indicators/recovery_factor.rs) +- `SharpeRatio` — Rolling Sharpe Ratio · [`sharpe_ratio.rs`](crates/wickra-core/src/indicators/sharpe_ratio.rs) +- `SortinoRatio` — Rolling Sortino Ratio — Sharpe with downside-only volatility · [`sortino_ratio.rs`](crates/wickra-core/src/indicators/sortino_ratio.rs) +- `SterlingRatio` — Sterling Ratio — mean return over the average drawdown of the equity curve · [`sterling_ratio.rs`](crates/wickra-core/src/indicators/sterling_ratio.rs) +- `TailRatio` — Tail Ratio — the right tail (95th percentile) over the absolute left tail (5th percentile) · [`tail_ratio.rs`](crates/wickra-core/src/indicators/tail_ratio.rs) +- `TreynorRatio` — Rolling Treynor Ratio · [`treynor_ratio.rs`](crates/wickra-core/src/indicators/treynor_ratio.rs) +- `UpsidePotentialRatio` — Upside Potential Ratio (Sortino, van der Meer & Plantinga) — upside mean over downside deviation · [`upside_potential_ratio.rs`](crates/wickra-core/src/indicators/upside_potential_ratio.rs) +- `ValueAtRisk` — Rolling historical Value-at-Risk (`VaR`) · [`value_at_risk.rs`](crates/wickra-core/src/indicators/value_at_risk.rs) +- `WinRate` — Win Rate — the fraction of winning returns over a rolling window · [`win_rate.rs`](crates/wickra-core/src/indicators/win_rate.rs) + +## Alt-Chart Bars (10) + +- `DollarBars` — Dollar bar builder — close a bar each time accumulated traded value reaches a threshold · [`dollar_bars.rs`](crates/wickra-core/src/indicators/dollar_bars.rs) +- `ImbalanceBars` — Tick-imbalance bar builder (simplified López de Prado) — sample on cumulative signed order flow · [`imbalance_bars.rs`](crates/wickra-core/src/indicators/imbalance_bars.rs) +- `KagiBars` — Kagi bar builder — reversal-amount line segments on close prices · [`kagi_bars.rs`](crates/wickra-core/src/indicators/kagi_bars.rs) +- `PointAndFigureBars` — Point-and-Figure bar builder — box-size columns with an N-box reversal · [`point_and_figure_bars.rs`](crates/wickra-core/src/indicators/point_and_figure_bars.rs) +- `RangeBars` — Range bar builder — fixed price-range bars with no reversal penalty · [`range_bars.rs`](crates/wickra-core/src/indicators/range_bars.rs) +- `RenkoBars` — Renko bar builder — fixed box-size bricks with the classic reversal rule · [`renko_bars.rs`](crates/wickra-core/src/indicators/renko_bars.rs) +- `RunBars` — Run bar builder (simplified López de Prado) — sample on runs of same-signed ticks · [`run_bars.rs`](crates/wickra-core/src/indicators/run_bars.rs) +- `ThreeLineBreakBars` — Three-Line-Break bar builder — line-break chart segments driven by close prices · [`three_line_break_bars.rs`](crates/wickra-core/src/indicators/three_line_break_bars.rs) +- `TickBars` — Tick bar builder — aggregate a fixed number of candles into one OHLCV bar · [`tick_bars.rs`](crates/wickra-core/src/indicators/tick_bars.rs) +- `VolumeBars` — Volume bar builder — close a bar each time accumulated volume reaches a threshold · [`volume_bars.rs`](crates/wickra-core/src/indicators/volume_bars.rs) + +## Market Breadth (15) + +- `AbsoluteBreadthIndex` — Absolute Breadth Index — the magnitude of net advancing-minus-declining issues · [`absolute_breadth_index.rs`](crates/wickra-core/src/indicators/absolute_breadth_index.rs) +- `AdvanceDecline` — Advance/Decline Line — cumulative net advancing-minus-declining issues · [`advance_decline.rs`](crates/wickra-core/src/indicators/advance_decline.rs) +- `AdvanceDeclineRatio` — Advance/Decline Ratio — advancing issues divided by declining issues · [`advance_decline_ratio.rs`](crates/wickra-core/src/indicators/advance_decline_ratio.rs) +- `AdVolumeLine` — Advance/Decline Volume Line — cumulative net advancing-minus-declining volume · [`ad_volume_line.rs`](crates/wickra-core/src/indicators/ad_volume_line.rs) +- `BreadthThrust` — Breadth Thrust (Zweig) — a moving average of the advancing-issues share · [`breadth_thrust.rs`](crates/wickra-core/src/indicators/breadth_thrust.rs) +- `BullishPercentIndex` — Bullish Percent Index — share of a universe on a point-and-figure buy signal · [`bullish_percent_index.rs`](crates/wickra-core/src/indicators/bullish_percent_index.rs) +- `CumulativeVolumeIndex` — Cumulative Volume Index — running total of volume-normalised net advancing volume · [`cumulative_volume_index.rs`](crates/wickra-core/src/indicators/cumulative_volume_index.rs) +- `HighLowIndex` — High-Low Index — a moving average of the record-high percentage · [`high_low_index.rs`](crates/wickra-core/src/indicators/high_low_index.rs) +- `McClellanOscillator` — McClellan Oscillator — the spread between a fast and slow EMA of breadth · [`mcclellan_oscillator.rs`](crates/wickra-core/src/indicators/mcclellan_oscillator.rs) +- `McClellanSummationIndex` — McClellan Summation Index — the running total of the McClellan Oscillator · [`mcclellan_summation_index.rs`](crates/wickra-core/src/indicators/mcclellan_summation_index.rs) +- `NewHighsNewLows` — New Highs − New Lows — net count of fresh period extremes across a universe · [`new_highs_new_lows.rs`](crates/wickra-core/src/indicators/new_highs_new_lows.rs) +- `PercentAboveMa` — Percent Above Moving Average — share of a universe trading above its MA · [`percent_above_ma.rs`](crates/wickra-core/src/indicators/percent_above_ma.rs) +- `TickIndex` — TICK Index — instantaneous net advancing-minus-declining issues · [`tick_index.rs`](crates/wickra-core/src/indicators/tick_index.rs) +- `Trin` — TRIN / Arms Index — the advance-decline ratio over the up-down volume ratio · [`trin.rs`](crates/wickra-core/src/indicators/trin.rs) +- `UpDownVolumeRatio` — Up/Down Volume Ratio — advancing volume divided by declining volume · [`up_down_volume_ratio.rs`](crates/wickra-core/src/indicators/up_down_volume_ratio.rs) + +## Seasonality & Session (12) + +- `AverageDailyRange` — Average Daily Range (ADR) — the mean high-minus-low range of the last `period` · [`average_daily_range.rs`](crates/wickra-core/src/indicators/average_daily_range.rs) +- `DayOfWeekProfile` — Day-of-Week Profile — the mean bar return for each weekday · [`day_of_week_profile.rs`](crates/wickra-core/src/indicators/day_of_week_profile.rs) +- `IntradayVolatilityProfile` — Intraday Volatility Profile — the return volatility in each intraday bucket · [`intraday_volatility_profile.rs`](crates/wickra-core/src/indicators/intraday_volatility_profile.rs) +- `OvernightGap` — Overnight Gap — the return from the previous session's close to the current · [`overnight_gap.rs`](crates/wickra-core/src/indicators/overnight_gap.rs) +- `OvernightIntradayReturn` — Overnight vs. Intraday Return — decomposes a session's total return into its · [`overnight_intraday_return.rs`](crates/wickra-core/src/indicators/overnight_intraday_return.rs) +- `SeasonalZScore` — Seasonal Z-Score — how far the current bar's return sits from the historical · [`seasonal_z_score.rs`](crates/wickra-core/src/indicators/seasonal_z_score.rs) +- `SessionHighLow` — Session High/Low — the running high and low of the current calendar-day · [`session_high_low.rs`](crates/wickra-core/src/indicators/session_high_low.rs) +- `SessionRange` — Session Range — the high-minus-low range accumulated within each of the · [`session_range.rs`](crates/wickra-core/src/indicators/session_range.rs) +- `SessionVwap` — Session VWAP — the volume-weighted average price accumulated since the start · [`session_vwap.rs`](crates/wickra-core/src/indicators/session_vwap.rs) +- `TimeOfDayReturnProfile` — Time-of-Day Return Profile — the mean bar return in each intraday time bucket · [`time_of_day_return_profile.rs`](crates/wickra-core/src/indicators/time_of_day_return_profile.rs) +- `TurnOfMonth` — Turn-of-Month Effect — the mean daily return of sessions that fall inside the · [`turn_of_month.rs`](crates/wickra-core/src/indicators/turn_of_month.rs) +- `VolumeByTimeProfile` — Volume-by-Time Profile — the mean traded volume in each intraday bucket · [`volume_by_time_profile.rs`](crates/wickra-core/src/indicators/volume_by_time_profile.rs) + +## Chart Patterns (8) + +- `CupAndHandle` — Cup-and-Handle (and Inverse) continuation chart pattern · [`cup_and_handle.rs`](crates/wickra-core/src/indicators/cup_and_handle.rs) +- `DoubleTopBottom` — Double Top / Double Bottom reversal chart pattern · [`double_top_bottom.rs`](crates/wickra-core/src/indicators/double_top_bottom.rs) +- `FlagPennant` — Flag / Pennant continuation chart pattern · [`flag_pennant.rs`](crates/wickra-core/src/indicators/flag_pennant.rs) +- `HeadAndShoulders` — Head-and-Shoulders (and Inverse) reversal chart pattern · [`head_and_shoulders.rs`](crates/wickra-core/src/indicators/head_and_shoulders.rs) +- `RectangleRange` — Rectangle / Range chart pattern · [`rectangle_range.rs`](crates/wickra-core/src/indicators/rectangle_range.rs) +- `Triangle` — Triangle (ascending / descending / symmetrical) chart pattern · [`triangle.rs`](crates/wickra-core/src/indicators/triangle.rs) +- `TripleTopBottom` — Triple Top / Triple Bottom reversal chart pattern · [`triple_top_bottom.rs`](crates/wickra-core/src/indicators/triple_top_bottom.rs) +- `Wedge` — Wedge (rising / falling) reversal chart pattern · [`wedge.rs`](crates/wickra-core/src/indicators/wedge.rs) + +## Harmonic Patterns (8) + +- `Abcd` — AB=CD harmonic pattern · [`abcd.rs`](crates/wickra-core/src/indicators/abcd.rs) +- `Bat` — Bat harmonic pattern · [`bat.rs`](crates/wickra-core/src/indicators/bat.rs) +- `Butterfly` — Butterfly harmonic pattern · [`butterfly.rs`](crates/wickra-core/src/indicators/butterfly.rs) +- `Crab` — Crab harmonic pattern · [`crab.rs`](crates/wickra-core/src/indicators/crab.rs) +- `Cypher` — Cypher harmonic pattern · [`cypher.rs`](crates/wickra-core/src/indicators/cypher.rs) +- `Gartley` — Gartley harmonic pattern · [`gartley.rs`](crates/wickra-core/src/indicators/gartley.rs) +- `Shark` — Shark harmonic pattern · [`shark.rs`](crates/wickra-core/src/indicators/shark.rs) +- `ThreeDrives` — Three Drives harmonic pattern · [`three_drives.rs`](crates/wickra-core/src/indicators/three_drives.rs) + +## Fibonacci (10) + +- `AutoFib` — Auto-Fibonacci — retracement of the most significant recent swing leg · [`auto_fib.rs`](crates/wickra-core/src/indicators/auto_fib.rs) +- `FibArcs` — Fibonacci Arcs — semicircular retracement levels centred on the swing end, · [`fib_arcs.rs`](crates/wickra-core/src/indicators/fib_arcs.rs) +- `FibChannel` — Fibonacci Channel — a sloped base trendline plus parallel lines offset by · [`fib_channel.rs`](crates/wickra-core/src/indicators/fib_channel.rs) +- `FibConfluence` — Fibonacci Confluence — the strongest retracement cluster across recent legs · [`fib_confluence.rs`](crates/wickra-core/src/indicators/fib_confluence.rs) +- `FibExtension` — Fibonacci Extension of the most recent confirmed swing leg · [`fib_extension.rs`](crates/wickra-core/src/indicators/fib_extension.rs) +- `FibFan` — Fibonacci Fan — trendlines fanning from a swing start through the · [`fib_fan.rs`](crates/wickra-core/src/indicators/fib_fan.rs) +- `FibProjection` — Fibonacci Projection — a measured move from the last three swing pivots · [`fib_projection.rs`](crates/wickra-core/src/indicators/fib_projection.rs) +- `FibRetracement` — Fibonacci Retracement of the most recent confirmed swing leg · [`fib_retracement.rs`](crates/wickra-core/src/indicators/fib_retracement.rs) +- `FibTimeZones` — Fibonacci Time Zones — vertical markers at Fibonacci bar-distances from the · [`fib_time_zones.rs`](crates/wickra-core/src/indicators/fib_time_zones.rs) +- `GoldenPocket` — Golden Pocket — the 0.618-0.65 optimal-trade-entry zone of the last swing · [`golden_pocket.rs`](crates/wickra-core/src/indicators/golden_pocket.rs) + diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..83e0646 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 kingchenc and the Wickra contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..ab12f88 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kingchenc and the Wickra contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..83e0646 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 kingchenc and the Wickra contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..ab12f88 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kingchenc and the Wickra contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2049f85 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# Wickra + +Streaming-first technical indicators — Rust core, **514 indicators** across 24 +families. Each indicator is a state machine that updates in O(1) per new data +point, so live trading bots and historical backtests share the exact same +implementation. + +```rust +use wickra::{Indicator, Sma}; + +let mut sma = Sma::new(14)?; +let prices = [1.0, 2.0, 3.0, 4.0, 5.0]; +let out: Vec> = prices.iter().map(|p| sma.update(*p)).collect(); +assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]); +``` + +## Indicator catalog + +Every indicator lives in +[`crates/wickra-core/src/indicators/`](crates/wickra-core/src/indicators/) as +one self-contained `.rs` file. For a compact, family-grouped index of all 514 +indicators — public struct name, the file's own one-line intent, and a direct +link to the implementation — see [`INDICATORS.md`](INDICATORS.md). + +Regenerate the catalog after editing the indicator list: + +```bash +python scripts/gen_indicators_index.py +``` + +The catalog is the recommended entry point for an AI agent that needs to look +up what indicators exist and pick one to translate (for example into another +framework's indicator contract); the agent opens only the `.rs` files it +actually needs. + +## Indicator families + +The canonical taxonomy lives in the `FAMILIES` constant at +[`crates/wickra-core/src/indicators/mod.rs`](crates/wickra-core/src/indicators/mod.rs) +and is enforced by an `assert_eq!(total, 514)` test in the same file. + +| Family | Count | Examples | +|---|---:|---| +| Moving Averages | 26 | SMA, EMA, HMA, KAMA, ALMA, JMA, FRAMA, Holt-Winters | +| Momentum Oscillators | 34 | RSI, StochRSI, ConnorsRSI, Williams %R, MFI, CCI, QQE | +| Trend & Directional | 28 | MACD, ADX (+DI/-DI), Aroon, Vortex, Choppiness Index | +| Price Oscillators | 14 | PPO, DPO, Coppock, Zero-Lag MACD, STC | +| Volatility & Bands | 25 | ATR, Bollinger Bands, Keltner, Yang-Zhang, Garman-Klass | +| Bands & Channels | 16 | MA Envelope, STARC Bands, VWAP StdDev Bands, TTM Squeeze | +| Trailing Stops | 19 | Parabolic SAR, SuperTrend, Chandelier Exit, Kase DevStop | +| Volume | 26 | OBV, VWAP, CMF, Klinger VO, Anchored VWAP, Twiggs Money Flow | +| Price Statistics | 55 | Linear Regression, Hurst, Z-Score, Cointegration, GARCH, Kalman Hedge Ratio | +| Ehlers / Cycle (DSP) | 29 | MAMA, Hilbert Transform, Decycler, Roofing Filter, Even Better Sinewave | +| Pivots & S/R | 12 | Classic/Fibonacci/Camarilla/Woodie/DeMark pivots, ZigZag | +| DeMark | 19 | TD Sequential, TD Setup, TD Combo, TD Countdown | +| Ichimoku & Charts | 7 | Ichimoku Kinko Hyo, Heikin-Ashi, Three Line Break | +| Candlestick Patterns | 66 | Doji, Hammer, Engulfing, Marubozu, Harami, Tweezer, 60+ more | +| Microstructure | 20 | Order-Book Imbalance, Kyle's Lambda, VPIN, Roll Measure | +| Derivatives | 17 | Funding Rate, OI Delta, Long/Short Ratio, Liquidation Features | +| Market Profile | 10 | Value Area, Volume Profile, TPO Profile, Initial Balance | +| Risk / Performance | 28 | Sharpe, Sortino, Calmar, Omega, Max Drawdown, VaR, CVaR, Kelly | +| Alt-Chart Bars | 10 | Renko, Kagi, Point & Figure, Range/Tick/Volume/Dollar/Imbalance Bars | +| Market Breadth | 15 | A/D Line, McClellan Oscillator, TRIN, Breadth Thrust | +| Seasonality & Session | 12 | Session VWAP, Turn-of-Month, Day-of-Week Profile | +| Chart Patterns | 8 | Double/Triple Top-Bottom, H&S, Triangle, Wedge, Cup & Handle | +| Harmonic Patterns | 8 | Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives | +| Fibonacci | 10 | Retracement, Extension, Projection, Fan, Arcs, Channel, Time Zones | +| **Total** | **514** | | + +Every candlestick pattern emits a signed per-bar value — `+1.0` bullish, +`−1.0` bearish, `0.0` none — so the family drops straight into a feature matrix +as one column each. + +## Rust API + +```rust +use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma}; + +// Batch and streaming share the same trait. +let mut sma = Sma::new(14)?; +let out: Vec> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + +let mut rsi = Rsi::new(14)?; +for price in live_feed { + if let Some(v) = rsi.update(price) { + println!("RSI = {v}"); + } +} + +// Compose: RSI(7) on top of EMA(14). +let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?); +chain.update(price); +``` + +`wickra-core` is `unsafe`-forbidden. The `wickra` facade crate is what +`use wickra::...` resolves to; everything below also re-exports from +`wickra_core::*` if you want to depend on the core directly. + +## Live data + +`wickra-data` ships a complete, dependency-free data layer: + +- streaming OHLCV CSV reader (`CandleReader`) +- tick-to-candle aggregator with arbitrary timeframes (`TickAggregator`) +- candle resampler for multi-timeframe analysis (`Resampler`) +- live Binance Spot WebSocket kline feed (`BinanceFeed`, feature `live-binance`) +- historical Binance REST kline fetcher (`fetch_binance_klines`) — native HTTP + JSON + +```rust +use wickra::{Indicator, Rsi}; +use wickra_data::live::binance::{BinanceKlineStream, Interval}; + +let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?; +let mut rsi = Rsi::new(14)?; +while let Some(event) = stream.next_event().await? { + if event.is_closed { + if let Some(v) = rsi.update(event.candle.close) { + println!("RSI = {v:.2}"); + } + } +} +``` + +## Crates + +| Crate | Purpose | +|---|---| +| `wickra` | Top-level facade re-exporting `wickra-core`. What `use wickra::...` resolves to. | +| `wickra-core` | Core engine + all 514 indicators + the `Indicator` trait. | +| `wickra-data` | CSV reader, tick aggregator, resampler, Binance live + historical feeds. | +| `wickra-bench` | Internal cross-library benchmark harness (not published). | + +## Project layout + +``` +wickra/ +├── crates/ +│ ├── wickra/ facade crate +│ ├── wickra-core/ core engine + 514 indicators +│ ├── wickra-data/ CSV / aggregator / Binance live + REST +│ └── wickra-bench/ internal cross-library benchmark harness +├── docs/ in-repo docs (see docs/README.md) +├── LICENSES/ per-license texts +├── scripts/ +│ └── gen_indicators_index.py +├── Cargo.toml workspace manifest +├── Cargo.lock +├── INDICATORS.md auto-generated compact catalog +├── README.md +├── LICENSE-APACHE +└── LICENSE-MIT +``` + +## Building & testing + +```bash +# Build the whole workspace +cargo build --workspace + +# Run all tests (unit + the cross-indicator invariants harness) +cargo test --workspace + +# Lint as CI does +cargo clippy --workspace --all-targets -- -D warnings + +# Wickra's own regression benchmarks +cargo bench -p wickra +``` + +## Adding an indicator + +1. Implement the `Indicator` trait in + `crates/wickra-core/src/indicators/.rs`. +2. Wire it into `indicators/mod.rs` (the `mod ;` declaration and the + `FAMILIES` array — keeping the per-family count in sync). +3. Add reference-value tests, a `batch == streaming` equivalence test, and + (where it makes sense) a proptest. +4. Re-run `python scripts/gen_indicators_index.py` to refresh `INDICATORS.md`. + The script also enforces the 514-indicator invariant via the FAMILIES + total. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT license ([LICENSE-MIT](LICENSE-MIT)) + +at your option. + +## Disclaimer + +Wickra is an indicator toolkit, not a trading system. Values it computes are +deterministic transforms of the input data — they are not financial advice and +they do not predict the market. Any use of this library in a production +trading context is at your own risk. \ No newline at end of file diff --git a/crates/wickra-bench/Cargo.toml b/crates/wickra-bench/Cargo.toml new file mode 100644 index 0000000..13d6d58 --- /dev/null +++ b/crates/wickra-bench/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wickra-bench" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "Internal cross-library benchmark harness (not published)." + +[lints] +workspace = true + +[dev-dependencies] +wickra = { path = "../wickra" } +wickra-data = { path = "../wickra-data" } +criterion = { workspace = true } +kand = "0.2.2" +ta = "0.5.0" +yata = "0.7.0" + +[[bench]] +name = "cross_lib" +harness = false diff --git a/crates/wickra-bench/benches/cross_lib.rs b/crates/wickra-bench/benches/cross_lib.rs new file mode 100644 index 0000000..1eeb482 --- /dev/null +++ b/crates/wickra-bench/benches/cross_lib.rs @@ -0,0 +1,699 @@ +//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`. +//! +//! All four are pure-Rust technical-analysis crates, so this is a like-for-like +//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact +//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`. +//! +//! Two arenas, kept honest: +//! +//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`), +//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand` +//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually +//! here, seeded from `kand`'s own batch output (the seed is computed outside the +//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are +//! exposed through a heavier signal-oriented indicator API, not a raw-value method, +//! so they are intentionally left out rather than compared unfairly. +//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`) +//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API; +//! ta-rs and yata are streaming-only and are deliberately absent from this arena. +//! +//! Run: `cargo bench -p wickra-bench` + +// Each indicator's benchmark group spells out every library arm explicitly, which +// runs a few groups over the 100-line lint threshold; that verbosity is the point. +#![allow(clippy::too_many_lines)] + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::hint::black_box; +use wickra::{Atr, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma}; +use wickra_data::csv::CandleReader; +use yata::prelude::Method; + +const SIZES: &[usize] = &[1_000, 10_000, 50_000]; + +const SMA_PERIOD: usize = 20; +const EMA_PERIOD: usize = 20; +const RSI_PERIOD: usize = 14; +const ATR_PERIOD: usize = 14; +const BB_PERIOD: usize = 20; +const BB_DEV: f64 = 2.0; +const MACD_FAST: usize = 12; +const MACD_SLOW: usize = 26; +const MACD_SIGNAL: usize = 9; + +fn load_candles() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/data/btcusdt-1m.csv" + ); + CandleReader::open(path) + .expect("dataset present") + .read_all() + .expect("valid OHLCV rows") +} + +/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`. +fn window_mean(series: &[f64], period: usize) -> f64 { + series[..period].iter().sum::() / period as f64 +} + +fn sma_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("sma_20"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Sma::new(SMA_PERIOD).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Sma::new(SMA_PERIOD).unwrap(); + black_box(ind.batch_nan(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + let seed = window_mean(series, SMA_PERIOD); + bencher.iter(|| { + let mut prev = seed; + for idx in SMA_PERIOD..series.len() { + prev = kand::ohlcv::sma::sma_inc( + prev, + series[idx], + series[idx - SMA_PERIOD], + SMA_PERIOD, + ) + .unwrap(); + black_box(prev); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut out = vec![0.0; series.len()]; + kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap(); + black_box(&out); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("yata/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap(); + for price in series { + black_box(ind.next(price)); + } + }); + }, + ); + } + group.finish(); +} + +fn ema_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("ema_20"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Ema::new(EMA_PERIOD).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Ema::new(EMA_PERIOD).unwrap(); + black_box(ind.batch_nan(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + let seed = window_mean(series, EMA_PERIOD); + bencher.iter(|| { + let mut prev = seed; + for &price in &series[EMA_PERIOD..] { + prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap(); + black_box(prev); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut out = vec![0.0; series.len()]; + kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap(); + black_box(&out); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = + ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("yata/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap(); + for price in series { + black_box(ind.next(price)); + } + }); + }, + ); + } + group.finish(); +} + +fn rsi_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("rsi_14"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Rsi::new(RSI_PERIOD).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Rsi::new(RSI_PERIOD).unwrap(); + black_box(ind.batch_nan(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + // Wilder seed: simple average of the first `period` gains and losses. + let mut gain = 0.0; + let mut loss = 0.0; + for idx in 1..=RSI_PERIOD { + let delta = series[idx] - series[idx - 1]; + if delta > 0.0 { + gain += delta; + } else { + loss -= delta; + } + } + let seed_gain = gain / RSI_PERIOD as f64; + let seed_loss = loss / RSI_PERIOD as f64; + bencher.iter(|| { + let mut avg_gain = seed_gain; + let mut avg_loss = seed_loss; + let mut prev_price = series[RSI_PERIOD]; + for &price in &series[RSI_PERIOD + 1..] { + let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc( + price, prev_price, avg_gain, avg_loss, RSI_PERIOD, + ) + .unwrap(); + avg_gain = next_gain; + avg_loss = next_loss; + prev_price = price; + black_box(rsi); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut rsi = vec![0.0; series.len()]; + let mut avg_gain = vec![0.0; series.len()]; + let mut avg_loss = vec![0.0; series.len()]; + kand::ohlcv::rsi::rsi( + series, + RSI_PERIOD, + &mut rsi, + &mut avg_gain, + &mut avg_loss, + ) + .unwrap(); + black_box(&rsi); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + } + group.finish(); +} + +fn macd_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("macd_12_26_9"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = MacdIndicator::classic(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = MacdIndicator::classic(); + black_box(ind.batch_macd(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + // Seed the fast/slow/signal EMAs from kand's own warmed-up batch state. + let lookback = + kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap(); + let mut macd_line = vec![0.0; series.len()]; + let mut signal_line = vec![0.0; series.len()]; + let mut histogram = vec![0.0; series.len()]; + let mut fast_ema = vec![0.0; series.len()]; + let mut slow_ema = vec![0.0; series.len()]; + kand::ohlcv::macd::macd( + series, + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + &mut macd_line, + &mut signal_line, + &mut histogram, + &mut fast_ema, + &mut slow_ema, + ) + .unwrap(); + let seed_fast = fast_ema[lookback]; + let seed_slow = slow_ema[lookback]; + let seed_signal = signal_line[lookback]; + bencher.iter(|| { + // macd_inc returns (macd, signal, hist) but not the new EMAs, so the + // fast/slow/signal state is threaded with kand's own ema_inc primitive. + let mut prev_fast = seed_fast; + let mut prev_slow = seed_slow; + let mut prev_signal = seed_signal; + for &price in &series[lookback + 1..] { + let fast = + kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap(); + let slow = + kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap(); + let macd = fast - slow; + let signal = + kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None) + .unwrap(); + prev_fast = fast; + prev_slow = slow; + prev_signal = signal; + black_box((macd, signal, macd - signal)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut macd_line = vec![0.0; series.len()]; + let mut signal_line = vec![0.0; series.len()]; + let mut histogram = vec![0.0; series.len()]; + let mut fast_ema = vec![0.0; series.len()]; + let mut slow_ema = vec![0.0; series.len()]; + kand::ohlcv::macd::macd( + series, + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + &mut macd_line, + &mut signal_line, + &mut histogram, + &mut fast_ema, + &mut slow_ema, + ) + .unwrap(); + black_box(&macd_line); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new( + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + ) + .unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + } + group.finish(); +} + +fn bbands_group(crit: &mut Criterion, closes: &[f64]) { + let mut group = crit.benchmark_group("bollinger_20_2"); + for &len in SIZES { + let len = len.min(closes.len()); + let series: &[f64] = &closes[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap(); + for &price in series { + black_box(ind.update(price)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap(); + black_box(ind.batch_bands(series)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + // Seed running sma/sum/sum_sq from kand's batch state at the warmup edge. + let mut upper = vec![0.0; series.len()]; + let mut middle = vec![0.0; series.len()]; + let mut lower = vec![0.0; series.len()]; + let mut sma = vec![0.0; series.len()]; + let mut variance = vec![0.0; series.len()]; + let mut sum = vec![0.0; series.len()]; + let mut sum_sq = vec![0.0; series.len()]; + kand::ohlcv::bbands::bbands( + series, + BB_PERIOD, + BB_DEV, + BB_DEV, + &mut upper, + &mut middle, + &mut lower, + &mut sma, + &mut variance, + &mut sum, + &mut sum_sq, + ) + .unwrap(); + let seed_sma = sma[BB_PERIOD - 1]; + let seed_sum = sum[BB_PERIOD - 1]; + let seed_sum_sq = sum_sq[BB_PERIOD - 1]; + bencher.iter(|| { + let mut prev_sma = seed_sma; + let mut prev_sum = seed_sum; + let mut prev_sum_sq = seed_sum_sq; + for idx in BB_PERIOD..series.len() { + let result = kand::ohlcv::bbands::bbands_inc( + series[idx], + prev_sma, + prev_sum, + prev_sum_sq, + series[idx - BB_PERIOD], + BB_PERIOD, + BB_DEV, + BB_DEV, + ) + .unwrap(); + prev_sma = result.1; + prev_sum = result.4; + prev_sum_sq = result.5; + black_box((result.0, result.1, result.2)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut upper = vec![0.0; series.len()]; + let mut middle = vec![0.0; series.len()]; + let mut lower = vec![0.0; series.len()]; + let mut sma = vec![0.0; series.len()]; + let mut variance = vec![0.0; series.len()]; + let mut sum = vec![0.0; series.len()]; + let mut sum_sq = vec![0.0; series.len()]; + kand::ohlcv::bbands::bbands( + series, + BB_PERIOD, + BB_DEV, + BB_DEV, + &mut upper, + &mut middle, + &mut lower, + &mut sma, + &mut variance, + &mut sum, + &mut sum_sq, + ) + .unwrap(); + black_box(&upper); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap(); + for &price in series { + black_box(ta::Next::next(&mut ind, price)); + } + }); + }, + ); + } + group.finish(); +} + +fn atr_group(crit: &mut Criterion, candles: &[Candle]) { + let mut group = crit.benchmark_group("atr_14"); + for &len in SIZES { + let len = len.min(candles.len()); + let series: &[Candle] = &candles[..len]; + group.throughput(Throughput::Elements(len as u64)); + + group.bench_with_input( + BenchmarkId::new("wickra/stream", len), + &series, + |bencher, &series| { + bencher.iter(|| { + let mut ind = Atr::new(ATR_PERIOD).unwrap(); + for &candle in series { + black_box(ind.update(candle)); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("wickra/batch", len), + &series, + |bencher, &series| { + // Column extraction is outside the timed loop, mirroring kand's arm. + let high: Vec = series.iter().map(|candle| candle.high).collect(); + let low: Vec = series.iter().map(|candle| candle.low).collect(); + let close: Vec = series.iter().map(|candle| candle.close).collect(); + bencher.iter(|| { + let mut ind = Atr::new(ATR_PERIOD).unwrap(); + black_box(ind.batch_atr(&high, &low, &close)); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/stream", len), + &series, + |bencher, &series| { + let high: Vec = series.iter().map(|candle| candle.high).collect(); + let low: Vec = series.iter().map(|candle| candle.low).collect(); + let close: Vec = series.iter().map(|candle| candle.close).collect(); + // Seed prev_atr from kand's batch ATR at the first valid index (= period). + let mut atr_out = vec![0.0; series.len()]; + kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap(); + let seed_atr = atr_out[ATR_PERIOD]; + bencher.iter(|| { + let mut prev_atr = seed_atr; + for idx in ATR_PERIOD + 1..series.len() { + prev_atr = kand::ohlcv::atr::atr_inc( + high[idx], + low[idx], + close[idx - 1], + prev_atr, + ATR_PERIOD, + ) + .unwrap(); + black_box(prev_atr); + } + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("kand/batch", len), + &series, + |bencher, &series| { + let high: Vec = series.iter().map(|candle| candle.high).collect(); + let low: Vec = series.iter().map(|candle| candle.low).collect(); + let close: Vec = series.iter().map(|candle| candle.close).collect(); + bencher.iter(|| { + let mut atr_out = vec![0.0; series.len()]; + kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap(); + black_box(&atr_out); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("ta-rs/stream", len), + &series, + |bencher, &series| { + let items: Vec = series + .iter() + .map(|candle| { + ta::DataItem::builder() + .open(candle.open) + .high(candle.high) + .low(candle.low) + .close(candle.close) + .volume(candle.volume) + .build() + .unwrap() + }) + .collect(); + bencher.iter(|| { + let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap(); + for item in &items { + black_box(ta::Next::next(&mut ind, item)); + } + }); + }, + ); + } + group.finish(); +} + +fn benches(crit: &mut Criterion) { + let candles = load_candles(); + let closes: Vec = candles.iter().map(|candle| candle.close).collect(); + sma_group(crit, &closes); + ema_group(crit, &closes); + rsi_group(crit, &closes); + macd_group(crit, &closes); + bbands_group(crit, &closes); + atr_group(crit, &candles); +} + +criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches); +criterion_main!(cross_lib); diff --git a/crates/wickra-bench/src/lib.rs b/crates/wickra-bench/src/lib.rs new file mode 100644 index 0000000..02b392f --- /dev/null +++ b/crates/wickra-bench/src/lib.rs @@ -0,0 +1,6 @@ +//! Internal cross-library benchmark harness for Wickra. +//! +//! This crate is `publish = false`. It exists only to host the Criterion +//! benchmark in `benches/cross_lib.rs`, which compares Wickra against the +//! Rust technical-analysis crates `kand`, `ta` (ta-rs) and `yata` on an +//! identical candle series. It deliberately carries no library code. diff --git a/crates/wickra-core/Cargo.toml b/crates/wickra-core/Cargo.toml new file mode 100644 index 0000000..c470387 --- /dev/null +++ b/crates/wickra-core/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "wickra-core" +description = "Core streaming-first technical indicators engine for the Wickra library" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +documentation = "https://docs.rs/wickra-core" + +# Render the docs on docs.rs with every feature enabled so the parallel +# (rayon-backed) batch APIs are documented. +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } +rayon = { workspace = true, optional = true } + +[features] +default = ["parallel"] +parallel = ["dep:rayon"] + +[dev-dependencies] +approx = { workspace = true } +proptest = { workspace = true } diff --git a/crates/wickra-core/src/calendar.rs b/crates/wickra-core/src/calendar.rs new file mode 100644 index 0000000..fbd4ea8 --- /dev/null +++ b/crates/wickra-core/src/calendar.rs @@ -0,0 +1,203 @@ +//! Pure calendar arithmetic for the timestamp-driven seasonality indicators. +//! +//! Every indicator in the *Seasonality & Session* family keys off the wall-clock +//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted +//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the +//! relevant exchange session rather than UTC. This module turns an epoch +//! millisecond instant into its civil fields using Howard Hinnant's +//! branch-light `civil_from_days` algorithm (the same one libc++ ships). +//! +//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before +//! the Unix epoch decompose correctly without a dedicated negative-input branch. + +/// Civil (wall-clock) decomposition of an epoch-millisecond instant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CivilTime { + /// Proleptic Gregorian year (can be negative for instants before year 1). + pub(crate) year: i64, + /// Month of year, `1..=12`. + pub(crate) month: u32, + /// Day of month, `1..=31`. + pub(crate) day: u32, + /// Hour of day, `0..=23`. + pub(crate) hour: u32, + /// Minute of hour, `0..=59`. + pub(crate) minute: u32, + /// Day of week with Monday as `0` through Sunday as `6`. + pub(crate) weekday: u32, +} + +impl CivilTime { + /// Minute of day, `0..=1439`. + pub(crate) const fn minute_of_day(&self) -> u32 { + self.hour * 60 + self.minute + } +} + +/// Decompose an epoch-millisecond instant into local civil fields. +/// +/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields +/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc. +pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime { + let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60; + let days = local_secs.div_euclid(86_400); + let secs_of_day = local_secs.rem_euclid(86_400); + let hour = (secs_of_day / 3600) as u32; + let minute = ((secs_of_day % 3600) / 60) as u32; + let (year, month, day) = civil_from_days(days); + // 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`. + let weekday = (days + 3).rem_euclid(7) as u32; + CivilTime { + year, + month, + day, + hour, + minute, + weekday, + } +} + +/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01. +/// +/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms". +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + (if month <= 2 { year + 1 } else { year }, month, day) +} + +/// Whether `year` is a Gregorian leap year. +pub(crate) const fn is_leap(year: i64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +/// Number of days in `month` (`1..=12`) of `year`. +pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + _ => { + if is_leap(year) { + 29 + } else { + 28 + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epoch_zero_is_thursday_midnight() { + let t = civil_from_timestamp(0, 0); + assert_eq!( + t, + CivilTime { + year: 1970, + month: 1, + day: 1, + hour: 0, + minute: 0, + weekday: 3, // Thursday + } + ); + assert_eq!(t.minute_of_day(), 0); + } + + #[test] + fn known_utc_instant_mid_year() { + // 2021-06-15 13:45:00 UTC = 1623764700 s. + let t = civil_from_timestamp(1_623_764_700_000, 0); + assert_eq!(t.year, 2021); + assert_eq!(t.month, 6); + assert_eq!(t.day, 15); + assert_eq!(t.hour, 13); + assert_eq!(t.minute, 45); + assert_eq!(t.weekday, 1); // Tuesday + assert_eq!(t.minute_of_day(), 13 * 60 + 45); + } + + #[test] + fn new_year_2021_is_friday() { + // 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump. + let t = civil_from_timestamp(1_609_459_200_000, 0); + assert_eq!((t.year, t.month, t.day), (2021, 1, 1)); + assert_eq!(t.weekday, 4); // Friday + } + + #[test] + fn positive_offset_rolls_to_next_day() { + // 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local. + let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000; + let t = civil_from_timestamp(base, 60); + assert_eq!((t.year, t.month, t.day), (2021, 1, 2)); + assert_eq!((t.hour, t.minute), (0, 30)); + assert_eq!(t.weekday, 5); // Saturday + } + + #[test] + fn negative_offset_rolls_to_previous_day() { + // 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local. + let base = 1_609_459_200_000 + 30 * 60 * 1000; + let t = civil_from_timestamp(base, -60); + assert_eq!((t.year, t.month, t.day), (2020, 12, 31)); + assert_eq!((t.hour, t.minute), (23, 30)); + assert_eq!(t.weekday, 3); // Thursday + } + + #[test] + fn sub_epoch_millis_floor_correctly() { + // -1 ms -> 1969-12-31 23:59:59.999, a Wednesday. + let t = civil_from_timestamp(-1, 0); + assert_eq!((t.year, t.month, t.day), (1969, 12, 31)); + assert_eq!((t.hour, t.minute), (23, 59)); + assert_eq!(t.weekday, 2); // Wednesday + } + + #[test] + fn far_negative_day_count_hits_pre_era_branch() { + // A day count below -719468 drives `z + 719468` negative, exercising the + // `z - 146096` era branch in civil_from_days (year < 1). + let (year, month, day) = civil_from_days(-1_000_000); + // -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic + // Gregorian, astronomical year numbering where year 0 exists). + assert_eq!((year, month, day), (-768, 2, 4)); + } + + #[test] + fn leap_year_rules() { + assert!(is_leap(2000)); + assert!(!is_leap(1900)); + assert!(is_leap(2024)); + assert!(!is_leap(2023)); + } + + #[test] + fn days_in_month_all_cases() { + assert_eq!(days_in_month(2023, 1), 31); + assert_eq!(days_in_month(2023, 4), 30); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 12), 31); + assert_eq!(days_in_month(2023, 11), 30); + } + + #[test] + fn leap_day_decodes() { + // 2024-02-29 12:00 UTC. + let secs = 1_709_208_000; // 2024-02-29T12:00:00Z + let t = civil_from_timestamp(secs * 1000, 0); + assert_eq!((t.year, t.month, t.day), (2024, 2, 29)); + assert_eq!(t.hour, 12); + } +} diff --git a/crates/wickra-core/src/cross_section.rs b/crates/wickra-core/src/cross_section.rs new file mode 100644 index 0000000..d26b223 --- /dev/null +++ b/crates/wickra-core/src/cross_section.rs @@ -0,0 +1,387 @@ +//! Cross-section value type: a market-breadth snapshot across a whole universe. +//! +//! A [`CrossSection`] is a single tick that carries the per-symbol state of +//! *every* symbol in a universe at one point in time. It is the non-OHLCV input +//! consumed by the market-breadth indicator family (advance/decline, `McClellan`, +//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the +//! whole cross-section into a single breadth reading. This is the same +//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`]. +//! +//! Each [`Member`] precomputes the per-symbol signals the breadth indicators +//! need — a signed price `change` (whose sign classifies the symbol as +//! advancing, declining or unchanged), the period `volume`, the +//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal` +//! state flags — so the indicators stay stateless per tick and never have to +//! track per-symbol history. +//! +//! [`DerivativesTick`]: crate::DerivativesTick +//! [`OrderBook`]: crate::OrderBook + +use crate::error::{Error, Result}; + +/// One symbol's contribution to a [`CrossSection`] tick. +/// +/// Field invariants enforced by [`CrossSection::new`] when the member is placed +/// into a tick: +/// +/// - `change` is finite (its sign classifies the symbol — positive is +/// advancing, negative is declining, zero is unchanged). +/// - `volume` is finite and non-negative. +/// +/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol +/// printed a new period extreme; `above_ma` / `on_buy_signal` are caller-supplied +/// per-symbol state signals (whether the symbol trades above its reference moving +/// average, and whether it is on a point-and-figure buy signal). None of the four +/// flags carries a numeric invariant. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq)] +#[allow( + clippy::struct_excessive_bools, + reason = "the four flags are independent per-symbol breadth signals, not a state machine" +)] +pub struct Member { + /// Price change versus the previous close. Sign classifies the symbol: + /// positive is advancing, negative is declining, zero is unchanged. + pub change: f64, + /// Period volume for the symbol (finite, non-negative). + pub volume: f64, + /// Whether the symbol printed a new period high. + pub new_high: bool, + /// Whether the symbol printed a new period low. + pub new_low: bool, + /// Whether the symbol is trading above its reference moving average + /// (consumed by the `% Above Moving Average` breadth indicator). + pub above_ma: bool, + /// Whether the symbol is on a point-and-figure buy signal + /// (consumed by the `Bullish Percent Index` breadth indicator). + pub on_buy_signal: bool, +} + +impl Member { + /// Assemble a cross-section member from its core signals, leaving the + /// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared. + /// + /// The field invariants documented on [`Member`] are validated centrally by + /// [`CrossSection::new`] when the member is placed into a tick; this + /// constructor only assembles the value so the `#[non_exhaustive]` struct can + /// be built from outside the crate. + #[must_use] + pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self { + Self { + change, + volume, + new_high, + new_low, + above_ma: false, + on_buy_signal: false, + } + } + + /// Assemble a cross-section member including the extended per-symbol state + /// signals `above_ma` and `on_buy_signal`. + /// + /// Use this constructor for the breadth indicators that read per-symbol + /// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new) + /// is the shorthand that leaves both flags `false`. + #[must_use] + #[allow( + clippy::fn_params_excessive_bools, + reason = "mirrors the four independent per-symbol flag fields of Member" + )] + pub const fn with_signals( + change: f64, + volume: f64, + new_high: bool, + new_low: bool, + above_ma: bool, + on_buy_signal: bool, + ) -> Self { + Self { + change, + volume, + new_high, + new_low, + above_ma, + on_buy_signal, + } + } +} + +/// A market-breadth cross-section: the per-symbol state of an entire universe at +/// a single point in time. +/// +/// Invariants enforced by [`new`](CrossSection::new): +/// +/// - `members` is non-empty (a breadth reading needs at least one symbol). +/// - every member's `change` is finite, and `volume` is finite and non-negative. +/// +/// `timestamp` is a caller-defined epoch / resolution and is not validated. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct CrossSection { + /// Per-symbol members of the universe for this tick. + pub members: Vec, + /// Tick timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl CrossSection { + /// Construct a cross-section, validating every member invariant. + /// + /// # Errors + /// + /// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any + /// member has a non-finite `change`, or if any member has a `volume` that is + /// not a finite non-negative number. + pub fn new(members: Vec, timestamp: i64) -> Result { + if members.is_empty() { + return Err(Error::InvalidCrossSection { + message: "cross-section must contain at least one member", + }); + } + for member in &members { + if !member.change.is_finite() { + return Err(Error::InvalidCrossSection { + message: "member change must be finite", + }); + } + if !member.volume.is_finite() || member.volume < 0.0 { + return Err(Error::InvalidCrossSection { + message: "member volume must be finite and non-negative", + }); + } + } + Ok(Self { members, timestamp }) + } + + /// Construct a cross-section without validation. The caller asserts that + /// every invariant documented on [`CrossSection`] holds. + #[must_use] + pub const fn new_unchecked(members: Vec, timestamp: i64) -> Self { + Self { members, timestamp } + } + + /// Number of advancing symbols (those with a strictly positive `change`). + #[must_use] + pub fn advancers(&self) -> usize { + self.members.iter().filter(|m| m.change > 0.0).count() + } + + /// Number of declining symbols (those with a strictly negative `change`). + #[must_use] + pub fn decliners(&self) -> usize { + self.members.iter().filter(|m| m.change < 0.0).count() + } + + /// Total volume traded by advancing symbols (those with positive `change`). + #[must_use] + pub fn advancing_volume(&self) -> f64 { + self.members + .iter() + .filter(|m| m.change > 0.0) + .map(|m| m.volume) + .sum() + } + + /// Total volume traded by declining symbols (those with negative `change`). + #[must_use] + pub fn declining_volume(&self) -> f64 { + self.members + .iter() + .filter(|m| m.change < 0.0) + .map(|m| m.volume) + .sum() + } + + /// Total volume traded across the whole universe. + #[must_use] + pub fn total_volume(&self) -> f64 { + self.members.iter().map(|m| m.volume).sum() + } + + /// Number of symbols that printed a new period high. + #[must_use] + pub fn new_highs(&self) -> usize { + self.members.iter().filter(|m| m.new_high).count() + } + + /// Number of symbols that printed a new period low. + #[must_use] + pub fn new_lows(&self) -> usize { + self.members.iter().filter(|m| m.new_low).count() + } + + /// Number of symbols trading above their reference moving average. + #[must_use] + pub fn above_ma_count(&self) -> usize { + self.members.iter().filter(|m| m.above_ma).count() + } + + /// Number of symbols on a point-and-figure buy signal. + #[must_use] + pub fn on_buy_signal_count(&self) -> usize { + self.members.iter().filter(|m| m.on_buy_signal).count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn members() -> Vec { + vec![ + Member::new(1.5, 100.0, true, false), + Member::new(-0.5, 50.0, false, true), + Member::new(0.0, 0.0, false, false), + ] + } + + #[test] + fn new_accepts_valid() { + let cs = CrossSection::new(members(), 42).unwrap(); + assert_eq!(cs.members.len(), 3); + assert_eq!(cs.timestamp, 42); + assert_eq!(cs.members[0].change, 1.5); + assert_eq!(cs.members[0].volume, 100.0); + assert!(cs.members[0].new_high); + assert!(cs.members[1].new_low); + } + + #[test] + fn member_new_assembles_fields() { + let m = Member::new(2.0, 10.0, true, false); + assert_eq!(m.change, 2.0); + assert_eq!(m.volume, 10.0); + assert!(m.new_high); + assert!(!m.new_low); + } + + #[test] + fn new_rejects_empty() { + assert!(matches!( + CrossSection::new(Vec::new(), 0), + Err(Error::InvalidCrossSection { .. }) + )); + } + + #[test] + fn new_rejects_non_finite_change() { + assert!(matches!( + CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0), + Err(Error::InvalidCrossSection { .. }) + )); + assert!(matches!( + CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0), + Err(Error::InvalidCrossSection { .. }) + )); + } + + #[test] + fn new_rejects_negative_volume() { + assert!(matches!( + CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0), + Err(Error::InvalidCrossSection { .. }) + )); + } + + #[test] + fn new_rejects_non_finite_volume() { + assert!(matches!( + CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0), + Err(Error::InvalidCrossSection { .. }) + )); + } + + #[test] + fn new_unchecked_skips_validation() { + let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7); + assert_eq!(cs.members.len(), 1); + assert_eq!(cs.timestamp, 7); + } + + #[test] + fn advancers_and_decliners_count_by_sign() { + let cs = CrossSection::new(members(), 0).unwrap(); + assert_eq!(cs.advancers(), 1); + assert_eq!(cs.decliners(), 1); + } + + #[test] + fn unchanged_members_count_as_neither() { + let cs = CrossSection::new( + vec![ + Member::new(0.0, 1.0, false, false), + Member::new(0.0, 1.0, false, false), + ], + 0, + ) + .unwrap(); + assert_eq!(cs.advancers(), 0); + assert_eq!(cs.decliners(), 0); + } + + #[test] + fn new_leaves_extended_flags_cleared() { + let m = Member::new(1.0, 10.0, true, false); + assert!(!m.above_ma); + assert!(!m.on_buy_signal); + } + + #[test] + fn with_signals_assembles_all_fields() { + let m = Member::with_signals(2.0, 10.0, true, false, true, true); + assert_eq!(m.change, 2.0); + assert_eq!(m.volume, 10.0); + assert!(m.new_high); + assert!(!m.new_low); + assert!(m.above_ma); + assert!(m.on_buy_signal); + } + + #[test] + fn volume_helpers_bucket_by_change_sign() { + let cs = CrossSection::new( + vec![ + Member::new(1.5, 100.0, false, false), // advancing + Member::new(2.0, 40.0, false, false), // advancing + Member::new(-0.5, 50.0, false, false), // declining + Member::new(0.0, 7.0, false, false), // unchanged + ], + 0, + ) + .unwrap(); + assert_eq!(cs.advancing_volume(), 140.0); + assert_eq!(cs.declining_volume(), 50.0); + assert_eq!(cs.total_volume(), 197.0); + } + + #[test] + fn high_low_helpers_count_flags() { + let cs = CrossSection::new( + vec![ + Member::new(1.0, 1.0, true, false), + Member::new(1.0, 1.0, true, false), + Member::new(-1.0, 1.0, false, true), + ], + 0, + ) + .unwrap(); + assert_eq!(cs.new_highs(), 2); + assert_eq!(cs.new_lows(), 1); + } + + #[test] + fn state_helpers_count_extended_flags() { + let cs = CrossSection::new( + vec![ + Member::with_signals(1.0, 1.0, false, false, true, true), + Member::with_signals(1.0, 1.0, false, false, true, false), + Member::with_signals(-1.0, 1.0, false, false, false, true), + ], + 0, + ) + .unwrap(); + assert_eq!(cs.above_ma_count(), 2); + assert_eq!(cs.on_buy_signal_count(), 2); + } +} diff --git a/crates/wickra-core/src/derivatives.rs b/crates/wickra-core/src/derivatives.rs new file mode 100644 index 0000000..ef290ab --- /dev/null +++ b/crates/wickra-core/src/derivatives.rs @@ -0,0 +1,321 @@ +//! Derivatives value type: the perpetual / futures tick. +//! +//! [`DerivativesTick`] is the non-OHLCV input consumed by the derivatives / +//! perpetual-futures indicator family. A single tick bundles the funding, +//! price, open-interest, positioning, taker-flow and liquidation fields a +//! perp/futures venue publishes per update; each indicator reads only the +//! subset it needs (the same one-rich-type-per-family pattern as [`Trade`] / +//! [`OrderBook`] in [`crate::microstructure`]). +//! +//! [`Trade`]: crate::microstructure::Trade +//! [`OrderBook`]: crate::microstructure::OrderBook + +use crate::error::{Error, Result}; + +/// A single derivatives / perpetual-futures market tick. +/// +/// Field invariants enforced by [`new`](DerivativesTick::new): +/// +/// - `funding_rate` is finite and **may be negative** (a negative funding rate +/// means shorts pay longs). +/// - `mark_price`, `index_price` and `futures_price` are finite and strictly +/// positive. +/// - `open_interest`, `long_size`, `short_size`, `taker_buy_volume`, +/// `taker_sell_volume`, `long_liquidation` and `short_liquidation` are finite +/// and non-negative. +/// +/// `timestamp` is a caller-defined epoch / resolution and is not validated. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DerivativesTick { + /// Current funding rate for the interval (finite; may be negative). + pub funding_rate: f64, + /// Perpetual mark price (finite, strictly positive). + pub mark_price: f64, + /// Spot / index price the perpetual tracks (finite, strictly positive). + pub index_price: f64, + /// Dated (e.g. quarterly) futures mark price (finite, strictly positive). + pub futures_price: f64, + /// Open interest — outstanding contracts / notional (finite, non-negative). + pub open_interest: f64, + /// Aggregate long size / long account count (finite, non-negative). + pub long_size: f64, + /// Aggregate short size / short account count (finite, non-negative). + pub short_size: f64, + /// Taker buy (ask-lifting) volume (finite, non-negative). + pub taker_buy_volume: f64, + /// Taker sell (bid-hitting) volume (finite, non-negative). + pub taker_sell_volume: f64, + /// Long-side liquidation notional (finite, non-negative). + pub long_liquidation: f64, + /// Short-side liquidation notional (finite, non-negative). + pub short_liquidation: f64, + /// Tick timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl DerivativesTick { + /// Construct a derivatives tick, validating every field invariant. + /// + /// # Errors + /// + /// Returns [`Error::InvalidDerivatives`] if `funding_rate` is not finite; + /// any of `mark_price`, `index_price`, `futures_price` is not a finite + /// positive number; or any of the six size / volume / liquidation fields is + /// not a finite non-negative number. + #[allow(clippy::too_many_arguments)] + pub fn new( + funding_rate: f64, + mark_price: f64, + index_price: f64, + futures_price: f64, + open_interest: f64, + long_size: f64, + short_size: f64, + taker_buy_volume: f64, + taker_sell_volume: f64, + long_liquidation: f64, + short_liquidation: f64, + timestamp: i64, + ) -> Result { + if !funding_rate.is_finite() { + return Err(Error::InvalidDerivatives { + message: "funding_rate must be finite", + }); + } + for price in [mark_price, index_price, futures_price] { + if !price.is_finite() || price <= 0.0 { + return Err(Error::InvalidDerivatives { + message: + "mark_price, index_price and futures_price must be finite and positive", + }); + } + } + for amount in [ + open_interest, + long_size, + short_size, + taker_buy_volume, + taker_sell_volume, + long_liquidation, + short_liquidation, + ] { + if !amount.is_finite() || amount < 0.0 { + return Err(Error::InvalidDerivatives { + message: "open interest, sizes, volumes and liquidations must be finite and non-negative", + }); + } + } + Ok(Self { + funding_rate, + mark_price, + index_price, + futures_price, + open_interest, + long_size, + short_size, + taker_buy_volume, + taker_sell_volume, + long_liquidation, + short_liquidation, + timestamp, + }) + } + + /// Construct a derivatives tick without validation. The caller asserts that + /// every field invariant documented on [`DerivativesTick`] holds. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub const fn new_unchecked( + funding_rate: f64, + mark_price: f64, + index_price: f64, + futures_price: f64, + open_interest: f64, + long_size: f64, + short_size: f64, + taker_buy_volume: f64, + taker_sell_volume: f64, + long_liquidation: f64, + short_liquidation: f64, + timestamp: i64, + ) -> Self { + Self { + funding_rate, + mark_price, + index_price, + futures_price, + open_interest, + long_size, + short_size, + taker_buy_volume, + taker_sell_volume, + long_liquidation, + short_liquidation, + timestamp, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fully valid tick used as a baseline; individual tests override one + /// field to exercise a single reject branch. + fn valid() -> DerivativesTick { + DerivativesTick::new( + 0.0001, 100.0, 99.5, 100.5, 1_000.0, 600.0, 400.0, 50.0, 40.0, 5.0, 3.0, 42, + ) + .unwrap() + } + + #[test] + fn new_accepts_valid() { + let tick = valid(); + assert_eq!(tick.funding_rate, 0.0001); + assert_eq!(tick.mark_price, 100.0); + assert_eq!(tick.index_price, 99.5); + assert_eq!(tick.futures_price, 100.5); + assert_eq!(tick.open_interest, 1_000.0); + assert_eq!(tick.long_size, 600.0); + assert_eq!(tick.short_size, 400.0); + assert_eq!(tick.taker_buy_volume, 50.0); + assert_eq!(tick.taker_sell_volume, 40.0); + assert_eq!(tick.long_liquidation, 5.0); + assert_eq!(tick.short_liquidation, 3.0); + assert_eq!(tick.timestamp, 42); + } + + #[test] + fn new_accepts_negative_funding_and_zero_amounts() { + let tick = DerivativesTick::new( + -0.0005, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + .unwrap(); + assert_eq!(tick.funding_rate, -0.0005); + assert_eq!(tick.open_interest, 0.0); + } + + #[test] + fn new_rejects_non_finite_funding() { + assert!(matches!( + DerivativesTick::new( + f64::NAN, + 100.0, + 100.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + assert!(matches!( + DerivativesTick::new( + f64::INFINITY, + 100.0, + 100.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_positive_mark() { + assert!(matches!( + DerivativesTick::new(0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_positive_index() { + assert!(matches!( + DerivativesTick::new(0.0, 100.0, -1.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_finite_futures() { + assert!(matches!( + DerivativesTick::new( + 0.0, + 100.0, + 100.0, + f64::NAN, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_negative_open_interest() { + assert!(matches!( + DerivativesTick::new(0.0, 100.0, 100.0, 100.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_non_finite_size() { + assert!(matches!( + DerivativesTick::new( + 0.0, + 100.0, + 100.0, + 100.0, + 0.0, + f64::INFINITY, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0 + ), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_rejects_negative_liquidation() { + assert!(matches!( + DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -2.0, 0), + Err(Error::InvalidDerivatives { .. }) + )); + } + + #[test] + fn new_unchecked_preserves_fields() { + let tick = DerivativesTick::new_unchecked( + -1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, 7, + ); + assert_eq!(tick.funding_rate, -1.0); + assert_eq!(tick.mark_price, -2.0); + assert_eq!(tick.short_liquidation, -11.0); + assert_eq!(tick.timestamp, 7); + } +} diff --git a/crates/wickra-core/src/error.rs b/crates/wickra-core/src/error.rs new file mode 100644 index 0000000..1abe5c0 --- /dev/null +++ b/crates/wickra-core/src/error.rs @@ -0,0 +1,73 @@ +//! Error types used across `wickra-core`. + +use thiserror::Error; + +/// Errors that can occur when constructing or operating on an indicator. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum Error { + /// A period (window length) must be at least one. + #[error("period must be greater than zero")] + PeriodZero, + + /// A specific minimum period requirement was not met (e.g. MACD needs slow > fast). + #[error("invalid period: {message}")] + InvalidPeriod { message: &'static str }, + + /// A non-finite value (NaN or infinity) was passed where a finite price was expected. + #[error("input value must be finite (got NaN or infinity)")] + NonFiniteInput, + + /// A candle whose components do not form a valid bar (e.g. high < low) was provided. + #[error("invalid candle: {message}")] + InvalidCandle { message: &'static str }, + + /// A tick whose components do not satisfy the tick invariants (e.g. negative + /// volume) was provided. Ticks are a different concept from candles and + /// surface as their own variant so consumers of a tick-stream pipeline + /// can match on a semantically-correct error instead of `InvalidCandle`. + #[error("invalid tick: {message}")] + InvalidTick { message: &'static str }, + + /// A multiplier or factor must be strictly positive. + #[error("multiplier must be greater than zero")] + NonPositiveMultiplier, + + /// An order-book snapshot whose levels do not satisfy the book invariants + /// (e.g. a crossed book, non-finite price, negative size, or mis-sorted + /// levels) was provided. Order books are a microstructure input distinct + /// from candles and ticks, so they surface as their own variant. + #[error("invalid order book: {message}")] + InvalidOrderBook { message: &'static str }, + + /// A trade whose components do not satisfy the trade invariants (e.g. + /// non-finite price or negative size) was provided. + #[error("invalid trade: {message}")] + InvalidTrade { message: &'static str }, + + /// A derivatives tick whose components do not satisfy the tick invariants + /// (e.g. a non-positive price, a non-finite funding rate, or a negative + /// size/volume/liquidation) was provided. Derivatives ticks (funding / + /// open-interest / liquidation feeds) are a perpetual-futures input + /// distinct from candles, order books and trades, so they surface as their + /// own variant. + #[error("invalid derivatives tick: {message}")] + InvalidDerivatives { message: &'static str }, + + /// A market-breadth cross-section whose members do not satisfy the + /// cross-section invariants (an empty universe, a non-finite change, or a + /// negative / non-finite volume) was provided. A cross-section is a + /// breadth input distinct from candles, ticks, order books and trades, so + /// it surfaces as its own variant. + #[error("invalid cross-section: {message}")] + InvalidCrossSection { message: &'static str }, + + /// A real-valued configuration parameter was outside its admissible range + /// (e.g. a non-positive standard-deviation multiplier, or a Kalman filter + /// covariance that is not strictly positive). This is the floating-point + /// analogue of [`Error::InvalidPeriod`], which only covers integer windows. + #[error("invalid parameter: {message}")] + InvalidParameter { message: &'static str }, +} + +/// Convenience alias for `Result`. +pub type Result = core::result::Result; diff --git a/crates/wickra-core/src/indicators/abandoned_baby.rs b/crates/wickra-core/src/indicators/abandoned_baby.rs new file mode 100644 index 0000000..a750b71 --- /dev/null +++ b/crates/wickra-core/src/indicators/abandoned_baby.rs @@ -0,0 +1,247 @@ +//! Abandoned Baby candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Abandoned Baby — a strong 3-bar reversal where a doji is "abandoned" by price +/// gaps on both sides, isolating it from the candles before and after. +/// +/// ```text +/// tol = tolerance * max(|bar2.open|, |bar2.close|) +/// bar2 doji (|bar2.close − bar2.open| <= tol) +/// +/// bullish (+1.0): bar1 red, bar2 gaps fully below bar1 (bar2.high < bar1.low), +/// bar3 green and gaps fully above bar2 (bar3.low > bar2.high) +/// bearish (−1.0): bar1 green, bar2 gaps fully above bar1 (bar2.low > bar1.high), +/// bar3 red and gaps fully below bar2 (bar3.high < bar2.low) +/// ``` +/// +/// Output is `0.0` otherwise. The first two bars always return `0.0` because the +/// three-bar window is not yet filled. `tolerance` defaults to `0.001` (10 bps +/// relative) and bounds how flat the middle candle must be to count as a doji; it +/// must lie in `[0, 1)`. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AbandonedBaby, Candle, Indicator}; +/// +/// let mut indicator = AbandonedBaby::new(); +/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(16.0, 18.1, 15.9, 18.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct AbandonedBaby { + tolerance: f64, + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl Default for AbandonedBaby { + fn default() -> Self { + Self::new() + } +} + +impl AbandonedBaby { + /// Construct a detector with the default relative doji tolerance (1e-3). + pub const fn new() -> Self { + Self { + tolerance: 0.001, + prev: None, + prev_prev: None, + has_emitted: false, + } + } + + /// Construct a detector with a custom relative doji tolerance. + /// + /// `tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(tolerance: f64) -> Result { + if !(0.0..1.0).contains(&tolerance) { + return Err(Error::InvalidPeriod { + message: "abandoned baby tolerance must lie in [0, 1)", + }); + } + Ok(Self { + tolerance, + prev: None, + prev_prev: None, + has_emitted: false, + }) + } + + /// Configured relative doji tolerance. + pub fn tolerance(&self) -> f64 { + self.tolerance + } +} + +impl Indicator for AbandonedBaby { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (pp, p) else { + return Some(0.0); + }; + let tol = self.tolerance * bar2.open.abs().max(bar2.close.abs()); + let bar2_is_doji = (bar2.close - bar2.open).abs() <= tol; + if !bar2_is_doji { + return Some(0.0); + } + // Bullish: red bar1, doji gaps below, green bar3 gaps above. + if bar1.close < bar1.open + && bar2.high < bar1.low + && candle.close > candle.open + && candle.low > bar2.high + { + return Some(1.0); + } + // Bearish: green bar1, doji gaps above, red bar3 gaps below. + if bar1.close > bar1.open + && bar2.low > bar1.high + && candle.close < candle.open + && candle.high < bar2.low + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AbandonedBaby" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(AbandonedBaby::with_tolerance(-0.01).is_err()); + assert!(AbandonedBaby::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = AbandonedBaby::with_tolerance(0.0).unwrap(); + assert!((t.tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = AbandonedBaby::default(); + assert_eq!(t.name(), "AbandonedBaby"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + assert!((t.tolerance() - 0.001).abs() < 1e-12); + } + + #[test] + fn bullish_abandoned_baby_is_plus_one() { + let mut t = AbandonedBaby::new(); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0)); + assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(1.0)); + } + + #[test] + fn bearish_abandoned_baby_is_minus_one() { + let mut t = AbandonedBaby::new(); + assert_eq!(t.update(c(15.0, 20.1, 14.9, 20.0, 0)), Some(0.0)); + assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(0.0)); + assert_eq!(t.update(c(19.0, 19.1, 16.9, 17.0, 2)), Some(-1.0)); + } + + #[test] + fn middle_not_doji_yields_zero() { + let mut t = AbandonedBaby::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + // Middle bar has a wide body -> not a doji. + assert_eq!(t.update(c(13.0, 14.0, 11.0, 11.5, 1)), Some(0.0)); + assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = AbandonedBaby::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + // Doji overlaps bar1's range -> no gap. + assert_eq!(t.update(c(15.0, 15.1, 14.9, 15.0, 1)), Some(0.0)); + assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = AbandonedBaby::new(); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (i as f64 * 0.3).sin() * 5.0; + c(base, base + 1.0, base - 1.0, base + 0.5, i) + }) + .collect(); + let mut a = AbandonedBaby::new(); + let mut b = AbandonedBaby::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = AbandonedBaby::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(13.0, 13.1, 12.9, 13.0, 1)); + t.update(c(16.0, 18.1, 15.9, 18.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/abcd.rs b/crates/wickra-core/src/indicators/abcd.rs new file mode 100644 index 0000000..63db16f --- /dev/null +++ b/crates/wickra-core/src/indicators/abcd.rs @@ -0,0 +1,154 @@ +//! AB=CD harmonic pattern. + +use crate::indicators::pattern_swing::{approx_equal, ratios_in, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// AB=CD — the simplest four-point harmonic pattern: an A→B leg, a B→C +/// retracement, and a C→D leg that mirrors A→B in length: +/// +/// ```text +/// BC / AB ∈ [0.382, 0.886] (C retraces AB) +/// CD / BC ∈ [1.13, 2.618] (D extends BC) +/// AB ≈ CD (within 10%) (the two legs are equal — the defining symmetry) +/// ``` +/// +/// Read from the last four confirmed pivots `A-B-C-D`. Output is `+1.0` +/// (bullish, D a swing low), `-1.0` (bearish, D a swing high), or `0.0`; never +/// `None`. See `crates/wickra-core/src/indicators/abcd.rs`. +#[derive(Debug, Clone)] +pub struct Abcd { + swing: SwingTracker, + has_emitted: bool, +} + +impl Abcd { + /// Construct a new AB=CD detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for Abcd { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Abcd { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let len = pivots.len(); + let pa = pivots[len - 4]; + let pb = pivots[len - 3]; + let pc = pivots[len - 2]; + let pd = pivots[len - 1]; + let ab = (pb.price - pa.price).abs(); + let bc = (pc.price - pb.price).abs(); + let cd = (pd.price - pc.price).abs(); + let ratios_ok = ratios_in(&[(bc / ab, 0.382, 0.886), (cd / bc, 1.13, 2.618)]); + let legs_equal = approx_equal(ab, cd, 0.10); + if ratios_ok && legs_equal { + return Some(if pd.direction < 0.0 { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Abcd" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Abcd::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Abcd::new(); + assert_eq!(indicator.name(), "Abcd"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!Abcd::default().is_ready()); + } + + #[test] + fn bullish_abcd_is_plus_one() { + // AB = 40 down, BC = 24.7 up (0.618), CD = 40 down → AB = CD. + let out = run(&[140.0, 100.0, 124.7, 84.7]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_abcd_is_minus_one() { + let out = run(&[150.0, 100.0, 140.0, 115.3, 155.3]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn unequal_legs_do_not_trigger() { + // CD (82) far longer than AB (40) → not an AB=CD. + let out = run(&[150.0, 100.0, 140.0, 118.0, 200.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Abcd::new(); + for c in candles_for_pivots(&[140.0, 100.0, 124.7]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[140.0, 100.0, 124.7, 84.7]); + let mut a = Abcd::new(); + let mut b = Abcd::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/absolute_breadth_index.rs b/crates/wickra-core/src/indicators/absolute_breadth_index.rs new file mode 100644 index 0000000..08020d0 --- /dev/null +++ b/crates/wickra-core/src/indicators/absolute_breadth_index.rs @@ -0,0 +1,144 @@ +//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues, +/// `|advancers - decliners|`. +/// +/// The ABI ignores the *direction* of breadth and measures only its *magnitude*: +/// a high reading means the universe moved decisively one way or the other (high +/// internal activity / volatility), while a low reading means advances and +/// declines were nearly balanced (a quiet, directionless market). It is sometimes +/// called a "market thermometer" because elevated readings often cluster around +/// turning points. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member}; +/// +/// let mut abi = AbsoluteBreadthIndex::new(); +/// // 2 advancers, 5 decliners -> |2 - 5| = 3. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(abi.update(tick), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AbsoluteBreadthIndex { + has_emitted: bool, +} + +impl AbsoluteBreadthIndex { + /// Construct a new Absolute Breadth Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for AbsoluteBreadthIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancers() as f64 - section.decliners() as f64; + self.has_emitted = true; + Some(net.abs()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AbsoluteBreadthIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let abi = AbsoluteBreadthIndex::new(); + assert_eq!(abi.name(), "AbsoluteBreadthIndex"); + assert_eq!(abi.warmup_period(), 1); + assert!(!abi.is_ready()); + } + + #[test] + fn magnitude_ignores_direction() { + let mut abi = AbsoluteBreadthIndex::new(); + assert_eq!(abi.update(section(2, 5)), Some(3.0)); + // Same magnitude with the direction reversed. + let mut abi2 = AbsoluteBreadthIndex::new(); + assert_eq!(abi2.update(section(5, 2)), Some(3.0)); + } + + #[test] + fn balanced_universe_yields_zero() { + let mut abi = AbsoluteBreadthIndex::new(); + assert_eq!(abi.update(section(3, 3)), Some(0.0)); + assert!(abi.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut abi = AbsoluteBreadthIndex::new(); + abi.update(section(2, 5)); + assert!(abi.is_ready()); + abi.reset(); + assert!(!abi.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(2, 5), section(5, 2), section(3, 3)]; + let mut a = AbsoluteBreadthIndex::new(); + let mut b = AbsoluteBreadthIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/acceleration_bands.rs b/crates/wickra-core/src/indicators/acceleration_bands.rs new file mode 100644 index 0000000..db61a60 --- /dev/null +++ b/crates/wickra-core/src/indicators/acceleration_bands.rs @@ -0,0 +1,277 @@ +//! Acceleration Bands (Price Headley). + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Acceleration Bands output: SMA of close with momentum-biased envelopes +/// driven by the bar's high/low geometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AccelerationBandsOutput { + /// Upper band: SMA of `high · (1 + factor · (high − low) / (high + low))`. + pub upper: f64, + /// Middle band: SMA of close. + pub middle: f64, + /// Lower band: SMA of `low · (1 − factor · (high − low) / (high + low))`. + pub lower: f64, +} + +/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each +/// bar's relative range `(high − low) / (high + low)`. +/// +/// ```text +/// ratio = (high − low) / (high + low) +/// raw_up = high · (1 + factor · ratio) +/// raw_lo = low · (1 − factor · ratio) +/// upper = SMA(raw_up, period) +/// middle = SMA(close, period) +/// lower = SMA(raw_lo, period) +/// ``` +/// +/// Headley's reference parameters are `period = 20`, `factor = 0.001` for +/// intraday equity markets — the geometric `ratio` term tends to scale on +/// fractional moves, so the literal `factor` is small. The bands compress in +/// quiet markets and flare on impulsive bars, making them a momentum-biased +/// alternative to the volatility-driven Bollinger or Keltner envelopes. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AccelerationBands, Candle, Indicator}; +/// +/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AccelerationBands { + upper_sma: Sma, + middle_sma: Sma, + lower_sma: Sma, + factor: f64, + period: usize, +} + +impl AccelerationBands { + /// Construct a new Acceleration Bands indicator. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive + /// and finite. + pub fn new(period: usize, factor: f64) -> Result { + if !factor.is_finite() || factor <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + upper_sma: Sma::new(period)?, + middle_sma: Sma::new(period)?, + lower_sma: Sma::new(period)?, + factor, + period, + }) + } + + /// Headley's classic configuration: `period = 20`, `factor = 0.001`. + pub fn classic() -> Self { + Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid") + } + + /// Configured `(period, factor)`. + pub const fn parameters(&self) -> (usize, f64) { + (self.period, self.factor) + } +} + +impl Indicator for AccelerationBands { + type Input = Candle; + type Output = AccelerationBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + // (high + low) == 0 is geometrically impossible for valid OHLC + // (high >= low and a zero-sum requires both equal to 0, which would + // make the bar degenerate). Guard anyway so a hypothetical zero-price + // bar collapses the ratio to zero rather than emitting NaN. + let sum_hl = candle.high + candle.low; + let ratio = if sum_hl == 0.0 { + 0.0 + } else { + (candle.high - candle.low) / sum_hl + }; + let raw_up = candle.high * self.factor.mul_add(ratio, 1.0); + let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0); + + // Feed all three SMAs unconditionally so they warm up in lock-step. + let upper = self.upper_sma.update(raw_up); + let middle = self.middle_sma.update(candle.close); + let lower = self.lower_sma.update(raw_lo); + let (upper, middle, lower) = (upper?, middle?, lower?); + Some(AccelerationBandsOutput { + upper, + middle, + lower, + }) + } + + fn reset(&mut self) { + self.upper_sma.reset(); + self.middle_sma.reset(); + self.lower_sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.middle_sma.is_ready() + } + + fn name(&self) -> &'static str { + "AccelerationBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + AccelerationBands::new(0, 0.001), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_positive_factor() { + assert!(matches!( + AccelerationBands::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AccelerationBands::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AccelerationBands::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let ab = AccelerationBands::classic(); + let (p, f) = ab.parameters(); + assert_eq!(p, 20); + assert_relative_eq!(f, 0.001, epsilon = 1e-12); + assert_eq!(ab.warmup_period(), 20); + assert_eq!(ab.name(), "AccelerationBands"); + } + + #[test] + fn flat_market_collapses_to_constant() { + // high == low so the ratio term is zero; all three SMAs converge to + // the same constant. + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut ab = AccelerationBands::new(5, 0.5).unwrap(); + let last = ab.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn warmup_returns_none() { + let mut ab = AccelerationBands::new(5, 0.001).unwrap(); + for i in 0..4 { + let base = 100.0 + f64::from(i); + assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none()); + } + assert!(ab.update(c(105.0, 103.0, 104.0)).is_some()); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut ab = AccelerationBands::new(20, 0.5).unwrap(); + for o in ab.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle); + assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = AccelerationBands::new(10, 0.5).unwrap(); + let mut b = AccelerationBands::new(10, 0.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..10) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut ab = AccelerationBands::new(5, 0.5).unwrap(); + ab.batch(&candles); + assert!(ab.is_ready()); + ab.reset(); + assert!(!ab.is_ready()); + assert_eq!(ab.update(candles[0]), None); + } + + #[test] + fn zero_price_candle_collapses_ratio_to_zero() { + // `high + low == 0` is geometrically only reachable with a fully-zero + // bar (high >= low and both non-negative for a real market, but + // `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The + // ratio guard must fire and the bands all collapse to zero. + let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap(); + let mut ab = AccelerationBands::new(1, 0.5).unwrap(); + let v = ab.update(zero).unwrap(); + assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12); + } + + /// Hand-computed reference. Single bar with `high = 12`, `low = 8`, + /// `close = 10`, `factor = 0.5`, `period = 1`. + /// `ratio = (12 − 8) / (12 + 8) = 0.2` + /// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2` + /// `raw_lo = 8 · (1 − 0.5 · 0.2) = 8 · 0.9 = 7.2` + /// `middle = SMA(close, 1) = 10` + #[test] + fn reference_value_single_bar() { + let mut ab = AccelerationBands::new(1, 0.5).unwrap(); + let v = ab.update(c(12.0, 8.0, 10.0)).unwrap(); + assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12); + assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/accelerator_oscillator.rs b/crates/wickra-core/src/indicators/accelerator_oscillator.rs new file mode 100644 index 0000000..2acc66e --- /dev/null +++ b/crates/wickra-core/src/indicators/accelerator_oscillator.rs @@ -0,0 +1,204 @@ +//! Accelerator Oscillator (Bill Williams). + +use crate::error::Result; +use crate::indicators::awesome_oscillator::AwesomeOscillator; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*. +/// +/// ```text +/// AO = SMA(median, fast) − SMA(median, slow) (the Awesome Oscillator) +/// AC = AO − SMA(AO, signal) +/// ``` +/// +/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum, +/// the Accelerator tracks the *change* in momentum: it is the AO minus a short +/// moving average of itself. Because acceleration leads speed, `AC` tends to +/// turn before the `AO` does. Bill Williams' classic configuration is the +/// `(5, 34)` AO with a `5`-period signal average. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AcceleratorOscillator}; +/// +/// let mut indicator = AcceleratorOscillator::classic(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AcceleratorOscillator { + ao: AwesomeOscillator, + signal: Sma, + ao_fast: usize, + ao_slow: usize, + signal_period: usize, +} + +impl AcceleratorOscillator { + /// Construct an Accelerator Oscillator with explicit AO and signal periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero + /// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the + /// AO `fast` period is not strictly below `slow`. + pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result { + Ok(Self { + ao: AwesomeOscillator::new(ao_fast, ao_slow)?, + signal: Sma::new(signal_period)?, + ao_fast, + ao_slow, + signal_period, + }) + } + + /// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal. + pub fn classic() -> Self { + Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid") + } + + /// Configured `(ao_fast, ao_slow, signal_period)`. + pub const fn params(&self) -> (usize, usize, usize) { + (self.ao_fast, self.ao_slow, self.signal_period) + } +} + +impl Indicator for AcceleratorOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let ao = self.ao.update(candle)?; + let signal = self.signal.update(ao)?; + Some(ao - signal) + } + + fn reset(&mut self) { + self.ao.reset(); + self.signal.reset(); + } + + fn warmup_period(&self) -> usize { + // The AO emits at candle `ao_slow`; the signal SMA then needs + // `signal_period` AO values. + self.ao_slow + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.signal.is_ready() + } + + fn name(&self) -> &'static str { + "AcceleratorOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn constant_series_yields_zero() { + // A flat market gives AO = 0, so its signal average and AC are 0 too. + let candles: Vec = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ac = AcceleratorOscillator::classic(); + for v in ac.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn matches_independent_ao_and_signal() { + let candles: Vec = (0..90) + .map(|i| { + let m = 100.0 + (i as f64 * 0.2).sin() * 6.0; + c(m + 1.5, m - 1.5, m + 0.3, i) + }) + .collect(); + let mut ac = AcceleratorOscillator::classic(); + let mut ao = AwesomeOscillator::classic(); + let mut signal = Sma::new(5).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = ac.update(*candle); + match ao.update(*candle) { + Some(ao_val) => match signal.update(ao_val) { + Some(sig) => { + assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9); + } + None => assert!(got.is_none(), "i={i}"), + }, + None => assert!(got.is_none(), "i={i}"), + } + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ac = AcceleratorOscillator::classic(); + let out = ac.batch(&candles); + assert_eq!(ac.warmup_period(), 38); + for (i, v) in out.iter().enumerate().take(37) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[37].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(AcceleratorOscillator::new(0, 34, 5).is_err()); + assert!(AcceleratorOscillator::new(5, 34, 0).is_err()); + assert!(AcceleratorOscillator::new(34, 5, 5).is_err()); + } + + /// Cover the const accessor `params` (69-71) and the Indicator-impl + /// `name` body (99-101). Existing tests inspect numeric output but + /// never query the metadata. + #[test] + fn accessors_and_metadata() { + let ac = AcceleratorOscillator::classic(); + assert_eq!(ac.params(), (5, 34, 5)); + assert_eq!(ac.name(), "AcceleratorOscillator"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ac = AcceleratorOscillator::classic(); + ac.batch(&candles); + assert!(ac.is_ready()); + ac.reset(); + assert!(!ac.is_ready()); + assert_eq!(ac.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..90) + .map(|i| { + let m = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(m + 1.5, m - 1.5, m + 0.5, i) + }) + .collect(); + let mut a = AcceleratorOscillator::classic(); + let mut b = AcceleratorOscillator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/ad_oscillator.rs b/crates/wickra-core/src/indicators/ad_oscillator.rs new file mode 100644 index 0000000..9823bf6 --- /dev/null +++ b/crates/wickra-core/src/indicators/ad_oscillator.rs @@ -0,0 +1,243 @@ +//! Williams A/D Oscillator (ADOSC). + +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Smoothing window applied to the Williams A/D line to form the oscillator. +const SIGNAL_PERIOD: usize = 13; + +/// Williams **A/D Oscillator** — the volume-free Williams Accumulation/ +/// Distribution line measured against its own moving average, so it oscillates +/// around zero instead of drifting like the cumulative line. +/// +/// The underlying line is Larry Williams' volume-less A/D (1972), which uses a +/// *true* high/low anchored on the prior close; the oscillator subtracts its +/// 13-bar simple moving average: +/// +/// ```text +/// TR_h_t = max(close_{t−1}, high_t) +/// TR_l_t = min(close_{t−1}, low_t) +/// WAD_t = WAD_{t−1} + (close_t − TR_l_t) if close_t > close_{t−1} +/// WAD_t = WAD_{t−1} + (close_t − TR_h_t) if close_t < close_{t−1} +/// WAD_t = WAD_{t−1} if close_t == close_{t−1} +/// ADOSC_t = WAD_t − SMA(WAD, 13)_t +/// ``` +/// +/// This is distinct from the raw cumulative line, which Wickra ships as +/// [`Wad`](crate::Wad): `Wad` is the drifting line for divergence analysis, +/// while this oscillator is its zero-centred, mean-reverting form (positive +/// when accumulation is running ahead of its recent average, negative when +/// distribution is). The first bar only seeds the previous close; the first +/// oscillator value lands once the 13-bar average of the line is full. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AdOscillator}; +/// +/// let mut indicator = AdOscillator::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AdOscillator { + prev_close: Option, + line: f64, + signal: Sma, + last: Option, +} + +impl Default for AdOscillator { + fn default() -> Self { + Self::new() + } +} + +impl AdOscillator { + /// Construct a new Williams A/D Oscillator. + #[must_use] + pub fn new() -> Self { + Self { + prev_close: None, + line: 0.0, + signal: Sma::new(SIGNAL_PERIOD).expect("SIGNAL_PERIOD is non-zero"), + last: None, + } + } + + /// Current oscillator value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for AdOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + // The first bar only establishes the previous close anchor. + self.prev_close = Some(candle.close); + return None; + }; + let delta = if candle.close > prev { + // Accumulation: distance from the true low. + candle.close - prev.min(candle.low) + } else if candle.close < prev { + // Distribution: distance from the true high (negative). + candle.close - prev.max(candle.high) + } else { + 0.0 + }; + self.line += delta; + self.prev_close = Some(candle.close); + let signal = self.signal.update(self.line)?; + let osc = self.line - signal; + self.last = Some(osc); + Some(osc) + } + + fn reset(&mut self) { + self.prev_close = None; + self.line = 0.0; + self.signal.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One seed bar establishes the prior close; the line then feeds the + // 13-bar signal SMA, which is full after `SIGNAL_PERIOD` line values. + 1 + SIGNAL_PERIOD + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ADOSC" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::wad::Wad; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 100.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let ad = AdOscillator::new(); + assert_eq!(ad.name(), "ADOSC"); + assert_eq!(ad.warmup_period(), 14); + assert!(!ad.is_ready()); + assert_eq!(ad.value(), None); + // `Default` matches `new`. + assert_eq!(AdOscillator::default().warmup_period(), 14); + } + + #[test] + fn seed_bar_returns_none() { + let mut ad = AdOscillator::new(); + assert_eq!(ad.update(c(100.0, 101.0, 99.0, 100.0, 0)), None); + } + + #[test] + fn equals_wad_line_minus_its_sma() { + // The oscillator is exactly the Williams A/D line minus its 13-SMA, so + // it must match the standalone `Wad` line passed through an SMA(13). + let candles: Vec = (0..80_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.3).sin() * 6.0; + c( + base, + base + 2.0, + base - 2.0, + base + (i as f64 * 0.5).cos(), + i, + ) + }) + .collect(); + let osc = AdOscillator::new().batch(&candles); + // Reconstruct: Wad line, then line − SMA(line, 13). + let line = Wad::new().batch(&candles); + let mut sma = Sma::new(SIGNAL_PERIOD).unwrap(); + let expected: Vec> = line + .iter() + .map(|v| v.and_then(|l| sma.update(l).map(|s| l - s))) + .collect(); + assert_eq!(osc, expected); + } + + #[test] + fn flat_market_oscillates_at_zero() { + // A flat market never accumulates or distributes, so the line is + // constant and the oscillator sits at zero once warm. + let mut ad = AdOscillator::new(); + let candles: Vec = (0..40).map(|i| c(50.0, 50.0, 50.0, 50.0, i)).collect(); + let out = ad.batch(&candles); + for v in out.iter().skip(ad.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_at_warmup_period() { + let mut ad = AdOscillator::new(); + let candles: Vec = (0..20) + .map(|i| { + let close = 100.0 + f64::from(i); + c(close, close + 2.0, close - 2.0, close, i64::from(i)) + }) + .collect(); + let out = ad.batch(&candles); + assert_eq!(ad.warmup_period(), 14); + for v in out.iter().take(13) { + assert!(v.is_none()); + } + assert!(out[13].is_some()); + } + + #[test] + fn reset_clears_state() { + let mut ad = AdOscillator::new(); + let candles: Vec = (0..30) + .map(|i| { + let close = 100.0 + f64::from(i); + c(close, close + 2.0, close - 2.0, close, i64::from(i)) + }) + .collect(); + ad.batch(&candles); + assert!(ad.is_ready()); + ad.reset(); + assert!(!ad.is_ready()); + assert_eq!(ad.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.2).sin() * 5.0; + c(base, base + 1.5, base - 1.5, base + 0.4, i) + }) + .collect(); + let batch = AdOscillator::new().batch(&candles); + let mut s = AdOscillator::new(); + let streamed: Vec<_> = candles.iter().map(|x| s.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/ad_volume_line.rs b/crates/wickra-core/src/indicators/ad_volume_line.rs new file mode 100644 index 0000000..ad40c3a --- /dev/null +++ b/crates/wickra-core/src/indicators/ad_volume_line.rs @@ -0,0 +1,157 @@ +//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of +/// net advancing volume across a universe. +/// +/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`, +/// where advancing volume is the total volume of symbols with a positive change +/// and declining volume the total volume of symbols with a negative change. The +/// line accumulates this net over time, so a rising line means volume is flowing +/// into advancing issues (healthy participation) while a falling line warns that +/// declining issues are carrying the volume — the volume-weighted analogue of the +/// plain Advance/Decline Line. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the +/// first tick). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member}; +/// +/// let mut adv = AdVolumeLine::new(); +/// // advancing volume 150, declining volume 50 -> net +100. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 150.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(adv.update(tick), Some(100.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdVolumeLine { + line: f64, + has_emitted: bool, +} + +impl AdVolumeLine { + /// Construct a new Advance/Decline Volume Line indicator. + #[must_use] + pub const fn new() -> Self { + Self { + line: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for AdVolumeLine { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancing_volume() - section.declining_volume(); + self.line += net; + self.has_emitted = true; + Some(self.line) + } + + fn reset(&mut self) { + self.line = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AdVolumeLine" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let adv = AdVolumeLine::new(); + assert_eq!(adv.name(), "AdVolumeLine"); + assert_eq!(adv.warmup_period(), 1); + assert!(!adv.is_ready()); + } + + #[test] + fn first_tick_emits_net_volume() { + let mut adv = AdVolumeLine::new(); + assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0)); + assert!(adv.is_ready()); + } + + #[test] + fn line_accumulates_across_ticks() { + let mut adv = AdVolumeLine::new(); + assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0)); + assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0)); + assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0)); + } + + #[test] + fn unchanged_volume_is_ignored() { + let mut adv = AdVolumeLine::new(); + // Unchanged symbols (zero change) contribute to neither bucket. + assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0)); + } + + #[test] + fn reset_clears_state() { + let mut adv = AdVolumeLine::new(); + adv.update(tick(&[(1.0, 100.0)])); + assert!(adv.is_ready()); + adv.reset(); + assert!(!adv.is_ready()); + assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 150.0), (-1.0, 50.0)]), + tick(&[(1.0, 60.0), (-1.0, 60.0)]), + tick(&[(1.0, 30.0)]), + ]; + let mut a = AdVolumeLine::new(); + let mut b = AdVolumeLine::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/adaptive_cci.rs b/crates/wickra-core/src/indicators/adaptive_cci.rs new file mode 100644 index 0000000..6d1216d --- /dev/null +++ b/crates/wickra-core/src/indicators/adaptive_cci.rs @@ -0,0 +1,245 @@ +//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an +/// **efficiency-ratio-adaptive** moving average of typical price instead of a +/// plain SMA, so it leads in trends and stays calm in chop. +/// +/// ```text +/// TP = (high + low + close) / 3 +/// ER = |TP_t − TP_oldest| / Σ |ΔTP| over the window (0..1) +/// sc = ( ER·(2/3 − 2/31) + 2/31 )² +/// mean += sc·(TP_t − mean) (adaptive centre, seeded with SMA) +/// MD = mean(|TP_i − mean|) over the window (mean deviation) +/// CCI = (TP_t − mean) / (0.015 · MD) +/// ``` +/// +/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving +/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it +/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio — +/// lets the centre line accelerate toward price in a clean trend (so the CCI +/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes). +/// The `0.015` scaling keeps Lambert's convention that roughly 70–80% of readings +/// fall in `[−100, +100]`. +/// +/// The output is unbounded around `0`; a flat window (zero mean deviation) returns +/// `0`. The first value lands after `period` inputs; each `update` is O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AdaptiveCci}; +/// +/// let mut indicator = AdaptiveCci::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AdaptiveCci { + period: usize, + window: VecDeque, + mean: Option, + last: Option, +} + +impl AdaptiveCci { + /// Construct an adaptive CCI with the given `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a + /// path of at least one step). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "adaptive CCI needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + mean: None, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for AdaptiveCci { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tp = candle.typical_price(); + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(tp); + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + + // Efficiency ratio over the window. + let oldest = self.window[0]; + let direction = (tp - oldest).abs(); + let mut path = 0.0; + for pair in self.window.iter().collect::>().windows(2) { + path += (pair[1] - pair[0]).abs(); + } + let er = if path > 0.0 { + (direction / path).clamp(0.0, 1.0) + } else { + 0.0 + }; + let fast = 2.0 / 3.0; + let slow = 2.0 / 31.0; + let sc = (er * (fast - slow) + slow).powi(2); + + let mean = match self.mean { + None => self.window.iter().sum::() / n, + Some(prev) => prev + sc * (tp - prev), + }; + self.mean = Some(mean); + + let md = self.window.iter().map(|&v| (v - mean).abs()).sum::() / n; + let cci = if md > 0.0 { + (tp - mean) / (0.015 * md) + } else { + 0.0 + }; + self.last = Some(cci); + Some(cci) + } + + fn reset(&mut self) { + self.window.clear(); + self.mean = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AdaptiveCci" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(tp: f64) -> Candle { + // open=high=low=close=tp -> typical price == tp. + Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0) + } + + #[test] + fn rejects_invalid_period() { + assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero))); + assert!(matches!( + AdaptiveCci::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let c = AdaptiveCci::new(20).unwrap(); + assert_eq!(c.period(), 20); + assert_eq!(c.warmup_period(), 20); + assert_eq!(c.name(), "AdaptiveCci"); + assert!(!c.is_ready()); + assert_eq!(c.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut c = AdaptiveCci::new(4).unwrap(); + let candles: Vec = (0..6).map(|i| candle(100.0 + f64::from(i))).collect(); + let out = c.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn uptrend_is_positive() { + let mut c = AdaptiveCci::new(10).unwrap(); + let candles: Vec = (0..40).map(|i| candle(100.0 + f64::from(i))).collect(); + let last = c.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last > 0.0, "uptrend should give positive CCI, got {last}"); + } + + #[test] + fn downtrend_is_negative() { + let mut c = AdaptiveCci::new(10).unwrap(); + let candles: Vec = (0..40).map(|i| candle(200.0 - f64::from(i))).collect(); + let last = c.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last < 0.0, "downtrend should give negative CCI, got {last}"); + } + + #[test] + fn flat_window_is_zero() { + let mut c = AdaptiveCci::new(5).unwrap(); + let candles: Vec = (0..10).map(|_| candle(100.0)).collect(); + for v in c.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn reset_clears_state() { + let mut c = AdaptiveCci::new(5).unwrap(); + let candles: Vec = (0..20).map(|i| candle(100.0 + f64::from(i))).collect(); + c.batch(&candles); + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + assert_eq!(c.value(), None); + assert_eq!(c.update(candle(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0)) + .collect(); + let batch = AdaptiveCci::new(20).unwrap().batch(&candles); + let mut b = AdaptiveCci::new(20).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/adaptive_cycle.rs b/crates/wickra-core/src/indicators/adaptive_cycle.rs new file mode 100644 index 0000000..f41689c --- /dev/null +++ b/crates/wickra-core/src/indicators/adaptive_cycle.rs @@ -0,0 +1,143 @@ +//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators). + +use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle; +use crate::traits::Indicator; + +/// Ehlers' Adaptive Cycle Indicator. +/// +/// Returns half the current dominant cycle period — the "best" lookback for +/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per +/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for +/// the fact that an oscillator over a half-cycle captures the full peak-to- +/// trough swing without aliasing. +/// +/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`, +/// matching the typical operating range of period-adaptive oscillators. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, AdaptiveCycle}; +/// +/// let mut ac = AdaptiveCycle::new(); +/// let mut last = None; +/// for i in 0..200 { +/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdaptiveCycle { + cycle: HilbertDominantCycle, + last_value: Option, +} + +impl AdaptiveCycle { + /// Construct a new adaptive cycle estimator. + pub fn new() -> Self { + Self::default() + } + + /// Current adaptive period if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for AdaptiveCycle { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let period = self.cycle.update(input)?; + let half = (period * 0.5).round().clamp(3.0, 25.0); + self.last_value = Some(half); + Some(half) + } + + fn reset(&mut self) { + self.cycle.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.cycle.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "AdaptiveCycle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut ac = AdaptiveCycle::new(); + assert_eq!(ac.warmup_period(), 50); + assert_eq!(ac.name(), "AdaptiveCycle"); + assert!(!ac.is_ready()); + assert!(ac.value().is_none()); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ac.batch(&prices); + assert!(ac.is_ready()); + assert!(ac.value().is_some()); + } + + #[test] + fn output_within_clamp_band() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0) + .collect(); + let mut ac = AdaptiveCycle::new(); + for v in ac.batch(&prices).into_iter().flatten() { + assert!((3.0..=25.0).contains(&v), "period {v} out of band"); + assert_eq!(v, v.round(), "expected integer-valued output"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = AdaptiveCycle::new(); + let mut b = AdaptiveCycle::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ac = AdaptiveCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ac.batch(&prices); + let before = ac.value(); + assert!(before.is_some()); + assert_eq!(ac.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut ac = AdaptiveCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ac.batch(&prices); + assert!(ac.is_ready()); + ac.reset(); + assert!(!ac.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs b/crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs new file mode 100644 index 0000000..368c531 --- /dev/null +++ b/crates/wickra-core/src/indicators/adaptive_laguerre_filter.rs @@ -0,0 +1,344 @@ +//! Ehlers' Adaptive Laguerre Filter. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial +/// smoother whose damping factor `gamma` is recomputed every bar from how well +/// the filter is currently tracking price. +/// +/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi), +/// but instead of a fixed `gamma` the filter adapts: it measures the recent +/// absolute error `|price − filter|`, normalises those errors across a window of +/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price +/// is tracking smoothly the errors are small and uniform (low `gamma`, fast +/// response); when price jumps, the spread of errors widens and `gamma` rises, +/// slowing the filter to reject the noise. +/// +/// ```text +/// diff_t = |price_t − filter_{t-1}| +/// over the last `period` diffs: +/// HH = max(diff), LL = min(diff) +/// norm_i = (diff_i − LL) / (HH − LL) (0 if HH == LL) +/// gamma = median(norm) +/// alpha = 1 − gamma +/// L0_t = alpha·price_t + gamma·L0_{t-1} +/// L1_t = −gamma·L0_t + L0_{t-1} + gamma·L1_{t-1} +/// L2_t = −gamma·L1_t + L1_{t-1} + gamma·L2_{t-1} +/// L3_t = −gamma·L2_t + L2_{t-1} + gamma·L3_{t-1} +/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6 +/// ``` +/// +/// The output is a smoothed price on the same scale as the input. The first +/// emission lands once the error window holds `period` values. +/// +/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis +/// of Stocks & Commodities, 2007. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, AdaptiveLaguerreFilter}; +/// +/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AdaptiveLaguerreFilter { + period: usize, + l0: f64, + l1: f64, + l2: f64, + l3: f64, + /// Previous filter output, or `None` before the first bar. + filter: Option, + /// The last `period` absolute errors `|price − filter|`. + diffs: VecDeque, +} + +impl AdaptiveLaguerreFilter { + /// Construct a new adaptive Laguerre filter with the given error-window + /// length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + l0: 0.0, + l1: 0.0, + l2: 0.0, + l3: 0.0, + filter: None, + diffs: VecDeque::with_capacity(period), + }) + } + + /// Configured error-window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the error window is full. + pub fn value(&self) -> Option { + if self.diffs.len() == self.period { + self.filter + } else { + None + } + } + + /// Median of the normalised errors currently in the window. Returns `0.0` + /// when every error is equal (e.g. during a constant warmup), which makes + /// the filter maximally fast. + fn adaptive_gamma(&self) -> f64 { + let mut hh = f64::MIN; + let mut ll = f64::MAX; + for &d in &self.diffs { + if d > hh { + hh = d; + } + if d < ll { + ll = d; + } + } + let range = hh - ll; + if range <= 0.0 { + return 0.0; + } + let mut norm: Vec = self.diffs.iter().map(|&d| (d - ll) / range).collect(); + // `total_cmp` never panics — under pathological (e.g. overflowing) fuzz + // inputs a normalised error can be non-finite; a total order keeps the + // sort sound where `partial_cmp` would return `None`. + norm.sort_by(f64::total_cmp); + let mid = norm.len() / 2; + if norm.len() % 2 == 1 { + norm[mid] + } else { + f64::midpoint(norm[mid - 1], norm[mid]) + } + } +} + +impl Indicator for AdaptiveLaguerreFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.value(); + } + // Absolute tracking error against the previous filter (0 on the first + // bar, where there is no prior filter value). + let diff = self.filter.map_or(0.0, |f| (price - f).abs()); + if self.diffs.len() == self.period { + self.diffs.pop_front(); + } + self.diffs.push_back(diff); + + let gamma = self.adaptive_gamma(); + let alpha = 1.0 - gamma; + + let l0 = alpha * price + gamma * self.l0; + let l1 = -gamma * l0 + self.l0 + gamma * self.l1; + let l2 = -gamma * l1 + self.l1 + gamma * self.l2; + let l3 = -gamma * l2 + self.l2 + gamma * self.l3; + self.l0 = l0; + self.l1 = l1; + self.l2 = l2; + self.l3 = l3; + + let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0; + self.filter = Some(filter); + self.value() + } + + fn reset(&mut self) { + self.l0 = 0.0; + self.l1 = 0.0; + self.l2 = 0.0; + self.l3 = 0.0; + self.filter = None; + self.diffs.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.diffs.len() == self.period + } + + fn name(&self) -> &'static str { + "AdaptiveLaguerre" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference: replays the exact recurrence from scratch. + fn naive(prices: &[f64], period: usize) -> Vec> { + let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); + let mut filter: Option = None; + let mut diffs: Vec = Vec::new(); + let mut out = Vec::with_capacity(prices.len()); + for &price in prices { + let diff = filter.map_or(0.0, |f: f64| (price - f).abs()); + diffs.push(diff); + if diffs.len() > period { + diffs.remove(0); + } + let hh = diffs.iter().copied().fold(f64::MIN, f64::max); + let ll = diffs.iter().copied().fold(f64::MAX, f64::min); + let range = hh - ll; + let gamma = if range <= 0.0 { + 0.0 + } else { + let mut norm: Vec = diffs.iter().map(|&d| (d - ll) / range).collect(); + norm.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mid = norm.len() / 2; + if norm.len() % 2 == 1 { + norm[mid] + } else { + f64::midpoint(norm[mid - 1], norm[mid]) + } + }; + let alpha = 1.0 - gamma; + let n0 = alpha * price + gamma * l0; + let n1 = -gamma * n0 + l0 + gamma * l1; + let n2 = -gamma * n1 + l1 + gamma * l2; + let n3 = -gamma * n2 + l2 + gamma * l3; + l0 = n0; + l1 = n1; + l2 = n2; + l3 = n3; + let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0; + filter = Some(f); + out.push(if diffs.len() == period { Some(f) } else { None }); + } + out + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + AdaptiveLaguerreFilter::new(0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let alf = AdaptiveLaguerreFilter::new(13).unwrap(); + assert_eq!(alf.period(), 13); + assert_eq!(alf.warmup_period(), 13); + assert_eq!(alf.name(), "AdaptiveLaguerre"); + } + + #[test] + fn warmup_returns_none_until_window_full() { + let mut alf = AdaptiveLaguerreFilter::new(3).unwrap(); + assert_eq!(alf.update(10.0), None); + assert_eq!(alf.update(11.0), None); + assert!(alf.update(12.0).is_some()); + } + + #[test] + fn constant_series_converges_to_constant() { + // Errors are all zero -> gamma 0 -> the 4-stage delay line fills with + // the constant and the filter settles on it. + let mut alf = AdaptiveLaguerreFilter::new(5).unwrap(); + let out = alf.batch(&[42.0_f64; 40]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 42.0, epsilon = 1e-9); + } + + #[test] + fn converged_output_stays_within_price_range() { + // Once the Laguerre cascade has filled (it cold-starts from zero, so the + // first few post-warmup values ramp up toward price), the filter is a + // convex blend of recent prices and must stay inside the data range. + let prices: Vec = (0..120) + .map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0) + .collect(); + let lo = prices.iter().copied().fold(f64::MAX, f64::min); + let hi = prices.iter().copied().fold(f64::MIN, f64::max); + let period = 8; + let mut alf = AdaptiveLaguerreFilter::new(period).unwrap(); + for (i, v) in alf.batch(&prices).into_iter().enumerate() { + // Skip the cold-start transient (a few multiples of the window). + if i < 4 * period { + continue; + } + let v = v.expect("filter is ready well past warmup"); + assert!( + v >= lo - 1e-6 && v <= hi + 1e-6, + "filter out of range at {i}" + ); + } + } + + #[test] + fn matches_naive_recurrence() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1) + .collect(); + let mut alf = AdaptiveLaguerreFilter::new(10).unwrap(); + let got = alf.batch(&prices); + let want = naive(&prices, 10); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut alf = AdaptiveLaguerreFilter::new(5).unwrap(); + alf.batch(&(1..=40).map(f64::from).collect::>()); + assert!(alf.is_ready()); + alf.reset(); + assert!(!alf.is_ready()); + assert_eq!(alf.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(|i| f64::from(i) * 0.7).collect(); + let mut a = AdaptiveLaguerreFilter::new(7).unwrap(); + let mut b = AdaptiveLaguerreFilter::new(7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut alf = AdaptiveLaguerreFilter::new(3).unwrap(); + alf.update(10.0); + alf.update(11.0); + let ready = alf.update(12.0).expect("ready after three inputs"); + assert_eq!(alf.update(f64::NAN), Some(ready)); + assert_eq!(alf.update(f64::INFINITY), Some(ready)); + } +} diff --git a/crates/wickra-core/src/indicators/adaptive_rsi.rs b/crates/wickra-core/src/indicators/adaptive_rsi.rs new file mode 100644 index 0000000..f662d92 --- /dev/null +++ b/crates/wickra-core/src/indicators/adaptive_rsi.rs @@ -0,0 +1,296 @@ +//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and +/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the +/// oscillator reacts fast in a clean move and smooths through chop. +/// +/// ```text +/// ER = |price_t − price_{t−period}| / Σ |Δprice| over the window (efficiency ratio, 0..1) +/// sc = ( ER·(2/3 − 2/31) + 2/31 )² (KAMA smoothing constant) +/// avg_gain += sc·(gain − avg_gain), avg_loss += sc·(loss − avg_loss) +/// RSI = 100 · avg_gain / (avg_gain + avg_loss) +/// ``` +/// +/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in +/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's +/// efficiency ratio (`directional move / total path`) to set the smoothing each +/// bar — near `1` (a clean trend) the averages track gains and losses almost +/// immediately; near `0` (noise) they barely move, filtering the chop. The result +/// is an RSI that is responsive when it should be and quiet when it should be. It +/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead +/// sets the lookback from the measured dominant cycle. +/// +/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The +/// first value lands after `period + 1` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, AdaptiveRsi}; +/// +/// let mut indicator = AdaptiveRsi::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AdaptiveRsi { + period: usize, + prices: VecDeque, + abs_changes: VecDeque, + abs_sum: f64, + prev: Option, + seed_gain: f64, + seed_loss: f64, + seed_count: usize, + avg_gain: Option, + avg_loss: Option, + last: Option, +} + +impl AdaptiveRsi { + /// Construct an adaptive RSI with the given efficiency-ratio `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prices: VecDeque::with_capacity(period + 1), + abs_changes: VecDeque::with_capacity(period), + abs_sum: 0.0, + prev: None, + seed_gain: 0.0, + seed_loss: 0.0, + seed_count: 0, + avg_gain: None, + avg_loss: None, + last: None, + }) + } + + /// Configured efficiency-ratio period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { + let denom = avg_gain + avg_loss; + if denom == 0.0 { + 50.0 + } else { + 100.0 * (avg_gain / denom) + } + } + + fn efficiency_ratio(&self, price: f64) -> f64 { + let oldest = *self.prices.front().expect("window non-empty"); + let direction = (price - oldest).abs(); + if self.abs_sum == 0.0 { + 0.0 + } else { + (direction / self.abs_sum).clamp(0.0, 1.0) + } + } +} + +impl Indicator for AdaptiveRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let Some(prev) = self.prev else { + self.prev = Some(price); + self.prices.push_back(price); + return None; + }; + let change = price - prev; + self.prev = Some(price); + let gain = if change > 0.0 { change } else { 0.0 }; + let loss = if change < 0.0 { -change } else { 0.0 }; + + // Maintain the price window (period + 1) and the |Δ| window (period). + self.prices.push_back(price); + if self.prices.len() > self.period + 1 { + self.prices.pop_front(); + } + if self.abs_changes.len() == self.period { + self.abs_sum -= self.abs_changes.pop_front().expect("non-empty"); + } + self.abs_changes.push_back(change.abs()); + self.abs_sum += change.abs(); + + if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) { + let er = self.efficiency_ratio(price); + let fast = 2.0 / 3.0; + let slow = 2.0 / 31.0; + let sc = (er * (fast - slow) + slow).powi(2); + let new_ag = ag + sc * (gain - ag); + let new_al = al + sc * (loss - al); + self.avg_gain = Some(new_ag); + self.avg_loss = Some(new_al); + let v = Self::rsi_from_avgs(new_ag, new_al); + self.last = Some(v); + return Some(v); + } + + self.seed_gain += gain; + self.seed_loss += loss; + self.seed_count += 1; + if self.seed_count == self.period { + let ag = self.seed_gain / self.period as f64; + let al = self.seed_loss / self.period as f64; + self.avg_gain = Some(ag); + self.avg_loss = Some(al); + let v = Self::rsi_from_avgs(ag, al); + self.last = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.prices.clear(); + self.abs_changes.clear(); + self.abs_sum = 0.0; + self.prev = None; + self.seed_gain = 0.0; + self.seed_loss = 0.0; + self.seed_count = 0; + self.avg_gain = None; + self.avg_loss = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AdaptiveRsi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let r = AdaptiveRsi::new(14).unwrap(); + assert_eq!(r.period(), 14); + assert_eq!(r.warmup_period(), 15); + assert_eq!(r.name(), "AdaptiveRsi"); + assert!(!r.is_ready()); + assert_eq!(r.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut r = AdaptiveRsi::new(4).unwrap(); + let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn pure_uptrend_is_one_hundred() { + let mut r = AdaptiveRsi::new(5).unwrap(); + let last = r + .batch(&(1..=40).map(f64::from).collect::>()) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn flat_market_is_neutral() { + let mut r = AdaptiveRsi::new(4).unwrap(); + let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-9); + } + + #[test] + fn output_in_range() { + let mut r = AdaptiveRsi::new(14).unwrap(); + for v in r + .batch( + &(0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!((0.0..=100.0).contains(&v)); + } + } + + #[test] + fn ignores_non_finite() { + let mut r = AdaptiveRsi::new(4).unwrap(); + let ready = r + .batch(&[1.0, 2.0, 3.0, 4.0, 5.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(r.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut r = AdaptiveRsi::new(4).unwrap(); + r.batch(&(1..=20).map(f64::from).collect::>()); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.value(), None); + assert_eq!(r.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = AdaptiveRsi::new(14).unwrap().batch(&xs); + let mut b = AdaptiveRsi::new(14).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/adl.rs b/crates/wickra-core/src/indicators/adl.rs new file mode 100644 index 0000000..a13928a --- /dev/null +++ b/crates/wickra-core/src/indicators/adl.rs @@ -0,0 +1,193 @@ +//! Accumulation/Distribution Line. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow +/// indicator. +/// +/// Each bar contributes a *money-flow volume*: the bar's volume weighted by +/// where the close fell within the bar's range. +/// +/// ```text +/// MFM_t = ((close − low) − (high − close)) / (high − low) (the money-flow multiplier, −1..+1) +/// MFV_t = MFM_t · volume_t +/// ADL_t = ADL_{t−1} + MFV_t +/// ``` +/// +/// A close near the high makes the multiplier near `+1` (accumulation), near +/// the low near `−1` (distribution). The running total is unbounded and drifts +/// with cumulative volume — what matters is its slope and its divergence from +/// price. A bar with `high == low` contributes `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Adl}; +/// +/// let mut indicator = Adl::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Adl { + total: f64, + has_emitted: bool, +} + +impl Adl { + /// Construct a new Accumulation/Distribution Line starting at zero. + pub const fn new() -> Self { + Self { + total: 0.0, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.total) + } else { + None + } + } +} + +impl Indicator for Adl { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let mfv = if range == 0.0 { + // A zero-range bar carries no positional information. + 0.0 + } else { + let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range; + mfm * candle.volume + }; + self.total += mfv; + self.has_emitted = true; + Some(self.total) + } + + fn reset(&mut self) { + self.total = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ADL" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100. + // bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100; + // ADL = 100 - 100 = 0. + let mut adl = Adl::new(); + let out = adl.batch(&[ + candle(8.0, 10.0, 8.0, 10.0, 100.0, 0), + candle(10.0, 12.0, 8.0, 9.0, 200.0, 1), + ]); + assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn emits_from_first_candle() { + let mut adl = Adl::new(); + assert_eq!(adl.warmup_period(), 1); + assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some()); + } + + /// Cover the Indicator-impl `name` body (94-96). The other accessors + /// are exercised by existing tests; `name` was never queried. + #[test] + fn accessors_and_metadata() { + let adl = Adl::new(); + assert_eq!(adl.name(), "ADL"); + } + + #[test] + fn close_at_high_accumulates_full_volume() { + // Every bar closes at its high: MFM = +1, so ADL grows by `volume`. + let mut adl = Adl::new(); + let mut expected = 0.0; + for i in 0..10 { + let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i); + expected += 25.0; + assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9); + } + } + + #[test] + fn zero_range_bar_contributes_nothing() { + let mut adl = Adl::new(); + adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0)); + let before = adl.value().unwrap(); + // A flat candle (high == low) adds zero. + let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap(); + assert_relative_eq!(after, before, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut adl = Adl::new(); + adl.batch(&[ + candle(8.0, 10.0, 8.0, 9.0, 100.0, 0), + candle(9.0, 11.0, 9.0, 10.0, 100.0, 1), + ]); + assert!(adl.is_ready()); + adl.reset(); + assert!(!adl.is_ready()); + assert_eq!(adl.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let batch = Adl::new().batch(&candles); + let mut b = Adl::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/advance_block.rs b/crates/wickra-core/src/indicators/advance_block.rs new file mode 100644 index 0000000..45d5faf --- /dev/null +++ b/crates/wickra-core/src/indicators/advance_block.rs @@ -0,0 +1,192 @@ +//! Advance Block candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Advance Block — a 3-bar bearish warning: three green candles still pushing to +/// higher closes, but visibly running out of steam — each real body shrinks while +/// the upper shadows lengthen, hinting the advance is about to stall. +/// +/// ```text +/// all three green & higher closes +/// each opens inside the prior body +/// shrinking bodies (body3 < body2 < body1) +/// upper shadow of bar3 >= upper shadow of bar2 and bar3 has an upper shadow +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Advance Block +/// is a single-direction (bearish-only) warning, so it never emits `+1.0`. The +/// first two bars always return `0.0` because the three-bar window is not yet +/// filled. Pattern-shape check only — no trend filter is applied; combine with a +/// trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AdvanceBlock, Candle, Indicator}; +/// +/// let mut indicator = AdvanceBlock::new(); +/// indicator.update(Candle::new(10.0, 13.1, 9.9, 13.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(12.0, 14.3, 11.9, 14.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.5, 15.0, 13.4, 14.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdvanceBlock { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl AdvanceBlock { + /// Construct a new Advance Block detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for AdvanceBlock { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = bar1.close - bar1.open; + let body2 = bar2.close - bar2.open; + let body3 = candle.close - candle.open; + let upper2 = bar2.high - bar2.close; + let upper3 = candle.high - candle.close; + if bar1.close > bar1.open + && bar2.close > bar2.open + && candle.close > candle.open + && bar2.close > bar1.close + && candle.close > bar2.close + && bar2.open >= bar1.open + && bar2.open <= bar1.close + && candle.open >= bar2.open + && candle.open <= bar2.close + && body2 < body1 + && body3 < body2 + && upper3 >= upper2 + && upper3 > 0.0 + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AdvanceBlock" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = AdvanceBlock::new(); + assert_eq!(t.name(), "AdvanceBlock"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn advance_block_is_minus_one() { + let mut t = AdvanceBlock::new(); + assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 14.3, 11.9, 14.0, 1)), Some(0.0)); + assert_eq!(t.update(c(13.5, 15.0, 13.4, 14.5, 2)), Some(-1.0)); + } + + #[test] + fn strong_advance_yields_zero() { + let mut t = AdvanceBlock::new(); + // Bodies grow instead of shrinking -> a strong advance, not blocked. + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 14.1, 11.4, 14.0, 2)), Some(0.0)); + } + + #[test] + fn no_upper_shadow_growth_yields_zero() { + let mut t = AdvanceBlock::new(); + t.update(c(10.0, 13.1, 9.9, 13.0, 0)); + t.update(c(12.0, 14.3, 11.9, 14.0, 1)); + // bar3 shrinking body but no upper shadow -> not blocked. + assert_eq!(t.update(c(13.5, 14.5, 13.4, 14.5, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = AdvanceBlock::new(); + assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 14.3, 11.9, 14.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 0.2, base + 1.5, i) + }) + .collect(); + let mut a = AdvanceBlock::new(); + let mut b = AdvanceBlock::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = AdvanceBlock::new(); + t.update(c(10.0, 13.1, 9.9, 13.0, 0)); + t.update(c(12.0, 14.3, 11.9, 14.0, 1)); + t.update(c(13.5, 15.0, 13.4, 14.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/advance_decline.rs b/crates/wickra-core/src/indicators/advance_decline.rs new file mode 100644 index 0000000..468c909 --- /dev/null +++ b/crates/wickra-core/src/indicators/advance_decline.rs @@ -0,0 +1,168 @@ +//! Advance/Decline Line — cumulative net advancing-minus-declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Advance/Decline Line (A/D Line) — the running cumulative sum of net advancing +/// issues across a universe. +/// +/// On each [`CrossSection`] tick the net breadth is `advancers - decliners`: +/// the number of symbols with a positive price change minus the number with a +/// negative change (unchanged symbols are ignored). The line accumulates this +/// net value over time, so a rising line means advancers have persistently +/// outnumbered decliners — broad participation — while a falling line warns that +/// a rally is being carried by fewer and fewer names (a breadth divergence when +/// the index itself is still rising). +/// +/// `Input = CrossSection`, `Output = f64`. The line is defined from the very +/// first tick, so `warmup_period == 1` and the indicator is ready after one +/// update. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AdvanceDecline, CrossSection, Indicator, Member}; +/// +/// let mut ad = AdvanceDecline::new(); +/// // 3 advancers, 1 decliner -> net +2. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(0.5, 10.0, false, false), +/// Member::new(2.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(ad.update(tick), Some(2.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdvanceDecline { + line: f64, + has_emitted: bool, +} + +impl AdvanceDecline { + /// Construct a new Advance/Decline Line indicator. + #[must_use] + pub const fn new() -> Self { + Self { + line: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for AdvanceDecline { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancers() as f64 - section.decliners() as f64; + self.line += net; + self.has_emitted = true; + Some(self.line) + } + + fn reset(&mut self) { + self.line = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AdvanceDecline" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + /// Build a cross-section with `up` advancers, `down` decliners and `flat` + /// unchanged symbols. + fn section(up: usize, down: usize, flat: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + for _ in 0..flat { + members.push(Member::new(0.0, 10.0, false, false)); + } + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let ad = AdvanceDecline::new(); + assert_eq!(ad.name(), "AdvanceDecline"); + assert_eq!(ad.warmup_period(), 1); + assert!(!ad.is_ready()); + } + + #[test] + fn first_tick_emits_net_breadth() { + let mut ad = AdvanceDecline::new(); + assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); + assert!(ad.is_ready()); + } + + #[test] + fn line_accumulates_across_ticks() { + let mut ad = AdvanceDecline::new(); + assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); // +2 -> 2 + assert_eq!(ad.update(section(1, 4, 0)), Some(-1.0)); // -3 -> -1 + assert_eq!(ad.update(section(2, 0, 0)), Some(1.0)); // +2 -> 1 + } + + #[test] + fn unchanged_symbols_are_ignored() { + let mut ad = AdvanceDecline::new(); + // 2 up, 2 down, 5 unchanged -> net 0, line stays flat. + assert_eq!(ad.update(section(2, 2, 5)), Some(0.0)); + assert_eq!(ad.update(section(2, 2, 5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut ad = AdvanceDecline::new(); + ad.update(section(5, 0, 0)); + assert!(ad.is_ready()); + ad.reset(); + assert!(!ad.is_ready()); + // Line restarts from zero, not from the pre-reset value. + assert_eq!(ad.update(section(1, 0, 0)), Some(1.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + section(3, 1, 2), + section(1, 4, 0), + section(2, 2, 1), + section(5, 0, 3), + ]; + let mut a = AdvanceDecline::new(); + let mut b = AdvanceDecline::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/advance_decline_ratio.rs b/crates/wickra-core/src/indicators/advance_decline_ratio.rs new file mode 100644 index 0000000..1619121 --- /dev/null +++ b/crates/wickra-core/src/indicators/advance_decline_ratio.rs @@ -0,0 +1,151 @@ +//! Advance/Decline Ratio — advancing issues divided by declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the +/// number of declining symbols across a universe. +/// +/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading +/// above one means advancing issues outnumber declining ones (broad strength), +/// while a reading below one signals broad weakness. Because it is a ratio rather +/// than a difference, the ADR is comparable across universes of different sizes. +/// +/// When a tick has no declining symbols the denominator is floored to one, so the +/// ratio degrades gracefully to the advancer count instead of dividing by zero. +/// +/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first +/// tick, so `warmup_period == 1` and the indicator is ready after one update. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member}; +/// +/// let mut adr = AdvanceDeclineRatio::new(); +/// // 3 advancers, 1 decliner -> ratio 3.0. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(0.5, 10.0, false, false), +/// Member::new(2.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(adr.update(tick), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdvanceDeclineRatio { + has_emitted: bool, +} + +impl AdvanceDeclineRatio { + /// Construct a new Advance/Decline Ratio indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for AdvanceDeclineRatio { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancers = section.advancers() as f64; + let decliners = section.decliners().max(1) as f64; + self.has_emitted = true; + Some(advancers / decliners) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AdvanceDeclineRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + // A non-empty unchanged member guarantees a valid universe when both + // counts are zero. + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let adr = AdvanceDeclineRatio::new(); + assert_eq!(adr.name(), "AdvanceDeclineRatio"); + assert_eq!(adr.warmup_period(), 1); + assert!(!adr.is_ready()); + } + + #[test] + fn first_tick_emits_ratio() { + let mut adr = AdvanceDeclineRatio::new(); + assert_eq!(adr.update(section(3, 1)), Some(3.0)); + assert!(adr.is_ready()); + } + + #[test] + fn zero_decliners_floors_denominator() { + let mut adr = AdvanceDeclineRatio::new(); + // 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0. + assert_eq!(adr.update(section(4, 0)), Some(4.0)); + } + + #[test] + fn no_advancers_yields_zero() { + let mut adr = AdvanceDeclineRatio::new(); + assert_eq!(adr.update(section(0, 5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut adr = AdvanceDeclineRatio::new(); + adr.update(section(3, 1)); + assert!(adr.is_ready()); + adr.reset(); + assert!(!adr.is_ready()); + assert_eq!(adr.update(section(2, 1)), Some(2.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)]; + let mut a = AdvanceDeclineRatio::new(); + let mut b = AdvanceDeclineRatio::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/adx.rs b/crates/wickra-core/src/indicators/adx.rs new file mode 100644 index 0000000..6691362 --- /dev/null +++ b/crates/wickra-core/src/indicators/adx.rs @@ -0,0 +1,345 @@ +//! Average Directional Index (ADX) with +DI / -DI components. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// ADX output: the three Wilder lines. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AdxOutput { + /// Plus Directional Indicator. + pub plus_di: f64, + /// Minus Directional Indicator. + pub minus_di: f64, + /// Average Directional Index (smoothed |DX|). + pub adx: f64, +} + +/// Wilder's Average Directional Index. +/// +/// Uses Wilder smoothing throughout. First `period` candles seed the directional +/// movement / true range sums; the next `period` candles produce DX values that +/// seed the ADX. The first complete `AdxOutput` is emitted after `2 * period` +/// candles. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Adx}; +/// +/// let mut indicator = Adx::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[allow(clippy::struct_field_names)] // adx_value pairs with adx (the output line) — renaming hurts clarity +#[derive(Debug, Clone)] +pub struct Adx { + period: usize, + prev: Option, + + // Wilder-smoothed sums during seeding. + tr_seed: f64, + plus_dm_seed: f64, + minus_dm_seed: f64, + seed_count: usize, + + // Smoothed running values after seeding. + tr_smooth: Option, + plus_dm_smooth: Option, + minus_dm_smooth: Option, + + // ADX seeding. + dx_buf: Vec, + adx_value: Option, + last_plus_di: f64, + last_minus_di: f64, +} + +impl Adx { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + tr_seed: 0.0, + plus_dm_seed: 0.0, + minus_dm_seed: 0.0, + seed_count: 0, + tr_smooth: None, + plus_dm_smooth: None, + minus_dm_smooth: None, + dx_buf: Vec::with_capacity(period), + adx_value: None, + last_plus_di: 0.0, + last_minus_di: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) { + let up = current.high - prev.high; + let down = prev.low - current.low; + let plus_dm = if up > down && up > 0.0 { up } else { 0.0 }; + let minus_dm = if down > up && down > 0.0 { down } else { 0.0 }; + (plus_dm, minus_dm) +} + +impl Indicator for Adx { + type Input = Candle; + type Output = AdxOutput; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + self.prev = Some(candle); + + let tr = candle.true_range(Some(prev.close)); + let (plus_dm, minus_dm) = directional_movement(&prev, &candle); + let n = self.period as f64; + + let (tr_v, plus_v, minus_v) = if let (Some(t), Some(p), Some(m)) = + (self.tr_smooth, self.plus_dm_smooth, self.minus_dm_smooth) + { + let t_new = t - t / n + tr; + let p_new = p - p / n + plus_dm; + let m_new = m - m / n + minus_dm; + self.tr_smooth = Some(t_new); + self.plus_dm_smooth = Some(p_new); + self.minus_dm_smooth = Some(m_new); + (t_new, p_new, m_new) + } else { + self.tr_seed += tr; + self.plus_dm_seed += plus_dm; + self.minus_dm_seed += minus_dm; + self.seed_count += 1; + if self.seed_count < self.period { + return None; + } + self.tr_smooth = Some(self.tr_seed); + self.plus_dm_smooth = Some(self.plus_dm_seed); + self.minus_dm_smooth = Some(self.minus_dm_seed); + (self.tr_seed, self.plus_dm_seed, self.minus_dm_seed) + }; + + let plus_di = if tr_v == 0.0 { + 0.0 + } else { + 100.0 * plus_v / tr_v + }; + let minus_di = if tr_v == 0.0 { + 0.0 + } else { + 100.0 * minus_v / tr_v + }; + self.last_plus_di = plus_di; + self.last_minus_di = minus_di; + + let dx_den = plus_di + minus_di; + let dx = if dx_den == 0.0 { + 0.0 + } else { + 100.0 * (plus_di - minus_di).abs() / dx_den + }; + + if let Some(prev_adx) = self.adx_value { + let new_adx = (prev_adx * (n - 1.0) + dx) / n; + self.adx_value = Some(new_adx); + return Some(AdxOutput { + plus_di, + minus_di, + adx: new_adx, + }); + } + + self.dx_buf.push(dx); + if self.dx_buf.len() == self.period { + let seed = self.dx_buf.iter().sum::() / n; + self.adx_value = Some(seed); + return Some(AdxOutput { + plus_di, + minus_di, + adx: seed, + }); + } + None + } + + fn reset(&mut self) { + self.prev = None; + self.tr_seed = 0.0; + self.plus_dm_seed = 0.0; + self.minus_dm_seed = 0.0; + self.seed_count = 0; + self.tr_smooth = None; + self.plus_dm_smooth = None; + self.minus_dm_smooth = None; + self.dx_buf.clear(); + self.adx_value = None; + self.last_plus_di = 0.0; + self.last_minus_di = 0.0; + } + + fn warmup_period(&self) -> usize { + 2 * self.period + } + + fn is_ready(&self) -> bool { + self.adx_value.is_some() + } + + fn name(&self) -> &'static str { + "ADX" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn pure_uptrend_yields_plus_di_dominant() { + // Strict uptrend: highs increase, lows increase, ADX should trend up, + // +DI should dominate -DI. + let candles: Vec = (0..50) + .map(|i| { + let base = 100.0 + f64::from(i) * 2.0; + c(base + 1.0, base - 0.5, base + 0.5) + }) + .collect(); + let mut adx = Adx::new(14).unwrap(); + let last = adx + .batch(&candles) + .into_iter() + .flatten() + .last() + .expect("emits"); + assert!( + last.plus_di > last.minus_di, + "+DI {} should exceed -DI {}", + last.plus_di, + last.minus_di + ); + assert!(last.adx > 0.0); + } + + #[test] + fn pure_downtrend_yields_minus_di_dominant() { + let candles: Vec = (0..50) + .rev() + .map(|i| { + let base = 100.0 + f64::from(i) * 2.0; + c(base + 1.0, base - 0.5, base + 0.5) + }) + .collect(); + let mut adx = Adx::new(14).unwrap(); + let last = adx + .batch(&candles) + .into_iter() + .flatten() + .last() + .expect("emits"); + assert!(last.minus_di > last.plus_di); + } + + #[test] + fn rejects_zero_period() { + assert!(Adx::new(0).is_err()); + } + + /// Cover the const accessor `period` (lines 89-91) and the Indicator-impl + /// `warmup_period` (199-201) + `name` (207-209). None of the trend tests + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let adx = Adx::new(14).unwrap(); + assert_eq!(adx.period(), 14); + assert_eq!(adx.warmup_period(), 28); + assert_eq!(adx.name(), "ADX"); + } + + /// Cover the `tr_v == 0.0` defensive branches in `update` (lines 142, + /// 147) — feeding a stream of perfectly flat candles (H == L == close + /// every bar) gives true-range 0 each step, so the smoothed `tr_smooth` + /// stays at 0.0 and the `plus_di` / `minus_di` divisions would otherwise + /// blow up. The indicator must emit zeros (DX denominator is also 0). + #[test] + fn zero_true_range_yields_zero_di_and_zero_adx() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut adx = Adx::new(5).unwrap(); + let last = adx + .batch(&candles) + .into_iter() + .flatten() + .last() + .expect("ADX emits after 2 * period candles"); + assert_eq!(last.plus_di, 0.0); + assert_eq!(last.minus_di, 0.0); + assert_eq!(last.adx, 0.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut a = Adx::new(14).unwrap(); + let mut b = Adx::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut adx = Adx::new(14).unwrap(); + adx.batch(&candles); + adx.reset(); + assert!(!adx.is_ready()); + } + + #[test] + fn outputs_remain_finite() { + let candles: Vec = (0..200) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut adx = Adx::new(14).unwrap(); + for v in adx.batch(&candles).into_iter().flatten() { + assert!(v.plus_di.is_finite() && v.minus_di.is_finite() && v.adx.is_finite()); + } + // Sanity: ADX is bounded by 100. + let last = adx.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last.adx <= 100.0 + 1e-6); + assert_relative_eq!(0.0_f64.max(last.adx), last.adx, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/adxr.rs b/crates/wickra-core/src/indicators/adxr.rs new file mode 100644 index 0000000..41b1f28 --- /dev/null +++ b/crates/wickra-core/src/indicators/adxr.rs @@ -0,0 +1,246 @@ +//! Average Directional Movement Index Rating (ADXR). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::adx::Adx; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wilder's Average Directional Movement Index Rating. +/// +/// `ADXR` smooths the [`Adx`] line by averaging its current value with the value +/// it had `period` bars ago: +/// +/// ```text +/// ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2 +/// ``` +/// +/// The lookback length is the same `period` that feeds the underlying ADX. +/// Wilder introduced ADXR alongside ADX in *New Concepts in Technical Trading +/// Systems* (1978) as a more stable directional-strength reading: because the +/// older `ADX` is `period - 1` bars stale, ADXR responds more slowly than ADX +/// and is used to compare trend-strength between different instruments. +/// +/// The first complete `ADXR` is emitted after `3 * period - 1` candles +/// (`2 * period` to seed the ADX plus another `period - 1` to fill the +/// lookback ring). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Adxr, Candle, Indicator}; +/// +/// let mut indicator = Adxr::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Adxr { + period: usize, + adx: Adx, + /// Ring buffer of the most recent `period` `ADX` values; the front is the + /// oldest, the back is the newest. ADXR is `(back + front) / 2` once the + /// ring is full. + window: VecDeque, + last: Option, +} + +impl Adxr { + /// Construct a new ADXR with the given Wilder smoothing period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + adx: Adx::new(period)?, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Adxr { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let adx_value = self.adx.update(candle)?.adx; + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(adx_value); + if self.window.len() < self.period { + return None; + } + let oldest = *self.window.front().expect("ring is full"); + let adxr = f64::midpoint(adx_value, oldest); + self.last = Some(adxr); + Some(adxr) + } + + fn reset(&mut self) { + self.adx.reset(); + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // ADX warmup is `2 * period` and emits one `ADX` per subsequent candle; + // the ADXR ring then needs `period - 1` more candles to fill, so the + // first ADXR lands at `2 * period + (period - 1) = 3 * period - 1`. + 3 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ADXR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(c, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Adxr::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut a = Adxr::new(14).unwrap(); + assert_eq!(a.period(), 14); + assert_eq!(a.warmup_period(), 41); + assert_eq!(a.name(), "ADXR"); + assert!(a.value().is_none()); + // Drive past warmup. + for i in 0..50_i64 { + let base = 100.0 + (i as f64) * 2.0; + a.update(candle(base + 1.0, base - 0.5, base + 0.5, i)); + } + assert!(a.value().is_some()); + } + + #[test] + fn pure_uptrend_yields_finite_positive_adxr() { + let candles: Vec = (0..80_i64) + .map(|i| { + let base = 100.0 + (i as f64) * 2.0; + candle(base + 1.0, base - 0.5, base + 0.5, i) + }) + .collect(); + let mut a = Adxr::new(14).unwrap(); + let last = a.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last > 0.0 && last <= 100.0 + 1e-9); + } + + #[test] + fn constant_series_yields_zero_adxr() { + let candles: Vec = (0..50_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect(); + let mut a = Adxr::new(5).unwrap(); + let last = a.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..80_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + let mut a = Adxr::new(5).unwrap(); + let out = a.batch(&candles); + let warmup = 3 * 5 - 1; // 14 + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn reference_value_against_explicit_adx_average() { + // The first ADXR(p) emits at index `3p - 2` (0-based), and equals + // (ADX[index] + ADX[index - (p - 1)]) / 2. Verify against a separate + // ADX run. + let candles: Vec = (0..60_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.2).sin() * 6.0; + candle(p + 1.5, p - 1.5, p, i) + }) + .collect(); + let period = 5; + let mut adx = Adx::new(period).unwrap(); + let adx_out: Vec<_> = adx + .batch(&candles) + .into_iter() + .map(|o| o.map(|x| x.adx)) + .collect(); + let mut adxr = Adxr::new(period).unwrap(); + let adxr_out = adxr.batch(&candles); + // First ADXR index (0-based) = 3 * period - 2 = 13. + let first = 3 * period - 2; + let prev = first - (period - 1); + let expected = f64::midpoint(adx_out[first].unwrap(), adx_out[prev].unwrap()); + assert_relative_eq!(adxr_out[first].unwrap(), expected, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.25).sin() * 5.0; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + let mut a = Adxr::new(7).unwrap(); + let mut b = Adxr::new(7).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..60_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut a = Adxr::new(5).unwrap(); + a.batch(&candles); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/alligator.rs b/crates/wickra-core/src/indicators/alligator.rs new file mode 100644 index 0000000..919aab8 --- /dev/null +++ b/crates/wickra-core/src/indicators/alligator.rs @@ -0,0 +1,223 @@ +//! Bill Williams' Alligator indicator. + +use crate::error::{Error, Result}; +use crate::indicators::smma::Smma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Alligator output: three smoothed moving averages of the median price +/// `(high + low) / 2`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AlligatorOutput { + /// `Jaw` — the slowest line (default period 13). + pub jaw: f64, + /// `Teeth` — the middle line (default period 8). + pub teeth: f64, + /// `Lips` — the fastest line (default period 5). + pub lips: f64, +} + +/// Bill Williams' Alligator: three `SMMA`s of the median price `(high + low) / 2` +/// with different periods. Classic parameters are `(jaw = 13, teeth = 8, lips = 5)`. +/// +/// The original chart variant additionally shifts each line forward by a fixed +/// number of bars for display (Jaw +8, Teeth +5, Lips +3). Wickra publishes the +/// *unshifted* `SMMA` values — the consumer can apply the visual shift on the +/// chart side. The indicator emits values once all three `SMMA`s have warmed +/// up, i.e. after `max(jaw, teeth, lips) = jaw` candles. +/// +/// Reference: Bill Williams, *Trading Chaos*, 1995. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Alligator, Candle, Indicator}; +/// +/// let mut alligator = Alligator::classic(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap(); +/// last = alligator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Alligator { + jaw_period: usize, + teeth_period: usize, + lips_period: usize, + jaw: Smma, + teeth: Smma, + lips: Smma, +} + +impl Alligator { + /// # Errors + /// Returns [`Error::PeriodZero`] if any period is zero. + pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result { + if jaw_period == 0 || teeth_period == 0 || lips_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + jaw_period, + teeth_period, + lips_period, + jaw: Smma::new(jaw_period)?, + teeth: Smma::new(teeth_period)?, + lips: Smma::new(lips_period)?, + }) + } + + /// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`. + pub fn classic() -> Self { + Self::new(13, 8, 5).expect("classic Alligator parameters are valid") + } + + /// Configured `(jaw_period, teeth_period, lips_period)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.jaw_period, self.teeth_period, self.lips_period) + } +} + +impl Indicator for Alligator { + type Input = Candle; + type Output = AlligatorOutput; + + fn update(&mut self, candle: Candle) -> Option { + let median = f64::midpoint(candle.high, candle.low); + // Feed every `SMMA` on every bar so they warm up in parallel; gating + // the longer lines behind the shorter ones would starve them during + // their own warmup. + let lips = self.lips.update(median); + let teeth = self.teeth.update(median); + let jaw = self.jaw.update(median); + Some(AlligatorOutput { + jaw: jaw?, + teeth: teeth?, + lips: lips?, + }) + } + + fn reset(&mut self) { + self.jaw.reset(); + self.teeth.reset(); + self.lips.reset(); + } + + fn warmup_period(&self) -> usize { + // All three SMMAs run on every bar, so readiness is gated by the + // longest period — the Jaw with the default parameters. + self.jaw_period.max(self.teeth_period).max(self.lips_period) + } + + fn is_ready(&self) -> bool { + self.jaw.is_ready() && self.teeth.is_ready() && self.lips.is_ready() + } + + fn name(&self) -> &'static str { + "Alligator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, ts: i64) -> Candle { + let close = f64::midpoint(high, low); + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Alligator::new(0, 8, 5), Err(Error::PeriodZero))); + assert!(matches!(Alligator::new(13, 0, 5), Err(Error::PeriodZero))); + assert!(matches!(Alligator::new(13, 8, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let alligator = Alligator::classic(); + assert_eq!(alligator.periods(), (13, 8, 5)); + assert_eq!(alligator.warmup_period(), 13); + assert_eq!(alligator.name(), "Alligator"); + } + + #[test] + fn constant_series_yields_the_constant() { + // Median price = 10 for every bar, so each SMMA seeds to 10 and stays. + let mut alligator = Alligator::classic(); + let candles: Vec = (0..40).map(|i| candle(11.0, 9.0, i)).collect(); + let out = alligator.batch(&candles); + for v in out.iter().skip(12).flatten() { + assert_relative_eq!(v.jaw, 10.0, epsilon = 1e-12); + assert_relative_eq!(v.teeth, 10.0, epsilon = 1e-12); + assert_relative_eq!(v.lips, 10.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_longest_period() { + let mut alligator = Alligator::new(5, 3, 2).unwrap(); + let candles: Vec = (0..6).map(|i| candle(11.0, 9.0, i)).collect(); + let out = alligator.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn pure_uptrend_ordering() { + // On a clean uptrend the fastest line (Lips, smallest SMMA) leads the + // slowest line (Jaw) — lips > teeth > jaw at the latest bar. + let mut alligator = Alligator::classic(); + let candles: Vec = (0_i64..80) + .map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i)) + .collect(); + let out = alligator.batch(&candles); + let last = out.last().unwrap().unwrap(); + assert!( + last.lips > last.teeth, + "lips {} > teeth {}", + last.lips, + last.teeth + ); + assert!( + last.teeth > last.jaw, + "teeth {} > jaw {}", + last.teeth, + last.jaw + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.2).sin() * 5.0; + candle(base + 1.0, base - 1.0, i) + }) + .collect(); + let mut a = Alligator::classic(); + let mut b = Alligator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut alligator = Alligator::classic(); + let candles: Vec = (0..40).map(|i| candle(11.0, 9.0, i)).collect(); + alligator.batch(&candles); + assert!(alligator.is_ready()); + alligator.reset(); + assert!(!alligator.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/alma.rs b/crates/wickra-core/src/indicators/alma.rs new file mode 100644 index 0000000..3ef911a --- /dev/null +++ b/crates/wickra-core/src/indicators/alma.rs @@ -0,0 +1,335 @@ +//! Arnaud Legoux Moving Average (ALMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Arnaud Legoux Moving Average — a Gaussian-weighted moving average. +/// +/// Each output is a weighted sum of the last `period` inputs: +/// +/// ```text +/// w[i] = exp(-(i - m)^2 / (2 * s^2)) for i in 0..period +/// m = offset * (period - 1) +/// s = period / sigma +/// ALMA = sum(price[i] * w[i]) / sum(w[i]) +/// ``` +/// +/// The Gaussian is centred on the relative index `offset * (period - 1)`, so +/// `offset = 0.85` puts the peak near the newest sample (responsive), while +/// `offset = 0.5` centres the peak in the middle of the window (smooth). +/// `sigma` controls how concentrated the Gaussian is: larger `sigma` -> +/// narrower kernel, smaller `sigma` -> broader (closer to SMA). +/// +/// Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009. +/// +/// # Defaults +/// +/// The community-standard parameters are `period = 9`, `offset = 0.85`, +/// `sigma = 6.0`. The first output lands after exactly `period` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Alma, Indicator}; +/// +/// let mut alma = Alma::new(9, 0.85, 6.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = alma.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Alma { + period: usize, + offset: f64, + sigma: f64, + /// Pre-computed, normalised weights (sum to 1). `weights[0]` is the oldest + /// sample in the window, `weights[period - 1]` the newest. + weights: Vec, + window: VecDeque, + current: Option, +} + +impl Alma { + /// Construct a new ALMA with the given period, offset and sigma. + /// + /// # Errors + /// + /// - [`Error::PeriodZero`] if `period == 0`. + /// - [`Error::InvalidPeriod`] if `offset` is outside `[0.0, 1.0]` or + /// `sigma <= 0.0` or either of `offset` / `sigma` is non-finite. + pub fn new(period: usize, offset: f64, sigma: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !offset.is_finite() || !(0.0..=1.0).contains(&offset) { + return Err(Error::InvalidPeriod { + message: "ALMA offset must be a finite value in [0, 1]", + }); + } + if !sigma.is_finite() || sigma <= 0.0 { + return Err(Error::InvalidPeriod { + message: "ALMA sigma must be a finite positive value", + }); + } + let m = offset * (period as f64 - 1.0); + let s = period as f64 / sigma; + let denom = 2.0 * s * s; + // The raw Gaussian weights sum to a strictly positive value because + // every term is `exp(_) > 0`, so the normalisation below cannot divide + // by zero. + let mut raw: Vec = (0..period) + .map(|i| (-((i as f64 - m).powi(2)) / denom).exp()) + .collect(); + let sum: f64 = raw.iter().sum(); + for w in &mut raw { + *w /= sum; + } + Ok(Self { + period, + offset, + sigma, + weights: raw, + window: VecDeque::with_capacity(period), + current: None, + }) + } + + /// Construct ALMA with the community-standard parameters + /// `(period = 9, offset = 0.85, sigma = 6.0)`. + pub fn classic() -> Self { + Self::new(9, 0.85, 6.0).expect("classic ALMA parameters are valid") + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured offset. + pub const fn offset(&self) -> f64 { + self.offset + } + + /// Configured sigma. + pub const fn sigma(&self) -> f64 { + self.sigma + } +} + +impl Indicator for Alma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut acc = 0.0; + for (w, p) in self.weights.iter().zip(self.window.iter()) { + acc += w * p; + } + self.current = Some(acc); + Some(acc) + } + + fn reset(&mut self) { + self.window.clear(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "ALMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Alma::new(0, 0.85, 6.0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_offset() { + assert!(matches!( + Alma::new(9, -0.1, 6.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, 1.1, 6.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, f64::NAN, 6.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn rejects_invalid_sigma() { + assert!(matches!( + Alma::new(9, 0.85, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, 0.85, -1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Alma::new(9, 0.85, f64::INFINITY), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let alma = Alma::new(9, 0.85, 6.0).unwrap(); + assert_eq!(alma.period(), 9); + assert_eq!(alma.warmup_period(), 9); + assert_eq!(alma.name(), "ALMA"); + assert!((alma.offset() - 0.85).abs() < 1e-12); + assert!((alma.sigma() - 6.0).abs() < 1e-12); + // Weights are normalised by construction. + let sum: f64 = alma.weights.iter().sum(); + assert_relative_eq!(sum, 1.0, epsilon = 1e-12); + } + + #[test] + fn classic_factory() { + let a = Alma::classic(); + assert_eq!(a.period(), 9); + assert!((a.offset() - 0.85).abs() < 1e-12); + assert!((a.sigma() - 6.0).abs() < 1e-12); + } + + #[test] + fn constant_series_yields_the_constant() { + // Normalised weights sum to 1, so any constant is reproduced exactly. + let mut alma = Alma::new(9, 0.85, 6.0).unwrap(); + let out = alma.batch(&[42.0_f64; 40]); + for v in out.iter().skip(8).flatten() { + assert_relative_eq!(*v, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut alma = Alma::new(5, 0.85, 6.0).unwrap(); + for i in 0..4 { + assert_eq!(alma.update(f64::from(i)), None); + } + assert!(alma.update(4.0).is_some()); + } + + #[test] + fn reference_value_period_3() { + // ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30]. + // m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5. + // Independently compute the normalised Gaussian weights and the + // expected weighted sum, then check the indicator output matches. + // Computing the expectation here (rather than pinning a printed + // constant) keeps the test stable across libm `exp` implementations. + let mut alma = Alma::new(3, 0.85, 6.0).unwrap(); + alma.update(10.0); + alma.update(20.0); + let v = alma.update(30.0).expect("ALMA emits after period"); + + let w0 = (-((0.0_f64 - 1.7).powi(2)) / 0.5).exp(); + let w1 = (-((1.0_f64 - 1.7).powi(2)) / 0.5).exp(); + let w2 = (-((2.0_f64 - 1.7).powi(2)) / 0.5).exp(); + let s = w0 + w1 + w2; + let expected = (10.0 * w0 + 20.0 * w1 + 30.0 * w2) / s; + + // The weighted sum is heavily skewed toward the newest sample so the + // output must sit close to but below the latest input (30). + assert!(v > 25.0 && v < 30.0, "ALMA(3) on [10,20,30] = {v}"); + assert_relative_eq!(v, expected, epsilon = 1e-12); + } + + #[test] + fn offset_zero_centres_on_oldest_sample() { + // With offset = 0 the Gaussian peaks at index 0, so ALMA leans toward + // the oldest sample in the window and away from the newest. + let mut alma = Alma::new(5, 0.0, 6.0).unwrap(); + let series: Vec = (1..=5).map(f64::from).collect(); + let mut last = None; + for p in &series { + last = alma.update(*p); + } + let v = last.unwrap(); + let mean = series.iter().sum::() / series.len() as f64; + // Oldest sample is 1.0, mean is 3.0; an offset-0 ALMA should sit + // strictly below the mean. + assert!(v < mean, "{v} should be less than {mean}"); + } + + #[test] + fn offset_one_centres_on_newest_sample() { + // Symmetric to the above: offset = 1 leans toward the newest sample. + let mut alma = Alma::new(5, 1.0, 6.0).unwrap(); + let series: Vec = (1..=5).map(f64::from).collect(); + let mut last = None; + for p in &series { + last = alma.update(*p); + } + let v = last.unwrap(); + let mean = series.iter().sum::() / series.len() as f64; + assert!(v > mean, "{v} should exceed {mean}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100) + .map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = Alma::new(9, 0.85, 6.0).unwrap(); + let mut b = Alma::new(9, 0.85, 6.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut alma = Alma::new(9, 0.85, 6.0).unwrap(); + alma.batch(&(1..=40).map(f64::from).collect::>()); + assert!(alma.is_ready()); + alma.reset(); + assert!(!alma.is_ready()); + assert_eq!(alma.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut alma = Alma::new(5, 0.85, 6.0).unwrap(); + alma.batch(&(1..=5).map(f64::from).collect::>()); + let before = alma.update(6.0).unwrap(); + // Non-finite inputs leave the window/current untouched. + assert_eq!(alma.update(f64::NAN), Some(before)); + assert_eq!(alma.update(f64::INFINITY), Some(before)); + } +} diff --git a/crates/wickra-core/src/indicators/alpha.rs b/crates/wickra-core/src/indicators/alpha.rs new file mode 100644 index 0000000..294b8a7 --- /dev/null +++ b/crates/wickra-core/src/indicators/alpha.rs @@ -0,0 +1,220 @@ +//! Rolling Jensen's Alpha (CAPM). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Jensen's Alpha. +/// +/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over +/// the trailing window of `period` pairs: +/// +/// ```text +/// Beta = cov(asset, bench) / var(bench) +/// Alpha = mean(asset) − ( risk_free + Beta · (mean(bench) − risk_free) ) +/// ``` +/// +/// Alpha is the *risk-adjusted excess return* — the slice of the asset's +/// performance that cannot be explained by simple exposure to the +/// benchmark. A positive alpha indicates outperformance net of the market +/// premium implied by the asset's beta; negative alpha is the opposite. +/// +/// Population covariance and variance are used (matching common +/// implementations in pandas-ta / quantstats); the rolling estimator stays +/// unbiased in the steady state for fixed `period`. +/// +/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to +/// `alpha = mean(asset) − risk_free` — the asset's mean excess return, with +/// no market-risk adjustment, since the regression slope is undefined. +/// +/// Each `update` is O(1). +#[derive(Debug, Clone)] +pub struct Alpha { + period: usize, + risk_free: f64, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl Alpha { + /// Construct a new rolling Alpha. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize, risk_free: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "alpha needs period >= 2", + }); + } + Ok(Self { + period, + risk_free, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured per-period risk-free rate. + pub const fn risk_free(&self) -> f64 { + self.risk_free + } +} + +impl Indicator for Alpha { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((a, b)); + self.sum_a += a; + self.sum_b += b; + self.sum_bb += b * b; + self.sum_ab += a * b; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n) - mean_b * mean_b; + if var_b <= 0.0 { + // Undefined beta: report unadjusted excess. + return Some(mean_a - self.risk_free); + } + let cov_ab = (self.sum_ab / n) - mean_a * mean_b; + let beta = cov_ab / var_b; + Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free))) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Alpha" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_less_than_two() { + assert!(matches!( + Alpha::new(1, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let a = Alpha::new(20, 0.001).unwrap(); + assert_eq!(a.period(), 20); + assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12); + assert_eq!(a.name(), "Alpha"); + assert_eq!(a.warmup_period(), 20); + } + + #[test] + fn capm_perfect_fit_yields_zero_alpha() { + // asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the + // CAPM-implied return matches the asset's mean perfectly. + let mut a = Alpha::new(20, 0.0).unwrap(); + let inputs: Vec<(f64, f64)> = (1..=20) + .map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01)) + .collect(); + let out = a.batch(&inputs); + assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn constant_alpha_offset_recovered() { + // asset = bench + 0.005 (additive alpha of 0.5%), beta == 1. + // Expected alpha = 0.005. + let mut a = Alpha::new(20, 0.0).unwrap(); + let inputs: Vec<(f64, f64)> = (1..=20) + .map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01)) + .collect(); + let out = a.batch(&inputs); + assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9); + } + + #[test] + fn flat_benchmark_falls_back_to_excess_return() { + // Benchmark all 0 -> beta undefined -> alpha = mean_a - rf. + let mut a = Alpha::new(4, 0.001).unwrap(); + let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]); + let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0; + assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12); + } + + #[test] + fn ignores_non_finite_input() { + let mut a = Alpha::new(3, 0.0).unwrap(); + assert_eq!(a.update((f64::NAN, 0.0)), None); + assert_eq!(a.update((0.0, f64::INFINITY)), None); + } + + #[test] + fn reset_clears_state() { + let mut a = Alpha::new(3, 0.0).unwrap(); + a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update((0.01, 0.005)), None); + } + + #[test] + fn batch_equals_streaming() { + let inputs: Vec<(f64, f64)> = (0..50) + .map(|i| { + let b = (f64::from(i) * 0.2).sin() * 0.01; + (1.5 * b + 0.002, b) + }) + .collect(); + let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs); + let mut s = Alpha::new(10, 0.0).unwrap(); + let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/amihud_illiquidity.rs b/crates/wickra-core/src/indicators/amihud_illiquidity.rs new file mode 100644 index 0000000..cef52a1 --- /dev/null +++ b/crates/wickra-core/src/indicators/amihud_illiquidity.rs @@ -0,0 +1,239 @@ +//! Amihud Illiquidity — average price impact per unit traded value. + +use std::collections::VecDeque; + +use crate::microstructure::Trade; +use crate::traits::Indicator; +use crate::{Error, Result}; + +/// Amihud Illiquidity — the average absolute log return per unit of traded +/// value over the last `period` trades (Amihud, 2002). +/// +/// ```text +/// rₜ = ln(priceₜ / priceₜ₋₁) +/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume) +/// Amihud = mean of ILLIQ over the last `period` trades +/// ``` +/// +/// Amihud's measure captures how much the price moves for a given amount of +/// traded value: a **high** reading means small volume already shifts the price +/// a lot (an illiquid, easily-moved market), a **low** reading means it takes +/// large volume to move the price (a deep, liquid market). It is the workhorse +/// cross-sectional liquidity proxy in market-microstructure research. +/// +/// `Input = Trade`. Trades with zero size carry no traded value and are skipped +/// (the ratio is undefined); the last value is returned and state is untouched. +/// The first valid trade only seeds the reference price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity}; +/// +/// let mut amihud = AmihudIlliquidity::new(20).unwrap(); +/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None); +/// ``` +#[derive(Debug, Clone)] +pub struct AmihudIlliquidity { + period: usize, + prev_price: Option, + window: VecDeque, + sum: f64, + last: Option, +} + +impl AmihudIlliquidity { + /// Construct a new Amihud Illiquidity over the given trade window. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_price: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for AmihudIlliquidity { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + // A zero-size trade has no traded value: the ratio is undefined, so the + // trade is skipped without touching the reference price. + if trade.size == 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(trade.price); + return None; + }; + self.prev_price = Some(trade.price); + // `prev` and `trade.price` are both finite and strictly positive + // (enforced by `Trade::new`), so the log return is well-defined and the + // traded value is strictly positive. + let ret = (trade.price / prev).ln().abs(); + let illiq = ret / (trade.price * trade.size); + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + } + self.window.push_back(illiq); + self.sum += illiq; + if self.window.len() < self.period { + return None; + } + let value = self.sum / self.period as f64; + self.last = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AmihudIlliquidity" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn trade(price: f64, size: f64) -> Trade { + Trade::new(price, size, Side::Buy, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let a = AmihudIlliquidity::new(20).unwrap(); + assert_eq!(a.period(), 20); + assert_eq!(a.warmup_period(), 21); + assert_eq!(a.name(), "AmihudIlliquidity"); + assert!(!a.is_ready()); + } + + #[test] + fn known_value() { + // period 1. Seed at 100, then 101 with size 10: + // |ln(101/100)| / (101 * 10). + let mut a = AmihudIlliquidity::new(1).unwrap(); + assert_eq!(a.update(trade(100.0, 10.0)), None); + let out = a.update(trade(101.0, 10.0)).unwrap(); + let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0); + assert_relative_eq!(out, expected, epsilon = 1e-15); + } + + #[test] + fn higher_for_thinner_volume() { + // Same price move on smaller volume => larger illiquidity reading. + let thin = { + let mut a = AmihudIlliquidity::new(1).unwrap(); + a.update(trade(100.0, 1.0)); + a.update(trade(101.0, 1.0)).unwrap() + }; + let thick = { + let mut a = AmihudIlliquidity::new(1).unwrap(); + a.update(trade(100.0, 1000.0)); + a.update(trade(101.0, 1000.0)).unwrap() + }; + assert!(thin > thick, "thin {thin} should exceed thick {thick}"); + } + + #[test] + fn flat_price_is_zero() { + let mut a = AmihudIlliquidity::new(5).unwrap(); + for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-15); + } + } + + #[test] + fn skips_zero_size_trades() { + let mut a = AmihudIlliquidity::new(1).unwrap(); + a.update(trade(100.0, 10.0)); + let baseline = a.update(trade(101.0, 10.0)).unwrap(); + // A zero-size trade is ignored; the previous reference price is kept. + assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline)); + // The next real trade still references price 101, not 200. + let mut control = a.clone(); + let after = a.update(trade(102.0, 10.0)).unwrap(); + assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after); + } + + #[test] + fn output_is_non_negative() { + let mut a = AmihudIlliquidity::new(10).unwrap(); + let trades: Vec = (0..100) + .map(|i| { + trade( + 100.0 + (f64::from(i) * 0.3).sin() * 5.0, + 1.0 + f64::from(i % 7), + ) + }) + .collect(); + for v in a.batch(&trades).into_iter().flatten() { + assert!(v >= 0.0, "illiquidity must be non-negative, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut a = AmihudIlliquidity::new(5).unwrap(); + for i in 0..20 { + a.update(trade(100.0 + f64::from(i), 2.0)); + } + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(trade(100.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..80) + .map(|i| { + trade( + 100.0 + (f64::from(i) * 0.25).sin() * 4.0, + 1.0 + f64::from(i % 5), + ) + }) + .collect(); + let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades); + let mut b = AmihudIlliquidity::new(14).unwrap(); + let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/anchored_rsi.rs b/crates/wickra-core/src/indicators/anchored_rsi.rs new file mode 100644 index 0000000..7d26356 --- /dev/null +++ b/crates/wickra-core/src/indicators/anchored_rsi.rs @@ -0,0 +1,284 @@ +//! Anchored Relative Strength Index. + +use crate::traits::Indicator; + +/// Anchored RSI — a cumulative Relative Strength Index whose averaging begins at +/// a user-chosen anchor bar rather than over a fixed Wilder period. +/// +/// Where [`crate::Rsi`] uses Wilder's `period`-length smoothing, Anchored RSI +/// accumulates *every* up- and down-move since the anchor with equal weight, so +/// it answers "what is the RSI of the entire move since the anchor point?". The +/// running relative strength is `Σ gains / Σ losses` over all bars in the +/// current anchor window (the bar count cancels, so this equals +/// `avg_gain / avg_loss`): +/// +/// ```text +/// RSI_t = 100 - 100 / (1 + Σ_{i ≥ anchor} gain_i / Σ_{i ≥ anchor} loss_i) +/// ``` +/// +/// As with [`crate::AnchoredVwap`], the anchor is chosen at runtime: +/// [`AnchoredRsi::set_anchor`] re-anchors at the **next** bar that arrives, +/// clearing the running sums. Because RSI needs a price *change*, the first bar +/// of a fresh anchor window only seeds the previous close and emits `None`; the +/// first value follows on the second bar (warmup period 2). +/// +/// Saturation follows the standard convention: a window with no losses yet (and +/// at least one gain) reads 100, no gains yet reads 0, and a perfectly flat +/// window reads the neutral 50. Non-finite inputs are ignored, leaving the last +/// value unchanged. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AnchoredRsi, Indicator}; +/// +/// let mut indicator = AnchoredRsi::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let price = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; +/// // Re-anchor at bar 40 (e.g. a major swing low). +/// if i == 40 { +/// indicator.set_anchor(); +/// } +/// last = indicator.update(price); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AnchoredRsi { + prev_close: Option, + sum_gain: f64, + sum_loss: f64, + last_value: Option, + pending_anchor: bool, +} + +impl AnchoredRsi { + /// Construct a fresh Anchored RSI. The first bar to arrive is the anchor. + pub const fn new() -> Self { + Self { + prev_close: None, + sum_gain: 0.0, + sum_loss: 0.0, + last_value: None, + pending_anchor: false, + } + } + + /// Mark a re-anchor: the **next** [`Indicator::update`] call clears the + /// running sums and previous close before folding in its own bar, starting + /// a fresh anchored window. + pub fn set_anchor(&mut self) { + self.pending_anchor = true; + } + + /// Current anchored RSI value if at least one price change has been + /// observed in the current anchor window. + pub const fn value(&self) -> Option { + self.last_value + } + + fn rsi_from_sums(sum_gain: f64, sum_loss: f64) -> f64 { + if sum_loss == 0.0 { + if sum_gain == 0.0 { + // No movement at all -> RSI undefined; standard convention returns 50. + 50.0 + } else { + 100.0 + } + } else { + let rs = sum_gain / sum_loss; + 100.0 - 100.0 / (1.0 + rs) + } + } +} + +impl Indicator for AnchoredRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + + if self.pending_anchor { + self.prev_close = None; + self.sum_gain = 0.0; + self.sum_loss = 0.0; + self.last_value = None; + self.pending_anchor = false; + } + + let Some(prev) = self.prev_close else { + self.prev_close = Some(input); + return None; + }; + self.prev_close = Some(input); + + let diff = input - prev; + if diff > 0.0 { + self.sum_gain += diff; + } else if diff < 0.0 { + self.sum_loss -= diff; + } + + let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss); + self.last_value = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.prev_close = None; + self.sum_gain = 0.0; + self.sum_loss = 0.0; + self.last_value = None; + self.pending_anchor = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "AnchoredRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = AnchoredRsi::new(); + assert_eq!(indicator.name(), "AnchoredRSI"); + assert_eq!(indicator.warmup_period(), 2); + assert_eq!(indicator.value(), None); + assert!(!indicator.is_ready()); + } + + #[test] + fn first_bar_seeds_and_returns_none() { + let mut indicator = AnchoredRsi::new(); + assert_eq!(indicator.update(100.0), None); + assert!(!indicator.is_ready()); + // Second bar produces the first value. + assert!(indicator.update(101.0).is_some()); + assert!(indicator.is_ready()); + } + + #[test] + fn pure_uptrend_saturates_at_100() { + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]); + assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn pure_downtrend_saturates_at_0() { + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]); + assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn flat_window_reads_50() { + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[42.0, 42.0, 42.0]); + assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12); + } + + #[test] + fn cumulative_reference_values() { + // prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3) + // after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333 + // after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667 + let mut indicator = AnchoredRsi::new(); + let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]); + assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6); + assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6); + } + + #[test] + fn set_anchor_clears_old_window() { + // Downtrend, then re-anchor and pump an uptrend: the new window must + // read 100, not the blended value. + let mut indicator = AnchoredRsi::new(); + indicator.batch(&[20.0, 19.0, 18.0, 17.0]); + assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12); + indicator.set_anchor(); + // First bar after anchor re-seeds (None), second bar emits. + assert_eq!(indicator.update(50.0), None); + let after = indicator.update(51.0).unwrap(); + assert_relative_eq!(after, 100.0, epsilon = 1e-12); + } + + #[test] + fn set_anchor_before_first_bar_acts_as_normal_start() { + let mut indicator = AnchoredRsi::new(); + indicator.set_anchor(); + assert_eq!(indicator.update(10.0), None); + assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn ignores_non_finite_input() { + let mut indicator = AnchoredRsi::new(); + indicator.batch(&[10.0, 11.0, 12.0]); + let before = indicator.value(); + assert!(before.is_some()); + assert_eq!(indicator.update(f64::NAN), before); + assert_eq!(indicator.update(f64::INFINITY), before); + assert_eq!(indicator.value(), before); + } + + #[test] + fn non_finite_before_any_bar_returns_none() { + let mut indicator = AnchoredRsi::new(); + assert_eq!(indicator.update(f64::NAN), None); + assert!(!indicator.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut indicator = AnchoredRsi::new(); + indicator.batch(&[10.0, 11.0, 12.0]); + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + assert_eq!(indicator.value(), None); + assert_eq!(indicator.update(50.0), None); + } + + #[test] + fn stays_in_0_100_range() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0) + .collect(); + let mut indicator = AnchoredRsi::new(); + for value in indicator.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i)) + .collect(); + let mut a = AnchoredRsi::new(); + let mut b = AnchoredRsi::new(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/anchored_vwap.rs b/crates/wickra-core/src/indicators/anchored_vwap.rs new file mode 100644 index 0000000..593b024 --- /dev/null +++ b/crates/wickra-core/src/indicators/anchored_vwap.rs @@ -0,0 +1,207 @@ +//! Anchored Volume-Weighted Average Price. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Anchored VWAP — a cumulative VWAP whose accumulation begins at a +/// user-chosen anchor bar rather than the session open. +/// +/// ```text +/// AVWAP_t = Σ_{i ≥ anchor} (typical_price_i · volume_i) / Σ_{i ≥ anchor} volume_i +/// ``` +/// +/// The indicator emits `None` until the first anchored bar has been ingested. +/// Calling [`AnchoredVwap::set_anchor`] re-anchors at the **next** bar that +/// arrives, clearing the running sums; this is the conventional behaviour for +/// "click to anchor" trader workflows where the anchor is set on the close of +/// a swing point and the next bar starts the new accumulation. The cumulative +/// total is unbounded; for finite-memory needs use [`crate::RollingVwap`]. +/// +/// Bars where the running volume is still zero (only happens if every anchored +/// bar so far carried zero volume) return `None` to avoid a zero-division. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AnchoredVwap, Candle, Indicator}; +/// +/// let mut indicator = AnchoredVwap::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// // Re-anchor at bar 40 (e.g. a major swing low). +/// if i == 40 { +/// indicator.set_anchor(); +/// } +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AnchoredVwap { + sum_pv: f64, + sum_v: f64, + has_emitted: bool, + pending_anchor: bool, +} + +impl AnchoredVwap { + /// Construct a fresh Anchored VWAP. The first bar to arrive is the anchor. + pub const fn new() -> Self { + Self { + sum_pv: 0.0, + sum_v: 0.0, + has_emitted: false, + pending_anchor: false, + } + } + + /// Mark a re-anchor: the **next** [`Indicator::update`] call clears the + /// running sums before adding its own contribution, effectively starting a + /// fresh anchored window. + pub fn set_anchor(&mut self) { + self.pending_anchor = true; + } + + /// Current anchored value if at least one bar with non-zero volume has + /// been observed in the current anchor window. + pub fn value(&self) -> Option { + if self.sum_v == 0.0 { + None + } else { + Some(self.sum_pv / self.sum_v) + } + } +} + +impl Indicator for AnchoredVwap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.pending_anchor { + // Drop the old window before folding in this bar. + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + self.pending_anchor = false; + } + let tp = candle.typical_price(); + self.sum_pv += tp * candle.volume; + self.sum_v += candle.volume; + if self.sum_v == 0.0 { + return None; + } + self.has_emitted = true; + Some(self.sum_pv / self.sum_v) + } + + fn reset(&mut self) { + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + self.pending_anchor = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AnchoredVWAP" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(price: f64, volume: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let v = AnchoredVwap::new(); + assert_eq!(v.name(), "AnchoredVWAP"); + assert_eq!(v.warmup_period(), 1); + assert_eq!(v.value(), None); + } + + #[test] + fn first_bar_with_zero_volume_returns_none() { + let mut v = AnchoredVwap::new(); + assert_eq!(v.update(c(50.0, 0.0, 0)), None); + assert!(!v.is_ready()); + // The next bar with volume still works. + assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12); + } + + #[test] + fn equal_volumes_yield_mean_typical_price() { + // typical_price of a flat OHLC bar equals the price. + let mut v = AnchoredVwap::new(); + let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]); + assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12); + } + + #[test] + fn set_anchor_clears_old_window() { + // Run a few bars at price 10, then re-anchor and pump in price 100. + // After the re-anchor the running mean must be 100, not the mix. + let mut v = AnchoredVwap::new(); + v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]); + assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12); + v.set_anchor(); + let after = v.update(c(100.0, 5.0, 3)).unwrap(); + assert_relative_eq!(after, 100.0, epsilon = 1e-12); + } + + #[test] + fn set_anchor_before_first_bar_acts_as_normal_first_bar() { + // Calling set_anchor on an empty indicator should be a no-op effect: + // the first bar still anchors the window. + let mut v = AnchoredVwap::new(); + v.set_anchor(); + assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12); + } + + #[test] + fn weighted_average_reference() { + // Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5. + let mut v = AnchoredVwap::new(); + let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]); + assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect(); + let mut a = AnchoredVwap::new(); + let mut b = AnchoredVwap::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut v = AnchoredVwap::new(); + v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + // After reset the first bar acts as the new anchor. + assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/andrews_pitchfork.rs b/crates/wickra-core/src/indicators/andrews_pitchfork.rs new file mode 100644 index 0000000..c8b3ac1 --- /dev/null +++ b/crates/wickra-core/src/indicators/andrews_pitchfork.rs @@ -0,0 +1,353 @@ +//! Andrews Pitchfork — median line and parallels off the last three swing pivots. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`AndrewsPitchfork`]: the three pitchfork lines projected to the +/// current bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AndrewsPitchforkOutput { + /// The median line — from the handle pivot through the midpoint of the other two. + pub median: f64, + /// The upper parallel (through the higher of the two anchor pivots). + pub upper: f64, + /// The lower parallel (through the lower of the two anchor pivots). + pub lower: f64, +} + +/// A confirmed swing pivot: its bar index and price. +#[derive(Debug, Clone, Copy)] +struct Pivot { + index: f64, + price: f64, + is_high: bool, +} + +/// Andrews Pitchfork — Alan Andrews' median-line tool drawn from the three most +/// recent **swing pivots**, projected forward to the current bar. +/// +/// ```text +/// detect alternating swing highs/lows with a `strength`-bar fractal +/// P0 = handle (oldest of the last three), P1, P2 = the next two +/// M = midpoint of P1 and P2 +/// median(t) = P0 + slope·(t − t0) slope = (M − P0) / (M_t − t0) +/// upper / lower = median(t) offset by the vertical gap to the higher / lower anchor +/// ``` +/// +/// The pitchfork projects a "fork" of three parallel lines: a central **median +/// line** drawn from a starting pivot through the midpoint of a later swing, plus +/// two parallels passing through that swing's high and low. Price tends to +/// oscillate around the median line and find support/resistance at the parallels. +/// This streaming version detects the pivots automatically with a symmetric +/// fractal of half-width `strength` (so each pivot is confirmed `strength` bars +/// late) and keeps the three most recent alternating swings. +/// +/// Because it depends on swing structure, readiness is **data-dependent**: the +/// first output appears once three alternating pivots have been confirmed. +/// `warmup_period` returns the minimum bars to confirm a single pivot. Each +/// `update` is O(`strength`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AndrewsPitchfork}; +/// +/// let mut indicator = AndrewsPitchfork::new(2).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 10.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// // A swinging series eventually establishes a pitchfork. +/// let _ = last; +/// ``` +#[derive(Debug, Clone)] +pub struct AndrewsPitchfork { + strength: usize, + window: VecDeque, + pivots: Vec, + count: usize, + last: Option, +} + +impl AndrewsPitchfork { + /// Construct an Andrews Pitchfork with the given fractal `strength` (bars on + /// each side of a pivot). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `strength == 0`. + pub fn new(strength: usize) -> Result { + if strength == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + strength, + window: VecDeque::with_capacity(2 * strength + 1), + pivots: Vec::new(), + count: 0, + last: None, + }) + } + + /// Configured fractal strength. + pub const fn strength(&self) -> usize { + self.strength + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + /// Record a freshly confirmed pivot, keeping the last three alternating swings. + fn record_pivot(&mut self, pivot: Pivot) { + if let Some(last) = self.pivots.last_mut() { + if last.is_high == pivot.is_high { + // Same kind: keep the more extreme one (and its index). + let more_extreme = if pivot.is_high { + pivot.price > last.price + } else { + pivot.price < last.price + }; + if more_extreme { + *last = pivot; + } + return; + } + } + self.pivots.push(pivot); + if self.pivots.len() > 3 { + self.pivots.remove(0); + } + } + + fn project(&self, tc: f64) -> Option { + let [p0, p1, p2] = self.pivots.as_slice() else { + return None; + }; + let mid_t = f64::midpoint(p1.index, p2.index); + let mid_p = f64::midpoint(p1.price, p2.price); + let slope = (mid_p - p0.price) / (mid_t - p0.index); + let median = p0.price + slope * (tc - p0.index); + let off1 = p1.price - (p0.price + slope * (p1.index - p0.index)); + let off2 = p2.price - (p0.price + slope * (p2.index - p0.index)); + Some(AndrewsPitchforkOutput { + median, + upper: median + off1.max(off2), + lower: median + off1.min(off2), + }) + } +} + +impl Indicator for AndrewsPitchfork { + type Input = Candle; + type Output = AndrewsPitchforkOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.count += 1; + let span = 2 * self.strength + 1; + if self.window.len() == span { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() == span { + let center = self.window[self.strength]; + let is_high = self + .window + .iter() + .enumerate() + .all(|(i, c)| i == self.strength || c.high < center.high); + let is_low = self + .window + .iter() + .enumerate() + .all(|(i, c)| i == self.strength || c.low > center.low); + // Absolute index of the center bar (1-based count minus the right span). + let center_index = (self.count - 1 - self.strength) as f64; + if is_high && !is_low { + self.record_pivot(Pivot { + index: center_index, + price: center.high, + is_high: true, + }); + } else if is_low && !is_high { + self.record_pivot(Pivot { + index: center_index, + price: center.low, + is_high: false, + }); + } + } + let tc = (self.count - 1) as f64; + if let Some(out) = self.project(tc) { + self.last = Some(out); + return Some(out); + } + None + } + + fn reset(&mut self) { + self.window.clear(); + self.pivots.clear(); + self.count = 0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 * self.strength + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AndrewsPitchfork" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64) -> Candle { + Candle::new_unchecked( + f64::midpoint(high, low), + high, + low, + f64::midpoint(high, low), + 1_000.0, + 0, + ) + } + + /// A clean zig-zag that prints alternating swing highs and lows. + fn zigzag() -> Vec { + let mut out = Vec::new(); + for i in 0..120 { + let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0; + out.push(c(base + 1.0, base - 1.0)); + } + out + } + + #[test] + fn rejects_zero_strength() { + assert!(matches!(AndrewsPitchfork::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let p = AndrewsPitchfork::new(2).unwrap(); + assert_eq!(p.strength(), 2); + assert_eq!(p.warmup_period(), 5); + assert_eq!(p.name(), "AndrewsPitchfork"); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn none_before_three_pivots() { + let mut p = AndrewsPitchfork::new(2).unwrap(); + // Too few bars to ever confirm three alternating pivots. + let out = p.batch(&[c(101.0, 99.0), c(102.0, 100.0), c(101.0, 99.0)]); + assert!(out.iter().all(Option::is_none)); + } + + #[test] + fn eventually_emits_on_swings() { + let mut p = AndrewsPitchfork::new(2).unwrap(); + let out = p.batch(&zigzag()); + assert!( + out.iter().any(Option::is_some), + "a swinging series should form a pitchfork" + ); + assert!(p.is_ready()); + } + + #[test] + fn upper_at_or_above_lower() { + let mut p = AndrewsPitchfork::new(2).unwrap(); + for o in p.batch(&zigzag()).into_iter().flatten() { + assert!( + o.upper >= o.lower, + "upper {} below lower {}", + o.upper, + o.lower + ); + } + } + + #[test] + fn reset_clears_state() { + let mut p = AndrewsPitchfork::new(2).unwrap(); + p.batch(&zigzag()); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + assert_eq!(p.strength(), 2); + } + + #[test] + fn record_pivot_keeps_more_extreme_same_kind() { + let mut p = AndrewsPitchfork::new(2).unwrap(); + p.record_pivot(Pivot { + index: 0.0, + price: 100.0, + is_high: true, + }); + // A higher high of the same kind replaces the stored one. + p.record_pivot(Pivot { + index: 1.0, + price: 105.0, + is_high: true, + }); + assert_eq!(p.pivots.len(), 1); + assert_eq!(p.pivots[0].price, 105.0); + // A lower high of the same kind is ignored. + p.record_pivot(Pivot { + index: 2.0, + price: 102.0, + is_high: true, + }); + assert_eq!(p.pivots.len(), 1); + assert_eq!(p.pivots[0].price, 105.0); + // A low pivot of the other kind is appended. + p.record_pivot(Pivot { + index: 3.0, + price: 90.0, + is_high: false, + }); + assert_eq!(p.pivots.len(), 2); + // A lower low of the same kind replaces the stored low. + p.record_pivot(Pivot { + index: 4.0, + price: 85.0, + is_high: false, + }); + assert_eq!(p.pivots[1].price, 85.0); + // A higher low of the same kind is ignored. + p.record_pivot(Pivot { + index: 5.0, + price: 88.0, + is_high: false, + }); + assert_eq!(p.pivots[1].price, 85.0); + } + + #[test] + fn batch_equals_streaming() { + let candles = zigzag(); + let batch = AndrewsPitchfork::new(2).unwrap().batch(&candles); + let mut b = AndrewsPitchfork::new(2).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/apo.rs b/crates/wickra-core/src/indicators/apo.rs new file mode 100644 index 0000000..6b5e242 --- /dev/null +++ b/crates/wickra-core/src/indicators/apo.rs @@ -0,0 +1,183 @@ +//! Absolute Price Oscillator (APO). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Absolute Price Oscillator — the raw difference between a fast and a slow +/// `EMA`. This is MACD's line without the signal-EMA — useful when only the +/// momentum-direction reading is needed. +/// +/// ```text +/// APO_t = EMA(close, fast)_t − EMA(close, slow)_t +/// ``` +/// +/// Default parameters mirror MACD: `(fast = 12, slow = 26)`. `fast` must be +/// strictly less than `slow`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Apo, Indicator}; +/// +/// let mut apo = Apo::new(12, 26).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = apo.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Apo { + fast_period: usize, + slow_period: usize, + fast: Ema, + slow: Ema, +} + +impl Apo { + /// # Errors + /// - [`Error::PeriodZero`] if either period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "APO fast period must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + }) + } + + /// MACD-style defaults: `(fast = 12, slow = 26)`. + pub fn classic() -> Self { + Self::new(12, 26).expect("classic APO parameters are valid") + } + + /// Configured `(fast, slow)`. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for Apo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both EMAs on every input so the slow one warms in parallel. + let f = self.fast.update(input); + let s = self.slow.update(input); + Some(f? - s?) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + // Slow EMA dominates; both EMAs emit at their `period` th input. + self.slow_period + } + + fn is_ready(&self) -> bool { + self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "APO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero))); + assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let apo = Apo::classic(); + assert_eq!(apo.periods(), (12, 26)); + assert_eq!(apo.warmup_period(), 26); + assert_eq!(apo.name(), "APO"); + } + + #[test] + fn classic_factory() { + assert_eq!(Apo::classic().periods(), (12, 26)); + } + + #[test] + fn constant_series_converges_to_zero() { + // Both EMAs reproduce the constant exactly, so APO is 0. + let mut apo = Apo::new(3, 5).unwrap(); + let out = apo.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_slow_period() { + let mut apo = Apo::new(2, 4).unwrap(); + assert_eq!(apo.warmup_period(), 4); + for i in 1..=3 { + assert_eq!(apo.update(f64::from(i)), None); + } + assert!(apo.update(4.0).is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Fast EMA leads the slow EMA on an uptrend, so APO > 0. + let mut apo = Apo::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + let out = apo.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!(*last > 0.0, "APO on uptrend should be positive: {last}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Apo::classic(); + let mut b = Apo::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut apo = Apo::classic(); + apo.batch(&(1..=80).map(f64::from).collect::>()); + assert!(apo.is_ready()); + apo.reset(); + assert!(!apo.is_ready()); + assert_eq!(apo.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/aroon.rs b/crates/wickra-core/src/indicators/aroon.rs new file mode 100644 index 0000000..0719ec9 --- /dev/null +++ b/crates/wickra-core/src/indicators/aroon.rs @@ -0,0 +1,194 @@ +//! Aroon Up / Down indicator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Aroon output: up and down strengths in [0, 100]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AroonOutput { + /// Time since the highest high, expressed as a percentage of the window. + pub up: f64, + /// Time since the lowest low, same convention. + pub down: f64, +} + +/// Aroon indicator: tracks how many bars since the highest high and lowest low +/// inside a `period + 1`-bar window. Returned as a percentage. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Aroon}; +/// +/// let mut indicator = Aroon::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Aroon { + period: usize, + candles: VecDeque, +} + +impl Aroon { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(period + 1), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Aroon { + type Input = Candle; + type Output = AroonOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.candles.len() == self.period + 1 { + self.candles.pop_front(); + } + self.candles.push_back(candle); + if self.candles.len() < self.period + 1 { + return None; + } + // Find the index (0 = oldest) of the highest high and lowest low. + let (mut hh_idx, mut ll_idx) = (0_usize, 0_usize); + let (mut hh, mut ll) = (f64::NEG_INFINITY, f64::INFINITY); + for (i, c) in self.candles.iter().enumerate() { + if c.high >= hh { + hh = c.high; + hh_idx = i; + } + if c.low <= ll { + ll = c.low; + ll_idx = i; + } + } + let n = self.period as f64; + let up = 100.0 * hh_idx as f64 / n; + let down = 100.0 * ll_idx as f64 / n; + Some(AroonOutput { up, down }) + } + + fn reset(&mut self) { + self.candles.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.candles.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "Aroon" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn pure_uptrend_aroon_up_100() { + let candles: Vec = (1..=15) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = Aroon::new(14).unwrap(); + let last = a.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.up, 100.0, epsilon = 1e-9); + // The lowest low is at the oldest position (index 0). + assert_relative_eq!(last.down, 0.0, epsilon = 1e-9); + } + + #[test] + fn pure_downtrend_aroon_down_100() { + let candles: Vec = (1..=15) + .rev() + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = Aroon::new(14).unwrap(); + let last = a.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.down, 100.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = Aroon::new(14).unwrap(); + let mut b = Aroon::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn outputs_in_range() { + let candles: Vec = (0..200) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = Aroon::new(14).unwrap(); + for o in a.batch(&candles).into_iter().flatten() { + assert!((0.0..=100.0).contains(&o.up)); + assert!((0.0..=100.0).contains(&o.down)); + } + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = Aroon::new(14).unwrap(); + a.batch(&candles); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(candles[0]), None); + } + + /// Cover the const accessor `period` (56-58) and the Indicator-impl + /// `name` body (104-106). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let a = Aroon::new(14).unwrap(); + assert_eq!(a.period(), 14); + assert_eq!(a.name(), "Aroon"); + } +} diff --git a/crates/wickra-core/src/indicators/aroon_oscillator.rs b/crates/wickra-core/src/indicators/aroon_oscillator.rs new file mode 100644 index 0000000..56d586f --- /dev/null +++ b/crates/wickra-core/src/indicators/aroon_oscillator.rs @@ -0,0 +1,200 @@ +//! Aroon Oscillator. + +use crate::error::Result; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +use super::Aroon; + +/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`. +/// +/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator +/// collapses them into one value in `[−100, 100]`: +/// +/// ```text +/// AroonOscillator = AroonUp − AroonDown +/// ``` +/// +/// Strongly positive means the most recent high is much fresher than the most +/// recent low (an up-trend); strongly negative is the mirror image. Readings +/// near zero mean neither extreme is recent — a range. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AroonOscillator}; +/// +/// let mut indicator = AroonOscillator::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + i as f64; +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert_eq!(last, Some(100.0)); // pure uptrend +/// ``` +#[derive(Debug, Clone)] +pub struct AroonOscillator { + aroon: Aroon, + last: Option, +} + +impl AroonOscillator { + /// Construct a new Aroon Oscillator with the given period. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + aroon: Aroon::new(period)?, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.aroon.period() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for AroonOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let osc = self.aroon.update(candle).map(|o| o.up - o.down)?; + self.last = Some(osc); + Some(osc) + } + + fn reset(&mut self) { + self.aroon.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.aroon.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AroonOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(AroonOscillator::new(0).is_err()); + } + + /// Cover the const accessors `period` / `value` (57-64) and the + /// Indicator-impl `name` body (90-92). `warmup_period` is covered + /// already by `warmup_period_matches_aroon`. + #[test] + fn accessors_and_metadata() { + let mut osc = AroonOscillator::new(7).unwrap(); + assert_eq!(osc.period(), 7); + assert_eq!(osc.name(), "AroonOscillator"); + assert_eq!(osc.value(), None); + for i in 0..8 { + osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i))); + } + assert!(osc.value().is_some()); + } + + #[test] + fn pure_uptrend_yields_plus_100() { + // Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0. + let mut osc = AroonOscillator::new(5).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let p = 100.0 + i as f64; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + for v in osc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 100.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_downtrend_yields_minus_100() { + let mut osc = AroonOscillator::new(5).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let p = 100.0 - i as f64; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + for v in osc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, -100.0, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_within_minus_100_and_100() { + let mut osc = AroonOscillator::new(14).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0; + candle(mid + 2.0, mid - 2.0, mid, i) + }) + .collect(); + for v in osc.batch(&candles).into_iter().flatten() { + assert!((-100.0..=100.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn warmup_period_matches_aroon() { + let osc = AroonOscillator::new(7).unwrap(); + assert_eq!(osc.warmup_period(), 8); + } + + #[test] + fn reset_clears_state() { + let mut osc = AroonOscillator::new(5).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0 + i as f64, 90.0, 95.0, i)) + .collect(); + osc.batch(&candles); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + assert_eq!(osc.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(mid + 2.0, mid - 2.0, mid, i) + }) + .collect(); + let batch = AroonOscillator::new(14).unwrap().batch(&candles); + let mut b = AroonOscillator::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/atr.rs b/crates/wickra-core/src/indicators/atr.rs new file mode 100644 index 0000000..32a39ab --- /dev/null +++ b/crates/wickra-core/src/indicators/atr.rs @@ -0,0 +1,437 @@ +//! Average True Range (Wilder). + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Average True Range with Wilder smoothing. +/// +/// The first emitted value, by convention, appears after `period` candles: the +/// first `period − 1` true-range values seed the Wilder average alongside the +/// `period`-th, then the smoothed update begins. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Atr}; +/// +/// let mut indicator = Atr::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Atr { + period: usize, + /// `period - 1` as `f64`, precomputed for the Wilder smoothing step. + n_minus_1: f64, + /// `1 / period`, precomputed so the per-tick smoothing multiplies instead of + /// divides. + inv_period: f64, + prev_close: Option, + seed_buf: Vec, + /// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than + /// `Option` so the hot recurrence avoids an enum-tag read per tick. + avg: f64, + seeded: bool, +} + +impl Atr { + /// Construct an ATR with the given Wilder period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + n_minus_1: (period - 1) as f64, + inv_period: 1.0 / period as f64, + prev_close: None, + seed_buf: Vec::with_capacity(period), + avg: 0.0, + seeded: false, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + if self.seeded { + Some(self.avg) + } else { + None + } + } + + /// Vectorized batch over raw high/low/close columns: one `f64` per bar + /// (`NaN` during warmup). The caller guarantees the three slices are equal + /// length and finite with valid OHLC ordering (the binding validates once up + /// front); ATR only reads high, low and the previous close. + /// + /// For a fresh indicator long enough to seed (`n >= period`) it runs the + /// true-range seed once and then the bare Wilder recurrence in a tight loop — + /// no per-bar `Candle` construction/validation, no `Option`, identical + /// division at the seed and `mul_add` afterwards, so the result is + /// *bit-for-bit* equal to replaying `update` over the same candles. Shorter + /// or non-fresh inputs defer to an exact `update` replay. + pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let p = self.period; + let n = high.len(); + if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p { + let mut out = vec![f64::NAN; n]; + for i in 0..n { + let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0); + if let Some(v) = self.update(candle) { + out[i] = v; + } + } + return out; + } + + // Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`. + let mut out = vec![f64::NAN; p - 1]; + out.reserve(n - (p - 1)); + // Seed: mean of the first `period` true ranges. TR₀ has no previous close. + let mut prev_close = close[0]; + let mut sum_tr = high[0] - low[0]; + self.seed_buf.push(sum_tr); + for i in 1..p { + let (h, l) = (high[i], low[i]); + let tr = (h - l) + .max((h - prev_close).abs()) + .max((l - prev_close).abs()); + prev_close = close[i]; + self.seed_buf.push(tr); + sum_tr += tr; + } + let mut avg = sum_tr / p as f64; + out.push(avg); + // Steady state: Wilder smoothing, reciprocal hoisted out of the loop. + for i in p..n { + let (h, l) = (high[i], low[i]); + let tr = (h - l) + .max((h - prev_close).abs()) + .max((l - prev_close).abs()); + prev_close = close[i]; + avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period; + out.push(avg); + } + + // Leave state where a full `update` replay would (seeded; seed_buf retained). + self.prev_close = Some(prev_close); + self.avg = avg; + self.seeded = true; + out + } +} + +impl Indicator for Atr { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tr = candle.true_range(self.prev_close); + self.prev_close = Some(candle.close); + + if self.seeded { + // Wilder smoothing with the reciprocal hoisted out of the hot path. + let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period; + self.avg = new_avg; + return Some(new_avg); + } + + self.seed_buf.push(tr); + if self.seed_buf.len() == self.period { + let seed = self.seed_buf.iter().copied().sum::() / self.period as f64; + self.avg = seed; + self.seeded = true; + return Some(seed); + } + None + } + + fn reset(&mut self) { + self.prev_close = None; + self.seed_buf.clear(); + self.avg = 0.0; + self.seeded = false; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.seeded + } + + fn name(&self) -> &'static str { + "ATR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + // ts/open/volume don't affect ATR; use safe placeholders. + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + /// Independent reference: Wilder ATR computed straight from the definition. + fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec> { + let n = period as f64; + let mut out = Vec::with_capacity(hlc.len()); + let mut trs: Vec = Vec::new(); + let mut avg: Option = None; + let mut prev_close: Option = None; + for &(h, l, cl) in hlc { + let tr = match prev_close { + None => h - l, + Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()), + }; + prev_close = Some(cl); + if let Some(a) = avg { + let na = (a * (n - 1.0) + tr) / n; + avg = Some(na); + out.push(Some(na)); + } else { + trs.push(tr); + if trs.len() == period { + avg = Some(trs.iter().sum::() / n); + out.push(avg); + } else { + out.push(None); + } + } + } + out + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Atr::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (54-62) and the + /// Indicator-impl `name` body (103-105). Existing tests inspect + /// numeric ATR output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut atr = Atr::new(14).unwrap(); + assert_eq!(atr.period(), 14); + assert_eq!(atr.name(), "ATR"); + assert_eq!(atr.value(), None); + for _ in 0..14 { + atr.update(c(11.0, 9.0, 10.0)); + } + assert!(atr.value().is_some()); + } + + #[test] + fn warmup_emits_on_period_th_candle() { + let candles = vec![ + c(2.0, 1.0, 1.5), + c(3.0, 2.0, 2.5), + c(4.0, 3.0, 3.5), + c(5.0, 4.0, 4.5), + c(6.0, 5.0, 5.5), + ]; + let mut atr = Atr::new(3).unwrap(); + let out = atr.batch(&candles); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_some()); + assert!(out[3].is_some()); + } + + #[test] + fn constant_range_yields_constant_atr() { + // Every candle has H=11, L=9, C=10 -> TR=2 (no gaps). + let candles: Vec = (0..30).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut atr = Atr::new(14).unwrap(); + let out = atr.batch(&candles); + for v in out.iter().skip(13).flatten() { + assert_relative_eq!(*v, 2.0, epsilon = 1e-12); + } + } + + #[test] + fn gap_up_uses_high_minus_prev_close() { + // Previous close 5, current candle H=10 L=9 C=9.5 -> TR = max(1, 5, 4) = 5. + let candles = vec![ + c(6.0, 4.0, 5.0), // prev close = 5 + c(10.0, 9.0, 9.5), // TR = 5 + ]; + let mut atr = Atr::new(2).unwrap(); + let out = atr.batch(&candles); + // Seed window covers TR_1 and TR_2. TR_1 = H1-L1 = 2 (no prev close). TR_2 = 5. + // Seed = (2+5)/2 = 3.5 + assert_relative_eq!(out[1].unwrap(), 3.5, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let mid = f64::from(i) + 10.0; + c(mid + 0.5, mid - 0.5, mid) + }) + .collect(); + let mut a = Atr::new(14).unwrap(); + let mut b = Atr::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut atr = Atr::new(5).unwrap(); + atr.batch(&candles); + assert!(atr.is_ready()); + atr.reset(); + assert!(!atr.is_ready()); + assert_eq!(atr.update(candles[0]), None); + } + + #[test] + fn never_negative() { + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut atr = Atr::new(14).unwrap(); + for v in atr.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "ATR must be non-negative: {v}"); + } + } + + fn bits_eq(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x == y || (x.is_nan() && y.is_nan())) + } + + fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let mut a = Atr::new(period).unwrap(); + (0..high.len()) + .map(|i| { + let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0); + a.update(candle).unwrap_or(f64::NAN) + }) + .collect() + } + + /// Valid OHLC columns from a wandering base price. + fn columns(n: usize) -> (Vec, Vec, Vec) { + let base: Vec = (0..n) + .map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0) + .collect(); + let high = base.iter().map(|b| b + 1.0).collect(); + let low = base.iter().map(|b| b - 1.0).collect(); + (high, low, base) + } + + #[test] + fn batch_atr_fast_path_is_bit_identical() { + let (high, low, close) = columns(300); + let mut atr = Atr::new(14).unwrap(); + let got = atr.batch_atr(&high, &low, &close); + assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close))); + let mut ref_atr = Atr::new(14).unwrap(); + for i in 0..high.len() { + ref_atr.update(Candle::new_unchecked( + close[i], high[i], low[i], close[i], 0.0, 0, + )); + } + let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0); + assert_eq!(atr.update(next), ref_atr.update(next)); + } + + #[test] + fn batch_atr_falls_back_when_not_fresh() { + let (high, low, close) = columns(40); + let mut atr = Atr::new(14).unwrap(); + atr.update(Candle::new_unchecked( + close[0], high[0], low[0], close[0], 0.0, 0, + )); + let mut ref_atr = Atr::new(14).unwrap(); + ref_atr.update(Candle::new_unchecked( + close[0], high[0], low[0], close[0], 0.0, 0, + )); + let want: Vec = (0..high.len()) + .map(|i| { + ref_atr + .update(Candle::new_unchecked( + close[i], high[i], low[i], close[i], 0.0, 0, + )) + .unwrap_or(f64::NAN) + }) + .collect(); + assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want)); + } + + #[test] + fn batch_atr_sub_period_slice_falls_back() { + let (high, low, close) = columns(5); + let mut atr = Atr::new(14).unwrap(); + let got = atr.batch_atr(&high, &low, &close); + assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close))); + assert!(got.iter().all(|x| x.is_nan())); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn atr_matches_naive( + period in 1usize..15, + bars in proptest::collection::vec( + (10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0), + 0..120, + ), + ) { + // bars: (low, range, close_fraction) -> a valid OHLC candle. + let hlc: Vec<(f64, f64, f64)> = bars + .iter() + .map(|&(low, range, frac)| (low + range, low, low + range * frac)) + .collect(); + let candles: Vec = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect(); + let mut atr = Atr::new(period).unwrap(); + let got = atr.batch(&candles); + let want = atr_naive(&hlc, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() <= 1e-9 * a.abs().max(1.0), + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/atr_bands.rs b/crates/wickra-core/src/indicators/atr_bands.rs new file mode 100644 index 0000000..601ad69 --- /dev/null +++ b/crates/wickra-core/src/indicators/atr_bands.rs @@ -0,0 +1,214 @@ +//! ATR Bands. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// ATR Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AtrBandsOutput { + /// Upper band: `close + multiplier · ATR`. + pub upper: f64, + /// Middle band: the current close. + pub middle: f64, + /// Lower band: `close − multiplier · ATR`. + pub lower: f64, +} + +/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`. +/// +/// ```text +/// upper = close + multiplier · ATR(period) +/// lower = close − multiplier · ATR(period) +/// ``` +/// +/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the +/// centerline is the *raw close* rather than a smoothed average — the band +/// rides the price tick-for-tick. This is the standard volatility-targeting +/// envelope traders use to set initial stop-loss and profit targets: an entry +/// at the close sets a `multiplier · ATR` stop and the symmetric target +/// without ever needing to wait for a moving average to warm up. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AtrBands, Candle, Indicator}; +/// +/// let mut indicator = AtrBands::new(14, 3.0).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AtrBands { + atr: Atr, + multiplier: f64, +} + +impl AtrBands { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on + /// invalid inputs. + pub fn new(period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(period)?, + multiplier, + }) + } + + /// Configured ATR period. + pub const fn period(&self) -> usize { + self.atr.period() + } + + /// Configured ATR multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for AtrBands { + type Input = Candle; + type Output = AtrBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + Some(AtrBandsOutput { + upper: candle.close + self.multiplier * atr, + middle: candle.close, + lower: candle.close - self.multiplier * atr, + }) + } + + fn reset(&mut self) { + self.atr.reset(); + } + + fn warmup_period(&self) -> usize { + self.atr.warmup_period() + } + + fn is_ready(&self) -> bool { + self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "AtrBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + AtrBands::new(14, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AtrBands::new(14, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AtrBands::new(14, f64::INFINITY), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let ab = AtrBands::new(14, 3.0).unwrap(); + assert_eq!(ab.period(), 14); + assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12); + assert_eq!(ab.warmup_period(), 14); + assert_eq!(ab.name(), "AtrBands"); + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut ab = AtrBands::new(5, 3.0).unwrap(); + let last = ab.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut ab = AtrBands::new(14, 3.0).unwrap(); + for o in ab.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = AtrBands::new(10, 2.5).unwrap(); + let mut b = AtrBands::new(10, 2.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut ab = AtrBands::new(5, 3.0).unwrap(); + ab.batch(&candles); + assert!(ab.is_ready()); + ab.reset(); + assert!(!ab.is_ready()); + assert_eq!(ab.update(candles[0]), None); + } + + /// Reference: with constant high-low spread of 2, ATR(period) converges to + /// 2 immediately; for multiplier 3 the bands are at `close ± 6`. + #[test] + fn reference_values_constant_spread() { + // Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5. + let candles: Vec = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut ab = AtrBands::new(5, 3.0).unwrap(); + let out = ab.batch(&candles); + assert!(out[0].is_none() && out[3].is_none()); + let v = out[4].unwrap(); + assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9); + assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/atr_ratchet.rs b/crates/wickra-core/src/indicators/atr_ratchet.rs new file mode 100644 index 0000000..d5846e5 --- /dev/null +++ b/crates/wickra-core/src/indicators/atr_ratchet.rs @@ -0,0 +1,279 @@ +//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`AtrRatchet`]: the active stop level and the trend direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AtrRatchetOutput { + /// The ratchet stop level — below price when long, above price when short. + pub value: f64, + /// Trend direction: `+1.0` long, `-1.0` short. + pub direction: f64, +} + +/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a +/// fixed fraction of ATR **every bar**, whether or not price moves. +/// +/// ```text +/// on entry (long): stop = close − start_mult · ATR +/// each later bar: stop = stop + increment · ATR (ratchets toward price) +/// flip to short when close < stop, reseeding stop = close + start_mult · ATR +/// ``` +/// +/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet +/// instead advances the stop a little each bar — `increment · ATR` — so a trade +/// that stalls is squeezed out over time even in a flat market. The initial +/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar +/// `increment` controls how aggressively the leash shortens. When price closes +/// through the stop the system reverses and reseeds at the full initial distance. +/// +/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is +/// O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AtrRatchet}; +/// +/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AtrRatchet { + atr: Atr, + atr_period: usize, + start_mult: f64, + increment: f64, + direction: f64, + stop: f64, + last: Option, +} + +impl AtrRatchet { + /// Construct an ATR Ratchet stop. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not + /// finite and positive. + pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result { + if !start_mult.is_finite() + || start_mult <= 0.0 + || !increment.is_finite() + || increment <= 0.0 + { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + atr_period, + start_mult, + increment, + direction: 0.0, + stop: 0.0, + last: None, + }) + } + + /// Configured `(atr_period, start_mult, increment)`. + pub const fn params(&self) -> (usize, f64, f64) { + (self.atr_period, self.start_mult, self.increment) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for AtrRatchet { + type Input = Candle; + type Output = AtrRatchetOutput; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let close = candle.close; + + if self.direction == 0.0 { + self.direction = 1.0; + self.stop = close - self.start_mult * atr; + } else if self.direction > 0.0 { + self.stop += self.increment * atr; + if close < self.stop { + self.direction = -1.0; + self.stop = close + self.start_mult * atr; + } + } else { + self.stop -= self.increment * atr; + if close > self.stop { + self.direction = 1.0; + self.stop = close - self.start_mult * atr; + } + } + + let out = AtrRatchetOutput { + value: self.stop, + direction: self.direction, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.atr.reset(); + self.direction = 0.0; + self.stop = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AtrRatchet" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + AtrRatchet::new(0, 4.0, 0.1), + Err(Error::PeriodZero) + )); + assert!(matches!( + AtrRatchet::new(14, 0.0, 0.1), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AtrRatchet::new(14, 4.0, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + AtrRatchet::new(14, 4.0, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let r = AtrRatchet::new(14, 4.0, 0.1).unwrap(); + assert_eq!(r.params(), (14, 4.0, 0.1)); + assert_eq!(r.warmup_period(), 14); + assert_eq!(r.name(), "AtrRatchet"); + assert!(!r.is_ready()); + assert_eq!(r.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let out = r.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn uptrend_keeps_stop_below_price() { + let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap(); + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + 2.0 * f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) { + if let Some(o) = o { + assert_eq!(o.direction, 1.0); + assert!(o.value < candle.close); + } + } + } + + #[test] + fn stall_eventually_triggers_flip() { + // A long trend then a long flat stretch: the ratchet creeps up each bar + // and eventually overtakes the flat close, flipping to short. + let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap(); + let mut candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + // Flat stretch at the last price. + candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5))); + let dirs: Vec = r + .batch(&candles) + .into_iter() + .flatten() + .map(|o| o.direction) + .collect(); + assert!( + dirs.iter().any(|&d| d < 0.0), + "the ratchet should eventually flip short" + ); + } + + #[test] + fn reset_clears_state() { + let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap(); + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + r.batch(&candles); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.value(), None); + assert_eq!(r.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + c(base + 2.0, base - 1.5, base + 0.5) + }) + .collect(); + let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles); + let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/atr_trailing_stop.rs b/crates/wickra-core/src/indicators/atr_trailing_stop.rs new file mode 100644 index 0000000..1ba90ba --- /dev/null +++ b/crates/wickra-core/src/indicators/atr_trailing_stop.rs @@ -0,0 +1,279 @@ +//! ATR Trailing Stop. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple +/// and ratchets in the direction of the trend. +/// +/// ```text +/// loss = multiplier · ATR +/// +/// stop_t = max(stop_{t−1}, close − loss) while price holds above the stop +/// = min(stop_{t−1}, close + loss) while price holds below the stop +/// = close − loss on a fresh break above the stop +/// = close + loss on a fresh break below the stop +/// ``` +/// +/// While price stays on one side of the stop the level only ratchets toward +/// price — up in an uptrend, down in a downtrend — never away from it. When a +/// close crosses the stop the level snaps to the opposite side, `loss` away +/// from the new close, flipping the trade. This is the trailing stop used by +/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below +/// price (a long). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AtrTrailingStop}; +/// +/// let mut indicator = AtrTrailingStop::new(14, 3.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AtrTrailingStop { + atr: Atr, + multiplier: f64, + atr_period: usize, + prev_close: Option, + prev_stop: Option, +} + +impl AtrTrailingStop { + /// Construct an ATR Trailing Stop with an explicit ATR period and multiple. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + multiplier, + atr_period, + prev_close: None, + prev_stop: None, + }) + } + + /// A common configuration: `ATR(14)` with a `3.0` multiplier. + pub fn classic() -> Self { + Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid") + } + + /// Configured `(atr_period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.atr_period, self.multiplier) + } +} + +impl Indicator for AtrTrailingStop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let loss = self.multiplier * atr; + let close = candle.close; + + let stop = match (self.prev_stop, self.prev_close) { + (Some(prev_stop), Some(prev_close)) => { + if close > prev_stop && prev_close > prev_stop { + // Holding above the stop — ratchet it up only. + (close - loss).max(prev_stop) + } else if close < prev_stop && prev_close < prev_stop { + // Holding below the stop — ratchet it down only. + (close + loss).min(prev_stop) + } else if close > prev_stop { + // Fresh break above — place the stop below the new close. + close - loss + } else { + // Fresh break below — place the stop above the new close. + close + loss + } + } + // First ATR-ready bar: seed the stop below price (a long). + _ => close - loss, + }; + + self.prev_close = Some(close); + self.prev_stop = Some(stop); + Some(stop) + } + + fn reset(&mut self) { + self.atr.reset(); + self.prev_close = None; + self.prev_stop = None; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.prev_stop.is_some() + } + + fn name(&self) -> &'static str { + "AtrTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_values_flat_market() { + // Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6. + // Seed stop = close - loss = 10 - 6 = 4, and it holds there. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ts = AtrTrailingStop::new(5, 3.0).unwrap(); + for v in ts.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 4.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_stop_ratchets_up_and_stays_below_price() { + let candles: Vec = (0..50) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ts = AtrTrailingStop::new(14, 3.0).unwrap(); + let emitted: Vec<(f64, f64)> = ts + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + for w in emitted.windows(2) { + assert!( + w[1].0 >= w[0].0 - 1e-9, + "stop must not loosen in an uptrend" + ); + } + for &(stop, close) in &emitted { + assert!(stop < close, "uptrend stop should sit below the close"); + } + } + + #[test] + fn stop_flips_to_the_other_side_when_price_reverses() { + let mut candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + // A steep decline drags price through the trailing stop. + candles.extend((0..40).map(|i| { + let base = 140.0 - 3.0 * i as f64; + c(base + 1.0, base - 1.0, base, 40 + i) + })); + let mut ts = AtrTrailingStop::new(14, 3.0).unwrap(); + let paired: Vec<(f64, f64)> = ts + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + assert!( + paired.iter().any(|&(stop, close)| stop < close), + "expected a long stretch with the stop below price" + ); + assert!( + paired.iter().any(|&(stop, close)| stop > close), + "expected the stop to flip above price after the reversal" + ); + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ts = AtrTrailingStop::new(8, 3.0).unwrap(); + let out = ts.batch(&candles); + assert_eq!(ts.warmup_period(), 8); + for (i, v) in out.iter().enumerate().take(7) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[7].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(AtrTrailingStop::new(0, 3.0).is_err()); + assert!(AtrTrailingStop::new(14, 0.0).is_err()); + assert!(AtrTrailingStop::new(14, -1.0).is_err()); + assert!(AtrTrailingStop::new(14, f64::NAN).is_err()); + } + + /// Cover the const accessor `params` (77-79) and the Indicator-impl + /// `name` body (130-132). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let s = AtrTrailingStop::classic(); + let (atr_p, mult) = s.params(); + assert_eq!(atr_p, 14); + assert!((mult - 3.0).abs() < 1e-12); + assert_eq!(s.name(), "AtrTrailingStop"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ts = AtrTrailingStop::classic(); + ts.batch(&candles); + assert!(ts.is_ready()); + ts.reset(); + assert!(!ts.is_ready()); + assert_eq!(ts.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = AtrTrailingStop::classic(); + let mut b = AtrTrailingStop::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/auto_fib.rs b/crates/wickra-core/src/indicators/auto_fib.rs new file mode 100644 index 0000000..d844106 --- /dev/null +++ b/crates/wickra-core/src/indicators/auto_fib.rs @@ -0,0 +1,191 @@ +//! Auto-Fibonacci — retracement of the most significant recent swing leg. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// How many recent pivots to consider when picking the dominant leg. +const PIVOT_HISTORY: usize = 6; + +/// The seven canonical retracement ratios, in ascending order. +const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0]; + +/// Auto-Fibonacci retracement levels for the dominant recent swing leg. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AutoFibOutput { + /// 0.0% — the dominant leg's end. + pub level_0: f64, + /// 23.6% retracement. + pub level_236: f64, + /// 38.2% retracement. + pub level_382: f64, + /// 50% retracement. + pub level_500: f64, + /// 61.8% retracement. + pub level_618: f64, + /// 78.6% retracement. + pub level_786: f64, + /// 100% — the dominant leg's start. + pub level_1000: f64, +} + +/// Auto-Fibonacci (`AutoFib`). +/// +/// Like [`crate::indicators::FibRetracement`], but instead of always using the +/// immediate last leg it scans the last six confirmed pivots and anchors the +/// retracement on the single largest-magnitude leg among them — the dominant +/// swing the market is most likely respecting. +/// +/// Parameter-free; construction is infallible. Returns `None` until two pivots +/// have confirmed. +/// +/// See `crates/wickra-core/src/indicators/auto_fib.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{AutoFib, Candle, Indicator}; +/// +/// let mut indicator = AutoFib::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct AutoFib { + swing: SwingTracker, +} + +impl AutoFib { + /// Construct a new Auto-Fibonacci tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY), + } + } + + fn levels(&self) -> Option { + let dominant = self.swing.pivots().windows(2).max_by(|x, y| { + (x[0].price - x[1].price) + .abs() + .total_cmp(&(y[0].price - y[1].price).abs()) + })?; + let (start, end) = (dominant[0].price, dominant[1].price); + let level = |r: f64| end + r * (start - end); + Some(AutoFibOutput { + level_0: level(RATIOS[0]), + level_236: level(RATIOS[1]), + level_382: level(RATIOS[2]), + level_500: level(RATIOS[3]), + level_618: level(RATIOS[4]), + level_786: level(RATIOS[5]), + level_1000: level(RATIOS[6]), + }) + } +} + +impl Default for AutoFib { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for AutoFib { + type Input = Candle; + type Output = AutoFibOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.levels() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 2 + } + + fn name(&self) -> &'static str { + "AutoFib" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = AutoFib::new(); + assert_eq!(indicator.name(), "AutoFib"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!AutoFib::default().is_ready()); + } + + #[test] + fn no_output_before_two_pivots() { + let mut indicator = AutoFib::new(); + let outputs: Vec<_> = candles_for_pivots(&[120.0]) + .into_iter() + .map(|c| indicator.update(c)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + } + + #[test] + fn anchors_on_the_largest_leg() { + // Pivots: 130 -> 120 (small, 10) -> 220 (large, 100) -> 200 (small, 20). + // The dominant leg is 120 -> 220; its retracement spans [120, 220]. + let mut indicator = AutoFib::new(); + let mut last = None; + for candle in candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // Largest leg 120 -> 220: 0% on 220 (end), 100% on 120 (start). + assert_relative_eq!(v.level_0, 220.0); + assert_relative_eq!(v.level_1000, 120.0); + assert_relative_eq!(v.level_500, 170.0); + assert_relative_eq!(v.level_618, 220.0 + 0.618 * (120.0 - 220.0)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = AutoFib::new(); + for candle in candles_for_pivots(&[200.0, 100.0]) { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert!(indicator.update(c).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]); + let mut a = AutoFib::new(); + let mut b = AutoFib::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/autocorrelation.rs b/crates/wickra-core/src/indicators/autocorrelation.rs new file mode 100644 index 0000000..cf66ad3 --- /dev/null +++ b/crates/wickra-core/src/indicators/autocorrelation.rs @@ -0,0 +1,224 @@ +//! Rolling lag-`k` autocorrelation. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling lag-`lag` autocorrelation of the last `period` inputs. +/// +/// Over the trailing window the Pearson correlation between the series and +/// itself shifted by `lag` is computed: +/// +/// ```text +/// y_i for i = 0..period − 1 +/// ACF(lag) = Σ ( (y_i − ȳ) · (y_{i + lag} − ȳ) ) / Σ ( y_i − ȳ )² +/// ``` +/// +/// `+1` means a perfectly repeating pattern at the given lag; `−1` means a +/// perfect alternation. Values near `0` mean the series at `t` and `t − +/// lag` carry no linear relationship — a clean white-noise proxy. The +/// classic application is detecting periodicity (a peak in `|ACF(lag)|` +/// flags a cycle of that length) or testing whether returns are +/// uncorrelated (a key efficient-markets diagnostic). +/// +/// `period` must be strictly greater than `lag` so that at least two +/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the +/// indicator returns `0` rather than dividing by zero. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Autocorrelation, Indicator}; +/// +/// let mut indicator = Autocorrelation::new(20, 1).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Autocorrelation { + period: usize, + lag: usize, + window: VecDeque, +} + +impl Autocorrelation { + /// Construct a new rolling lag-`lag` autocorrelation over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`. + pub fn new(period: usize, lag: usize) -> Result { + if lag == 0 { + return Err(Error::InvalidPeriod { + message: "autocorrelation lag must be >= 1", + }); + } + if period <= lag { + return Err(Error::InvalidPeriod { + message: "autocorrelation needs period > lag", + }); + } + Ok(Self { + period, + lag, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured lag. + pub const fn lag(&self) -> usize { + self.lag + } +} + +impl Indicator for Autocorrelation { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + // ACF over the current window with a single inner pass. The window is + // small relative to a typical input stream so the O(period) per-bar + // cost is bounded by the user-chosen `period`; the constant factor + // is dominated by two adds and one multiply per element. + let n = self.period as f64; + let mean = self.window.iter().sum::() / n; + let mut denom = 0.0; + let mut numer = 0.0; + // The window is a deque; index via slices for cache-friendly access. + let (front, back) = self.window.as_slices(); + let get = |i: usize| -> f64 { + if i < front.len() { + front[i] + } else { + back[i - front.len()] + } + }; + for i in 0..self.period { + let d = get(i) - mean; + denom += d * d; + } + let lag = self.lag; + for i in 0..(self.period - lag) { + numer += (get(i) - mean) * (get(i + lag) - mean); + } + if denom == 0.0 { + return Some(0.0); + } + Some(numer / denom) + } + + 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 { + "Autocorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_lag() { + assert!(Autocorrelation::new(10, 0).is_err()); + } + + #[test] + fn rejects_lag_geq_period() { + assert!(Autocorrelation::new(5, 5).is_err()); + assert!(Autocorrelation::new(5, 10).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let a = Autocorrelation::new(14, 2).unwrap(); + assert_eq!(a.period(), 14); + assert_eq!(a.lag(), 2); + assert_eq!(a.warmup_period(), 14); + assert_eq!(a.name(), "Autocorrelation"); + } + + #[test] + fn constant_series_yields_zero() { + let mut a = Autocorrelation::new(10, 1).unwrap(); + for v in a.batch(&[42.0; 30]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn alternating_series_lag_one_is_strongly_negative() { + // [−1, 1, −1, 1, …] alternates each step. + let prices: Vec = (0..20) + .map(|i| if i % 2 == 0 { -1.0 } else { 1.0 }) + .collect(); + let mut a = Autocorrelation::new(10, 1).unwrap(); + let last = a.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last < -0.5, + "alternating series should be strongly negative, got {last}" + ); + } + + #[test] + fn repeating_series_is_strongly_positive_at_period() { + // A series that repeats every 4 steps must have ACF(4) ≈ +1. + let pattern = [1.0, 2.0, 3.0, 4.0]; + let prices: Vec = (0..32).map(|i| pattern[i % 4]).collect(); + let mut a = Autocorrelation::new(16, 4).unwrap(); + let last = a.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last > 0.5, + "period-4 repeat should ACF(4) > 0.5, got {last}" + ); + } + + #[test] + fn reset_clears_state() { + let mut a = Autocorrelation::new(5, 1).unwrap(); + a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect(); + let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices); + let mut b = Autocorrelation::new(14, 2).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/autocorrelation_periodogram.rs b/crates/wickra-core/src/indicators/autocorrelation_periodogram.rs new file mode 100644 index 0000000..2f70fe8 --- /dev/null +++ b/crates/wickra-core/src/indicators/autocorrelation_periodogram.rs @@ -0,0 +1,340 @@ +//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle. +#![allow(clippy::doc_markdown)] + +use std::collections::VecDeque; +use std::f64::consts::TAU; + +use crate::error::{Error, Result}; +use crate::indicators::roofing_filter::RoofingFilter; +use crate::traits::Indicator; + +/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`). +const AVG_LENGTH: usize = 3; + +/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle +/// period** of the market by correlating a roofing-filtered price with lagged +/// copies of itself and reading off the spectral peak. +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8): +/// +/// ```text +/// Filt = RoofingFilter(price) (detrend + denoise) +/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period +/// for each candidate period: +/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))² +/// R[period] = 0.2·power[period] + 0.8·R[period]_{t−1} (EMA across time) +/// normalise by a decaying max, then +/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5 +/// ``` +/// +/// The autocorrelation function emphasises whatever cycle is actually present and +/// suppresses noise; transforming it into a periodogram and taking the +/// power-weighted centre of gravity gives a smooth, robust estimate of the +/// dominant cycle length. That cycle is the key input for every *adaptive* +/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The +/// output is a period in bars within `[min_period, max_period]`. +/// +/// The first value lands after `max_period + AvgLength` inputs. Each `update` is +/// O(`max_period²`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, AutocorrelationPeriodogram}; +/// use std::f64::consts::TAU; +/// +/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap(); +/// let mut last = None; +/// for i in 0..200 { +/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AutocorrelationPeriodogram { + min_period: usize, + max_period: usize, + roof: RoofingFilter, + buffer: VecDeque, + r: Vec, + max_pwr: f64, + last: Option, +} + +impl AutocorrelationPeriodogram { + /// Construct an autocorrelation periodogram searching cycles in + /// `[min_period, max_period]`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`, or + /// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or + /// `max_period <= min_period`. + pub fn new(min_period: usize, max_period: usize) -> Result { + if min_period == 0 || max_period == 0 { + return Err(Error::PeriodZero); + } + if min_period < AVG_LENGTH + 1 || max_period <= min_period { + return Err(Error::InvalidPeriod { + message: "autocorrelation periodogram needs AvgLength < min_period < max_period", + }); + } + Ok(Self { + min_period, + max_period, + roof: RoofingFilter::new(10, max_period)?, + buffer: VecDeque::with_capacity(max_period + AVG_LENGTH), + r: vec![0.0; max_period + 1], + max_pwr: 0.0, + last: None, + }) + } + + /// Configured `(min_period, max_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.min_period, self.max_period) + } + + /// Current dominant-cycle estimate if available. + pub const fn value(&self) -> Option { + self.last + } + + /// Pearson correlation of the `AvgLength`-deep slices offset by `lag`. + /// `buffer` is newest-last; `filt(k)` is the value `k` bars back. + fn correlation(&self, lag: usize) -> f64 { + let len = self.buffer.len(); + let filt = |k: usize| self.buffer[len - 1 - k]; + let m = AVG_LENGTH as f64; + let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0); + for count in 0..AVG_LENGTH { + let x = filt(count); + let y = filt(lag + count); + sx += x; + sy += y; + sxx += x * x; + syy += y * y; + sxy += x * y; + } + let denom = (m * sxx - sx * sx) * (m * syy - sy * sy); + if denom > 0.0 { + (m * sxy - sx * sy) / denom.sqrt() + } else { + 0.0 + } + } +} + +impl Indicator for AutocorrelationPeriodogram { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let filt = self.roof.update(price)?; + if self.buffer.len() == self.max_period + AVG_LENGTH { + self.buffer.pop_front(); + } + self.buffer.push_back(filt); + if self.buffer.len() < self.max_period + AVG_LENGTH { + return None; + } + + // Autocorrelation across lags. + let mut corr = vec![0.0; self.max_period + 1]; + for (lag, c) in corr.iter_mut().enumerate() { + *c = self.correlation(lag); + } + + // Periodogram: spectral power for each candidate period, EMA'd over time. + self.max_pwr *= 0.995; + for period in self.min_period..=self.max_period { + let mut cosine = 0.0; + let mut sine = 0.0; + for (n, &cn) in corr + .iter() + .enumerate() + .take(self.max_period + 1) + .skip(AVG_LENGTH) + { + let angle = TAU * n as f64 / period as f64; + cosine += cn * angle.cos(); + sine += cn * angle.sin(); + } + let power = cosine * cosine + sine * sine; + self.r[period] = 0.2 * power + 0.8 * self.r[period]; + if self.r[period] > self.max_pwr { + self.max_pwr = self.r[period]; + } + } + + // Power-weighted centre of gravity of the strong periods. + let mut spx = 0.0; + let mut sp = 0.0; + for period in self.min_period..=self.max_period { + let pwr = if self.max_pwr > 0.0 { + self.r[period] / self.max_pwr + } else { + 0.0 + }; + if pwr >= 0.5 { + spx += period as f64 * pwr; + sp += pwr; + } + } + let dominant = if sp > 0.0 { + (spx / sp).clamp(self.min_period as f64, self.max_period as f64) + } else { + self.min_period as f64 + }; + self.last = Some(dominant); + Some(dominant) + } + + fn reset(&mut self) { + self.roof.reset(); + self.buffer.clear(); + self.r.iter_mut().for_each(|x| *x = 0.0); + self.max_pwr = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.max_period + AVG_LENGTH + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AutocorrelationPeriodogram" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + AutocorrelationPeriodogram::new(0, 48), + Err(Error::PeriodZero) + )); + assert!(matches!( + AutocorrelationPeriodogram::new(3, 48), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + AutocorrelationPeriodogram::new(48, 10), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let p = AutocorrelationPeriodogram::new(10, 48).unwrap(); + assert_eq!(p.periods(), (10, 48)); + assert_eq!(p.warmup_period(), 51); + assert_eq!(p.name(), "AutocorrelationPeriodogram"); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap(); + let xs: Vec = (0..40) + .map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0) + .collect(); + let out = p.batch(&xs); + let warmup = p.warmup_period(); // 23 + assert_eq!(warmup, 23); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn output_within_period_band() { + let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + for v in p.batch(&xs).into_iter().flatten() { + assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}"); + } + } + + #[test] + fn detects_injected_cycle() { + // A clean 20-bar sine: the dominant cycle estimate should settle near 20. + let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap(); + let xs: Vec = (0..600) + .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + let last = p.batch(&xs).into_iter().flatten().last().unwrap(); + assert!( + (last - 20.0).abs() < 6.0, + "expected ~20-bar cycle, got {last}" + ); + } + + #[test] + fn ignores_non_finite() { + let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap(); + p.batch( + &(0..80) + .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect::>(), + ); + let before = p.value(); + assert_eq!(p.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap(); + p.batch( + &(0..120) + .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect::>(), + ); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..200) + .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs); + let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn flat_input_falls_back_to_min_period() { + // Constant input has zero variance, so every lag correlation is + // degenerate (denom <= 0), the max power is zero and no period clears + // the 0.5 threshold -> the dominant cycle defaults to `min_period`. + let flat = [100.0_f64; 200]; + let last = AutocorrelationPeriodogram::new(10, 48) + .unwrap() + .batch(&flat) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 10.0); + } +} diff --git a/crates/wickra-core/src/indicators/average_daily_range.rs b/crates/wickra-core/src/indicators/average_daily_range.rs new file mode 100644 index 0000000..15d33e5 --- /dev/null +++ b/crates/wickra-core/src/indicators/average_daily_range.rs @@ -0,0 +1,231 @@ +//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period` +//! completed calendar-day sessions. + +use std::collections::VecDeque; + +use crate::calendar::civil_from_timestamp; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Average Daily Range over the last `period` completed sessions. +/// +/// The indicator tracks the running high / low of the current session (the +/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by +/// `utc_offset_minutes`). When a new day begins, the just-finished session's +/// range (`high - low`) joins a rolling window of the last `period` completed +/// days, and the reported value is their mean. The current, still-forming day is +/// excluded until it closes. No value is produced until the first session +/// completes. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AverageDailyRange}; +/// +/// let hour = 3_600_000; +/// let mut adr = AverageDailyRange::new(2, 0).unwrap(); +/// // Day 1 range 10 (high 110, low 100) — still forming, so None. +/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none()); +/// // First bar of day 2 closes day 1: ADR = 10. +/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap(); +/// assert!((v - 10.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct AverageDailyRange { + period: usize, + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + cur_high: f64, + cur_low: f64, + completed: VecDeque, + sum: f64, +} + +impl AverageDailyRange { + /// Construct an ADR indicator over `period` completed days. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize, utc_offset_minutes: i32) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + utc_offset_minutes, + day_key: None, + cur_high: f64::NEG_INFINITY, + cur_low: f64::INFINITY, + completed: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured `(period, utc_offset_minutes)`. + pub const fn params(&self) -> (usize, i32) { + (self.period, self.utc_offset_minutes) + } + + /// Most recent ADR if at least one session has completed. + pub fn value(&self) -> Option { + if self.completed.is_empty() { + None + } else { + Some(self.sum / self.completed.len() as f64) + } + } +} + +impl Indicator for AverageDailyRange { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + match self.day_key { + Some(prev) if prev == key => { + if candle.high > self.cur_high { + self.cur_high = candle.high; + } + if candle.low < self.cur_low { + self.cur_low = candle.low; + } + } + Some(_) => { + let range = self.cur_high - self.cur_low; + self.completed.push_back(range); + self.sum += range; + if self.completed.len() > self.period { + self.sum -= self + .completed + .pop_front() + .expect("len > period implies a front element"); + } + self.day_key = Some(key); + self.cur_high = candle.high; + self.cur_low = candle.low; + } + None => { + self.day_key = Some(key); + self.cur_high = candle.high; + self.cur_low = candle.low; + } + } + self.value() + } + + fn reset(&mut self) { + self.day_key = None; + self.cur_high = f64::NEG_INFINITY; + self.cur_low = f64::INFINITY; + self.completed.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + !self.completed.is_empty() + } + + fn name(&self) -> &'static str { + "AverageDailyRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + const DAY: i64 = 24 * HOUR; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + let mid = f64::midpoint(high, low); + Candle::new(mid, high, low, mid, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + AverageDailyRange::new(0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn metadata_and_accessors() { + let adr = AverageDailyRange::new(5, -60).unwrap(); + assert_eq!(adr.params(), (5, -60)); + assert_eq!(adr.name(), "AverageDailyRange"); + assert_eq!(adr.warmup_period(), 5); + assert!(!adr.is_ready()); + assert!(adr.value().is_none()); + } + + #[test] + fn averages_completed_day_ranges() { + let mut adr = AverageDailyRange::new(3, 0).unwrap(); + // Day 1: range 10. + assert!(adr.update(c(110.0, 100.0, 0)).is_none()); + assert!(adr.update(c(108.0, 104.0, HOUR)).is_none()); + // Day 2 opens -> day 1 (range 10) completes. + let v = adr.update(c(120.0, 110.0, DAY)).unwrap(); + assert_relative_eq!(v, 10.0); + assert!(adr.is_ready()); + // Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10. + let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap(); + assert_relative_eq!(v, 10.0); + } + + #[test] + fn rolls_off_oldest_day_beyond_period() { + let mut adr = AverageDailyRange::new(2, 0).unwrap(); + adr.update(c(110.0, 100.0, 0)); // day 1 range 10 + let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10] + assert_relative_eq!(v, 10.0); + // Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5. + let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap(); + assert_relative_eq!(v, 12.5); + // Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off. + let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap(); + assert_relative_eq!(v, 17.5); + } + + #[test] + fn reset_clears_state() { + let mut adr = AverageDailyRange::new(2, 0).unwrap(); + adr.update(c(110.0, 100.0, 0)); + adr.update(c(120.0, 110.0, DAY)); + adr.reset(); + assert!(!adr.is_ready()); + assert!(adr.value().is_none()); + assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + c( + 110.0 + f64::from(i % 5), + 100.0 - f64::from(i % 3), + i64::from(i) * 6 * HOUR, + ) + }) + .collect(); + let mut a = AverageDailyRange::new(4, 0).unwrap(); + let mut b = AverageDailyRange::new(4, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/average_drawdown.rs b/crates/wickra-core/src/indicators/average_drawdown.rs new file mode 100644 index 0000000..8395bb9 --- /dev/null +++ b/crates/wickra-core/src/indicators/average_drawdown.rs @@ -0,0 +1,211 @@ +//! Rolling Average Drawdown. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Average Drawdown. +/// +/// Input is treated as an equity-curve sample. Over the trailing window of +/// `period` values the indicator identifies each **distinct drawdown episode** +/// — a stretch where equity is below the running peak — and reports the **mean +/// of the episodes' maximum depths**: +/// +/// ```text +/// episode opens when equity < running peak +/// episode closes when equity reaches a new peak (full recovery) +/// depth(episode) = (episode_peak − episode_trough) / episode_peak +/// AvgDD = mean(depth over episodes in window) (0 if no drawdown) +/// ``` +/// +/// This is the conventional "average drawdown" (mean depth across separate +/// drawdowns), which is distinct from the [`crate::PainIndex`] — the latter +/// averages the under-water fraction at *every* bar, so a long shallow +/// drawdown weighs more there than here. Output is a non-negative fraction +/// (`0.05` ≈ 5 % mean episode depth). +/// +/// Each `update` is O(period). +#[derive(Debug, Clone)] +pub struct AverageDrawdown { + period: usize, + window: VecDeque, +} + +impl AverageDrawdown { + /// Construct a new rolling Average Drawdown. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for AverageDrawdown { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut peak = f64::NEG_INFINITY; + let mut sum_depth = 0.0_f64; + let mut episodes = 0_u32; + let mut in_dd = false; + let mut episode_peak = 0.0_f64; + let mut episode_trough = 0.0_f64; + for &v in &self.window { + if v >= peak { + if in_dd { + if episode_peak > 0.0 { + sum_depth += (episode_peak - episode_trough) / episode_peak; + episodes += 1; + } + in_dd = false; + } + peak = v; + } else if in_dd { + if v < episode_trough { + episode_trough = v; + } + } else { + in_dd = true; + episode_peak = peak; + episode_trough = v; + } + } + if in_dd && episode_peak > 0.0 { + sum_depth += (episode_peak - episode_trough) / episode_peak; + episodes += 1; + } + Some(if episodes == 0 { + 0.0 + } else { + sum_depth / f64::from(episodes) + }) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "AverageDrawdown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let a = AverageDrawdown::new(10).unwrap(); + assert_eq!(a.period(), 10); + assert_eq!(a.name(), "AverageDrawdown"); + assert_eq!(a.warmup_period(), 10); + } + + #[test] + fn pure_uptrend_yields_zero() { + let mut a = AverageDrawdown::new(5).unwrap(); + let out = a.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_value() { + // window [100, 120, 90, 110]: one drawdown episode, opened at 90 (peak + // 120) and never recovering to 120 within the window. Its depth is + // (120 - 90) / 120 = 0.25; 110 stays inside the same episode and does + // not deepen the trough. One episode -> AvgDD = 0.25. + let mut a = AverageDrawdown::new(4).unwrap(); + let out = a.batch(&[100.0, 120.0, 90.0, 110.0]); + assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-12); + } + + #[test] + fn averages_distinct_episodes() { + // [100, 90, 100, 80, 100]: episode 1 troughs at 90 then recovers to 100 + // -> depth 0.10; episode 2 troughs at 80 then recovers -> depth 0.20. + // Mean of the two episode depths = 0.15 (distinct from the Pain Index, + // which would weight every under-water bar instead). + let mut a = AverageDrawdown::new(5).unwrap(); + let out = a.batch(&[100.0, 90.0, 100.0, 80.0, 100.0]); + assert_relative_eq!(out[4].unwrap(), 0.15, epsilon = 1e-12); + } + + #[test] + fn ignores_non_finite_input() { + let mut a = AverageDrawdown::new(3).unwrap(); + assert_eq!(a.update(f64::NAN), None); + assert_eq!(a.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut a = AverageDrawdown::new(3).unwrap(); + a.batch(&[100.0, 90.0, 110.0]); + assert!(a.is_ready()); + a.reset(); + assert!(!a.is_ready()); + assert_eq!(a.update(100.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let batch = AverageDrawdown::new(10).unwrap().batch(&prices); + let mut s = AverageDrawdown::new(10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_positive_peak_yields_zero() { + let mut a = AverageDrawdown::new(3).unwrap(); + let out = a.batch(&[0.0_f64; 6]); + for v in out.into_iter().flatten() { + assert_eq!(v, 0.0); + } + } +} diff --git a/crates/wickra-core/src/indicators/avg_price.rs b/crates/wickra-core/src/indicators/avg_price.rs new file mode 100644 index 0000000..41121da --- /dev/null +++ b/crates/wickra-core/src/indicators/avg_price.rs @@ -0,0 +1,92 @@ +//! Average Price (AVGPRICE). + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Average Price (`AVGPRICE`) — the bar's `(open + high + low + close) / 4`. +/// +/// A per-bar price aggregate that, unlike [`TypicalPrice`](crate::TypicalPrice) +/// and [`WeightedClose`](crate::WeightedClose), folds in the open as well as the +/// high, low and close. As a stateless transform it emits a value from the very +/// first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AvgPrice}; +/// +/// let mut indicator = AvgPrice::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AvgPrice { + has_emitted: bool, +} + +impl AvgPrice { + /// Construct a new Average Price transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for AvgPrice { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.avg_price()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AVGPRICE" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn averages_the_four_prices() { + // (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5. + let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap(); + let mut ap = AvgPrice::new(); + assert!(!ap.is_ready()); + assert_relative_eq!(ap.update(candle).unwrap(), 10.5, epsilon = 1e-12); + assert!(ap.is_ready()); + } + + #[test] + fn accessors_and_reset() { + let mut ap = AvgPrice::new(); + assert_eq!(ap.name(), "AVGPRICE"); + assert_eq!(ap.warmup_period(), 1); + let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap(); + let _ = ap.update(candle); + assert!(ap.is_ready()); + ap.reset(); + assert!(!ap.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/awesome_oscillator.rs b/crates/wickra-core/src/indicators/awesome_oscillator.rs new file mode 100644 index 0000000..5d02b65 --- /dev/null +++ b/crates/wickra-core/src/indicators/awesome_oscillator.rs @@ -0,0 +1,157 @@ +//! Awesome Oscillator (Bill Williams). + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Awesome Oscillator: `SMA(median_price, 5) - SMA(median_price, 34)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AwesomeOscillator}; +/// +/// let mut indicator = AwesomeOscillator::new(3, 10).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AwesomeOscillator { + fast: Sma, + slow: Sma, + fast_period: usize, + slow_period: usize, +} + +impl AwesomeOscillator { + /// # Errors + /// Returns [`Error::PeriodZero`] for zero periods or [`Error::InvalidPeriod`] when fast >= slow. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "AO fast period must be strictly less than slow", + }); + } + Ok(Self { + fast: Sma::new(fast)?, + slow: Sma::new(slow)?, + fast_period: fast, + slow_period: slow, + }) + } + + /// Classic Bill Williams configuration: (5, 34). + pub fn classic() -> Self { + Self::new(5, 34).expect("classic AO periods are valid") + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for AwesomeOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let median = candle.median_price(); + let f = self.fast.update(median); + let s = self.slow.update(median); + match (f, s) { + (Some(a), Some(b)) => Some(a - b), + _ => None, + } + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + self.slow_period + } + + fn is_ready(&self) -> bool { + self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "AwesomeOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn constant_series_yields_zero() { + let candles: Vec = (0..80).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut ao = AwesomeOscillator::classic(); + let last = ao.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(AwesomeOscillator::new(34, 5).is_err()); + assert!(AwesomeOscillator::new(5, 5).is_err()); + assert!(AwesomeOscillator::new(0, 5).is_err()); + } + + /// Cover the const accessor `periods` (59-61) and the Indicator-impl + /// `warmup_period` (83-85) + `name` (91-93). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let ao = AwesomeOscillator::classic(); + assert_eq!(ao.periods(), (5, 34)); + assert_eq!(ao.warmup_period(), 34); + assert_eq!(ao.name(), "AwesomeOscillator"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = AwesomeOscillator::classic(); + let mut b = AwesomeOscillator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut ao = AwesomeOscillator::classic(); + ao.batch(&candles); + assert!(ao.is_ready()); + ao.reset(); + assert!(!ao.is_ready()); + assert_eq!(ao.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs b/crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs new file mode 100644 index 0000000..270cd3d --- /dev/null +++ b/crates/wickra-core/src/indicators/awesome_oscillator_histogram.rs @@ -0,0 +1,231 @@ +//! Awesome Oscillator Histogram. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::awesome_oscillator::AwesomeOscillator; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Awesome Oscillator Histogram — the bar-to-bar **momentum** of the Awesome +/// Oscillator over a `lookback` window. This is the value behind the coloured +/// histogram bars in Bill Williams' charts: each bar shows how much `AO` has +/// changed, so positive values mean `AO` is rising (the histogram "greens up") +/// and negative values mean it is falling. +/// +/// ```text +/// AO = SMA(median, fast) − SMA(median, slow) +/// AOHist = AO_t − AO_{t−lookback} +/// ``` +/// +/// This is distinct from the two related indicators Wickra ships: the raw +/// [`AwesomeOscillator`](crate::AwesomeOscillator) is `AO` itself, and the +/// [`AcceleratorOscillator`](crate::AcceleratorOscillator) is `AO − SMA(AO, n)`. +/// The histogram instead reports `AO`'s rate of change. The default `lookback` +/// is `1` (the classic one-bar histogram delta). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AwesomeOscillatorHistogram, Candle, Indicator}; +/// +/// let mut hist = AwesomeOscillatorHistogram::classic(); +/// let mut last = None; +/// for i in 0..80 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 0.5, p - 0.5, p, 1.0, i64::from(i)).unwrap(); +/// last = hist.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AwesomeOscillatorHistogram { + fast_period: usize, + slow_period: usize, + lookback: usize, + ao: AwesomeOscillator, + history: VecDeque, + emitted: bool, +} + +impl AwesomeOscillatorHistogram { + /// # Errors + /// - [`Error::PeriodZero`] if any period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, lookback: usize) -> Result { + if fast == 0 || slow == 0 || lookback == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "AwesomeOscillatorHistogram fast must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + lookback, + ao: AwesomeOscillator::new(fast, slow)?, + history: VecDeque::with_capacity(lookback + 1), + emitted: false, + }) + } + + /// Bill Williams' defaults with a one-bar histogram delta `(5, 34, 1)`. + pub fn classic() -> Self { + Self::new(5, 34, 1).expect("classic Awesome Oscillator Histogram parameters are valid") + } + + /// Configured `(fast_period, slow_period, lookback)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.lookback) + } +} + +impl Indicator for AwesomeOscillatorHistogram { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let ao = self.ao.update(candle)?; + self.history.push_back(ao); + if self.history.len() <= self.lookback { + return None; + } + let prev = self.history.pop_front().expect("history is non-empty"); + self.emitted = true; + Some(ao - prev) + } + + fn reset(&mut self) { + self.ao.reset(); + self.history.clear(); + self.emitted = false; + } + + fn warmup_period(&self) -> usize { + // AO first emits at `slow` candles; `lookback` more AO values are then + // needed before `AO_t − AO_{t−lookback}` can be formed. + self.slow_period + self.lookback + } + + fn is_ready(&self) -> bool { + self.emitted + } + + fn name(&self) -> &'static str { + "AwesomeOscillatorHistogram" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(price: f64, ts: i64) -> Candle { + Candle::new(price, price + 0.5, price - 0.5, price, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + AwesomeOscillatorHistogram::new(0, 34, 1), + Err(Error::PeriodZero) + )); + assert!(matches!( + AwesomeOscillatorHistogram::new(5, 0, 1), + Err(Error::PeriodZero) + )); + assert!(matches!( + AwesomeOscillatorHistogram::new(5, 34, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + AwesomeOscillatorHistogram::new(34, 5, 1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let hist = AwesomeOscillatorHistogram::classic(); + assert_eq!(hist.periods(), (5, 34, 1)); + assert_eq!(hist.warmup_period(), 35); + assert_eq!(hist.name(), "AwesomeOscillatorHistogram"); + } + + #[test] + fn constant_series_yields_zero() { + // AO of a flat series is 0, so its momentum is 0. + let mut hist = AwesomeOscillatorHistogram::new(3, 5, 1).unwrap(); + let candles: Vec = (0..30).map(|i| candle(42.0, i)).collect(); + let out = hist.batch(&candles); + for v in out.iter().skip(hist.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut hist = AwesomeOscillatorHistogram::new(2, 4, 2).unwrap(); + assert_eq!(hist.warmup_period(), 6); + let candles: Vec = (0..8) + .map(|i| candle(10.0 + f64::from(i), i64::from(i))) + .collect(); + let out = hist.batch(&candles); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn equals_ao_difference() { + // The histogram must equal AO_t − AO_{t−lookback} bar for bar. + let candles: Vec = (0..60_i64) + .map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i)) + .collect(); + let lookback = 1; + let ao_series = AwesomeOscillator::new(5, 34).unwrap().batch(&candles); + let hist = AwesomeOscillatorHistogram::new(5, 34, lookback) + .unwrap() + .batch(&candles); + for i in 0..candles.len() { + if let Some(h) = hist[i] { + let ao_now = ao_series[i].expect("AO present once histogram emits"); + let ao_prev = + ao_series[i - lookback].expect("prior AO present once histogram emits"); + assert_relative_eq!(h, ao_now - ao_prev, epsilon = 1e-9); + } + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100_i64) + .map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i)) + .collect(); + let batch = AwesomeOscillatorHistogram::classic().batch(&candles); + let mut b = AwesomeOscillatorHistogram::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut hist = AwesomeOscillatorHistogram::classic(); + let candles: Vec = (0..80) + .map(|i| candle(10.0 + f64::from(i), i64::from(i))) + .collect(); + hist.batch(&candles); + assert!(hist.is_ready()); + hist.reset(); + assert!(!hist.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/balance_of_power.rs b/crates/wickra-core/src/indicators/balance_of_power.rs new file mode 100644 index 0000000..6a11333 --- /dev/null +++ b/crates/wickra-core/src/indicators/balance_of_power.rs @@ -0,0 +1,175 @@ +//! Balance of Power. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Balance of Power — where the close settled within the bar's range relative +/// to the open. +/// +/// ```text +/// BOP = (close − open) / (high − low) +/// ``` +/// +/// The result lives in `[−1, +1]`: `+1` is a bar that opened on its low and +/// closed on its high (buyers in full control), `−1` the mirror image. It is +/// a stateless per-bar reading — a quick gauge of intrabar conviction. A +/// zero-range bar carries no information and yields `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, BalanceOfPower}; +/// +/// let mut indicator = BalanceOfPower::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct BalanceOfPower { + has_emitted: bool, +} + +impl BalanceOfPower { + /// Construct a new Balance of Power transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for BalanceOfPower { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + let bop = if range == 0.0 { + // A zero-range bar carries no directional information. + 0.0 + } else { + (candle.close - candle.open) / range + }; + Some(bop) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "BalanceOfPower" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5. + let mut bop = BalanceOfPower::new(); + assert_relative_eq!( + bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(), + 0.5, + epsilon = 1e-12 + ); + } + + #[test] + fn close_on_high_after_open_on_low_is_plus_one() { + let mut bop = BalanceOfPower::new(); + // open == low, close == high -> BOP = +1. + assert_relative_eq!( + bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(), + 1.0, + epsilon = 1e-12 + ); + } + + #[test] + fn stays_within_unit_range() { + let candles: Vec = (0..100) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0; + let close = mid + (i as f64 * 0.5).cos() * 2.0; + candle(mid, mid + 3.0, mid - 3.0, close, i) + }) + .collect(); + let mut bop = BalanceOfPower::new(); + for v in bop.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]"); + } + } + + #[test] + fn zero_range_bar_yields_zero() { + let mut bop = BalanceOfPower::new(); + assert_relative_eq!( + bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + /// Cover the Indicator-impl `name` body (73-75). + #[test] + fn name_metadata() { + let bop = BalanceOfPower::new(); + assert_eq!(bop.name(), "BalanceOfPower"); + } + + #[test] + fn emits_from_first_candle() { + let mut bop = BalanceOfPower::new(); + assert_eq!(bop.warmup_period(), 1); + assert!(!bop.is_ready()); + assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(bop.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut bop = BalanceOfPower::new(); + bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(bop.is_ready()); + bop.reset(); + assert!(!bop.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = BalanceOfPower::new(); + let mut b = BalanceOfPower::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/bandpass_filter.rs b/crates/wickra-core/src/indicators/bandpass_filter.rs new file mode 100644 index 0000000..95eff17 --- /dev/null +++ b/crates/wickra-core/src/indicators/bandpass_filter.rs @@ -0,0 +1,243 @@ +//! Ehlers Bandpass Filter — isolates the cyclic component around a target period. +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content +/// around a target `period` and rejects both the trend (low frequencies) and the +/// noise (high frequencies). +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013): +/// +/// ```text +/// beta = cos(2π / period) +/// gamma = 1 / cos(4π · bandwidth / period) +/// alpha = gamma − sqrt(gamma² − 1) +/// BP_t = 0.5·(1 − alpha)·(price_t − price_{t−2}) +/// + beta·(1 + alpha)·BP_{t−1} − alpha·BP_{t−2} +/// ``` +/// +/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is +/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to +/// `period`; wide bandwidth lets more of the spectrum through. The output is a +/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the +/// dominant cycle aligns with `period`. It is the building block for cycle-phase +/// and cycle-amplitude work. +/// +/// The recursion needs two prior prices and two prior outputs; until then it emits +/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is +/// produced every bar. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, BandpassFilter}; +/// +/// let mut indicator = BandpassFilter::new(20, 0.3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BandpassFilter { + period: usize, + bandwidth: f64, + beta: f64, + alpha: f64, + prev_price_1: Option, + prev_price_2: Option, + bp1: f64, + bp2: f64, + last: Option, +} + +impl BandpassFilter { + /// Construct a bandpass filter tuned to `period` with the given `bandwidth` + /// fraction. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside + /// `(0, 1)`. + pub fn new(period: usize, bandwidth: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 { + return Err(Error::InvalidParameter { + message: "bandpass bandwidth must be in (0, 1)", + }); + } + let period_f = period as f64; + let beta = (2.0 * PI / period_f).cos(); + let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos(); + let alpha = gamma - (gamma * gamma - 1.0).sqrt(); + Ok(Self { + period, + bandwidth, + beta, + alpha, + prev_price_1: None, + prev_price_2: None, + bp1: 0.0, + bp2: 0.0, + last: None, + }) + } + + /// Configured `(period, bandwidth)`. + pub const fn params(&self) -> (usize, f64) { + (self.period, self.bandwidth) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for BandpassFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let bp = match self.prev_price_2 { + Some(p2) => { + 0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1 + - self.alpha * self.bp2 + } + None => 0.0, + }; + self.prev_price_2 = self.prev_price_1; + self.prev_price_1 = Some(price); + self.bp2 = self.bp1; + self.bp1 = bp; + self.last = Some(bp); + Some(bp) + } + + fn reset(&mut self) { + self.prev_price_1 = None; + self.prev_price_2 = None; + self.bp1 = 0.0; + self.bp2 = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "BandpassFilter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + BandpassFilter::new(0, 0.3), + Err(Error::PeriodZero) + )); + assert!(matches!( + BandpassFilter::new(20, 0.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + BandpassFilter::new(20, 1.0), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bp = BandpassFilter::new(20, 0.3).unwrap(); + assert_eq!(bp.params(), (20, 0.3)); + assert_eq!(bp.warmup_period(), 1); + assert_eq!(bp.name(), "BandpassFilter"); + assert!(!bp.is_ready()); + assert_eq!(bp.value(), None); + } + + #[test] + fn first_bars_are_zero() { + let mut bp = BandpassFilter::new(20, 0.3).unwrap(); + assert_eq!(bp.update(100.0), Some(0.0)); + assert_eq!(bp.update(101.0), Some(0.0)); + // From the third bar the recursion is active. + assert!(bp.is_ready()); + } + + #[test] + fn constant_input_stays_zero() { + // A trend-free flat input has no cyclic content -> output stays 0. + let mut bp = BandpassFilter::new(20, 0.3).unwrap(); + for v in bp.batch(&[50.0; 200]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn cyclic_input_oscillates_around_zero() { + let mut bp = BandpassFilter::new(20, 0.3).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + let out: Vec = bp.batch(&xs).into_iter().flatten().skip(100).collect(); + let mean = out.iter().sum::() / out.len() as f64; + assert!( + mean.abs() < 1.0, + "bandpass output should be ~zero mean, got {mean}" + ); + assert!(out.iter().any(|&v| v > 0.5)); + assert!(out.iter().any(|&v| v < -0.5)); + } + + #[test] + fn ignores_non_finite() { + let mut bp = BandpassFilter::new(20, 0.3).unwrap(); + bp.batch(&(0..40).map(f64::from).collect::>()); + let before = bp.value(); + assert_eq!(bp.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut bp = BandpassFilter::new(20, 0.3).unwrap(); + bp.batch(&(0..40).map(f64::from).collect::>()); + assert!(bp.is_ready()); + bp.reset(); + assert!(!bp.is_ready()); + assert_eq!(bp.update(100.0), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs); + let mut b = BandpassFilter::new(20, 0.3).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/bat.rs b/crates/wickra-core/src/indicators/bat.rs new file mode 100644 index 0000000..44b5b76 --- /dev/null +++ b/crates/wickra-core/src/indicators/bat.rs @@ -0,0 +1,154 @@ +//! Bat harmonic pattern. + +use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep +/// `0.886` D completion: +/// +/// ```text +/// AB / XA ∈ [0.382, 0.50] +/// BC / AB ∈ [0.382, 0.886] +/// CD / BC ∈ [1.618, 2.618] +/// AD / XA ∈ [0.84, 0.93] (≈ 0.886 — the defining D completion) +/// ``` +/// +/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high), +/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`. +#[derive(Debug, Clone)] +pub struct Bat { + swing: SwingTracker, + has_emitted: bool, +} + +impl Bat { + /// Construct a new Bat detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for Bat { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Bat { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let cd = (p.d - p.c).abs(); + let ad = (p.d - p.a).abs(); + let matched = ratios_in(&[ + (ab / xa, 0.382, 0.50), + (bc / ab, 0.382, 0.886), + (cd / bc, 1.618, 2.618), + (ad / xa, 0.84, 0.93), + ]); + if matched { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Bat" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Bat::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Bat::new(); + assert_eq!(indicator.name(), "Bat"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!Bat::default().is_ready()); + } + + #[test] + fn bullish_bat_is_plus_one() { + let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_bat_is_minus_one() { + let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn out_of_ratio_does_not_trigger() { + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Bat::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]); + let mut a = Bat::new(); + let mut b = Bat::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/belt_hold.rs b/crates/wickra-core/src/indicators/belt_hold.rs new file mode 100644 index 0000000..4b868eb --- /dev/null +++ b/crates/wickra-core/src/indicators/belt_hold.rs @@ -0,0 +1,211 @@ +//! Belt-hold candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Belt-hold — a single-bar reversal: a long candle that opens at one extreme of +/// its range (an "opening marubozu") and runs the other way. +/// +/// ```text +/// range = high − low +/// bullish (+1.0): green, opens at the low (open − low <= tol * range) & long body +/// bearish (−1.0): red, opens at the high (high − open <= tol * range) & long body +/// long body = |close − open| >= 0.5 * range +/// ``` +/// +/// Output is `0.0` when the opening side carries a shadow, the body is short, or +/// the range is degenerate. `shadow_tolerance` defaults to `0.05` (5 % of the bar +/// range allowed on the opening side) and must lie in `[0, 1)`. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BeltHold, Candle, Indicator}; +/// +/// let mut indicator = BeltHold::new(); +/// // Bullish belt-hold: opens at the low, closes near the high. +/// let candle = Candle::new(10.0, 12.0, 10.0, 11.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct BeltHold { + shadow_tolerance: f64, + has_emitted: bool, +} + +impl Default for BeltHold { + fn default() -> Self { + Self::new() + } +} + +impl BeltHold { + /// Construct a Belt-hold detector with the default 5 % opening-shadow tolerance. + pub const fn new() -> Self { + Self { + shadow_tolerance: 0.05, + has_emitted: false, + } + } + + /// Construct a Belt-hold detector with a custom opening-shadow tolerance. + /// + /// `shadow_tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(shadow_tolerance: f64) -> Result { + if !(0.0..1.0).contains(&shadow_tolerance) { + return Err(Error::InvalidPeriod { + message: "belt-hold shadow tolerance must lie in [0, 1)", + }); + } + Ok(Self { + shadow_tolerance, + has_emitted: false, + }) + } + + /// Configured opening-shadow tolerance. + pub fn shadow_tolerance(&self) -> f64 { + self.shadow_tolerance + } +} + +impl Indicator for BeltHold { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = candle.close - candle.open; + if body.abs() < 0.5 * range { + return Some(0.0); + } + let tol = self.shadow_tolerance * range; + // Bullish: opens at the low (no lower shadow), green body. + if body > 0.0 && candle.open - candle.low <= tol { + return Some(1.0); + } + // Bearish: opens at the high (no upper shadow), red body. + if body < 0.0 && candle.high - candle.open <= tol { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "BeltHold" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(BeltHold::with_tolerance(-0.01).is_err()); + assert!(BeltHold::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = BeltHold::with_tolerance(0.0).unwrap(); + assert!((t.shadow_tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = BeltHold::default(); + assert_eq!(t.name(), "BeltHold"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + assert!((t.shadow_tolerance() - 0.05).abs() < 1e-12); + } + + #[test] + fn bullish_belt_hold_is_plus_one() { + let mut t = BeltHold::new(); + assert_eq!(t.update(c(10.0, 12.0, 10.0, 11.5, 0)), Some(1.0)); + } + + #[test] + fn bearish_belt_hold_is_minus_one() { + let mut t = BeltHold::new(); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.5, 0)), Some(-1.0)); + } + + #[test] + fn opening_shadow_yields_zero() { + let mut t = BeltHold::new(); + // Opens 0.5 above the low -> lower shadow exceeds tolerance. + assert_eq!(t.update(c(10.5, 12.0, 10.0, 11.5, 0)), Some(0.0)); + } + + #[test] + fn short_body_yields_zero() { + let mut t = BeltHold::new(); + // Body 0.5 < half the range (1.0) -> not a long belt-hold. + assert_eq!(t.update(c(10.0, 12.0, 10.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = BeltHold::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base, base + 1.8, i) + }) + .collect(); + let mut a = BeltHold::new(); + let mut b = BeltHold::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = BeltHold::new(); + t.update(c(10.0, 12.0, 10.0, 11.5, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/beta.rs b/crates/wickra-core/src/indicators/beta.rs new file mode 100644 index 0000000..3d7be81 --- /dev/null +++ b/crates/wickra-core/src/indicators/beta.rs @@ -0,0 +1,242 @@ +//! Rolling Beta — sensitivity of an asset to a benchmark. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Beta of an `asset` series relative to a `benchmark` series. +/// +/// Each `update` receives one `(asset, benchmark)` pair. Over the trailing +/// window of `period` pairs: +/// +/// ```text +/// cov_ab = (1/n) · Σ a·b − ā·b̄ +/// var_b = (1/n) · Σ b² − b̄² +/// Beta = cov_ab / var_b +/// ``` +/// +/// Beta measures how much the asset moves for a unit move in the +/// benchmark. A reading of `1.0` means the two move together one-for-one; +/// `2.0` means the asset typically doubles the benchmark's moves; +/// `0.5` means it moves only half as much; `0.0` means moves are +/// uncorrelated; negative Betas signal a hedge. It is the slope of the +/// OLS regression of the asset on the benchmark and the foundation of the +/// CAPM. Unlike [`crate::PearsonCorrelation`], Beta is *not* unit-free — +/// it carries the ratio of standard deviations. +/// +/// Each `update` is O(1): four running sums (`Σa`, `Σb`, `Σb²`, `Σa·b`) +/// are maintained as the window slides. A flat benchmark window has zero +/// variance and Beta is undefined; the indicator returns `0` in that +/// case rather than producing `NaN`. +/// +/// Conventionally Beta is computed on **returns** (typically log-returns) +/// rather than raw prices; feed the indicator pre-computed returns if +/// that is your convention. The pure rolling OLS slope is the same +/// either way. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Beta, Indicator}; +/// +/// let mut indicator = Beta::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// // Asset doubles every benchmark move. +/// last = indicator.update((2.0 * f64::from(i), f64::from(i))); +/// } +/// assert!((last.unwrap() - 2.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct Beta { + period: usize, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl Beta { + /// Construct a new rolling Beta. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "beta needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Beta { + /// `(asset, benchmark)` pair. + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((a, b)); + self.sum_a += a; + self.sum_b += b; + self.sum_bb += b * b; + self.sum_ab += a * b; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0); + let cov = self.sum_ab / n - mean_a * mean_b; + if var_b == 0.0 { + // A flat benchmark has no defined beta. + return Some(0.0); + } + Some(cov / var_b) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Beta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(Beta::new(0).is_err()); + assert!(Beta::new(1).is_err()); + assert!(Beta::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let b = Beta::new(14).unwrap(); + assert_eq!(b.period(), 14); + assert_eq!(b.warmup_period(), 14); + assert_eq!(b.name(), "Beta"); + } + + #[test] + fn perfect_two_to_one_relationship() { + let pairs: Vec<(f64, f64)> = (0..10) + .map(|i| (2.0 * f64::from(i), f64::from(i))) + .collect(); + let last = Beta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 2.0, epsilon = 1e-9); + } + + #[test] + fn perfect_negative_one() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (-f64::from(i), f64::from(i))).collect(); + let last = Beta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_benchmark_yields_zero() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect(); + let last = Beta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut b = Beta::new(5).unwrap(); + b.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(b.is_ready()); + b.reset(); + assert!(!b.is_ready()); + assert_eq!(b.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin() * 2.0 + 0.3 * t.cos(), t.sin()) + }) + .collect(); + let batch = Beta::new(14).unwrap().batch(&pairs); + let mut b = Beta::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut b = Beta::new(3).unwrap(); + assert_eq!(b.update((f64::NAN, 1.0)), None); + assert_eq!(b.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(b.update((1.0, 2.0)), None); + assert_eq!(b.update((2.0, 5.0)), None); + assert!(b.update((3.0, 7.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/beta_neutral_spread.rs b/crates/wickra-core/src/indicators/beta_neutral_spread.rs new file mode 100644 index 0000000..8d43bd8 --- /dev/null +++ b/crates/wickra-core/src/indicators/beta_neutral_spread.rs @@ -0,0 +1,261 @@ +//! Beta-neutral spread: the rolling OLS regression residual of two series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// The beta-neutral spread between two assets — the residual of a rolling +/// ordinary-least-squares regression of `a` on `b`. +/// +/// Each `update` takes one `(a, b)` price pair. Over the trailing window of +/// `period` pairs the indicator fits the hedge ratio `β` (and intercept `α`) by +/// OLS and reports the **current** residual: +/// +/// ```text +/// β = cov(a, b) / var(b) α = ā − β · b̄ +/// spread = a_now − (α + β · b_now) +/// ``` +/// +/// Subtracting `β · b` removes `a`'s exposure to `b`, so the spread is market- +/// (beta-)neutral: it is what is left after the common factor is hedged out. +/// Positive means `a` is rich relative to its hedge, negative means cheap — the +/// raw signal a pairs trade fades. Where [`crate::PairSpreadZScore`] standardises +/// this residual into a z-score and [`crate::Cointegration`] bundles it with an +/// ADF test, this indicator returns the residual itself, in price units. +/// +/// If `b` is flat over the window (`var(b) = 0`) there is no defined slope; the +/// indicator falls back to `β = 0`, so the spread becomes `a_now − ā`. +/// +/// Each `update` is `O(1)`: four running sums (`Σa`, `Σb`, `Σb²`, `Σab`) are +/// maintained as the window slides. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BetaNeutralSpread, Indicator}; +/// +/// let mut s = BetaNeutralSpread::new(20).unwrap(); +/// let mut last = None; +/// for t in 0..40 { +/// let b = 100.0 + f64::from(t); +/// // a = 2·b + 5 exactly ⇒ the regression explains a fully ⇒ spread ≈ 0. +/// last = s.update((2.0 * b + 5.0, b)); +/// } +/// assert!(last.unwrap().abs() < 1e-6); +/// ``` +#[derive(Debug, Clone)] +pub struct BetaNeutralSpread { + period: usize, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl BetaNeutralSpread { + /// Construct a new beta-neutral spread. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression slope + /// needs at least two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "beta-neutral spread needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Configured look-back window. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for BetaNeutralSpread { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((a, b)); + self.sum_a += a; + self.sum_b += b; + self.sum_bb += b * b; + self.sum_ab += a * b; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0); + let (beta, intercept) = if var_b == 0.0 { + (0.0, mean_a) + } else { + let cov = self.sum_ab / n - mean_a * mean_b; + let slope = cov / var_b; + (slope, mean_a - slope * mean_b) + }; + Some(a - (intercept + beta * b)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "BetaNeutralSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(BetaNeutralSpread::new(1).is_err()); + assert!(BetaNeutralSpread::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = BetaNeutralSpread::new(20).unwrap(); + assert_eq!(s.period(), 20); + assert_eq!(s.warmup_period(), 20); + assert_eq!(s.name(), "BetaNeutralSpread"); + assert!(!s.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut s = BetaNeutralSpread::new(3).unwrap(); + assert_eq!(s.update((1.0, 1.0)), None); + assert_eq!(s.update((2.0, 2.0)), None); + assert!(s.update((3.0, 3.0)).is_some()); + assert!(s.is_ready()); + } + + #[test] + fn perfect_linear_relationship_has_zero_spread() { + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| { + let b = 100.0 + f64::from(t); + (2.0 * b + 5.0, b) + }) + .collect(); + let last = BetaNeutralSpread::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-6); + } + + #[test] + fn dislocation_produces_nonzero_spread() { + // a tracks 2·b, then the last bar jumps up ⇒ positive residual. + let mut pairs: Vec<(f64, f64)> = (0..19) + .map(|t| { + let b = 100.0 + f64::from(t); + (2.0 * b + 5.0, b) + }) + .collect(); + pairs.push((2.0 * 119.0 + 5.0 + 10.0, 119.0)); + let last = BetaNeutralSpread::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last > 1.0, "spread {last}"); + } + + #[test] + fn flat_b_falls_back_to_demeaned_a() { + // b constant ⇒ β = 0 ⇒ spread = a − mean(a). Last window of a = 0..9, + // mean = 4.5, last a = 9 ⇒ spread = 4.5. + let pairs: Vec<(f64, f64)> = (0..10).map(|t| (f64::from(t), 7.0)).collect(); + let last = BetaNeutralSpread::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 4.5, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut s = BetaNeutralSpread::new(4).unwrap(); + s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 9.0), (5.0, 2.0)]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|t| { + let b = 30.0 + 0.7 * f64::from(t); + (1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b) + }) + .collect(); + let batch = BetaNeutralSpread::new(20).unwrap().batch(&pairs); + let mut s = BetaNeutralSpread::new(20).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut s = BetaNeutralSpread::new(3).unwrap(); + assert_eq!(s.update((f64::NAN, 1.0)), None); + assert_eq!(s.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(s.update((1.0, 2.0)), None); + assert_eq!(s.update((2.0, 5.0)), None); + assert!(s.update((3.0, 7.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/better_volume.rs b/crates/wickra-core/src/indicators/better_volume.rs new file mode 100644 index 0000000..e53d4f0 --- /dev/null +++ b/crates/wickra-core/src/indicators/better_volume.rs @@ -0,0 +1,254 @@ +//! Better Volume (VSA) — a streaming effort-versus-result oscillator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result" +/// oscillator: how much volume (effort) a bar spent relative to the price range +/// (result) it achieved, both normalised against their own recent averages. +/// +/// ```text +/// range_t = high_t − low_t +/// rel_vol = volume_t / SMA(volume, period) +/// rel_range = range_t / SMA(range, period) +/// BetterVol = rel_vol − rel_range +/// ``` +/// +/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets +/// through the relationship between **effort** (volume) and **result** (the bar's +/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while +/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort +/// produced little movement, the hallmark of absorption (supply meeting demand at +/// a top, or vice versa at a bottom). A bar that travels far on light volume — +/// negative oscillator — shows *ease of movement*, a trend meeting no resistance. +/// +/// Both legs are normalised by their `period` simple moving averages (including +/// the current bar), so the output is centred near `0` and self-scales to the +/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing +/// by zero. The first value lands after `period` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, BetterVolume}; +/// +/// let mut indicator = BetterVolume::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BetterVolume { + period: usize, + volumes: VecDeque, + ranges: VecDeque, + vol_sum: f64, + range_sum: f64, + last: Option, +} + +impl BetterVolume { + /// Construct a new Better Volume oscillator with the given averaging `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + volumes: VecDeque::with_capacity(period), + ranges: VecDeque::with_capacity(period), + vol_sum: 0.0, + range_sum: 0.0, + last: None, + }) + } + + /// Configured averaging period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for BetterVolume { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + if self.volumes.len() == self.period { + self.vol_sum -= self.volumes.pop_front().expect("non-empty"); + self.range_sum -= self.ranges.pop_front().expect("non-empty"); + } + self.volumes.push_back(candle.volume); + self.ranges.push_back(range); + self.vol_sum += candle.volume; + self.range_sum += range; + if self.volumes.len() < self.period { + return None; + } + let n = self.period as f64; + let sma_vol = self.vol_sum / n; + let sma_range = self.range_sum / n; + let rel_vol = if sma_vol > 0.0 { + candle.volume / sma_vol + } else { + 0.0 + }; + let rel_range = if sma_range > 0.0 { + range / sma_range + } else { + 0.0 + }; + let out = rel_vol - rel_range; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.volumes.clear(); + self.ranges.clear(); + self.vol_sum = 0.0; + self.range_sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "BetterVolume" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, high, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let bv = BetterVolume::new(20).unwrap(); + assert_eq!(bv.period(), 20); + assert_eq!(bv.warmup_period(), 20); + assert_eq!(bv.name(), "BetterVolume"); + assert!(!bv.is_ready()); + assert_eq!(bv.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut bv = BetterVolume::new(3).unwrap(); + let candles: Vec = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect(); + let out = bv.batch(&candles); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn steady_bars_are_neutral() { + // Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0. + let mut bv = BetterVolume::new(4).unwrap(); + let candles: Vec = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect(); + let last = bv.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn churn_bar_is_positive() { + // Three normal bars, then a high-volume narrow-range bar -> positive. + let mut bv = BetterVolume::new(4).unwrap(); + let mut candles: Vec = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect(); + candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range + let last = bv.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last > 0.0, "churn bar should be positive, got {last}"); + } + + #[test] + fn ease_of_movement_bar_is_negative() { + // Three normal bars, then a wide-range light-volume bar -> negative. + let mut bv = BetterVolume::new(4).unwrap(); + let mut candles: Vec = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect(); + candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume + let last = bv.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last < 0.0, + "ease-of-movement bar should be negative, got {last}" + ); + } + + #[test] + fn zero_everything_is_zero() { + // Zero volume and zero range -> both legs guarded to 0. + let mut bv = BetterVolume::new(3).unwrap(); + let candles: Vec = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect(); + for v in bv.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut bv = BetterVolume::new(3).unwrap(); + bv.batch( + &(0..6) + .map(|_| candle(102.0, 100.0, 1_000.0)) + .collect::>(), + ); + assert!(bv.is_ready()); + bv.reset(); + assert!(!bv.is_ready()); + assert_eq!(bv.value(), None); + assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + candle( + base + 2.0, + base - 1.5, + 1_000.0 + (f64::from(i) * 0.5).cos() * 400.0, + ) + }) + .collect(); + let batch = BetterVolume::new(20).unwrap().batch(&candles); + let mut b = BetterVolume::new(20).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/bipower_variation.rs b/crates/wickra-core/src/indicators/bipower_variation.rs new file mode 100644 index 0000000..118e3a1 --- /dev/null +++ b/crates/wickra-core/src/indicators/bipower_variation.rs @@ -0,0 +1,281 @@ +//! Realized Bipower Variation — a jump-robust quadratic-variation estimator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Realized Bipower Variation — the sum of *adjacent* absolute log-return +/// products over the trailing `period` returns, scaled to estimate integrated +/// variance. +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// BV = (π / 2) · Σ |r_t| · |r_{t−1}| over the window +/// ``` +/// +/// Bipower variation (Barndorff-Nielsen & Shephard 2004) estimates the same +/// integrated variance as [`RealizedVolatility`](crate::RealizedVolatility)'s +/// `Σ r²`, but by multiplying *neighbouring* absolute returns rather than +/// squaring a single one. A price jump inflates exactly one return; because that +/// return appears in a product with its (ordinary) neighbour rather than squared, +/// its contribution stays bounded — so `BV` is **robust to jumps** while realized +/// variance is not. The constant `π / 2 = μ₁⁻²` (with `μ₁ = E|Z| = √(2/π)` for a +/// standard normal) debiases the product of two half-normal magnitudes back to a +/// variance scale. +/// +/// The output is on the **variance** scale (the jump-robust counterpart of +/// realized *variance*, not volatility); take its square root for a volatility, +/// and compare `RV − BV` to isolate the jump contribution. A window of `period` +/// returns contributes `period − 1` adjacent products; each `update` is O(1) via +/// a running sum. +/// +/// Non-finite and non-positive prices are ignored (the log return would be +/// undefined): the tick is dropped, state is left untouched, and the last value +/// is returned. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BipowerVariation, Indicator}; +/// +/// let mut indicator = BipowerVariation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BipowerVariation { + period: usize, + prev_price: Option, + /// Rolling window of the last `period` log returns. + window: VecDeque, + /// Running sum of adjacent absolute-return products inside the window. + sum_adjacent: f64, + last: Option, +} + +impl BipowerVariation { + /// Construct a new bipower-variation indicator. + /// + /// `period` is the number of log returns in the rolling window; the estimate + /// uses the `period − 1` adjacent products between them. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `period == 1` (an adjacent product needs at + /// least two returns). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "bipower variation period must be >= 2", + }); + } + Ok(Self { + period, + prev_price: None, + window: VecDeque::with_capacity(period), + sum_adjacent: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +/// `μ₁⁻² = π / 2`, the debiasing constant for a product of half-normal returns. +const MU1_INV_SQ: f64 = std::f64::consts::FRAC_PI_2; + +impl Indicator for BipowerVariation { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite / non-positive prices are skipped: `ln(input / prev)` is + // undefined, so the tick must not enter the return window. + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + // `prev` came from `self.prev_price`, gated by the guard above, so it is + // finite and positive — the log return is always well-defined. + let r = (input / prev).ln(); + // The incoming return forms a product with the current last return. + if let Some(&back) = self.window.back() { + self.sum_adjacent += back.abs() * r.abs(); + } + self.window.push_back(r); + if self.window.len() > self.period { + let first = self.window.pop_front().expect("window is non-empty"); + // The product between the dropped return and the new front leaves. + let second = *self.window.front().expect("window still has >= 1 element"); + self.sum_adjacent -= first.abs() * second.abs(); + } + if self.window.len() < self.period { + return None; + } + // Products are non-negative; the rolling subtraction can leave a tiny + // negative residual when returns are ~0, so clamp before scaling. + let bv = MU1_INV_SQ * self.sum_adjacent.max(0.0); + self.last = Some(bv); + Some(bv) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum_adjacent = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first log return needs a previous price, then the window fills. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "BipowerVariation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(BipowerVariation::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_period_one() { + assert!(matches!( + BipowerVariation::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bv = BipowerVariation::new(20).unwrap(); + assert_eq!(bv.period(), 20); + assert_eq!(bv.warmup_period(), 21); + assert_eq!(bv.name(), "BipowerVariation"); + assert!(!bv.is_ready()); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut bv = BipowerVariation::new(5).unwrap(); + let out = bv.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn known_value() { + // period = 2: one adjacent product. r1 = ln(1.1), r2 = ln(0.9). + // BV = (π/2)·|r1|·|r2|. + let mut bv = BipowerVariation::new(2).unwrap(); + let out = bv.batch(&[100.0, 110.0, 99.0]); + assert!(out[1].is_none()); + let r1 = (110.0_f64 / 100.0).ln(); + let r2 = (99.0_f64 / 110.0).ln(); + let expected = std::f64::consts::FRAC_PI_2 * r1.abs() * r2.abs(); + assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12); + } + + #[test] + fn rolling_window_drops_oldest_product() { + // period = 2, four prices -> two emissions, each a single product. + let mut bv = BipowerVariation::new(2).unwrap(); + let out = bv.batch(&[100.0, 110.0, 99.0, 105.0]); + let r2 = (99.0_f64 / 110.0).ln(); + let r3 = (105.0_f64 / 99.0).ln(); + let expected = std::f64::consts::FRAC_PI_2 * r2.abs() * r3.abs(); + assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut bv = BipowerVariation::new(10).unwrap(); + for v in bv.batch(&[100.0; 40]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut bv = BipowerVariation::new(20).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in bv.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "bipower variation must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut bv = BipowerVariation::new(5).unwrap(); + let out = bv.batch(&(1..=20).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(bv.update(f64::NAN), last); + assert_eq!(bv.update(f64::INFINITY), last); + } + + #[test] + fn skips_non_positive_prices() { + let mut bv = BipowerVariation::new(5).unwrap(); + let warmup = bv.batch(&(1..=20).map(f64::from).collect::>()); + let baseline = warmup.last().copied().flatten().expect("warmed up"); + assert_eq!(bv.update(-5.0), Some(baseline)); + assert_eq!(bv.update(0.0), Some(baseline)); + // State untouched: a clone advanced by the same real tick agrees. + let mut control = bv.clone(); + let after = bv.update(21.0).expect("ready"); + assert_eq!(control.update(21.0).expect("ready"), after); + } + + #[test] + fn reset_clears_state() { + let mut bv = BipowerVariation::new(5).unwrap(); + bv.batch(&(1..=20).map(f64::from).collect::>()); + assert!(bv.is_ready()); + bv.reset(); + assert!(!bv.is_ready()); + assert_eq!(bv.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = BipowerVariation::new(20).unwrap().batch(&prices); + let mut b = BipowerVariation::new(20).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/body_size_pct.rs b/crates/wickra-core/src/indicators/body_size_pct.rs new file mode 100644 index 0000000..478bd76 --- /dev/null +++ b/crates/wickra-core/src/indicators/body_size_pct.rs @@ -0,0 +1,193 @@ +//! Body Size Percent — candle body as a fraction of its range. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Body Size Percent — the absolute body as a fraction of the bar's range. +/// +/// ```text +/// BodySizePct = |close − open| / (high − low) +/// ``` +/// +/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened +/// at one extreme and closed at the other, no wicks), `0` a doji (open equals +/// close, the bar is all wick). It is the *unsigned* magnitude companion to +/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction, +/// this keeps only the conviction, which is exactly what candlestick body / +/// range filters key on. A zero-range bar carries no information and yields `0`. +/// +/// This is a stateless per-bar transform: every candle produces one value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, BodySizePct}; +/// +/// let mut indicator = BodySizePct::new(); +/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5. +/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap(); +/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct BodySizePct { + has_emitted: bool, +} + +impl BodySizePct { + /// Construct a new Body Size Percent transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for BodySizePct { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + let out = if range == 0.0 { + // A zero-range bar has no body proportion to speak of. + 0.0 + } else { + (candle.close - candle.open).abs() / range + }; + Some(out) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "BodySizePct" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // |12 - 10| / (14 - 10) = 0.5. + let mut bsp = BodySizePct::new(); + assert_relative_eq!( + bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(), + 0.5, + epsilon = 1e-12 + ); + } + + #[test] + fn marubozu_is_one() { + // open == low, close == high, no wicks -> full body -> 1. + let mut bsp = BodySizePct::new(); + assert_relative_eq!( + bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(), + 1.0, + epsilon = 1e-12 + ); + } + + #[test] + fn doji_is_zero() { + // open == close with a real range -> body 0. + let mut bsp = BodySizePct::new(); + assert_relative_eq!( + bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn unsigned_regardless_of_direction() { + // A red bar with the same body magnitude reads identically to a green one. + let mut bsp = BodySizePct::new(); + let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(); + let mut bsp2 = BodySizePct::new(); + let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap(); + assert_relative_eq!(green, red, epsilon = 1e-12); + } + + #[test] + fn zero_range_bar_yields_zero() { + let mut bsp = BodySizePct::new(); + assert_relative_eq!( + bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn stays_within_unit_range() { + let candles: Vec = (0..100) + .map(|i| { + let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0; + let close = mid + (f64::from(i) * 0.5).cos() * 2.0; + candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i)) + }) + .collect(); + let mut bsp = BodySizePct::new(); + for v in bsp.batch(&candles).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]"); + } + } + + #[test] + fn name_metadata() { + let bsp = BodySizePct::new(); + assert_eq!(bsp.name(), "BodySizePct"); + } + + #[test] + fn emits_from_first_candle() { + let mut bsp = BodySizePct::new(); + assert_eq!(bsp.warmup_period(), 1); + assert!(!bsp.is_ready()); + assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(bsp.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut bsp = BodySizePct::new(); + bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(bsp.is_ready()); + bsp.reset(); + assert!(!bsp.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i)) + }) + .collect(); + let mut a = BodySizePct::new(); + let mut b = BodySizePct::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/bollinger.rs b/crates/wickra-core/src/indicators/bollinger.rs new file mode 100644 index 0000000..b993cb6 --- /dev/null +++ b/crates/wickra-core/src/indicators/bollinger.rs @@ -0,0 +1,520 @@ +//! Bollinger Bands. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Bollinger Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BollingerOutput { + /// Upper band: `middle + multiplier * stddev`. + pub upper: f64, + /// Middle band: SMA over the window. + pub middle: f64, + /// Lower band: `middle − multiplier * stddev`. + pub lower: f64, + /// Sample standard deviation (denominator `period`, population stddev) used to build + /// the bands. Reported separately because some callers compute their own bands. + pub stddev: f64, +} + +/// Bollinger Bands with SMA middle band and population standard deviation envelopes. +/// +/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original +/// publication uses population (not sample) standard deviation, which matches every +/// reference implementation (TA-Lib, pandas-ta, etc.). +/// +/// The running `sum` and `sum_sq` are reseeded from the live window every +/// `16 · period` updates to cap floating-point drift on long streams. This is +/// amortised O(1), preserves bit-equivalence with the previous behaviour on +/// inputs that did not drift, and is particularly important for `sum_sq`, +/// where catastrophic cancellation between large add/subtract pairs can drive +/// the computed variance negative (the `.max(0.0)` clamp below is the +/// safety-net for the rare cases where the reseed has not happened yet). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, BollingerBands}; +/// +/// let mut indicator = BollingerBands::new(5, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BollingerBands { + period: usize, + multiplier: f64, + /// Fixed-capacity ring buffer of the last `period` finite inputs. A flat + /// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path. + buf: Box<[f64]>, + /// Index of the next slot to write — also the oldest element once full. + head: usize, + /// Number of slots filled, saturating at `period`. + count: usize, + sum: f64, + sum_sq: f64, + /// Number of finite updates since the running sums were last reseeded + /// from the live window. See [`RECOMPUTE_EVERY`] below. + updates_since_recompute: usize, +} + +/// How often (in finite updates) the incremental `sum` / `sum_sq` are reseeded +/// from the live window. The multiplier `16` keeps the amortised cost flat and +/// caps any cancellation drift to roughly `16 · period · ULP · max(|x|²)` — +/// negligible on real-world price scales. +const RECOMPUTE_EVERY: usize = 16; + +impl BollingerBands { + /// Construct a new Bollinger Bands indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] for `period == 0` and + /// [`Error::NonPositiveMultiplier`] for `multiplier <= 0`. + pub fn new(period: usize, multiplier: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + multiplier, + buf: vec![0.0; period].into_boxed_slice(), + head: 0, + count: 0, + sum: 0.0, + sum_sq: 0.0, + updates_since_recompute: 0, + }) + } + + /// Classic configuration: `period = 20`, `multiplier = 2.0`. + pub fn classic() -> Self { + Self::new(20, 2.0).expect("classic Bollinger parameters are valid") + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } + + /// Vectorized flat batch for bindings: returns `n * 4` values laid out as + /// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`. + /// + /// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq` + /// and drift-reseed, writing the four band values directly instead of an + /// `Option` per element. Same add/subtract order, same reseed + /// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to + /// replaying `update`, including the long-stream drift bound. Any other state, + /// or a non-finite element, defers to the exact `update` replay. + /// + /// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch), + /// which returns `Vec>`; only the bindings, which want + /// a flat `f64` buffer, call this. + pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec { + let p = self.period; + let n = inputs.len(); + if self.count != 0 + || self.updates_since_recompute != 0 + || !inputs.iter().all(|x| x.is_finite()) + { + // Slow path: exact replay of `update` into the flat layout. + let mut out = vec![f64::NAN; n * 4]; + for (i, &x) in inputs.iter().enumerate() { + if let Some(o) = self.update(x) { + out[i * 4] = o.upper; + out[i * 4 + 1] = o.middle; + out[i * 4 + 2] = o.lower; + out[i * 4 + 3] = o.stddev; + } + } + return out; + } + + let p_f64 = p as f64; + let mult = self.multiplier; + // Pre-sized output: warmup rows stay NaN, ready rows are written in place + // by index — no per-row `push` length/capacity check. + let mut out = vec![f64::NAN; n * 4]; + for (i, &x) in inputs.iter().enumerate() { + if self.count == p { + let old = self.buf[self.head]; + self.sum -= old; + self.sum_sq -= old * old; + self.buf[self.head] = x; + self.sum += x; + self.sum_sq += x * x; + } else { + self.buf[self.head] = x; + self.sum += x; + self.sum_sq += x * x; + self.count += 1; + } + self.head += 1; + if self.head == p { + self.head = 0; + } + self.updates_since_recompute += 1; + if self.updates_since_recompute >= RECOMPUTE_EVERY * p { + let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]); + self.sum = chronological.clone().copied().sum(); + self.sum_sq = chronological.map(|&v| v * v).sum(); + self.updates_since_recompute = 0; + } + if self.count == p { + let mean = self.sum / p_f64; + let stddev = (self.sum_sq / p_f64 - mean * mean).max(0.0).sqrt(); + let band = mult * stddev; + out[i * 4] = mean + band; + out[i * 4 + 1] = mean; + out[i * 4 + 2] = mean - band; + out[i * 4 + 3] = stddev; + } + } + out + } + + fn current(&self) -> Option { + if self.count != self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Population variance: E[x^2] - (E[x])^2. Clamp small negative values that arise + // from catastrophic cancellation on near-constant inputs. + let var = (self.sum_sq / n - mean * mean).max(0.0); + let stddev = var.sqrt(); + Some(BollingerOutput { + upper: mean + self.multiplier * stddev, + middle: mean, + lower: mean - self.multiplier * stddev, + stddev, + }) + } +} + +impl Indicator for BollingerBands { + type Input = f64; + type Output = BollingerOutput; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current(); + } + if self.count == self.period { + let old = self.buf[self.head]; + self.sum -= old; + self.sum_sq -= old * old; + self.buf[self.head] = input; + self.sum += input; + self.sum_sq += input * input; + } else { + self.buf[self.head] = input; + self.sum += input; + self.sum_sq += input * input; + self.count += 1; + } + self.head += 1; + if self.head == self.period { + self.head = 0; + } + self.updates_since_recompute += 1; + if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { + // Reseed in chronological order (oldest at `head`) to keep the running + // sums bit-equivalent to a fresh from-scratch pass on stable inputs. + let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]); + self.sum = chronological.clone().copied().sum(); + self.sum_sq = chronological.map(|&x| x * x).sum(); + self.updates_since_recompute = 0; + } + self.current() + } + + fn reset(&mut self) { + self.head = 0; + self.count = 0; + self.sum = 0.0; + self.sum_sq = 0.0; + self.updates_since_recompute = 0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.count == self.period + } + + fn name(&self) -> &'static str { + "BollingerBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + use std::collections::VecDeque; + + fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput { + assert!( + prices.len() >= period, + "naive requires at least `period` prices" + ); + let w = &prices[prices.len() - period..]; + let mean = w.iter().sum::() / period as f64; + let var = w.iter().map(|x| (x - mean).powi(2)).sum::() / period as f64; + let s = var.sqrt(); + BollingerOutput { + upper: mean + mult * s, + middle: mean, + lower: mean - mult * s, + stddev: s, + } + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + BollingerBands::new(0, 2.0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + BollingerBands::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + BollingerBands::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + BollingerBands::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + /// Cover the convenience constructor `BollingerBands::classic()` plus the + /// const accessors `period` / `multiplier` and the Indicator-impl + /// metadata methods `warmup_period` / `name`. Existing tests never + /// invoked `classic()` (every test passed explicit parameters to + /// `new`) and never queried any of the four getters. + #[test] + fn classic_and_accessors_and_metadata() { + let bb = BollingerBands::classic(); + assert_eq!(bb.period(), 20); + assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(bb.warmup_period(), 20); + assert_eq!(bb.name(), "BollingerBands"); + } + + #[test] + fn warmup_returns_none() { + let mut bb = BollingerBands::new(5, 2.0).unwrap(); + for v in [1.0, 2.0, 3.0, 4.0] { + assert!(bb.update(v).is_none()); + } + assert!(bb.update(5.0).is_some()); + } + + #[test] + fn constant_series_yields_zero_stddev() { + let mut bb = BollingerBands::new(10, 2.0).unwrap(); + let out = bb.batch(&[5.0_f64; 30]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-12); + assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 5.0, epsilon = 1e-12); + } + + #[test] + fn matches_naive_definition() { + let prices: Vec = (1..=60) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut bb = BollingerBands::new(20, 2.0).unwrap(); + let out = bb.batch(&prices); + for i in 19..prices.len() { + let got = out[i].unwrap(); + let want = naive(&prices[..=i], 20, 2.0); + assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9); + assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9); + assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9); + assert_relative_eq!(got.lower, want.lower, epsilon = 1e-9); + } + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (1..=100).map(f64::from).collect(); + let mut bb = BollingerBands::new(20, 2.0).unwrap(); + for o in bb.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(|i| f64::from(i) * 0.7).collect(); + let mut a = BollingerBands::new(10, 2.0).unwrap(); + let mut b = BollingerBands::new(10, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut bb = BollingerBands::new(5, 2.0).unwrap(); + bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(bb.is_ready()); + bb.reset(); + assert!(!bb.is_ready()); + } + + /// Long-running stability check. After several recompute cycles the + /// reported Bollinger bands must still equal a fresh from-scratch + /// computation over the live window — even on inputs designed to cause + /// catastrophic cancellation in the `sum_sq` accumulator (alternating + /// between two very different magnitudes). + #[test] + fn long_stream_drift_stays_bounded() { + let period = 20; + let mult = 2.0; + let mut bb = BollingerBands::new(period, mult).unwrap(); + let mut window: VecDeque = VecDeque::with_capacity(period); + // Forces the periodic reseed to fire 5+ times. + let n_updates = 16 * period * 5; + let mut last = None; + for i in 0..n_updates { + let v = if i % 2 == 0 { 1e6 } else { 1.0 }; + last = bb.update(v); + if window.len() == period { + window.pop_front(); + } + window.push_back(v); + } + let scratch = naive(&window.iter().copied().collect::>(), period, mult); + let got = last.expect("warmed up"); + assert!( + (got.middle - scratch.middle).abs() < 1e-3, + "middle drift: got={}, scratch={}", + got.middle, + scratch.middle, + ); + assert!( + (got.stddev - scratch.stddev).abs() < 1e-3, + "stddev drift: got={}, scratch={}", + got.stddev, + scratch.stddev, + ); + } + + fn bits_eq(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x == y || (x.is_nan() && y.is_nan())) + } + + /// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`. + fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec { + let mut bb = BollingerBands::new(period, mult).unwrap(); + let mut out = Vec::with_capacity(series.len() * 4); + for &x in series { + match bb.update(x) { + Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]), + None => out.extend_from_slice(&[f64::NAN; 4]), + } + } + out + } + + #[test] + fn batch_bands_fast_path_is_bit_identical_with_reseed() { + // > 16*period inputs so the drift-reseed branch fires inside batch_bands. + let series: Vec = (0..500) + .map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0) + .collect(); + let mut bb = BollingerBands::new(20, 2.0).unwrap(); + let got = bb.batch_bands(&series); + assert!(bits_eq(&got, &bb_replay(20, 2.0, &series))); + // State continues identically. + let mut ref_bb = BollingerBands::new(20, 2.0).unwrap(); + for &x in &series { + ref_bb.update(x); + } + assert_eq!(bb.update(55.0), ref_bb.update(55.0)); + } + + #[test] + fn batch_bands_falls_back_on_non_finite() { + let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0]; + let mut bb = BollingerBands::new(3, 2.0).unwrap(); + assert!(bits_eq( + &bb.batch_bands(&series), + &bb_replay(3, 2.0, &series) + )); + } + + #[test] + fn batch_bands_falls_back_when_not_fresh() { + let mut bb = BollingerBands::new(3, 2.0).unwrap(); + bb.update(99.0); + let series = [1.0, 2.0, 3.0, 4.0]; + let mut ref_bb = BollingerBands::new(3, 2.0).unwrap(); + ref_bb.update(99.0); + let mut want = Vec::new(); + for &x in &series { + match ref_bb.update(x) { + Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]), + None => want.extend_from_slice(&[f64::NAN; 4]), + } + } + assert!(bits_eq(&bb.batch_bands(&series), &want)); + } + + #[test] + fn batch_bands_sub_period_slice_is_all_nan() { + let series = [1.0, 2.0, 3.0]; + let mut bb = BollingerBands::new(10, 2.0).unwrap(); + let got = bb.batch_bands(&series); + assert!(bits_eq(&got, &bb_replay(10, 2.0, &series))); + assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12); + } + + #[test] + fn ignores_non_finite_input() { + let mut bb = BollingerBands::new(5, 2.0).unwrap(); + let ready = bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = ready.last().unwrap().unwrap(); + // Non-finite inputs return the current bands without mutating the window. + assert_eq!(bb.update(f64::NAN).unwrap(), last); + assert_eq!(bb.update(f64::INFINITY).unwrap(), last); + // The window still holds 1..=5, so a real input slides it to 2..=6. + let after = bb.update(6.0).unwrap(); + assert_relative_eq!( + after.middle, + (2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0, + epsilon = 1e-12 + ); + } +} diff --git a/crates/wickra-core/src/indicators/bollinger_bandwidth.rs b/crates/wickra-core/src/indicators/bollinger_bandwidth.rs new file mode 100644 index 0000000..d8a6a42 --- /dev/null +++ b/crates/wickra-core/src/indicators/bollinger_bandwidth.rs @@ -0,0 +1,212 @@ +//! Bollinger Bandwidth. + +use crate::error::Result; +use crate::traits::Indicator; + +use super::BollingerBands; + +/// Bollinger Bandwidth — the width of the Bollinger Bands relative to the +/// middle band. +/// +/// ```text +/// Bandwidth = (upper − lower) / middle +/// ``` +/// +/// Because the bands are `middle ± multiplier · stddev`, the bandwidth is +/// `2 · multiplier · stddev / middle` — a normalised volatility reading. Its +/// value is the basis of two classic patterns: the **squeeze** (bandwidth at a +/// multi-month low, signalling a coiled, low-volatility market about to +/// expand) and the **bulge** (bandwidth at an extreme high). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, BollingerBandwidth}; +/// +/// let mut indicator = BollingerBandwidth::new(20, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BollingerBandwidth { + bands: BollingerBands, + last: Option, +} + +impl BollingerBandwidth { + /// Construct a new Bollinger Bandwidth indicator. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] for `period == 0` and + /// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`. + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + bands: BollingerBands::new(period, multiplier)?, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.bands.period() + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.bands.multiplier() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for BollingerBandwidth { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let o = self.bands.update(input)?; + let bandwidth = if o.middle == 0.0 { + // Undefined against a zero middle band. + 0.0 + } else { + (o.upper - o.lower) / o.middle + }; + self.last = Some(bandwidth); + Some(bandwidth) + } + + fn reset(&mut self) { + self.bands.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.bands.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "BollingerBandwidth" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_invalid_parameters() { + assert!(BollingerBandwidth::new(0, 2.0).is_err()); + assert!(BollingerBandwidth::new(20, 0.0).is_err()); + assert!(BollingerBandwidth::new(20, -1.0).is_err()); + } + + /// Cover the public const accessors `period`, `multiplier`, `value` and + /// the Indicator-impl `warmup_period` + `name` methods. None of the + /// pre-existing tests inspected the metadata surface — they only fed + /// numeric updates and asserted on the bandwidth values, leaving the + /// five getter bodies (lines 54-66, 90-92, 98-100) untouched. + #[test] + fn accessors_and_metadata() { + let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap(); + assert_eq!(bbw.period(), 20); + assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12); + // value() before warmup must be the literal None branch of self.last. + assert_eq!(bbw.value(), None); + assert_eq!(bbw.warmup_period(), 20); + assert_eq!(bbw.name(), "BollingerBandwidth"); + // Drive past warmup so value() exercises the Some branch as well. + for i in 1..=20 { + bbw.update(f64::from(i)); + } + assert!(bbw.value().is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Flat prices: the bands collapse onto the middle, so width is 0. + let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap(); + let out = bbw.batch(&[100.0; 20]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + /// Cover the defensive `o.middle == 0.0` branch in `update` (line 77). + /// All other tests use price levels ≈100, so the rolling SMA is always + /// strictly positive and the zero-middle fallback is unreachable. Feed + /// a symmetric series whose 5-bar mean is exactly 0 to force the branch + /// and assert the indicator yields exactly 0.0 (rather than inf/nan). + #[test] + fn zero_middle_band_yields_zero_bandwidth() { + let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap(); + // sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle + // lands on exactly 0.0 at the fifth input. Stddev > 0, so absent + // the guard the next line would divide by zero. + let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]); + assert_eq!(out[..4], [None, None, None, None]); + let v = out[4].expect("warmed up"); + assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0"); + } + + #[test] + fn matches_bands_definition() { + // Bandwidth must equal (upper - lower) / middle from BollingerBands. + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices); + let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices); + for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() { + // Same warmup period on both — emission shape must agree at every index. + assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}"); + if let (Some(wv), Some(bv)) = (w, b) { + assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12); + } + } + } + + #[test] + fn output_is_non_negative() { + let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap(); + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0) + .collect(); + for v in bbw.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "bandwidth must be non-negative, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap(); + bbw.batch(&(1..=20).map(f64::from).collect::>()); + assert!(bbw.is_ready()); + bbw.reset(); + assert!(!bbw.is_ready()); + assert_eq!(bbw.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0) + .collect(); + let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices); + let mut b = BollingerBandwidth::new(20, 2.0).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/bomar_bands.rs b/crates/wickra-core/src/indicators/bomar_bands.rs new file mode 100644 index 0000000..dd215a7 --- /dev/null +++ b/crates/wickra-core/src/indicators/bomar_bands.rs @@ -0,0 +1,259 @@ +//! Bomar Bands — adaptive percentage bands that contain a target fraction of +//! recent price. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rolling_quantile::quantile_sorted; +use crate::traits::Indicator; + +/// Bomar Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BomarBandsOutput { + /// Upper band: `middle + |middle| · p`. + pub upper: f64, + /// Middle line: the simple moving average over the window. + pub middle: f64, + /// Lower band: `middle − |middle| · p`. + pub lower: f64, +} + +/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage` +/// fraction of recent closes falls inside them. +/// +/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an +/// inspiration — percentage bands around a moving average, with the percentage +/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra +/// realises that idea deterministically: the half-width is the `coverage` +/// quantile of the relative deviations from the midline, so by construction +/// `coverage` of the window's closes lie inside the bands. +/// +/// ```text +/// middle = SMA(close, period) +/// dev_i = | close_i / middle − 1 | // relative distance from midline +/// p = coverage-quantile of { dev_i } // type-7 interpolation +/// upper = middle + |middle| · p +/// lower = middle − |middle| · p +/// ``` +/// +/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset +/// here is data-driven: the bands widen in turbulent regimes and tighten in +/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is +/// an order statistic of the actual deviations rather than a multiple of the +/// standard deviation, so it is unaffected by the shape of the tails beyond the +/// `coverage` rank. When the midline is zero the relative deviation is +/// undefined and the bands collapse onto the midline. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BomarBands, Indicator}; +/// +/// let mut indicator = BomarBands::new(20, 0.85).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i % 7)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BomarBands { + period: usize, + coverage: f64, + window: VecDeque, + scratch: Vec, +} + +impl BomarBands { + /// Construct new Bomar Bands. + /// + /// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidParameter`] if `coverage` is not a finite value in + /// `(0.0, 1.0]`. + pub fn new(period: usize, coverage: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 { + return Err(Error::InvalidParameter { + message: "bomar bands coverage must be a finite value in (0.0, 1.0]", + }); + } + Ok(Self { + period, + coverage, + window: VecDeque::with_capacity(period), + scratch: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured coverage fraction. + pub const fn coverage(&self) -> f64 { + self.coverage + } +} + +impl Indicator for BomarBands { + type Input = f64; + type Output = BomarBandsOutput; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let sum: f64 = self.window.iter().sum(); + let middle = sum / (self.period as f64); + let denom = middle.abs(); + + self.scratch.clear(); + for &v in &self.window { + let dev = if denom == 0.0 { + 0.0 + } else { + ((v - middle) / denom).abs() + }; + self.scratch.push(dev); + } + self.scratch.sort_by(f64::total_cmp); + let p = quantile_sorted(&self.scratch, self.coverage); + let offset = denom * p; + + Some(BomarBandsOutput { + upper: middle + offset, + middle, + lower: middle - offset, + }) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "BomarBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero))); + assert!(BomarBands::new(1, 0.85).is_ok()); + } + + #[test] + fn rejects_out_of_range_coverage() { + assert!(matches!( + BomarBands::new(20, 0.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + BomarBands::new(20, 1.1), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + BomarBands::new(20, -0.5), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + BomarBands::new(20, f64::NAN), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bb = BomarBands::new(20, 0.85).unwrap(); + assert_eq!(bb.period(), 20); + assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12); + assert_eq!(bb.warmup_period(), 20); + assert_eq!(bb.name(), "BomarBands"); + assert!(!bb.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut bb = BomarBands::new(4, 0.85).unwrap(); + assert!(bb.update(100.0).is_none()); + assert!(bb.update(102.0).is_none()); + assert!(bb.update(98.0).is_none()); + assert!(bb.update(104.0).is_some()); + assert!(bb.is_ready()); + } + + #[test] + fn known_bands() { + // mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101. + // offset = 101 * 3/101 = 3 -> upper 104, lower 98. + let mut bb = BomarBands::new(4, 0.85).unwrap(); + let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]); + let last = out[3].unwrap(); + assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9); + } + + #[test] + fn zero_midline_collapses_bands() { + // Window mean exactly zero -> relative deviation undefined -> collapse. + let mut bb = BomarBands::new(2, 0.85).unwrap(); + let out = bb.batch(&[3.0, -3.0]); + let last = out[1].unwrap(); + assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12); + assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12); + } + + #[test] + fn rolling_window_evicts_oldest() { + // Eight values through a period-4 window: only the last four survive, + // reproducing the `known_bands` window. + let mut bb = BomarBands::new(4, 0.85).unwrap(); + let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]); + let last = out[7].unwrap(); + assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut bb = BomarBands::new(4, 0.85).unwrap(); + for v in [100.0, 102.0, 98.0, 104.0] { + bb.update(v); + } + assert!(bb.is_ready()); + bb.reset(); + assert!(!bb.is_ready()); + assert!(bb.update(100.0).is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/breadth_thrust.rs b/crates/wickra-core/src/indicators/breadth_thrust.rs new file mode 100644 index 0000000..99d9549 --- /dev/null +++ b/crates/wickra-core/src/indicators/breadth_thrust.rs @@ -0,0 +1,165 @@ +//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share. + +use crate::cross_section::CrossSection; +use crate::error::Result; +use crate::traits::Indicator; +use crate::Sma; + +/// Breadth Thrust (Zweig) — a simple moving average of the advancing-issues +/// share, `advancers / (advancers + decliners)`. +/// +/// Martin Zweig's breadth thrust smooths the fraction of participating issues +/// that are advancing over a short window (the classic period is 10). A "thrust" +/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth) +/// to above ~0.615 within about ten sessions — historically a rare, reliable +/// signal that a powerful new advance has begun with broad participation. +/// +/// Each tick's share floors the participating count to one, so a tick with no +/// advancing or declining issues contributes a defined `0.0` instead of dividing +/// by zero. The reading is `None` until `period` ticks have been seen. +/// +/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`), +/// `warmup_period == period`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member}; +/// +/// let mut bt = BreadthThrust::new(2).unwrap(); +/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap(); +/// assert_eq!(bt.update(up.clone()), None); // warming up +/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing +/// ``` +#[derive(Debug, Clone)] +pub struct BreadthThrust { + sma: Sma, +} + +impl BreadthThrust { + /// Construct a new Breadth Thrust over the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + sma: Sma::new(period)?, + }) + } + + /// Configured window length. + #[must_use] + pub const fn period(&self) -> usize { + self.sma.period() + } +} + +impl Indicator for BreadthThrust { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancers = section.advancers(); + let decliners = section.decliners(); + let participating = (advancers + decliners).max(1) as f64; + let share = advancers as f64 / participating; + self.sma.update(share) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma.period() + } + + fn is_ready(&self) -> bool { + self.sma.value().is_some() + } + + fn name(&self) -> &'static str { + "BreadthThrust" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::error::Error; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let bt = BreadthThrust::new(10).unwrap(); + assert_eq!(bt.name(), "BreadthThrust"); + assert_eq!(bt.warmup_period(), 10); + assert_eq!(bt.period(), 10); + assert!(!bt.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn averages_the_advancing_share() { + let mut bt = BreadthThrust::new(2).unwrap(); + // share = 8 / 10 = 0.8 ; window not full yet. + assert_eq!(bt.update(section(8, 2)), None); + // share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7. + let value = bt.update(section(6, 4)).unwrap(); + assert!((value - 0.7).abs() < 1e-9); + assert!(bt.is_ready()); + // share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55. + let value = bt.update(section(5, 5)).unwrap(); + assert!((value - 0.55).abs() < 1e-9); + } + + #[test] + fn empty_participation_floors_to_zero_share() { + let mut bt = BreadthThrust::new(1).unwrap(); + // No advancers or decliners -> 0 / max(0, 1) = 0.0. + assert_eq!(bt.update(section(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut bt = BreadthThrust::new(2).unwrap(); + bt.update(section(8, 2)); + bt.update(section(6, 4)); + assert!(bt.is_ready()); + bt.reset(); + assert!(!bt.is_ready()); + assert_eq!(bt.update(section(8, 2)), None); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)]; + let mut a = BreadthThrust::new(2).unwrap(); + let mut b = BreadthThrust::new(2).unwrap(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/breakaway.rs b/crates/wickra-core/src/indicators/breakaway.rs new file mode 100644 index 0000000..e6e965c --- /dev/null +++ b/crates/wickra-core/src/indicators/breakaway.rs @@ -0,0 +1,250 @@ +//! Breakaway candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Breakaway — a 5-bar reversal that fades an exhausted run. A trend gaps away on +/// the second bar, drifts two more bars in the same direction, then the fifth bar +/// snaps the other way and closes back inside the body gap left between the first +/// and second bars, signalling the move has broken away from the crowd and is +/// turning. +/// +/// ```text +/// bullish (+1.0) — appears in a decline: +/// bar1 black (close < open) +/// bar2 black & its body gaps DOWN below bar1's body (bar2.open < bar1.close) +/// bar3 extends lower (high & low below bar2) +/// bar4 black & extends lower (high & low below bar3) +/// bar5 green & closes inside the bar1/bar2 body gap (bar2.open < close < bar1.close) +/// +/// bearish (−1.0) — the mirror in an advance: +/// bar1 white (close > open) +/// bar2 white & its body gaps UP above bar1's body (bar2.open > bar1.close) +/// bar3 extends higher (high & low above bar2) +/// bar4 white & extends higher (high & low above bar3) +/// bar5 red & closes inside the bar1/bar2 body gap (bar1.close < close < bar2.open) +/// ``` +/// +/// The middle bar (`bar3`) may be either colour — only its high/low must extend +/// the run. Output is `+1.0` bullish, `−1.0` bearish, `0.0` otherwise. The first +/// four bars always return `0.0` because the five-bar window is not yet filled. +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. Recognition uses TA-Lib's +/// `CDLBREAKAWAY` body-gap and high/low ordering rules directly; it does not add +/// TA-Lib's rolling body-length average, matching the geometric house style of +/// the other multi-bar patterns in this family. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Breakaway, Candle, Indicator}; +/// +/// let mut indicator = Breakaway::new(); +/// indicator.update(Candle::new(20.0, 20.2, 14.8, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(14.0, 14.1, 11.9, 12.0, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(12.5, 13.0, 10.5, 11.0, 1.0, 2).unwrap()); +/// indicator.update(Candle::new(11.0, 11.5, 9.0, 9.5, 1.0, 3).unwrap()); +/// let out = indicator +/// .update(Candle::new(9.5, 14.7, 9.4, 14.5, 1.0, 4).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Breakaway { + c1: Option, + c2: Option, + c3: Option, + c4: Option, + has_emitted: bool, +} + +impl Breakaway { + /// Construct a new Breakaway detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + c3: None, + c4: None, + has_emitted: false, + } + } +} + +impl Indicator for Breakaway { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + let bar4 = self.c4; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = self.c4; + self.c4 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else { + return Some(0.0); + }; + // Bullish: a decline gaps lower, runs two more bars down, then a green + // bar5 closes back inside the bar1/bar2 body gap. + if bar1.close < bar1.open + && bar2.close < bar2.open + && bar2.open < bar1.close + && bar3.high < bar2.high + && bar3.low < bar2.low + && bar4.close < bar4.open + && bar4.high < bar3.high + && bar4.low < bar3.low + && candle.close > candle.open + && candle.close > bar2.open + && candle.close < bar1.close + { + return Some(1.0); + } + // Bearish: the mirror — an advance gaps higher, runs two more bars up, + // then a red bar5 closes back inside the bar1/bar2 body gap. + if bar1.close > bar1.open + && bar2.close > bar2.open + && bar2.open > bar1.close + && bar3.high > bar2.high + && bar3.low > bar2.low + && bar4.close > bar4.open + && bar4.high > bar3.high + && bar4.low > bar3.low + && candle.close < candle.open + && candle.close < bar2.open + && candle.close > bar1.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.c4 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Breakaway" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = Breakaway::new(); + assert_eq!(t.name(), "Breakaway"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_breakaway_is_plus_one() { + let mut t = Breakaway::new(); + assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 11.9, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(12.5, 13.0, 10.5, 11.0, 2)), Some(0.0)); + assert_eq!(t.update(c(11.0, 11.5, 9.0, 9.5, 3)), Some(0.0)); + assert_eq!(t.update(c(9.5, 14.7, 9.4, 14.5, 4)), Some(1.0)); + } + + #[test] + fn bearish_breakaway_is_minus_one() { + let mut t = Breakaway::new(); + assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), Some(0.0)); + assert_eq!(t.update(c(21.0, 23.1, 20.9, 23.0, 1)), Some(0.0)); + assert_eq!(t.update(c(22.5, 24.5, 21.5, 24.0, 2)), Some(0.0)); + assert_eq!(t.update(c(24.0, 26.5, 23.0, 26.0, 3)), Some(0.0)); + assert_eq!(t.update(c(27.0, 27.2, 20.4, 20.5, 4)), Some(-1.0)); + } + + #[test] + fn no_body_gap_yields_zero() { + let mut t = Breakaway::new(); + // bar2 does not gap below bar1's body (bar2.open >= bar1.close). + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + t.update(c(16.0, 16.1, 13.9, 14.0, 1)); + t.update(c(13.5, 14.0, 11.5, 12.0, 2)); + t.update(c(12.0, 12.5, 10.0, 10.5, 3)); + assert_eq!(t.update(c(10.5, 15.7, 10.4, 15.5, 4)), Some(0.0)); + } + + #[test] + fn bullish_close_outside_gap_yields_zero() { + let mut t = Breakaway::new(); + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + t.update(c(14.0, 14.1, 11.9, 12.0, 1)); + t.update(c(12.5, 13.0, 10.5, 11.0, 2)); + t.update(c(11.0, 11.5, 9.0, 9.5, 3)); + // bar5 closes at 13.0 — below bar2.open (14), so outside the body gap. + assert_eq!(t.update(c(9.5, 13.2, 9.4, 13.0, 4)), Some(0.0)); + } + + #[test] + fn first_four_bars_return_zero() { + let mut t = Breakaway::new(); + assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 11.9, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(12.5, 13.0, 10.5, 11.0, 2)), Some(0.0)); + assert_eq!(t.update(c(11.0, 11.5, 9.0, 9.5, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 0.5, base + 1.5, i) + }) + .collect(); + let mut a = Breakaway::new(); + let mut b = Breakaway::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Breakaway::new(); + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + t.update(c(14.0, 14.1, 11.9, 12.0, 1)); + t.update(c(12.5, 13.0, 10.5, 11.0, 2)); + t.update(c(11.0, 11.5, 9.0, 9.5, 3)); + t.update(c(9.5, 14.7, 9.4, 14.5, 4)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/bullish_percent_index.rs b/crates/wickra-core/src/indicators/bullish_percent_index.rs new file mode 100644 index 0000000..5eb5049 --- /dev/null +++ b/crates/wickra-core/src/indicators/bullish_percent_index.rs @@ -0,0 +1,147 @@ +//! Bullish Percent Index — share of a universe on a point-and-figure buy signal. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are +/// currently on a point-and-figure buy signal. +/// +/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count / +/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller +/// evaluates each symbol's point-and-figure chart when it builds the tick). It is +/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend. +/// Readings above 70 are considered overbought (broad strength, but a crowded +/// market) and below 30 oversold; reversals from those zones are classic BPI +/// buy/sell triggers. +/// +/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`), +/// `warmup_period == 1`. The universe is non-empty by construction, so the share +/// is always defined. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member}; +/// +/// let mut bpi = BullishPercentIndex::new(); +/// // 2 of 4 symbols on a buy signal -> 50%. +/// let tick = CrossSection::new( +/// vec![ +/// Member::with_signals(1.0, 10.0, false, false, false, true), +/// Member::with_signals(1.0, 10.0, false, false, false, true), +/// Member::with_signals(-1.0, 10.0, false, false, false, false), +/// Member::with_signals(-1.0, 10.0, false, false, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(bpi.update(tick), Some(50.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct BullishPercentIndex { + has_emitted: bool, +} + +impl BullishPercentIndex { + /// Construct a new Bullish Percent Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for BullishPercentIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let bullish = section.on_buy_signal_count() as f64; + let total = section.members.len() as f64; + self.has_emitted = true; + Some(100.0 * bullish / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "BullishPercentIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(bullish: usize, bearish: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..bullish { + members.push(Member::with_signals(1.0, 10.0, false, false, false, true)); + } + for _ in 0..bearish { + members.push(Member::with_signals(-1.0, 10.0, false, false, false, false)); + } + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let bpi = BullishPercentIndex::new(); + assert_eq!(bpi.name(), "BullishPercentIndex"); + assert_eq!(bpi.warmup_period(), 1); + assert!(!bpi.is_ready()); + } + + #[test] + fn first_tick_emits_percentage() { + let mut bpi = BullishPercentIndex::new(); + assert_eq!(bpi.update(tick(2, 2)), Some(50.0)); + assert!(bpi.is_ready()); + } + + #[test] + fn all_bullish_is_one_hundred() { + let mut bpi = BullishPercentIndex::new(); + assert_eq!(bpi.update(tick(5, 0)), Some(100.0)); + } + + #[test] + fn none_bullish_is_zero() { + let mut bpi = BullishPercentIndex::new(); + assert_eq!(bpi.update(tick(0, 4)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut bpi = BullishPercentIndex::new(); + bpi.update(tick(2, 2)); + assert!(bpi.is_ready()); + bpi.reset(); + assert!(!bpi.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)]; + let mut a = BullishPercentIndex::new(); + let mut b = BullishPercentIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} 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 0000000..5663187 --- /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/butterfly.rs b/crates/wickra-core/src/indicators/butterfly.rs new file mode 100644 index 0000000..7718229 --- /dev/null +++ b/crates/wickra-core/src/indicators/butterfly.rs @@ -0,0 +1,154 @@ +//! Butterfly harmonic pattern. + +use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Butterfly — a 5-point (X-A-B-C-D) harmonic pattern with a `0.786` B and an +/// **extended** D that overshoots X: +/// +/// ```text +/// AB / XA ∈ [0.74, 0.84] (≈ 0.786) +/// BC / AB ∈ [0.382, 0.886] +/// CD / BC ∈ [1.618, 2.618] +/// AD / XA ∈ [1.27, 1.618] (the defining extended D completion) +/// ``` +/// +/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high), +/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/butterfly.rs`. +#[derive(Debug, Clone)] +pub struct Butterfly { + swing: SwingTracker, + has_emitted: bool, +} + +impl Butterfly { + /// Construct a new Butterfly detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for Butterfly { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Butterfly { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let cd = (p.d - p.c).abs(); + let ad = (p.d - p.a).abs(); + let matched = ratios_in(&[ + (ab / xa, 0.74, 0.84), + (bc / ab, 0.382, 0.886), + (cd / bc, 1.618, 2.618), + (ad / xa, 1.27, 1.618), + ]); + if matched { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Butterfly" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Butterfly::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Butterfly::new(); + assert_eq!(indicator.name(), "Butterfly"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!Butterfly::default().is_ready()); + } + + #[test] + fn bullish_butterfly_is_plus_one() { + let out = run(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_butterfly_is_minus_one() { + let out = run(&[150.0, 110.0, 141.4, 121.4, 170.2]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn out_of_ratio_does_not_trigger() { + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Butterfly::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]); + let mut a = Butterfly::new(); + let mut b = Butterfly::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/calendar_spread.rs b/crates/wickra-core/src/indicators/calendar_spread.rs new file mode 100644 index 0000000..4da8b57 --- /dev/null +++ b/crates/wickra-core/src/indicators/calendar_spread.rs @@ -0,0 +1,141 @@ +//! Calendar Spread — the dated future's relative premium to the perpetual. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Calendar Spread — the relative spread between a dated (e.g. quarterly) +/// futures price and the perpetual mark price. +/// +/// ```text +/// spread = (futuresPrice − markPrice) / markPrice +/// ``` +/// +/// A calendar (or inter-delivery) spread trades the *near* leg against the +/// *far* leg — here the perpetual against a dated future. The relative spread is +/// the roll yield available between the two contracts: positive when the future +/// trades over the perpetual (contango roll), negative when under +/// (backwardation). Where [`TermStructureBasis`] measures the future against +/// spot, this measures it against the perpetual — the leg a perp-vs-future +/// basis trade actually holds. The output is a fraction; multiply by `10_000` +/// for basis points. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [`TermStructureBasis`]: crate::TermStructureBasis +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CalendarSpread, DerivativesTick, Indicator}; +/// +/// fn tick(futures: f64, mark: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut cs = CalendarSpread::new(); +/// // futures 101 vs perpetual mark 100 -> 0.01. +/// assert!((cs.update(tick(101.0, 100.0)).unwrap() - 0.01).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CalendarSpread { + has_emitted: bool, +} + +impl CalendarSpread { + /// Construct a new calendar-spread indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for CalendarSpread { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some((tick.futures_price - tick.mark_price) / tick.mark_price) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CalendarSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(futures: f64, mark: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, mark, mark, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let cs = CalendarSpread::new(); + assert_eq!(cs.name(), "CalendarSpread"); + assert_eq!(cs.warmup_period(), 1); + assert!(!cs.is_ready()); + } + + #[test] + fn future_over_perp_is_positive() { + let mut cs = CalendarSpread::new(); + let out = cs.update(tick(101.0, 100.0)).unwrap(); + assert!((out - 0.01).abs() < 1e-12); + assert!(cs.is_ready()); + } + + #[test] + fn future_under_perp_is_negative() { + let mut cs = CalendarSpread::new(); + let out = cs.update(tick(99.0, 100.0)).unwrap(); + assert!((out + 0.01).abs() < 1e-12); + } + + #[test] + fn flat_is_zero() { + let mut cs = CalendarSpread::new(); + assert_eq!(cs.update(tick(100.0, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(100.0 + f64::from(i % 5), 100.0)) + .collect(); + let mut a = CalendarSpread::new(); + let mut b = CalendarSpread::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut cs = CalendarSpread::new(); + cs.update(tick(101.0, 100.0)); + assert!(cs.is_ready()); + cs.reset(); + assert!(!cs.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/calmar_ratio.rs b/crates/wickra-core/src/indicators/calmar_ratio.rs new file mode 100644 index 0000000..531244a --- /dev/null +++ b/crates/wickra-core/src/indicators/calmar_ratio.rs @@ -0,0 +1,202 @@ +//! Rolling Calmar Ratio — return over max drawdown. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Calmar Ratio. +/// +/// Input is treated as a single period return. Over the trailing window of +/// `period` returns the indicator reconstructs the implied equity curve +/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and +/// divides the mean return by that drawdown: +/// +/// ```text +/// equity_t = ∏(1 + r_i) for i in window up to t +/// mdd = max peak-to-trough decline of equity over window +/// Calmar = mean(returns) / mdd +/// ``` +/// +/// If the drawdown is zero (monotonically non-decreasing equity in the +/// window) the indicator returns `0.0` rather than `NaN` / `Inf`. +/// +/// The equity curve is recomputed inside the window each `update`, which +/// keeps each call O(period) — acceptable for typical backtest windows +/// (`period ≤ 252`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CalmarRatio, Indicator}; +/// +/// let mut cr = CalmarRatio::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CalmarRatio { + period: usize, + window: VecDeque, + sum: f64, +} + +impl CalmarRatio { + /// Construct a new rolling Calmar Ratio. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "calmar ratio needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for CalmarRatio { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + } + self.window.push_back(input); + self.sum += input; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Build equity curve and track the worst peak-to-trough drawdown. + let mut equity = 1.0_f64; + let mut peak = 1.0_f64; + let mut mdd = 0.0_f64; + for &r in &self.window { + equity *= 1.0 + r; + if equity > peak { + peak = equity; + } + // peak starts at 1.0 and never decreases, so peak > 0 by construction. + let dd = (peak - equity) / peak; + if dd > mdd { + mdd = dd; + } + } + if mdd == 0.0 { + return Some(0.0); + } + Some(mean / mdd) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "CalmarRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_less_than_two() { + assert!(matches!( + CalmarRatio::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let c = CalmarRatio::new(10).unwrap(); + assert_eq!(c.period(), 10); + assert_eq!(c.name(), "CalmarRatio"); + assert_eq!(c.warmup_period(), 10); + } + + #[test] + fn pure_uptrend_yields_zero() { + // All positive returns -> no drawdown -> Calmar = 0 by convention. + let mut c = CalmarRatio::new(5).unwrap(); + let out = c.batch(&[0.01; 10]); + for v in out.into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn reference_value() { + // returns = [0.10, -0.20, 0.05] + // equity: 1.0 -> 1.10 -> 0.88 -> 0.924 + // peak 1.10, trough 0.88 -> mdd = 0.20. + // mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666... + // Calmar = -0.01666... / 0.20 ≈ -0.08333... + let mut c = CalmarRatio::new(3).unwrap(); + let out = c.batch(&[0.10, -0.20, 0.05]); + let mean = (0.10 - 0.20 + 0.05) / 3.0; + let expected = mean / 0.20; + assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut c = CalmarRatio::new(3).unwrap(); + assert_eq!(c.update(f64::NAN), None); + assert_eq!(c.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut c = CalmarRatio::new(3).unwrap(); + c.batch(&[0.10, -0.20, 0.05]); + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + assert_eq!(c.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..50) + .map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02) + .collect(); + let batch = CalmarRatio::new(10).unwrap().batch(&returns); + let mut s = CalmarRatio::new(10).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/camarilla_pivots.rs b/crates/wickra-core/src/indicators/camarilla_pivots.rs new file mode 100644 index 0000000..7195dab --- /dev/null +++ b/crates/wickra-core/src/indicators/camarilla_pivots.rs @@ -0,0 +1,197 @@ +//! Camarilla Pivot Points (Nick Stott). + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Camarilla Pivot Points output: four resistances, the pivot, four supports. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CamarillaPivotsOutput { + /// Pivot Point: `(H + L + C) / 3` (informational, not in the Camarilla R/S formulas). + pub pp: f64, + /// Resistance 1: `C + (H − L)·1.1/12`. + pub r1: f64, + /// Resistance 2: `C + (H − L)·1.1/6`. + pub r2: f64, + /// Resistance 3: `C + (H − L)·1.1/4`. + pub r3: f64, + /// Resistance 4: `C + (H − L)·1.1/2`. + pub r4: f64, + /// Support 1: `C − (H − L)·1.1/12`. + pub s1: f64, + /// Support 2: `C − (H − L)·1.1/6`. + pub s2: f64, + /// Support 3: `C − (H − L)·1.1/4`. + pub s3: f64, + /// Support 4: `C − (H − L)·1.1/2`. + pub s4: f64, +} + +/// Camarilla Pivot Points — Nick Stott's four-tier range-based level set. +/// Anchored on the prior close rather than the typical price, with widths +/// scaled by the constant `1.1` divided by `{12, 6, 4, 2}`. +/// +/// ```text +/// PP = (H + L + C) / 3 +/// R_n = C + (H − L) · 1.1 / d_n S_n = C − (H − L) · 1.1 / d_n +/// where d_1 = 12, d_2 = 6, d_3 = 4, d_4 = 2 +/// ``` +/// +/// R3/S3 are typically used as reversal levels; R4/S4 as breakout levels. As +/// with the other pivot variants there are no parameters and no warmup — the +/// first candle produces the first set of levels. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Camarilla, Candle, Indicator}; +/// +/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap(); +/// let levels = Camarilla::new().update(prev).unwrap(); +/// assert!(levels.r4 > levels.r3); +/// assert!(levels.s4 < levels.s3); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Camarilla { + ready: bool, +} + +impl Camarilla { + /// Construct a new Camarilla Pivot Points indicator. + pub const fn new() -> Self { + Self { ready: false } + } +} + +const CAM: f64 = 1.1; + +impl Indicator for Camarilla { + type Input = Candle; + type Output = CamarillaPivotsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let (h, l, c) = (candle.high, candle.low, candle.close); + let range = h - l; + let pp = (h + l + c) / 3.0; + let w1 = range * CAM / 12.0; + let w2 = range * CAM / 6.0; + let w3 = range * CAM / 4.0; + let w4 = range * CAM / 2.0; + let out = CamarillaPivotsOutput { + pp, + r1: c + w1, + r2: c + w2, + r3: c + w3, + r4: c + w4, + s1: c - w1, + s2: c - w2, + s3: c - w3, + s4: c - w4, + }; + self.ready = true; + Some(out) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "Camarilla" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, h, l, close, 1.0, ts).unwrap() + } + + #[test] + fn formula_reference_values() { + // H=110, L=90, C=105, range=20. + let levels = Camarilla::new().update(c(110.0, 90.0, 105.0, 0)).unwrap(); + let range = 20.0; + assert!((levels.r1 - (105.0 + range * 1.1 / 12.0)).abs() < 1e-12); + assert!((levels.r2 - (105.0 + range * 1.1 / 6.0)).abs() < 1e-12); + assert!((levels.r3 - (105.0 + range * 1.1 / 4.0)).abs() < 1e-12); + assert!((levels.r4 - (105.0 + range * 1.1 / 2.0)).abs() < 1e-12); + assert!((levels.s1 - (105.0 - range * 1.1 / 12.0)).abs() < 1e-12); + assert!((levels.s4 - (105.0 - range * 1.1 / 2.0)).abs() < 1e-12); + } + + #[test] + fn resistance_strictly_widens_with_index() { + let levels = Camarilla::new().update(c(120.0, 80.0, 110.0, 0)).unwrap(); + assert!(levels.r4 > levels.r3); + assert!(levels.r3 > levels.r2); + assert!(levels.r2 > levels.r1); + assert!(levels.r1 > 110.0); + assert!(levels.s1 < 110.0); + assert!(levels.s2 < levels.s1); + assert!(levels.s3 < levels.s2); + assert!(levels.s4 < levels.s3); + } + + #[test] + fn constant_series_collapses_levels() { + let levels = Camarilla::new().update(c(50.0, 50.0, 50.0, 0)).unwrap(); + assert_eq!(levels.r4, 50.0); + assert_eq!(levels.s4, 50.0); + assert_eq!(levels.pp, 50.0); + } + + #[test] + fn warmup_and_ready() { + let mut p = Camarilla::new(); + assert!(!p.is_ready()); + assert_eq!(p.warmup_period(), 1); + p.update(c(11.0, 9.0, 10.0, 0)); + assert!(p.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut p = Camarilla::new(); + p.update(c(11.0, 9.0, 10.0, 0)); + p.reset(); + assert!(!p.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0_i32..40) + .map(|i| { + c( + f64::from(i) + 2.0, + f64::from(i), + f64::from(i) + 1.0, + i.into(), + ) + }) + .collect(); + let mut a = Camarilla::new(); + let mut b = Camarilla::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = Camarilla::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "Camarilla"); + } +} diff --git a/crates/wickra-core/src/indicators/candle_volume.rs b/crates/wickra-core/src/indicators/candle_volume.rs new file mode 100644 index 0000000..8fd9271 --- /dev/null +++ b/crates/wickra-core/src/indicators/candle_volume.rs @@ -0,0 +1,231 @@ +#![allow(clippy::doc_markdown)] +//! CandleVolume — candlestick body with a volume-scaled width. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`CandleVolume`]: the signed candle body and its volume-relative width. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CandleVolumeOutput { + /// Signed body `close − open` (positive = bullish candle). + pub body: f64, + /// Box width — volume relative to its `period` average (`1.0` = average). + pub width: f64, +} + +/// CandleVolume — the candlestick analogue of [`Equivolume`](crate::Equivolume): +/// each bar's **body** (`close − open`) paired with a **width** proportional to its +/// volume relative to the recent average. +/// +/// ```text +/// body = close − open (signed; + bullish, − bearish) +/// width = volume / SMA(volume, period) (1.0 = average volume) +/// ``` +/// +/// Where Equivolume uses the high-low *range* for the box height, CandleVolume uses +/// the candlestick *body*, preserving direction: a wide bullish body (long up +/// candle on heavy volume) is strong demand, a wide bearish body strong supply, and +/// a narrow body on heavy volume (wide but short) is churn. The signed body plus +/// the normalised width capture both the move's direction and the participation +/// behind it. +/// +/// The first value lands after `period` inputs (to seed the volume average). Each +/// `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, CandleVolume}; +/// +/// let mut indicator = CandleVolume::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0 + f64::from(i), 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CandleVolume { + period: usize, + vol_sma: Sma, + last: Option, +} + +impl CandleVolume { + /// Construct a CandleVolume with the given volume-averaging `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + vol_sma: Sma::new(period)?, + last: None, + }) + } + + /// Configured volume-averaging period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for CandleVolume { + type Input = Candle; + type Output = CandleVolumeOutput; + + fn update(&mut self, candle: Candle) -> Option { + let avg_vol = self.vol_sma.update(candle.volume)?; + let body = candle.close - candle.open; + let width = if avg_vol > 0.0 { + candle.volume / avg_vol + } else { + 0.0 + }; + let out = CandleVolumeOutput { body, width }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.vol_sma.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "CandleVolume" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, close: f64, volume: f64) -> Candle { + let high = open.max(close) + 1.0; + let low = open.min(close) - 1.0; + Candle::new_unchecked(open, high, low, close, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(CandleVolume::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let cv = CandleVolume::new(14).unwrap(); + assert_eq!(cv.period(), 14); + assert_eq!(cv.warmup_period(), 14); + assert_eq!(cv.name(), "CandleVolume"); + assert!(!cv.is_ready()); + assert_eq!(cv.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut cv = CandleVolume::new(3).unwrap(); + let candles: Vec = (0..6).map(|_| c(100.0, 101.0, 1_000.0)).collect(); + let out = cv.batch(&candles); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn bullish_body_positive() { + let mut cv = CandleVolume::new(2).unwrap(); + let out = cv + .batch(&[c(100.0, 103.0, 1_000.0), c(100.0, 103.0, 1_000.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.body, 3.0, epsilon = 1e-9); + } + + #[test] + fn bearish_body_negative() { + let mut cv = CandleVolume::new(2).unwrap(); + let out = cv + .batch(&[c(103.0, 100.0, 1_000.0), c(103.0, 100.0, 1_000.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.body, -3.0, epsilon = 1e-9); + } + + #[test] + fn heavy_bar_is_wide() { + let mut cv = CandleVolume::new(3).unwrap(); + let candles = [ + c(100.0, 101.0, 1_000.0), + c(100.0, 101.0, 1_000.0), + c(100.0, 101.0, 4_000.0), + ]; + let out = cv.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(out.width > 1.0); + } + + #[test] + fn reset_clears_state() { + let mut cv = CandleVolume::new(3).unwrap(); + cv.batch(&[c(100.0, 101.0, 1_000.0); 6]); + assert!(cv.is_ready()); + cv.reset(); + assert!(!cv.is_ready()); + assert_eq!(cv.value(), None); + assert_eq!(cv.update(c(100.0, 101.0, 1_000.0)), None); + } + + #[test] + fn zero_volume_gives_zero_width() { + let mut cv = CandleVolume::new(2).unwrap(); + let out = cv + .batch(&[c(10.0, 11.0, 0.0), c(11.0, 12.0, 0.0), c(12.0, 13.0, 0.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(out.width, 0.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.25).sin() * 5.0; + c(b, b + 0.5, 1_000.0 + f64::from(i)) + }) + .collect(); + let batch = CandleVolume::new(14).unwrap().batch(&candles); + let mut b = CandleVolume::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/cci.rs b/crates/wickra-core/src/indicators/cci.rs new file mode 100644 index 0000000..05a843c --- /dev/null +++ b/crates/wickra-core/src/indicators/cci.rs @@ -0,0 +1,177 @@ +//! Commodity Channel Index (CCI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Commodity Channel Index. +/// +/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where +/// `TP = (high + low + close) / 3`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Cci}; +/// +/// let mut indicator = Cci::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Cci { + period: usize, + factor: f64, + window: VecDeque, + sum: f64, +} + +impl Cci { + /// Construct a new CCI with the canonical 0.015 scaling factor. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Self::with_factor(period, 0.015) + } + + /// Construct a CCI with a custom scaling factor (the standard literature + /// uses 0.015 to put roughly 70 % of values inside ±100). + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `factor <= 0`. + pub fn with_factor(period: usize, factor: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !factor.is_finite() || factor <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + factor, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Cci { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tp = candle.typical_price(); + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + } + self.window.push_back(tp); + self.sum += tp; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let mad: f64 = self.window.iter().map(|v| (v - mean).abs()).sum::() / n; + if mad == 0.0 { + return Some(0.0); + } + Some((tp - mean) / (self.factor * mad)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "CCI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn flat_candles_yield_zero() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut cci = Cci::new(20).unwrap(); + for v in cci.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn rejects_invalid_input() { + assert!(Cci::new(0).is_err()); + assert!(Cci::with_factor(20, 0.0).is_err()); + assert!(Cci::with_factor(20, -1.0).is_err()); + } + + /// Cover the const accessor `period` (68-70) and the Indicator-impl + /// `warmup_period` (102-104) + `name` (110-112). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let cci = Cci::new(20).unwrap(); + assert_eq!(cci.period(), 20); + assert_eq!(cci.warmup_period(), 20); + assert_eq!(cci.name(), "CCI"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.2).sin() * 10.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = Cci::new(20).unwrap(); + let mut b = Cci::new(20).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut cci = Cci::new(20).unwrap(); + cci.batch(&candles); + assert!(cci.is_ready()); + cci.reset(); + assert!(!cci.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/center_of_gravity.rs b/crates/wickra-core/src/indicators/center_of_gravity.rs new file mode 100644 index 0000000..33316ba --- /dev/null +++ b/crates/wickra-core/src/indicators/center_of_gravity.rs @@ -0,0 +1,204 @@ +//! Ehlers Center of Gravity Oscillator. +#![allow(clippy::manual_midpoint)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Center of Gravity (CG) oscillator. +/// +/// Treats the most recent `period` prices as masses and reports the +/// weighted "center" of that mass distribution, negated so positive readings +/// correspond to recent strength: +/// +/// ```text +/// num = sum_{k=0..period-1} (1 + k) * price[t - k] +/// den = sum_{k=0..period-1} price[t - k] +/// cg = - num / den + (period + 1) / 2 +/// ``` +/// +/// The constant offset centres the oscillator around zero. From Ehlers, +/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, CenterOfGravity}; +/// +/// let mut cg = CenterOfGravity::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CenterOfGravity { + period: usize, + window: VecDeque, + last_value: Option, +} + +impl CenterOfGravity { + /// Construct with the rolling window length. + /// + /// # 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), + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for CenterOfGravity { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + // Most recent has weight 1; oldest has weight `period`. + let mut num = 0.0; + let mut den = 0.0; + for (k, p) in self.window.iter().rev().enumerate() { + let w = 1.0 + k as f64; + num += w * p; + den += p; + } + let v = if den.abs() > f64::EPSILON { + -num / den + (self.period as f64 + 1.0) / 2.0 + } else { + 0.0 + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.window.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "CenterOfGravity" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut cg = CenterOfGravity::new(10).unwrap(); + assert_eq!(cg.period(), 10); + assert_eq!(cg.warmup_period(), 10); + assert_eq!(cg.name(), "CenterOfGravity"); + assert!(!cg.is_ready()); + for i in 1..=10 { + cg.update(f64::from(i)); + } + assert!(cg.is_ready()); + assert!(cg.value().is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // num = sum k * p, den = period * p, ratio = (period + 1) / 2, + // so cg = - (period+1)/2 + (period+1)/2 = 0. + let mut cg = CenterOfGravity::new(5).unwrap(); + let out = cg.batch(&[7.0_f64; 30]); + for x in out.iter().skip(5).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(f64::from).collect(); + let mut a = CenterOfGravity::new(10).unwrap(); + let mut b = CenterOfGravity::new(10).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut cg = CenterOfGravity::new(5).unwrap(); + cg.batch(&(1..=10).map(f64::from).collect::>()); + let before = cg.value(); + assert!(before.is_some()); + assert_eq!(cg.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut cg = CenterOfGravity::new(5).unwrap(); + cg.batch(&(1..=10).map(f64::from).collect::>()); + assert!(cg.is_ready()); + cg.reset(); + assert!(!cg.is_ready()); + } + + #[test] + fn warmup_returns_none_until_seed() { + let mut cg = CenterOfGravity::new(4).unwrap(); + assert_eq!(cg.update(1.0), None); + assert_eq!(cg.update(2.0), None); + assert_eq!(cg.update(3.0), None); + assert!(cg.update(4.0).is_some()); + } + + #[test] + fn zero_window_uses_zero_fallback() { + // den == sum(prices) == 0 when the rolling window is all zeros, which + // exercises the protective fallback in the divisor guard. + let mut cg = CenterOfGravity::new(5).unwrap(); + let out = cg.batch(&[0.0_f64; 10]); + for x in out.iter().skip(5).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-12); + } + } +} diff --git a/crates/wickra-core/src/indicators/central_pivot_range.rs b/crates/wickra-core/src/indicators/central_pivot_range.rs new file mode 100644 index 0000000..97aee8f --- /dev/null +++ b/crates/wickra-core/src/indicators/central_pivot_range.rs @@ -0,0 +1,171 @@ +//! Central Pivot Range (CPR) — the pivot plus its two central levels. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`CentralPivotRange`]: the pivot and the two central lines that +/// bracket it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CentralPivotRangeOutput { + /// Pivot point `(high + low + close) / 3`. + pub pivot: f64, + /// Top central line — the higher of the two central levels. + pub tc: f64, + /// Bottom central line — the lower of the two central levels. + pub bc: f64, +} + +/// Central Pivot Range (CPR) — the classic pivot point flanked by two "central" +/// levels whose separation gauges the day's expected character. +/// +/// ```text +/// pivot = (high + low + close) / 3 +/// bc' = (high + low) / 2 +/// tc' = 2·pivot − bc' +/// TC = max(tc', bc'), BC = min(tc', bc') +/// ``` +/// +/// The CPR is computed from the **previous** period's bar (feed it completed +/// daily/weekly bars). The width of the range `TC − BC` is the headline read: a +/// **narrow** CPR signals a likely trending day (price has little balance area to +/// chew through), while a **wide** CPR signals a likely range-bound, balanced +/// day. Price opening above the whole range is bullish, below it bearish, inside +/// it neutral. The `tc'`/`bc'` formulas are symmetric about the pivot; this +/// implementation labels the larger as `TC` and the smaller as `BC` so `TC >= BC` +/// always holds. +/// +/// There are no parameters and no warmup — each completed bar yields one CPR. +/// Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, CentralPivotRange}; +/// +/// let mut indicator = CentralPivotRange::new(); +/// let prev_day = Candle::new(101.0, 110.0, 90.0, 105.0, 1_000.0, 0).unwrap(); +/// let cpr = indicator.update(prev_day).unwrap(); +/// assert!(cpr.tc >= cpr.bc); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CentralPivotRange { + ready: bool, +} + +impl CentralPivotRange { + /// Construct a new Central Pivot Range. The indicator is parameter-free. + #[must_use] + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for CentralPivotRange { + type Input = Candle; + type Output = CentralPivotRangeOutput; + + fn update(&mut self, candle: Candle) -> Option { + let pivot = (candle.high + candle.low + candle.close) / 3.0; + let bc_raw = f64::midpoint(candle.high, candle.low); + let tc_raw = 2.0 * pivot - bc_raw; + let tc = tc_raw.max(bc_raw); + let bc = tc_raw.min(bc_raw); + self.ready = true; + Some(CentralPivotRangeOutput { pivot, tc, bc }) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "CentralPivotRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(close, high, low, close, 1_000.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let cpr = CentralPivotRange::new(); + assert_eq!(cpr.warmup_period(), 1); + assert_eq!(cpr.name(), "CentralPivotRange"); + assert!(!cpr.is_ready()); + } + + #[test] + fn formula_reference_values() { + // H=110, L=90, C=105 -> pivot = 305/3; bc' = 100; tc' = 2*pivot - 100. + let out = CentralPivotRange::new() + .update(c(110.0, 90.0, 105.0)) + .unwrap(); + let pivot = 305.0 / 3.0; + let bc_raw = 100.0; + let tc_raw = 2.0 * pivot - bc_raw; + assert!((out.pivot - pivot).abs() < 1e-12); + assert!((out.tc - tc_raw.max(bc_raw)).abs() < 1e-12); + assert!((out.bc - tc_raw.min(bc_raw)).abs() < 1e-12); + } + + #[test] + fn tc_never_below_bc() { + let out = CentralPivotRange::new() + .update(c(200.0, 100.0, 150.0)) + .unwrap(); + assert!(out.tc >= out.bc); + } + + #[test] + fn constant_bar_collapses_range() { + // H = L = C -> pivot = bc' = tc' = the price; range collapses. + let out = CentralPivotRange::new() + .update(c(50.0, 50.0, 50.0)) + .unwrap(); + assert_eq!(out.pivot, 50.0); + assert_eq!(out.tc, 50.0); + assert_eq!(out.bc, 50.0); + } + + #[test] + fn ready_after_first_update() { + let mut cpr = CentralPivotRange::new(); + assert!(!cpr.is_ready()); + cpr.update(c(11.0, 9.0, 10.0)); + assert!(cpr.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut cpr = CentralPivotRange::new(); + cpr.update(c(11.0, 9.0, 10.0)); + assert!(cpr.is_ready()); + cpr.reset(); + assert!(!cpr.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let batch = CentralPivotRange::new().batch(&candles); + let mut b = CentralPivotRange::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/cfo.rs b/crates/wickra-core/src/indicators/cfo.rs new file mode 100644 index 0000000..67b416a --- /dev/null +++ b/crates/wickra-core/src/indicators/cfo.rs @@ -0,0 +1,176 @@ +//! Chande Forecast Oscillator (CFO). + +use crate::error::{Error, Result}; +use crate::indicators::linreg::LinearRegression; +use crate::traits::Indicator; + +/// Tushar Chande's Forecast Oscillator — the percentage difference between +/// the close and the endpoint of an `n`-bar linear-regression forecast of the +/// close. +/// +/// ```text +/// CFO_t = 100 · (close_t − LinearRegression(close, period)_t) / close_t +/// ``` +/// +/// Positive readings mean the close is *above* the linear forecast (price has +/// overshot trend); negative readings mean it sits below. Wraps the existing +/// `LinearRegression` so the warmup matches. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Cfo, Indicator}; +/// +/// let mut cfo = Cfo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = cfo.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Cfo { + period: usize, + linreg: LinearRegression, + current: Option, +} + +impl Cfo { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + linreg: LinearRegression::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Cfo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + let forecast = self.linreg.update(input)?; + // Hold the previous value if the close is zero — the percentage form + // is undefined and a return of inf would propagate badly. + if input == 0.0 { + return self.current; + } + let value = 100.0 * (input - forecast) / input; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.linreg.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "CFO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Cfo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let cfo = Cfo::new(14).unwrap(); + assert_eq!(cfo.period(), 14); + assert_eq!(cfo.warmup_period(), 14); + assert_eq!(cfo.name(), "CFO"); + } + + #[test] + fn constant_series_yields_zero() { + // LinReg of a constant series equals the constant, so close − forecast + // is 0 and CFO is 0. + let mut cfo = Cfo::new(5).unwrap(); + let out = cfo.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn perfect_linear_series_yields_zero() { + // LinReg of a perfectly linear input fits the line exactly, so the + // close lands on the forecast and CFO = 0. + let mut cfo = Cfo::new(5).unwrap(); + let prices: Vec = (1..=20).map(|i| f64::from(i) * 2.0).collect(); + let out = cfo.batch(&prices); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut cfo = Cfo::new(3).unwrap(); + for i in 1..=2 { + assert_eq!(cfo.update(f64::from(i)), None); + } + assert!(cfo.update(3.0).is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = Cfo::new(14).unwrap(); + let mut b = Cfo::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut cfo = Cfo::new(5).unwrap(); + cfo.batch(&(1..=20).map(f64::from).collect::>()); + assert!(cfo.is_ready()); + cfo.reset(); + assert!(!cfo.is_ready()); + assert_eq!(cfo.update(1.0), None); + } + + #[test] + fn zero_close_holds_value() { + let mut cfo = Cfo::new(3).unwrap(); + cfo.batch(&[1.0_f64, 2.0, 3.0]); + let before = cfo.current; + assert_eq!(cfo.update(0.0), before); + } +} diff --git a/crates/wickra-core/src/indicators/chaikin_oscillator.rs b/crates/wickra-core/src/indicators/chaikin_oscillator.rs new file mode 100644 index 0000000..3ea663a --- /dev/null +++ b/crates/wickra-core/src/indicators/chaikin_oscillator.rs @@ -0,0 +1,242 @@ +//! Chaikin Oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::adl::Adl; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line. +/// +/// ```text +/// ChaikinOsc_t = EMA(ADL, fast)_t − EMA(ADL, slow)_t +/// ``` +/// +/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a +/// zero-centred momentum oscillator: positive when short-term accumulation +/// outpaces the longer trend, negative when distribution leads. Because the +/// ADL emits from the very first candle, the slow EMA gates the first output — +/// the warmup period is exactly `slow`. Chaikin's classic configuration is +/// `fast = 3`, `slow = 10`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChaikinOscillator}; +/// +/// let mut indicator = ChaikinOscillator::classic(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChaikinOscillator { + adl: Adl, + fast: Ema, + slow: Ema, + fast_period: usize, + slow_period: usize, +} + +impl ChaikinOscillator { + /// Construct a Chaikin Oscillator with explicit fast / slow EMA periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "Chaikin Oscillator needs fast < slow", + }); + } + Ok(Self { + adl: Adl::new(), + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + fast_period: fast, + slow_period: slow, + }) + } + + /// Chaikin's classic configuration: `EMA(ADL, 3) − EMA(ADL, 10)`. + pub fn classic() -> Self { + Self::new(3, 10).expect("classic Chaikin Oscillator params are valid") + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for ChaikinOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // The ADL emits a value from the very first candle, so both EMAs are + // fed on every bar and warm up in parallel. + let adl = self.adl.update(candle)?; + let fast = self.fast.update(adl); + let slow = self.slow.update(adl); + Some(fast? - slow?) + } + + fn reset(&mut self) { + self.adl.reset(); + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + // ADL is ready at candle 1; the slow EMA gates the first emission. + self.slow_period + } + + fn is_ready(&self) -> bool { + self.fast.is_ready() && self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "ChaikinOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn cdl(base: f64, volume: f64, ts: i64) -> Candle { + Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap() + } + + fn flat(price: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, 100.0, ts).unwrap() + } + + #[test] + fn matches_independent_adl_and_emas() { + // The oscillator must equal feeding a standalone ADL into two + // standalone EMAs and differencing them once both are ready. + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0; + Candle::new( + mid, + mid + 1.5, + mid - 1.5, + mid + 0.3, + 10.0 + (i % 6) as f64, + i, + ) + .unwrap() + }) + .collect(); + let mut osc = ChaikinOscillator::classic(); + let mut adl = Adl::new(); + let mut fast = Ema::new(3).unwrap(); + let mut slow = Ema::new(10).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = osc.update(*candle); + let a = adl.update(*candle).expect("ADL emits from candle 1"); + let f = fast.update(a); + let s = slow.update(a); + match (f, s) { + (Some(fv), Some(sv)) => { + assert_relative_eq!( + got.expect("oscillator ready once slow EMA is"), + fv - sv, + epsilon = 1e-9 + ); + } + _ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"), + } + } + } + + #[test] + fn flat_market_yields_zero() { + // A flat candle has zero money-flow volume, so the ADL never moves and + // both EMAs of a constant-zero series stay at zero. + let candles: Vec = (0..60).map(|i| flat(10.0, i)).collect(); + let mut osc = ChaikinOscillator::classic(); + for v in osc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect(); + let mut osc = ChaikinOscillator::classic(); + let out = osc.batch(&candles); + assert_eq!(osc.warmup_period(), 10); + for (i, v) in out.iter().enumerate().take(9) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[9].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(ChaikinOscillator::new(0, 10).is_err()); + assert!(ChaikinOscillator::new(3, 0).is_err()); + assert!(ChaikinOscillator::new(10, 3).is_err()); + assert!(ChaikinOscillator::new(5, 5).is_err()); + } + + /// Cover the const accessor `periods` (76-78) and the Indicator-impl + /// `name` body (109-111). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let osc = ChaikinOscillator::classic(); + assert_eq!(osc.periods(), (3, 10)); + assert_eq!(osc.name(), "ChaikinOscillator"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect(); + let mut osc = ChaikinOscillator::classic(); + osc.batch(&candles); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + assert_eq!(osc.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + Candle::new( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + .unwrap() + }) + .collect(); + let mut a = ChaikinOscillator::classic(); + let mut b = ChaikinOscillator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/chaikin_volatility.rs b/crates/wickra-core/src/indicators/chaikin_volatility.rs new file mode 100644 index 0000000..0ca8b84 --- /dev/null +++ b/crates/wickra-core/src/indicators/chaikin_volatility.rs @@ -0,0 +1,232 @@ +//! Chaikin Volatility. + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::indicators::roc::Roc; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chaikin Volatility — the rate of change of a smoothed high-low spread. +/// +/// ```text +/// spread_t = high_t − low_t +/// smoothed_t = EMA(spread, ema_period)_t +/// ChaikinVol = 100 · (smoothed_t − smoothed_{t−roc_period}) / smoothed_{t−roc_period} +/// ``` +/// +/// Marc Chaikin's volatility measure tracks not the *level* of the trading +/// range but how fast it is *widening or narrowing*. A rising value means +/// ranges are expanding (often near a top, as fear spikes); a falling value +/// means they are contracting (often a quiet, complacent market). The classic +/// configuration smooths the spread with a `10`-period EMA and takes its +/// `10`-period rate of change. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChaikinVolatility}; +/// +/// let mut indicator = ChaikinVolatility::new(10, 10).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChaikinVolatility { + ema: Ema, + roc: Roc, + ema_period: usize, + roc_period: usize, +} + +impl ChaikinVolatility { + /// Construct a Chaikin Volatility with explicit EMA and rate-of-change + /// periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period + /// is zero. + pub fn new(ema_period: usize, roc_period: usize) -> Result { + Ok(Self { + ema: Ema::new(ema_period)?, + roc: Roc::new(roc_period)?, + ema_period, + roc_period, + }) + } + + /// Marc Chaikin's classic configuration: `EMA(10)` of the spread, `ROC(10)`. + pub fn classic() -> Self { + Self::new(10, 10).expect("classic Chaikin Volatility params are valid") + } + + /// Configured `(ema_period, roc_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.ema_period, self.roc_period) + } +} + +impl Indicator for ChaikinVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let spread = candle.high - candle.low; + let smoothed = self.ema.update(spread)?; + self.roc.update(smoothed) + } + + fn reset(&mut self) { + self.ema.reset(); + self.roc.reset(); + } + + fn warmup_period(&self) -> usize { + // The EMA emits at candle `ema_period`; the ROC then needs + // `roc_period` more smoothed values to span its lookback. + self.ema_period + self.roc_period + } + + fn is_ready(&self) -> bool { + self.roc.is_ready() + } + + fn name(&self) -> &'static str { + "ChaikinVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn constant_range_yields_zero() { + // A constant high-low spread smooths to a constant EMA, whose rate of + // change is zero. + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(10, 10).unwrap(); + for v in cv.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn widening_range_reads_positive() { + // Each bar's range is strictly wider than the last -> expanding + // volatility -> positive Chaikin Volatility. + let candles: Vec = (0..60) + .map(|i| { + let half = 1.0 + i as f64 * 0.1; + c(100.0 + half, 100.0 - half, 100.0, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(10, 10).unwrap(); + for v in cv.batch(&candles).into_iter().flatten() { + assert!(v > 0.0, "an expanding range should read positive, got {v}"); + } + } + + #[test] + fn matches_independent_ema_and_roc() { + let candles: Vec = (0..80) + .map(|i| { + let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0; + c(100.0 + half, 100.0 - half, 100.0, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(10, 10).unwrap(); + let mut ema = Ema::new(10).unwrap(); + let mut roc = Roc::new(10).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = cv.update(*candle); + match ema.update(candle.high - candle.low) { + Some(e) => { + let want = roc.update(e); + assert_eq!(got, want, "i={i}"); + } + None => assert!(got.is_none(), "i={i}"), + } + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(5, 5).unwrap(); + let out = cv.batch(&candles); + assert_eq!(cv.warmup_period(), 10); + for (i, v) in out.iter().enumerate().take(9) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[9].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_zero_period() { + assert!(ChaikinVolatility::new(0, 10).is_err()); + assert!(ChaikinVolatility::new(10, 0).is_err()); + } + + /// Cover the const accessor `periods` (69-71) and the Indicator-impl + /// `name` body (99-101). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let cv = ChaikinVolatility::new(10, 10).unwrap(); + assert_eq!(cv.periods(), (10, 10)); + assert_eq!(cv.name(), "ChaikinVolatility"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cv = ChaikinVolatility::classic(); + cv.batch(&candles); + assert!(cv.is_ready()); + cv.reset(); + assert!(!cv.is_ready()); + assert_eq!(cv.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0; + c(100.0 + half, 100.0 - half, 100.0, i) + }) + .collect(); + let mut a = ChaikinVolatility::classic(); + let mut b = ChaikinVolatility::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/chande_kroll_stop.rs b/crates/wickra-core/src/indicators/chande_kroll_stop.rs new file mode 100644 index 0000000..813885a --- /dev/null +++ b/crates/wickra-core/src/indicators/chande_kroll_stop.rs @@ -0,0 +1,260 @@ +//! Chande Kroll Stop. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chande Kroll Stop output: the long-side and short-side stop levels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ChandeKrollStopOutput { + /// Long-position stop — the lowest preliminary low-stop over `stop_period`. + pub stop_long: f64, + /// Short-position stop — the highest preliminary high-stop over `stop_period`. + pub stop_short: f64, +} + +/// Chande Kroll Stop — Tushar Chande and Stanley Kroll's two-stage ATR stop. +/// +/// ```text +/// preliminary (window p = atr_period, x = atr_multiplier): +/// high_stop = highest_high(p) − x · ATR(p) +/// low_stop = lowest_low(p) + x · ATR(p) +/// +/// final (window q = stop_period): +/// stop_short = highest(high_stop, q) +/// stop_long = lowest(low_stop, q) +/// ``` +/// +/// The first stage builds an ATR stop off the recent extreme, exactly like a +/// [`ChandelierExit`](crate::ChandelierExit); the second stage smooths it by +/// taking the most extreme preliminary stop over a shorter window, which keeps +/// the stop from whipsawing on a single wide bar. The classic configuration +/// from *The New Technical Trader* is `ATR(10)`, multiplier `1.0`, smoothing +/// window `9`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChandeKrollStop}; +/// +/// let mut indicator = ChandeKrollStop::new(10, 1.0, 9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChandeKrollStop { + atr_period: usize, + atr_multiplier: f64, + stop_period: usize, + atr: Atr, + highs: VecDeque, + lows: VecDeque, + high_stops: VecDeque, + low_stops: VecDeque, +} + +impl ChandeKrollStop { + /// Construct a Chande Kroll Stop with explicit ATR and smoothing windows. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period` or `stop_period` is zero, + /// and [`Error::NonPositiveMultiplier`] if `atr_multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, atr_multiplier: f64, stop_period: usize) -> Result { + if !atr_multiplier.is_finite() || atr_multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + if stop_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + atr_period, + atr_multiplier, + stop_period, + atr: Atr::new(atr_period)?, + highs: VecDeque::with_capacity(atr_period), + lows: VecDeque::with_capacity(atr_period), + high_stops: VecDeque::with_capacity(stop_period), + low_stops: VecDeque::with_capacity(stop_period), + }) + } + + /// The classic configuration: `ATR(10)`, multiplier `1.0`, window `9`. + pub fn classic() -> Self { + Self::new(10, 1.0, 9).expect("classic Chande Kroll Stop params are valid") + } + + /// Configured `(atr_period, atr_multiplier, stop_period)`. + pub const fn params(&self) -> (usize, f64, usize) { + (self.atr_period, self.atr_multiplier, self.stop_period) + } +} + +impl Indicator for ChandeKrollStop { + type Input = Candle; + type Output = ChandeKrollStopOutput; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle); + if self.highs.len() == self.atr_period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.atr_period { + return None; + } + // ATR(atr_period) becomes ready on exactly the candle that fills the + // preliminary window, so this never discards a value. + let atr = atr?; + let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let high_stop = highest - self.atr_multiplier * atr; + let low_stop = lowest + self.atr_multiplier * atr; + + if self.high_stops.len() == self.stop_period { + self.high_stops.pop_front(); + self.low_stops.pop_front(); + } + self.high_stops.push_back(high_stop); + self.low_stops.push_back(low_stop); + if self.high_stops.len() < self.stop_period { + return None; + } + let stop_short = self + .high_stops + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let stop_long = self.low_stops.iter().copied().fold(f64::INFINITY, f64::min); + Some(ChandeKrollStopOutput { + stop_long, + stop_short, + }) + } + + fn reset(&mut self) { + self.atr.reset(); + self.highs.clear(); + self.lows.clear(); + self.high_stops.clear(); + self.low_stops.clear(); + } + + fn warmup_period(&self) -> usize { + // The preliminary stop first appears on candle `atr_period`; the + // smoothing window then needs `stop_period` of them. + self.atr_period + self.stop_period - 1 + } + + fn is_ready(&self) -> bool { + self.high_stops.len() == self.stop_period + } + + fn name(&self) -> &'static str { + "ChandeKrollStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_values_flat_market() { + // Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9. + // high_stop = 11 - 1·2 = 9; low_stop = 9 + 1·2 = 11. + // stop_short = highest(high_stop, q) = 9; stop_long = lowest(low_stop, q) = 11. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut cks = ChandeKrollStop::new(5, 1.0, 3).unwrap(); + let last = cks.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.stop_short, 9.0, epsilon = 1e-12); + assert_relative_eq!(last.stop_long, 11.0, epsilon = 1e-12); + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..16) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cks = ChandeKrollStop::new(4, 1.0, 3).unwrap(); + let out = cks.batch(&candles); + assert_eq!(cks.warmup_period(), 6); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[5].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(ChandeKrollStop::new(0, 1.0, 9).is_err()); + assert!(ChandeKrollStop::new(10, 1.0, 0).is_err()); + assert!(ChandeKrollStop::new(10, 0.0, 9).is_err()); + assert!(ChandeKrollStop::new(10, -1.0, 9).is_err()); + assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err()); + } + + /// Cover the const accessor `params` (97-99) and the Indicator-impl + /// `name` body (164-166). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let s = ChandeKrollStop::new(10, 1.0, 9).unwrap(); + let (p, m, q) = s.params(); + assert_eq!(p, 10); + assert!((m - 1.0).abs() < 1e-12); + assert_eq!(q, 9); + assert_eq!(s.name(), "ChandeKrollStop"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cks = ChandeKrollStop::classic(); + cks.batch(&candles); + assert!(cks.is_ready()); + cks.reset(); + assert!(!cks.is_ready()); + assert_eq!(cks.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = ChandeKrollStop::classic(); + let mut b = ChandeKrollStop::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/chandelier_exit.rs b/crates/wickra-core/src/indicators/chandelier_exit.rs new file mode 100644 index 0000000..c79841c --- /dev/null +++ b/crates/wickra-core/src/indicators/chandelier_exit.rs @@ -0,0 +1,242 @@ +//! Chandelier Exit. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chandelier Exit output: the long-side and short-side trailing stops. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ChandelierExitOutput { + /// Long-position stop: `highest_high − multiplier · ATR`. + pub long_stop: f64, + /// Short-position stop: `lowest_low + multiplier · ATR`. + pub short_stop: f64, +} + +/// Chandelier Exit — Chuck LeBeau's ATR trailing stop, hung from the highest +/// high (for longs) or the lowest low (for shorts) of the lookback window. +/// +/// ```text +/// long_stop = highest_high(period) − multiplier · ATR(period) +/// short_stop = lowest_low(period) + multiplier · ATR(period) +/// ``` +/// +/// A long position is exited when price closes below `long_stop`; a short +/// when it closes above `short_stop`. Because the stop hangs a fixed number +/// of ATRs off the extreme of the window — like a chandelier off a ceiling — +/// it follows price up but never loosens. LeBeau's classic configuration is a +/// `22`-bar window with a `3.0` multiplier. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChandelierExit}; +/// +/// let mut indicator = ChandelierExit::new(22, 3.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChandelierExit { + period: usize, + multiplier: f64, + atr: Atr, + highs: VecDeque, + lows: VecDeque, +} + +impl ChandelierExit { + /// Construct a Chandelier Exit with an explicit window and band multiplier. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + multiplier, + atr: Atr::new(period)?, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// LeBeau's classic configuration: a `22`-bar window, `3.0` multiplier. + pub fn classic() -> Self { + Self::new(22, 3.0).expect("classic Chandelier Exit params are valid") + } + + /// Configured `(period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.period, self.multiplier) + } +} + +impl Indicator for ChandelierExit { + type Input = Candle; + type Output = ChandelierExitOutput; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle); + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.period { + return None; + } + // ATR(period) becomes ready on exactly the candle that fills the + // highest-high / lowest-low window, so this never discards a value. + let atr = atr?; + let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + Some(ChandelierExitOutput { + long_stop: highest - self.multiplier * atr, + short_stop: lowest + self.multiplier * atr, + }) + } + + fn reset(&mut self) { + self.atr.reset(); + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.highs.len() == self.period + } + + fn name(&self) -> &'static str { + "ChandelierExit" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_values_flat_market() { + // Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9. + // long_stop = 11 - 3·2 = 5; short_stop = 9 + 3·2 = 15. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ce = ChandelierExit::new(5, 3.0).unwrap(); + let last = ce.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.long_stop, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.short_stop, 15.0, epsilon = 1e-12); + } + + #[test] + fn long_stop_below_highest_short_stop_above_lowest() { + let candles: Vec = (0..120) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.2).sin() * 9.0; + c(mid + 1.5, mid - 1.5, mid + 0.4, i) + }) + .collect(); + let mut ce = ChandelierExit::classic(); + for (i, o) in ce.batch(&candles).into_iter().enumerate() { + if let Some(o) = o { + // The window's extremes bound the stops from one side. + let win = &candles[i + 1 - 22..=i]; + let hh = win.iter().map(|c| c.high).fold(f64::NEG_INFINITY, f64::max); + let ll = win.iter().map(|c| c.low).fold(f64::INFINITY, f64::min); + assert!(o.long_stop <= hh + 1e-9); + assert!(o.short_stop >= ll - 1e-9); + } + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ce = ChandelierExit::new(8, 3.0).unwrap(); + let out = ce.batch(&candles); + assert_eq!(ce.warmup_period(), 8); + for (i, v) in out.iter().enumerate().take(7) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[7].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(ChandelierExit::new(0, 3.0).is_err()); + assert!(ChandelierExit::new(22, 0.0).is_err()); + assert!(ChandelierExit::new(22, -1.0).is_err()); + assert!(ChandelierExit::new(22, f64::NAN).is_err()); + } + + /// Cover the const accessor `params` (83-85) and the Indicator-impl + /// `name` body (128-130). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let ce = ChandelierExit::new(22, 3.0).unwrap(); + let (p, m) = ce.params(); + assert_eq!(p, 22); + assert!((m - 3.0).abs() < 1e-12); + assert_eq!(ce.name(), "ChandelierExit"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ce = ChandelierExit::classic(); + ce.batch(&candles); + assert!(ce.is_ready()); + ce.reset(); + assert!(!ce.is_ready()); + assert_eq!(ce.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = ChandelierExit::classic(); + let mut b = ChandelierExit::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/choppiness_index.rs b/crates/wickra-core/src/indicators/choppiness_index.rs new file mode 100644 index 0000000..e5f9c0b --- /dev/null +++ b/crates/wickra-core/src/indicators/choppiness_index.rs @@ -0,0 +1,229 @@ +//! Choppiness Index. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Choppiness Index — is the market trending or just chopping sideways? +/// +/// ```text +/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n) +/// ``` +/// +/// The ratio compares the *distance price actually travelled* (the summed true +/// range) with the *net ground it covered* (the high-low span of the window). +/// A clean trend travels almost exactly its span, so the ratio is near `1` and +/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the +/// ratio is large and `CI` climbs toward `100`. The conventional reading is +/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields +/// `100` by convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChoppinessIndex}; +/// +/// let mut indicator = ChoppinessIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChoppinessIndex { + period: usize, + log_n: f64, + prev_close: Option, + tr_window: VecDeque, + tr_sum: f64, + highs: VecDeque, + lows: VecDeque, +} + +impl ChoppinessIndex { + /// Construct a new Choppiness Index over `period` bars. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)` + /// denominator is zero for `period == 1` and undefined for `period == 0`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "choppiness index needs period >= 2", + }); + } + Ok(Self { + period, + log_n: (period as f64).log10(), + prev_close: None, + tr_window: VecDeque::with_capacity(period), + tr_sum: 0.0, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ChoppinessIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tr = candle.true_range(self.prev_close); + self.prev_close = Some(candle.close); + + if self.tr_window.len() == self.period { + self.tr_sum -= self.tr_window.pop_front().expect("non-empty"); + self.highs.pop_front(); + self.lows.pop_front(); + } + self.tr_window.push_back(tr); + self.tr_sum += tr; + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + + if self.tr_window.len() < self.period { + return None; + } + let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let span = highest - lowest; + if span == 0.0 { + // A perfectly flat window: maximal choppiness by convention. + return Some(100.0); + } + Some(100.0 * (self.tr_sum / span).log10() / self.log_n) + } + + fn reset(&mut self) { + self.prev_close = None; + self.tr_window.clear(); + self.tr_sum = 0.0; + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.tr_window.len() == self.period + } + + fn name(&self) -> &'static str { + "ChoppinessIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value_equal_range_bars() { + // Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2. + // CI = 100 · log10(4 / 2) / log10(2) = 100. + let mut ci = ChoppinessIndex::new(2).unwrap(); + let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9); + } + + #[test] + fn flat_window_yields_hundred() { + let candles: Vec = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect(); + let mut ci = ChoppinessIndex::new(14).unwrap(); + for v in ci.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn steady_trend_reads_low() { + // A clean one-directional march travels close to its span -> low CI. + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ci = ChoppinessIndex::new(14).unwrap(); + for v in ci.batch(&candles).into_iter().flatten() { + assert!(v < 50.0, "a steady trend should read below 50, got {v}"); + assert!(v >= 0.0, "CI must be non-negative, got {v}"); + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ci = ChoppinessIndex::new(8).unwrap(); + let out = ci.batch(&candles); + assert_eq!(ci.warmup_period(), 8); + for (i, v) in out.iter().enumerate().take(7) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[7].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_period_below_two() { + assert!(ChoppinessIndex::new(0).is_err()); + assert!(ChoppinessIndex::new(1).is_err()); + assert!(ChoppinessIndex::new(2).is_ok()); + } + + /// Cover the const accessor `period` (73-75) and the Indicator-impl + /// `name` body (125-127). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let ci = ChoppinessIndex::new(14).unwrap(); + assert_eq!(ci.period(), 14); + assert_eq!(ci.name(), "ChoppinessIndex"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ci = ChoppinessIndex::new(14).unwrap(); + ci.batch(&candles); + assert!(ci.is_ready()); + ci.reset(); + assert!(!ci.is_ready()); + assert_eq!(ci.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = ChoppinessIndex::new(14).unwrap(); + let mut b = ChoppinessIndex::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/classic_pivots.rs b/crates/wickra-core/src/indicators/classic_pivots.rs new file mode 100644 index 0000000..b160cd8 --- /dev/null +++ b/crates/wickra-core/src/indicators/classic_pivots.rs @@ -0,0 +1,202 @@ +//! Classic (Floor-Trader) Pivot Points. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Classic Pivot Points output: pivot plus three resistances and three supports. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClassicPivotsOutput { + /// Pivot Point: `(H + L + C) / 3`. + pub pp: f64, + /// Resistance 1: `2·PP − L`. + pub r1: f64, + /// Resistance 2: `PP + (H − L)`. + pub r2: f64, + /// Resistance 3: `H + 2·(PP − L)`. + pub r3: f64, + /// Support 1: `2·PP − H`. + pub s1: f64, + /// Support 2: `PP − (H − L)`. + pub s2: f64, + /// Support 3: `L − 2·(H − PP)`. + pub s3: f64, +} + +/// Classic (Floor-Trader) Pivot Points — the standard pivot/resistance/support +/// levels computed from a completed candle's high, low and close. +/// +/// ```text +/// PP = (H + L + C) / 3 +/// R1 = 2·PP − L S1 = 2·PP − H +/// R2 = PP + (H − L) S2 = PP − (H − L) +/// R3 = H + 2·(PP − L) S3 = L − 2·(H − PP) +/// ``` +/// +/// Pivots are typically computed once per session (day, week, month) from the +/// **previous** session's bar and used as fixed reference levels for the next +/// session. The streaming API here simply re-evaluates the formula on every +/// candle it sees, which makes it a one-step transform you can wire to any +/// pre-aggregated session bar. There are no parameters and no warmup — the +/// first candle produces the first set of levels. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, ClassicPivots, Indicator}; +/// +/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap(); +/// let mut pp = ClassicPivots::new(); +/// let levels = pp.update(prev).unwrap(); +/// assert!((levels.pp - 101.6666666666).abs() < 1e-9); +/// assert!(levels.r1 > levels.pp); +/// assert!(levels.s1 < levels.pp); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ClassicPivots { + ready: bool, +} + +impl ClassicPivots { + /// Construct a new Classic Pivot Points indicator. The indicator has no + /// parameters and no warmup. + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for ClassicPivots { + type Input = Candle; + type Output = ClassicPivotsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let (h, l, c) = (candle.high, candle.low, candle.close); + let pp = (h + l + c) / 3.0; + let range = h - l; + let out = ClassicPivotsOutput { + pp, + r1: 2.0 * pp - l, + r2: pp + range, + r3: h + 2.0 * (pp - l), + s1: 2.0 * pp - h, + s2: pp - range, + s3: l - 2.0 * (h - pp), + }; + self.ready = true; + Some(out) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "ClassicPivots" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, h, l, close, 1.0, ts).unwrap() + } + + #[test] + fn formula_reference_values() { + // H=110, L=90, C=105 -> PP = 305/3 ≈ 101.6667. + let levels = ClassicPivots::new() + .update(c(110.0, 90.0, 105.0, 0)) + .unwrap(); + let pp = 305.0 / 3.0; + let range = 20.0; + assert!((levels.pp - pp).abs() < 1e-12); + assert!((levels.r1 - (2.0 * pp - 90.0)).abs() < 1e-12); + assert!((levels.s1 - (2.0 * pp - 110.0)).abs() < 1e-12); + assert!((levels.r2 - (pp + range)).abs() < 1e-12); + assert!((levels.s2 - (pp - range)).abs() < 1e-12); + assert!((levels.r3 - (110.0 + 2.0 * (pp - 90.0))).abs() < 1e-12); + assert!((levels.s3 - (90.0 - 2.0 * (110.0 - pp))).abs() < 1e-12); + } + + #[test] + fn ordering_resistance_above_pivot_above_support() { + // For any non-degenerate bar with H > L, R-levels exceed PP and S-levels lie below. + let levels = ClassicPivots::new() + .update(c(200.0, 100.0, 150.0, 0)) + .unwrap(); + assert!(levels.r3 >= levels.r2); + assert!(levels.r2 >= levels.r1); + assert!(levels.r1 >= levels.pp); + assert!(levels.pp >= levels.s1); + assert!(levels.s1 >= levels.s2); + assert!(levels.s2 >= levels.s3); + } + + #[test] + fn constant_series_collapses_levels() { + // H = L = C means range = 0 and every level equals the close. + let levels = ClassicPivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap(); + assert_eq!(levels.pp, 50.0); + assert_eq!(levels.r1, 50.0); + assert_eq!(levels.s1, 50.0); + assert_eq!(levels.r2, 50.0); + assert_eq!(levels.s2, 50.0); + assert_eq!(levels.r3, 50.0); + assert_eq!(levels.s3, 50.0); + } + + #[test] + fn ready_after_first_update_warmup_is_one() { + let mut pp = ClassicPivots::new(); + assert!(!pp.is_ready()); + assert_eq!(pp.warmup_period(), 1); + pp.update(c(11.0, 9.0, 10.0, 0)); + assert!(pp.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut pp = ClassicPivots::new(); + pp.update(c(11.0, 9.0, 10.0, 0)); + assert!(pp.is_ready()); + pp.reset(); + assert!(!pp.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0_i32..40) + .map(|i| { + c( + f64::from(i) + 2.0, + f64::from(i), + f64::from(i) + 1.0, + i.into(), + ) + }) + .collect(); + let mut a = ClassicPivots::new(); + let mut b = ClassicPivots::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let pp = ClassicPivots::new(); + assert_eq!(pp.warmup_period(), 1); + assert_eq!(pp.name(), "ClassicPivots"); + } +} diff --git a/crates/wickra-core/src/indicators/close_vs_open.rs b/crates/wickra-core/src/indicators/close_vs_open.rs new file mode 100644 index 0000000..eee8f74 --- /dev/null +++ b/crates/wickra-core/src/indicators/close_vs_open.rs @@ -0,0 +1,157 @@ +//! Close vs Open — the signed relative body of a bar. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Close vs Open — the bar's body as a signed fraction of its open price. +/// +/// ```text +/// CloseVsOpen = (close − open) / open +/// ``` +/// +/// A scale-free, signed measure of how far price travelled from open to close: +/// `+0.02` is a bar that closed 2% above its open (a green bar), `−0.02` the +/// mirror. Unlike [`BalanceOfPower`](crate::BalanceOfPower) — which normalises +/// the body by the bar *range* — this normalises by the *open price*, so it is +/// directly comparable to a return and stays meaningful across instruments of +/// different nominal price. A zero open carries no scale and yields `0`. +/// +/// This is a stateless per-bar transform: every candle produces one value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, CloseVsOpen}; +/// +/// let mut indicator = CloseVsOpen::new(); +/// // open 100, close 102 -> +0.02. +/// let c = Candle::new(100.0, 103.0, 99.0, 102.0, 10.0, 0).unwrap(); +/// assert!((indicator.update(c).unwrap() - 0.02).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CloseVsOpen { + has_emitted: bool, +} + +impl CloseVsOpen { + /// Construct a new Close vs Open transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for CloseVsOpen { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let out = if candle.open == 0.0 { + // A zero open price carries no scale to normalise against. + 0.0 + } else { + (candle.close - candle.open) / candle.open + }; + Some(out) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CloseVsOpen" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (102 - 100) / 100 = 0.02. + let mut cvo = CloseVsOpen::new(); + assert_relative_eq!( + cvo.update(candle(100.0, 103.0, 99.0, 102.0, 0)).unwrap(), + 0.02, + epsilon = 1e-12 + ); + } + + #[test] + fn negative_body_is_negative() { + let mut cvo = CloseVsOpen::new(); + // close below open -> negative. + assert_relative_eq!( + cvo.update(candle(100.0, 101.0, 97.0, 98.0, 0)).unwrap(), + -0.02, + epsilon = 1e-12 + ); + } + + #[test] + fn zero_open_yields_zero() { + // Candle permits a zero open (only finiteness + OHLC ordering checked). + let mut cvo = CloseVsOpen::new(); + assert_relative_eq!( + cvo.update(candle(0.0, 1.0, 0.0, 0.5, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn name_metadata() { + let cvo = CloseVsOpen::new(); + assert_eq!(cvo.name(), "CloseVsOpen"); + } + + #[test] + fn emits_from_first_candle() { + let mut cvo = CloseVsOpen::new(); + assert_eq!(cvo.warmup_period(), 1); + assert!(!cvo.is_ready()); + assert!(cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(cvo.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut cvo = CloseVsOpen::new(); + cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(cvo.is_ready()); + cvo.reset(); + assert!(!cvo.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i)) + }) + .collect(); + let mut a = CloseVsOpen::new(); + let mut b = CloseVsOpen::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/closing_marubozu.rs b/crates/wickra-core/src/indicators/closing_marubozu.rs new file mode 100644 index 0000000..f35ef25 --- /dev/null +++ b/crates/wickra-core/src/indicators/closing_marubozu.rs @@ -0,0 +1,177 @@ +//! Closing Marubozu candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Closing Marubozu — a single-bar strong-momentum candle with a long body and no +/// shadow on the *close* end. A white closing marubozu closes right at the high +/// (no upper shadow) and may carry an opening shadow below; a black one closes +/// right at the low (no lower shadow) and may carry an opening shadow above. The +/// shaved close end shows the move ran unopposed into the bell. +/// +/// ```text +/// range = high − low +/// long body: |close − open| >= 0.7 * range +/// white: close > open and high − close <= 0.05 * range (close at the high) +/// black: close < open and close − low <= 0.05 * range (close at the low) +/// ``` +/// +/// Output is `+1.0` for a white closing marubozu, `−1.0` for a black one, and +/// `0.0` otherwise. Body and shadow thresholds follow the geometric house style +/// rather than TA-Lib's rolling averages. The opposite shaved end is +/// [`crate::OpeningMarubozu`]. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it drops +/// straight into a machine-learning feature matrix where the bullish and bearish +/// variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, ClosingMarubozu, Indicator}; +/// +/// let mut indicator = ClosingMarubozu::new(); +/// // White: closes at the high, small opening shadow below. +/// let candle = Candle::new(10.5, 15.0, 10.0, 15.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ClosingMarubozu { + has_emitted: bool, +} + +impl ClosingMarubozu { + /// Construct a new Closing Marubozu detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for ClosingMarubozu { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = candle.close - candle.open; + if body.abs() < 0.7 * range { + return Some(0.0); + } + let tol = 0.05 * range; + if body > 0.0 && candle.high - candle.close <= tol { + return Some(1.0); + } + if body < 0.0 && candle.close - candle.low <= tol { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ClosingMarubozu" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ClosingMarubozu::new(); + assert_eq!(t.name(), "ClosingMarubozu"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn white_closing_marubozu_is_plus_one() { + let mut t = ClosingMarubozu::new(); + // Closes at the high, opening shadow below. + assert_eq!(t.update(c(10.5, 15.0, 10.0, 15.0, 0)), Some(1.0)); + } + + #[test] + fn black_closing_marubozu_is_minus_one() { + let mut t = ClosingMarubozu::new(); + // Closes at the low, opening shadow above. + assert_eq!(t.update(c(14.5, 15.0, 10.0, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn white_with_upper_shadow_yields_zero() { + let mut t = ClosingMarubozu::new(); + // Long white body but a clear upper shadow -> close is not at the high. + assert_eq!(t.update(c(10.5, 16.0, 10.0, 15.0, 0)), Some(0.0)); + } + + #[test] + fn black_with_lower_shadow_yields_zero() { + let mut t = ClosingMarubozu::new(); + // Long black body but a clear lower shadow -> close is not at the low. + assert_eq!(t.update(c(14.5, 15.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn short_body_yields_zero() { + let mut t = ClosingMarubozu::new(); + // Body is short relative to range. + assert_eq!(t.update(c(12.0, 15.0, 10.0, 12.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = ClosingMarubozu::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 0.5, base + 5.0, base, base + 5.0, i) + }) + .collect(); + let mut a = ClosingMarubozu::new(); + let mut b = ClosingMarubozu::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ClosingMarubozu::new(); + t.update(c(10.5, 15.0, 10.0, 15.0, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/cmf.rs b/crates/wickra-core/src/indicators/cmf.rs new file mode 100644 index 0000000..042f307 --- /dev/null +++ b/crates/wickra-core/src/indicators/cmf.rs @@ -0,0 +1,280 @@ +//! Chaikin Money Flow (CMF). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator. +/// +/// Each bar produces a *money-flow volume*: the bar's volume weighted by where +/// the close fell within its range (the same money-flow multiplier the +/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to +/// summed volume over the lookback window: +/// +/// ```text +/// MFM_t = ((close − low) − (high − close)) / (high − low) (−1..+1) +/// MFV_t = MFM_t · volume_t +/// CMF_t = Σ(MFV, period) / Σ(volume, period) +/// ``` +/// +/// The result lives in `[−1, +1]`: sustained closes near the high push CMF +/// toward `+1` (accumulation), near the low toward `−1` (distribution). A bar +/// with `high == low` carries no positional information and contributes a +/// money-flow volume of `0`; a window whose total volume is zero yields `0.0` +/// by convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow}; +/// +/// let mut indicator = ChaikinMoneyFlow::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChaikinMoneyFlow { + period: usize, + mfv_window: VecDeque, + vol_window: VecDeque, + mfv_sum: f64, + vol_sum: f64, +} + +impl ChaikinMoneyFlow { + /// Construct a new Chaikin Money Flow over `period` bars. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + mfv_window: VecDeque::with_capacity(period), + vol_window: VecDeque::with_capacity(period), + mfv_sum: 0.0, + vol_sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ChaikinMoneyFlow { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let mfv = if range == 0.0 { + // A zero-range bar carries no positional information. + 0.0 + } else { + let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range; + mfm * candle.volume + }; + + if self.mfv_window.len() == self.period { + self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty"); + self.vol_sum -= self.vol_window.pop_front().expect("non-empty"); + } + self.mfv_window.push_back(mfv); + self.vol_window.push_back(candle.volume); + self.mfv_sum += mfv; + self.vol_sum += candle.volume; + + if self.mfv_window.len() < self.period { + return None; + } + if self.vol_sum == 0.0 { + // No volume traded across the whole window — no flow to report. + return Some(0.0); + } + Some(self.mfv_sum / self.vol_sum) + } + + fn reset(&mut self) { + self.mfv_window.clear(); + self.vol_window.clear(); + self.mfv_sum = 0.0; + self.vol_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.mfv_window.len() == self.period + } + + fn name(&self) -> &'static str { + "CMF" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100. + // bar 2 closes mid-range -> MFM = 0, MFV = 0. + // CMF = (100 + 0) / (100 + 100) = 0.5. + let mut cmf = ChaikinMoneyFlow::new(2).unwrap(); + let out = cmf.batch(&[ + candle(8.0, 10.0, 8.0, 10.0, 100.0, 0), + candle(10.0, 12.0, 8.0, 10.0, 100.0, 1), + ]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12); + } + + #[test] + fn stays_within_unit_range() { + let candles: Vec = (0..120) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0; + candle( + mid, + mid + 3.0, + mid - 3.0, + mid + (i as f64 * 0.5).cos() * 2.0, + 10.0 + (i % 7) as f64, + i, + ) + }) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(20).unwrap(); + for v in cmf.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]"); + } + } + + #[test] + fn closes_at_high_yield_cmf_one() { + // Every bar closes on its high -> MFM = +1 -> CMF saturates at +1. + let candles: Vec = (0..30) + .map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(14).unwrap(); + for v in cmf.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_window_yields_zero() { + // A window with no traded volume divides 0/0 — defined as 0.0. + let candles: Vec = (0..20) + .map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(10).unwrap(); + for v in cmf.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn first_value_on_period_th_candle() { + let candles: Vec = (0..10) + .map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(5).unwrap(); + let out = cmf.batch(&candles); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first CMF lands at index period - 1"); + assert_eq!(cmf.warmup_period(), 5); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` (71-73) and the Indicator-impl + /// `name` body (124-126). `warmup_period` is covered elsewhere. + #[test] + fn accessors_and_metadata() { + let cmf = ChaikinMoneyFlow::new(20).unwrap(); + assert_eq!(cmf.period(), 20); + assert_eq!(cmf.name(), "CMF"); + } + + /// Cover the `range == 0.0` defensive branch (line 84). All other + /// tests use H != L candles; feed all-flat candles (H == L) so the + /// MFV computation must take the zero-range fallback and emit MFV = 0. + #[test] + fn zero_range_candle_contributes_zero_mfv() { + let mut cmf = ChaikinMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..5) + .map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 5.0, i).unwrap()) + .collect(); + let last = cmf + .batch(&candles) + .into_iter() + .flatten() + .last() + .expect("emits"); + // Every bar contributed 0 to mfv_sum, so the ratio is 0. + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(10).unwrap(); + cmf.batch(&candles); + assert!(cmf.is_ready()); + cmf.reset(); + assert!(!cmf.is_ready()); + assert_eq!(cmf.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = ChaikinMoneyFlow::new(20).unwrap(); + let mut b = ChaikinMoneyFlow::new(20).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/cmo.rs b/crates/wickra-core/src/indicators/cmo.rs new file mode 100644 index 0000000..2b07bc7 --- /dev/null +++ b/crates/wickra-core/src/indicators/cmo.rs @@ -0,0 +1,233 @@ +//! Chande Momentum Oscillator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Chande Momentum Oscillator — Tushar Chande's bounded momentum gauge. +/// +/// Over the last `period` price *changes* it sums the gains and the losses +/// separately and reports: +/// +/// ```text +/// CMO = 100 · (Σ gains − Σ losses) / (Σ gains + Σ losses) +/// ``` +/// +/// The result is bounded in `[−100, 100]`: `+100` is a window of pure gains, +/// `−100` a window of pure losses, `0` a perfect balance. Unlike RSI the sums +/// are *unsmoothed* — every change in the window carries equal weight — so CMO +/// reacts faster and swings wider. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Cmo}; +/// +/// let mut indicator = Cmo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert_eq!(last, Some(100.0)); // pure uptrend saturates at +100 +/// ``` +#[derive(Debug, Clone)] +pub struct Cmo { + period: usize, + prev_price: Option, + /// Rolling window of `(gain, loss)` pairs, oldest at the front. + window: VecDeque<(f64, f64)>, + sum_gain: f64, + sum_loss: f64, + current: Option, +} + +impl Cmo { + /// Construct a new CMO with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_price: None, + window: VecDeque::with_capacity(period), + sum_gain: 0.0, + sum_loss: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Cmo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.current; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + + let change = input - prev; + let gain = change.max(0.0); + let loss = (-change).max(0.0); + + if self.window.len() == self.period { + let (old_gain, old_loss) = self.window.pop_front().expect("window is non-empty"); + self.sum_gain -= old_gain; + self.sum_loss -= old_loss; + } + self.window.push_back((gain, loss)); + self.sum_gain += gain; + self.sum_loss += loss; + + if self.window.len() < self.period { + return None; + } + let denom = self.sum_gain + self.sum_loss; + let cmo = if denom == 0.0 { + // A flat window (no gains and no losses): momentum is exactly zero. + 0.0 + } else { + 100.0 * (self.sum_gain - self.sum_loss) / denom + }; + self.current = Some(cmo); + Some(cmo) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum_gain = 0.0; + self.sum_loss = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "CMO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Cmo::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (66-73) and the + /// Indicator-impl `name` body (134-136). Existing tests inspect + /// CMO output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut cmo = Cmo::new(14).unwrap(); + assert_eq!(cmo.period(), 14); + assert_eq!(cmo.name(), "CMO"); + assert_eq!(cmo.value(), None); + for i in 1..=15 { + cmo.update(f64::from(i)); + } + assert!(cmo.value().is_some()); + } + + #[test] + fn reference_value() { + // CMO(3) over [10, 11, 10, 12]: changes +1, −1, +2. + // Σgain = 3, Σloss = 1 -> 100·(3−1)/(3+1) = 50. + let mut cmo = Cmo::new(3).unwrap(); + let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]); + assert_eq!(cmo.warmup_period(), 4); + assert_eq!(out[0], None); + assert_eq!(out[2], None); + assert_relative_eq!(out[3].unwrap(), 50.0, epsilon = 1e-12); + } + + #[test] + fn pure_uptrend_saturates_at_plus_100() { + let mut cmo = Cmo::new(5).unwrap(); + let out = cmo.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.iter().skip(6).flatten() { + assert_relative_eq!(*v, 100.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_downtrend_saturates_at_minus_100() { + let mut cmo = Cmo::new(5).unwrap(); + let out = cmo.batch(&(1..=20).rev().map(f64::from).collect::>()); + for v in out.iter().skip(6).flatten() { + assert_relative_eq!(*v, -100.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut cmo = Cmo::new(5).unwrap(); + let out = cmo.batch(&[42.0; 20]); + for v in out.iter().skip(6).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut cmo = Cmo::new(3).unwrap(); + let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]); + let ready = out[3].expect("CMO(3) ready after four inputs"); + assert_eq!(cmo.update(f64::NAN), Some(ready)); + assert_eq!(cmo.update(f64::INFINITY), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut cmo = Cmo::new(3).unwrap(); + cmo.batch(&[10.0, 11.0, 12.0, 13.0, 14.0]); + assert!(cmo.is_ready()); + cmo.reset(); + assert!(!cmo.is_ready()); + assert_eq!(cmo.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0) + .collect(); + let batch = Cmo::new(9).unwrap().batch(&prices); + let mut b = Cmo::new(9).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/coefficient_of_variation.rs b/crates/wickra-core/src/indicators/coefficient_of_variation.rs new file mode 100644 index 0000000..166d4c8 --- /dev/null +++ b/crates/wickra-core/src/indicators/coefficient_of_variation.rs @@ -0,0 +1,187 @@ +//! Rolling Coefficient of Variation (`StdDev / Mean`). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Coefficient of Variation — the rolling population standard deviation +/// divided by the rolling mean. +/// +/// ```text +/// mean = (1/n) · Σ price +/// sd = √( (1/n) · Σ price² − mean² ) +/// CV = sd / mean +/// ``` +/// +/// CV is a dimensionless dispersion measure: it scales `StdDev` by the price +/// level so two assets at very different price magnitudes can be compared +/// directly. A higher CV means more relative variability for the same +/// average price. +/// +/// When the rolling mean is exactly zero the ratio is undefined; the +/// indicator returns `0.0` in that degenerate case rather than producing a +/// `NaN`/infinity. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CoefficientOfVariation, Indicator}; +/// +/// let mut indicator = CoefficientOfVariation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CoefficientOfVariation { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl CoefficientOfVariation { + /// Construct a new rolling CV with the given period. + /// + /// # 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: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for CoefficientOfVariation { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.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(value); + self.sum += value; + self.sum_sq += value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let sd = variance.sqrt(); + if mean == 0.0 { + // Undefined ratio: return 0 instead of NaN/inf so downstream + // consumers can keep arithmetic going on flat or zeroed series. + return Some(0.0); + } + Some(sd / mean) + } + + 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 { + "CoefficientOfVariation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + CoefficientOfVariation::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let cv = CoefficientOfVariation::new(14).unwrap(); + assert_eq!(cv.period(), 14); + assert_eq!(cv.warmup_period(), 14); + assert_eq!(cv.name(), "CoefficientOfVariation"); + } + + #[test] + fn reference_value() { + // CV(3) of [2, 4, 6]: mean = 4, variance = 8/3, sd = √(8/3); CV = sd / 4. + let mut cv = CoefficientOfVariation::new(3).unwrap(); + let out = cv.batch(&[2.0, 4.0, 6.0]); + assert_eq!(out[0], None); + let expected = (8.0_f64 / 3.0).sqrt() / 4.0; + assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut cv = CoefficientOfVariation::new(5).unwrap(); + for o in cv.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(o, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_mean_returns_zero() { + // [-1, 0, 1] has mean 0; the CV is defined to be 0 rather than NaN. + let mut cv = CoefficientOfVariation::new(3).unwrap(); + let out = cv.batch(&[-1.0, 0.0, 1.0]); + assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut cv = CoefficientOfVariation::new(5).unwrap(); + cv.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(cv.is_ready()); + cv.reset(); + assert!(!cv.is_ready()); + assert_eq!(cv.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + let batch = CoefficientOfVariation::new(14).unwrap().batch(&prices); + let mut b = CoefficientOfVariation::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/cointegration.rs b/crates/wickra-core/src/indicators/cointegration.rs new file mode 100644 index 0000000..dfee22b --- /dev/null +++ b/crates/wickra-core/src/indicators/cointegration.rs @@ -0,0 +1,461 @@ +//! Cointegration — rolling Engle–Granger hedge ratio plus an ADF stationarity test. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Output of [`Cointegration`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CointegrationOutput { + /// Engle–Granger hedge ratio `β`: the rolling OLS slope of `a` on `b`. + pub hedge_ratio: f64, + /// The current spread (regression residual) `a − (α + β·b)`. + pub spread: f64, + /// Augmented Dickey–Fuller `t`-statistic on the spread. **More negative** + /// means more strongly mean-reverting (cointegrated); compare against the + /// usual ADF/MacKinnon critical values (e.g. roughly `−2.9` at 5%). `0` + /// when the test is undefined (a degenerate, zero-variance spread). + pub adf_stat: f64, +} + +/// Rolling cointegration test for a pair of assets (Engle–Granger two-step). +/// +/// Each `update` receives one `(a, b)` pair (price levels, or log-levels if you +/// prefer). Over the trailing window of `period` pairs the indicator: +/// +/// 1. fits the **hedge ratio** `β` (and intercept `α`) by ordinary least +/// squares of `a` on `b`, and forms the **spread** `eₜ = aₜ − (α + β·bₜ)`; +/// 2. runs an **augmented Dickey–Fuller** test (no constant, no trend, with +/// `adf_lags` lagged differences) on the spread series and reports its +/// `t`-statistic. +/// +/// A strongly negative ADF statistic means the spread reverts to its mean — the +/// pair is cointegrated and the spread is tradeable. A statistic near zero +/// means the spread wanders like a random walk (no cointegration). This is the +/// classic pairs-trading screen: `β` tells you the hedge size, the spread is +/// what you trade, and the ADF statistic tells you whether it is worth trading. +/// +/// Each `update` is `O(period + adf_lags³)`: the hedge ratio is maintained from +/// running sums, while the spread series and the small ADF regression are +/// recomputed over the window — both bounded by the fixed parameters, not the +/// series length. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Cointegration, Indicator}; +/// +/// let mut c = Cointegration::new(30, 1).unwrap(); +/// let mut last = None; +/// for t in 0..60 { +/// let b = 100.0 + f64::from(t); +/// // `a` tracks 2·b with a small mean-reverting wobble ⇒ cointegrated. +/// let a = 2.0 * b + 5.0 + 0.5 * (f64::from(t) * 0.7).sin(); +/// last = c.update((a, b)); +/// } +/// let out = last.unwrap(); +/// assert!((out.hedge_ratio - 2.0).abs() < 0.1); +/// assert!(out.adf_stat < 0.0); // mean-reverting spread +/// ``` +#[derive(Debug, Clone)] +pub struct Cointegration { + period: usize, + adf_lags: usize, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl Cointegration { + /// Construct a new rolling cointegration test. + /// + /// `period` is the look-back window; `adf_lags` is the number of lagged + /// differences in the augmented Dickey–Fuller regression (`0` is the plain + /// Dickey–Fuller test). + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2·adf_lags + 4`, which is + /// the smallest window that leaves the ADF regression at least one degree + /// of freedom. + pub fn new(period: usize, adf_lags: usize) -> Result { + let min_period = 2 * adf_lags + 4; + if period < min_period { + return Err(Error::InvalidPeriod { + message: "cointegration needs period >= 2*adf_lags + 4", + }); + } + Ok(Self { + period, + adf_lags, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Look-back window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Number of lagged differences in the ADF regression. + pub const fn adf_lags(&self) -> usize { + self.adf_lags + } +} + +impl Indicator for Cointegration { + /// `(a, b)` price pair. + type Input = (f64, f64); + type Output = CointegrationOutput; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((a, b)); + self.sum_a += a; + self.sum_b += b; + self.sum_bb += b * b; + self.sum_ab += a * b; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0); + let (hedge_ratio, intercept) = if var_b == 0.0 { + // A flat `b` window has no defined slope; fall back to a level shift. + (0.0, mean_a) + } else { + let cov = self.sum_ab / n - mean_a * mean_b; + let beta = cov / var_b; + (beta, mean_a - beta * mean_b) + }; + // Build the spread (residual) series over the window, oldest → newest. + let spreads: Vec = self + .window + .iter() + .map(|&(ai, bi)| ai - (intercept + hedge_ratio * bi)) + .collect(); + let spread = *spreads.last().expect("window is full"); + let adf_stat = adf_no_constant(&spreads, self.adf_lags); + Some(CointegrationOutput { + hedge_ratio, + spread, + adf_stat, + }) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Cointegration" + } +} + +/// Solve the linear system `mat·x = rhs` for a small square system by Gaussian +/// elimination, returning `None` if the matrix is (numerically) singular. +/// +/// `mat` is row-major and consumed; `rhs` is the right-hand side. +fn solve(mut mat: Vec>, mut rhs: Vec) -> Option> { + let dim = rhs.len(); + for col in 0..dim { + let pivot = mat[col][col]; + if pivot.abs() < 1e-12 { + return None; + } + let pivot_row = mat[col].clone(); + for row in (col + 1)..dim { + let factor = mat[row][col] / pivot; + for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) { + *cell -= factor * above; + } + rhs[row] -= factor * rhs[col]; + } + } + let mut sol = vec![0.0; dim]; + for row in (0..dim).rev() { + let known: f64 = mat[row] + .iter() + .zip(&sol) + .skip(row + 1) + .map(|(coeff, value)| coeff * value) + .sum(); + sol[row] = (rhs[row] - known) / mat[row][row]; + } + Some(sol) +} + +/// Augmented Dickey–Fuller `t`-statistic on `series`, with `lags` lagged +/// differences and **no** constant or trend term (the Engle–Granger residual +/// form). Returns `0.0` when the regression is degenerate. +/// +/// The regression is `Δeₜ = ρ·eₜ₋₁ + Σ γᵢ·Δeₜ₋ᵢ + εₜ`; the reported statistic +/// is `ρ̂ / se(ρ̂)`. +fn adf_no_constant(series: &[f64], lags: usize) -> f64 { + let len = series.len(); + let num_reg = lags + 1; // regressors: eₜ₋₁ plus `lags` lagged differences + let first = lags + 1; // first usable observation index + if len <= first { + return 0.0; + } + let num_obs = len - first; + if num_obs <= num_reg { + return 0.0; // need at least one residual degree of freedom + } + let regressors = |idx: usize| -> Vec { + let mut row = vec![0.0; num_reg]; + row[0] = series[idx - 1]; + for lag in 1..=lags { + row[lag] = series[idx - lag] - series[idx - lag - 1]; + } + row + }; + let mut xtx = vec![vec![0.0; num_reg]; num_reg]; + let mut xty = vec![0.0; num_reg]; + for idx in first..len { + let diff = series[idx] - series[idx - 1]; + let row = regressors(idx); + for (ri, &left) in row.iter().enumerate() { + xty[ri] += left * diff; + for (ci, &right) in row.iter().enumerate() { + xtx[ri][ci] += left * right; + } + } + } + let Some(theta) = solve(xtx.clone(), xty) else { + return 0.0; + }; + let rho = theta[0]; + let mut rss = 0.0; + for idx in first..len { + let diff = series[idx] - series[idx - 1]; + let pred: f64 = regressors(idx) + .iter() + .zip(&theta) + .map(|(coeff, value)| coeff * value) + .sum(); + let resid = diff - pred; + rss += resid * resid; + } + let dof = (num_obs - num_reg) as f64; + let sigma2 = rss / dof; + // (XᵀX)⁻¹₀₀ from solving XᵀX·x = e₀. `xtx` is the same matrix the first + // solve already factored successfully, so this one cannot be singular. + let mut unit = vec![0.0; num_reg]; + unit[0] = 1.0; + let inverse = solve(xtx, unit).expect("xtx is non-singular: the coefficient solve succeeded"); + let var_rho = sigma2 * inverse[0]; + if var_rho <= 0.0 { + return 0.0; + } + rho / var_rho.sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_too_small_period() { + // period must be >= 2*lags + 4. + assert!(Cointegration::new(3, 0).is_err()); // needs >= 4 + assert!(Cointegration::new(4, 0).is_ok()); + assert!(Cointegration::new(5, 1).is_err()); // needs >= 6 + assert!(Cointegration::new(6, 1).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let c = Cointegration::new(30, 2).unwrap(); + assert_eq!(c.period(), 30); + assert_eq!(c.adf_lags(), 2); + assert_eq!(c.warmup_period(), 30); + assert_eq!(c.name(), "Cointegration"); + } + + #[test] + fn adf_guards_and_degenerate_spread() { + // Series too short for any observation ⇒ 0. + assert_eq!(adf_no_constant(&[1.0], 1), 0.0); + // Long enough but too few degrees of freedom ⇒ 0. + assert_eq!(adf_no_constant(&[1.0, 2.0, 3.0], 1), 0.0); + // A perfect deterministic AR(1) spread (eₜ = 0.5·eₜ₋₁) is fit exactly, + // so the residual variance — and hence the t-statistic — is 0. + let geom: Vec = (0..8).map(|t| 0.5_f64.powi(t)).collect(); + assert_eq!(adf_no_constant(&geom, 0), 0.0); + } + + #[test] + fn recovers_hedge_ratio() { + // a = 2·b + 5 + small wobble ⇒ β ≈ 2. + let pairs: Vec<(f64, f64)> = (0..60) + .map(|t| { + let b = 100.0 + f64::from(t); + let a = 2.0 * b + 5.0 + 0.4 * (f64::from(t) * 0.9).sin(); + (a, b) + }) + .collect(); + let out = Cointegration::new(30, 1) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!( + (out.hedge_ratio - 2.0).abs() < 0.1, + "beta {}", + out.hedge_ratio + ); + } + + #[test] + fn stationary_spread_is_strongly_negative() { + // A clean mean-reverting (sinusoidal) spread ⇒ very negative ADF. + let pairs: Vec<(f64, f64)> = (0..80) + .map(|t| { + let b = 50.0 + 0.5 * f64::from(t); + let a = 2.0 * b + 1.0 + 0.5 * (f64::from(t) * 0.6).sin(); + (a, b) + }) + .collect(); + let out = Cointegration::new(40, 1) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(out.adf_stat < -2.0, "adf {}", out.adf_stat); + } + + #[test] + fn perfect_cointegration_has_zero_spread_and_defined_ratio() { + // a = 2·b + 5 exactly ⇒ residuals all zero ⇒ ADF degenerate ⇒ 0. + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| { + let b = 100.0 + f64::from(t); + (2.0 * b + 5.0, b) + }) + .collect(); + let out = Cointegration::new(20, 1) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.hedge_ratio, 2.0, epsilon = 1e-9); + assert_relative_eq!(out.spread, 0.0, epsilon = 1e-6); + assert_relative_eq!(out.adf_stat, 0.0, epsilon = 1e-12); + } + + #[test] + fn flat_b_falls_back_to_level() { + // Constant b ⇒ no slope ⇒ hedge ratio 0, spread = a − mean(a). + let pairs: Vec<(f64, f64)> = (0..20) + .map(|t| (10.0 + 0.3 * (f64::from(t) * 0.5).sin(), 7.0)) + .collect(); + let out = Cointegration::new(10, 0) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.hedge_ratio, 0.0, epsilon = 1e-12); + } + + #[test] + fn plain_dickey_fuller_lags_zero() { + // Exercise the lags = 0 path (1×1 ADF system). + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| { + let b = 20.0 + 0.4 * f64::from(t); + let a = 1.5 * b + 0.6 * (f64::from(t) * 0.7).sin(); + (a, b) + }) + .collect(); + let out = Cointegration::new(20, 0) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!((out.hedge_ratio - 1.5).abs() < 0.1); + assert!(out.adf_stat < 0.0); + } + + #[test] + fn reset_clears_state() { + let mut c = Cointegration::new(10, 1).unwrap(); + for t in 0..20 { + let b = 100.0 + f64::from(t); + c.update((2.0 * b + (f64::from(t) * 0.5).sin(), b)); + } + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + assert_eq!(c.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|t| { + let b = 30.0 + 0.7 * f64::from(t); + let a = 1.8 * b + 2.0 + 0.5 * (f64::from(t) * 0.4).sin(); + (a, b) + }) + .collect(); + let batch = Cointegration::new(25, 2).unwrap().batch(&pairs); + let mut c = Cointegration::new(25, 2).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut c = Cointegration::new(4, 0).unwrap(); + assert_eq!(c.update((f64::NAN, 1.0)), None); + assert_eq!(c.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(c.update((1.0, 2.0)), None); + assert_eq!(c.update((2.0, 5.0)), None); + assert_eq!(c.update((3.0, 7.0)), None); + assert!(c.update((4.0, 11.0)).is_some()); + } +} 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 0000000..bbeec26 --- /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/composite_profile.rs b/crates/wickra-core/src/indicators/composite_profile.rs new file mode 100644 index 0000000..7290caa --- /dev/null +++ b/crates/wickra-core/src/indicators/composite_profile.rs @@ -0,0 +1,347 @@ +//! Composite Profile — POC and value area over a long composite window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`CompositeProfile`]: the point of control and the value-area bounds. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CompositeProfileOutput { + /// Point of Control — the price (bin centre) with the most volume. + pub poc: f64, + /// Value-Area High — top of the band holding `value_area_pct` of volume. + pub vah: f64, + /// Value-Area Low — bottom of that band. + pub val: f64, +} + +/// Composite Profile — a multi-session volume profile reduced to its **point of +/// control** and **value area**, built over a long composite window. +/// +/// ```text +/// build a `bins`-bucket volume profile over the last `period` candles +/// POC = bin with the most volume +/// expand from the POC, always adding the heavier adjacent bin, until the +/// accumulated volume reaches `value_area_pct` of the total +/// VAH / VAL = the highest / lowest price included +/// ``` +/// +/// A composite profile merges many sessions into one structure to reveal the +/// dominant value area and control price across a longer horizon — the levels that +/// matter for swing positioning rather than a single day. The point of control is +/// the fairest price (heaviest trade); the value area (classically 70% of volume) +/// brackets where the market spent most of its time. Price inside the value area is +/// "in balance"; acceptance outside it signals a value migration. +/// +/// The first value lands after `period` candles; each `update` rebuilds the +/// profile in O(`period · bins`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, CompositeProfile}; +/// +/// let mut indicator = CompositeProfile::new(100, 50, 0.70).unwrap(); +/// let mut last = None; +/// for i in 0..150 { +/// let base = 100.0 + (f64::from(i) * 0.1).sin() * 8.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CompositeProfile { + period: usize, + bins: usize, + value_area_pct: f64, + window: VecDeque, + last: Option, +} + +impl CompositeProfile { + /// Construct a Composite Profile. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero, or + /// [`Error::InvalidParameter`] if `value_area_pct` is not in `(0, 1]`. + pub fn new(period: usize, bins: usize, value_area_pct: f64) -> Result { + if period == 0 || bins == 0 { + return Err(Error::PeriodZero); + } + if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 { + return Err(Error::InvalidParameter { + message: "value_area_pct must be in (0, 1]", + }); + } + Ok(Self { + period, + bins, + value_area_pct, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, bins, value_area_pct)`. + pub const fn params(&self) -> (usize, usize, f64) { + (self.period, self.bins, self.value_area_pct) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn compute(&self) -> CompositeProfileOutput { + let mut low = f64::INFINITY; + let mut high = f64::NEG_INFINITY; + for c in &self.window { + low = low.min(c.low); + high = high.max(c.high); + } + let span = high - low; + if span <= 0.0 { + return CompositeProfileOutput { + poc: low, + vah: low, + val: low, + }; + } + let width = span / self.bins as f64; + let centre = |idx: usize| low + (idx as f64 + 0.5) * width; + let mut hist = vec![0.0; self.bins]; + for c in &self.window { + if c.volume == 0.0 { + continue; + } + let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1); + let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1); + let share = c.volume / (hi_idx - lo_idx + 1) as f64; + for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *bin += share; + } + } + let total: f64 = hist.iter().sum(); + let mut poc = 0; + let mut poc_vol = f64::NEG_INFINITY; + for (idx, &vol) in hist.iter().enumerate() { + if vol > poc_vol { + poc_vol = vol; + poc = idx; + } + } + let target = total * self.value_area_pct; + let mut acc = hist[poc]; + let mut top = poc; + let mut bottom = poc; + while acc < target && (top < self.bins - 1 || bottom > 0) { + let above = if top < self.bins - 1 { + hist[top + 1] + } else { + f64::NEG_INFINITY + }; + let below = if bottom > 0 { + hist[bottom - 1] + } else { + f64::NEG_INFINITY + }; + if above >= below { + top += 1; + acc += hist[top]; + } else { + bottom -= 1; + acc += hist[bottom]; + } + } + CompositeProfileOutput { + poc: centre(poc), + vah: centre(top), + val: centre(bottom), + } + } +} + +impl Indicator for CompositeProfile { + type Input = Candle; + type Output = CompositeProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "CompositeProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked( + f64::midpoint(high, low), + high, + low, + f64::midpoint(high, low), + volume, + 0, + ) + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + CompositeProfile::new(0, 50, 0.7), + Err(Error::PeriodZero) + )); + assert!(matches!( + CompositeProfile::new(100, 0, 0.7), + Err(Error::PeriodZero) + )); + assert!(matches!( + CompositeProfile::new(100, 50, 0.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + CompositeProfile::new(100, 50, 1.5), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let p = CompositeProfile::new(100, 50, 0.7).unwrap(); + assert_eq!(p.params(), (100, 50, 0.7)); + assert_eq!(p.warmup_period(), 100); + assert_eq!(p.name(), "CompositeProfile"); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut p = CompositeProfile::new(4, 8, 0.7).unwrap(); + let candles: Vec = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect(); + let out = p.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn value_area_brackets_poc() { + let mut p = CompositeProfile::new(20, 30, 0.7).unwrap(); + let candles: Vec = (0..40) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.3).sin() * 8.0, + 90.0 + (f64::from(i) * 0.3).cos() * 8.0, + 1_000.0, + ) + }) + .collect(); + for o in p.batch(&candles).into_iter().flatten() { + assert!(o.val <= o.poc && o.poc <= o.vah); + } + } + + #[test] + fn poc_at_heavy_cluster() { + // Volume clustered at ~100; thin pokes elsewhere -> POC near 100. + let mut p = CompositeProfile::new(6, 30, 0.7).unwrap(); + let mut candles: Vec = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect(); + candles.push(c(140.0, 60.0, 50.0)); + let out = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + (out.poc - 100.0).abs() < 5.0, + "POC should sit at the cluster, got {}", + out.poc + ); + } + + #[test] + fn reset_clears_state() { + let mut p = CompositeProfile::new(4, 8, 0.7).unwrap(); + p.batch(&[c(110.0, 90.0, 1_000.0); 6]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.25).sin() * 9.0, + 90.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = CompositeProfile::new(50, 50, 0.7).unwrap().batch(&candles); + let mut b = CompositeProfile::new(50, 50, 0.7).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn flat_window_collapses_to_price() { + // Zero high-low span returns the price for POC, VAH and VAL. + let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap(); + cp.update(c(50.0, 50.0, 10.0)); + let out = cp.update(c(50.0, 50.0, 10.0)).unwrap(); + assert_eq!(out.poc, out.vah); + assert_eq!(out.poc, out.val); + } + + #[test] + fn zero_volume_window_is_handled() { + // Non-flat window of zero-volume candles hits the skip path. + let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap(); + cp.update(c(60.0, 40.0, 0.0)); + assert!(cp.update(c(60.0, 40.0, 0.0)).is_some()); + } + + #[test] + fn value_area_expands_down_from_top_poc() { + // POC sits in the top bin; with a wide value-area target the area runs + // out of bins above (the ceiling branch) and keeps expanding downward. + let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap(); + cp.update(c(100.0, 0.0, 30.0)); // thin spread across all three bins + let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); // heavy in the top bin + assert!(out.val <= out.poc && out.poc <= out.vah); + } +} diff --git a/crates/wickra-core/src/indicators/concealing_baby_swallow.rs b/crates/wickra-core/src/indicators/concealing_baby_swallow.rs new file mode 100644 index 0000000..f33b5f8 --- /dev/null +++ b/crates/wickra-core/src/indicators/concealing_baby_swallow.rs @@ -0,0 +1,278 @@ +//! Concealing Baby Swallow candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Returns `true` when `candle` is a black marubozu: a down candle whose body fills +/// the range with negligible shadows on both ends. +fn black_marubozu(candle: Candle) -> bool { + let range = candle.high - candle.low; + if range <= 0.0 { + return false; + } + let upper = candle.high - candle.open; + let lower = candle.close - candle.low; + candle.open > candle.close && upper <= 0.05 * range && lower <= 0.05 * range +} + +/// Concealing Baby Swallow — a rare 4-bar bullish reversal. Two black marubozu lead +/// a steep decline; the third is a black candle that gaps down on the open yet +/// throws a long upper shadow back up into the second body; the fourth is a large +/// black candle that completely engulfs the third, shadows included. The relentless +/// selling that can no longer make ground signals capitulation. +/// +/// ```text +/// bar1, bar2 black marubozu (body == range, negligible shadows) +/// bar3 black, opens below bar2's body (open3 < close2) with an upper +/// shadow into it (high3 > close2) +/// bar4 black, engulfs bar3 including shadows: open4 > high3 and close4 < low3 +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Concealing Baby +/// Swallow is a single-direction (bullish-only) reversal, so it never emits `−1.0`. +/// The first three bars always return `0.0` because the four-bar window is not yet +/// filled. Body and shadow thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, ConcealingBabySwallow, Indicator}; +/// +/// let mut indicator = ConcealingBabySwallow::new(); +/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(16.0, 16.1, 11.9, 12.0, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(11.0, 13.0, 9.9, 10.0, 1.0, 2).unwrap()); +/// let out = indicator +/// .update(Candle::new(14.0, 14.1, 8.9, 9.0, 1.0, 3).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ConcealingBabySwallow { + c1: Option, + c2: Option, + c3: Option, + has_emitted: bool, +} + +impl ConcealingBabySwallow { + /// Construct a new Concealing Baby Swallow detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + c3: None, + has_emitted: false, + } + } +} + +impl Indicator for ConcealingBabySwallow { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3)) = (bar1, bar2, bar3) else { + return Some(0.0); + }; + // bar1 and bar2 are black marubozu. + if !black_marubozu(bar1) || !black_marubozu(bar2) { + return Some(0.0); + } + // bar3 is black, gaps down on the open, throws an upper shadow into bar2. + if bar3.open <= bar3.close { + return Some(0.0); + } + if bar3.open >= bar2.close { + return Some(0.0); // no downside open gap + } + if bar3.high <= bar2.close { + return Some(0.0); // upper shadow does not reach into bar2's body + } + // bar4 is black and engulfs bar3 including its shadows. + if candle.open <= candle.close { + return Some(0.0); + } + if candle.open > bar3.high && candle.close < bar3.low { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 4 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ConcealingBabySwallow" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ConcealingBabySwallow::new(); + assert_eq!(t.name(), "ConcealingBabySwallow"); + assert_eq!(t.warmup_period(), 4); + assert!(!t.is_ready()); + } + + #[test] + fn concealing_baby_swallow_is_plus_one() { + let mut t = ConcealingBabySwallow::new(); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 9.9, 10.0, 2)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(1.0)); + } + + #[test] + fn warmup_returns_zero() { + let mut t = ConcealingBabySwallow::new(); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 9.9, 10.0, 2)), Some(0.0)); + } + + #[test] + fn first_bar_not_marubozu_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + // bar1 white. + t.update(c(15.0, 20.1, 14.9, 20.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + t.update(c(11.0, 13.0, 9.9, 10.0, 2)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn first_bar_zero_range_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + // bar1 zero range -> not a marubozu. + t.update(c(15.0, 15.0, 15.0, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + t.update(c(11.0, 13.0, 9.9, 10.0, 2)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn second_bar_not_marubozu_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + // bar2 white. + t.update(c(12.0, 16.1, 11.9, 16.0, 1)); + t.update(c(11.0, 13.0, 9.9, 10.0, 2)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn third_bar_not_black_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + // bar3 white. + t.update(c(11.0, 13.0, 9.9, 12.5, 2)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn third_bar_no_gap_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + // bar3 black but opens at/above bar2's close -> no downside gap. + t.update(c(12.5, 13.0, 9.9, 10.0, 2)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn third_bar_no_upper_shadow_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + // bar3 black, gaps down, but its high does not reach into bar2's body. + t.update(c(11.0, 11.5, 9.9, 10.0, 2)); + assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn fourth_bar_not_black_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + t.update(c(11.0, 13.0, 9.9, 10.0, 2)); + // bar4 white. + assert_eq!(t.update(c(14.0, 14.1, 8.9, 14.05, 3)), Some(0.0)); + } + + #[test] + fn fourth_bar_not_engulfing_yields_zero() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + t.update(c(11.0, 13.0, 9.9, 10.0, 2)); + // bar4 black but does not engulf bar3's high. + assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 200.0 - i as f64; + c(base, base + 0.05, base - 5.0, base - 5.0, i) + }) + .collect(); + let mut a = ConcealingBabySwallow::new(); + let mut b = ConcealingBabySwallow::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ConcealingBabySwallow::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(16.0, 16.1, 11.9, 12.0, 1)); + t.update(c(11.0, 13.0, 9.9, 10.0, 2)); + t.update(c(14.0, 14.1, 8.9, 9.0, 3)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/conditional_value_at_risk.rs b/crates/wickra-core/src/indicators/conditional_value_at_risk.rs new file mode 100644 index 0000000..4565c52 --- /dev/null +++ b/crates/wickra-core/src/indicators/conditional_value_at_risk.rs @@ -0,0 +1,221 @@ +//! Rolling Conditional Value-at-Risk (`CVaR` / Expected Shortfall). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Conditional Value-at-Risk (Expected Shortfall). +/// +/// Where [`crate::ValueAtRisk`] reports the loss at the lower-tail quantile, +/// `CVaR` averages **all** returns below that quantile — the expected loss +/// conditional on being in the bad tail: +/// +/// ```text +/// q = 1 − confidence +/// tail = returns over window with rank fraction ≤ q +/// CVaR = − mean(tail) if mean is negative +/// CVaR = 0 otherwise +/// ``` +/// +/// The tail comprises the `floor(q · n)` smallest returns; if `floor` rounds +/// down to zero the smallest single return is used so the metric stays +/// defined for any `period ≥ 2`. Output is the magnitude of the expected +/// shortfall (sign-flipped to be non-negative). `CVaR` is by construction +/// `≥ VaR` because it averages losses *beyond* the `VaR` threshold. +/// +/// Each `update` is O(period · log period). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{ConditionalValueAtRisk, Indicator}; +/// +/// let mut c = ConditionalValueAtRisk::new(100, 0.95).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = c.update((f64::from(i) * 0.1).sin() * 0.02); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ConditionalValueAtRisk { + period: usize, + confidence: f64, + window: VecDeque, +} + +impl ConditionalValueAtRisk { + /// Construct a new rolling `CVaR`. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`, or if + /// `confidence` is outside `(0, 1)`. + pub fn new(period: usize, confidence: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "conditional value-at-risk needs period >= 2", + }); + } + if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 { + return Err(Error::InvalidPeriod { + message: "confidence must lie strictly between 0 and 1", + }); + } + Ok(Self { + period, + confidence, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured confidence level. + pub const fn confidence(&self) -> f64 { + self.confidence + } +} + +impl Indicator for ConditionalValueAtRisk { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut sorted: Vec = self.window.iter().copied().collect(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let q = 1.0 - self.confidence; + let n = sorted.len(); + // Number of samples in the tail. Floor, with a min of 1 so the + // expectation is always defined. + let k = ((q * n as f64).floor() as usize).max(1); + let tail = &sorted[..k]; + let mean = tail.iter().sum::() / k as f64; + Some((-mean).max(0.0)) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "ConditionalValueAtRisk" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + ConditionalValueAtRisk::new(1, 0.95), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ConditionalValueAtRisk::new(20, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ConditionalValueAtRisk::new(20, 1.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let c = ConditionalValueAtRisk::new(100, 0.95).unwrap(); + assert_eq!(c.period(), 100); + assert_relative_eq!(c.confidence(), 0.95, epsilon = 1e-12); + assert_eq!(c.name(), "ConditionalValueAtRisk"); + assert_eq!(c.warmup_period(), 100); + } + + #[test] + fn reference_value() { + // 20 returns -10..9 (each *0.01); confidence 0.95. + // q = 0.05, n = 20, k = floor(0.05*20) = 1. + // Tail = {-0.10}, CVaR = 0.10. + let mut c = ConditionalValueAtRisk::new(20, 0.95).unwrap(); + let returns: Vec = (-10..10).map(|i| f64::from(i) * 0.01).collect(); + let out = c.batch(&returns); + assert_relative_eq!(out[19].unwrap(), 0.10, epsilon = 1e-9); + } + + #[test] + fn cvar_geq_var_on_same_window() { + // Sanity: with confidence 0.9, the tail of 10 returns has 1 sample; + // VaR uses interpolation between 0 and 1, so CVaR (mean of just the + // worst) >= VaR. + use crate::ValueAtRisk; + let returns: Vec = vec![ + -0.05, -0.02, -0.01, 0.0, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05, + ]; + let mut v = ValueAtRisk::new(10, 0.9).unwrap(); + let mut c = ConditionalValueAtRisk::new(10, 0.9).unwrap(); + let v_out = v.batch(&returns); + let c_out = c.batch(&returns); + let var = v_out[9].unwrap(); + let cvar = c_out[9].unwrap(); + assert!(cvar >= var - 1e-12, "CVaR {cvar} should be >= VaR {var}"); + } + + #[test] + fn all_positive_returns_yield_zero() { + let mut c = ConditionalValueAtRisk::new(5, 0.95).unwrap(); + let out = c.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]); + assert_eq!(out[4], Some(0.0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap(); + assert_eq!(c.update(f64::NAN), None); + assert_eq!(c.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap(); + c.batch(&[-0.01, -0.02, -0.03]); + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + assert_eq!(c.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect(); + let batch = ConditionalValueAtRisk::new(10, 0.95) + .unwrap() + .batch(&returns); + let mut s = ConditionalValueAtRisk::new(10, 0.95).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/connors_rsi.rs b/crates/wickra-core/src/indicators/connors_rsi.rs new file mode 100644 index 0000000..843a1b6 --- /dev/null +++ b/crates/wickra-core/src/indicators/connors_rsi.rs @@ -0,0 +1,307 @@ +//! Connors RSI (CRSI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rsi::Rsi; +use crate::traits::Indicator; + +/// Larry Connors' RSI — average of three short-term mean-reversion components, +/// each individually bounded in `[0, 100]` so the aggregate is too: +/// +/// 1. `RSI(close, period_rsi)` — a fast `RSI` (Connors' default `3`). +/// 2. `RSI(streak, period_streak)` — `RSI` of the current up/down run length +/// (`+1, +2, ...` for consecutive up closes, `−1, −2, ...` for down closes, +/// `0` for unchanged). Connors' default `2`. +/// 3. `PercentRank(ROC(1), period_rank)` — the percentile rank of yesterday's +/// 1-period return in the last `period_rank` returns. Connors' default `100`. +/// +/// ```text +/// CRSI = (RSI(close)_t + RSI(streak)_t + PercentRank(roc1)_t) / 3 +/// ``` +/// +/// All three components live in `[0, 100]`, so `CRSI ∈ [0, 100]`. Connors' +/// trading rule of thumb: `CRSI < 5` is oversold, `CRSI > 95` is overbought +/// — both rare conditions, hence the short lookbacks. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{ConnorsRsi, Indicator}; +/// +/// let mut crsi = ConnorsRsi::classic(); +/// let mut last = None; +/// for i in 0..200 { +/// last = crsi.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ConnorsRsi { + period_rsi: usize, + period_streak: usize, + period_rank: usize, + rsi_close: Rsi, + rsi_streak: Rsi, + prev_price: Option, + streak: f64, + /// Rolling window of the last `period_rank` 1-period returns + /// (`(price_t − price_{t-1}) / price_{t-1}`). + rocs: VecDeque, + current: Option, +} + +impl ConnorsRsi { + /// # Errors + /// Returns [`Error::PeriodZero`] if any of the three periods is zero. + pub fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> Result { + if period_rsi == 0 || period_streak == 0 || period_rank == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period_rsi, + period_streak, + period_rank, + rsi_close: Rsi::new(period_rsi)?, + rsi_streak: Rsi::new(period_streak)?, + prev_price: None, + streak: 0.0, + rocs: VecDeque::with_capacity(period_rank), + current: None, + }) + } + + /// Connors' recommended defaults: `(period_rsi = 3, period_streak = 2, period_rank = 100)`. + pub fn classic() -> Self { + Self::new(3, 2, 100).expect("classic Connors RSI parameters are valid") + } + + /// Configured `(period_rsi, period_streak, period_rank)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.period_rsi, self.period_streak, self.period_rank) + } +} + +impl Indicator for ConnorsRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + // Run the close-RSI on every input so it warms up regardless of the + // streak / percent-rank branches. + let rsi_close = self.rsi_close.update(input); + + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + + // Update the up/down streak run length. + self.streak = if input > prev { + self.streak.max(0.0) + 1.0 + } else if input < prev { + self.streak.min(0.0) - 1.0 + } else { + 0.0 + }; + let rsi_streak = self.rsi_streak.update(self.streak); + + // 1-period return; defined only when the previous price is non-zero. + if prev != 0.0 { + let roc = (input - prev) / prev; + if self.rocs.len() == self.period_rank { + self.rocs.pop_front(); + } + self.rocs.push_back(roc); + } + self.prev_price = Some(input); + + // PercentRank emits once the ROC window has filled. + let percent_rank = if self.rocs.len() == self.period_rank { + let latest = *self.rocs.back().expect("non-empty window"); + let below = self.rocs.iter().filter(|&&r| r < latest).count(); + Some(100.0 * below as f64 / self.period_rank as f64) + } else { + None + }; + + let value = (rsi_close?, rsi_streak?, percent_rank?); + let crsi = (value.0 + value.1 + value.2) / 3.0; + self.current = Some(crsi); + Some(crsi) + } + + fn reset(&mut self) { + self.rsi_close.reset(); + self.rsi_streak.reset(); + self.prev_price = None; + self.streak = 0.0; + self.rocs.clear(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The slowest branch is the percent-rank: it needs period_rank + 1 + // prices (period_rank one-period returns). The close-RSI needs + // period_rsi + 1 prices and the streak-RSI needs period_streak + 1 + // streak values = period_streak + 2 prices. The rank branch dominates + // for Connors' defaults. + let rsi_close = self.period_rsi + 1; + let rsi_streak = self.period_streak + 2; + let rank = self.period_rank + 1; + rsi_close.max(rsi_streak).max(rank) + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "ConnorsRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero))); + assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero))); + assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let crsi = ConnorsRsi::classic(); + assert_eq!(crsi.periods(), (3, 2, 100)); + assert_eq!(crsi.name(), "ConnorsRSI"); + // Slowest branch: percent_rank with period_rank + 1 = 101. + assert_eq!(crsi.warmup_period(), 101); + } + + #[test] + fn classic_factory() { + assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100)); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + // Use small periods so the test is fast. + let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap(); + // Slowest: 5 + 1 = 6. + assert_eq!(crsi.warmup_period(), 6); + let prices: Vec = (1..=8).map(f64::from).collect(); + let out = crsi.batch(&prices); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn pure_uptrend_saturates_high() { + // A monotonic uptrend drives all three components toward 100: + // RSI of monotonic ups is 100, streak stays positive and growing so + // its RSI is 100, and every new 1-period return matches the prior + // ones so percent rank stabilises near 0 — but the average of all + // three still climbs well above 50. + let mut crsi = ConnorsRsi::classic(); + for i in 1..=200 { + crsi.update(f64::from(i)); + } + let v = crsi.current.unwrap(); + assert!( + v > 60.0, + "uptrend should drive Connors RSI well above 50: {v}" + ); + } + + #[test] + fn output_is_bounded() { + let mut crsi = ConnorsRsi::classic(); + let prices: Vec = (0..300) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0) + .collect(); + for v in crsi.batch(&prices).iter().flatten() { + assert!( + (0.0..=100.0).contains(v), + "Connors RSI out of [0, 100]: {v}" + ); + } + } + + #[test] + fn streak_resets_to_zero_on_unchanged_close() { + // Helper: feed a sequence and inspect the internal streak. + let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap(); + crsi.update(10.0); + crsi.update(11.0); + crsi.update(12.0); + assert_eq!(crsi.streak, 2.0); + crsi.update(12.0); + assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12); + crsi.update(11.0); + assert_eq!(crsi.streak, -1.0); + crsi.update(10.0); + assert_eq!(crsi.streak, -2.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = ConnorsRsi::classic(); + let mut b = ConnorsRsi::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut crsi = ConnorsRsi::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + crsi.batch(&prices); + assert!(crsi.is_ready()); + crsi.reset(); + assert!(!crsi.is_ready()); + assert_eq!(crsi.streak, 0.0); + assert!(crsi.prev_price.is_none()); + } + + #[test] + fn ignores_non_finite_input() { + let mut crsi = ConnorsRsi::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + crsi.batch(&prices); + let before = crsi.current; + assert_eq!(crsi.update(f64::NAN), before); + assert_eq!(crsi.update(f64::INFINITY), before); + } + + #[test] + fn zero_prev_skips_roc_update() { + // A previous price of 0.0 makes the 1-bar return undefined; the + // ROC ring buffer must be left unchanged on that step. Feeding + // 0.0 as the very first price seeds `prev_price = Some(0.0)`, so + // the next bar takes the `prev == 0.0` branch. + let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap(); + // Bar 1 seeds prev_price to 0.0. + crsi.update(0.0); + // Bar 2 must not push onto the ROC window; we cannot observe the + // ring directly but the indicator must not panic and must not + // emit until at least period_rank distinct non-zero returns have + // accumulated. + let after = crsi.update(1.0); + assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}"); + } +} diff --git a/crates/wickra-core/src/indicators/coppock.rs b/crates/wickra-core/src/indicators/coppock.rs new file mode 100644 index 0000000..398b1b2 --- /dev/null +++ b/crates/wickra-core/src/indicators/coppock.rs @@ -0,0 +1,254 @@ +//! Coppock Curve. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::{Roc, Wma}; + +/// Coppock Curve — Edwin Coppock's long-term momentum indicator. +/// +/// The Coppock Curve is a weighted moving average of the sum of two rates of +/// change: +/// +/// ```text +/// Coppock = WMA( ROC(long) + ROC(short), wma_period ) +/// ``` +/// +/// Coppock designed it (1962) as a long-horizon buy signal for stock indices: +/// on a monthly chart with the conventional `(long = 14, short = 11, +/// wma_period = 10)`, a turn upward from below zero has historically marked +/// the start of a new bull phase. The two ROCs blend a slightly longer and a +/// slightly shorter momentum horizon; the WMA smooths the result. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Coppock}; +/// +/// let mut indicator = Coppock::new(14, 11, 10).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Coppock { + roc_long_period: usize, + roc_short_period: usize, + wma_period: usize, + roc_long: Roc, + roc_short: Roc, + wma: Wma, + current: Option, +} + +impl Coppock { + /// Construct a new Coppock Curve with the two ROC periods and the WMA period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is `0`. + pub fn new(roc_long_period: usize, roc_short_period: usize, wma_period: usize) -> Result { + if roc_long_period == 0 || roc_short_period == 0 || wma_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + roc_long_period, + roc_short_period, + wma_period, + roc_long: Roc::new(roc_long_period)?, + roc_short: Roc::new(roc_short_period)?, + wma: Wma::new(wma_period)?, + current: None, + }) + } + + /// The `(roc_long, roc_short, wma)` periods. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.roc_long_period, self.roc_short_period, self.wma_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Coppock { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; no component is advanced. + return self.current; + } + let long = self.roc_long.update(input); + let short = self.roc_short.update(input); + let result = match (long, short) { + (Some(l), Some(s)) => self.wma.update(l + s), + _ => None, + }; + if result.is_some() { + self.current = result; + } + result + } + + fn reset(&mut self) { + self.roc_long.reset(); + self.roc_short.reset(); + self.wma.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // Let `L = max(roc_long_period, roc_short_period)` and `W = wma_period`. + // Both ROCs need `period + 1` inputs to emit; the slower one therefore + // first emits at **0-based index L** (= the `(L + 1)`-th input). From + // that bar onward both ROCs feed the WMA in lock-step, so the WMA + // sees its `W`-th input at 0-based index `L + W − 1` — the first bar + // it emits. `warmup_period` is the 1-based count of inputs needed for + // the first `Some` value, which is `(L + W − 1) + 1 = L + W`. + // + // Worked example for `Coppock::new(6, 4, 3)`: + // - ROC(6).first_some at index 6 (the 7th input). + // - ROC(4).first_some at index 4 (the 5th input). Both available + // from index 6 onward. + // - WMA(3) consumes 3 inputs at indices 6, 7, 8 → first WMA `Some` + // at index 8 (the 9th input). `warmup_period() == 9`. + self.roc_long_period.max(self.roc_short_period) + self.wma_period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "Coppock" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Coppock::new(0, 11, 10), Err(Error::PeriodZero))); + assert!(matches!(Coppock::new(14, 0, 10), Err(Error::PeriodZero))); + assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `periods` / `value` (lines 68-75) and the + /// Indicator-impl `name` body (128-130). Existing tests inspect numeric + /// output and `warmup_period` but never query the configured periods, + /// the current cached value, or the indicator name. + #[test] + fn accessors_and_metadata() { + let mut c = Coppock::new(14, 11, 10).unwrap(); + assert_eq!(c.periods(), (14, 11, 10)); + assert_eq!(c.name(), "Coppock"); + assert_eq!(c.value(), None); + // Drive past warmup so value() flips to Some. + for i in 1..=u32::try_from(c.warmup_period()).unwrap() { + c.update(100.0 + f64::from(i)); + } + assert!(c.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut c = Coppock::new(6, 4, 3).unwrap(); + assert_eq!(c.warmup_period(), 9); + let out = c.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().take(8) { + assert!(v.is_none()); + } + assert!(out[8].is_some()); + } + + /// `warmup_period()` equals the 1-based index of the first emitted + /// `Some` for every legal parameter combination — including the + /// parameter set `(roc_long=4, roc_short=2, wma=3)` that an external + /// audit claimed would prove the formula off by one. It does not: the + /// slower ROC first emits at 0-based index 4, the WMA needs 3 such inputs + /// and emits at 0-based index 6 (the 7th input), which is what + /// `roc_long.max(roc_short) + wma = max(4, 2) + 3 = 7` reports. + #[test] + fn warmup_period_matches_first_some_for_every_parameter_set() { + let prices: Vec = (1..=80).map(|i| 100.0 + f64::from(i)).collect(); + for &(long, short, wma) in &[(6, 4, 3), (14, 11, 10), (4, 2, 3), (10, 3, 5), (3, 3, 3)] { + let mut c = Coppock::new(long, short, wma).unwrap(); + let warmup = c.warmup_period(); + let out = c.batch(&prices); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!( + v.is_none(), + "Coppock({long}, {short}, {wma}): index {i} expected None during warmup, got {v:?}" + ); + } + assert!( + out[warmup - 1].is_some(), + "Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None", + ); + } + } + + #[test] + fn constant_series_yields_zero() { + // Both ROCs are 0 on a flat series, so the WMA of zeros is 0. + let mut c = Coppock::new(6, 4, 3).unwrap(); + let out = c.batch(&[100.0; 40]); + for v in out.iter().skip(c.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_is_positive() { + // A steady uptrend has positive ROCs, so the Coppock Curve is positive. + let mut c = Coppock::new(14, 11, 10).unwrap(); + let prices: Vec = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = c.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!( + *last > 0.0, + "uptrend Coppock should be positive, got {last}" + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut c = Coppock::new(6, 4, 3).unwrap(); + let out = c.batch(&(1..=40).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(c.update(f64::NAN), last); + assert_eq!(c.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut c = Coppock::new(6, 4, 3).unwrap(); + c.batch(&(1..=40).map(f64::from).collect::>()); + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + assert_eq!(c.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 10.0) + .collect(); + let batch = Coppock::new(14, 11, 10).unwrap().batch(&prices); + let mut b = Coppock::new(14, 11, 10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/correlation_trend_indicator.rs b/crates/wickra-core/src/indicators/correlation_trend_indicator.rs new file mode 100644 index 0000000..c3ab03e --- /dev/null +++ b/crates/wickra-core/src/indicators/correlation_trend_indicator.rs @@ -0,0 +1,258 @@ +//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time. +#![allow(clippy::doc_markdown)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation +/// coefficient between price and a perfectly straight ramp over the lookback. +/// +/// ```text +/// CTI = corr( price over the window , [0, 1, …, period−1] ) +/// ``` +/// +/// John Ehlers' CTI asks "how closely does recent price track a straight line?" +/// by correlating the windowed price against the time index itself. A reading near +/// `+1` means price is rising in a near-perfect line (strong uptrend); near `−1` +/// means a clean downtrend; near `0` means no linear trend (a range or choppy +/// market). Because correlation is scale- and offset-invariant, the slope's +/// steepness does not matter — only how *linear* the move is — which makes CTI an +/// unusually clean trend/range classifier. It differs from +/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a +/// *lagged copy of itself* rather than with time. +/// +/// The output is in `[−1, +1]`; a flat window (zero price variance) returns `0`. +/// The first value lands after `period` inputs; each `update` recomputes the +/// correlation over the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, CorrelationTrendIndicator}; +/// +/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct CorrelationTrendIndicator { + period: usize, + window: VecDeque, + last: Option, +} + +impl CorrelationTrendIndicator { + /// Construct a CTI over `period` bars. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two + /// points). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "CTI needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let n = self.period as f64; + let mut sum_x = 0.0; + let mut sum_xx = 0.0; + let mut sum_xt = 0.0; + for (i, &x) in self.window.iter().enumerate() { + let t = i as f64; + sum_x += x; + sum_xx += x * x; + sum_xt += x * t; + } + // Time index 0..n-1 has closed-form sums. + let sum_t = n * (n - 1.0) / 2.0; + let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + let cov = n * sum_xt - sum_x * sum_t; + let var_x = n * sum_xx - sum_x * sum_x; + let var_t = n * sum_tt - sum_t * sum_t; + let denom = (var_x * var_t).sqrt(); + if denom == 0.0 { + 0.0 + } else { + (cov / denom).clamp(-1.0, 1.0) + } + } +} + +impl Indicator for CorrelationTrendIndicator { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "CorrelationTrendIndicator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + CorrelationTrendIndicator::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(CorrelationTrendIndicator::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let cti = CorrelationTrendIndicator::new(20).unwrap(); + assert_eq!(cti.period(), 20); + assert_eq!(cti.warmup_period(), 20); + assert_eq!(cti.name(), "CorrelationTrendIndicator"); + assert!(!cti.is_ready()); + assert_eq!(cti.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut cti = CorrelationTrendIndicator::new(4).unwrap(); + let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn clean_uptrend_is_one() { + let mut cti = CorrelationTrendIndicator::new(10).unwrap(); + let last = cti + .batch(&(0..40).map(f64::from).collect::>()) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn clean_downtrend_is_minus_one() { + let mut cti = CorrelationTrendIndicator::new(10).unwrap(); + let last = cti + .batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::>()) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn flat_window_is_zero() { + let mut cti = CorrelationTrendIndicator::new(8).unwrap(); + let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut cti = CorrelationTrendIndicator::new(20).unwrap(); + for v in cti + .batch( + &(0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn ignores_non_finite() { + let mut cti = CorrelationTrendIndicator::new(4).unwrap(); + let ready = cti + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(cti.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut cti = CorrelationTrendIndicator::new(4).unwrap(); + cti.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(cti.is_ready()); + cti.reset(); + assert!(!cti.is_ready()); + assert_eq!(cti.value(), None); + assert_eq!(cti.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs); + let mut b = CorrelationTrendIndicator::new(20).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/counterattack.rs b/crates/wickra-core/src/indicators/counterattack.rs new file mode 100644 index 0000000..93afb83 --- /dev/null +++ b/crates/wickra-core/src/indicators/counterattack.rs @@ -0,0 +1,245 @@ +//! Counterattack candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Counterattack — a 2-bar reversal where the second bar storms back to close +/// right where the first bar closed. A long candle runs with the trend, then an +/// opposite-coloured long candle opens far in the trend direction and rallies (or +/// sells off) all the way back to the prior close — the two closes meeting forms +/// the "counterattack line". +/// +/// ```text +/// long bodies = |close − open| >= 0.5 * (high − low) (both bars) +/// equal closes = |close2 − close1| <= tol * mean(range1, range2) +/// bullish (+1.0): bar1 black (down), bar2 white (up), equal closes +/// bearish (−1.0): bar1 white (up), bar2 black (down), equal closes +/// ``` +/// +/// Output is `+1.0` bullish, `−1.0` bearish, and `0.0` when the bodies are short, +/// the colours match, or the closes are not level. The first bar always returns +/// `0.0` because the two-bar window is not yet filled. `equal_tolerance` defaults +/// to `0.05` (TA-Lib's `CDLCOUNTERATTACK` "equal" factor — 5 % of the mean bar +/// range) and must lie in `[0, 1)`. The body-length test uses a fixed half-range +/// fraction rather than TA-Lib's rolling body average, matching the geometric +/// house style of this pattern family. Pattern-shape check only — no trend filter +/// is applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Counterattack, Indicator}; +/// +/// let mut indicator = Counterattack::new(); +/// // Bullish: a long black bar, then a long white bar closing at the same level. +/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Counterattack { + equal_tolerance: f64, + prev: Option, + has_emitted: bool, +} + +impl Default for Counterattack { + fn default() -> Self { + Self::new() + } +} + +impl Counterattack { + /// Construct a Counterattack detector with the default 5 % equal-close tolerance. + pub const fn new() -> Self { + Self { + equal_tolerance: 0.05, + prev: None, + has_emitted: false, + } + } + + /// Construct a Counterattack detector with a custom equal-close tolerance. + /// + /// `equal_tolerance` is the fraction of the mean bar range within which the + /// two closes must agree and must lie in `[0, 1)`. + pub fn with_tolerance(equal_tolerance: f64) -> Result { + if !(0.0..1.0).contains(&equal_tolerance) { + return Err(Error::InvalidPeriod { + message: "counterattack equal tolerance must lie in [0, 1)", + }); + } + Ok(Self { + equal_tolerance, + prev: None, + has_emitted: false, + }) + } + + /// Configured equal-close tolerance. + pub fn equal_tolerance(&self) -> f64 { + self.equal_tolerance + } +} + +impl Indicator for Counterattack { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + let range2 = candle.high - candle.low; + let body1 = bar1.close - bar1.open; + let body2 = candle.close - candle.open; + let long1 = body1.abs() >= 0.5 * range1; + let long2 = body2.abs() >= 0.5 * range2; + let tol = self.equal_tolerance * 0.5 * (range1 + range2); + let equal_close = (candle.close - bar1.close).abs() <= tol; + if !(long1 && long2 && equal_close) { + return Some(0.0); + } + // Bullish: a long black bar met by a long white bar closing level. + if body1 < 0.0 && body2 > 0.0 { + return Some(1.0); + } + // Bearish: a long white bar met by a long black bar closing level. + if body1 > 0.0 && body2 < 0.0 { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Counterattack" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(Counterattack::with_tolerance(-0.01).is_err()); + assert!(Counterattack::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = Counterattack::with_tolerance(0.0).unwrap(); + assert!((t.equal_tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = Counterattack::default(); + assert_eq!(t.name(), "Counterattack"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + assert!((t.equal_tolerance() - 0.05).abs() < 1e-12); + } + + #[test] + fn bullish_counterattack_is_plus_one() { + let mut t = Counterattack::new(); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 1)), Some(1.0)); + } + + #[test] + fn bearish_counterattack_is_minus_one() { + let mut t = Counterattack::new(); + assert_eq!(t.update(c(15.0, 20.1, 14.9, 20.0, 0)), Some(0.0)); + assert_eq!(t.update(c(25.0, 25.1, 19.9, 20.0, 1)), Some(-1.0)); + } + + #[test] + fn unequal_close_yields_zero() { + let mut t = Counterattack::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + // Second close at 17.0 is far from the first close (15.0) -> not level. + assert_eq!(t.update(c(10.0, 17.1, 9.9, 17.0, 1)), Some(0.0)); + } + + #[test] + fn same_color_yields_zero() { + let mut t = Counterattack::new(); + // Both bars black -> not opposite colours. + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 1)), Some(0.0)); + } + + #[test] + fn short_body_yields_zero() { + let mut t = Counterattack::new(); + // Second bar has a tiny body relative to its range. + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + assert_eq!(t.update(c(14.8, 20.0, 9.9, 15.2, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = Counterattack::new(); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 2.0, base + 1.5, i) + }) + .collect(); + let mut a = Counterattack::new(); + let mut b = Counterattack::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Counterattack::new(); + t.update(c(20.0, 20.1, 14.9, 15.0, 0)); + t.update(c(10.0, 15.1, 9.9, 15.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/crab.rs b/crates/wickra-core/src/indicators/crab.rs new file mode 100644 index 0000000..2b692c0 --- /dev/null +++ b/crates/wickra-core/src/indicators/crab.rs @@ -0,0 +1,154 @@ +//! Crab harmonic pattern. + +use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Crab — a 5-point (X-A-B-C-D) harmonic pattern with the deepest D completion +/// of the family, an `1.618` extension of XA: +/// +/// ```text +/// AB / XA ∈ [0.382, 0.618] +/// BC / AB ∈ [0.382, 0.886] +/// CD / BC ∈ [2.24, 3.618] (a very long terminal leg) +/// AD / XA ∈ [1.55, 1.65] (≈ 1.618 — the defining D completion) +/// ``` +/// +/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high), +/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/crab.rs`. +#[derive(Debug, Clone)] +pub struct Crab { + swing: SwingTracker, + has_emitted: bool, +} + +impl Crab { + /// Construct a new Crab detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for Crab { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Crab { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let cd = (p.d - p.c).abs(); + let ad = (p.d - p.a).abs(); + let matched = ratios_in(&[ + (ab / xa, 0.382, 0.618), + (bc / ab, 0.382, 0.886), + (cd / bc, 2.24, 3.618), + (ad / xa, 1.55, 1.65), + ]); + if matched { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Crab" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Crab::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Crab::new(); + assert_eq!(indicator.name(), "Crab"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!Crab::default().is_ready()); + } + + #[test] + fn bullish_crab_is_plus_one() { + let out = run(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_crab_is_minus_one() { + let out = run(&[150.0, 110.0, 130.0, 112.5, 174.7]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn out_of_ratio_does_not_trigger() { + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Crab::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]); + let mut a = Crab::new(); + let mut b = Crab::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/cumulative_volume_index.rs b/crates/wickra-core/src/indicators/cumulative_volume_index.rs new file mode 100644 index 0000000..4250c00 --- /dev/null +++ b/crates/wickra-core/src/indicators/cumulative_volume_index.rs @@ -0,0 +1,163 @@ +//! Cumulative Volume Index — running total of volume-normalised net advancing volume. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net +/// advancing volume across a universe. +/// +/// On each [`CrossSection`] tick the increment is `(advancing volume - declining +/// volume) / total volume`: the share of the tick's total volume that flowed, +/// net, into advancing issues. The index accumulates this share over time. Where +/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and +/// so drifts with secular growth in trading activity — the CVI normalises each +/// tick by its own total volume, so a one-share-net day in a thin market counts +/// the same as in a heavy one. This keeps the index comparable across regimes of +/// very different volume. +/// +/// When a tick has zero total volume the net is necessarily zero too, so the +/// increment is zero and the index is unchanged (the divisor is floored to the +/// smallest positive `f64` purely to keep the division defined). +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member}; +/// +/// let mut cvi = CumulativeVolumeIndex::new(); +/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 150.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(cvi.update(tick), Some(0.5)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CumulativeVolumeIndex { + index: f64, + has_emitted: bool, +} + +impl CumulativeVolumeIndex { + /// Construct a new Cumulative Volume Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { + index: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for CumulativeVolumeIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancing_volume() - section.declining_volume(); + let total = section.total_volume().max(f64::MIN_POSITIVE); + self.index += net / total; + self.has_emitted = true; + Some(self.index) + } + + fn reset(&mut self) { + self.index = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CumulativeVolumeIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let cvi = CumulativeVolumeIndex::new(); + assert_eq!(cvi.name(), "CumulativeVolumeIndex"); + assert_eq!(cvi.warmup_period(), 1); + assert!(!cvi.is_ready()); + } + + #[test] + fn first_tick_emits_normalised_net() { + let mut cvi = CumulativeVolumeIndex::new(); + assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5)); + assert!(cvi.is_ready()); + } + + #[test] + fn index_accumulates_normalised_shares() { + let mut cvi = CumulativeVolumeIndex::new(); + assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5)); + // adv 60, dec 60, total 120 -> net 0 -> index unchanged. + assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5)); + } + + #[test] + fn zero_total_volume_leaves_index_unchanged() { + let mut cvi = CumulativeVolumeIndex::new(); + cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])); + // A tick with no volume at all: net 0 / floored divisor -> 0 increment. + assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5)); + } + + #[test] + fn reset_clears_state() { + let mut cvi = CumulativeVolumeIndex::new(); + cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])); + assert!(cvi.is_ready()); + cvi.reset(); + assert!(!cvi.is_ready()); + assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 150.0), (-1.0, 50.0)]), + tick(&[(1.0, 60.0), (-1.0, 60.0)]), + tick(&[(0.0, 0.0)]), + ]; + let mut a = CumulativeVolumeIndex::new(); + let mut b = CumulativeVolumeIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/cup_and_handle.rs b/crates/wickra-core/src/indicators/cup_and_handle.rs new file mode 100644 index 0000000..5a5544e --- /dev/null +++ b/crates/wickra-core/src/indicators/cup_and_handle.rs @@ -0,0 +1,176 @@ +//! Cup-and-Handle (and Inverse) continuation chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Cup-and-Handle / Inverse — a rounded base (the cup) followed by a shallow +/// pullback (the handle) near the rim, then a breakout in the cup's direction. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%) and read from the +/// last four pivots: +/// +/// ```text +/// cup-and-handle (bullish, +1): Rim(high) , Cup(low) , Rim(high) , Handle(low) +/// the two rims match (±3%) ; the handle low sits ABOVE the cup low (a shallow +/// pullback) and below the right rim +/// +/// inverse (bearish, -1): Rim(low) , Cap(high) , Rim(low) , Handle(high) +/// the two rims match ; the handle high sits BELOW the cap high and above the +/// right rim +/// ``` +/// +/// The shallow handle (closer to the rim than the cup extreme) is what +/// distinguishes a cup-and-handle from a plain double bottom/top. Output is +/// `+1.0` / `-1.0` / `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct CupAndHandle { + swing: SwingTracker, + has_emitted: bool, +} + +impl CupAndHandle { + /// Construct a new Cup-and-Handle detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for CupAndHandle { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for CupAndHandle { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let n = pivots.len(); + let rim_left = pivots[n - 4]; + let extreme = pivots[n - 3]; + let rim_right = pivots[n - 2]; + let handle = pivots[n - 1]; + let rims_match = approx_equal(rim_left.price, rim_right.price, LEVEL_TOLERANCE); + + if handle.direction < 0.0 { + // Bullish cup-and-handle: rims are highs, cup is the low between them, + // handle is a shallow low above the cup but below the right rim. + if rims_match && handle.price > extreme.price && handle.price < rim_right.price { + return Some(1.0); + } + } else if rims_match && handle.price < extreme.price && handle.price > rim_right.price { + // Inverse: rims are lows, cap is the high, handle a shallow high. + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CupAndHandle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = CupAndHandle::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = CupAndHandle::new(); + assert_eq!(indicator.name(), "CupAndHandle"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!CupAndHandle::default().is_ready()); + } + + #[test] + fn cup_and_handle_is_plus_one() { + // Rims 120/121, cup 90 (deep), handle 110 (shallow, above the cup). + let out = run(&[120.0, 90.0, 121.0, 110.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn inverse_cup_and_handle_is_minus_one() { + // Lead high then rims 100/101, cap 130, handle 110 (below cap, above rim). + let out = run(&[140.0, 100.0, 130.0, 101.0, 110.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn deep_handle_is_not_cup_and_handle() { + // Handle (85) below the cup low (90) → a double bottom, not cup-and-handle. + let out = run(&[120.0, 90.0, 121.0, 85.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn inverse_with_mismatched_rims_does_not_trigger() { + // Inverse shape (ends high) but the rims (100 / 90) diverge → enters the + // inverse branch yet reports no pattern. + let out = run(&[140.0, 100.0, 130.0, 90.0, 110.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = CupAndHandle::new(); + for c in candles_for_pivots(&[120.0, 90.0, 121.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 90.0, 121.0, 110.0]); + let mut a = CupAndHandle::new(); + let mut b = CupAndHandle::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/cvd.rs b/crates/wickra-core/src/indicators/cvd.rs new file mode 100644 index 0000000..908b284 --- /dev/null +++ b/crates/wickra-core/src/indicators/cvd.rs @@ -0,0 +1,127 @@ +//! Cumulative Volume Delta — running sum of signed trade volume. + +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Cumulative Volume Delta (CVD) — the running sum of [signed volume]. +/// +/// ```text +/// CVDₜ = CVDₜ₋₁ + sizeₜ · (+1 if buy, −1 if sell) +/// ``` +/// +/// CVD is an unbounded running total: a rising line signals net buying pressure +/// over the session, a falling line net selling. Divergence between CVD and +/// price is a classic absorption / exhaustion signal. Call [`reset`] at the +/// start of each session to re-anchor the cumulative total at zero. +/// +/// `Input = Trade`, `Output = f64`. Ready after the first trade. +/// +/// [signed volume]: crate::SignedVolume +/// [`reset`]: crate::Indicator::reset +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CumulativeVolumeDelta, Indicator, Side, Trade}; +/// +/// let mut cvd = CumulativeVolumeDelta::new(); +/// assert_eq!(cvd.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), Some(5.0)); +/// assert_eq!(cvd.update(Trade::new(100.0, 2.0, Side::Sell, 1).unwrap()), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CumulativeVolumeDelta { + cumulative: f64, + has_emitted: bool, +} + +impl CumulativeVolumeDelta { + /// Construct a new CVD indicator with a zero running total. + pub const fn new() -> Self { + Self { + cumulative: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for CumulativeVolumeDelta { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + self.has_emitted = true; + self.cumulative += trade.size * trade.side.sign(); + Some(self.cumulative) + } + + fn reset(&mut self) { + self.cumulative = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CumulativeVolumeDelta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(size: f64, side: Side, ts: i64) -> Trade { + Trade::new(100.0, size, side, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let cvd = CumulativeVolumeDelta::new(); + assert_eq!(cvd.name(), "CumulativeVolumeDelta"); + assert_eq!(cvd.warmup_period(), 1); + assert!(!cvd.is_ready()); + } + + #[test] + fn accumulates_signed_volume() { + let mut cvd = CumulativeVolumeDelta::new(); + assert_eq!(cvd.update(trade(5.0, Side::Buy, 0)), Some(5.0)); + assert_eq!(cvd.update(trade(2.0, Side::Sell, 1)), Some(3.0)); + assert_eq!(cvd.update(trade(4.0, Side::Sell, 2)), Some(-1.0)); + assert!(cvd.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..20) + .map(|i| { + let side = if i % 3 == 0 { Side::Sell } else { Side::Buy }; + trade(1.0 + (i % 4) as f64, side, i) + }) + .collect(); + let mut a = CumulativeVolumeDelta::new(); + let mut b = CumulativeVolumeDelta::new(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_re_anchors_at_zero() { + let mut cvd = CumulativeVolumeDelta::new(); + cvd.update(trade(5.0, Side::Buy, 0)); + cvd.reset(); + assert!(!cvd.is_ready()); + // After reset the running total starts again from zero. + assert_eq!(cvd.update(trade(2.0, Side::Buy, 1)), Some(2.0)); + } +} diff --git a/crates/wickra-core/src/indicators/cybernetic_cycle.rs b/crates/wickra-core/src/indicators/cybernetic_cycle.rs new file mode 100644 index 0000000..e2655df --- /dev/null +++ b/crates/wickra-core/src/indicators/cybernetic_cycle.rs @@ -0,0 +1,240 @@ +//! Ehlers Cybernetic Cycle Component. +#![allow(clippy::doc_markdown)] + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Cybernetic Cycle Component (CCC). +/// +/// Classic EasyLanguage construct from *Cybernetic Analysis for Stocks and +/// Futures* (Ehlers 2004, ch. 4): +/// +/// ```text +/// smooth[t] = (x[t] + 2*x[t-1] + 2*x[t-2] + x[t-3]) / 6 +/// cycle[t] = (1 - alpha/2)^2 * (smooth[t] - 2*smooth[t-1] + smooth[t-2]) +/// + 2 * (1 - alpha) * cycle[t-1] +/// - (1 - alpha)^2 * cycle[t-2] +/// ``` +/// +/// The result is a near-zero-mean oscillator that tracks the dominant cycle +/// component while filtering trend. `alpha` is a smoothing fraction in +/// `(0, 1]`; Ehlers recommends `2 / (period + 1)` for a given critical period. +/// +/// The first six outputs follow Ehlers' "use the input directly" initial +/// condition so downstream consumers stay reactive. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, CyberneticCycle}; +/// +/// let mut cc = CyberneticCycle::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// last = cc.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CyberneticCycle { + period: usize, + alpha: f64, + in_buf: [Option; 4], + smooth_buf: [Option; 3], + cycle_buf: [Option; 3], + count: usize, + last_value: Option, +} + +impl CyberneticCycle { + /// Construct with the dominant-cycle period (alpha = 2 / (period + 1)). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let alpha = 2.0 / (period as f64 + 1.0); + Ok(Self { + period, + alpha, + in_buf: [None; 4], + smooth_buf: [None; 3], + cycle_buf: [None; 3], + count: 0, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Smoothing alpha. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// Shift in `x` at position 0 of a 3-slot buffer. + fn push3(buf: &mut [Option; 3], x: f64) { + buf[2] = buf[1]; + buf[1] = buf[0]; + buf[0] = Some(x); + } + fn push4(buf: &mut [Option; 4], x: f64) { + buf[3] = buf[2]; + buf[2] = buf[1]; + buf[1] = buf[0]; + buf[0] = Some(x); + } +} + +impl Indicator for CyberneticCycle { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + Self::push4(&mut self.in_buf, input); + + // Smooth needs four prior inputs (positions 0..=3). + let smooth = if let (Some(a), Some(b), Some(c), Some(d)) = ( + self.in_buf[0], + self.in_buf[1], + self.in_buf[2], + self.in_buf[3], + ) { + (a + 2.0 * b + 2.0 * c + d) / 6.0 + } else { + // Initial condition: use the raw input. + input + }; + Self::push3(&mut self.smooth_buf, smooth); + + // Cycle needs two prior smooths and two prior cycles. + let one_minus_half_alpha = 1.0 - self.alpha / 2.0; + let one_minus_alpha = 1.0 - self.alpha; + let drv = one_minus_half_alpha * one_minus_half_alpha; + + // The 3-slot `smooth_buf` and `cycle_buf` ring buffers fill within a + // few updates, so the pattern match only fails during warmup. The + // `else` branch is therefore the Ehlers initial condition: the + // second-difference of the raw input series, scaled by 0.5 — matches + // the EasyLanguage implementation's first-bar fallback. + let cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = ( + self.smooth_buf[0], + self.smooth_buf[1], + self.smooth_buf[2], + self.cycle_buf[0], + self.cycle_buf[1], + ) { + drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1 + - one_minus_alpha * one_minus_alpha * c2 + } else { + let (x0, x1, x2) = ( + self.in_buf[0].unwrap_or(input), + self.in_buf[1].unwrap_or(input), + self.in_buf[2].unwrap_or(input), + ); + (x0 - 2.0 * x1 + x2) / 4.0 + }; + + Self::push3(&mut self.cycle_buf, cycle); + self.last_value = Some(cycle); + Some(cycle) + } + + fn reset(&mut self) { + self.in_buf = [None; 4]; + self.smooth_buf = [None; 3]; + self.cycle_buf = [None; 3]; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "CyberneticCycle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(CyberneticCycle::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut cc = CyberneticCycle::new(10).unwrap(); + assert_eq!(cc.period(), 10); + assert_relative_eq!(cc.alpha(), 2.0 / 11.0, epsilon = 1e-15); + assert_eq!(cc.warmup_period(), 1); + assert_eq!(cc.name(), "CyberneticCycle"); + assert!(!cc.is_ready()); + cc.update(100.0); + assert!(cc.is_ready()); + } + + #[test] + fn constant_series_converges_to_zero() { + let mut cc = CyberneticCycle::new(10).unwrap(); + let out = cc.batch(&[50.0_f64; 200]); + for x in out.iter().skip(50).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0) + .collect(); + let mut a = CyberneticCycle::new(15).unwrap(); + let mut b = CyberneticCycle::new(15).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut cc = CyberneticCycle::new(10).unwrap(); + cc.batch(&(1..=30).map(f64::from).collect::>()); + let before = cc.value(); + assert!(before.is_some()); + assert_eq!(cc.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut cc = CyberneticCycle::new(10).unwrap(); + cc.batch(&(1..=30).map(f64::from).collect::>()); + assert!(cc.is_ready()); + cc.reset(); + assert!(!cc.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/cypher.rs b/crates/wickra-core/src/indicators/cypher.rs new file mode 100644 index 0000000..57c017e --- /dev/null +++ b/crates/wickra-core/src/indicators/cypher.rs @@ -0,0 +1,152 @@ +//! Cypher harmonic pattern. + +use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Cypher — a 5-point (X-A-B-C-D) harmonic pattern whose C leg is measured +/// against XA (not AB) and whose D retraces the XC leg by `0.786`: +/// +/// ```text +/// AB / XA ∈ [0.382, 0.618] +/// BC / XA ∈ [1.13, 1.414] (C extends beyond A, measured on XA) +/// CD / XC ∈ [0.74, 0.83] (≈ 0.786 retracement of XC — the D completion) +/// ``` +/// +/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high), +/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/cypher.rs`. +#[derive(Debug, Clone)] +pub struct Cypher { + swing: SwingTracker, + has_emitted: bool, +} + +impl Cypher { + /// Construct a new Cypher detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for Cypher { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Cypher { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let xc = (p.c - p.x).abs(); + let cd = (p.d - p.c).abs(); + let matched = ratios_in(&[ + (ab / xa, 0.382, 0.618), + (bc / xa, 1.13, 1.414), + (cd / xc, 0.74, 0.83), + ]); + if matched { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Cypher" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Cypher::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Cypher::new(); + assert_eq!(indicator.name(), "Cypher"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!Cypher::default().is_ready()); + } + + #[test] + fn bullish_cypher_is_plus_one() { + let out = run(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_cypher_is_minus_one() { + let out = run(&[150.0, 110.0, 130.0, 82.0, 135.45]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn out_of_ratio_does_not_trigger() { + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Cypher::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]); + let mut a = Cypher::new(); + let mut b = Cypher::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/day_of_week_profile.rs b/crates/wickra-core/src/indicators/day_of_week_profile.rs new file mode 100644 index 0000000..8a312be --- /dev/null +++ b/crates/wickra-core/src/indicators/day_of_week_profile.rs @@ -0,0 +1,202 @@ +//! Day-of-Week Profile — the mean bar return for each weekday. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +const DAYS: usize = 7; + +/// Day-of-Week Profile output: the per-weekday mean return. +/// +/// `bins[i]` is the mean simple return of all bars whose local weekday was `i`, +/// with Monday as `0` through Sunday as `6`. Weekdays with no bars read `0.0`. +#[derive(Debug, Clone, PartialEq)] +pub struct DayOfWeekProfileOutput { + /// Per-weekday mean return, Monday first. Always length 7. + pub bins: Vec, +} + +/// Mean bar return bucketed by local weekday (Monday `0` .. Sunday `6`). +/// +/// Each bar's simple return `close / previous_close - 1` is accumulated into the +/// bucket of its local weekday (the wall-clock day of +/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`), and the +/// profile reports the running mean per weekday. The first bar produces no output. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, DayOfWeekProfile}; +/// +/// let day = 24 * 3_600_000; +/// let mut prof = DayOfWeekProfile::new(0); +/// // 1970-01-01 was a Thursday (weekday 3). +/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none()); +/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, day).unwrap()).unwrap(); +/// assert_eq!(out.bins.len(), 7); +/// ``` +#[derive(Debug, Clone)] +pub struct DayOfWeekProfile { + utc_offset_minutes: i32, + prev_close: Option, + sum: [f64; DAYS], + count: [u64; DAYS], + last: Option, +} + +impl DayOfWeekProfile { + /// Construct a Day-of-Week Profile with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + prev_close: None, + sum: [0.0; DAYS], + count: [0; DAYS], + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent profile if at least one return has been recorded. + pub fn value(&self) -> Option<&DayOfWeekProfileOutput> { + self.last.as_ref() + } + + fn snapshot(&self) -> DayOfWeekProfileOutput { + let bins = self + .sum + .iter() + .zip(&self.count) + .map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 }) + .collect(); + DayOfWeekProfileOutput { bins } + } +} + +impl Indicator for DayOfWeekProfile { + type Input = Candle; + type Output = DayOfWeekProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let result = if let Some(prev) = self.prev_close { + let ret = if prev == 0.0 { + 0.0 + } else { + candle.close / prev - 1.0 + }; + let day = civil.weekday as usize; + self.sum[day] += ret; + self.count[day] += 1; + let out = self.snapshot(); + self.last = Some(out.clone()); + Some(out) + } else { + None + }; + self.prev_close = Some(candle.close); + result + } + + fn reset(&mut self) { + self.prev_close = None; + self.sum = [0.0; DAYS]; + self.count = [0; DAYS]; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "DayOfWeekProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const DAY: i64 = 24 * 3_600_000; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let prof = DayOfWeekProfile::new(60); + assert_eq!(prof.utc_offset_minutes(), 60); + assert_eq!(prof.name(), "DayOfWeekProfile"); + assert_eq!(prof.warmup_period(), 2); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + } + + #[test] + fn buckets_by_weekday() { + let mut prof = DayOfWeekProfile::new(0); + // 1970-01-01 Thursday (3); 01-02 Friday (4). + assert!(prof.update(c(100.0, 0)).is_none()); + let out = prof.update(c(101.0, DAY)).unwrap(); // Friday return +0.01 + assert_eq!(out.bins.len(), 7); + assert_relative_eq!(out.bins[4], 0.01); // Friday + assert_relative_eq!(out.bins[3], 0.0); // Thursday had no return + assert!(prof.is_ready()); + } + + #[test] + fn averages_same_weekday_across_weeks() { + let mut prof = DayOfWeekProfile::new(0); + prof.update(c(100.0, 0)); // Thu + prof.update(c(101.0, DAY)); // Fri +0.01 + // Jump to next Friday (7 days later from day 0 -> +7 days, weekday 4). + prof.update(c(100.0, 7 * DAY)); // Thu+? actually day 7 -> weekday (7+3)%7=3 Thu + let out = prof.update(c(103.0, 8 * DAY)).unwrap(); // day 8 -> Fri, return + // Friday now has two samples; both positive. + assert!(out.bins[4] > 0.0); + } + + #[test] + fn zero_prev_close_uses_zero_return() { + let mut prof = DayOfWeekProfile::new(0); + prof.update(c(0.0, 0)); + let out = prof.update(c(5.0, DAY)).unwrap(); + assert_relative_eq!(out.bins[4], 0.0); // Friday, guarded return 0 + } + + #[test] + fn reset_clears_state() { + let mut prof = DayOfWeekProfile::new(0); + prof.update(c(100.0, 0)); + prof.update(c(101.0, DAY)); + prof.reset(); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + assert!(prof.update(c(100.0, 2 * DAY)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| c(100.0 + f64::from(i % 5), i64::from(i) * DAY)) + .collect(); + let mut a = DayOfWeekProfile::new(0); + let mut b = DayOfWeekProfile::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/decycler.rs b/crates/wickra-core/src/indicators/decycler.rs new file mode 100644 index 0000000..85fd511 --- /dev/null +++ b/crates/wickra-core/src/indicators/decycler.rs @@ -0,0 +1,213 @@ +//! Ehlers Decycler (single-pole high-pass complement). + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Decycler: price minus the dominant cycle component. +/// +/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole +/// high-pass filter with critical period `period`. Subtracting the high-pass +/// from the raw price leaves the slow component — equivalent to a smoothed +/// trend line with no group delay at low frequencies. From *Cycle Analytics +/// for Traders* (Ehlers 2013, ch. 4). +/// +/// The high-pass uses the standard 2-pole formulation: +/// +/// ```text +/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period) +/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2]) +/// + 2*(1 - alpha) * HP[t-1] +/// - (1 - alpha)^2 * HP[t-2] +/// ``` +/// +/// The first two outputs simply equal the input (warmup buffering), which is +/// the conventional Ehlers initialisation and keeps downstream consumers +/// reactive while the recursion fills. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Decycler}; +/// +/// let mut dc = Decycler::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..50 { +/// last = dc.update(100.0 + f64::from(i) * 0.5); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Decycler { + period: usize, + alpha: f64, + prev_in_1: Option, + prev_in_2: Option, + prev_hp_1: f64, + prev_hp_2: f64, + last_value: Option, +} + +impl Decycler { + /// Construct a Decycler with the given critical period for the high-pass filter. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let arg = 0.707 * 2.0 * PI / period as f64; + let c = arg.cos(); + let alpha = (c + arg.sin() - 1.0) / c; + Ok(Self { + period, + alpha, + prev_in_1: None, + prev_in_2: None, + prev_hp_1: 0.0, + prev_hp_2: 0.0, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// High-pass `alpha` coefficient derived from the period. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current decycler value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// Compute and store the high-pass output for the latest input. + fn step_hp(&mut self, input: f64) -> f64 { + let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else { + self.prev_hp_2 = self.prev_hp_1; + self.prev_hp_1 = 0.0; + return 0.0; + }; + let one_minus_half_alpha = 1.0 - self.alpha / 2.0; + let one_minus_alpha = 1.0 - self.alpha; + let drv = one_minus_half_alpha * one_minus_half_alpha; + let term1 = drv * (input - 2.0 * x1 + x2); + let term2 = 2.0 * one_minus_alpha * self.prev_hp_1; + let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2; + let hp = term1 + term2 - term3; + self.prev_hp_2 = self.prev_hp_1; + self.prev_hp_1 = hp; + hp + } +} + +impl Indicator for Decycler { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let hp = self.step_hp(input); + let v = input - hp; + self.prev_in_2 = self.prev_in_1; + self.prev_in_1 = Some(input); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev_in_1 = None; + self.prev_in_2 = None; + self.prev_hp_1 = 0.0; + self.prev_hp_2 = 0.0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "Decycler" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Decycler::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut dc = Decycler::new(20).unwrap(); + assert_eq!(dc.period(), 20); + assert_eq!(dc.warmup_period(), 1); + assert_eq!(dc.name(), "Decycler"); + assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0); + assert!(!dc.is_ready()); + dc.update(100.0); + assert!(dc.is_ready()); + assert!(dc.value().is_some()); + } + + #[test] + fn constant_series_passes_through() { + // For a flat input, the high-pass output is zero, so the decycler + // equals the input. + let mut dc = Decycler::new(20).unwrap(); + let out = dc.batch(&[42.0_f64; 80]); + for x in out.iter().flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0) + .collect(); + let mut a = Decycler::new(20).unwrap(); + let mut b = Decycler::new(20).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut dc = Decycler::new(20).unwrap(); + dc.batch(&(1..=30).map(f64::from).collect::>()); + let before = dc.value(); + assert!(before.is_some()); + assert_eq!(dc.update(f64::NAN), before); + assert_eq!(dc.update(f64::INFINITY), before); + } + + #[test] + fn reset_clears_state() { + let mut dc = Decycler::new(20).unwrap(); + dc.batch(&(1..=40).map(f64::from).collect::>()); + assert!(dc.is_ready()); + dc.reset(); + assert!(!dc.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/decycler_oscillator.rs b/crates/wickra-core/src/indicators/decycler_oscillator.rs new file mode 100644 index 0000000..287066f --- /dev/null +++ b/crates/wickra-core/src/indicators/decycler_oscillator.rs @@ -0,0 +1,182 @@ +//! Ehlers Decycler Oscillator (difference of two decyclers). + +use crate::error::{Error, Result}; +use crate::indicators::decycler::Decycler; +use crate::traits::Indicator; + +/// Difference between a fast and a slow [`Decycler`], producing a smoothed +/// oscillator that crosses zero at trend changes. +/// +/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`. +/// The construct removes the trend component that both decyclers share, leaving +/// the medium-frequency cycle band — analogous in spirit to MACD but with +/// Ehlers' zero-lag high-pass filters instead of EMAs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, DecyclerOscillator}; +/// +/// let mut dco = DecyclerOscillator::new(10, 30).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DecyclerOscillator { + fast: Decycler, + slow: Decycler, + last_value: Option, +} + +impl DecyclerOscillator { + /// Construct with the fast and slow periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is zero, and + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "fast period must be strictly less than slow period", + }); + } + Ok(Self { + fast: Decycler::new(fast)?, + slow: Decycler::new(slow)?, + last_value: None, + }) + } + + /// Configured `(fast, slow)` periods. + pub fn periods(&self) -> (usize, usize) { + (self.fast.period(), self.slow.period()) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for DecyclerOscillator { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + // Both child `Decycler` instances emit `Some` from the first bar + // (Ehlers' convention is "output = input" until the recursion warms), + // so the pair is always populated and the `?` short-circuit never + // fires in practice. + let f = self.fast.update(input)?; + let s = self.slow.update(input)?; + let v = f - s; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.fast.warmup_period().max(self.slow.warmup_period()) + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "DecyclerOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_invalid_periods() { + assert!(matches!( + DecyclerOscillator::new(0, 20), + Err(Error::PeriodZero) + )); + assert!(matches!( + DecyclerOscillator::new(10, 0), + Err(Error::PeriodZero) + )); + assert!(matches!( + DecyclerOscillator::new(20, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + DecyclerOscillator::new(10, 10), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + assert_eq!(dco.periods(), (10, 30)); + assert_eq!(dco.name(), "DecyclerOscillator"); + assert!(dco.warmup_period() >= 1); + assert!(!dco.is_ready()); + dco.update(100.0); + assert!(dco.is_ready()); + assert!(dco.value().is_some()); + } + + #[test] + fn constant_series_yields_zero() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + let out = dco.batch(&[42.0_f64; 80]); + for x in out.iter().flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0) + .collect(); + let mut a = DecyclerOscillator::new(10, 30).unwrap(); + let mut b = DecyclerOscillator::new(10, 30).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + dco.batch(&(1..=50).map(f64::from).collect::>()); + let before = dco.value(); + assert!(before.is_some()); + assert_eq!(dco.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut dco = DecyclerOscillator::new(10, 30).unwrap(); + dco.batch(&(1..=50).map(f64::from).collect::>()); + assert!(dco.is_ready()); + dco.reset(); + assert!(!dco.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/dema.rs b/crates/wickra-core/src/indicators/dema.rs new file mode 100644 index 0000000..4f0d974 --- /dev/null +++ b/crates/wickra-core/src/indicators/dema.rs @@ -0,0 +1,143 @@ +//! Double Exponential Moving Average (DEMA). + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Double Exponential Moving Average: `2 * EMA - EMA(EMA)`. +/// +/// Designed by Patrick Mulloy to reduce the lag of a single EMA while keeping +/// the smoothing benefit. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Dema}; +/// +/// let mut indicator = Dema::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Dema { + ema1: Ema, + ema2: Ema, + period: usize, +} + +impl Dema { + /// # Errors + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + ema1: Ema::new(period)?, + ema2: Ema::new(period)?, + period, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Dema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let e1 = self.ema1.update(input)?; + let e2 = self.ema2.update(e1)?; + Some(2.0 * e1 - e2) + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + } + + fn warmup_period(&self) -> usize { + // EMA1 seeds at period, then EMA2 needs another (period - 1) values to seed. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.ema2.is_ready() + } + + fn name(&self) -> &'static str { + "DEMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_constant_dema() { + let mut dema = Dema::new(5).unwrap(); + let out = dema.batch(&[100.0_f64; 60]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 100.0, epsilon = 1e-9); + } + + #[test] + fn linear_uptrend_dema_above_ema_eventually() { + // On a linear uptrend DEMA should be ahead of (greater than) a plain EMA, + // because the second-order correction removes lag. + let prices: Vec = (1..=200).map(f64::from).collect(); + let mut dema = Dema::new(20).unwrap(); + let mut ema = Ema::new(20).unwrap(); + let dema_out = dema.batch(&prices); + let ema_out = ema.batch(&prices); + // Compare at the last index where both are ready. + let d = dema_out.last().unwrap().unwrap(); + let e = ema_out.last().unwrap().unwrap(); + assert!(d > e, "DEMA={d} should exceed EMA={e} on uptrend"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80).map(|i| f64::from(i) * 0.5).collect(); + let mut a = Dema::new(7).unwrap(); + let mut b = Dema::new(7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut dema = Dema::new(5).unwrap(); + dema.batch(&(1..=50).map(f64::from).collect::>()); + assert!(dema.is_ready()); + dema.reset(); + assert!(!dema.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Dema::new(0).is_err()); + } + + /// Cover the const accessor `period` (43-45) and the Indicator-impl + /// `warmup_period` (63-66) + `name` (72-74). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let dema = Dema::new(5).unwrap(); + assert_eq!(dema.period(), 5); + // EMA1 seeds at period (5), EMA2 needs another (period - 1) = 4 -> + // total warmup = 2*period - 1 = 9. + assert_eq!(dema.warmup_period(), 9); + assert_eq!(dema.name(), "DEMA"); + } +} diff --git a/crates/wickra-core/src/indicators/demand_index.rs b/crates/wickra-core/src/indicators/demand_index.rs new file mode 100644 index 0000000..ed1ccfb --- /dev/null +++ b/crates/wickra-core/src/indicators/demand_index.rs @@ -0,0 +1,242 @@ +//! Demand Index (James Sibbet). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// James Sibbet's Demand Index — a smoothed ratio of buying pressure to +/// selling pressure, classifying each bar's volume by whether the close rose +/// or fell relative to the previous close. +/// +/// Sibbet's original 1970s formulation runs the raw buying/selling pressure +/// through several smoothings and yields a number that swings in `[−100, 100]`. +/// This implementation uses the textbook simplified form that captures the same +/// signal in a streaming-friendly shape: +/// +/// ```text +/// pressure_t = volume_t · ((close_t − close_{t−1}) / max(close_{t−1}, ε)) +/// · (1 + (high_t − low_t) / max(close_{t−1}, ε)) +/// DI_t = EMA(pressure, period)_t +/// ``` +/// +/// Positive readings mean the smoothed money flow is leaning to the buy side +/// (up-day volume dominates), negative to the sell side. The first candle only +/// establishes the previous close, so the first non-`None` value lands once the +/// EMA has accumulated `period` pressure samples. A previous close of zero +/// contributes no signal (avoids division by zero). The output is unbounded; +/// what matters is the sign and the divergence against price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DemandIndex, Indicator}; +/// +/// let mut indicator = DemandIndex::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DemandIndex { + period: usize, + ema: Ema, + prev_close: Option, +} + +impl DemandIndex { + /// Construct a new Demand Index with the given EMA smoothing period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + ema: Ema::new(period)?, + prev_close: None, + }) + } + + /// Configured EMA smoothing period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DemandIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let pressure = if prev == 0.0 { + // No prior baseline -> can't normalise; treat as no flow. + 0.0 + } else { + let ret = (candle.close - prev) / prev; + let range_norm = (candle.high - candle.low) / prev; + candle.volume * ret * (1.0 + range_norm) + }; + self.prev_close = Some(candle.close); + self.ema.update(pressure) + } + + fn reset(&mut self) { + self.ema.reset(); + self.prev_close = None; + } + + fn warmup_period(&self) -> usize { + // One seed bar to establish the previous close, then the EMA needs + // `period` samples to seed. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "DemandIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let di = DemandIndex::new(10).unwrap(); + assert_eq!(di.period(), 10); + assert_eq!(di.name(), "DemandIndex"); + assert_eq!(di.warmup_period(), 11); + } + + #[test] + fn constant_series_yields_zero() { + // No close change -> pressure = 0 on every bar -> EMA stays at 0. + let candles: Vec = (0..40) + .map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i)) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + for v in di.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn rising_series_yields_positive_signal() { + // Strictly rising closes on constant volume -> pressure is positive every + // bar -> smoothed DI must end up strictly positive. + let candles: Vec = (0..40) + .map(|i| { + let f = i as f64; + c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i) + }) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + let out = di.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert!( + last > 0.0, + "rising series must yield positive DI, got {last}" + ); + } + + #[test] + fn falling_series_yields_negative_signal() { + let candles: Vec = (0..40) + .map(|i| { + let f = i as f64; + c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i) + }) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + let out = di.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert!( + last < 0.0, + "falling series must yield negative DI, got {last}" + ); + } + + #[test] + fn zero_prev_close_contributes_no_signal() { + // First two bars: prev close is exactly zero -> pressure clipped to 0. + // We then continue with a non-zero series and confirm output behaves. + let mut di = DemandIndex::new(3).unwrap(); + di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0)); + // Bar 2 sees prev_close == 0 -> pressure = 0. + di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1)); + // Subsequent bars now have non-zero prev_close. + di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2)); + // Just check that nothing exploded; an EMA(3) needs 3 samples post-seed. + // The first sample at bar 2 was zero, the second at bar 3 positive. + let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3)); + assert!(v.is_some()); + assert!(v.unwrap().is_finite()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.2).sin() * 5.0; + c( + mid, + mid + 1.5, + mid - 1.5, + mid + 0.3, + 80.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = DemandIndex::new(10).unwrap(); + let mut b = DemandIndex::new(10).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let f = i as f64; + c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i) + }) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + di.batch(&candles); + assert!(di.is_ready()); + di.reset(); + assert!(!di.is_ready()); + assert_eq!(di.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/demark_pivots.rs b/crates/wickra-core/src/indicators/demark_pivots.rs new file mode 100644 index 0000000..28c29bd --- /dev/null +++ b/crates/wickra-core/src/indicators/demark_pivots.rs @@ -0,0 +1,192 @@ +//! `DeMark` Pivot Points. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `DeMark` Pivot Points output: a single resistance, pivot and support. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DemarkPivotsOutput { + /// Pivot Point: `X / 4` where `X` is the conditional sum (see [`DemarkPivots`]). + pub pp: f64, + /// Resistance 1: `X / 2 − L`. + pub r1: f64, + /// Support 1: `X / 2 − H`. + pub s1: f64, +} + +/// `DeMark` Pivot Points — Tom `DeMark`'s conditional pivot formulation, derived +/// from a sum `X` that depends on whether the bar closed up, down or flat. +/// +/// ```text +/// X = 2·H + L + C if C < O (down bar) +/// H + 2·L + C if C > O (up bar) +/// H + L + 2·C if C == O (doji) +/// +/// PP = X / 4 +/// R1 = X / 2 − L +/// S1 = X / 2 − H +/// ``` +/// +/// Unlike the classic pivots, only one resistance and one support are +/// produced; `DeMark`'s intent is a tighter, condition-sensitive set rather than +/// a multi-tier fan. The branching means a bar's open carries information that +/// other pivot variants discard. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DemarkPivots, Indicator}; +/// +/// // Up bar: O=100, H=120, L=80, C=110 -> X = H + 2·L + C = 390. +/// let up = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap(); +/// let lv = DemarkPivots::new().update(up).unwrap(); +/// assert!((lv.pp - 97.5).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DemarkPivots { + ready: bool, +} + +impl DemarkPivots { + /// Construct a new `DeMark` Pivot Points indicator. + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for DemarkPivots { + type Input = Candle; + type Output = DemarkPivotsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let open = candle.open; + let high = candle.high; + let low = candle.low; + let close = candle.close; + let x = if close < open { + 2.0 * high + low + close + } else if close > open { + high + 2.0 * low + close + } else { + high + low + 2.0 * close + }; + let pp = x / 4.0; + let half = x / 2.0; + let out = DemarkPivotsOutput { + pp, + r1: half - low, + s1: half - high, + }; + self.ready = true; + Some(out) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "DemarkPivots" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn down_bar_uses_2h_plus_l_plus_c() { + // O=110, H=120, L=80, C=100 (close < open) -> X = 2·120 + 80 + 100 = 420. + let cd = Candle::new(110.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap(); + let lv = DemarkPivots::new().update(cd).unwrap(); + assert!((lv.pp - 105.0).abs() < 1e-12); + assert!((lv.r1 - (210.0 - 80.0)).abs() < 1e-12); + assert!((lv.s1 - (210.0 - 120.0)).abs() < 1e-12); + } + + #[test] + fn up_bar_uses_h_plus_2l_plus_c() { + // O=100, H=120, L=80, C=110 (close > open) -> X = 120 + 160 + 110 = 390. + let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap(); + let lv = DemarkPivots::new().update(cd).unwrap(); + assert!((lv.pp - 97.5).abs() < 1e-12); + assert!((lv.r1 - (195.0 - 80.0)).abs() < 1e-12); + assert!((lv.s1 - (195.0 - 120.0)).abs() < 1e-12); + } + + #[test] + fn doji_uses_h_plus_l_plus_2c() { + // O = C = 100, H=120, L=80 -> X = 120 + 80 + 200 = 400. + let cd = Candle::new(100.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap(); + let lv = DemarkPivots::new().update(cd).unwrap(); + assert!((lv.pp - 100.0).abs() < 1e-12); + } + + #[test] + fn ordering_resistance_above_pivot_above_support() { + let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap(); + let lv = DemarkPivots::new().update(cd).unwrap(); + assert!(lv.r1 >= lv.pp); + assert!(lv.pp >= lv.s1); + } + + #[test] + fn constant_series_collapses_levels() { + let cd = Candle::new(50.0, 50.0, 50.0, 50.0, 1.0, 0).unwrap(); + let lv = DemarkPivots::new().update(cd).unwrap(); + assert_eq!(lv.pp, 50.0); + assert_eq!(lv.r1, 50.0); + assert_eq!(lv.s1, 50.0); + } + + #[test] + fn warmup_and_ready() { + let mut p = DemarkPivots::new(); + assert!(!p.is_ready()); + assert_eq!(p.warmup_period(), 1); + let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap(); + p.update(cd); + assert!(p.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut p = DemarkPivots::new(); + let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap(); + p.update(cd); + p.reset(); + assert!(!p.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = f64::from(i); + Candle::new(base, base + 2.0, base - 0.5, base + 1.0, 1.0, i64::from(i)).unwrap() + }) + .collect(); + let mut a = DemarkPivots::new(); + let mut b = DemarkPivots::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = DemarkPivots::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "DemarkPivots"); + } +} diff --git a/crates/wickra-core/src/indicators/depth_slope.rs b/crates/wickra-core/src/indicators/depth_slope.rs new file mode 100644 index 0000000..7c1c875 --- /dev/null +++ b/crates/wickra-core/src/indicators/depth_slope.rs @@ -0,0 +1,258 @@ +//! Depth Slope — how fast resting liquidity accumulates away from the mid. + +use crate::microstructure::{Level, OrderBook}; +use crate::traits::Indicator; + +/// Ordinary-least-squares slope of cumulative resting size against distance +/// from the mid, over the levels of one book side. +/// +/// `signed_distance` is `+1.0` for the ask side (price above the mid) and +/// `−1.0` for the bid side (price below the mid), so the regressor `x` — +/// distance from the mid — is non-negative on both sides. The response `y` is +/// the cumulative size walking outward from the touch. Returns `0.0` for a +/// degenerate fit where every level sits at the same distance (zero variance in +/// `x`). +fn cumulative_slope(levels: &[Level], mid: f64, signed_distance: f64) -> f64 { + let count = levels.len() as f64; + let mut cumulative = 0.0; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_xx = 0.0; + for level in levels { + let x = signed_distance * (level.price - mid); + cumulative += level.size; + sum_x += x; + sum_y += cumulative; + sum_xy += x * cumulative; + sum_xx += x * x; + } + let denom = count * sum_xx - sum_x * sum_x; + if denom == 0.0 { + return 0.0; + } + (count * sum_xy - sum_x * sum_y) / denom +} + +/// Depth Slope — the average rate at which cumulative resting size grows with +/// distance from the mid, across the bid and ask sides of the book. +/// +/// For each side the indicator runs an ordinary-least-squares regression of +/// cumulative size (walking outward from the touch) on the level's distance +/// from the mid, then reports the mean of the two slopes: +/// +/// ```text +/// slope_side = OLS slope of (|priceᵢ − mid|, Σ_{j≤i} sizeⱼ) +/// depthSlope = (slope_bid + slope_ask) / 2 +/// ``` +/// +/// Because the response is *cumulative* size it never decreases with distance, +/// so the slope is non-negative: it is a magnitude, not a direction. A large +/// slope means cumulative liquidity builds quickly away from the touch — a deep +/// book that absorbs large orders with little walking; a small slope is a thin, +/// shallow book. A book whose size is concentrated at the touch and thins out +/// behind it (a fragile book) reads a *smaller* slope than one of equal total +/// depth that thickens with distance. +/// +/// A side with fewer than two levels carries no slope, so the indicator returns +/// `0.0` whenever either side has fewer than two levels (including an empty +/// book). +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DepthSlope, Indicator, Level, OrderBook}; +/// +/// // Both sides thicken linearly away from the mid (sizes 1, 2, 3 …). +/// let book = OrderBook::new( +/// vec![Level::new(99.0, 1.0).unwrap(), Level::new(98.0, 2.0).unwrap()], +/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 2.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut ds = DepthSlope::new(); +/// assert!(ds.update(book).unwrap() > 0.0); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DepthSlope { + has_emitted: bool, +} + +impl DepthSlope { + /// Construct a new depth-slope indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for DepthSlope { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let Some(mid) = book.mid() else { + return Some(0.0); + }; + if book.bids.len() < 2 || book.asks.len() < 2 { + return Some(0.0); + } + let bid_slope = cumulative_slope(&book.bids, mid, -1.0); + let ask_slope = cumulative_slope(&book.asks, mid, 1.0); + Some(f64::midpoint(bid_slope, ask_slope)) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DepthSlope" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let ds = DepthSlope::new(); + assert_eq!(ds.name(), "DepthSlope"); + assert_eq!(ds.warmup_period(), 1); + assert!(!ds.is_ready()); + } + + #[test] + fn thickening_book_has_positive_slope() { + let mut ds = DepthSlope::new(); + let out = ds + .update(book( + &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)], + &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)], + )) + .unwrap(); + assert!(out > 0.0); + assert!(ds.is_ready()); + } + + #[test] + fn front_loaded_book_has_smaller_slope_than_back_loaded() { + // Same total depth (6 per side), but one book thickens away from the + // touch and the other thins. Cumulative slope is non-negative for both; + // the back-loaded book accumulates faster, so its slope is larger. + let mut back = DepthSlope::new(); + let back_slope = back + .update(book( + &[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)], + &[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)], + )) + .unwrap(); + let mut front = DepthSlope::new(); + let front_slope = front + .update(book( + &[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)], + &[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)], + )) + .unwrap(); + assert!(front_slope >= 0.0); + assert!(back_slope > front_slope); + } + + #[test] + fn known_slope_value() { + // Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3. + // OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2. + let mut ds = DepthSlope::new(); + let out = ds + .update(book( + &[(99.0, 1.0), (98.0, 2.0)], + &[(101.0, 1.0), (102.0, 2.0)], + )) + .unwrap(); + assert!((out - 2.0).abs() < 1e-9); + } + + #[test] + fn single_level_side_is_zero() { + let mut ds = DepthSlope::new(); + // Bid side has only one level -> no slope -> 0. + assert_eq!( + ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])), + Some(0.0) + ); + } + + #[test] + fn empty_book_is_zero() { + let mut ds = DepthSlope::new(); + assert_eq!( + ds.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn degenerate_distance_slope_is_zero() { + // Two levels at the same distance from mid carry zero x-variance. + let levels = [ + Level::new_unchecked(100.0, 1.0), + Level::new_unchecked(100.0, 2.0), + ]; + assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let extra = f64::from(i % 4); + book( + &[(99.0, 1.0 + extra), (98.0, 2.0)], + &[(101.0, 1.0), (102.0, 2.0 + extra)], + ) + }) + .collect(); + let mut a = DepthSlope::new(); + let mut b = DepthSlope::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ds = DepthSlope::new(); + ds.update(book( + &[(99.0, 1.0), (98.0, 2.0)], + &[(101.0, 1.0), (102.0, 2.0)], + )); + assert!(ds.is_ready()); + ds.reset(); + assert!(!ds.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/derivative_oscillator.rs b/crates/wickra-core/src/indicators/derivative_oscillator.rs new file mode 100644 index 0000000..bb08904 --- /dev/null +++ b/crates/wickra-core/src/indicators/derivative_oscillator.rs @@ -0,0 +1,218 @@ +//! Derivative Oscillator (Constance Brown). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::rsi::Rsi; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram. +/// +/// The RSI is smoothed twice with EMAs, then a simple moving average of that +/// double-smoothed line is subtracted as a signal, leaving a zero-centered +/// histogram: +/// +/// ```text +/// rsi = RSI(price, rsi_period) +/// s1 = EMA(rsi, smooth1) +/// s2 = EMA(s1, smooth2) // double-smoothed RSI +/// signal = SMA(s2, signal_period) +/// DerivativeOscillator = s2 - signal +/// ``` +/// +/// The double EMA smoothing strips the RSI's high-frequency noise, and +/// subtracting the SMA signal removes the residual level, so the result +/// oscillates around zero: positive (and rising) bars mark accelerating bullish +/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`, +/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`. +/// +/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period − 2` +/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativeOscillator, Indicator}; +/// +/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DerivativeOscillator { + rsi: Rsi, + ema1: Ema, + ema2: Ema, + signal: Sma, + warmup: usize, +} + +impl DerivativeOscillator { + /// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and + /// SMA signal periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is `0`. + pub fn new( + rsi_period: usize, + smooth1: usize, + smooth2: usize, + signal_period: usize, + ) -> Result { + if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + rsi: Rsi::new(rsi_period)?, + ema1: Ema::new(smooth1)?, + ema2: Ema::new(smooth2)?, + signal: Sma::new(signal_period)?, + // RSI seeds at rsi_period + 1, then each stage adds (len - 1). + warmup: rsi_period + smooth1 + smooth2 + signal_period - 2, + }) + } + + /// Total warmup length (also returned by `warmup_period`). + pub const fn warmup(&self) -> usize { + self.warmup + } +} + +impl Indicator for DerivativeOscillator { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let rsi = self.rsi.update(input)?; + let s1 = self.ema1.update(rsi)?; + let s2 = self.ema2.update(s1)?; + let signal = self.signal.update(s2)?; + Some(s2 - signal) + } + + fn reset(&mut self) { + self.rsi.reset(); + self.ema1.reset(); + self.ema2.reset(); + self.signal.reset(); + } + + fn warmup_period(&self) -> usize { + self.warmup + } + + fn is_ready(&self) -> bool { + self.signal.is_ready() + } + + fn name(&self) -> &'static str { + "DerivativeOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_periods() { + assert!(matches!( + DerivativeOscillator::new(0, 5, 3, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + DerivativeOscillator::new(14, 0, 3, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + DerivativeOscillator::new(14, 5, 0, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + DerivativeOscillator::new(14, 5, 3, 0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessor `warmup` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); + // 14 + 5 + 3 + 9 - 2 = 29. + assert_eq!(d.warmup(), 29); + assert_eq!(d.warmup_period(), 29); + assert_eq!(d.name(), "DerivativeOscillator"); + } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0) + .collect(); + let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); + let out = d.batch(&prices); + let warmup = d.warmup_period(); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first value must land at warmup_period - 1" + ); + } + + #[test] + fn matches_manual_chain() { + // Equals RSI -> EMA -> EMA, minus the SMA signal of that line. + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0) + .collect(); + let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); + let mut rsi = Rsi::new(14).unwrap(); + let mut e1 = Ema::new(5).unwrap(); + let mut e2 = Ema::new(3).unwrap(); + let mut sig = Sma::new(9).unwrap(); + for (i, &p) in prices.iter().enumerate() { + let got = d.update(p); + let want = rsi + .update(p) + .and_then(|r| e1.update(r)) + .and_then(|x| e2.update(x)) + .and_then(|s2| sig.update(s2).map(|s| s2 - s)); + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (got, want) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); + d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::>()); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0) + .collect(); + let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); + let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/detrended_std_dev.rs b/crates/wickra-core/src/indicators/detrended_std_dev.rs new file mode 100644 index 0000000..d475cef --- /dev/null +++ b/crates/wickra-core/src/indicators/detrended_std_dev.rs @@ -0,0 +1,224 @@ +//! Population standard deviation of residuals from a rolling OLS detrend. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Detrended (residual) standard deviation over the last `period` inputs. +/// +/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line +/// `y = a + b·x` is fitted and the residual sum of squares is then divided +/// by `n` (population convention): +/// +/// ```text +/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// SS_total = Σy² − n·ȳ² +/// RSS = SS_total − slope² · ( denom / n ) +/// DetrendedStdDev = √( RSS / n ) +/// ``` +/// +/// Unlike [`crate::StdDev`], which measures dispersion around the rolling +/// **mean**, `DetrendedStdDev` measures dispersion around the rolling +/// **linear trend** — the portion of the price action that is *not* +/// explained by the local slope. On a strongly trending series this is +/// much smaller than `StdDev`; on a sideways, mean-reverting series the +/// two converge. +/// +/// The divisor is `n` (population), matching the convention of +/// [`crate::StdDev`]; use [`crate::StandardError`] when you want the +/// textbook standard error of estimate with `n − 2` residual degrees of +/// freedom. +/// +/// Each `update` is O(1) via the same rolling sums as +/// [`crate::LinearRegression`], plus a running `Σy²`. Floating-point +/// cancellation noise in the residual is clamped to zero before the square +/// root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DetrendedStdDev, Indicator}; +/// +/// let mut indicator = DetrendedStdDev::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.3).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DetrendedStdDev { + period: usize, + window: VecDeque, + sum_x: f64, + /// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`. + denom: f64, + sum_y: f64, + sum_xy: f64, + sum_y_sq: f64, +} + +impl DetrendedStdDev { + /// Construct a new rolling detrended standard deviation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line + /// is undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "detrended stddev needs period >= 2", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + sum_y_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DetrendedStdDev { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + self.sum_y_sq -= y0 * y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + self.sum_y_sq += value * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let mean_y = self.sum_y / n; + let ss_total = self.sum_y_sq - n * mean_y * mean_y; + let s_xx = self.denom / n; + let rss = (ss_total - slope * slope * s_xx).max(0.0); + Some((rss / n).sqrt()) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + self.sum_y_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 { + "DetrendedStdDev" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(DetrendedStdDev::new(0).is_err()); + assert!(DetrendedStdDev::new(1).is_err()); + assert!(DetrendedStdDev::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let d = DetrendedStdDev::new(14).unwrap(); + assert_eq!(d.period(), 14); + assert_eq!(d.warmup_period(), 14); + assert_eq!(d.name(), "DetrendedStdDev"); + } + + #[test] + fn perfect_line_has_zero_residual() { + // Residuals are zero on a perfectly linear series. + let prices: Vec = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut d = DetrendedStdDev::new(10).unwrap(); + for v in d.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut d = DetrendedStdDev::new(5).unwrap(); + for v in d.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn never_exceeds_stddev() { + // The detrended residual is the projection of (y - ȳ) orthogonal to + // the trend axis, so its norm cannot exceed the raw stddev. Equality + // holds iff the OLS slope is exactly zero. + let prices: Vec = (0..60) + .map(|i| 50.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 4.0) + .collect(); + let mut d = DetrendedStdDev::new(14).unwrap(); + let mut sd = crate::StdDev::new(14).unwrap(); + for &p in &prices { + let (dv, sv) = (d.update(p), sd.update(p)); + assert_eq!(dv.is_some(), sv.is_some()); + if let (Some(dv), Some(sv)) = (dv, sv) { + assert!(dv <= sv + 1e-9, "detrended {dv} should be <= stddev {sv}"); + } + } + } + + #[test] + fn reset_clears_state() { + let mut d = DetrendedStdDev::new(5).unwrap(); + d.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0) + .collect(); + let batch = DetrendedStdDev::new(14).unwrap().batch(&prices); + let mut b = DetrendedStdDev::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/disparity_index.rs b/crates/wickra-core/src/indicators/disparity_index.rs new file mode 100644 index 0000000..70635b7 --- /dev/null +++ b/crates/wickra-core/src/indicators/disparity_index.rs @@ -0,0 +1,169 @@ +//! Disparity Index. + +use crate::error::Result; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// Disparity Index — the percentage gap between price and its moving average. +/// +/// ```text +/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period) +/// ``` +/// +/// Originating in Japanese technical analysis (*kairi*), the disparity index +/// expresses how far price has stretched from its `period`-bar simple moving +/// average, as a percentage of that average. Positive readings mean price is +/// above the mean (potentially overbought / strong), negative readings mean it +/// is below (potentially oversold / weak); the magnitude measures how +/// over-extended the move is. +/// +/// The first output lands once the inner SMA is ready (input `period`). If the +/// moving average is exactly zero the gap percentage is undefined and the index +/// returns `0.0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DisparityIndex, Indicator}; +/// +/// let mut indicator = DisparityIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DisparityIndex { + period: usize, + sma: Sma, +} + +impl DisparityIndex { + /// Construct a disparity index over `period` inputs. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + sma: Sma::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DisparityIndex { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let mean = self.sma.update(input)?; + if mean == 0.0 { + return Some(0.0); + } + Some(100.0 * (input - mean) / mean) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "DisparityIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(DisparityIndex::new(0).is_err()); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let di = DisparityIndex::new(14).unwrap(); + assert_eq!(di.period(), 14); + assert_eq!(di.warmup_period(), 14); + assert_eq!(di.name(), "DisparityIndex"); + } + + #[test] + fn warmup_then_known_value() { + // SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50. + let mut di = DisparityIndex::new(3).unwrap(); + assert_eq!(di.update(2.0), None); + assert_eq!(di.update(4.0), None); + assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_is_zero() { + // Price equals its own mean -> zero disparity. + let mut di = DisparityIndex::new(5).unwrap(); + for v in di.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn negative_when_below_mean() { + // SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25. + let mut di = DisparityIndex::new(3).unwrap(); + let v = di.batch(&[10.0, 8.0, 6.0]); + assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12); + } + + #[test] + fn zero_mean_returns_zero() { + // A window summing to zero (mean 0) makes the percentage undefined; the + // index returns 0.0 rather than a non-finite value. + let mut di = DisparityIndex::new(2).unwrap(); + assert_eq!(di.update(-3.0), None); + // SMA(2) of [-3, 3] = 0 -> guarded to 0.0. + assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut di = DisparityIndex::new(5).unwrap(); + di.batch(&(1..=20).map(f64::from).collect::>()); + assert!(di.is_ready()); + di.reset(); + assert!(!di.is_ready()); + assert_eq!(di.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = DisparityIndex::new(7).unwrap(); + let mut b = DisparityIndex::new(7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/distance_ssd.rs b/crates/wickra-core/src/indicators/distance_ssd.rs new file mode 100644 index 0000000..ac0db62 --- /dev/null +++ b/crates/wickra-core/src/indicators/distance_ssd.rs @@ -0,0 +1,249 @@ +//! Gatev distance (sum of squared deviations) between two normalised series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Sum of squared deviations between two price series, normalised to a common +/// start — the classic Gatev et al. pairs-selection distance. +/// +/// Each `update` takes one `(a, b)` price pair. Over the trailing window of +/// `period` pairs each series is rebased to `1` at the window's first bar and +/// the squared gap between the two normalised paths is summed: +/// +/// ```text +/// ãᵢ = aᵢ / a_first b̃ᵢ = bᵢ / b_first +/// SSD = Σ (ãᵢ − b̃ᵢ)² +/// ``` +/// +/// Rebasing puts the two series on the same scale (both start at `1`), so the +/// distance measures how far their *relative* paths drift apart. A **small** +/// SSD means the two assets track each other tightly — the screen Gatev, +/// Goetzmann and Rouwenhorst use to pick tradeable pairs; a large SSD means +/// they have decoupled. The output is always `≥ 0`. If either series is `0` at +/// the start of the window the normalisation is undefined and the indicator +/// returns `0`. +/// +/// Each `update` is `O(period)`, bounded by the fixed window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DistanceSsd, Indicator}; +/// +/// let mut d = DistanceSsd::new(20).unwrap(); +/// let mut last = None; +/// for t in 0..40 { +/// let base = 100.0 + f64::from(t); +/// // Two near-identical paths ⇒ tiny distance. +/// last = d.update((base, base * 1.0001)); +/// } +/// assert!(last.unwrap() < 1e-3); +/// ``` +#[derive(Debug, Clone)] +pub struct DistanceSsd { + period: usize, + window: VecDeque<(f64, f64)>, +} + +impl DistanceSsd { + /// Construct a new Gatev distance estimator. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a distance needs at + /// least two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "distance SSD needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured look-back window. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DistanceSsd { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let &(a_first, b_first) = self.window.front().expect("window is full"); + if a_first == 0.0 || b_first == 0.0 { + // Cannot rebase a series that starts at zero. + return Some(0.0); + } + let ssd = self + .window + .iter() + .map(|&(a, b)| { + let gap = a / a_first - b / b_first; + gap * gap + }) + .sum(); + Some(ssd) + } + + 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 { + "DistanceSsd" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(DistanceSsd::new(1).is_err()); + assert!(DistanceSsd::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let d = DistanceSsd::new(20).unwrap(); + assert_eq!(d.period(), 20); + assert_eq!(d.warmup_period(), 20); + assert_eq!(d.name(), "DistanceSsd"); + assert!(!d.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut d = DistanceSsd::new(3).unwrap(); + assert_eq!(d.update((1.0, 1.0)), None); + assert_eq!(d.update((2.0, 2.0)), None); + assert!(d.update((3.0, 3.0)).is_some()); + assert!(d.is_ready()); + } + + #[test] + fn identical_normalised_paths_have_zero_distance() { + // b = 2·a ⇒ both rebase to the same path ⇒ SSD = 0. + let pairs: Vec<(f64, f64)> = (0..20) + .map(|t| { + let a = 100.0 + f64::from(t); + (a, 2.0 * a) + }) + .collect(); + let last = DistanceSsd::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn diverging_paths_have_positive_distance() { + let pairs: Vec<(f64, f64)> = (0..20) + .map(|t| (100.0 + f64::from(t), 100.0 + 3.0 * f64::from(t))) + .collect(); + let last = DistanceSsd::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last > 0.0, "ssd {last}"); + } + + #[test] + fn hand_computed_value() { + // Window of three pairs, a_first = b_first = 1: + // (1,1) → 0; (2,4) → (2−4)² = 4; (3,9) → (3−9)² = 36 ⇒ SSD = 40. + let pairs = [(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)]; + let last = DistanceSsd::new(3) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 40.0, epsilon = 1e-12); + } + + #[test] + fn zero_start_returns_zero() { + // First bar of the window has a = 0 ⇒ rebasing undefined ⇒ 0. + let pairs = [(0.0, 1.0), (2.0, 2.0), (3.0, 3.0)]; + let last = DistanceSsd::new(3) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut d = DistanceSsd::new(4).unwrap(); + d.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 4.0), (4.0, 5.0), (5.0, 6.0)]); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|t| { + let a = 100.0 + f64::from(t); + (a, 100.0 + 1.2 * f64::from(t) + (f64::from(t) * 0.5).sin()) + }) + .collect(); + let batch = DistanceSsd::new(15).unwrap().batch(&pairs); + let mut d = DistanceSsd::new(15).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| d.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut d = DistanceSsd::new(3).unwrap(); + assert_eq!(d.update((f64::NAN, 1.0)), None); + assert_eq!(d.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(d.update((1.0, 1.0)), None); + assert_eq!(d.update((2.0, 4.0)), None); + assert!(d.update((3.0, 9.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/doji.rs b/crates/wickra-core/src/indicators/doji.rs new file mode 100644 index 0000000..77704e0 --- /dev/null +++ b/crates/wickra-core/src/indicators/doji.rs @@ -0,0 +1,316 @@ +//! Doji candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Doji — a candle whose body is negligible relative to its range. +/// +/// A Doji prints whenever the absolute distance between open and close is +/// small compared to the total `high − low` range. It is the canonical +/// indecision bar and a building block for many three-bar reversal patterns. +/// +/// ```text +/// body = |close − open| +/// range = high − low +/// doji = body <= body_threshold * range +/// ``` +/// +/// # Signed ±1 encoding +/// +/// By default the output is `+1.0` when a Doji is detected and `0.0` +/// otherwise — a direction-less detection flag. For a drop-in machine-learning +/// feature where every candlestick pattern shares the same sign convention +/// (`+1.0` bullish, `−1.0` bearish, `0.0` none), switch the detector into +/// signed mode with [`Doji::signed`]. A detected Doji is then classified by +/// where its (negligible) body sits within the bar's range: +/// +/// ```text +/// pos = (0.5 * (open + close) − low) / (high − low) +/// pos > 2/3 -> +1.0 dragonfly (long lower shadow, bullish) +/// pos < 1/3 -> −1.0 gravestone (long upper shadow, bearish) +/// else -> 0.0 long-legged / standard (neutral) +/// ``` +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Doji, Indicator}; +/// +/// // Default: direction-less detection flag. +/// let mut indicator = Doji::default(); +/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// +/// // Signed: a dragonfly Doji (body at the top, long lower shadow) is bullish. +/// let mut signed = Doji::new().signed(); +/// let dragonfly = Candle::new(10.0, 10.05, 6.0, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(signed.update(dragonfly), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Doji { + body_threshold: f64, + signed: bool, + has_emitted: bool, +} + +impl Default for Doji { + fn default() -> Self { + Self::new() + } +} + +impl Doji { + /// Construct a Doji detector with the default body threshold (`0.1`). + pub const fn new() -> Self { + Self { + body_threshold: 0.1, + signed: false, + has_emitted: false, + } + } + + /// Construct a Doji detector with a custom body / range threshold. + /// + /// `body_threshold` must lie in `(0, 1]`. + pub fn with_threshold(body_threshold: f64) -> Result { + if !(body_threshold > 0.0 && body_threshold <= 1.0) { + return Err(Error::InvalidPeriod { + message: "doji body threshold must lie in (0, 1]", + }); + } + Ok(Self { + body_threshold, + signed: false, + has_emitted: false, + }) + } + + /// Switch to the signed dragonfly / gravestone encoding (consuming builder). + /// + /// In signed mode a detected Doji emits `+1.0` (dragonfly, bullish), + /// `−1.0` (gravestone, bearish) or `0.0` (long-legged / neutral) instead of + /// the default direction-less `+1.0` detection flag. See the type-level + /// docs for the exact classification rule. + #[must_use] + pub fn signed(mut self) -> Self { + self.signed = true; + self + } + + /// Configured body / range threshold. + pub fn body_threshold(&self) -> f64 { + self.body_threshold + } + + /// Whether this detector emits the signed dragonfly / gravestone encoding. + pub fn is_signed(&self) -> bool { + self.signed + } +} + +impl Indicator for Doji { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body > self.body_threshold * range { + return Some(0.0); + } + if !self.signed { + return Some(1.0); + } + // Signed mode: classify the Doji by where its (negligible) body sits + // within the high–low range. + let body_mid = 0.5 * (candle.open + candle.close); + let pos = (body_mid - candle.low) / range; + if pos > 2.0 / 3.0 { + Some(1.0) + } else if pos < 1.0 / 3.0 { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Doji" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(Doji::with_threshold(0.0).is_err()); + assert!(Doji::with_threshold(-0.1).is_err()); + assert!(Doji::with_threshold(1.5).is_err()); + } + + #[test] + fn accepts_valid_threshold() { + let d = Doji::with_threshold(0.05).unwrap(); + assert!((d.body_threshold() - 0.05).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let d = Doji::default(); + assert_eq!(d.name(), "Doji"); + assert_eq!(d.warmup_period(), 1); + assert!(!d.is_ready()); + assert!(!d.is_signed()); + assert!((d.body_threshold() - 0.1).abs() < 1e-12); + } + + #[test] + fn obvious_doji_is_one() { + let mut d = Doji::new(); + // open == close, full range -> body / range = 0. + assert_eq!(d.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(1.0)); + assert!(d.is_ready()); + } + + #[test] + fn marubozu_is_not_doji() { + // Big body, no shadows -> body / range = 1.0 > 0.1. + let mut d = Doji::new(); + assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut d = Doji::new(); + assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = Doji::new(); + let mut b = Doji::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut d = Doji::new(); + d.update(c(10.0, 11.0, 9.0, 10.0, 0)); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + } + + #[test] + fn signed_accessor_and_builder() { + let d = Doji::new().signed(); + assert!(d.is_signed()); + // The consuming builder composes with `with_threshold`. + let t = Doji::with_threshold(0.05).unwrap().signed(); + assert!(t.is_signed()); + assert!((t.body_threshold() - 0.05).abs() < 1e-12); + } + + #[test] + fn signed_dragonfly_is_plus_one() { + // Body at the top of the range, long lower shadow -> bullish. + let mut d = Doji::new().signed(); + assert_eq!(d.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(1.0)); + } + + #[test] + fn signed_gravestone_is_minus_one() { + // Body at the bottom of the range, long upper shadow -> bearish. + let mut d = Doji::new().signed(); + assert_eq!(d.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn signed_long_legged_is_zero() { + // Body centred, symmetric shadows -> neutral. + let mut d = Doji::new().signed(); + assert_eq!(d.update(c(10.0, 12.0, 8.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn signed_non_doji_is_zero() { + // A large body is not a Doji at all -> 0 regardless of position. + let mut d = Doji::new().signed(); + assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn signed_zero_range_is_zero() { + let mut d = Doji::new().signed(); + assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn signed_batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + // Alternate dragonfly / gravestone / centred Doji shapes. + match i % 3 { + 0 => c(base, base + 0.05, base - 4.0, base, i), + 1 => c(base, base + 4.0, base - 0.05, base, i), + _ => c(base, base + 2.0, base - 2.0, base, i), + } + }) + .collect(); + let mut a = Doji::new().signed(); + let mut b = Doji::new().signed(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn signed_survives_reset() { + let mut d = Doji::new().signed(); + d.update(c(10.0, 10.05, 6.0, 10.0, 0)); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + // `reset` clears only the streaming state, not the signed configuration. + assert!(d.is_signed()); + assert_eq!(d.update(c(10.0, 10.05, 6.0, 10.0, 1)), Some(1.0)); + } +} diff --git a/crates/wickra-core/src/indicators/doji_star.rs b/crates/wickra-core/src/indicators/doji_star.rs new file mode 100644 index 0000000..75336fb --- /dev/null +++ b/crates/wickra-core/src/indicators/doji_star.rs @@ -0,0 +1,213 @@ +//! Doji Star candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Doji Star — a 2-bar reversal warning. A long trending body is followed by a +/// doji whose tiny body gaps away in the direction of the trend, the indecision +/// hinting the move is about to turn. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) (bar1) +/// doji = |close − open| <= 0.1 * (high − low) (bar2) +/// bullish (+1.0): bar1 black, doji body gaps DOWN below it (max(o2,c2) < close1) +/// bearish (−1.0): bar1 white, doji body gaps UP above it (min(o2,c2) > close1) +/// ``` +/// +/// Output is `+1.0` (bullish star, after a black bar) or `−1.0` (bearish star, +/// after a white bar) when the pattern completes, and `0.0` otherwise. The first +/// bar always returns `0.0` because the two-bar window is not yet filled. Doji +/// thresholds follow the geometric house style (fixed half-range body for the +/// long bar, tenth-range body for the doji) rather than TA-Lib's rolling +/// averages. Pattern-shape check only — no trend filter is applied; combine with +/// a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DojiStar, Indicator}; +/// +/// let mut indicator = DojiStar::new(); +/// // Long black bar, then a doji gapping down -> bullish star. +/// indicator.update(Candle::new(20.0, 20.2, 14.8, 15.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DojiStar { + prev: Option, + has_emitted: bool, +} + +impl DojiStar { + /// Construct a new Doji Star detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for DojiStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + let range2 = candle.high - candle.low; + if range1 <= 0.0 || range2 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.close - bar1.open; + if body1.abs() < 0.5 * range1 { + return Some(0.0); + } + if (candle.close - candle.open).abs() > 0.1 * range2 { + return Some(0.0); + } + let doji_top = candle.open.max(candle.close); + let doji_bottom = candle.open.min(candle.close); + // Bullish: long black bar, doji body gaps down below it. + if body1 < 0.0 && doji_top < bar1.close { + return Some(1.0); + } + // Bearish: long white bar, doji body gaps up above it. + if body1 > 0.0 && doji_bottom > bar1.close { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DojiStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = DojiStar::new(); + assert_eq!(t.name(), "DojiStar"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_doji_star_is_plus_one() { + let mut t = DojiStar::new(); + assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(1.0)); + } + + #[test] + fn bearish_doji_star_is_minus_one() { + let mut t = DojiStar::new(); + assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), Some(0.0)); + assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(-1.0)); + } + + #[test] + fn second_bar_not_doji_yields_zero() { + let mut t = DojiStar::new(); + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + // Wide body, not a doji. + assert_eq!(t.update(c(13.0, 13.2, 11.0, 11.5, 1)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = DojiStar::new(); + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + // Doji overlaps bar1's body (no gap down). + assert_eq!(t.update(c(16.0, 16.1, 15.9, 16.0, 1)), Some(0.0)); + } + + #[test] + fn short_first_body_yields_zero() { + let mut t = DojiStar::new(); + // First bar body too short to be the "long" leg. + t.update(c(20.0, 24.0, 16.0, 19.5, 0)); + assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = DojiStar::new(); + assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 2 == 0 { + c(base + 5.0, base + 5.2, base - 0.2, base, i) + } else { + c(base - 3.0, base - 2.9, base - 3.1, base - 3.0, i) + } + }) + .collect(); + let mut a = DojiStar::new(); + let mut b = DojiStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = DojiStar::new(); + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + t.update(c(13.0, 13.1, 12.9, 13.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = DojiStar::new(); + t.update(c(20.0, 20.2, 14.8, 15.0, 0)); + // Flat second bar (high == low) -> zero-range guard. + assert_eq!(t.update(c(13.0, 13.0, 13.0, 13.0, 1)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/dollar_bars.rs b/crates/wickra-core/src/indicators/dollar_bars.rs new file mode 100644 index 0000000..0cd20b7 --- /dev/null +++ b/crates/wickra-core/src/indicators/dollar_bars.rs @@ -0,0 +1,224 @@ +//! Dollar bar builder — close a bar each time accumulated traded value reaches a threshold. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed dollar bar (an OHLC aggregate spanning ~`dollar_per_bar` of traded value). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DollarBar { + /// Open of the first candle in the bar. + pub open: f64, + /// Highest high across the bar. + pub high: f64, + /// Lowest low across the bar. + pub low: f64, + /// Close of the candle that closed the bar. + pub close: f64, + /// Summed volume across the bar. + pub volume: f64, + /// Accumulated traded value (`Σ close · volume`, `>= dollar_per_bar`). + pub dollar: f64, +} + +/// Dollar bar builder — emits a bar each time accumulated traded value +/// (`price × volume`) reaches `dollar_per_bar`. +/// +/// Dollar bars are the most drift-robust of the information-driven bar types. Where +/// [`VolumeBars`](crate::VolumeBars) close on a fixed *quantity* of shares/contracts, +/// dollar bars close on a fixed *value*: each candle contributes `close × volume` to +/// the running total. As a market's price level rises over years, a fixed share +/// count buys ever more value and volume bars drift in meaning; dollar bars stay +/// economically comparable across the whole history, which is why they are the +/// preferred sampling for long backtests and machine-learning features. +/// +/// The bar is candle-granular: at most one bar closes per candle, and the candle +/// that crosses the threshold closes the bar with its overshoot included. +/// [`BarBuilder::update`] returns either an empty vector or a single [`DollarBar`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, DollarBars}; +/// +/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap(); +/// let mut bars = DollarBars::new(1000.0).unwrap(); +/// assert!(bars.update(c(10.0, 60.0)).is_empty()); // 600 +/// let out = bars.update(c(10.0, 60.0)); // 1200 >= 1000 -> close +/// assert_eq!(out.len(), 1); +/// assert_eq!(out[0].dollar, 1200.0); +/// ``` +#[derive(Debug, Clone)] +pub struct DollarBars { + dollar_per_bar: f64, + count: usize, + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, + dollar: f64, +} + +impl DollarBars { + /// Construct a dollar-bar builder with the given traded-value threshold. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `dollar_per_bar` is not finite and positive. + pub fn new(dollar_per_bar: f64) -> Result { + if !dollar_per_bar.is_finite() || dollar_per_bar <= 0.0 { + return Err(Error::InvalidPeriod { + message: "dollar_per_bar must be finite and positive", + }); + } + Ok(Self { + dollar_per_bar, + count: 0, + open: 0.0, + high: 0.0, + low: 0.0, + close: 0.0, + volume: 0.0, + dollar: 0.0, + }) + } + + /// Configured traded-value threshold per bar. + pub const fn dollar_per_bar(&self) -> f64 { + self.dollar_per_bar + } + + /// Traded value accumulated into the in-progress bar. + pub const fn accumulated(&self) -> f64 { + self.dollar + } +} + +impl BarBuilder for DollarBars { + type Bar = DollarBar; + + fn update(&mut self, candle: Candle) -> Vec { + if self.count == 0 { + self.open = candle.open; + self.high = candle.high; + self.low = candle.low; + self.volume = 0.0; + } else { + self.high = self.high.max(candle.high); + self.low = self.low.min(candle.low); + } + self.close = candle.close; + self.volume += candle.volume; + self.dollar += candle.close * candle.volume; + self.count += 1; + if self.dollar < self.dollar_per_bar { + return Vec::new(); + } + let bar = DollarBar { + open: self.open, + high: self.high, + low: self.low, + close: self.close, + volume: self.volume, + dollar: self.dollar, + }; + self.count = 0; + self.dollar = 0.0; + vec![bar] + } + + fn reset(&mut self) { + self.count = 0; + self.volume = 0.0; + self.dollar = 0.0; + } + + fn name(&self) -> &'static str { + "DollarBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new(open, high, low, close, volume, 0).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(matches!( + DollarBars::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + DollarBars::new(-1000.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + DollarBars::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bars = DollarBars::new(50_000.0).unwrap(); + assert_relative_eq!(bars.dollar_per_bar(), 50_000.0, epsilon = 1e-6); + assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12); + assert_eq!(bars.name(), "DollarBars"); + } + + #[test] + fn closes_when_value_reached() { + let mut bars = DollarBars::new(1000.0).unwrap(); + assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty()); // 600 + let out = bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)); // 1200 + assert_eq!(out.len(), 1); + assert_relative_eq!(out[0].dollar, 1200.0, epsilon = 1e-9); + assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12); + } + + #[test] + fn aggregates_ohlc() { + let mut bars = DollarBars::new(1000.0).unwrap(); + bars.update(candle(10.0, 11.0, 9.0, 10.0, 50.0)); // 500 + let out = bars.update(candle(10.0, 12.0, 9.5, 11.0, 60.0)); // 500 + 660 = 1160 + assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12); + assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12); + assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12); + } + + #[test] + fn below_threshold_emits_nothing() { + let mut bars = DollarBars::new(1000.0).unwrap(); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0)); // 300 + assert_relative_eq!(bars.accumulated(), 300.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut bars = DollarBars::new(1000.0).unwrap(); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)); + bars.reset(); + assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12); + assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 10.0)).is_empty()); + } + + #[test] + fn batch_concatenates_completed_bars() { + let mut bars = DollarBars::new(1000.0).unwrap(); + let candles = [ + candle(10.0, 10.0, 10.0, 10.0, 60.0), + candle(10.0, 10.0, 10.0, 10.0, 60.0), + candle(10.0, 10.0, 10.0, 10.0, 60.0), + candle(10.0, 10.0, 10.0, 10.0, 60.0), + ]; + let out = bars.batch(&candles); + assert_eq!(out.len(), 2); + } +} diff --git a/crates/wickra-core/src/indicators/donchian.rs b/crates/wickra-core/src/indicators/donchian.rs new file mode 100644 index 0000000..a2f6783 --- /dev/null +++ b/crates/wickra-core/src/indicators/donchian.rs @@ -0,0 +1,181 @@ +//! Donchian Channels. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Donchian Channels output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DonchianOutput { + /// Highest high over the lookback. + pub upper: f64, + /// Average of upper and lower. + pub middle: f64, + /// Lowest low over the lookback. + pub lower: f64, +} + +/// Donchian Channels: rolling highest high / lowest low envelopes. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Donchian}; +/// +/// let mut indicator = Donchian::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Donchian { + period: usize, + candles: VecDeque, +} + +impl Donchian { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Donchian { + type Input = Candle; + type Output = DonchianOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.candles.len() == self.period { + self.candles.pop_front(); + } + self.candles.push_back(candle); + if self.candles.len() < self.period { + return None; + } + let upper = self + .candles + .iter() + .map(|c| c.high) + .fold(f64::NEG_INFINITY, f64::max); + let lower = self + .candles + .iter() + .map(|c| c.low) + .fold(f64::INFINITY, f64::min); + Some(DonchianOutput { + upper, + middle: f64::midpoint(upper, lower), + lower, + }) + } + + fn reset(&mut self) { + self.candles.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.candles.len() == self.period + } + + fn name(&self) -> &'static str { + "DonchianChannels" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn flat_market_yields_equal_bands() { + let candles: Vec = (0..20).map(|_| c(11.0, 9.0, 10.0)).collect(); + let mut d = Donchian::new(5).unwrap(); + let last = d.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, 11.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 9.0, epsilon = 1e-12); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = Donchian::new(10).unwrap(); + let mut b = Donchian::new(10).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut d = Donchian::new(10).unwrap(); + for o in d.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn rejects_zero_period() { + assert!(Donchian::new(0).is_err()); + } + + /// Cover the const accessor `period` (57-59) and the Indicator-impl + /// `warmup_period` (95-97) + `name` (103-105). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let d = Donchian::new(20).unwrap(); + assert_eq!(d.period(), 20); + assert_eq!(d.warmup_period(), 20); + assert_eq!(d.name(), "DonchianChannels"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut d = Donchian::new(5).unwrap(); + d.batch(&candles); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/donchian_stop.rs b/crates/wickra-core/src/indicators/donchian_stop.rs new file mode 100644 index 0000000..4b99d0f --- /dev/null +++ b/crates/wickra-core/src/indicators/donchian_stop.rs @@ -0,0 +1,218 @@ +//! Donchian Channel Stop (Turtle). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Donchian Channel Stop output: the long-side and short-side trailing stops. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DonchianStopOutput { + /// Long-position stop: the lowest low over the lookback. + pub stop_long: f64, + /// Short-position stop: the highest high over the lookback. + pub stop_short: f64, +} + +/// Donchian Channel Stop — the original Turtle-trader exit rule. A long is +/// trailed at the lowest low of the last `period` bars; a short at the highest +/// high. There is no ATR, no multiplier, and no flip-bit — the two levels are +/// always emitted and the caller selects whichever side matches the position. +/// +/// ```text +/// stop_long = min(low, over period bars) +/// stop_short = max(high, over period bars) +/// ``` +/// +/// Richard Dennis' original Turtle System used a 20-bar entry channel and a +/// 10-bar exit channel — feed this indicator the exit window. The first +/// `period` candles are warmup; on the bar that fills the window it begins +/// emitting both stops. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, DonchianStop}; +/// +/// let mut indicator = DonchianStop::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DonchianStop { + period: usize, + highs: VecDeque, + lows: VecDeque, +} + +impl DonchianStop { + /// Construct a Donchian Channel Stop with an explicit lookback. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// The Turtle-system exit window: a `10`-bar lookback. + pub fn classic() -> Self { + Self::new(10).expect("classic Donchian Stop period is valid") + } + + /// Configured lookback. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DonchianStop { + type Input = Candle; + type Output = DonchianStopOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.period { + return None; + } + let stop_short = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let stop_long = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + Some(DonchianStopOutput { + stop_long, + stop_short, + }) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.highs.len() == self.period + } + + fn name(&self) -> &'static str { + "DonchianStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(DonchianStop::new(0).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = DonchianStop::classic(); + assert_eq!(s.period(), 10); + assert_eq!(s.warmup_period(), 10); + assert_eq!(s.name(), "DonchianStop"); + } + + #[test] + fn first_emission_matches_warmup() { + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = DonchianStop::new(5).unwrap(); + let out = s.batch(&candles); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some()); + } + + #[test] + fn reference_values_uptrend_window() { + // Highs 0..5 = 1..6; lowest low = 0, highest high = 5. + let candles: Vec = (0..5) + .map(|i| { + let base = i as f64 + 0.5; + c(base + 0.5, base - 0.5, base, i) + }) + .collect(); + let mut s = DonchianStop::new(5).unwrap(); + let out = s.batch(&candles); + let v = out[4].expect("ready at index 4"); + assert_relative_eq!(v.stop_short, 5.0, epsilon = 1e-12); + assert_relative_eq!(v.stop_long, 0.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_holds_both_stops() { + let candles: Vec = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut s = DonchianStop::new(5).unwrap(); + for v in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v.stop_short, 11.0, epsilon = 1e-12); + assert_relative_eq!(v.stop_long, 9.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = DonchianStop::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = DonchianStop::classic(); + let mut b = DonchianStop::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/double_bollinger.rs b/crates/wickra-core/src/indicators/double_bollinger.rs new file mode 100644 index 0000000..9e8c3c9 --- /dev/null +++ b/crates/wickra-core/src/indicators/double_bollinger.rs @@ -0,0 +1,261 @@ +//! Double Bollinger Bands (Kathy Lien). + +use crate::error::{Error, Result}; +use crate::indicators::bollinger::BollingerBands; +use crate::traits::Indicator; + +/// Double Bollinger Bands output: two concentric bands at `k_inner` and +/// `k_outer` standard deviations around a shared SMA middle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DoubleBollingerOutput { + /// Outer upper band: `middle + k_outer · stddev`. + pub upper_outer: f64, + /// Inner upper band: `middle + k_inner · stddev`. + pub upper_inner: f64, + /// Middle band: SMA over the window. + pub middle: f64, + /// Inner lower band: `middle − k_inner · stddev`. + pub lower_inner: f64, + /// Outer lower band: `middle − k_outer · stddev`. + pub lower_outer: f64, +} + +/// Double Bollinger Bands: two concentric Bollinger envelopes (Kathy Lien). +/// +/// ```text +/// middle = SMA(period) +/// sigma = population stddev over the window +/// upper_outer = middle + k_outer · sigma // wide channel (often 2σ) +/// upper_inner = middle + k_inner · sigma // narrow channel (often 1σ) +/// lower_inner = middle − k_inner · sigma +/// lower_outer = middle − k_outer · sigma +/// ``` +/// +/// Lien's trading framework partitions price into three zones: +/// +/// - **Sell zone:** close below `lower_inner`. +/// - **Neutral zone:** close between `lower_inner` and `upper_inner`. +/// - **Buy zone:** close above `upper_inner`. +/// +/// A close beyond the outer band marks an extended move that traders typically +/// fade or trail. The constructor enforces `k_outer > k_inner` so the outputs +/// remain monotonically ordered. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DoubleBollinger, Indicator}; +/// +/// let mut indicator = DoubleBollinger::new(20, 1.0, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DoubleBollinger { + inner: BollingerBands, + k_inner: f64, + k_outer: f64, +} + +impl DoubleBollinger { + /// Construct a new Double Bollinger Bands indicator. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`, + /// [`Error::NonPositiveMultiplier`] if either `k_inner` or `k_outer` is + /// non-positive or non-finite, and [`Error::InvalidPeriod`] if + /// `k_outer <= k_inner` (the outer band must strictly enclose the inner + /// band so the zone-partitioning interpretation holds). + pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result { + if !k_inner.is_finite() || k_inner <= 0.0 || !k_outer.is_finite() || k_outer <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + if k_outer <= k_inner { + return Err(Error::InvalidPeriod { + message: "double bollinger requires k_outer > k_inner", + }); + } + // Build the inner state on the outer multiplier so the upper/lower + // outputs of `BollingerBands::update` already give us the outer band; + // the inner band is reconstructed from the same `stddev`. + Ok(Self { + inner: BollingerBands::new(period, k_outer)?, + k_inner, + k_outer, + }) + } + + /// Kathy Lien's classic configuration: SMA(20) with `±1σ` and `±2σ` bands. + pub fn classic() -> Self { + Self::new(20, 1.0, 2.0).expect("classic Double Bollinger parameters are valid") + } + + /// Configured `(period, k_inner, k_outer)`. + pub const fn parameters(&self) -> (usize, f64, f64) { + (self.inner.period(), self.k_inner, self.k_outer) + } +} + +impl Indicator for DoubleBollinger { + type Input = f64; + type Output = DoubleBollingerOutput; + + fn update(&mut self, value: f64) -> Option { + let o = self.inner.update(value)?; + Some(DoubleBollingerOutput { + upper_outer: o.upper, + upper_inner: o.middle + self.k_inner * o.stddev, + middle: o.middle, + lower_inner: o.middle - self.k_inner * o.stddev, + lower_outer: o.lower, + }) + } + + fn reset(&mut self) { + self.inner.reset(); + } + + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + + fn name(&self) -> &'static str { + "DoubleBollinger" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + DoubleBollinger::new(0, 1.0, 2.0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + DoubleBollinger::new(20, 0.0, 2.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + DoubleBollinger::new(20, 1.0, -2.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + DoubleBollinger::new(20, f64::NAN, 2.0), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn rejects_outer_not_greater_than_inner() { + assert!(matches!( + DoubleBollinger::new(20, 2.0, 1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + DoubleBollinger::new(20, 2.0, 2.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let db = DoubleBollinger::classic(); + let (p, ki, ko) = db.parameters(); + assert_eq!(p, 20); + assert_relative_eq!(ki, 1.0, epsilon = 1e-12); + assert_relative_eq!(ko, 2.0, epsilon = 1e-12); + assert_eq!(db.warmup_period(), 20); + assert_eq!(db.name(), "DoubleBollinger"); + } + + #[test] + fn constant_series_collapses_all_bands() { + let mut db = DoubleBollinger::new(10, 1.0, 2.0).unwrap(); + let last = db + .batch(&[5.0_f64; 20]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.upper_outer, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.upper_inner, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower_inner, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower_outer, 5.0, epsilon = 1e-12); + } + + #[test] + fn bands_strictly_ordered_with_dispersion() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0) + .collect(); + let mut db = DoubleBollinger::classic(); + for o in db.batch(&prices).into_iter().flatten() { + assert!(o.upper_outer >= o.upper_inner); + assert!(o.upper_inner >= o.middle); + assert!(o.middle >= o.lower_inner); + assert!(o.lower_inner >= o.lower_outer); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..50).map(|i| f64::from(i) * 0.7).collect(); + let mut a = DoubleBollinger::new(10, 1.0, 2.0).unwrap(); + let mut b = DoubleBollinger::new(10, 1.0, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut db = DoubleBollinger::new(5, 1.0, 2.0).unwrap(); + db.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(db.is_ready()); + db.reset(); + assert!(!db.is_ready()); + assert_eq!(db.update(1.0), None); + } + + /// The inner band must agree with running a separate `BollingerBands` at + /// the inner multiplier. + #[test] + fn inner_band_matches_separate_bollinger() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0) + .collect(); + let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap(); + let mut bb_inner = BollingerBands::new(20, 1.0).unwrap(); + let mut bb_outer = BollingerBands::new(20, 2.0).unwrap(); + for p in &prices { + let d = db.update(*p); + let i = bb_inner.update(*p); + let o = bb_outer.update(*p); + if let (Some(d), Some(i), Some(o)) = (d, i, o) { + assert_relative_eq!(d.middle, i.middle, epsilon = 1e-9); + assert_relative_eq!(d.upper_inner, i.upper, epsilon = 1e-9); + assert_relative_eq!(d.lower_inner, i.lower, epsilon = 1e-9); + assert_relative_eq!(d.upper_outer, o.upper, epsilon = 1e-9); + assert_relative_eq!(d.lower_outer, o.lower, epsilon = 1e-9); + } + } + } +} diff --git a/crates/wickra-core/src/indicators/double_top_bottom.rs b/crates/wickra-core/src/indicators/double_top_bottom.rs new file mode 100644 index 0000000..be8cf90 --- /dev/null +++ b/crates/wickra-core/src/indicators/double_top_bottom.rs @@ -0,0 +1,188 @@ +//! Double Top / Double Bottom reversal chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern. +/// +/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold +/// zig-zag, [`SWING_THRESHOLD`] = 5%). A pattern is recognised on the bar that +/// confirms the **second** matching extreme: +/// +/// ```text +/// double top : … High₁ , Low , High₂ with High₁ ≈ High₂ → -1 (bearish) +/// double bottom : … Low₁ , High , Low₂ with Low₁ ≈ Low₂ → +1 (bullish) +/// ``` +/// +/// Two extremes count as the same level when they are within +/// [`LEVEL_TOLERANCE`] (3%) of each other. Because pivots strictly alternate +/// high/low, the trough between the twin tops (or the peak between the twin +/// bottoms) is guaranteed to sit beyond both, so no extra separation check is +/// needed. +/// +/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on +/// every other bar (including warmup and bars that confirm a pivot which does +/// not complete the pattern). Like the candlestick family this detector never +/// returns `None`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DoubleTopBottom, Indicator}; +/// +/// let mut indicator = DoubleTopBottom::new(); +/// for (i, &(high, low)) in [ +/// (100.0, 99.5), +/// (120.0, 119.5), +/// (110.0, 100.0), // confirms the first top at 120 +/// (120.0, 119.0), // confirms the trough at 100 +/// (115.0, 110.0), // confirms the second top at 120 → double top +/// ] +/// .iter() +/// .enumerate() +/// { +/// let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap(); +/// let signal = indicator.update(c).unwrap(); +/// if i == 4 { +/// assert_eq!(signal, -1.0); +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct DoubleTopBottom { + swing: SwingTracker, + has_emitted: bool, +} + +impl DoubleTopBottom { + /// Construct a new Double Top / Double Bottom detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 3), + has_emitted: false, + } + } +} + +impl Default for DoubleTopBottom { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for DoubleTopBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 3 { + return Some(0.0); + } + let first = pivots[pivots.len() - 3]; + let last = pivots[pivots.len() - 1]; + if approx_equal(first.price, last.price, LEVEL_TOLERANCE) { + // `last` is the just-confirmed extreme: a high → double top (bearish), + // a low → double bottom (bullish). + return Some(if last.direction > 0.0 { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // The first complete pattern needs three confirmed pivots; the earliest + // bar that can confirm a third pivot is the fifth. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DoubleTopBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = DoubleTopBottom::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = DoubleTopBottom::new(); + assert_eq!(indicator.name(), "DoubleTopBottom"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!DoubleTopBottom::default().is_ready()); + } + + #[test] + fn double_top_is_minus_one() { + // Twin highs 120 / 120 with a 100 trough → double top on the second. + let out = run(&[120.0, 100.0, 120.0]); + assert_eq!(*out.last().unwrap(), -1.0); + // All earlier bars are warmup / non-completing. + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn double_bottom_is_plus_one() { + // Lead high, then twin lows 100 / 99 around a 120 peak → double bottom. + let out = run(&[130.0, 100.0, 120.0, 99.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn unequal_tops_do_not_trigger() { + // Second top 140 diverges from the first (120) → no pattern. + let out = run(&[120.0, 100.0, 140.0]); + assert_eq!(*out.last().unwrap(), 0.0); + assert!(out.iter().all(|&x| x == 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = DoubleTopBottom::new(); + for c in candles_for_pivots(&[120.0, 100.0, 120.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 120.0]); + let mut a = DoubleTopBottom::new(); + let mut b = DoubleTopBottom::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/downside_gap_three_methods.rs b/crates/wickra-core/src/indicators/downside_gap_three_methods.rs new file mode 100644 index 0000000..e3d9ee5 --- /dev/null +++ b/crates/wickra-core/src/indicators/downside_gap_three_methods.rs @@ -0,0 +1,210 @@ +//! Downside Gap Three Methods candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Downside Gap Three Methods — a 3-bar bearish continuation. Two black candles +/// decline with a downside body gap between them, then a white candle opens inside +/// the second body and closes inside the first body, partially filling the gap +/// without erasing the prior decline. +/// +/// ```text +/// bar1 black, bar2 black +/// downside body gap: open2 < close1 (bar2's body sits entirely below bar1's) +/// bar3 white, opens within bar2's body and closes within bar1's body +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Downside Gap +/// Three Methods is a single-direction (bearish-only) continuation, so it never +/// emits `+1.0`; its bullish mirror is [`crate::UpsideGapThreeMethods`]. The first +/// two bars always return `0.0` because the three-bar window is not yet filled. +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DownsideGapThreeMethods, Indicator}; +/// +/// let mut indicator = DownsideGapThreeMethods::new(); +/// indicator.update(Candle::new(13.0, 13.2, 11.8, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(11.0, 11.1, 9.8, 10.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.5, 12.6, 10.4, 12.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DownsideGapThreeMethods { + c1: Option, + c2: Option, + has_emitted: bool, +} + +impl DownsideGapThreeMethods { + /// Construct a new Downside Gap Three Methods detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + has_emitted: false, + } + } +} + +impl Indicator for DownsideGapThreeMethods { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + self.c1 = self.c2; + self.c2 = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + // bar1 and bar2 are both black. + if bar1.close >= bar1.open || bar2.close >= bar2.open { + return Some(0.0); + } + // Downside body gap: bar2's body sits entirely below bar1's. + if bar2.open >= bar1.close { + return Some(0.0); + } + // bar3 is white. + if candle.close <= candle.open { + return Some(0.0); + } + // bar3 opens within bar2's body and closes within bar1's body. + if candle.open > bar2.close + && candle.open < bar2.open + && candle.close > bar1.close + && candle.close < bar1.open + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DownsideGapThreeMethods" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = DownsideGapThreeMethods::new(); + assert_eq!(t.name(), "DownsideGapThreeMethods"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn downside_gap_three_methods_is_minus_one() { + let mut t = DownsideGapThreeMethods::new(); + assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 11.1, 9.8, 10.0, 1)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(-1.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = DownsideGapThreeMethods::new(); + assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 11.1, 9.8, 10.0, 1)), Some(0.0)); + } + + #[test] + fn non_black_first_bars_yield_zero() { + let mut t = DownsideGapThreeMethods::new(); + // bar1 is white. + t.update(c(11.0, 13.2, 10.8, 13.0, 0)); + t.update(c(11.0, 11.1, 9.8, 10.0, 1)); + assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = DownsideGapThreeMethods::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + // bar2 opens above bar1's close -> no downside body gap. + t.update(c(12.5, 12.6, 11.4, 11.5, 1)); + assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.0, 2)), Some(0.0)); + } + + #[test] + fn third_bar_not_white_yields_zero() { + let mut t = DownsideGapThreeMethods::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + t.update(c(11.0, 11.1, 9.8, 10.0, 1)); + // bar3 black. + assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(0.0)); + } + + #[test] + fn third_bar_outside_bodies_yields_zero() { + let mut t = DownsideGapThreeMethods::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + t.update(c(11.0, 11.1, 9.8, 10.0, 1)); + // bar3 white but closes above bar1's body. + assert_eq!(t.update(c(10.5, 14.0, 10.4, 13.5, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 200.0 - i as f64; + c(base, base + 0.1, base - 5.2, base - 5.0, i) + }) + .collect(); + let mut a = DownsideGapThreeMethods::new(); + let mut b = DownsideGapThreeMethods::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = DownsideGapThreeMethods::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + t.update(c(11.0, 11.1, 9.8, 10.0, 1)); + t.update(c(10.5, 12.6, 10.4, 12.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/dpo.rs b/crates/wickra-core/src/indicators/dpo.rs new file mode 100644 index 0000000..d3aa627 --- /dev/null +++ b/crates/wickra-core/src/indicators/dpo.rs @@ -0,0 +1,224 @@ +//! Detrended Price Oscillator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Detrended Price Oscillator — strips the trend out of price to expose its +/// shorter cycles. +/// +/// Instead of comparing price to a *current* moving average, DPO compares a +/// **past** price — shifted back by `period / 2 + 1` bars — to the moving +/// average of the window: +/// +/// ```text +/// shift = period / 2 + 1 +/// DPO_t = price_{t − shift} − SMA(period)_t +/// ``` +/// +/// Because the price is taken from roughly half a cycle back, the dominant +/// trend cancels out and what remains oscillates around zero — making the +/// peak-to-peak cycle length easy to read. DPO is **not** a momentum +/// indicator and is not meant to track the latest bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Dpo}; +/// +/// let mut indicator = Dpo::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 10.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Dpo { + period: usize, + shift: usize, + /// Window of the most recent `capacity` prices, oldest at the front. + capacity: usize, + window: VecDeque, + sum: f64, + last: Option, +} + +impl Dpo { + /// Construct a new DPO with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let shift = period / 2 + 1; + // The window must cover both the SMA (`period` prices) and the + // look-back (`shift + 1` prices: the current bar plus `shift` history). + let capacity = period.max(shift + 1); + Ok(Self { + period, + shift, + capacity, + window: VecDeque::with_capacity(capacity), + sum: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// The look-back shift `period / 2 + 1`. + pub const fn shift(&self) -> usize { + self.shift + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Dpo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the window is left untouched. + return self.last; + } + self.window.push_back(input); + self.sum += input; + let len = self.window.len(); + if len > self.period { + // The price that just left the SMA window. + self.sum -= self.window[len - 1 - self.period]; + } + if self.window.len() > self.capacity { + self.window.pop_front(); + } + if self.window.len() < self.capacity { + return None; + } + let sma = self.sum / self.period as f64; + // `price_{t - shift}` — index counts back from the newest bar. + let shifted = self.window[self.window.len() - 1 - self.shift]; + let dpo = shifted - sma; + self.last = Some(dpo); + Some(dpo) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.capacity + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "DPO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Dpo::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (73-85) and the + /// Indicator-impl `name` body (132-134). `shift` is already covered + /// by `shift_is_half_period_plus_one`; `warmup_period` by + /// `reference_values`. + #[test] + fn accessors_and_metadata() { + let mut dpo = Dpo::new(20).unwrap(); + assert_eq!(dpo.period(), 20); + assert_eq!(dpo.name(), "DPO"); + assert_eq!(dpo.value(), None); + for i in 1..=dpo.warmup_period() { + dpo.update(f64::from(u32::try_from(i).unwrap())); + } + assert!(dpo.value().is_some()); + } + + #[test] + fn shift_is_half_period_plus_one() { + assert_eq!(Dpo::new(20).unwrap().shift(), 11); + assert_eq!(Dpo::new(4).unwrap().shift(), 3); + } + + #[test] + fn reference_values() { + // DPO(4): shift = 3, capacity = max(4, 4) = 4. + // At input 4: window [1,2,3,4], SMA = 2.5, price[t-3] = 1 -> 1 - 2.5 = -1.5. + let mut dpo = Dpo::new(4).unwrap(); + let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(dpo.warmup_period(), 4); + assert_eq!(out[0], None); + assert_eq!(out[2], None); + assert_relative_eq!(out[3].unwrap(), -1.5, epsilon = 1e-12); + assert_relative_eq!(out[4].unwrap(), -1.5, epsilon = 1e-12); + assert_relative_eq!(out[5].unwrap(), -1.5, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + // A flat series: the shifted price equals the SMA, so DPO is 0. + let mut dpo = Dpo::new(10).unwrap(); + let out = dpo.batch(&[50.0; 40]); + for v in out.iter().skip(dpo.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut dpo = Dpo::new(4).unwrap(); + let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(dpo.update(f64::NAN), last); + assert_eq!(dpo.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut dpo = Dpo::new(4).unwrap(); + dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert!(dpo.is_ready()); + dpo.reset(); + assert!(!dpo.is_ready()); + assert_eq!(dpo.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 7.0) + .collect(); + let batch = Dpo::new(20).unwrap().batch(&prices); + let mut b = Dpo::new(20).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/dragonfly_doji.rs b/crates/wickra-core/src/indicators/dragonfly_doji.rs new file mode 100644 index 0000000..3b37b51 --- /dev/null +++ b/crates/wickra-core/src/indicators/dragonfly_doji.rs @@ -0,0 +1,163 @@ +//! Dragonfly Doji candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Dragonfly Doji — a single-bar bullish reversal. Open, close, and high sit at +/// the top of the bar while a long lower shadow shows price was driven down hard +/// and then bid all the way back to the open — buyers rejecting the lows. +/// +/// ```text +/// range = high − low +/// doji = |close − open| <= 0.1 * range +/// no upper wick = high − max(open, close) <= 0.1 * range +/// long lower = min(open, close) − low >= 0.5 * range +/// ``` +/// +/// Output is `+1.0` when the dragonfly prints and `0.0` otherwise. Dragonfly Doji +/// is a single-direction (bullish-only) shape, so it never emits `−1.0`. Body and +/// shadow thresholds follow the geometric house style (fixed fractions of the bar +/// range) rather than TA-Lib's rolling averages. Pattern-shape check only — no +/// trend filter is applied; combine with a trend indicator for actionable +/// signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DragonflyDoji, Indicator}; +/// +/// let mut indicator = DragonflyDoji::new(); +/// // Body at the top, long lower shadow. +/// let candle = Candle::new(10.0, 10.05, 6.0, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DragonflyDoji { + has_emitted: bool, +} + +impl DragonflyDoji { + /// Construct a new Dragonfly Doji detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for DragonflyDoji { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + if (candle.close - candle.open).abs() > 0.1 * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if upper <= 0.1 * range && lower >= 0.5 * range { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DragonflyDoji" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = DragonflyDoji::new(); + assert_eq!(t.name(), "DragonflyDoji"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn dragonfly_is_plus_one() { + let mut t = DragonflyDoji::new(); + assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(1.0)); + } + + #[test] + fn upper_shadow_yields_zero() { + let mut t = DragonflyDoji::new(); + // Long upper shadow -> not a dragonfly (this is a gravestone shape). + assert_eq!(t.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(0.0)); + } + + #[test] + fn short_lower_shadow_yields_zero() { + let mut t = DragonflyDoji::new(); + // Body at the top but the lower shadow is too short. + assert_eq!(t.update(c(10.0, 10.05, 9.6, 10.0, 0)), Some(0.0)); + } + + #[test] + fn non_doji_yields_zero() { + let mut t = DragonflyDoji::new(); + assert_eq!(t.update(c(10.0, 12.0, 6.0, 11.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = DragonflyDoji::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 0.05, base - 4.0, base, i) + }) + .collect(); + let mut a = DragonflyDoji::new(); + let mut b = DragonflyDoji::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = DragonflyDoji::new(); + t.update(c(10.0, 10.05, 6.0, 10.0, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/drawdown_duration.rs b/crates/wickra-core/src/indicators/drawdown_duration.rs new file mode 100644 index 0000000..6412e83 --- /dev/null +++ b/crates/wickra-core/src/indicators/drawdown_duration.rs @@ -0,0 +1,174 @@ +//! Drawdown Duration — bars since the last all-time peak ("time under water"). + +use crate::traits::Indicator; + +/// Cumulative drawdown duration in bars. +/// +/// Each `update` receives one equity-curve sample. The indicator tracks the +/// **running all-time peak** seen since construction (or last `reset`) and +/// reports how many bars have elapsed since that peak was set: +/// +/// ```text +/// peak_t = max(input over [0..=t]) +/// duration_t = bars elapsed since peak_t was first set +/// ``` +/// +/// A new peak resets the duration to `0`. As long as the series stays under +/// water the duration grows linearly with each bar. +/// +/// The indicator emits a value on every bar (no warmup beyond the first +/// input) and runs in O(1) per `update`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DrawdownDuration, Indicator}; +/// +/// let mut dd = DrawdownDuration::new(); +/// assert_eq!(dd.update(100.0), Some(0)); // first bar -> new peak +/// assert_eq!(dd.update(95.0), Some(1)); // 1 bar under water +/// assert_eq!(dd.update(90.0), Some(2)); // 2 bars under water +/// assert_eq!(dd.update(110.0), Some(0)); // new peak -> reset +/// ``` +#[derive(Debug, Clone, Default)] +pub struct DrawdownDuration { + peak: f64, + bars_under_water: u32, + seen: bool, +} + +impl DrawdownDuration { + /// Construct a new Drawdown Duration tracker. + pub const fn new() -> Self { + Self { + peak: f64::NEG_INFINITY, + bars_under_water: 0, + seen: false, + } + } + + /// Bars elapsed since the running all-time peak was set. + pub const fn value(&self) -> Option { + if self.seen { + Some(self.bars_under_water) + } else { + None + } + } +} + +impl Indicator for DrawdownDuration { + type Input = f64; + type Output = u32; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if !self.seen || input >= self.peak { + self.peak = input; + self.bars_under_water = 0; + } else { + self.bars_under_water = self.bars_under_water.saturating_add(1); + } + self.seen = true; + Some(self.bars_under_water) + } + + fn reset(&mut self) { + self.peak = f64::NEG_INFINITY; + self.bars_under_water = 0; + self.seen = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.seen + } + + fn name(&self) -> &'static str { + "DrawdownDuration" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut d = DrawdownDuration::new(); + assert_eq!(d.name(), "DrawdownDuration"); + assert_eq!(d.warmup_period(), 1); + assert_eq!(d.value(), None); + d.update(100.0); + assert_eq!(d.value(), Some(0)); + } + + #[test] + fn first_bar_is_peak() { + let mut d = DrawdownDuration::new(); + assert_eq!(d.update(100.0), Some(0)); + } + + #[test] + fn under_water_counter_increments() { + let mut d = DrawdownDuration::new(); + d.update(100.0); + assert_eq!(d.update(90.0), Some(1)); + assert_eq!(d.update(80.0), Some(2)); + assert_eq!(d.update(85.0), Some(3)); + } + + #[test] + fn new_peak_resets_counter() { + let mut d = DrawdownDuration::new(); + d.update(100.0); + d.update(90.0); + d.update(80.0); + assert_eq!(d.update(105.0), Some(0)); + assert_eq!(d.update(95.0), Some(1)); + } + + #[test] + fn equal_value_is_treated_as_peak() { + let mut d = DrawdownDuration::new(); + d.update(100.0); + assert_eq!(d.update(100.0), Some(0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut d = DrawdownDuration::new(); + d.update(100.0); + d.update(90.0); + let v = d.value(); + assert_eq!(d.update(f64::NAN), v); + assert_eq!(d.update(f64::INFINITY), v); + } + + #[test] + fn reset_clears_state() { + let mut d = DrawdownDuration::new(); + d.batch(&[100.0, 90.0, 80.0]); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.update(100.0), Some(0)); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..30) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + let batch = DrawdownDuration::new().batch(&prices); + let mut s = DrawdownDuration::new(); + let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/dumpling_top.rs b/crates/wickra-core/src/indicators/dumpling_top.rs new file mode 100644 index 0000000..a3bd715 --- /dev/null +++ b/crates/wickra-core/src/indicators/dumpling_top.rs @@ -0,0 +1,215 @@ +//! Dumpling Top — a rounded top (dome) confirmed by a breakdown. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Dumpling Top — the bearish mirror of the [`FryPanBottom`](crate::FryPanBottom): +/// a gently rounded **top** (dome) across the window, confirmed by a close back +/// below where it started. +/// +/// ```text +/// over the last `period` closes: +/// the maximum close sits in the middle third of the window (the "dome") +/// the latest close is below the first close (the breakdown) +/// signal = −1 when both hold, else 0 +/// ``` +/// +/// The dumpling top is a distribution pattern: price rounds over at the top as +/// buying fades, then rolls down through the level it rose from. Detection requires +/// a *central* high (a symmetric dome, not a one-sided spike) and a close below the +/// window's opening level. The output is `−1.0` (pattern) or `0.0`. +/// +/// The first value lands after `period` inputs; each `update` scans the window in +/// O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, DumplingTop}; +/// +/// let mut indicator = DumplingTop::new(9).unwrap(); +/// let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0]; +/// let mut last = None; +/// for &cl in &closes { +/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert_eq!(last, Some(-1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct DumplingTop { + period: usize, + closes: VecDeque, + last: Option, +} + +impl DumplingTop { + /// Construct a Dumpling Top over `period` bars. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 5`. + pub fn new(period: usize) -> Result { + if period < 5 { + return Err(Error::InvalidPeriod { + message: "dumpling top needs period >= 5", + }); + } + Ok(Self { + period, + closes: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for DumplingTop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.closes.len() == self.period { + self.closes.pop_front(); + } + self.closes.push_back(candle.close); + if self.closes.len() < self.period { + return None; + } + let first = *self.closes.front().expect("non-empty"); + let last = *self.closes.back().expect("non-empty"); + let mut max_idx = 0; + let mut max_val = f64::NEG_INFINITY; + for (i, &v) in self.closes.iter().enumerate() { + if v > max_val { + max_val = v; + max_idx = i; + } + } + let lo = self.period / 4; + let hi = self.period - self.period / 4; + let dome = max_idx >= lo && max_idx < hi; + let broke_down = last < first && last < max_val; + let v = if dome && broke_down { -1.0 } else { 0.0 }; + self.last = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.closes.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "DumplingTop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64) -> Candle { + Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0) + } + + #[test] + fn rejects_small_period() { + assert!(matches!( + DumplingTop::new(4), + Err(Error::InvalidPeriod { .. }) + )); + assert!(DumplingTop::new(5).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let d = DumplingTop::new(9).unwrap(); + assert_eq!(d.period(), 9); + assert_eq!(d.warmup_period(), 9); + assert_eq!(d.name(), "DumplingTop"); + assert!(!d.is_ready()); + assert_eq!(d.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut d = DumplingTop::new(5).unwrap(); + let out = d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0), c(98.0)]); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn rounded_top_then_breakdown_signals() { + let mut d = DumplingTop::new(9).unwrap(); + let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0]; + let candles: Vec = closes.iter().map(|&x| c(x)).collect(); + let last = d.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, -1.0); + } + + #[test] + fn one_sided_rise_is_zero() { + let mut d = DumplingTop::new(9).unwrap(); + let candles: Vec = (0..9).map(|i| c(100.0 + f64::from(i))).collect(); + let last = d.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn no_breakdown_is_zero() { + let mut d = DumplingTop::new(9).unwrap(); + let closes = [ + 100.0, 102.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, + ]; + let candles: Vec = closes.iter().map(|&x| c(x)).collect(); + let last = d.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut d = DumplingTop::new(5).unwrap(); + d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0)]); + assert!(d.is_ready()); + d.reset(); + assert!(!d.is_ready()); + assert_eq!(d.value(), None); + assert_eq!(d.update(c(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0)) + .collect(); + let batch = DumplingTop::new(9).unwrap().batch(&candles); + let mut b = DumplingTop::new(9).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/dx.rs b/crates/wickra-core/src/indicators/dx.rs new file mode 100644 index 0000000..3acb2ed --- /dev/null +++ b/crates/wickra-core/src/indicators/dx.rs @@ -0,0 +1,232 @@ +//! Directional Movement Index (DX), Wilder-smoothed. + +use crate::error::{Error, Result}; +use crate::indicators::adx::directional_movement; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wilder's Directional Movement Index (`DX`). +/// +/// `DX = 100 · |+DI − −DI| / (+DI + −DI)`, the un-smoothed precursor to +/// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional +/// indicators are derived from Wilder-smoothed `+DM`, `−DM` and true range over +/// `period` bars, so the first value is emitted after `period + 1` candles. +/// +/// `DX` ranges over `[0, 100]`: high when one side of the directional system +/// clearly dominates (a strong trend) and near zero when `+DI` and `−DI` are +/// balanced (a range). When both directional indicators are zero — a perfectly +/// flat market — the index returns `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Dx}; +/// +/// let mut indicator = Dx::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Dx { + period: usize, + prev: Option, + plus_dm_seed: f64, + minus_dm_seed: f64, + tr_seed: f64, + seed_count: usize, + plus_dm_smooth: Option, + minus_dm_smooth: Option, + tr_smooth: Option, +} + +impl Dx { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + plus_dm_seed: 0.0, + minus_dm_seed: 0.0, + tr_seed: 0.0, + seed_count: 0, + plus_dm_smooth: None, + minus_dm_smooth: None, + tr_smooth: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Dx { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + self.prev = Some(candle); + + let (plus_dm, minus_dm) = directional_movement(&prev, &candle); + let tr = candle.true_range(Some(prev.close)); + let n = self.period as f64; + + let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) = + (self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth) + { + let p_new = p - p / n + plus_dm; + let m_new = m - m / n + minus_dm; + let t_new = t - t / n + tr; + self.plus_dm_smooth = Some(p_new); + self.minus_dm_smooth = Some(m_new); + self.tr_smooth = Some(t_new); + (p_new, m_new, t_new) + } else { + self.plus_dm_seed += plus_dm; + self.minus_dm_seed += minus_dm; + self.tr_seed += tr; + self.seed_count += 1; + if self.seed_count < self.period { + return None; + } + self.plus_dm_smooth = Some(self.plus_dm_seed); + self.minus_dm_smooth = Some(self.minus_dm_seed); + self.tr_smooth = Some(self.tr_seed); + (self.plus_dm_seed, self.minus_dm_seed, self.tr_seed) + }; + + let (plus_di, minus_di) = if tr_v == 0.0 { + (0.0, 0.0) + } else { + (100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v) + }; + let di_sum = plus_di + minus_di; + let dx = if di_sum == 0.0 { + 0.0 + } else { + 100.0 * (plus_di - minus_di).abs() / di_sum + }; + Some(dx) + } + + fn reset(&mut self) { + self.prev = None; + self.plus_dm_seed = 0.0; + self.minus_dm_seed = 0.0; + self.tr_seed = 0.0; + self.seed_count = 0; + self.plus_dm_smooth = None; + self.minus_dm_smooth = None; + self.tr_smooth = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.tr_smooth.is_some() + } + + fn name(&self) -> &'static str { + "DX" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Dx::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let dx = Dx::new(7).unwrap(); + assert_eq!(dx.period(), 7); + assert_eq!(dx.name(), "DX"); + assert_eq!(dx.warmup_period(), 7); + assert!(!dx.is_ready()); + } + + #[test] + fn strong_trend_drives_dx_high() { + // A clean uptrend has one-sided directional movement, so DX is large. + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i) * 2.0; + c(base + 1.0, base - 0.5, base + 0.5) + }) + .collect(); + let mut dx = Dx::new(3).unwrap(); + let out: Vec> = dx.batch(&candles); + assert_eq!(out[0], None); + assert!(out[3].is_some()); + let last = out.into_iter().flatten().last().unwrap(); + assert!(last > 50.0 && last <= 100.0); + assert!(dx.is_ready()); + } + + #[test] + fn flat_market_returns_zero() { + // Both directional indicators collapse to zero -> DX is zero. + let candles: Vec = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect(); + let mut dx = Dx::new(3).unwrap(); + let last = dx.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn balanced_directional_movement_is_low() { + // Alternating up and down bars of equal magnitude keep +DI and -DI close, + // so DX stays well below a trending reading. + let candles: Vec = (0..30) + .map(|i| { + let base = if i % 2 == 0 { 100.0 } else { 101.0 }; + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut dx = Dx::new(5).unwrap(); + let last = dx.batch(&candles).into_iter().flatten().last().unwrap(); + assert!((0.0..=100.0).contains(&last)); + } + + #[test] + fn reset_restores_initial_state() { + let candles: Vec = (0..6) + .map(|i| { + let base = 100.0 + f64::from(i) * 2.0; + c(base + 1.0, base - 0.5, base + 0.5) + }) + .collect(); + let mut dx = Dx::new(3).unwrap(); + let _ = dx.batch(&candles); + assert!(dx.is_ready()); + dx.reset(); + assert!(!dx.is_ready()); + assert_eq!(dx.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/dynamic_momentum_index.rs b/crates/wickra-core/src/indicators/dynamic_momentum_index.rs new file mode 100644 index 0000000..d722f7d --- /dev/null +++ b/crates/wickra-core/src/indicators/dynamic_momentum_index.rs @@ -0,0 +1,301 @@ +//! Dynamic Momentum Index (Chande's volatility-adaptive RSI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::indicators::std_dev::StdDev; +use crate::traits::Indicator; + +// Chande's definitional constants. +const STD_PERIOD: usize = 5; // volatility window +const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility +const MIN_PERIOD: usize = 5; // fastest RSI lookback +const MAX_PERIOD: usize = 30; // slowest RSI lookback + +/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in +/// volatile markets and lengthens in calm ones. +/// +/// A standard RSI uses a fixed period; the DMI varies it from the recent +/// volatility so the oscillator stays responsive when the market is fast and +/// smooth when it is quiet: +/// +/// ```text +/// vol = StdDev(close, 5) +/// vol_avg = SMA(vol, 10) +/// Vi = vol / vol_avg (volatility index) +/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback) +/// avg_gain, avg_loss = simple means of the last `td` price changes +/// DMI = 100 * avg_gain / (avg_gain + avg_loss) +/// ``` +/// +/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility +/// lengthens it toward `30` (slower). The averages of gains and losses are +/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as +/// the window length flexes. Output is bounded in `[0, 100]`; a flat market +/// returns the neutral `50`. +/// +/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change +/// buffer always holds enough history for any dynamic lookback up to `30`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DynamicMomentumIndex, Indicator}; +/// +/// let mut dmi = DynamicMomentumIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DynamicMomentumIndex { + period: usize, + vol: StdDev, + vol_avg: Sma, + prev_close: Option, + /// The last `MAX_PERIOD` price changes, oldest at the front. + changes: VecDeque, + last_vol_avg: Option, + last_value: Option, +} + +impl DynamicMomentumIndex { + /// Construct a DMI with the given base RSI period (Chande uses 14). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + vol: StdDev::new(STD_PERIOD)?, + vol_avg: Sma::new(STD_AVG_PERIOD)?, + prev_close: None, + changes: VecDeque::with_capacity(MAX_PERIOD), + last_vol_avg: None, + last_value: None, + }) + } + + /// Configured base period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// Dynamic lookback for the current volatility, clamped to `[5, 30]`. + fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize { + if vol_avg <= 0.0 || vol <= 0.0 { + // No measurable volatility -> slowest (calmest) lookback. + return MAX_PERIOD; + } + let vi = vol / vol_avg; + let td = (self.period as f64 / vi).round(); + // td is finite and positive here; clamp into the valid band. + (td as usize).clamp(MIN_PERIOD, MAX_PERIOD) + } +} + +impl Indicator for DynamicMomentumIndex { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + // Track the smoothed volatility on every close. + if let Some(v) = self.vol.update(input) { + self.last_vol_avg = self.vol_avg.update(v); + } + + // Record the price change. + if let Some(prev) = self.prev_close { + let change = input - prev; + if self.changes.len() == MAX_PERIOD { + self.changes.pop_front(); + } + self.changes.push_back(change); + } + self.prev_close = Some(input); + + let vol = self.vol.value()?; + let vol_avg = self.last_vol_avg?; + if self.changes.len() < MAX_PERIOD { + return None; + } + + let td = self.dynamic_period(vol, vol_avg); + // Average gains and losses over the last `td` changes. + let mut sum_gain = 0.0; + let mut sum_loss = 0.0; + for &c in self.changes.iter().skip(MAX_PERIOD - td) { + if c > 0.0 { + sum_gain += c; + } else if c < 0.0 { + sum_loss -= c; + } + } + let denom = sum_gain + sum_loss; + let v = if denom == 0.0 { + 50.0 + } else { + // Ratio first, then scale, so `100 * g / g` cannot round above 100. + 100.0 * (sum_gain / denom) + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.vol.reset(); + self.vol_avg.reset(); + self.prev_close = None; + self.changes.clear(); + self.last_vol_avg = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the + // binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter. + MAX_PERIOD + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "DynamicMomentumIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + DynamicMomentumIndex::new(0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessors `period` + `value` and the Indicator-impl + /// `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let dmi = DynamicMomentumIndex::new(14).unwrap(); + assert_eq!(dmi.period(), 14); + assert_eq!(dmi.value(), None); + assert_eq!(dmi.warmup_period(), 31); + assert_eq!(dmi.name(), "DynamicMomentumIndex"); + } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (0..50) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0) + .collect(); + let mut dmi = DynamicMomentumIndex::new(14).unwrap(); + let out = dmi.batch(&prices); + for (i, v) in out.iter().enumerate().take(30) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[30].is_some(), "first value at warmup_period - 1 = 30"); + } + + #[test] + fn pure_uptrend_is_one_hundred() { + // Every change positive -> avg_loss 0 -> 100, regardless of dynamic period. + let prices: Vec = (1..=60).map(f64::from).collect(); + let mut dmi = DynamicMomentumIndex::new(14).unwrap(); + let last = dmi.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn flat_market_is_neutral() { + // Constant prices: no volatility (dynamic period -> max) and no changes + // -> neutral 50. + let mut dmi = DynamicMomentumIndex::new(14).unwrap(); + let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-12); + } + + #[test] + fn output_stays_in_range() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0) + .collect(); + let mut dmi = DynamicMomentumIndex::new(14).unwrap(); + for v in dmi.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]"); + } + } + + #[test] + fn high_volatility_shortens_period() { + let dmi = DynamicMomentumIndex::new(14).unwrap(); + // Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7. + assert_eq!(dmi.dynamic_period(2.0, 1.0), 7); + // Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28. + assert_eq!(dmi.dynamic_period(0.5, 1.0), 28); + // Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN. + assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD); + assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD); + // Zero volatility -> slowest lookback. + assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD); + assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD); + } + + #[test] + fn ignores_non_finite_input() { + let mut dmi = DynamicMomentumIndex::new(14).unwrap(); + let ready = dmi + .batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::>()) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(dmi.update(f64::NAN), Some(ready)); + assert_eq!(dmi.update(f64::INFINITY), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut dmi = DynamicMomentumIndex::new(14).unwrap(); + dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::>()); + assert!(dmi.is_ready()); + dmi.reset(); + assert!(!dmi.is_ready()); + assert_eq!(dmi.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0) + .collect(); + let mut a = DynamicMomentumIndex::new(14).unwrap(); + let mut b = DynamicMomentumIndex::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/ease_of_movement.rs b/crates/wickra-core/src/indicators/ease_of_movement.rs new file mode 100644 index 0000000..aadca4f --- /dev/null +++ b/crates/wickra-core/src/indicators/ease_of_movement.rs @@ -0,0 +1,289 @@ +//! Ease of Movement (Arms). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Richard Arms' Ease of Movement — how far price travels per unit of volume. +/// +/// ```text +/// distance_t = (high_t + low_t)/2 − (high_{t−1} + low_{t−1})/2 +/// EMV_t = distance_t · (high_t − low_t) · divisor / volume_t +/// EOM_t = SMA(EMV, period)_t +/// ``` +/// +/// A large positive EMV means price climbed a long way on light volume — it +/// moved "easily"; a value near zero means heavy volume was needed to shift +/// price at all. The `divisor` only rescales the output: the conventional +/// `1e8` keeps `EMV` in a readable range for typical share volumes. A bar with +/// zero volume contributes `EMV = 0` (no trading carries no signal), as does a +/// zero-range bar. The first candle only seeds the previous midpoint, so the +/// first value appears on candle `period + 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, EaseOfMovement}; +/// +/// let mut indicator = EaseOfMovement::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EaseOfMovement { + period: usize, + divisor: f64, + prev_mid: Option, + window: VecDeque, + sum: f64, +} + +impl EaseOfMovement { + /// Construct an Ease of Movement with the conventional `1e8` volume divisor. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Self::with_divisor(period, 100_000_000.0) + } + + /// Construct an Ease of Movement with an explicit volume divisor. The + /// divisor is a pure output-scaling constant; pick whatever keeps `EMV` + /// readable for your instrument's volume magnitude. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `divisor` is not strictly positive + /// and finite. + pub fn with_divisor(period: usize, divisor: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !divisor.is_finite() || divisor <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + divisor, + prev_mid: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured volume divisor. + pub const fn divisor(&self) -> f64 { + self.divisor + } +} + +impl Indicator for EaseOfMovement { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let mid = f64::midpoint(candle.high, candle.low); + let Some(prev_mid) = self.prev_mid else { + // The first candle only establishes the previous midpoint. + self.prev_mid = Some(mid); + return None; + }; + let distance = mid - prev_mid; + let range = candle.high - candle.low; + let emv = if candle.volume == 0.0 { + // No volume traded — the move carries no ease-of-movement signal. + 0.0 + } else { + distance * range * self.divisor / candle.volume + }; + self.prev_mid = Some(mid); + + if self.window.len() == self.period { + self.sum -= self.window.pop_front().expect("non-empty"); + } + self.window.push_back(emv); + self.sum += emv; + if self.window.len() < self.period { + return None; + } + Some(self.sum / self.period as f64) + } + + fn reset(&mut self) { + self.prev_mid = None; + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // One seed candle establishes the first previous midpoint, then + // `period` EMV values fill the averaging window. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "EaseOfMovement" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // EOM(period = 1, divisor = 1): one EMV value is its own average. + // candle 1: midpoint (10 + 8)/2 = 9 only seeds the previous mid. + // candle 2: mid = (14 + 10)/2 = 12, distance = 3, range = 4, + // EMV = 3 * 4 * 1 / 100 = 0.12. + let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap(); + let out = eom.batch(&[ + candle(9.0, 10.0, 8.0, 9.0, 50.0, 0), + candle(12.0, 14.0, 10.0, 12.0, 100.0, 1), + ]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12); + } + + #[test] + fn rising_midpoints_yield_positive_eom() { + // Strictly rising midpoints on constant volume -> every EMV is + // positive, so the averaged EOM is positive. + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 100.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(14).unwrap(); + for v in eom.batch(&candles).into_iter().flatten() { + assert!(v > 0.0, "EOM {v} should be positive on a rising series"); + } + } + + #[test] + fn constant_series_yields_zero() { + // Unchanging candles -> zero distance -> EMV is zero throughout. + let candles: Vec = (0..30) + .map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i)) + .collect(); + let mut eom = EaseOfMovement::new(10).unwrap(); + for v in eom.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_contributes_zero() { + // A zero-volume bar yields EMV = 0 instead of dividing by zero. + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 0.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(10).unwrap(); + for v in eom.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn first_value_on_period_plus_one_candle() { + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 50.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(5).unwrap(); + let out = eom.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[5].is_some(), "first EOM lands at index period"); + assert_eq!(eom.warmup_period(), 6); + } + + #[test] + fn rejects_invalid_input() { + assert!(EaseOfMovement::new(0).is_err()); + assert!(EaseOfMovement::with_divisor(14, 0.0).is_err()); + assert!(EaseOfMovement::with_divisor(14, -1.0).is_err()); + assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err()); + } + + /// Cover the const accessors `period` / `divisor` (82-90) and the + /// Indicator-impl `name` body (141-143). Existing tests inspect EMV + /// output but never query the metadata methods. + #[test] + fn accessors_and_metadata() { + let emv = EaseOfMovement::new(14).unwrap(); + assert_eq!(emv.period(), 14); + // The canonical divisor (per the new() default) — keep in sync with src. + assert_relative_eq!(emv.divisor(), 100_000_000.0, epsilon = 1e-6); + assert_eq!(emv.name(), "EaseOfMovement"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 50.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(10).unwrap(); + eom.batch(&candles); + assert!(eom.is_ready()); + eom.reset(); + assert!(!eom.is_ready()); + assert_eq!(eom.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = EaseOfMovement::new(14).unwrap(); + let mut b = EaseOfMovement::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/effective_spread.rs b/crates/wickra-core/src/indicators/effective_spread.rs new file mode 100644 index 0000000..5c61cb0 --- /dev/null +++ b/crates/wickra-core/src/indicators/effective_spread.rs @@ -0,0 +1,157 @@ +//! Effective Spread — the realised cost of a single trade in basis points. + +use crate::microstructure::TradeQuote; +use crate::traits::Indicator; + +/// Effective Spread — twice the signed deviation of an executed trade price +/// from the prevailing mid, expressed in basis points of the mid. +/// +/// ```text +/// effectiveSpread = 2 · D · (tradePrice − mid) / mid · 10_000 (bps) +/// ``` +/// +/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell). The +/// factor of two scales the one-sided deviation up to a full round-trip cost so +/// it is directly comparable to the [quoted spread]: a marketable order that +/// fills exactly at the touch of an otherwise quoted-spread book pays an +/// effective spread equal to the quoted spread. Trades that fill *inside* the +/// spread (price improvement) read below the quoted spread; trades that walk +/// the book read above it. +/// +/// A buy printed above the mid (`tradePrice > mid`) and a sell printed below it +/// both yield a positive effective spread — the conventional sign, since the +/// aggressor pays in both cases. A trade printed on the wrong side of the mid +/// for its aggressor flag (a buy below the mid) reads negative, the signature of +/// price improvement or a stale/mislabelled quote. +/// +/// `Input = TradeQuote`, `Output = f64`. Stateless; ready after the first +/// trade-quote. +/// +/// [quoted spread]: crate::QuotedSpread +/// +/// # Example +/// +/// ``` +/// use wickra_core::{EffectiveSpread, Indicator, Side, Trade, TradeQuote}; +/// +/// let mut es = EffectiveSpread::new(); +/// // Buy filled at 100.05 against a mid of 100.0: +/// // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps. +/// let trade = Trade::new(100.05, 1.0, Side::Buy, 0).unwrap(); +/// let quote = TradeQuote::new(trade, 100.0).unwrap(); +/// assert!((es.update(quote).unwrap() - 10.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct EffectiveSpread { + has_emitted: bool, +} + +impl EffectiveSpread { + /// Construct a new effective-spread indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for EffectiveSpread { + type Input = TradeQuote; + type Output = f64; + + fn update(&mut self, quote: TradeQuote) -> Option { + self.has_emitted = true; + let sign = quote.trade.side.sign(); + Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.mid * 10_000.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "EffectiveSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::{Side, Trade}; + use crate::traits::BatchExt; + + fn quote(price: f64, side: Side, mid: f64) -> TradeQuote { + TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let es = EffectiveSpread::new(); + assert_eq!(es.name(), "EffectiveSpread"); + assert_eq!(es.warmup_period(), 1); + assert!(!es.is_ready()); + } + + #[test] + fn buy_above_mid_is_positive() { + let mut es = EffectiveSpread::new(); + // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps. + let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap(); + assert!((out - 10.0).abs() < 1e-9); + assert!(es.is_ready()); + } + + #[test] + fn sell_below_mid_is_positive() { + let mut es = EffectiveSpread::new(); + // 2 · (−1) · (99.95 − 100.0) / 100.0 · 10_000 = 10 bps. + let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap(); + assert!((out - 10.0).abs() < 1e-9); + } + + #[test] + fn price_improvement_reads_negative() { + let mut es = EffectiveSpread::new(); + // A buy filled below the mid: price improvement -> negative. + let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap(); + assert!(out < 0.0); + } + + #[test] + fn trade_at_mid_is_zero() { + let mut es = EffectiveSpread::new(); + assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let quotes: Vec = (0..20) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + let price = 100.0 + f64::from(i % 4) * 0.01; + quote(price, side, 100.0) + }) + .collect(); + let mut a = EffectiveSpread::new(); + let mut b = EffectiveSpread::new(); + assert_eq!( + a.batch("es), + quotes.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut es = EffectiveSpread::new(); + es.update(quote(100.05, Side::Buy, 100.0)); + assert!(es.is_ready()); + es.reset(); + assert!(!es.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/ehlers_stochastic.rs b/crates/wickra-core/src/indicators/ehlers_stochastic.rs new file mode 100644 index 0000000..ac7d708 --- /dev/null +++ b/crates/wickra-core/src/indicators/ehlers_stochastic.rs @@ -0,0 +1,224 @@ +//! Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input. +#![allow(clippy::doc_markdown)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::roofing_filter::RoofingFilter; +use crate::traits::Indicator; + +/// Ehlers' Adaptive Stochastic. +/// +/// Implements the construction described in *Cycle Analytics for Traders* +/// (Ehlers 2013, ch. 7): the raw price is first passed through a +/// [`RoofingFilter`] (high-pass + SuperSmoother bandpass) to isolate the +/// tradable cycle band, then the classic Stochastic %K formula is applied +/// to the filtered output over `period` bars and finally re-smoothed by a +/// 2-bar SuperSmoother. The result is a ±1-normalised oscillator that +/// reacts to cycles without trending bias from low-frequency drift. +/// +/// The output uses Ehlers' `2 * (X - MinX) / (MaxX - MinX) - 1` convention, +/// so the range is `[-1, +1]` rather than the conventional `[0, 100]`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, EhlersStochastic}; +/// +/// let mut es = EhlersStochastic::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = es.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EhlersStochastic { + period: usize, + roofing: RoofingFilter, + filtered_buf: VecDeque, + // Tiny 2-tap IIR (Ehlers uses a simple SMA(2) for the final smoothing). + prev_stoch: f64, + has_prev: bool, + last_value: Option, +} + +impl EhlersStochastic { + /// Construct with the rolling window length used by the inner stochastic. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + // Defaults match Ehlers' (10, 48) roofing filter cutoffs. + roofing: RoofingFilter::new(10, 48)?, + filtered_buf: VecDeque::with_capacity(period), + prev_stoch: 0.0, + has_prev: false, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for EhlersStochastic { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let filtered = self.roofing.update(input)?; + if self.filtered_buf.len() == self.period { + self.filtered_buf.pop_front(); + } + self.filtered_buf.push_back(filtered); + if self.filtered_buf.len() < self.period { + return None; + } + let max = self + .filtered_buf + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let min = self + .filtered_buf + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + let range = max - min; + let raw = if range > 0.0 { + ((filtered - min) / range).mul_add(2.0, -1.0) + } else { + 0.0 + }; + // 2-bar SMA smoothing. + let smoothed = if self.has_prev { + 0.5 * (raw + self.prev_stoch) + } else { + raw + }; + self.prev_stoch = raw; + self.has_prev = true; + self.last_value = Some(smoothed); + Some(smoothed) + } + + fn reset(&mut self) { + self.roofing.reset(); + self.filtered_buf.clear(); + self.prev_stoch = 0.0; + self.has_prev = false; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + self.roofing.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "EhlersStochastic" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(EhlersStochastic::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut es = EhlersStochastic::new(20).unwrap(); + assert_eq!(es.period(), 20); + assert_eq!(es.warmup_period(), 22); + assert_eq!(es.name(), "EhlersStochastic"); + assert!(!es.is_ready()); + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + es.batch(&prices); + assert!(es.is_ready()); + assert!(es.value().is_some()); + } + + #[test] + fn output_bounded_in_unit_interval() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut es = EhlersStochastic::new(20).unwrap(); + for v in es.batch(&prices).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "value out of band: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = EhlersStochastic::new(20).unwrap(); + let mut b = EhlersStochastic::new(20).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut es = EhlersStochastic::new(20).unwrap(); + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + es.batch(&prices); + let before = es.value(); + assert!(before.is_some()); + assert_eq!(es.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut es = EhlersStochastic::new(20).unwrap(); + let prices: Vec = (0..150) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + es.batch(&prices); + assert!(es.is_ready()); + es.reset(); + assert!(!es.is_ready()); + } + + #[test] + fn flat_window_emits_zero() { + // A constant series has zero high-pass output, so `max == min` and the + // `range > 0.0` guard takes the `0.0` fallback rather than dividing. + let mut es = EhlersStochastic::new(20).unwrap(); + for v in es.batch(&[100.0_f64; 150]).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } +} diff --git a/crates/wickra-core/src/indicators/ehma.rs b/crates/wickra-core/src/indicators/ehma.rs new file mode 100644 index 0000000..39899ab --- /dev/null +++ b/crates/wickra-core/src/indicators/ehma.rs @@ -0,0 +1,202 @@ +//! Exponential Hull Moving Average (EHMA). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Exponential Hull Moving Average: the Hull construction built from EMAs +/// instead of WMAs. +/// +/// ```text +/// EHMA = EMA( 2 · EMA(price, period/2) − EMA(price, period), round(sqrt(period)) ) +/// ``` +/// +/// Alan Hull's [`Hma`](crate::Hma) uses weighted moving averages; replacing them +/// with exponential moving averages keeps the same lag-reduction trick — a fast +/// half-length average minus a full-length one, smoothed over `sqrt(period)` — +/// while inheriting the EMA's strictly recursive O(1) update and infinite +/// (exponentially decaying) memory. The result is marginally smoother than the +/// WMA-based Hull at the cost of a little more lag. +/// +/// The half period is `(period / 2).max(1)` and the smoothing period is +/// `round(sqrt(period)).max(1)`, matching the rounding used by [`Hma`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Ehma}; +/// +/// let mut indicator = Ehma::new(9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ehma { + period: usize, + half_ema: Ema, + full_ema: Ema, + smooth_ema: Ema, +} + +impl Ehma { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let half = (period / 2).max(1); + let smooth = (period as f64).sqrt().round() as usize; + let smooth = smooth.max(1); + Ok(Self { + period, + half_ema: Ema::new(half)?, + full_ema: Ema::new(period)?, + smooth_ema: Ema::new(smooth)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Ehma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both component EMAs on every input so they warm up in parallel; + // gating the longer one behind the shorter would delay the first + // emission past `warmup_period()`. + let h = self.half_ema.update(input); + let f = self.full_ema.update(input); + let (h, f) = (h?, f?); + let diff = 2.0 * h - f; + self.smooth_ema.update(diff) + } + + fn reset(&mut self) { + self.half_ema.reset(); + self.full_ema.reset(); + self.smooth_ema.reset(); + } + + fn warmup_period(&self) -> usize { + // full_ema seeds at `period`, then smooth_ema needs another + // (round(sqrt(period)) - 1) values to seed. + let sm = (self.period as f64).sqrt().round() as usize; + self.period + sm.max(1) - 1 + } + + fn is_ready(&self) -> bool { + self.smooth_ema.is_ready() + } + + fn name(&self) -> &'static str { + "EHMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_constant_ehma() { + let mut ehma = Ehma::new(9).unwrap(); + let out = ehma.batch(&[10.0_f64; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 10.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100).map(|i| f64::from(i) * 0.7).collect(); + let mut a = Ehma::new(9).unwrap(); + let mut b = Ehma::new(9).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ehma = Ehma::new(9).unwrap(); + ehma.batch(&(1..=80).map(f64::from).collect::>()); + assert!(ehma.is_ready()); + ehma.reset(); + assert!(!ehma.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Ehma::new(0).is_err()); + } + + /// Cover the const accessor `period` and the Indicator-impl `name`. + /// `warmup_period` is covered by `first_emission_matches_warmup_period`. + #[test] + fn accessors_and_metadata() { + let ehma = Ehma::new(9).unwrap(); + assert_eq!(ehma.period(), 9); + assert_eq!(ehma.name(), "EHMA"); + } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut ehma = Ehma::new(9).unwrap(); + let out = ehma.batch(&prices); + let warmup = ehma.warmup_period(); + // full EMA seeds at 9, smooth EMA round(sqrt(9))=3 needs 2 more -> 11. + assert_eq!(warmup, 11); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first EHMA value must land at warmup_period - 1" + ); + } + + #[test] + fn matches_independent_emas() { + // The two component EMAs run as independent siblings on the price + // stream; EHMA must equal feeding three standalone EMAs and combining. + let prices: Vec = (1..=50) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut ehma = Ehma::new(9).unwrap(); + let mut half = Ema::new(4).unwrap(); // (9 / 2).max(1) + let mut full = Ema::new(9).unwrap(); + let mut smooth = Ema::new(3).unwrap(); // round(sqrt(9)) + for (i, &p) in prices.iter().enumerate() { + let got = ehma.update(p); + let want = match (half.update(p), full.update(p)) { + (Some(h), Some(f)) => smooth.update(2.0 * h - f), + _ => None, + }; + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (got, want) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn period_one_collapses_to_pass_through() { + // period 1: half=1, full=1, smooth=round(sqrt(1))=1; every EMA seeds on + // the first input, so EHMA(1) passes the price straight through. + let mut ehma = Ehma::new(1).unwrap(); + assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12); + assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/elder_impulse.rs b/crates/wickra-core/src/indicators/elder_impulse.rs new file mode 100644 index 0000000..919474c --- /dev/null +++ b/crates/wickra-core/src/indicators/elder_impulse.rs @@ -0,0 +1,243 @@ +//! Elder Impulse System. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::macd::MacdIndicator; +use crate::traits::Indicator; + +/// Alexander Elder's Impulse System — a tri-state momentum gauge combining the +/// slope of an `EMA` trend filter with the slope of the `MACD` histogram. +/// +/// On each bar Wickra reports: +/// +/// - `+1` ("green / buy") when both the `EMA` trend and the `MACD` histogram +/// are rising bar-over-bar. +/// - `−1` ("red / sell") when both are falling. +/// - `0` ("blue / neutral") when the two disagree. +/// +/// The defaults track Elder's *Come Into My Trading Room* parameterisation: +/// `EMA(13)` for the trend, `MACD(12, 26, 9)` for the histogram. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{ElderImpulse, Indicator}; +/// +/// let mut elder = ElderImpulse::classic(); +/// let mut last = None; +/// for i in 0..120 { +/// last = elder.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ElderImpulse { + ema_period: usize, + macd_fast: usize, + macd_slow: usize, + macd_signal: usize, + ema: Ema, + macd: MacdIndicator, + prev_ema: Option, + prev_hist: Option, + current: Option, +} + +impl ElderImpulse { + /// # Errors + /// Forwarded from [`Ema::new`] / [`MacdIndicator::new`]. + pub fn new( + ema_period: usize, + macd_fast: usize, + macd_slow: usize, + macd_signal: usize, + ) -> Result { + if ema_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + ema_period, + macd_fast, + macd_slow, + macd_signal, + ema: Ema::new(ema_period)?, + macd: MacdIndicator::new(macd_fast, macd_slow, macd_signal)?, + prev_ema: None, + prev_hist: None, + current: None, + }) + } + + /// Elder's recommended defaults `(ema_period = 13, macd = 12/26/9)`. + pub fn classic() -> Self { + Self::new(13, 12, 26, 9).expect("classic Elder Impulse parameters are valid") + } + + /// Configured `(ema_period, macd_fast, macd_slow, macd_signal)`. + pub const fn periods(&self) -> (usize, usize, usize, usize) { + ( + self.ema_period, + self.macd_fast, + self.macd_slow, + self.macd_signal, + ) + } +} + +impl Indicator for ElderImpulse { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both branches on every input so they warm in parallel. + let ema_now = self.ema.update(input); + let macd_now = self.macd.update(input); + let (ema_now, macd_now) = (ema_now?, macd_now?); + + // The Impulse needs two consecutive readings on both branches to + // judge direction. The first ready bar seeds prev_*; the second emits. + let prev_ema = self.prev_ema; + let prev_hist = self.prev_hist; + self.prev_ema = Some(ema_now); + self.prev_hist = Some(macd_now.histogram); + let prev_ema = prev_ema?; + let prev_hist = prev_hist?; + + let ema_rising = ema_now > prev_ema; + let ema_falling = ema_now < prev_ema; + let hist_rising = macd_now.histogram > prev_hist; + let hist_falling = macd_now.histogram < prev_hist; + + let value = if ema_rising && hist_rising { + 1.0 + } else if ema_falling && hist_falling { + -1.0 + } else { + 0.0 + }; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.ema.reset(); + self.macd.reset(); + self.prev_ema = None; + self.prev_hist = None; + self.current = None; + } + + fn warmup_period(&self) -> usize { + // MACD's warmup is slow + signal − 1; EMA's is ema_period. The + // slowest branch fires the *first* impulse-ready reading, but + // judging direction needs one *more* bar on top. + let macd_warmup = self.macd_slow + self.macd_signal - 1; + self.ema_period.max(macd_warmup) + 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "ElderImpulse" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + ElderImpulse::new(0, 12, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + ElderImpulse::new(13, 0, 26, 9), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_invalid_macd_params() { + // MacdIndicator validates fast < slow. + assert!(ElderImpulse::new(13, 26, 12, 9).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let elder = ElderImpulse::classic(); + assert_eq!(elder.periods(), (13, 12, 26, 9)); + assert_eq!(elder.name(), "ElderImpulse"); + } + + #[test] + fn classic_factory() { + assert_eq!(ElderImpulse::classic().periods(), (13, 12, 26, 9)); + } + + #[test] + fn constant_series_yields_neutral() { + // Both EMA and MACD-histogram are flat on a constant series, so + // neither is rising nor falling -> Impulse = 0. + let mut elder = ElderImpulse::classic(); + let out = elder.batch(&[42.0_f64; 120]); + // Take values from the post-warmup region. + for v in out.iter().skip(40).flatten() { + assert_eq!(*v, 0.0); + } + } + + #[test] + fn pure_uptrend_signals_buy() { + // Monotonic uptrend: EMA rises every bar; MACD histogram is positive + // and (after the slow EMA catches up) also rising bar-over-bar. + let mut elder = ElderImpulse::classic(); + for i in 1..=300 { + elder.update(f64::from(i)); + } + // The final reading should be +1 (buy) or 0 — never -1 on a clean + // up trend. + let v = elder.current.unwrap(); + assert!(v >= 0.0, "uptrend should not signal sell: {v}"); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut elder = ElderImpulse::new(3, 2, 4, 3).unwrap(); + // MACD warmup: 4 + 3 - 1 = 6; EMA warmup: 3; max = 6; +1 for the + // direction bar = 7. + assert_eq!(elder.warmup_period(), 7); + let prices: Vec = (1..=10).map(f64::from).collect(); + let out = elder.batch(&prices); + for v in out.iter().take(6) { + assert!(v.is_none()); + } + assert!(out[6].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = ElderImpulse::classic(); + let mut b = ElderImpulse::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut elder = ElderImpulse::classic(); + elder.batch(&(1..=200).map(f64::from).collect::>()); + assert!(elder.is_ready()); + elder.reset(); + assert!(!elder.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/elder_ray.rs b/crates/wickra-core/src/indicators/elder_ray.rs new file mode 100644 index 0000000..8a854ab --- /dev/null +++ b/crates/wickra-core/src/indicators/elder_ray.rs @@ -0,0 +1,192 @@ +//! Elder Ray — Bull Power and Bear Power. + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// One Elder Ray reading: the bull and bear power for a bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ElderRayOutput { + /// `high − EMA(close)`: how far buyers pushed price above the trend mean. + pub bull_power: f64, + /// `low − EMA(close)`: how far sellers pushed price below the trend mean + /// (negative in a normal market). + pub bear_power: f64, +} + +/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator. +/// +/// An EMA of the close marks the market's consensus of value; the bar's high and +/// low relative to it measure how far the bulls and bears could push price away +/// from that consensus: +/// +/// ```text +/// ema = EMA(close, period) +/// BullPower = high - ema +/// BearPower = low - ema +/// ``` +/// +/// Bull Power is normally positive (the high prints above the mean) and Bear +/// Power normally negative (the low prints below it). Their behaviour relative +/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend +/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup. +/// +/// The first reading lands once the inner EMA is seeded, at bar `period`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, ElderRay, Indicator}; +/// +/// let mut er = ElderRay::new(13).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap(); +/// last = er.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ElderRay { + period: usize, + ema: Ema, +} + +impl ElderRay { + /// Construct an Elder Ray with the given EMA period. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + ema: Ema::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ElderRay { + type Input = Candle; + type Output = ElderRayOutput; + + fn update(&mut self, candle: Candle) -> Option { + let ema = self.ema.update(candle.close)?; + Some(ElderRayOutput { + bull_power: candle.high - ema, + bear_power: candle.low - ema, + }) + } + + fn reset(&mut self) { + self.ema.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "ElderRay" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64) -> Candle { + Candle::new(close, high, low, close, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(ElderRay::new(0).is_err()); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let er = ElderRay::new(13).unwrap(); + assert_eq!(er.period(), 13); + assert_eq!(er.warmup_period(), 13); + assert_eq!(er.name(), "ElderRay"); + } + + #[test] + fn warmup_then_known_value() { + // EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes). + // bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1. + let mut er = ElderRay::new(3).unwrap(); + assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None); + assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None); + let v = er.update(candle(16.0, 13.0, 14.0)).unwrap(); + assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12); + assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12); + } + + #[test] + fn matches_manual_ema() { + let bars: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + candle(base + 2.0, base - 2.0, base) + }) + .collect(); + let mut er = ElderRay::new(13).unwrap(); + let mut ema = Ema::new(13).unwrap(); + for (i, c) in bars.iter().enumerate() { + let got = er.update(*c); + let want = ema.update(c.close).map(|e| (c.high - e, c.low - e)); + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(g), Some((b, be))) = (got, want) { + assert_relative_eq!(g.bull_power, b, epsilon = 1e-9); + assert_relative_eq!(g.bear_power, be, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut er = ElderRay::new(5).unwrap(); + er.batch( + &(0..20) + .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect::>(), + ); + assert!(er.is_ready()); + er.reset(); + assert!(!er.is_ready()); + assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let bars: Vec = (0..30) + .map(|i| { + let base = 50.0 + f64::from(i); + candle(base + 1.5, base - 1.5, base) + }) + .collect(); + let mut a = ElderRay::new(7).unwrap(); + let mut b = ElderRay::new(7).unwrap(); + assert_eq!( + a.batch(&bars), + bars.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/elder_safezone.rs b/crates/wickra-core/src/indicators/elder_safezone.rs new file mode 100644 index 0000000..f8721cf --- /dev/null +++ b/crates/wickra-core/src/indicators/elder_safezone.rs @@ -0,0 +1,360 @@ +//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`ElderSafeZone`]: the active stop level and the trend direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ElderSafeZoneOutput { + /// The `SafeZone` stop level — below price when long, above price when short. + pub value: f64, + /// Trend direction: `+1.0` long, `-1.0` short. + pub direction: f64, +} + +/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the +/// **average market noise** away from price. +/// +/// ```text +/// long market noise = average downside penetration = mean( prev_low − low | low < prev_low ) +/// short market noise = average upside penetration = mean( high − prev_high | high > prev_high ) +/// long stop = ratchet_up( low_t − coeff · avg_down_penetration ) +/// short stop = ratchet_down( high_t + coeff · avg_up_penetration ) +/// ``` +/// +/// Elder defines *noise* in an uptrend as the part of each bar that pokes below +/// the previous bar's low (a "downside penetration"). Averaging those +/// penetrations over a lookback and placing the stop `coeff` multiples below the +/// current low keeps the stop just outside normal pullbacks while still exiting on +/// a genuine reversal. The stop trails in the trend's favour and flips when price +/// closes through it. The average uses only the bars that actually penetrated +/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's +/// extreme. +/// +/// The first bar seeds the prior candle; the next `period` bars accumulate the +/// penetration statistics, so the first stop lands after `period + 1` inputs. +/// Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ElderSafeZone}; +/// +/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ElderSafeZone { + period: usize, + coeff: f64, + prev: Option, + down_pen: VecDeque, + up_pen: VecDeque, + down_sum: f64, + up_sum: f64, + down_count: usize, + up_count: usize, + direction: f64, + stop: f64, + last: Option, +} + +impl ElderSafeZone { + /// Construct an Elder `SafeZone` stop with the given averaging `period` and + /// noise `coeff`icient. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive. + pub fn new(period: usize, coeff: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !coeff.is_finite() || coeff <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + coeff, + prev: None, + down_pen: VecDeque::with_capacity(period), + up_pen: VecDeque::with_capacity(period), + down_sum: 0.0, + up_sum: 0.0, + down_count: 0, + up_count: 0, + direction: 0.0, + stop: 0.0, + last: None, + }) + } + + /// Configured `(period, coeff)`. + pub const fn params(&self) -> (usize, f64) { + (self.period, self.coeff) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn push(window: &mut VecDeque, sum: &mut f64, count: &mut usize, period: usize, pen: f64) { + if window.len() == period { + let old = window.pop_front().expect("non-empty"); + *sum -= old; + if old > 0.0 { + *count -= 1; + } + } + window.push_back(pen); + *sum += pen; + if pen > 0.0 { + *count += 1; + } + } + + fn avg(sum: f64, count: usize) -> f64 { + if count == 0 { + 0.0 + } else { + sum / count as f64 + } + } +} + +impl Indicator for ElderSafeZone { + type Input = Candle; + type Output = ElderSafeZoneOutput; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let dp = (prev.low - candle.low).max(0.0); + let up = (candle.high - prev.high).max(0.0); + self.prev = Some(candle); + + Self::push( + &mut self.down_pen, + &mut self.down_sum, + &mut self.down_count, + self.period, + dp, + ); + Self::push( + &mut self.up_pen, + &mut self.up_sum, + &mut self.up_count, + self.period, + up, + ); + if self.down_pen.len() < self.period { + return None; + } + + let avg_down = Self::avg(self.down_sum, self.down_count); + let avg_up = Self::avg(self.up_sum, self.up_count); + + if self.direction == 0.0 { + self.direction = 1.0; + self.stop = candle.low - self.coeff * avg_down; + } else if self.direction > 0.0 { + let raw = candle.low - self.coeff * avg_down; + self.stop = self.stop.max(raw); + if candle.close < self.stop { + self.direction = -1.0; + self.stop = candle.high + self.coeff * avg_up; + } + } else { + let raw = candle.high + self.coeff * avg_up; + self.stop = self.stop.min(raw); + if candle.close > self.stop { + self.direction = 1.0; + self.stop = candle.low - self.coeff * avg_down; + } + } + + let out = ElderSafeZoneOutput { + value: self.stop, + direction: self.direction, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev = None; + self.down_pen.clear(); + self.up_pen.clear(); + self.down_sum = 0.0; + self.up_sum = 0.0; + self.down_count = 0; + self.up_count = 0; + self.direction = 0.0; + self.stop = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ElderSafeZone" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero))); + assert!(matches!( + ElderSafeZone::new(14, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + ElderSafeZone::new(14, -1.0), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let e = ElderSafeZone::new(14, 2.0).unwrap(); + assert_eq!(e.params(), (14, 2.0)); + assert_eq!(e.warmup_period(), 15); + assert_eq!(e.name(), "ElderSafeZone"); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut e = ElderSafeZone::new(3, 2.0).unwrap(); + let candles: Vec = (0..8) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let out = e.batch(&candles); + let warmup = e.warmup_period(); // 4 + assert_eq!(warmup, 4); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn uptrend_keeps_stop_below_price() { + let mut e = ElderSafeZone::new(5, 2.0).unwrap(); + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + 2.0 * f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) { + if let Some(o) = o { + assert_eq!(o.direction, 1.0); + assert!(o.value <= candle.close); + } + } + } + + #[test] + fn noiseless_trend_stop_sits_at_low() { + // Every bar makes a higher low -> no downside penetration -> avg 0 -> + // the stop sits exactly at the bar's low. + let mut e = ElderSafeZone::new(3, 2.0).unwrap(); + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + let out = e.batch(&candles); + let last_candle = candles.last().unwrap(); + let last = out.last().unwrap().unwrap(); + assert!((last.value - last_candle.low).abs() < 1e-9); + } + + #[test] + fn flips_on_reversal() { + let mut candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + candles.extend((0..40).map(|i| { + let base = 140.0 - f64::from(i); + c(base + 1.0, base - 1.0, base - 0.5) + })); + let mut e = ElderSafeZone::new(5, 2.0).unwrap(); + let dirs: Vec = e + .batch(&candles) + .into_iter() + .flatten() + .map(|o| o.direction) + .collect(); + assert!(dirs.iter().any(|&d| d > 0.0)); + assert!(dirs.iter().any(|&d| d < 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut e = ElderSafeZone::new(5, 2.0).unwrap(); + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + e.batch(&candles); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + assert_eq!(e.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + c(base + 2.0, base - 1.5, base + 0.5) + }) + .collect(); + let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles); + let mut b = ElderSafeZone::new(14, 2.0).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/ema.rs b/crates/wickra-core/src/indicators/ema.rs new file mode 100644 index 0000000..28f4c4d --- /dev/null +++ b/crates/wickra-core/src/indicators/ema.rs @@ -0,0 +1,441 @@ +//! Exponential Moving Average. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Exponential Moving Average with smoothing factor `alpha = 2 / (period + 1)`. +/// +/// The first value is seeded with the simple mean of the first `period` inputs +/// (the classical TA-Lib convention). From then on each new input contributes +/// `alpha * input + (1 - alpha) * previous`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Ema}; +/// +/// let mut indicator = Ema::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ema { + period: usize, + alpha: f64, + /// `1 - alpha`, precomputed so the recurrence avoids a subtraction per tick. + /// Cached value, so the steady-state output is bit-for-bit unchanged. + one_minus_alpha: f64, + /// Latest EMA value, valid only once `seeded` is true. Stored as a bare `f64` + /// (plus the `seeded` flag) rather than `Option` so the steady-state + /// recurrence reads and writes 8 bytes with no enum-tag handling per tick. + current: f64, + /// Whether `current` holds a real value yet (warmup complete). + seeded: bool, + warmup_buf: Vec, +} + +impl Ema { + /// Construct an EMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let alpha = 2.0 / (period as f64 + 1.0); + Ok(Self { + period, + alpha, + one_minus_alpha: 1.0 - alpha, + current: 0.0, + seeded: false, + warmup_buf: Vec::with_capacity(period), + }) + } + + /// Construct an EMA with a custom smoothing factor `alpha in (0, 1]`. + /// + /// The reported `period` is derived from `alpha` via `2/alpha - 1` and rounded; + /// `warmup_period()` falls back to `1` because the implementation seeds from the + /// very first input. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `alpha` is not in `(0.0, 1.0]` or non-finite. + pub fn with_alpha(alpha: f64) -> Result { + if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 { + return Err(Error::InvalidPeriod { + message: "alpha must be in (0.0, 1.0]", + }); + } + Ok(Self { + period: 1, + alpha, + one_minus_alpha: 1.0 - alpha, + current: 0.0, + seeded: false, + warmup_buf: Vec::with_capacity(1), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Smoothing factor. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current value if available. + pub const fn value(&self) -> Option { + if self.seeded { + Some(self.current) + } else { + None + } + } + + /// Whether the EMA has seen no input yet (neither seeded nor mid-warmup). + /// Lets composite indicators (e.g. MACD) decide if a fast batch path is safe. + pub(crate) fn is_fresh(&self) -> bool { + !self.seeded && self.warmup_buf.is_empty() + } + + /// Force the EMA into its seeded steady state with `current` as the latest + /// value. Used by composite fused batch paths (MACD) to leave each sub-EMA + /// where a per-tick `update` replay would, so a later `update` continues + /// correctly. The post-seed recurrence never re-reads `warmup_buf`, so it is + /// left as-is. + pub(crate) fn seed_to(&mut self, current: f64) { + self.current = current; + self.seeded = true; + } + + /// Vectorized batch returning one `f64` per input (`NaN` during warmup). + /// + /// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket + /// default via inherent-method resolution. For a fresh indicator over an + /// all-finite slice it runs the seed (mean of the first `period`) once and + /// then the bare `alpha * x + (1 - alpha) * prev` recurrence in a tight loop + /// with no per-element `is_finite`/`seeded` branch and no `Option` — yet uses + /// the identical `mul_add`, so the result is *bit-for-bit* equal to replaying + /// `update`. Any other state, or a non-finite element, defers to the exact + /// `update` replay. + pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec { + let p = self.period; + if self.seeded || !self.warmup_buf.is_empty() || !inputs.iter().all(|x| x.is_finite()) { + return inputs + .iter() + .map(|&x| self.update(x).unwrap_or(f64::NAN)) + .collect(); + } + + let n = inputs.len(); + if n < p { + // Not enough to seed; mirror `update` stashing inputs for warmup. + self.warmup_buf.extend_from_slice(inputs); + return vec![f64::NAN; n]; + } + + // Warmup `[0, p-1)` is `NaN`; values from the seed on are pushed once each. + let mut out = vec![f64::NAN; p - 1]; + out.reserve(n - (p - 1)); + let seed = inputs[..p].iter().copied().sum::() / p as f64; + let mut cur = seed; + out.push(seed); + let (alpha, oma) = (self.alpha, self.one_minus_alpha); + for &x in &inputs[p..] { + cur = alpha.mul_add(x, oma * cur); + out.push(cur); + } + + // Leave state exactly where `update` would: seeded on `current`, with the + // first `period` inputs retained in `warmup_buf` (never cleared post-seed). + self.current = cur; + self.seeded = true; + self.warmup_buf.extend_from_slice(&inputs[..p]); + out + } + + /// Internal helper that feeds a value without finiteness validation. The caller + /// guarantees `input.is_finite()`. Used by MACD which has already validated. + pub(crate) fn step_unchecked(&mut self, input: f64) -> Option { + if self.seeded { + let new = self + .alpha + .mul_add(input, self.one_minus_alpha * self.current); + self.current = new; + return Some(new); + } + self.warmup_buf.push(input); + if self.warmup_buf.len() == self.period { + let seed = self.warmup_buf.iter().copied().sum::() / self.period as f64; + self.current = seed; + self.seeded = true; + return Some(seed); + } + None + } +} + +impl Indicator for Ema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + self.step_unchecked(input) + } + + fn reset(&mut self) { + self.current = 0.0; + self.seeded = false; + self.warmup_buf.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.seeded + } + + fn name(&self) -> &'static str { + "EMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference: SMA-seeded EMA computed straight from the definition. + fn ema_naive(prices: &[f64], period: usize) -> Vec> { + let alpha = 2.0 / (period as f64 + 1.0); + let mut out = Vec::with_capacity(prices.len()); + let mut state: Option = None; + for (i, &p) in prices.iter().enumerate() { + if let Some(prev) = state { + let v = alpha * p + (1.0 - alpha) * prev; + state = Some(v); + out.push(Some(v)); + } else if i + 1 == period { + let seed = prices[..period].iter().sum::() / period as f64; + state = Some(seed); + out.push(Some(seed)); + } else { + out.push(None); + } + } + out + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Ema::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` (74-77) and the Indicator-impl + /// `warmup_period` (123-125) + `name` (131-133). `alpha` and `value` + /// are exercised by other tests and downstream consumers; only the + /// three metadata methods were dead. + #[test] + fn accessors_and_metadata() { + let ema = Ema::new(14).unwrap(); + assert_eq!(ema.period(), 14); + assert_eq!(ema.warmup_period(), 14); + assert_eq!(ema.name(), "EMA"); + } + + #[test] + fn warmup_returns_none_until_seed() { + let mut ema = Ema::new(3).unwrap(); + assert_eq!(ema.update(1.0), None); + assert_eq!(ema.update(2.0), None); + assert_eq!(ema.update(3.0), Some(2.0)); // seed = SMA([1,2,3]) = 2 + } + + #[test] + fn first_value_equals_sma_seed() { + let mut ema = Ema::new(5).unwrap(); + let inputs = [10.0, 20.0, 30.0, 40.0, 50.0]; + let mut last = None; + for v in inputs { + last = ema.update(v); + } + assert_relative_eq!(last.unwrap(), 30.0, epsilon = 1e-12); + } + + #[test] + fn alpha_matches_period_formula() { + let ema = Ema::new(10).unwrap(); + assert_relative_eq!(ema.alpha(), 2.0 / 11.0, epsilon = 1e-15); + } + + #[test] + fn step_after_seed_uses_alpha_formula() { + // period=3 => alpha = 0.5; seed = mean([1,2,3]) = 2; next input 10 + // expected = 0.5*10 + 0.5*2 = 6 + let mut ema = Ema::new(3).unwrap(); + ema.batch(&[1.0, 2.0, 3.0]); + assert_relative_eq!(ema.update(10.0).unwrap(), 6.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_converges_to_constant() { + let mut ema = Ema::new(10).unwrap(); + let out = ema.batch(&[42.0_f64; 100]); + for x in out.iter().skip(9) { + assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-9); + } + } + + #[test] + fn with_alpha_validates_range() { + assert!(Ema::with_alpha(0.5).is_ok()); + assert!(Ema::with_alpha(1.0).is_ok()); + assert!(matches!( + Ema::with_alpha(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Ema::with_alpha(1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Ema::with_alpha(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn reset_clears_state() { + let mut ema = Ema::new(3).unwrap(); + ema.batch(&[1.0, 2.0, 3.0]); + assert!(ema.is_ready()); + ema.reset(); + assert!(!ema.is_ready()); + assert_eq!(ema.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30).map(f64::from).collect(); + let mut a = Ema::new(5).unwrap(); + let mut b = Ema::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut ema = Ema::new(3).unwrap(); + ema.batch(&[1.0, 2.0, 3.0]); + let before = ema.value(); + assert_eq!(ema.update(f64::NAN), before); + assert_eq!(ema.update(f64::INFINITY), before); + } + + fn bits_eq(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x == y || (x.is_nan() && y.is_nan())) + } + + fn ema_replay(period: usize, series: &[f64]) -> Vec { + let mut e = Ema::new(period).unwrap(); + series + .iter() + .map(|&x| e.update(x).unwrap_or(f64::NAN)) + .collect() + } + + #[test] + fn batch_nan_fast_path_is_bit_identical() { + let series: Vec = (0..300) + .map(|i| (f64::from(i) * 0.25).cos() * 8.0 + 40.0) + .collect(); + let mut ema = Ema::new(14).unwrap(); + let got = ema.batch_nan(&series); + assert!(bits_eq(&got, &ema_replay(14, &series))); + let mut ref_ema = Ema::new(14).unwrap(); + for &x in &series { + ref_ema.update(x); + } + assert_eq!(ema.update(7.5), ref_ema.update(7.5)); + } + + #[test] + fn batch_nan_falls_back_on_non_finite() { + let series = [1.0, 2.0, 3.0, f64::INFINITY, 5.0, 6.0, 7.0]; + let mut ema = Ema::new(3).unwrap(); + assert!(bits_eq(&ema.batch_nan(&series), &ema_replay(3, &series))); + } + + #[test] + fn batch_nan_falls_back_when_warming() { + let mut ema = Ema::new(3).unwrap(); + ema.update(10.0); // mid-warmup: warmup_buf non-empty, not seeded + let series = [1.0, 2.0, 3.0, 4.0]; + let mut ref_ema = Ema::new(3).unwrap(); + ref_ema.update(10.0); + let want: Vec = series + .iter() + .map(|&x| ref_ema.update(x).unwrap_or(f64::NAN)) + .collect(); + assert!(bits_eq(&ema.batch_nan(&series), &want)); + } + + #[test] + fn batch_nan_sub_period_slice_stays_unseeded() { + let series = [1.0, 2.0]; + let mut ema = Ema::new(5).unwrap(); + let got = ema.batch_nan(&series); + assert!(got.iter().all(|x| x.is_nan()) && got.len() == 2); + assert!(!ema.is_ready()); + // Warmup state was stashed: feeding the rest seeds exactly as a full stream. + assert!(bits_eq( + &[ema.update(3.0).unwrap_or(f64::NAN)], + &[ema_replay(5, &[1.0, 2.0, 3.0])[2]] + )); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn ema_matches_naive( + period in 1usize..20, + prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..150), + ) { + let mut ema = Ema::new(period).unwrap(); + let got = ema.batch(&prices); + let want = ema_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() <= 1e-9 * a.abs().max(1.0), + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/empirical_mode_decomposition.rs b/crates/wickra-core/src/indicators/empirical_mode_decomposition.rs new file mode 100644 index 0000000..e0e3fd2 --- /dev/null +++ b/crates/wickra-core/src/indicators/empirical_mode_decomposition.rs @@ -0,0 +1,266 @@ +//! Ehlers Empirical Mode Decomposition (bandpass + envelope). + +use std::collections::VecDeque; +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' adaptation of Empirical Mode Decomposition (EMD). +/// +/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14). +/// The procedure is: +/// +/// 1. Apply a bandpass filter centred on `period` to the price. +/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction` +/// of the period. +/// 3. Average the peaks and valleys separately to form an upper / lower +/// envelope, then return the centred bandpass minus the envelope mean +/// (the "EMD" line). +/// +/// The output crosses zero at trend changes and stays near zero in +/// non-trending markets — the classic visual cue Ehlers documents. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, EmpiricalModeDecomposition}; +/// +/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); +/// let mut last = None; +/// for i in 0..200 { +/// last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EmpiricalModeDecomposition { + period: usize, + fraction: f64, + bandpass: f64, + prev_bp_1: f64, + prev_bp_2: f64, + prev_in_1: Option, + prev_in_2: Option, + beta: f64, + alpha: f64, + smoother: SuperSmoother, + peak_smoother: SuperSmoother, + valley_smoother: SuperSmoother, + bp_buf: VecDeque, + bp_history_len: usize, + last_value: Option, +} + +impl EmpiricalModeDecomposition { + /// Construct with the bandpass centre period and the peak-detection + /// window fraction. + /// + /// `fraction` is multiplied by `period` to size the rolling peak/valley + /// window; Ehlers recommends `0.5`. Both must be positive. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, and + /// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite. + pub fn new(period: usize, fraction: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 { + return Err(Error::InvalidPeriod { + message: "fraction must be in (0, 1]", + }); + } + let beta = (2.0 * PI / period as f64).cos(); + let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos(); + let alpha = gamma - (gamma * gamma - 1.0).sqrt(); + let history = (period as f64 * fraction).round().max(1.0) as usize; + Ok(Self { + period, + fraction, + bandpass: 0.0, + prev_bp_1: 0.0, + prev_bp_2: 0.0, + prev_in_1: None, + prev_in_2: None, + beta, + alpha, + smoother: SuperSmoother::new(period.max(2))?, + peak_smoother: SuperSmoother::new(period.max(2))?, + valley_smoother: SuperSmoother::new(period.max(2))?, + bp_buf: VecDeque::with_capacity(history), + bp_history_len: history, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured fraction. + pub const fn fraction(&self) -> f64 { + self.fraction + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for EmpiricalModeDecomposition { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + // 2nd-order resonant bandpass per Ehlers ch. 6. + let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) { + 0.5 * (1.0 - self.alpha) * (input - x2) + + self.beta * (1.0 + self.alpha) * self.prev_bp_1 + - self.alpha * self.prev_bp_2 + } else { + 0.0 + }; + self.prev_bp_2 = self.prev_bp_1; + self.prev_bp_1 = bp; + self.bandpass = bp; + self.prev_in_2 = self.prev_in_1; + self.prev_in_1 = Some(input); + + if self.bp_buf.len() == self.bp_history_len { + self.bp_buf.pop_front(); + } + self.bp_buf.push_back(bp); + if self.bp_buf.len() < self.bp_history_len { + return None; + } + + // Identify the current peak (largest), valley (smallest) within the window. + let peak = self + .bp_buf + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min); + + let avg_peak = self.peak_smoother.update(peak)?; + let avg_valley = self.valley_smoother.update(valley)?; + + // The EMD line is the bandpass minus the smoothed mean envelope. + let mean = 0.5 * (avg_peak + avg_valley); + let raw = bp - mean; + let v = self.smoother.update(raw)?; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.bandpass = 0.0; + self.prev_bp_1 = 0.0; + self.prev_bp_2 = 0.0; + self.prev_in_1 = None; + self.prev_in_2 = None; + self.smoother.reset(); + self.peak_smoother.reset(); + self.valley_smoother.reset(); + self.bp_buf.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.bp_history_len + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "EmpiricalModeDecomposition" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn new_rejects_invalid_params() { + assert!(matches!( + EmpiricalModeDecomposition::new(0, 0.5), + Err(Error::PeriodZero) + )); + assert!(matches!( + EmpiricalModeDecomposition::new(20, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + EmpiricalModeDecomposition::new(20, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + EmpiricalModeDecomposition::new(20, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + assert_eq!(emd.period(), 20); + assert!((emd.fraction() - 0.5).abs() < 1e-15); + assert_eq!(emd.name(), "EmpiricalModeDecomposition"); + assert!(emd.warmup_period() >= 1); + assert!(!emd.is_ready()); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + emd.batch(&prices); + assert!(emd.is_ready()); + assert!(emd.value().is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0) + .collect(); + let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + emd.batch(&prices); + let before = emd.value(); + assert!(before.is_some()); + assert_eq!(emd.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + emd.batch(&prices); + assert!(emd.is_ready()); + emd.reset(); + assert!(!emd.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/engulfing.rs b/crates/wickra-core/src/indicators/engulfing.rs new file mode 100644 index 0000000..c92b1b6 --- /dev/null +++ b/crates/wickra-core/src/indicators/engulfing.rs @@ -0,0 +1,193 @@ +//! Bullish / Bearish Engulfing candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Engulfing — a 2-bar reversal pattern. The current candle's body fully +/// engulfs the prior candle's body and points in the opposite direction. +/// +/// ```text +/// prev_body = |prev.close − prev.open| +/// curr_body = |curr.close − curr.open| +/// bullish = prev red & curr green +/// & curr.open <= prev.close & curr.close >= prev.open +/// & curr_body > prev_body +/// bearish = prev green & curr red +/// & curr.open >= prev.close & curr.close <= prev.open +/// & curr_body > prev_body +/// ``` +/// +/// Output is `+1.0` for a bullish engulfing, `−1.0` for a bearish one, and +/// `0.0` otherwise. The first bar always returns `0.0` because no previous +/// body exists to engulf. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Engulfing, Indicator}; +/// +/// let mut indicator = Engulfing::new(); +/// // Prior red candle followed by a larger green engulfing candle. +/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Engulfing { + prev: Option, + has_emitted: bool, +} + +impl Engulfing { + /// Construct a new Engulfing detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for Engulfing { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let prev_body = (p.close - p.open).abs(); + let curr_body = (candle.close - candle.open).abs(); + if prev_body <= 0.0 || curr_body <= prev_body { + return Some(0.0); + } + let prev_red = p.close < p.open; + let prev_green = p.close > p.open; + let curr_green = candle.close > candle.open; + let curr_red = candle.close < candle.open; + if prev_red && curr_green && candle.open <= p.close && candle.close >= p.open { + Some(1.0) + } else if prev_green && curr_red && candle.open >= p.close && candle.close <= p.open { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Engulfing" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let e = Engulfing::new(); + assert_eq!(e.name(), "Engulfing"); + assert_eq!(e.warmup_period(), 2); + assert!(!e.is_ready()); + } + + #[test] + fn bullish_engulfing_is_plus_one() { + let mut e = Engulfing::new(); + // Prior red 11 -> 10, current green 9.5 -> 11.5 (body 2 > 1). + assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(1.0)); + } + + #[test] + fn bearish_engulfing_is_minus_one() { + let mut e = Engulfing::new(); + // Prior green 10 -> 11, current red 12 -> 9. + assert_eq!(e.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(e.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(-1.0)); + } + + #[test] + fn same_direction_is_not_engulfing() { + let mut e = Engulfing::new(); + e.update(c(10.0, 11.0, 9.8, 11.0, 0)); + // Another green candle that engulfs but matches direction -> 0. + assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + } + + #[test] + fn smaller_body_is_not_engulfing() { + let mut e = Engulfing::new(); + e.update(c(11.0, 11.2, 8.0, 8.5, 0)); + // Body 0.5 < 2.5 -> not engulfing. + assert_eq!(e.update(c(8.6, 9.0, 8.4, 8.7, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut e = Engulfing::new(); + assert_eq!(e.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 3 == 0 { + c(base + 1.0, base + 1.5, base - 0.5, base, i) + } else { + c(base - 1.0, base + 2.0, base - 1.5, base + 2.0, i) + } + }) + .collect(); + let mut a = Engulfing::new(); + let mut b = Engulfing::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut e = Engulfing::new(); + e.update(c(10.0, 11.0, 9.0, 11.0, 0)); + e.update(c(11.0, 12.0, 10.0, 12.0, 1)); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + // After reset the next bar again has no prev. + assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/equivolume.rs b/crates/wickra-core/src/indicators/equivolume.rs new file mode 100644 index 0000000..7501243 --- /dev/null +++ b/crates/wickra-core/src/indicators/equivolume.rs @@ -0,0 +1,235 @@ +//! Equivolume — the price box height and its volume-scaled width. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`Equivolume`]: the box's price height and its volume-relative width. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EquivolumeOutput { + /// Box height — the bar's price range `high − low`. + pub height: f64, + /// Box width — volume relative to its `period` average (`1.0` = average). + pub width: f64, +} + +/// Equivolume — Richard Arms' charting style rendered as numbers: each bar is a +/// "box" whose **height** is its price range and whose **width** is its volume +/// relative to the recent average. +/// +/// ```text +/// height = high − low +/// width = volume / SMA(volume, period) (1.0 = average volume) +/// ``` +/// +/// Equivolume discards time and substitutes volume for the horizontal axis: a tall +/// narrow box is an easy move (big range on light volume), while a short wide box +/// is churn (small range on heavy volume) that often marks support/resistance. +/// Reporting the two dimensions lets you reconstruct that shape programmatically: +/// the height/width relationship is Arms' "ease of movement" read. The width is +/// normalised by the volume SMA so it self-scales across instruments. +/// +/// The first value lands after `period` inputs (to seed the volume average). Each +/// `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Equivolume}; +/// +/// let mut indicator = Equivolume::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Equivolume { + period: usize, + vol_sma: Sma, + last: Option, +} + +impl Equivolume { + /// Construct an Equivolume with the given volume-averaging `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + vol_sma: Sma::new(period)?, + last: None, + }) + } + + /// Configured volume-averaging period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Equivolume { + type Input = Candle; + type Output = EquivolumeOutput; + + fn update(&mut self, candle: Candle) -> Option { + let avg_vol = self.vol_sma.update(candle.volume)?; + let height = candle.high - candle.low; + let width = if avg_vol > 0.0 { + candle.volume / avg_vol + } else { + 0.0 + }; + let out = EquivolumeOutput { height, width }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.vol_sma.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Equivolume" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Equivolume::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let e = Equivolume::new(14).unwrap(); + assert_eq!(e.period(), 14); + assert_eq!(e.warmup_period(), 14); + assert_eq!(e.name(), "Equivolume"); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut e = Equivolume::new(3).unwrap(); + let candles: Vec = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect(); + let out = e.batch(&candles); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn height_is_range() { + let mut e = Equivolume::new(2).unwrap(); + let out = e + .batch(&[c(105.0, 100.0, 1_000.0), c(105.0, 100.0, 1_000.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.height, 5.0, epsilon = 1e-9); + } + + #[test] + fn average_volume_width_is_one() { + let mut e = Equivolume::new(3).unwrap(); + let out = e + .batch(&[c(102.0, 98.0, 1_000.0); 6]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.width, 1.0, epsilon = 1e-9); + } + + #[test] + fn heavy_bar_is_wide() { + let mut e = Equivolume::new(3).unwrap(); + let candles = [ + c(102.0, 98.0, 1_000.0), + c(102.0, 98.0, 1_000.0), + c(102.0, 98.0, 4_000.0), + ]; + let out = e.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + out.width > 1.0, + "a heavy bar should be wider than average, got {}", + out.width + ); + } + + #[test] + fn reset_clears_state() { + let mut e = Equivolume::new(3).unwrap(); + e.batch(&[c(102.0, 98.0, 1_000.0); 6]); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + assert_eq!(e.update(c(102.0, 98.0, 1_000.0)), None); + } + + #[test] + fn zero_volume_gives_zero_width() { + let mut e = Equivolume::new(2).unwrap(); + let out = e + .batch(&[c(11.0, 9.0, 0.0), c(12.0, 10.0, 0.0), c(13.0, 11.0, 0.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(out.width, 0.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.25).sin() * 5.0, + 90.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = Equivolume::new(14).unwrap().batch(&candles); + let mut b = Equivolume::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/estimated_leverage_ratio.rs b/crates/wickra-core/src/indicators/estimated_leverage_ratio.rs new file mode 100644 index 0000000..8b756df --- /dev/null +++ b/crates/wickra-core/src/indicators/estimated_leverage_ratio.rs @@ -0,0 +1,155 @@ +//! Estimated Leverage Ratio — open interest per unit of aggregate position size. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Estimated Leverage Ratio (ELR) — open interest relative to the aggregate +/// long+short position size, a proxy for how leveraged outstanding positions are. +/// +/// ```text +/// ELR = open_interest / (long_size + short_size) +/// ``` +/// +/// The classic estimated leverage ratio compares open interest (the notional of +/// outstanding contracts) to the capital backing it. With the size fields of a +/// [`DerivativesTick`] standing in for the position base, the ratio rises when a +/// given pool of positions controls more open interest — i.e. when the market is +/// running hotter leverage. Spikes in ELR mark crowded, fragile conditions where a +/// move can cascade into liquidations; a falling ELR marks deleveraging. +/// +/// The ratio is non-negative; a tick with zero aggregate size reports `0` rather +/// than dividing by zero. It is stateless — each tick yields one value (no warmup). +/// Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, EstimatedLeverageRatio}; +/// +/// let mut indicator = EstimatedLeverageRatio::new(); +/// let tick = DerivativesTick::new(0.0001, 100.0, 100.0, 100.0, 1_000.0, 400.0, 600.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap(); +/// let elr = indicator.update(tick).unwrap(); +/// assert!((elr - 1.0).abs() < 1e-12); // 1000 / (400 + 600) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct EstimatedLeverageRatio { + ready: bool, +} + +impl EstimatedLeverageRatio { + /// Construct a new Estimated Leverage Ratio. The indicator is parameter-free. + #[must_use] + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for EstimatedLeverageRatio { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let base = tick.long_size + tick.short_size; + let elr = if base > 0.0 { + tick.open_interest / base + } else { + 0.0 + }; + self.ready = true; + Some(elr) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "EstimatedLeverageRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn tick(oi: f64, long: f64, short: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, oi, long, short, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let e = EstimatedLeverageRatio::new(); + assert_eq!(e.warmup_period(), 1); + assert_eq!(e.name(), "EstimatedLeverageRatio"); + assert!(!e.is_ready()); + } + + #[test] + fn ratio_reference_value() { + let mut e = EstimatedLeverageRatio::new(); + // 1000 / (400 + 600) = 1.0. + assert_relative_eq!( + e.update(tick(1_000.0, 400.0, 600.0)).unwrap(), + 1.0, + epsilon = 1e-12 + ); + } + + #[test] + fn higher_oi_raises_ratio() { + let mut e = EstimatedLeverageRatio::new(); + let low = e.update(tick(1_000.0, 500.0, 500.0)).unwrap(); + let high = e.update(tick(3_000.0, 500.0, 500.0)).unwrap(); + assert!(high > low); + } + + #[test] + fn zero_base_is_zero() { + let mut e = EstimatedLeverageRatio::new(); + assert_relative_eq!( + e.update(tick(1_000.0, 0.0, 0.0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn ready_after_first_update() { + let mut e = EstimatedLeverageRatio::new(); + assert!(!e.is_ready()); + e.update(tick(1_000.0, 500.0, 500.0)); + assert!(e.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut e = EstimatedLeverageRatio::new(); + e.update(tick(1_000.0, 500.0, 500.0)); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..40) + .map(|i| tick(1_000.0 + f64::from(i) * 10.0, 500.0, 500.0)) + .collect(); + let batch = EstimatedLeverageRatio::new().batch(&ticks); + let mut b = EstimatedLeverageRatio::new(); + let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/even_better_sinewave.rs b/crates/wickra-core/src/indicators/even_better_sinewave.rs new file mode 100644 index 0000000..3b4951b --- /dev/null +++ b/crates/wickra-core/src/indicators/even_better_sinewave.rs @@ -0,0 +1,269 @@ +//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1]. +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator +/// that swings cleanly in `[−1, +1]` regardless of price amplitude. +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12): +/// +/// ```text +/// alpha1 = (1 − sin(2π/hp_period)) / cos(2π/hp_period) +/// HP_t = 0.5·(1 + alpha1)·(price_t − price_{t−1}) + alpha1·HP_{t−1} (one-pole highpass) +/// Filt = SuperSmoother(HP, ssf_length) +/// Wave = (Filt_t + Filt_{t−1} + Filt_{t−2}) / 3 +/// Pwr = (Filt_t² + Filt_{t−1}² + Filt_{t−2}²) / 3 +/// EBSW = Wave / sqrt(Pwr) +/// ``` +/// +/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to +/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that +/// cycle by its RMS power normalises the amplitude, so the output reads like a +/// clean sine wave bounded in `[−1, +1]` whatever the instrument. Unlike the +/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature +/// components from the Hilbert transform and can whip in trends), the EBSW stays +/// well-behaved and is read directly: crossing up through `0`/`−0.9` is a buy +/// cue, crossing down through `0`/`+0.9` a sell cue. +/// +/// The first value lands once three SuperSmoothed samples exist +/// (`warmup_period == 3`). Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, EvenBetterSinewave}; +/// +/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EvenBetterSinewave { + hp_period: usize, + ssf_length: usize, + alpha1: f64, + smoother: SuperSmoother, + prev_price: Option, + hp: f64, + filt1: Option, + filt2: Option, + filt3: Option, + last: Option, +} + +impl EvenBetterSinewave { + /// Construct an EBSW with the given highpass `hp_period` and SuperSmoother + /// `ssf_length`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is `0`. + pub fn new(hp_period: usize, ssf_length: usize) -> Result { + if hp_period == 0 || ssf_length == 0 { + return Err(Error::PeriodZero); + } + let w = 2.0 * PI / hp_period as f64; + let alpha1 = (1.0 - w.sin()) / w.cos(); + Ok(Self { + hp_period, + ssf_length, + alpha1, + smoother: SuperSmoother::new(ssf_length)?, + prev_price: None, + hp: 0.0, + filt1: None, + filt2: None, + filt3: None, + last: None, + }) + } + + /// Configured `(hp_period, ssf_length)`. + pub const fn params(&self) -> (usize, usize) { + (self.hp_period, self.ssf_length) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for EvenBetterSinewave { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let hp = match self.prev_price { + Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp, + None => 0.0, + }; + self.prev_price = Some(price); + self.hp = hp; + let filt = self.smoother.update(hp)?; + // Shift the three-deep filter buffer. + self.filt3 = self.filt2; + self.filt2 = self.filt1; + self.filt1 = Some(filt); + let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else { + return None; + }; + let wave = (f1 + f2 + f3) / 3.0; + let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0; + let ebsw = if pwr > 0.0 { + (wave / pwr.sqrt()).clamp(-1.0, 1.0) + } else { + 0.0 + }; + self.last = Some(ebsw); + Some(ebsw) + } + + fn reset(&mut self) { + self.smoother.reset(); + self.prev_price = None; + self.hp = 0.0; + self.filt1 = None; + self.filt2 = None; + self.filt3 = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "EvenBetterSinewave" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_params() { + assert!(matches!( + EvenBetterSinewave::new(0, 10), + Err(Error::PeriodZero) + )); + assert!(matches!( + EvenBetterSinewave::new(40, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let e = EvenBetterSinewave::new(40, 10).unwrap(); + assert_eq!(e.params(), (40, 10)); + assert_eq!(e.warmup_period(), 3); + assert_eq!(e.name(), "EvenBetterSinewave"); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut e = EvenBetterSinewave::new(40, 10).unwrap(); + let xs: Vec = (0..12) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0) + .collect(); + let out = e.batch(&xs); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn output_in_range() { + let mut e = EvenBetterSinewave::new(40, 10).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0) + .collect(); + for v in e.batch(&xs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}"); + } + } + + #[test] + fn cyclic_input_swings_both_signs() { + let mut e = EvenBetterSinewave::new(30, 8).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0) + .collect(); + let out: Vec = e.batch(&xs).into_iter().flatten().skip(100).collect(); + assert!(out.iter().any(|&v| v > 0.5)); + assert!(out.iter().any(|&v| v < -0.5)); + } + + #[test] + fn ignores_non_finite() { + let mut e = EvenBetterSinewave::new(40, 10).unwrap(); + e.batch( + &(0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin()) + .collect::>(), + ); + let before = e.value(); + assert_eq!(e.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut e = EvenBetterSinewave::new(40, 10).unwrap(); + e.batch( + &(0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin()) + .collect::>(), + ); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs); + let mut b = EvenBetterSinewave::new(40, 10).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn flat_input_yields_zero_power() { + // A constant series drives the highpass/smoother outputs to zero, so the + // signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm). + let flat = [100.0_f64; 200]; + let last = EvenBetterSinewave::new(40, 10) + .unwrap() + .batch(&flat) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } +} diff --git a/crates/wickra-core/src/indicators/evening_doji_star.rs b/crates/wickra-core/src/indicators/evening_doji_star.rs new file mode 100644 index 0000000..639b5fe --- /dev/null +++ b/crates/wickra-core/src/indicators/evening_doji_star.rs @@ -0,0 +1,259 @@ +//! Evening Doji Star candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Evening Doji Star — a 3-bar bearish top reversal. A long white bar extends the +/// advance, a doji gaps up above it (the star of indecision), then a black bar +/// gaps back down and closes deep into the first body, confirming the turn. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// doji = |close − open| <= 0.1 * (high − low) +/// bar1 white & long +/// bar2 doji, body gaps UP above bar1 body (min(o2,c2) > close1) +/// bar3 black, body gaps DOWN below the doji (max(o3,c3) < min(o2,c2)) +/// bar3 closes deep into bar1 body (close3 < close1 − penetration·body1) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Evening Doji +/// Star is a single-direction (bearish-only) reversal, so it never emits `+1.0`. +/// The first two bars always return `0.0` because the three-bar window is not yet +/// filled. `penetration` is how far into the first body the third bar must close; +/// it defaults to `0.3` (TA-Lib's `CDLEVENINGDOJISTAR` default) and must lie in +/// `[0, 1)`. Body and doji thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, EveningDojiStar, Indicator}; +/// +/// let mut indicator = EveningDojiStar::new(); +/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(17.0, 17.1, 16.9, 17.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(16.0, 16.1, 11.9, 12.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct EveningDojiStar { + penetration: f64, + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl Default for EveningDojiStar { + fn default() -> Self { + Self::new() + } +} + +impl EveningDojiStar { + /// Construct an Evening Doji Star detector with the default 0.3 penetration. + pub const fn new() -> Self { + Self { + penetration: 0.3, + prev: None, + prev_prev: None, + has_emitted: false, + } + } + + /// Construct an Evening Doji Star detector with a custom penetration fraction. + /// + /// `penetration` must lie in `[0, 1)`. + pub fn with_penetration(penetration: f64) -> Result { + if !(0.0..1.0).contains(&penetration) { + return Err(Error::InvalidPeriod { + message: "evening doji star penetration must lie in [0, 1)", + }); + } + Ok(Self { + penetration, + prev: None, + prev_prev: None, + has_emitted: false, + }) + } + + /// Configured penetration fraction. + pub fn penetration(&self) -> f64 { + self.penetration + } +} + +impl Indicator for EveningDojiStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.prev_prev; + let bar2 = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + let range2 = bar2.high - bar2.low; + if range1 <= 0.0 || range2 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.close - bar1.open; + if body1 < 0.5 * range1 { + return Some(0.0); // bar1 must be a long white body + } + if (bar2.close - bar2.open).abs() > 0.1 * range2 { + return Some(0.0); // bar2 must be a doji + } + let star_bottom = bar2.open.min(bar2.close); + let bar3_top = candle.open.max(candle.close); + if star_bottom > bar1.close + && candle.close < candle.open + && bar3_top < star_bottom + && candle.close < bar1.close - self.penetration * body1 + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "EveningDojiStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_penetration() { + assert!(EveningDojiStar::with_penetration(-0.01).is_err()); + assert!(EveningDojiStar::with_penetration(1.0).is_err()); + } + + #[test] + fn accepts_valid_penetration() { + let t = EveningDojiStar::with_penetration(0.5).unwrap(); + assert!((t.penetration() - 0.5).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = EveningDojiStar::default(); + assert_eq!(t.name(), "EveningDojiStar"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + assert!((t.penetration() - 0.3).abs() < 1e-12); + } + + #[test] + fn evening_doji_star_is_minus_one() { + let mut t = EveningDojiStar::new(); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(17.0, 17.1, 16.9, 17.0, 1)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(-1.0)); + } + + #[test] + fn middle_not_doji_yields_zero() { + let mut t = EveningDojiStar::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + // Wide-bodied star, not a doji. + t.update(c(16.0, 18.1, 15.9, 18.0, 1)); + assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0)); + } + + #[test] + fn shallow_close_yields_zero() { + let mut t = EveningDojiStar::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(17.0, 17.1, 16.9, 17.0, 1)); + // bar3 black but closes at 14.0 -> only 1.0 into the 5.0 body (< 0.3·5). + assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = EveningDojiStar::new(); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(17.0, 17.1, 16.9, 17.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 5.2, base - 0.1, base + 5.0, i) + }) + .collect(); + let mut a = EveningDojiStar::new(); + let mut b = EveningDojiStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = EveningDojiStar::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(17.0, 17.1, 16.9, 17.0, 1)); + t.update(c(16.0, 16.1, 11.9, 12.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = EveningDojiStar::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + t.update(c(17.0, 17.1, 16.9, 17.0, 1)); + assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0)); + } + + #[test] + fn short_first_body_yields_zero() { + let mut t = EveningDojiStar::new(); + // bar1 has a wide range but a tiny body -> not a long white body. + t.update(c(10.0, 16.0, 9.0, 10.5, 0)); + t.update(c(17.0, 17.1, 16.9, 17.0, 1)); + assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/evwma.rs b/crates/wickra-core/src/indicators/evwma.rs new file mode 100644 index 0000000..4626678 --- /dev/null +++ b/crates/wickra-core/src/indicators/evwma.rs @@ -0,0 +1,238 @@ +//! Elastic Volume-Weighted Moving Average (EVWMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Christian P. Fries' Elastic Volume-Weighted Moving Average. +/// +/// Unlike `VWMA` which is a per-bar weighted mean, `EVWMA` runs an +/// "elastic" recurrence whose smoothing weight is the bar's volume relative +/// to the running window-volume: +/// +/// ```text +/// V_sum_t = Σ volume_i over the last `period` candles +/// EVWMA_t = ((V_sum_t - volume_t) * EVWMA_{t-1} + volume_t * close_t) / V_sum_t +/// ``` +/// +/// A bar whose volume is small compared to the window total barely moves the +/// average; a bar whose volume dominates the window pulls it strongly toward +/// the bar's close. The series is seeded with the close of the first candle +/// after the volume window has filled (i.e. after `period` candles). +/// +/// If `V_sum_t == 0` (every candle in the window has zero volume), the +/// recurrence is undefined; the indicator holds its previous value. +/// +/// Reference: Christian P. Fries, *Wilmott Magazine*, 2001. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Evwma, Indicator}; +/// +/// let mut evwma = Evwma::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap(); +/// last = evwma.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Evwma { + period: usize, + /// Rolling window of `(close, volume)` pairs, oldest at the front. + window: VecDeque<(f64, f64)>, + sum_v: f64, + current: Option, +} + +impl Evwma { + /// # 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_v: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Evwma { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + let volume = candle.volume; + if self.window.len() == self.period { + let (_, old_v) = self.window.pop_front().expect("window is non-empty"); + self.sum_v -= old_v; + } + self.window.push_back((close, volume)); + self.sum_v += volume; + if self.window.len() < self.period { + return None; + } + // The volume sum may be zero (every bar in the window had zero + // volume); the recurrence is undefined, so seed/hold instead. + if self.sum_v <= 0.0 { + if self.current.is_none() { + self.current = Some(close); + } + return self.current; + } + let prev = self.current.unwrap_or(close); + let next = ((self.sum_v - volume) * prev + volume * close) / self.sum_v; + self.current = Some(next); + Some(next) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_v = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "EVWMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Evwma::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut e = Evwma::new(5).unwrap(); + assert_eq!(e.period(), 5); + assert_eq!(e.warmup_period(), 5); + assert_eq!(e.name(), "EVWMA"); + assert_eq!(e.value(), None); + for i in 0..5 { + e.update(candle(10.0, 1.0, i)); + } + assert!(e.value().is_some()); + } + + #[test] + fn constant_series_yields_the_constant() { + // A flat close — every (V_sum - v) * prev + v * close reduces to + // V_sum * close, so the recurrence preserves the constant after the + // first seeded sample. + let mut e = Evwma::new(5).unwrap(); + let candles: Vec = (0..30).map(|i| candle(42.0, 3.0, i)).collect(); + let out = e.batch(&candles); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_value_period_2() { + // EVWMA(2). Bars: (close, volume) = (10, 1), (20, 3), (30, 1). + // Bar 1: window not full (size 1) -> None. + // Bar 2: window full, sum_v = 4, prev seeds to 20. + // EVWMA = ((4 - 3) * 20 + 3 * 20) / 4 = 80 / 4 = 20. + // Bar 3: window slides, sum_v = 4 (drops the 1, gains the 1). + // EVWMA = ((4 - 1) * 20 + 1 * 30) / 4 = (60 + 30) / 4 = 22.5. + let mut e = Evwma::new(2).unwrap(); + assert_eq!(e.update(candle(10.0, 1.0, 0)), None); + assert_relative_eq!( + e.update(candle(20.0, 3.0, 1)).unwrap(), + 20.0, + epsilon = 1e-12 + ); + assert_relative_eq!( + e.update(candle(30.0, 1.0, 2)).unwrap(), + 22.5, + epsilon = 1e-12 + ); + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut e = Evwma::new(4).unwrap(); + for i in 0..3 { + assert_eq!(e.update(candle(10.0, 1.0, i)), None); + } + assert!(e.update(candle(10.0, 1.0, 3)).is_some()); + } + + #[test] + fn zero_volume_window_holds_value() { + // Every bar has zero volume: no participation, so the recurrence + // can't move and EVWMA simply seeds to the first close. + let mut e = Evwma::new(3).unwrap(); + e.update(candle(10.0, 0.0, 0)); + e.update(candle(15.0, 0.0, 1)); + let v = e.update(candle(20.0, 0.0, 2)).unwrap(); + assert_relative_eq!(v, 20.0, epsilon = 1e-12); + // Next bar still flat-zero volume: holds 20. + let v2 = e.update(candle(50.0, 0.0, 3)).unwrap(); + assert_relative_eq!(v2, 20.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c, 1.0 + (i % 7) as f64, i) + }) + .collect(); + let batch = Evwma::new(10).unwrap().batch(&candles); + let mut b = Evwma::new(10).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut e = Evwma::new(3).unwrap(); + let candles: Vec = (0..10).map(|i| candle(10.0 + i as f64, 2.0, i)).collect(); + e.batch(&candles); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.update(candle(10.0, 1.0, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/ewma_volatility.rs b/crates/wickra-core/src/indicators/ewma_volatility.rs new file mode 100644 index 0000000..e496326 --- /dev/null +++ b/crates/wickra-core/src/indicators/ewma_volatility.rs @@ -0,0 +1,264 @@ +//! EWMA Volatility — `RiskMetrics` exponentially-weighted volatility. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// EWMA Volatility — the `RiskMetrics` exponentially-weighted estimate of the +/// volatility of log returns. +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// σ²_t = λ · σ²_{t−1} + (1 − λ) · r²_t +/// EWMA = √σ²_t +/// ``` +/// +/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — an equally +/// weighted, mean-centred sample standard deviation over a fixed window — the +/// EWMA estimator weights recent squared returns geometrically by the decay +/// factor `λ`. The most recent return carries weight `1 − λ`, the one before it +/// `λ(1 − λ)`, and so on, so the estimate reacts to a volatility shock +/// immediately and then forgets it at rate `λ`. This is the J.P. Morgan +/// `RiskMetrics` one-parameter model; the standard daily decay is `λ = 0.94` +/// (monthly `0.97`). No mean is subtracted: squared returns *are* the variance +/// contribution, which matches the `RiskMetrics` assumption of a zero conditional +/// mean over short horizons. +/// +/// The recursion is seeded with the first squared return (`σ²₁ = r²₁`) and emits +/// from the first return onward, so the very first reading is a one-observation +/// estimate that the decay then refines. Each `update` is O(1). +/// +/// Non-finite and non-positive prices are ignored (the log return would be +/// undefined): the tick is dropped, state is left untouched, and the last value +/// is returned. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{EwmaVolatility, Indicator}; +/// +/// let mut indicator = EwmaVolatility::new(0.94).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct EwmaVolatility { + lambda: f64, + prev_price: Option, + /// Exponentially-weighted variance of log returns; `None` until seeded. + variance: Option, + last: Option, +} + +impl EwmaVolatility { + /// Construct a new EWMA-volatility indicator. + /// + /// `lambda` is the decay factor, strictly between `0` and `1` (`RiskMetrics` + /// uses `0.94` for daily data). Larger `lambda` means a longer memory and a + /// smoother estimate. + /// + /// # Errors + /// Returns [`Error::InvalidParameter`] if `lambda` is not finite or not in + /// the open interval `(0, 1)`. + pub fn new(lambda: f64) -> Result { + if !lambda.is_finite() || lambda <= 0.0 || lambda >= 1.0 { + return Err(Error::InvalidParameter { + message: "EWMA volatility lambda must be in the open interval (0, 1)", + }); + } + Ok(Self { + lambda, + prev_price: None, + variance: None, + last: None, + }) + } + + /// Configured decay factor. + pub const fn lambda(&self) -> f64 { + self.lambda + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for EwmaVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite / non-positive prices are skipped: `ln(input / prev)` is + // undefined, so the tick must not enter the variance recursion. + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + // `prev` came from `self.prev_price`, gated by the guard above, so it is + // finite and positive — the log return is always well-defined. + let r = (input / prev).ln(); + let var = match self.variance { + // Seed the recursion with the first squared return. + None => r * r, + Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r, + }; + self.variance = Some(var); + // `var` is a convex combination of non-negative terms, but rounding can + // leave a tiny negative residual when every return is ~0; clamp first. + let vol = var.max(0.0).sqrt(); + self.last = Some(vol); + Some(vol) + } + + fn reset(&mut self) { + self.prev_price = None; + self.variance = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first log return needs a previous price; the estimate is seeded + // and emitted on that first return. + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "EwmaVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_lambda() { + for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] { + assert!(matches!( + EwmaVolatility::new(bad), + Err(Error::InvalidParameter { .. }) + )); + } + } + + #[test] + fn accessors_and_metadata() { + let ewma = EwmaVolatility::new(0.94).unwrap(); + assert_relative_eq!(ewma.lambda(), 0.94); + assert_eq!(ewma.warmup_period(), 2); + assert_eq!(ewma.name(), "EwmaVolatility"); + assert!(!ewma.is_ready()); + assert_eq!(ewma.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut ewma = EwmaVolatility::new(0.94).unwrap(); + assert_eq!(ewma.update(100.0), None); + let out = ewma.update(110.0); + assert!(out.is_some()); + assert!(ewma.is_ready()); + } + + #[test] + fn known_value() { + // r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then + // σ²₂ = λ·r1² + (1−λ)·r2². + let lambda = 0.94; + let mut ewma = EwmaVolatility::new(lambda).unwrap(); + let out = ewma.batch(&[100.0, 110.0, 99.0]); + let r1 = (110.0_f64 / 100.0).ln(); + let r2 = (99.0_f64 / 110.0).ln(); + assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12); + let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2; + assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut ewma = EwmaVolatility::new(0.9).unwrap(); + for v in ewma.batch(&[100.0; 40]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut ewma = EwmaVolatility::new(0.94).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in ewma.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut ewma = EwmaVolatility::new(0.94).unwrap(); + let out = ewma.batch(&(1..=20).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(ewma.update(f64::NAN), last); + assert_eq!(ewma.update(f64::INFINITY), last); + } + + #[test] + fn skips_non_positive_prices() { + let mut ewma = EwmaVolatility::new(0.94).unwrap(); + let warmup = ewma.batch(&(1..=20).map(f64::from).collect::>()); + let baseline = warmup.last().copied().flatten().expect("warmed up"); + assert_eq!(ewma.update(-5.0), Some(baseline)); + assert_eq!(ewma.update(0.0), Some(baseline)); + // State untouched: a clone advanced by the same real tick agrees. + let mut control = ewma.clone(); + let after = ewma.update(21.0).expect("ready"); + assert_eq!(control.update(21.0).expect("ready"), after); + } + + #[test] + fn skips_non_positive_before_first_price() { + // The skip guard fires before any previous price exists. + let mut ewma = EwmaVolatility::new(0.94).unwrap(); + assert_eq!(ewma.update(0.0), None); + assert_eq!(ewma.update(f64::NAN), None); + assert_eq!(ewma.update(100.0), None); + assert!(ewma.update(110.0).is_some()); + } + + #[test] + fn reset_clears_state() { + let mut ewma = EwmaVolatility::new(0.94).unwrap(); + ewma.batch(&(1..=20).map(f64::from).collect::>()); + assert!(ewma.is_ready()); + ewma.reset(); + assert!(!ewma.is_ready()); + assert_eq!(ewma.value(), None); + assert_eq!(ewma.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices); + let mut b = EwmaVolatility::new(0.94).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/expectancy.rs b/crates/wickra-core/src/indicators/expectancy.rs new file mode 100644 index 0000000..1a8dddb --- /dev/null +++ b/crates/wickra-core/src/indicators/expectancy.rs @@ -0,0 +1,211 @@ +//! Expectancy — expected return per unit of average loss (R-multiple). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Expectancy — the expected return per trade expressed in units of average +/// loss (the "R-multiple" expectancy) over the last `period` returns. +/// +/// ```text +/// mean = average of the `period` returns +/// avgLoss = average of the absolute losing returns (rᵢ < 0) +/// E = mean / avgLoss (0 when there are no losing returns) +/// ``` +/// +/// Feed a stream of per-trade or per-bar returns. Expectancy answers "how much +/// do I make per trade for every unit I typically risk": `E = 0.3` means the +/// system nets `0.3R` per trade on average, where `R` is the average loss. +/// Dividing the mean return by the average loss makes the figure comparable +/// across systems with different bet sizes — unlike the raw mean return (which +/// is just an SMA of the series). A positive `E` is a profitable edge, a +/// negative `E` a losing one. +/// +/// When the window contains **no** losing returns there is no risk reference to +/// normalise against, so the indicator returns `0` (undefined R-multiple) +/// rather than dividing by zero. +/// +/// Each `update` is O(1): the running sum and the loss aggregates are +/// maintained incrementally. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BatchExt, Indicator, Expectancy}; +/// +/// let mut indicator = Expectancy::new(4).unwrap(); +/// // returns +2, -1, +2, -1: mean 0.5, avg loss 1 -> E = 0.5. +/// let out = indicator.batch(&[2.0, -1.0, 2.0, -1.0]); +/// assert_eq!(out[3], Some(0.5)); +/// ``` +#[derive(Debug, Clone)] +pub struct Expectancy { + period: usize, + window: VecDeque, + sum: f64, + sum_abs_loss: f64, + loss_count: usize, +} + +impl Expectancy { + /// Construct a new Expectancy over the given window. + /// + /// # 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: 0.0, + sum_abs_loss: 0.0, + loss_count: 0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Expectancy { + 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("window is non-empty"); + self.sum -= old; + if old < 0.0 { + self.sum_abs_loss -= -old; + self.loss_count -= 1; + } + } + self.window.push_back(ret); + self.sum += ret; + if ret < 0.0 { + self.sum_abs_loss += -ret; + self.loss_count += 1; + } + if self.window.len() < self.period { + return None; + } + if self.loss_count == 0 { + // No losing returns: no risk reference to express the edge in. + return Some(0.0); + } + let mean = self.sum / self.period as f64; + let avg_loss = self.sum_abs_loss / self.loss_count as f64; + Some(mean / avg_loss) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_abs_loss = 0.0; + self.loss_count = 0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Expectancy" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Expectancy::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let e = Expectancy::new(20).unwrap(); + assert_eq!(e.period(), 20); + assert_eq!(e.warmup_period(), 20); + assert_eq!(e.name(), "Expectancy"); + assert!(!e.is_ready()); + } + + #[test] + fn positive_edge() { + // +2, -1, +2, -1: mean 0.5, avgLoss 1 -> 0.5. + let mut e = Expectancy::new(4).unwrap(); + let out = e.batch(&[2.0, -1.0, 2.0, -1.0]); + assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12); + } + + #[test] + fn negative_edge() { + // +1, -2, +1, -2: mean -0.5, avgLoss 2 -> -0.25. + let mut e = Expectancy::new(4).unwrap(); + let out = e.batch(&[1.0, -2.0, 1.0, -2.0]); + assert_relative_eq!(out[3].unwrap(), -0.25, epsilon = 1e-12); + } + + #[test] + fn no_losses_returns_zero() { + // All winning returns: no risk reference -> 0. + let mut e = Expectancy::new(5).unwrap(); + for v in e.batch(&[1.0, 2.0, 3.0, 1.0, 2.0]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn flat_returns_are_not_losses() { + // Zeros are not losses: mean (2+0+2+0)/4 = 1, but no losing returns + // -> 0 (undefined R-multiple). + let mut e = Expectancy::new(4).unwrap(); + let out = e.batch(&[2.0, 0.0, 2.0, 0.0]); + assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn rolling_window_evicts_old_losses() { + // period 4. Window [+2,-1,+2,-1] -> 0.5; then push +3,+3,+3,+3 to evict + // all losses -> no losses -> 0. + let mut e = Expectancy::new(4).unwrap(); + let out = e.batch(&[2.0, -1.0, 2.0, -1.0, 3.0, 3.0, 3.0, 3.0]); + assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12); + assert_relative_eq!(out[7].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut e = Expectancy::new(5).unwrap(); + e.batch(&[1.0, -1.0, 2.0, -2.0, 1.0]); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let rets: Vec = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect(); + let batch = Expectancy::new(14).unwrap().batch(&rets); + let mut b = Expectancy::new(14).unwrap(); + let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/falling_three_methods.rs b/crates/wickra-core/src/indicators/falling_three_methods.rs new file mode 100644 index 0000000..6ac04d1 --- /dev/null +++ b/crates/wickra-core/src/indicators/falling_three_methods.rs @@ -0,0 +1,235 @@ +//! Falling Three Methods candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Falling Three Methods — a 5-bar bearish continuation. A long black candle is +/// followed by three small bars that drift up but stay inside its range (a brief +/// rest), then a second long black candle closes below the first, resuming the +/// decline. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1 black & long +/// bar2, bar3, bar4 small bodies, each contained within bar1's high/low range +/// bar5 black, closing below bar1's close +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Falling Three +/// Methods is a single-direction (bearish-only) continuation, so it never emits +/// `+1.0`. The first four bars always return `0.0` because the five-bar window is +/// not yet filled. Body thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, FallingThreeMethods, Indicator}; +/// +/// let mut indicator = FallingThreeMethods::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(11.0, 12.1, 10.9, 12.0, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(11.5, 12.6, 11.4, 12.5, 1.0, 2).unwrap()); +/// indicator.update(Candle::new(12.0, 13.1, 11.9, 13.0, 1.0, 3).unwrap()); +/// let out = indicator +/// .update(Candle::new(12.5, 12.6, 8.9, 9.0, 1.0, 4).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FallingThreeMethods { + c1: Option, + c2: Option, + c3: Option, + c4: Option, + has_emitted: bool, +} + +impl FallingThreeMethods { + /// Construct a new Falling Three Methods detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + c3: None, + c4: None, + has_emitted: false, + } + } +} + +impl Indicator for FallingThreeMethods { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + let bar4 = self.c4; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = self.c4; + self.c4 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + if range1 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.open - bar1.close; + if body1 < 0.5 * range1 { + return Some(0.0); // bar1 must be a long black body + } + // The three middle bars stay within bar1's range with smaller bodies. + for mid in [bar2, bar3, bar4] { + if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low { + return Some(0.0); + } + } + // bar5 is a black candle closing below bar1's close. + if candle.close < candle.open && candle.close < bar1.close { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.c4 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "FallingThreeMethods" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = FallingThreeMethods::new(); + assert_eq!(t.name(), "FallingThreeMethods"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + } + + #[test] + fn falling_three_methods_is_minus_one() { + let mut t = FallingThreeMethods::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0)); + assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0)); + assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(-1.0)); + } + + #[test] + fn middle_bar_breaks_range_yields_zero() { + let mut t = FallingThreeMethods::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(11.0, 12.1, 10.9, 12.0, 1)); + // bar3 pokes below bar1's low. + t.update(c(11.5, 12.6, 9.0, 12.5, 2)); + t.update(c(12.0, 13.1, 11.9, 13.0, 3)); + assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0)); + } + + #[test] + fn bar5_not_new_low_yields_zero() { + let mut t = FallingThreeMethods::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(11.0, 12.1, 10.9, 12.0, 1)); + t.update(c(11.5, 12.6, 11.4, 12.5, 2)); + t.update(c(12.0, 13.1, 11.9, 13.0, 3)); + // bar5 black but closes above bar1's close. + assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 4)), Some(0.0)); + } + + #[test] + fn first_four_bars_return_zero() { + let mut t = FallingThreeMethods::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0)); + assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 200.0 - i as f64; + c(base + 5.0, base + 5.1, base - 0.1, base, i) + }) + .collect(); + let mut a = FallingThreeMethods::new(); + let mut b = FallingThreeMethods::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = FallingThreeMethods::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(11.0, 12.1, 10.9, 12.0, 1)); + t.update(c(11.5, 12.6, 11.4, 12.5, 2)); + t.update(c(12.0, 13.1, 11.9, 13.0, 3)); + t.update(c(12.5, 12.6, 8.9, 9.0, 4)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_first_bar_yields_zero() { + let mut t = FallingThreeMethods::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + t.update(c(11.0, 12.1, 10.9, 12.0, 1)); + t.update(c(11.5, 12.6, 11.4, 12.5, 2)); + t.update(c(12.0, 13.1, 11.9, 13.0, 3)); + assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0)); + } + + #[test] + fn short_first_body_yields_zero() { + let mut t = FallingThreeMethods::new(); + // bar1 has a wide range but a tiny body -> not a long black body. + t.update(c(10.0, 16.0, 9.0, 10.2, 0)); + t.update(c(11.0, 12.1, 10.9, 12.0, 1)); + t.update(c(11.5, 12.6, 11.4, 12.5, 2)); + t.update(c(12.0, 13.1, 11.9, 13.0, 3)); + assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/fama.rs b/crates/wickra-core/src/indicators/fama.rs new file mode 100644 index 0000000..85b2a4f --- /dev/null +++ b/crates/wickra-core/src/indicators/fama.rs @@ -0,0 +1,174 @@ +//! Ehlers Following Adaptive Moving Average (FAMA). + +use crate::error::Result; +use crate::indicators::mama::Mama; +use crate::traits::Indicator; + +/// Scalar wrapper that exposes only the FAMA line from a [`Mama`] indicator. +/// +/// FAMA (Following Adaptive Moving Average) is MAMA's lagging companion in +/// Ehlers' MESA construction. It uses half MAMA's adaptive alpha, so it +/// reacts later than MAMA — MAMA crossing above FAMA marks a trend +/// confirmation, MAMA below FAMA a reversal. See [`Mama`] for the joint +/// `(mama, fama)` output; this wrapper exposes the slow line as a plain +/// scalar indicator so it can be chained directly. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Fama}; +/// +/// let mut fama = Fama::new(0.5, 0.05).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Fama { + inner: Mama, + last_value: Option, +} + +impl Fama { + /// Construct with the same `(fast_limit, slow_limit)` semantics as [`Mama`]. + /// + /// # Errors + /// + /// Forwards [`Mama::new`]'s validation errors. + pub fn new(fast_limit: f64, slow_limit: f64) -> Result { + Ok(Self { + inner: Mama::new(fast_limit, slow_limit)?, + last_value: None, + }) + } + + /// Default `(0.5, 0.05)` parameters. + pub fn classic() -> Self { + Self { + inner: Mama::classic(), + last_value: None, + } + } + + /// Configured `(fast_limit, slow_limit)`. + pub const fn limits(&self) -> (f64, f64) { + self.inner.limits() + } + + /// Current FAMA value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for Fama { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let v = self.inner.update(input)?.fama; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.inner.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "FAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::traits::BatchExt; + + #[test] + fn rejects_invalid_limits() { + assert!(matches!( + Fama::new(0.0, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Fama::new(0.05, 0.5), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn new_with_valid_limits_constructs_via_mama() { + // `classic()` bypasses `new` by going through `Mama::classic`; this + // test exercises the happy-path `Ok(Self { inner: Mama::new(..)? })` + // arm so the `?` doesn't only collapse to the error path. + let mut fama = Fama::new(0.5, 0.05).expect("valid Mama limits"); + assert_eq!(fama.limits(), (0.5, 0.05)); + for i in 0..60 { + fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(fama.value().is_some()); + } + + #[test] + fn accessors_and_metadata() { + let mut fama = Fama::classic(); + assert_eq!(fama.limits(), (0.5, 0.05)); + assert_eq!(fama.warmup_period(), 33); + assert_eq!(fama.name(), "FAMA"); + assert!(!fama.is_ready()); + for i in 0..60 { + fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(fama.is_ready()); + assert!(fama.value().is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).cos() * 5.0) + .collect(); + let mut a = Fama::classic(); + let mut b = Fama::classic(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut fama = Fama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + fama.batch(&prices); + let before = fama.value(); + assert!(before.is_some()); + assert_eq!(fama.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut fama = Fama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + fama.batch(&prices); + assert!(fama.is_ready()); + fama.reset(); + assert!(!fama.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/fib_arcs.rs b/crates/wickra-core/src/indicators/fib_arcs.rs new file mode 100644 index 0000000..0b4660a --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_arcs.rs @@ -0,0 +1,198 @@ +//! Fibonacci Arcs — semicircular retracement levels centred on the swing end, +//! decaying back toward it as time elapses. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The three arc ratios drawn (38.2% / 50% / 61.8%). +const RATIOS: [f64; 3] = [0.382, 0.5, 0.618]; + +/// Fibonacci Arc prices evaluated at the current bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibArcsOutput { + /// Price of the 38.2% arc at the current bar. + pub arc_382: f64, + /// Price of the 50% arc at the current bar. + pub arc_500: f64, + /// Price of the 61.8% arc at the current bar. + pub arc_618: f64, +} + +/// Fibonacci Arcs (`FibArcs`). +/// +/// Three arcs centred on the end of the most recent confirmed swing leg. Time is +/// normalised by the leg's bar-width so the construction is chart-scale-free: at +/// the leg's end bar each arc sits exactly on its retracement level, and as time +/// elapses the arc curves back toward the swing-end price, reaching it one leg +/// width later. +/// +/// ```text +/// u = (cur - end_bar) / (end_bar - start_bar) +/// arc(r) = end + (start - end) * r * sqrt(max(0, 1 - u^2)) +/// ``` +/// +/// Parameter-free; construction is infallible. Returns `None` until the first +/// leg is complete. +/// +/// See `crates/wickra-core/src/indicators/fib_arcs.rs`. +#[derive(Debug, Clone)] +pub struct FibArcs { + swing: SwingTracker, +} + +impl FibArcs { + /// Construct a new Fibonacci Arcs tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 2), + } + } + + fn arcs(&self) -> Option { + let pivots = self.swing.pivots(); + let start = pivots.first()?; + let end = pivots.get(1)?; + // Consecutive pivots occur at strictly increasing bars → span >= 1 bar. + let span_bars = (end.bar - start.bar) as f64; + let u = (self.swing.current_bar() - end.bar) as f64 / span_bars; + let curve = (1.0 - u * u).max(0.0).sqrt(); + let arc = |r: f64| end.price + (start.price - end.price) * r * curve; + Some(FibArcsOutput { + arc_382: arc(RATIOS[0]), + arc_500: arc(RATIOS[1]), + arc_618: arc(RATIOS[2]), + }) + } +} + +impl Default for FibArcs { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibArcs { + type Input = Candle; + type Output = FibArcsOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.arcs() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 2 + } + + fn name(&self) -> &'static str { + "FibArcs" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + + /// Leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3 so the arc is + /// first reported with `u = (3 - 2) / (2 - 0) = 0.5`. + fn down_leg() -> Vec { + vec![ + c(200.0, 199.0, 0), + c(190.0, 160.0, 1), // confirm high @200 + c(150.0, 100.0, 2), // extend low to 100 (bar 2) + c(110.0, 105.0, 3), // confirm low @100 -> two pivots + ] + } + + #[test] + fn accessors_and_metadata() { + let indicator = FibArcs::new(); + assert_eq!(indicator.name(), "FibArcs"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!FibArcs::default().is_ready()); + } + + #[test] + fn no_output_before_two_pivots() { + let mut indicator = FibArcs::new(); + let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)] + .into_iter() + .map(|x| indicator.update(x)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + assert!(!indicator.is_ready()); + } + + #[test] + fn arcs_curve_back_toward_the_swing_end() { + let mut indicator = FibArcs::new(); + let mut last = None; + for candle in down_leg() { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // u = 0.5 → curve = sqrt(0.75); arc(r) = 100 + 100 * r * curve. + let curve = 0.75_f64.sqrt(); + assert_relative_eq!(v.arc_382, 100.0 + 100.0 * 0.382 * curve); + assert_relative_eq!(v.arc_500, 100.0 + 100.0 * 0.5 * curve); + assert_relative_eq!(v.arc_618, 100.0 + 100.0 * 0.618 * curve); + } + + #[test] + fn arc_clamps_to_zero_beyond_one_leg_width() { + // Extend far past the end pivot so u > 1; the curve clamps to 0 and the + // arcs collapse onto the swing-end price. + let mut indicator = FibArcs::new(); + for candle in down_leg() { + let _ = indicator.update(candle); + } + // Feed flat bars that neither extend nor confirm a new pivot. + let mut last = None; + for ts in 4..12 { + last = indicator.update(c(108.0, 106.0, ts)); + } + let v = last.unwrap(); + assert_relative_eq!(v.arc_382, 100.0); + assert_relative_eq!(v.arc_618, 100.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibArcs::new(); + for candle in down_leg() { + let _ = indicator.update(candle); + } + indicator.reset(); + assert!(!indicator.is_ready()); + assert!(indicator.update(c(100.0, 99.5, 0)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = down_leg(); + let mut a = FibArcs::new(); + let mut b = FibArcs::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_channel.rs b/crates/wickra-core/src/indicators/fib_channel.rs new file mode 100644 index 0000000..ff101e5 --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_channel.rs @@ -0,0 +1,192 @@ +//! Fibonacci Channel — a sloped base trendline plus parallel lines offset by +//! Fibonacci multiples of the channel width. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The parallel-line ratios above the base (61.8% / 100% / 161.8% of the width). +const RATIOS: [f64; 3] = [0.618, 1.0, 1.618]; + +/// Fibonacci Channel line prices evaluated at the current bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibChannelOutput { + /// The base trendline price at the current bar. + pub base: f64, + /// Base + 61.8% of the channel width. + pub level_618: f64, + /// Base + 100% of the width — the opposite channel boundary. + pub level_1000: f64, + /// Base + 161.8% of the width. + pub level_1618: f64, +} + +/// Fibonacci Channel (`FibChannel`). +/// +/// From the last three confirmed pivots, the two same-direction outer pivots +/// define a sloped base trendline and the opposite middle pivot sets the channel +/// width (its signed distance from the base line). Parallel lines are then offset +/// by Fibonacci multiples of that width and reported at the current bar. +/// +/// ```text +/// slope = (p2 - p0) / (bar2 - bar0) +/// base(bar) = p0 + slope * (bar - bar0) +/// width = p1 - base(bar1) +/// level(r) = base(cur) + r * width +/// ``` +/// +/// Parameter-free; construction is infallible. Returns `None` until three pivots +/// have confirmed. +/// +/// See `crates/wickra-core/src/indicators/fib_channel.rs`. +#[derive(Debug, Clone)] +pub struct FibChannel { + swing: SwingTracker, +} + +impl FibChannel { + /// Construct a new Fibonacci Channel tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 3), + } + } + + fn channel(&self) -> Option { + let pivots = self.swing.pivots(); + let p0 = pivots.first()?; + let p1 = pivots.get(1)?; + let p2 = pivots.get(2)?; + // p0 and p2 are the same-direction outer pivots; their bars differ + // strictly, so the slope denominator is non-zero. + let slope = (p2.price - p0.price) / (p2.bar - p0.bar) as f64; + let base_at = |bar: usize| p0.price + slope * (bar - p0.bar) as f64; + let width = p1.price - base_at(p1.bar); + let base = base_at(self.swing.current_bar()); + Some(FibChannelOutput { + base, + level_618: base + RATIOS[0] * width, + level_1000: base + RATIOS[1] * width, + level_1618: base + RATIOS[2] * width, + }) + } +} + +impl Default for FibChannel { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibChannel { + type Input = Candle; + type Output = FibChannelOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.channel() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 3 + } + + fn name(&self) -> &'static str { + "FibChannel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + + /// Pivots: high 200 (bar 0), low 100 (bar 1), high 220 (bar 3); confirmed at + /// bar 4 so the channel is first reported at current bar 4. + fn three_pivots() -> Vec { + vec![ + c(200.0, 199.0, 0), + c(190.0, 100.0, 1), // confirm high @200, low candidate @100 + c(110.0, 108.0, 2), // confirm low @100, high candidate @110 + c(220.0, 210.0, 3), // extend high to 220 (bar 3) + c(200.0, 150.0, 4), // confirm high @220 -> three pivots + ] + } + + #[test] + fn accessors_and_metadata() { + let indicator = FibChannel::new(); + assert_eq!(indicator.name(), "FibChannel"); + assert_eq!(indicator.warmup_period(), 3); + assert!(!indicator.is_ready()); + assert!(!FibChannel::default().is_ready()); + } + + #[test] + fn no_output_before_three_pivots() { + let mut indicator = FibChannel::new(); + let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 100.0, 1), c(110.0, 108.0, 2)] + .into_iter() + .map(|x| indicator.update(x)) + .collect(); + // Only two pivots confirm within these three bars. + assert!(outputs.iter().all(Option::is_none)); + assert!(!indicator.is_ready()); + } + + #[test] + fn channel_levels_from_three_pivots() { + let mut indicator = FibChannel::new(); + let mut last = None; + for candle in three_pivots() { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // Base through highs (0,200) and (3,220); width from low (1,100); cur = 4. + let slope = (220.0 - 200.0) / 3.0; + let base_cur = 200.0 + slope * 4.0; + let width = 100.0 - (200.0 + slope * 1.0); + assert_relative_eq!(v.base, base_cur); + assert_relative_eq!(v.level_1000, base_cur + width); + assert_relative_eq!(v.level_618, base_cur + 0.618 * width); + assert_relative_eq!(v.level_1618, base_cur + 1.618 * width); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibChannel::new(); + for candle in three_pivots() { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + assert!(indicator.update(c(100.0, 99.5, 0)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = three_pivots(); + let mut a = FibChannel::new(); + let mut b = FibChannel::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_confluence.rs b/crates/wickra-core/src/indicators/fib_confluence.rs new file mode 100644 index 0000000..b4bb656 --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_confluence.rs @@ -0,0 +1,196 @@ +//! Fibonacci Confluence — the strongest retracement cluster across recent legs. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// How many recent pivots to consider; six pivots yield up to five legs. +const PIVOT_HISTORY: usize = 6; + +/// The retracement ratios contributed by each leg to the confluence search. +const RATIOS: [f64; 3] = [0.382, 0.5, 0.618]; + +/// The strongest Fibonacci confluence zone found across recent swing legs. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibConfluenceOutput { + /// Mean price of the densest cluster of retracement levels. + pub price: f64, + /// Number of retracement levels that fall inside the cluster (its strength). + pub strength: f64, +} + +/// Fibonacci Confluence (`FibConfluence`). +/// +/// Computes the 38.2% / 50% / 61.8% retracement prices of every leg among the +/// last six confirmed pivots, then reports the densest price cluster — where +/// levels from different legs stack up, the zone the market is most likely to +/// react to. `price` is the cluster mean; `strength` is how many levels it +/// gathers. +/// +/// Parameter-free; construction is infallible. Returns `None` until at least two +/// legs (three pivots) exist. +/// +/// See `crates/wickra-core/src/indicators/fib_confluence.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{FibConfluence, Candle, Indicator}; +/// +/// let mut indicator = FibConfluence::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct FibConfluence { + swing: SwingTracker, +} + +impl FibConfluence { + /// Construct a new Fibonacci Confluence tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY), + } + } + + fn confluence(&self) -> Option { + let pivots = self.swing.pivots(); + if pivots.len() < 3 { + return None; + } + let levels: Vec = pivots + .windows(2) + .flat_map(|leg| { + let (start, end) = (leg[0].price, leg[1].price); + RATIOS.map(|r| end + r * (start - end)) + }) + .collect(); + // The `len < 3` guard guarantees at least two legs, hence a non-empty + // level set, so `max_by` always yields a cluster. + let (count, total) = levels + .iter() + .map(|¢er| { + let members: Vec = levels + .iter() + .copied() + .filter(|&x| approx_equal(x, center, LEVEL_TOLERANCE)) + .collect(); + (members.len(), members.iter().sum::()) + }) + .max_by(|a, b| a.0.cmp(&b.0)) + .expect("at least two legs guarantee a non-empty level set"); + Some(FibConfluenceOutput { + price: total / count as f64, + strength: count as f64, + }) + } +} + +impl Default for FibConfluence { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibConfluence { + type Input = Candle; + type Output = FibConfluenceOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.confluence() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 3 + } + + fn name(&self) -> &'static str { + "FibConfluence" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = FibConfluence::new(); + assert_eq!(indicator.name(), "FibConfluence"); + assert_eq!(indicator.warmup_period(), 3); + assert!(!indicator.is_ready()); + assert!(!FibConfluence::default().is_ready()); + } + + #[test] + fn no_output_before_two_legs() { + let mut indicator = FibConfluence::new(); + let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0]) + .into_iter() + .map(|c| indicator.update(c)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + assert!(!indicator.is_ready()); + } + + #[test] + fn picks_the_densest_cluster() { + // Legs 200->100 and 100->160. The 38.2% of each (138.2 and ~137.08) + // sit within 3% of each other and form the densest cluster (strength 2). + let mut indicator = FibConfluence::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + assert_relative_eq!(v.strength, 2.0); + let want = (138.2 + (160.0 + 0.382 * (100.0 - 160.0))) / 2.0; + assert_relative_eq!(v.price, want, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibConfluence::new(); + for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert!(indicator.update(c).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[200.0, 100.0, 160.0, 120.0]); + let mut a = FibConfluence::new(); + let mut b = FibConfluence::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_extension.rs b/crates/wickra-core/src/indicators/fib_extension.rs new file mode 100644 index 0000000..0f6177d --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_extension.rs @@ -0,0 +1,186 @@ +//! Fibonacci Extension of the most recent confirmed swing leg. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The five canonical extension ratios, in ascending order. Each is a multiple +/// of the swing leg measured from its origin, so `1.0` sits on the leg's end and +/// every ratio here projects further in the direction of the move. +const RATIOS: [f64; 5] = [1.272, 1.414, 1.618, 2.0, 2.618]; + +/// Fibonacci Extension levels for the most recent swing leg. +/// +/// Each field is the price reached if the move continues to the matching +/// multiple of the leg, measured from the leg's start. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibExtensionOutput { + /// 127.2% extension. + pub level_1272: f64, + /// 141.4% extension. + pub level_1414: f64, + /// 161.8% extension — the "golden" extension. + pub level_1618: f64, + /// 200% extension. + pub level_2000: f64, + /// 261.8% extension. + pub level_2618: f64, +} + +/// Fibonacci Extension (`FibExtension`). +/// +/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold and, once +/// two pivots exist, projects the leg between them to the canonical extension +/// ratios — the price targets a continuation of the move would reach. +/// +/// Parameter-free; construction is infallible. Returns `None` until the first +/// leg is complete. +/// +/// See `crates/wickra-core/src/indicators/fib_extension.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{FibExtension, Candle, Indicator}; +/// +/// let mut indicator = FibExtension::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct FibExtension { + swing: SwingTracker, +} + +impl FibExtension { + /// Construct a new Fibonacci Extension tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 2), + } + } + + /// Extension price at ratio `e` for a leg from `start` to `end`: the total + /// move is `e` times the leg, measured from `start`. + fn level(start: f64, end: f64, e: f64) -> f64 { + start + e * (end - start) + } + + fn levels(&self) -> Option { + let pivots = self.swing.pivots(); + let [start, end] = [pivots.first()?.price, pivots.get(1)?.price]; + Some(FibExtensionOutput { + level_1272: Self::level(start, end, RATIOS[0]), + level_1414: Self::level(start, end, RATIOS[1]), + level_1618: Self::level(start, end, RATIOS[2]), + level_2000: Self::level(start, end, RATIOS[3]), + level_2618: Self::level(start, end, RATIOS[4]), + }) + } +} + +impl Default for FibExtension { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibExtension { + type Input = Candle; + type Output = FibExtensionOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.levels() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 2 + } + + fn name(&self) -> &'static str { + "FibExtension" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = FibExtension::new(); + assert_eq!(indicator.name(), "FibExtension"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!FibExtension::default().is_ready()); + } + + #[test] + fn no_output_before_two_pivots() { + let mut indicator = FibExtension::new(); + let outputs: Vec<_> = candles_for_pivots(&[120.0]) + .into_iter() + .map(|c| indicator.update(c)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + } + + #[test] + fn extension_levels_of_a_down_leg() { + // Leg start = 200 (high), end = 100 (low): a 100-point drop continued. + let mut indicator = FibExtension::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 100.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // 161.8% extension projects 1.618 * (-100) below the 200 origin. + assert_relative_eq!(v.level_1272, 200.0 - 127.2); + assert_relative_eq!(v.level_1414, 200.0 - 141.4); + assert_relative_eq!(v.level_1618, 200.0 - 161.8); + assert_relative_eq!(v.level_2000, 0.0); + assert_relative_eq!(v.level_2618, 200.0 - 261.8); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibExtension::new(); + for candle in candles_for_pivots(&[200.0, 100.0]) { + let _ = indicator.update(candle); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert!(indicator.update(c).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[200.0, 100.0, 150.0]); + let mut a = FibExtension::new(); + let mut b = FibExtension::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_fan.rs b/crates/wickra-core/src/indicators/fib_fan.rs new file mode 100644 index 0000000..2202073 --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_fan.rs @@ -0,0 +1,180 @@ +//! Fibonacci Fan — trendlines fanning from a swing start through the +//! retracement levels at the swing end, extended to the current bar. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The three fan ratios drawn (38.2% / 50% / 61.8%). +const RATIOS: [f64; 3] = [0.382, 0.5, 0.618]; + +/// Fibonacci Fan line prices evaluated at the current bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibFanOutput { + /// Price of the 38.2% fan line at the current bar. + pub fan_382: f64, + /// Price of the 50% fan line at the current bar. + pub fan_500: f64, + /// Price of the 61.8% fan line at the current bar. + pub fan_618: f64, +} + +/// Fibonacci Fan (`FibFan`). +/// +/// Anchored at the start of the most recent confirmed swing leg, three lines fan +/// out through the 38.2% / 50% / 61.8% retracement levels located at the leg's +/// end bar, then extend to the current bar. Each line's price is reported as the +/// fan opens with elapsed time. +/// +/// ```text +/// line(r) = start + r * (end - start) * (cur - start_bar) / (end_bar - start_bar) +/// ``` +/// +/// Parameter-free; construction is infallible. Returns `None` until the first +/// leg is complete. +/// +/// See `crates/wickra-core/src/indicators/fib_fan.rs`. +#[derive(Debug, Clone)] +pub struct FibFan { + swing: SwingTracker, +} + +impl FibFan { + /// Construct a new Fibonacci Fan tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 2), + } + } + + fn fan(&self) -> Option { + let pivots = self.swing.pivots(); + let start = pivots.first()?; + let end = pivots.get(1)?; + // Consecutive pivots occur at strictly increasing bars, so the span is + // always at least one bar — no division by zero. + let span_bars = (end.bar - start.bar) as f64; + let elapsed = (self.swing.current_bar() - start.bar) as f64; + let progress = elapsed / span_bars; + let line = |r: f64| start.price + r * (end.price - start.price) * progress; + Some(FibFanOutput { + fan_382: line(RATIOS[0]), + fan_500: line(RATIOS[1]), + fan_618: line(RATIOS[2]), + }) + } +} + +impl Default for FibFan { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibFan { + type Input = Candle; + type Output = FibFanOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.fan() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 2 + } + + fn name(&self) -> &'static str { + "FibFan" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + + /// Drive a leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3, so + /// the fan is first reported at bar 3 with `progress = 3 / 2 = 1.5`. + fn down_leg() -> Vec { + vec![ + c(200.0, 199.0, 0), // bootstrap high @200 (bar 0) + c(190.0, 160.0, 1), // confirm high @200, low candidate @160 + c(150.0, 100.0, 2), // extend low to 100 (bar 2) + c(110.0, 105.0, 3), // confirm low @100 -> two pivots + ] + } + + #[test] + fn accessors_and_metadata() { + let indicator = FibFan::new(); + assert_eq!(indicator.name(), "FibFan"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!FibFan::default().is_ready()); + } + + #[test] + fn no_output_before_two_pivots() { + let mut indicator = FibFan::new(); + // Only the high confirms here; no end pivot yet. + let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)] + .into_iter() + .map(|x| indicator.update(x)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + assert!(!indicator.is_ready()); + } + + #[test] + fn fan_lines_open_with_elapsed_time() { + let mut indicator = FibFan::new(); + let mut last = None; + for candle in down_leg() { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // progress = (3 - 0) / (2 - 0) = 1.5; line(r) = 200 + r*(-100)*1.5. + assert_relative_eq!(v.fan_382, 200.0 - 0.382 * 150.0); + assert_relative_eq!(v.fan_500, 125.0); + assert_relative_eq!(v.fan_618, 200.0 - 0.618 * 150.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibFan::new(); + for candle in down_leg() { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + assert!(indicator.update(c(100.0, 99.5, 0)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = down_leg(); + let mut a = FibFan::new(); + let mut b = FibFan::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_projection.rs b/crates/wickra-core/src/indicators/fib_projection.rs new file mode 100644 index 0000000..c168614 --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_projection.rs @@ -0,0 +1,180 @@ +//! Fibonacci Projection — a measured move from the last three swing pivots. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The four canonical projection ratios, in ascending order. Each scales the +/// A→B leg and projects it from C; `1.0` is the classic AB=CD measured move. +const RATIOS: [f64; 4] = [0.618, 1.0, 1.618, 2.618]; + +/// Fibonacci Projection levels (the C→D target zone of a measured move). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibProjectionOutput { + /// 61.8% projection of the A→B leg from C. + pub level_618: f64, + /// 100% projection — the AB=CD measured move. + pub level_1000: f64, + /// 161.8% projection. + pub level_1618: f64, + /// 261.8% projection. + pub level_2618: f64, +} + +/// Fibonacci Projection (`FibProjection`). +/// +/// Reads the last three confirmed swing pivots as the points A, B and C of a +/// measured move and projects the A→B leg from C at the canonical ratios — the +/// price targets for the C→D leg. +/// +/// Parameter-free; construction is infallible. Returns `None` until three +/// pivots have confirmed. +/// +/// See `crates/wickra-core/src/indicators/fib_projection.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{FibProjection, Candle, Indicator}; +/// +/// let mut indicator = FibProjection::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct FibProjection { + swing: SwingTracker, +} + +impl FibProjection { + /// Construct a new Fibonacci Projection tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 3), + } + } + + fn levels(&self) -> Option { + let pivots = self.swing.pivots(); + let [a, b, c] = [ + pivots.first()?.price, + pivots.get(1)?.price, + pivots.get(2)?.price, + ]; + let project = |p: f64| c + p * (b - a); + Some(FibProjectionOutput { + level_618: project(RATIOS[0]), + level_1000: project(RATIOS[1]), + level_1618: project(RATIOS[2]), + level_2618: project(RATIOS[3]), + }) + } +} + +impl Default for FibProjection { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibProjection { + type Input = Candle; + type Output = FibProjectionOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.levels() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 3 + } + + fn name(&self) -> &'static str { + "FibProjection" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = FibProjection::new(); + assert_eq!(indicator.name(), "FibProjection"); + assert_eq!(indicator.warmup_period(), 3); + assert!(!indicator.is_ready()); + assert!(!FibProjection::default().is_ready()); + } + + #[test] + fn no_output_before_three_pivots() { + let mut indicator = FibProjection::new(); + let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0]) + .into_iter() + .map(|c| indicator.update(c)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + assert!(!indicator.is_ready()); + } + + #[test] + fn measured_move_from_three_pivots() { + // A = 200 (high), B = 160 (low), C = 190 (high). A->B = -40, projected + // down from C. + let mut indicator = FibProjection::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + let (a, b, c) = (200.0, 160.0, 190.0); + assert_relative_eq!(v.level_618, c + 0.618 * (b - a)); + assert_relative_eq!(v.level_1000, c + (b - a)); + assert_relative_eq!(v.level_1618, c + 1.618 * (b - a)); + assert_relative_eq!(v.level_2618, c + 2.618 * (b - a)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibProjection::new(); + for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert!(indicator.update(c).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[200.0, 160.0, 190.0, 150.0]); + let mut a = FibProjection::new(); + let mut b = FibProjection::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_retracement.rs b/crates/wickra-core/src/indicators/fib_retracement.rs new file mode 100644 index 0000000..64dfa2e --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_retracement.rs @@ -0,0 +1,216 @@ +//! Fibonacci Retracement of the most recent confirmed swing leg. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The seven canonical retracement ratios, in ascending order. `0.0` marks the +/// most recent swing extreme (the end of the leg) and `1.0` the swing origin +/// (its start); the interior ratios are the classic Fibonacci pullbacks. +const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0]; + +/// Fibonacci Retracement levels for the most recent swing leg. +/// +/// Each field is the price at the matching retracement ratio, measured from the +/// leg's end (`level_0`, the latest confirmed extreme) back toward its start +/// (`level_1000`, the prior pivot). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibRetracementOutput { + /// 0.0% — the most recent confirmed swing extreme. + pub level_0: f64, + /// 23.6% retracement. + pub level_236: f64, + /// 38.2% retracement. + pub level_382: f64, + /// 50% retracement (not a Fibonacci ratio, but conventionally drawn). + pub level_500: f64, + /// 61.8% retracement — the "golden ratio" pullback. + pub level_618: f64, + /// 78.6% retracement. + pub level_786: f64, + /// 100% — the swing origin. + pub level_1000: f64, +} + +/// Fibonacci Retracement (`FibRetracement`). +/// +/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold (the +/// same non-repainting logic as [`crate::indicators::ZigZag`]) and, once two +/// pivots exist, reports the seven retracement levels of the leg between them. +/// +/// The levels are recomputed each time a new pivot confirms; between +/// confirmations [`Indicator::update`] returns the locked levels of the current +/// leg. Before the first leg is complete it returns `None`. +/// +/// Parameter-free: the threshold is a compile-time constant, mirroring the +/// chart- and harmonic-pattern detectors, so construction is infallible. +/// +/// See `crates/wickra-core/src/indicators/fib_retracement.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{FibRetracement, Candle, Indicator}; +/// +/// let mut indicator = FibRetracement::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct FibRetracement { + swing: SwingTracker, +} + +impl FibRetracement { + /// Construct a new Fibonacci Retracement tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 2), + } + } + + /// Retracement price at ratio `r` for a leg from `start` to `end`: `0.0` + /// sits on `end`, `1.0` on `start`. + fn level(start: f64, end: f64, r: f64) -> f64 { + end + r * (start - end) + } + + fn levels(&self) -> Option { + let pivots = self.swing.pivots(); + let [start, end] = [pivots.first()?.price, pivots.get(1)?.price]; + Some(FibRetracementOutput { + level_0: Self::level(start, end, RATIOS[0]), + level_236: Self::level(start, end, RATIOS[1]), + level_382: Self::level(start, end, RATIOS[2]), + level_500: Self::level(start, end, RATIOS[3]), + level_618: Self::level(start, end, RATIOS[4]), + level_786: Self::level(start, end, RATIOS[5]), + level_1000: Self::level(start, end, RATIOS[6]), + }) + } +} + +impl Default for FibRetracement { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibRetracement { + type Input = Candle; + type Output = FibRetracementOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.levels() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 2 + } + + fn name(&self) -> &'static str { + "FibRetracement" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = FibRetracement::new(); + assert_eq!(indicator.name(), "FibRetracement"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!FibRetracement::default().is_ready()); + } + + #[test] + fn no_output_before_two_pivots() { + let mut indicator = FibRetracement::new(); + // A single confirmed pivot is not enough to define a leg. + let candles = candles_for_pivots(&[120.0]); + let outputs: Vec<_> = candles.into_iter().map(|c| indicator.update(c)).collect(); + assert!(outputs.iter().all(Option::is_none)); + assert!(!indicator.is_ready()); + } + + #[test] + fn retracement_levels_of_a_down_leg() { + // Leg start = 200 (high), end = 100 (low): a 100-point drop. + let mut indicator = FibRetracement::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 100.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // 0% on the low (end), 100% on the high (start). + assert_relative_eq!(v.level_0, 100.0); + assert_relative_eq!(v.level_1000, 200.0); + // 61.8% retracement of a 100-point drop, measured up from the low. + assert_relative_eq!(v.level_618, 161.8); + assert_relative_eq!(v.level_500, 150.0); + assert_relative_eq!(v.level_382, 138.2); + assert_relative_eq!(v.level_236, 123.6); + assert_relative_eq!(v.level_786, 178.6); + } + + #[test] + fn levels_refresh_on_a_new_leg() { + // Four pivots, cap = 2: once the third and fourth confirm, the reported + // leg shifts to the latest pair (130 high -> 90 low). + let mut indicator = FibRetracement::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 100.0, 130.0, 90.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert_relative_eq!(v.level_0, 90.0); + assert_relative_eq!(v.level_1000, 130.0); + assert_relative_eq!(v.level_618, 90.0 + 0.618 * 40.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibRetracement::new(); + for candle in candles_for_pivots(&[200.0, 100.0]) { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert!(indicator.update(c).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[200.0, 100.0, 150.0]); + let mut a = FibRetracement::new(); + let mut b = FibRetracement::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fib_time_zones.rs b/crates/wickra-core/src/indicators/fib_time_zones.rs new file mode 100644 index 0000000..3f0f283 --- /dev/null +++ b/crates/wickra-core/src/indicators/fib_time_zones.rs @@ -0,0 +1,196 @@ +//! Fibonacci Time Zones — vertical markers at Fibonacci bar-distances from the +//! most recent swing pivot. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Where the current bar sits relative to the Fibonacci time-zone grid anchored +/// on the most recent confirmed pivot. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibTimeZonesOutput { + /// `1.0` when the current bar lands on a Fibonacci time zone (a bar distance + /// of 1, 2, 3, 5, 8, 13, … from the anchor pivot), otherwise `0.0`. + pub on_zone: f64, + /// Number of bars until the next Fibonacci time zone (`0` is never returned — + /// when on a zone this is the gap to the following one). + pub bars_to_next: f64, +} + +/// Fibonacci Time Zones (`FibTimeZones`). +/// +/// Anchored on the most recent confirmed swing pivot, the Fibonacci sequence +/// `1, 2, 3, 5, 8, 13, …` marks bars at which trend changes are classically +/// anticipated. Reports whether the current bar is on a zone and how many bars +/// remain until the next one. +/// +/// Parameter-free; construction is infallible. Returns `None` until the first +/// pivot has confirmed. +/// +/// See `crates/wickra-core/src/indicators/fib_time_zones.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{FibTimeZones, Candle, Indicator}; +/// +/// let mut indicator = FibTimeZones::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct FibTimeZones { + swing: SwingTracker, +} + +impl FibTimeZones { + /// Construct a new Fibonacci Time Zones tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 2), + } + } + + fn zones(&self) -> Option { + let anchor = self.swing.pivots().last()?; + let distance = self.swing.current_bar() - anchor.bar; + // Walk the time-zone sequence 1, 2, 3, 5, 8, … : `lo` advances through the + // members, `on_zone` records a hit, and the loop exits with `lo` holding + // the smallest member strictly greater than `distance`. + let (mut lo, mut hi) = (1usize, 2usize); + let mut on_zone = false; + while lo <= distance { + if lo == distance { + on_zone = true; + } + let next = lo + hi; + lo = hi; + hi = next; + } + Some(FibTimeZonesOutput { + on_zone: f64::from(u8::from(on_zone)), + bars_to_next: (lo - distance) as f64, + }) + } +} + +impl Default for FibTimeZones { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FibTimeZones { + type Input = Candle; + type Output = FibTimeZonesOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.zones() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + !self.swing.pivots().is_empty() + } + + fn name(&self) -> &'static str { + "FibTimeZones" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + + /// One pivot confirms at bar 0 (high @200, confirmed at bar 1); subsequent + /// flat bars neither extend nor confirm, so the anchor stays at bar 0 and the + /// distance equals the current bar index. + fn anchored_run() -> Vec { + let mut bars = vec![c(200.0, 199.0, 0), c(190.0, 150.0, 1)]; + for ts in 2..=5 { + bars.push(c(155.0, 151.0, ts)); + } + bars + } + + #[test] + fn accessors_and_metadata() { + let indicator = FibTimeZones::new(); + assert_eq!(indicator.name(), "FibTimeZones"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!FibTimeZones::default().is_ready()); + } + + #[test] + fn no_output_before_first_pivot() { + let mut indicator = FibTimeZones::new(); + // The bootstrap bar confirms nothing. + assert!(indicator.update(c(200.0, 199.0, 0)).is_none()); + assert!(!indicator.is_ready()); + } + + #[test] + fn flags_zones_and_counts_to_next() { + let mut indicator = FibTimeZones::new(); + let out: Vec<_> = anchored_run() + .into_iter() + .map(|x| indicator.update(x)) + .collect(); + assert!(out[0].is_none()); // bootstrap, no pivot yet + assert!(indicator.is_ready()); + // out[i] is reported at current bar i; anchor at bar 0 → distance = i. + let d1 = out[1].unwrap(); // distance 1 → a zone + assert_relative_eq!(d1.on_zone, 1.0); + assert_relative_eq!(d1.bars_to_next, 1.0); // next zone at 2 + let d4 = out[4].unwrap(); // distance 4 → not a zone + assert_relative_eq!(d4.on_zone, 0.0); + assert_relative_eq!(d4.bars_to_next, 1.0); // next zone at 5 + let d5 = out[5].unwrap(); // distance 5 → a zone + assert_relative_eq!(d5.on_zone, 1.0); + assert_relative_eq!(d5.bars_to_next, 3.0); // next zone at 8 + } + + #[test] + fn reset_clears_state() { + let mut indicator = FibTimeZones::new(); + for candle in anchored_run() { + let _ = indicator.update(candle); + } + assert!(indicator.is_ready()); + indicator.reset(); + assert!(!indicator.is_ready()); + assert!(indicator.update(c(100.0, 99.5, 0)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = anchored_run(); + let mut a = FibTimeZones::new(); + let mut b = FibTimeZones::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fibonacci_pivots.rs b/crates/wickra-core/src/indicators/fibonacci_pivots.rs new file mode 100644 index 0000000..e011e8f --- /dev/null +++ b/crates/wickra-core/src/indicators/fibonacci_pivots.rs @@ -0,0 +1,195 @@ +//! Fibonacci Pivot Points. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Fibonacci Pivot Points output: pivot plus three Fib-spaced resistances and +/// supports. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FibonacciPivotsOutput { + /// Pivot Point: `(H + L + C) / 3`. + pub pp: f64, + /// Resistance 1: `PP + 0.382·(H − L)`. + pub r1: f64, + /// Resistance 2: `PP + 0.618·(H − L)`. + pub r2: f64, + /// Resistance 3: `PP + 1.000·(H − L)`. + pub r3: f64, + /// Support 1: `PP − 0.382·(H − L)`. + pub s1: f64, + /// Support 2: `PP − 0.618·(H − L)`. + pub s2: f64, + /// Support 3: `PP − 1.000·(H − L)`. + pub s3: f64, +} + +/// Fibonacci Pivot Points — the classic pivot plus three resistances and +/// supports spaced by the Fibonacci ratios 0.382 / 0.618 / 1.000 applied to +/// the prior bar's range. +/// +/// ```text +/// PP = (H + L + C) / 3 +/// R1 = PP + 0.382·(H − L) S1 = PP − 0.382·(H − L) +/// R2 = PP + 0.618·(H − L) S2 = PP − 0.618·(H − L) +/// R3 = PP + 1.000·(H − L) S3 = PP − 1.000·(H − L) +/// ``` +/// +/// As with [`crate::ClassicPivots`], levels are typically built from the +/// previous session's bar. There are no parameters and no warmup. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, FibonacciPivots, Indicator}; +/// +/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap(); +/// let levels = FibonacciPivots::new().update(prev).unwrap(); +/// assert!(levels.r3 > levels.r2); +/// assert!(levels.r2 > levels.r1); +/// assert!(levels.s1 > levels.s2); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FibonacciPivots { + ready: bool, +} + +impl FibonacciPivots { + /// Construct a new Fibonacci Pivot Points indicator. + pub const fn new() -> Self { + Self { ready: false } + } +} + +const FIB1: f64 = 0.382; +const FIB2: f64 = 0.618; +const FIB3: f64 = 1.000; + +impl Indicator for FibonacciPivots { + type Input = Candle; + type Output = FibonacciPivotsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let (h, l, c) = (candle.high, candle.low, candle.close); + let pp = (h + l + c) / 3.0; + let range = h - l; + let out = FibonacciPivotsOutput { + pp, + r1: pp + FIB1 * range, + r2: pp + FIB2 * range, + r3: pp + FIB3 * range, + s1: pp - FIB1 * range, + s2: pp - FIB2 * range, + s3: pp - FIB3 * range, + }; + self.ready = true; + Some(out) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "FibonacciPivots" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, h, l, close, 1.0, ts).unwrap() + } + + #[test] + fn formula_reference_values() { + // H=110, L=90, range=20, PP = (110+90+100)/3 = 100. + let levels = FibonacciPivots::new() + .update(c(110.0, 90.0, 100.0, 0)) + .unwrap(); + assert!((levels.pp - 100.0).abs() < 1e-12); + assert!((levels.r1 - (100.0 + 0.382 * 20.0)).abs() < 1e-12); + assert!((levels.r2 - (100.0 + 0.618 * 20.0)).abs() < 1e-12); + assert!((levels.r3 - (100.0 + 20.0)).abs() < 1e-12); + assert!((levels.s1 - (100.0 - 0.382 * 20.0)).abs() < 1e-12); + assert!((levels.s2 - (100.0 - 0.618 * 20.0)).abs() < 1e-12); + assert!((levels.s3 - (100.0 - 20.0)).abs() < 1e-12); + } + + #[test] + fn resistances_strictly_above_pp_supports_strictly_below() { + let levels = FibonacciPivots::new() + .update(c(120.0, 80.0, 110.0, 0)) + .unwrap(); + assert!(levels.r3 > levels.r2); + assert!(levels.r2 > levels.r1); + assert!(levels.r1 > levels.pp); + assert!(levels.pp > levels.s1); + assert!(levels.s1 > levels.s2); + assert!(levels.s2 > levels.s3); + } + + #[test] + fn constant_series_collapses_levels() { + let levels = FibonacciPivots::new() + .update(c(50.0, 50.0, 50.0, 0)) + .unwrap(); + assert_eq!(levels.pp, 50.0); + assert_eq!(levels.r1, 50.0); + assert_eq!(levels.s3, 50.0); + } + + #[test] + fn warmup_and_ready() { + let mut p = FibonacciPivots::new(); + assert!(!p.is_ready()); + assert_eq!(p.warmup_period(), 1); + p.update(c(11.0, 9.0, 10.0, 0)); + assert!(p.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut p = FibonacciPivots::new(); + p.update(c(11.0, 9.0, 10.0, 0)); + p.reset(); + assert!(!p.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0_i32..40) + .map(|i| { + c( + f64::from(i) + 2.0, + f64::from(i), + f64::from(i) + 1.0, + i.into(), + ) + }) + .collect(); + let mut a = FibonacciPivots::new(); + let mut b = FibonacciPivots::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = FibonacciPivots::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "FibonacciPivots"); + } +} diff --git a/crates/wickra-core/src/indicators/fisher_rsi.rs b/crates/wickra-core/src/indicators/fisher_rsi.rs new file mode 100644 index 0000000..26687c9 --- /dev/null +++ b/crates/wickra-core/src/indicators/fisher_rsi.rs @@ -0,0 +1,186 @@ +//! Fisher-transformed RSI. + +use crate::error::Result; +use crate::indicators::rsi::Rsi; +use crate::traits::Indicator; + +/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi). +/// +/// The RSI is bounded in `[0, 100]` and its distribution piles up near the +/// middle, which blurs turning points. The Fisher transform reshapes a bounded +/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric +/// peaks: +/// +/// ```text +/// rsi = RSI(price, period) in [0, 100] +/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1) +/// Fisher = 0.5 * ln((1 + x) / (1 - x)) +/// ``` +/// +/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The +/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with +/// sharp excursions marking momentum extremes. The first value lands with the +/// inner RSI, after `period + 1` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{FisherRsi, Indicator}; +/// +/// let mut indicator = FisherRsi::new(9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct FisherRsi { + period: usize, + rsi: Rsi, +} + +impl FisherRsi { + /// Construct a Fisher RSI with the given RSI period. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + rsi: Rsi::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for FisherRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let rsi = self.rsi.update(input)?; + let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999); + Some(0.5 * ((1.0 + x) / (1.0 - x)).ln()) + } + + fn reset(&mut self) { + self.rsi.reset(); + } + + fn warmup_period(&self) -> usize { + self.rsi.warmup_period() + } + + fn is_ready(&self) -> bool { + self.rsi.is_ready() + } + + fn name(&self) -> &'static str { + "FisherRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(FisherRsi::new(0).is_err()); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let f = FisherRsi::new(9).unwrap(); + assert_eq!(f.period(), 9); + // RSI warmup is period + 1. + assert_eq!(f.warmup_period(), 10); + assert_eq!(f.name(), "FisherRSI"); + } + + #[test] + fn warmup_matches_rsi() { + let mut f = FisherRsi::new(3).unwrap(); + // RSI(3) needs 4 inputs; the first three return None. + assert_eq!(f.update(1.0), None); + assert_eq!(f.update(2.0), None); + assert_eq!(f.update(3.0), None); + assert!(f.update(4.0).is_some()); + } + + #[test] + fn matches_fisher_of_rsi() { + // Fisher RSI must equal the Fisher transform of the standalone RSI. + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0) + .collect(); + let mut fr = FisherRsi::new(9).unwrap(); + let mut rsi = Rsi::new(9).unwrap(); + for (i, &p) in prices.iter().enumerate() { + let got = fr.update(p); + let want = rsi.update(p).map(|r| { + let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999); + 0.5 * ((1.0 + x) / (1.0 - x)).ln() + }); + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (got, want) { + assert_relative_eq!(a, b, epsilon = 1e-12); + } + } + } + + #[test] + fn strong_uptrend_is_positive() { + // A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher. + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut f = FisherRsi::new(9).unwrap(); + let last = f.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last > 1.0, + "strong uptrend should give a large positive value, got {last}" + ); + } + + #[test] + fn clamp_keeps_output_finite_at_extremes() { + // Monotonic rise pins RSI at 100; the clamp must keep Fisher finite. + let prices: Vec = (1..=30).map(f64::from).collect(); + let mut f = FisherRsi::new(5).unwrap(); + for v in f.batch(&prices).into_iter().flatten() { + assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut f = FisherRsi::new(5).unwrap(); + f.batch(&(1..=20).map(f64::from).collect::>()); + assert!(f.is_ready()); + f.reset(); + assert!(!f.is_ready()); + assert_eq!(f.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40) + .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0) + .collect(); + let mut a = FisherRsi::new(9).unwrap(); + let mut b = FisherRsi::new(9).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fisher_transform.rs b/crates/wickra-core/src/indicators/fisher_transform.rs new file mode 100644 index 0000000..1b7876f --- /dev/null +++ b/crates/wickra-core/src/indicators/fisher_transform.rs @@ -0,0 +1,199 @@ +//! Ehlers Fisher Transform. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Fisher Transform of price. +/// +/// Normalises the most recent price to `[-1, +1]` via min/max over a `period` +/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and +/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a +/// near-Gaussian distribution, so extreme readings stand out cleanly. A +/// secondary signal is produced by lagging the Fisher value by one bar (the +/// classic trigger), making the indicator a two-line crossover system in +/// charts. +/// +/// Only the primary Fisher value is exposed here as a scalar; the lagged +/// trigger is one update behind by construction. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, FisherTransform}; +/// +/// let mut ft = FisherTransform::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct FisherTransform { + period: usize, + window: VecDeque, + smoothed: f64, + last_fisher: Option, +} + +impl FisherTransform { + /// Construct with the rolling extrema window length. + /// + /// # 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), + smoothed: 0.0, + last_fisher: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current Fisher value if available. + pub const fn value(&self) -> Option { + self.last_fisher + } +} + +impl Indicator for FisherTransform { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_fisher; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let max = self + .window + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let min = self.window.iter().copied().fold(f64::INFINITY, f64::min); + let range = max - min; + // Normalise to roughly [-1, +1]; centred midpoint when range == 0. + let raw = if range > 0.0 { + ((input - min) / range).mul_add(2.0, -1.0) + } else { + 0.0 + }; + // Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp. + self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed); + // Clamp strictly inside (-1, +1) to keep the log finite. + let clamped = self.smoothed.clamp(-0.999, 0.999); + let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln(); + self.last_fisher = Some(fisher); + Some(fisher) + } + + fn reset(&mut self) { + self.window.clear(); + self.smoothed = 0.0; + self.last_fisher = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last_fisher.is_some() + } + + fn name(&self) -> &'static str { + "FisherTransform" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut ft = FisherTransform::new(10).unwrap(); + assert_eq!(ft.period(), 10); + assert_eq!(ft.warmup_period(), 10); + assert_eq!(ft.name(), "FisherTransform"); + assert!(ft.value().is_none()); + for i in 1..=10 { + ft.update(f64::from(i)); + } + assert!(ft.value().is_some()); + assert!(ft.is_ready()); + } + + #[test] + fn warmup_returns_none_until_seed() { + let mut ft = FisherTransform::new(5).unwrap(); + for i in 1..=4 { + assert_eq!(ft.update(f64::from(i)), None); + } + assert!(ft.update(5.0).is_some()); + } + + #[test] + fn constant_series_zero_range_yields_zero() { + let mut ft = FisherTransform::new(5).unwrap(); + let out = ft.batch(&[42.0_f64; 30]); + for x in out.iter().skip(5).flatten() { + assert!(x.abs() < 1e-6, "expected near-zero, got {x}"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0) + .collect(); + let mut a = FisherTransform::new(10).unwrap(); + let mut b = FisherTransform::new(10).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ft = FisherTransform::new(5).unwrap(); + ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let before = ft.value(); + assert!(before.is_some()); + assert_eq!(ft.update(f64::NAN), before); + assert_eq!(ft.update(f64::INFINITY), before); + } + + #[test] + fn reset_clears_state() { + let mut ft = FisherTransform::new(5).unwrap(); + ft.batch(&(1..=20).map(f64::from).collect::>()); + assert!(ft.is_ready()); + ft.reset(); + assert!(!ft.is_ready()); + assert_eq!(ft.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/flag_pennant.rs b/crates/wickra-core/src/indicators/flag_pennant.rs new file mode 100644 index 0000000..0fc5bf9 --- /dev/null +++ b/crates/wickra-core/src/indicators/flag_pennant.rs @@ -0,0 +1,162 @@ +//! Flag / Pennant continuation chart pattern. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Maximum size of the consolidation swing relative to the pole for a +/// flag/pennant to qualify — the pullback must retrace less than half the pole. +const MAX_RETRACE_FRACTION: f64 = 0.5; + +/// Flag / Pennant — a brief consolidation against a sharp prior move (the +/// "pole"), resolving in the pole's direction. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the +/// last three pivots `pole_start → pole_end → consolidation`: +/// +/// ```text +/// pole = |pole_end − pole_start| (the sharp impulse) +/// pullback = |consolidation − pole_end| (the shallow counter-move) +/// qualifies when pullback < 0.5 · pole +/// bull flag : pole_end is a swing high → +1 (up-pole, continuation up) +/// bear flag : pole_end is a swing low → -1 (down-pole, continuation down) +/// ``` +/// +/// The detector fires on the bar that confirms the consolidation pivot (the flag +/// is complete; the breakout is expected to follow). Output is `+1.0` / `-1.0` / +/// `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct FlagPennant { + swing: SwingTracker, + has_emitted: bool, +} + +impl FlagPennant { + /// Construct a new Flag / Pennant detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 3), + has_emitted: false, + } + } +} + +impl Default for FlagPennant { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FlagPennant { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 3 { + return Some(0.0); + } + let n = pivots.len(); + let pole_start = pivots[n - 3]; + let pole_end = pivots[n - 2]; + let consolidation = pivots[n - 1]; + let pole = (pole_end.price - pole_start.price).abs(); + let pullback = (consolidation.price - pole_end.price).abs(); + + if pole > 0.0 && pullback < MAX_RETRACE_FRACTION * pole { + // pole_end a high → up-pole → bull flag; a low → bear flag. + return Some(if pole_end.direction > 0.0 { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Three confirmed pivots; the earliest confirmation of the third is bar 4. + 4 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "FlagPennant" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = FlagPennant::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = FlagPennant::new(); + assert_eq!(indicator.name(), "FlagPennant"); + assert_eq!(indicator.warmup_period(), 4); + assert!(!indicator.is_ready()); + assert!(!FlagPennant::default().is_ready()); + } + + #[test] + fn bull_flag_is_plus_one() { + // Up-pole 100 → 140 (40), shallow pullback to 130 (10 < 20) → bull flag. + let out = run(&[150.0, 100.0, 140.0, 130.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn bear_flag_is_minus_one() { + // Down-pole 140 → 100 (40), shallow pullback to 110 (10 < 20) → bear flag. + let out = run(&[140.0, 100.0, 110.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn deep_pullback_is_not_a_flag() { + // Pole 100 → 140 (40) but pullback to 104 (36 > 20) → not a flag. + let out = run(&[150.0, 100.0, 140.0, 104.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FlagPennant::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 130.0]); + let mut a = FlagPennant::new(); + let mut b = FlagPennant::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/footprint.rs b/crates/wickra-core/src/indicators/footprint.rs new file mode 100644 index 0000000..2e5d38e --- /dev/null +++ b/crates/wickra-core/src/indicators/footprint.rs @@ -0,0 +1,259 @@ +//! Footprint — buy/sell volume profile per price bucket within a bar. + +use std::collections::BTreeMap; + +use crate::error::{Error, Result}; +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// One price bucket of a [`Footprint`]: the buy- and sell-initiated volume that +/// traded there since the last reset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FootprintLevel { + /// Bucket price (the bucket index times the tick size). + pub price: f64, + /// Sell-initiated (bid-hitting) volume traded at this bucket. + pub bid_vol: f64, + /// Buy-initiated (ask-lifting) volume traded at this bucket. + pub ask_vol: f64, +} + +/// The full footprint of a bar: one [`FootprintLevel`] per touched price +/// bucket, sorted ascending by price. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct FootprintOutput { + /// Touched price buckets, lowest price first. + pub levels: Vec, +} + +/// Footprint — the buy/sell volume profile of a bar, bucketed by price. +/// +/// A footprint (a.k.a. bid/ask or volume cluster chart) decomposes the volume +/// traded within a bar across the price levels at which it printed, splitting +/// each level into buy-initiated (ask-lifting) and sell-initiated (bid-hitting) +/// volume. It exposes *where* inside a bar the activity happened and which side +/// was the aggressor there — the basis for absorption, imbalance and +/// point-of-control analysis that a single OHLCV bar hides. +/// +/// Each trade is assigned to the price bucket `round(price / tick_size)`; its +/// size is added to that bucket's ask volume for a buy and bid volume for a +/// sell. Every [`update`] returns the complete footprint accumulated since the +/// last [`reset`], as a [`FootprintOutput`] whose `levels` are sorted ascending +/// by price. Call [`reset`] at each bar (or session) boundary to start a fresh +/// footprint. +/// +/// `Input = Trade`, `Output = FootprintOutput`. Ready after the first trade. +/// +/// [`update`]: crate::Indicator::update +/// [`reset`]: crate::Indicator::reset +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Footprint, Indicator, Side, Trade}; +/// +/// let mut fp = Footprint::new(1.0).unwrap(); +/// fp.update(Trade::new(100.2, 2.0, Side::Buy, 0).unwrap()); +/// let out = fp.update(Trade::new(100.7, 3.0, Side::Sell, 1).unwrap()).unwrap(); +/// // Two buckets: 100 (ask 2) and 101 (bid 3). +/// assert_eq!(out.levels.len(), 2); +/// assert_eq!(out.levels[0].price, 100.0); +/// assert_eq!(out.levels[0].ask_vol, 2.0); +/// assert_eq!(out.levels[1].price, 101.0); +/// assert_eq!(out.levels[1].bid_vol, 3.0); +/// ``` +#[derive(Debug, Clone)] +pub struct Footprint { + tick_size: f64, + // bucket index -> (bid_vol = sell-initiated, ask_vol = buy-initiated). + buckets: BTreeMap, + has_emitted: bool, +} + +impl Footprint { + /// Construct a footprint with the given price-bucket `tick_size`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidTick`] if `tick_size` is not a finite, strictly + /// positive number. + pub fn new(tick_size: f64) -> Result { + if !tick_size.is_finite() || tick_size <= 0.0 { + return Err(Error::InvalidTick { + message: "footprint tick_size must be finite and positive", + }); + } + Ok(Self { + tick_size, + buckets: BTreeMap::new(), + has_emitted: false, + }) + } + + /// The configured price-bucket size. + pub const fn tick_size(&self) -> f64 { + self.tick_size + } + + fn bucket_index(&self, price: f64) -> i64 { + // Float-to-int `as` saturates rather than wrapping, so an extreme + // price/tick ratio clamps to i64::MIN/MAX instead of misbehaving; + // realistic ratios fit comfortably. + #[allow(clippy::cast_possible_truncation)] + { + (price / self.tick_size).round() as i64 + } + } + + fn snapshot(&self) -> FootprintOutput { + let levels = self + .buckets + .iter() + .map(|(&index, &(bid_vol, ask_vol))| FootprintLevel { + price: index as f64 * self.tick_size, + bid_vol, + ask_vol, + }) + .collect(); + FootprintOutput { levels } + } +} + +impl Indicator for Footprint { + type Input = Trade; + type Output = FootprintOutput; + + fn update(&mut self, trade: Trade) -> Option { + self.has_emitted = true; + let index = self.bucket_index(trade.price); + let entry = self.buckets.entry(index).or_insert((0.0, 0.0)); + if trade.side.sign() > 0.0 { + entry.1 += trade.size; + } else { + entry.0 += trade.size; + } + Some(self.snapshot()) + } + + fn reset(&mut self) { + self.buckets.clear(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Footprint" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(price: f64, size: f64, side: Side) -> Trade { + Trade::new(price, size, side, 0).unwrap() + } + + #[test] + fn rejects_bad_tick_size() { + assert!(matches!( + Footprint::new(0.0), + Err(Error::InvalidTick { .. }) + )); + assert!(matches!( + Footprint::new(-1.0), + Err(Error::InvalidTick { .. }) + )); + assert!(matches!( + Footprint::new(f64::NAN), + Err(Error::InvalidTick { .. }) + )); + assert!(Footprint::new(0.5).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let fp = Footprint::new(0.25).unwrap(); + assert_eq!(fp.name(), "Footprint"); + assert_eq!(fp.warmup_period(), 1); + assert_eq!(fp.tick_size(), 0.25); + assert!(!fp.is_ready()); + } + + #[test] + fn buckets_buy_and_sell_volume() { + let mut fp = Footprint::new(1.0).unwrap(); + fp.update(trade(100.2, 2.0, Side::Buy)); + fp.update(trade(100.7, 3.0, Side::Sell)); + let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap(); + assert!(fp.is_ready()); + // Bucket 100: buy 2 + buy 1 = ask 3, bid 0. Bucket 101: sell 3. + assert_eq!(out.levels.len(), 2); + assert_eq!(out.levels[0].price, 100.0); + assert_eq!(out.levels[0].ask_vol, 3.0); + assert_eq!(out.levels[0].bid_vol, 0.0); + assert_eq!(out.levels[1].price, 101.0); + assert_eq!(out.levels[1].bid_vol, 3.0); + assert_eq!(out.levels[1].ask_vol, 0.0); + } + + #[test] + fn levels_sorted_ascending_by_price() { + let mut fp = Footprint::new(1.0).unwrap(); + fp.update(trade(103.0, 1.0, Side::Buy)); + fp.update(trade(100.0, 1.0, Side::Sell)); + let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap(); + let prices: Vec = out.levels.iter().map(|l| l.price).collect(); + assert_eq!(prices, vec![100.0, 101.0, 103.0]); + } + + #[test] + fn sub_tick_prices_share_a_bucket() { + let mut fp = Footprint::new(0.5).unwrap(); + // 100.24 and 100.26 both round to bucket 200 (price 100.0)... check: + // 100.24/0.5 = 200.48 -> 200; 100.26/0.5 = 200.52 -> 201. Distinct. + fp.update(trade(100.20, 1.0, Side::Buy)); // 200.4 -> 200 -> price 100.0 + let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); // 200.2 -> 200 + assert_eq!(out.levels.len(), 1); + assert_eq!(out.levels[0].price, 100.0); + assert_eq!(out.levels[0].ask_vol, 3.0); + } + + #[test] + fn reset_clears_the_footprint() { + let mut fp = Footprint::new(1.0).unwrap(); + fp.update(trade(100.0, 5.0, Side::Buy)); + assert!(fp.is_ready()); + fp.reset(); + assert!(!fp.is_ready()); + let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap(); + assert_eq!(out.levels.len(), 1); + assert_eq!(out.levels[0].price, 200.0); + assert_eq!(out.levels[0].bid_vol, 1.0); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..30) + .map(|i| { + let side = if i % 3 == 0 { Side::Sell } else { Side::Buy }; + trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side) + }) + .collect(); + let mut a = Footprint::new(1.0).unwrap(); + let mut b = Footprint::new(1.0).unwrap(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/force_index.rs b/crates/wickra-core/src/indicators/force_index.rs new file mode 100644 index 0000000..d47874e --- /dev/null +++ b/crates/wickra-core/src/indicators/force_index.rs @@ -0,0 +1,198 @@ +//! Force Index (Elder). + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Alexander Elder's Force Index — price change scaled by volume, EMA-smoothed. +/// +/// ```text +/// raw_t = (close_t − close_{t−1}) · volume_t +/// Force_t = EMA(raw, period)_t +/// ``` +/// +/// The raw force is positive on an up-close and negative on a down-close, and +/// its magnitude grows with the volume that backed the move — a big move on +/// heavy volume registers a large force. Smoothing the raw series with an EMA +/// gives a tradeable line; Elder's classic period is `13`. The first candle +/// only establishes the previous close, so the first raw value appears on +/// candle 2 and the first smoothed value on candle `period + 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ForceIndex}; +/// +/// let mut indicator = ForceIndex::new(13).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ForceIndex { + period: usize, + prev_close: Option, + ema: Ema, +} + +impl ForceIndex { + /// Construct a new Force Index with the given EMA smoothing period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + prev_close: None, + ema: Ema::new(period)?, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ForceIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + // The first candle only establishes the previous close. + self.prev_close = Some(candle.close); + return None; + }; + let raw = (candle.close - prev) * candle.volume; + self.prev_close = Some(candle.close); + self.ema.update(raw) + } + + fn reset(&mut self) { + self.prev_close = None; + self.ema.reset(); + } + + fn warmup_period(&self) -> usize { + // One seed candle establishes the first previous close, then the EMA + // needs `period` raw values. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "ForceIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through. + // candle 1 (close 10) only seeds the previous close -> None. + // candle 2: raw = (12 - 10) * 100 = +200. + // candle 3: raw = (11 - 12) * 200 = -200. + let mut fi = ForceIndex::new(1).unwrap(); + let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9); + } + + #[test] + fn pure_uptrend_is_positive() { + // Strictly rising closes on constant volume -> every raw force is + // positive, so the smoothed force is positive too. + let candles: Vec = (1..40) + .map(|i| c(f64::from(i), 100.0, i64::from(i))) + .collect(); + let mut fi = ForceIndex::new(13).unwrap(); + for v in fi.batch(&candles).into_iter().flatten() { + assert!(v > 0.0, "force {v} should be positive in an uptrend"); + } + } + + #[test] + fn pure_downtrend_is_negative() { + let candles: Vec = (1..40) + .rev() + .map(|i| c(f64::from(i), 100.0, i64::from(i))) + .collect(); + let mut fi = ForceIndex::new(13).unwrap(); + for v in fi.batch(&candles).into_iter().flatten() { + assert!(v < 0.0, "force {v} should be negative in a downtrend"); + } + } + + #[test] + fn first_value_on_period_plus_one_candle() { + let candles: Vec = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect(); + let mut fi = ForceIndex::new(5).unwrap(); + let out = fi.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[5].is_some(), "first force lands at index period"); + assert_eq!(fi.warmup_period(), 6); + } + + #[test] + fn rejects_zero_period() { + assert!(ForceIndex::new(0).is_err()); + } + + /// Cover the const accessor `period` (58-60) and the Indicator-impl + /// `name` body (93-95). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let fi = ForceIndex::new(13).unwrap(); + assert_eq!(fi.period(), 13); + assert_eq!(fi.name(), "ForceIndex"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect(); + let mut fi = ForceIndex::new(13).unwrap(); + fi.batch(&candles); + assert!(fi.is_ready()); + fi.reset(); + assert!(!fi.is_ready()); + assert_eq!(fi.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let close = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(close, 10.0 + (i % 5) as f64, i) + }) + .collect(); + let mut a = ForceIndex::new(13).unwrap(); + let mut b = ForceIndex::new(13).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/fractal_chaos_bands.rs b/crates/wickra-core/src/indicators/fractal_chaos_bands.rs new file mode 100644 index 0000000..c7df3ad --- /dev/null +++ b/crates/wickra-core/src/indicators/fractal_chaos_bands.rs @@ -0,0 +1,271 @@ +//! Fractal Chaos Bands (Bill Williams Fractals). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Fractal Chaos Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FractalChaosBandsOutput { + /// Upper band: high of the most recent confirmed fractal high. + pub upper: f64, + /// Lower band: low of the most recent confirmed fractal low. + pub lower: f64, +} + +/// Fractal Chaos Bands: a step-function envelope of the most recent Bill +/// Williams fractal highs and lows. +/// +/// A bar is a **fractal high** when its high is the maximum of the window +/// `[i − k, …, i + k]`. A **fractal low** is defined symmetrically on lows. +/// The bands hold the high (low) of the latest confirmed fractal high (low), +/// stepping outwards whenever a new fractal forms and otherwise staying flat: +/// +/// ```text +/// confirmation_lag = k // the centre bar is known only k bars later +/// upper = high of the most recent confirmed fractal high +/// lower = low of the most recent confirmed fractal low +/// ``` +/// +/// `k = 2` (5-bar fractals) is the canonical Williams setting and matches the +/// "Fractal Chaos Bands" oscillator shipped with several chart vendors. With +/// `k` bars of look-ahead, every band update reflects price `k` bars ago — +/// strict streaming preserves this lag rather than peeking into the future. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, FractalChaosBands, Indicator}; +/// +/// let mut indicator = FractalChaosBands::new(2).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// // Confirmation requires `2k + 1` bars plus at least one fractal of each +/// // kind, so `last` may legitimately be `None` on a single sweep without +/// // both a peak and a trough in the window. +/// let _ = last; +/// ``` +#[derive(Debug, Clone)] +pub struct FractalChaosBands { + k: usize, + window: VecDeque, + last_upper: Option, + last_lower: Option, +} + +impl FractalChaosBands { + /// Construct a new Fractal Chaos Bands indicator with the given fractal + /// half-width `k` (a bar is a fractal high if its high exceeds the highs + /// of the `k` bars on either side; canonical `k = 2`). + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `k == 0` (a single bar is always its + /// own trivial fractal). + pub fn new(k: usize) -> Result { + if k == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + k, + window: VecDeque::with_capacity(2 * k + 1), + last_upper: None, + last_lower: None, + }) + } + + /// Canonical Bill Williams configuration: `k = 2` (5-bar fractals). + pub fn classic() -> Self { + Self::new(2).expect("classic Fractal Chaos Bands parameters are valid") + } + + /// Configured half-width `k`. + pub const fn k(&self) -> usize { + self.k + } +} + +impl Indicator for FractalChaosBands { + type Input = Candle; + type Output = FractalChaosBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let window_len = 2 * self.k + 1; + if self.window.len() == window_len { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < window_len { + return None; + } + // The centre bar is at index `k`. Strictly compare against the `k` + // bars on either side: `>` for the high and `<` for the low (a ties- + // included pattern would fire on flat tops/bottoms, against Williams' + // intent). + let center = &self.window[self.k]; + let mut is_high = true; + let mut is_low = true; + for (i, c) in self.window.iter().enumerate() { + if i == self.k { + continue; + } + if c.high >= center.high { + is_high = false; + } + if c.low <= center.low { + is_low = false; + } + } + if is_high { + self.last_upper = Some(center.high); + } + if is_low { + self.last_lower = Some(center.low); + } + // Both bands must have been seen at least once before we can emit. + match (self.last_upper, self.last_lower) { + (Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }), + _ => None, + } + } + + fn reset(&mut self) { + self.window.clear(); + self.last_upper = None; + self.last_lower = None; + } + + fn warmup_period(&self) -> usize { + 2 * self.k + 1 + } + + fn is_ready(&self) -> bool { + self.last_upper.is_some() && self.last_lower.is_some() + } + + fn name(&self) -> &'static str { + "FractalChaosBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_k() { + assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let f = FractalChaosBands::classic(); + assert_eq!(f.k(), 2); + assert_eq!(f.warmup_period(), 5); + assert_eq!(f.name(), "FractalChaosBands"); + } + + /// Detect a single peak and a single trough with `k = 2`. + /// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2), + /// (2,2,2), (1,1,1), (2,2,2), (5,3,4). + /// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side + /// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on + /// each side (after index 5 lands). Both bands first emit on index 5. + #[test] + fn detects_simple_peak_and_trough() { + let candles = vec![ + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4 + c(3.0, 0.5, 1.0), // trough: low 0.5 is the min + c(2.0, 2.0, 2.0), + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + ]; + let mut f = FractalChaosBands::new(2).unwrap(); + let out = f.batch(&candles); + // Bars 0..4 are warmup or single-band only — both bands haven't been + // confirmed yet. + for v in out.iter().take(5) { + assert!(v.is_none()); + } + // Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was + // confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5 + // is the first bar with *both* upper and lower set. + let v = out[5].unwrap(); + assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12); + } + + /// In a flat market no bar is strictly higher (or lower) than its + /// neighbours, so no fractal ever confirms and the indicator never emits. + #[test] + fn flat_market_never_emits() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut f = FractalChaosBands::new(2).unwrap(); + for v in f.batch(&candles) { + assert!(v.is_none()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = FractalChaosBands::new(2).unwrap(); + let mut b = FractalChaosBands::new(2).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles = vec![ + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + c(5.0, 3.0, 4.0), + c(3.0, 0.5, 1.0), + c(2.0, 2.0, 2.0), + c(1.0, 1.0, 1.0), + c(2.0, 2.0, 2.0), + ]; + let mut f = FractalChaosBands::new(2).unwrap(); + f.batch(&candles); + assert!(f.is_ready()); + f.reset(); + assert!(!f.is_ready()); + assert_eq!(f.update(candles[0]), None); + } + + #[test] + fn upper_above_lower_when_both_set() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut f = FractalChaosBands::new(2).unwrap(); + for o in f.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.lower); + } + } +} diff --git a/crates/wickra-core/src/indicators/frama.rs b/crates/wickra-core/src/indicators/frama.rs new file mode 100644 index 0000000..6f6c926 --- /dev/null +++ b/crates/wickra-core/src/indicators/frama.rs @@ -0,0 +1,259 @@ +//! Fractal Adaptive Moving Average (FRAMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Fractal Adaptive Moving Average. +/// +/// FRAMA picks its smoothing constant from the fractal dimension `D` of the +/// recent window: in a trending (low-`D`) market it follows price tightly, in +/// a choppy (high-`D`) market it smooths heavily. The window of `period` +/// closes is split into two equal halves; the fractal dimension comes from +/// the price ranges of the halves vs. the whole window: +/// +/// ```text +/// N1 = (max(first half) - min(first half)) / (period / 2) +/// N2 = (max(second half) - min(second half)) / (period / 2) +/// N3 = (max(window) - min(window)) / period +/// D = (log(N1 + N2) - log(N3)) / log(2) +/// alpha = exp(-4.6 * (D - 1)) clamped to [0.01, 1.0] +/// ``` +/// +/// The output is an EMA-like recurrence +/// `FRAMA_t = alpha * close_t + (1 - alpha) * FRAMA_{t - 1}`, seeded with the +/// first close. `period` must be even and at least 2. +/// +/// Reference: John F. Ehlers, *Fractal Adaptive Moving Average*, 2005. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Frama, Indicator}; +/// +/// let mut frama = Frama::new(16).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = frama.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Frama { + period: usize, + half: usize, + window: VecDeque, + current: Option, +} + +impl Frama { + /// # Errors + /// - [`Error::PeriodZero`] if `period == 0`. + /// - [`Error::InvalidPeriod`] if `period` is odd or below 2. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "FRAMA period must be at least 2", + }); + } + if period % 2 != 0 { + return Err(Error::InvalidPeriod { + message: "FRAMA period must be even", + }); + } + Ok(Self { + period, + half: period / 2, + window: VecDeque::with_capacity(period), + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Frama { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + + let half = self.half; + let mut h_first = f64::NEG_INFINITY; + let mut l_first = f64::INFINITY; + let mut h_second = f64::NEG_INFINITY; + let mut l_second = f64::INFINITY; + let mut h_whole = f64::NEG_INFINITY; + let mut l_whole = f64::INFINITY; + for (i, &p) in self.window.iter().enumerate() { + if p > h_whole { + h_whole = p; + } + if p < l_whole { + l_whole = p; + } + if i < half { + if p > h_first { + h_first = p; + } + if p < l_first { + l_first = p; + } + } else { + if p > h_second { + h_second = p; + } + if p < l_second { + l_second = p; + } + } + } + + let half_f = half as f64; + let period_f = self.period as f64; + let n1 = (h_first - l_first) / half_f; + let n2 = (h_second - l_second) / half_f; + let n3 = (h_whole - l_whole) / period_f; + + let alpha = if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 { + let d = ((n1 + n2).ln() - n3.ln()) / 2.0_f64.ln(); + (-4.6 * (d - 1.0)).exp().clamp(0.01, 1.0) + } else { + // Degenerate (perfectly flat half or whole window): use the slowest + // smoothing so the indicator coasts on its previous value. + 0.01 + }; + + let prev = self.current.unwrap_or(input); + let next = alpha * input + (1.0 - alpha) * prev; + self.current = Some(next); + Some(next) + } + + fn reset(&mut self) { + self.window.clear(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "FRAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Frama::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_period() { + assert!(matches!(Frama::new(1), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Frama::new(3), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Frama::new(15), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let frama = Frama::new(16).unwrap(); + assert_eq!(frama.period(), 16); + assert_eq!(frama.warmup_period(), 16); + assert_eq!(frama.name(), "FRAMA"); + } + + #[test] + fn constant_series_yields_the_constant() { + // Flat input -> alpha clamps to 0.01 (degenerate ranges) and the + // EMA recurrence holds the seed value forever. + let mut frama = Frama::new(4).unwrap(); + let out = frama.batch(&[42.0_f64; 30]); + for v in out.iter().skip(3).flatten() { + assert_relative_eq!(*v, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut frama = Frama::new(4).unwrap(); + assert_eq!(frama.update(1.0), None); + assert_eq!(frama.update(2.0), None); + assert_eq!(frama.update(3.0), None); + assert!(frama.update(4.0).is_some()); + } + + #[test] + fn pure_uptrend_alpha_close_to_one() { + // A strict monotonic uptrend has fractal dimension ~1, so alpha is + // pushed to 1.0 and FRAMA reduces to the latest price. + let mut frama = Frama::new(4).unwrap(); + let prices: Vec = (1..=8).map(f64::from).collect(); + let out = frama.batch(&prices); + let last = out.last().unwrap().unwrap(); + assert!( + (last - 8.0).abs() < 0.05, + "FRAMA on a clean uptrend should hug the latest close: {last}" + ); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Frama::new(8).unwrap(); + let mut b = Frama::new(8).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut frama = Frama::new(4).unwrap(); + frama.batch(&(1..=20).map(f64::from).collect::>()); + assert!(frama.is_ready()); + frama.reset(); + assert!(!frama.is_ready()); + assert_eq!(frama.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut frama = Frama::new(4).unwrap(); + frama.batch(&[1.0, 2.0, 3.0, 4.0]); + let before = frama.update(5.0).unwrap(); + assert_eq!(frama.update(f64::NAN), Some(before)); + assert_eq!(frama.update(f64::INFINITY), Some(before)); + } +} diff --git a/crates/wickra-core/src/indicators/fry_pan_bottom.rs b/crates/wickra-core/src/indicators/fry_pan_bottom.rs new file mode 100644 index 0000000..17446d0 --- /dev/null +++ b/crates/wickra-core/src/indicators/fry_pan_bottom.rs @@ -0,0 +1,218 @@ +//! Frying Pan Bottom — a rounded bottom (U) confirmed by recovery. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Frying Pan Bottom — a gently rounded bottom across the lookback window: prices +/// decline, flatten near the centre, then recover above where they started. +/// +/// ```text +/// over the last `period` closes: +/// the minimum close sits in the middle third of the window (the "bowl") +/// the latest close is above the first close (the rim is recovered) +/// signal = +1 when both hold, else 0 +/// ``` +/// +/// The frying pan is a bullish accumulation pattern: a saucer-shaped base where +/// selling dries up, the curve flattens, and price lifts off the rim. Detecting it +/// requires the low point to be central (a symmetric bowl, not a one-sided drop) +/// and the close to have climbed back above the window's opening level, confirming +/// the breakout from the base. The output is `+1.0` (pattern) or `0.0`. +/// +/// The first value lands after `period` inputs; each `update` scans the window in +/// O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, FryPanBottom}; +/// +/// let mut indicator = FryPanBottom::new(9).unwrap(); +/// // A U-shaped base then recovery. +/// let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0]; +/// let mut last = None; +/// for &cl in &closes { +/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert_eq!(last, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct FryPanBottom { + period: usize, + closes: VecDeque, + last: Option, +} + +impl FryPanBottom { + /// Construct a Frying Pan Bottom over `period` bars. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 5` (a bowl needs room for a + /// central low between recovering sides). + pub fn new(period: usize) -> Result { + if period < 5 { + return Err(Error::InvalidPeriod { + message: "frying pan bottom needs period >= 5", + }); + } + Ok(Self { + period, + closes: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for FryPanBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.closes.len() == self.period { + self.closes.pop_front(); + } + self.closes.push_back(candle.close); + if self.closes.len() < self.period { + return None; + } + let first = *self.closes.front().expect("non-empty"); + let last = *self.closes.back().expect("non-empty"); + // Index of the minimum close. + let mut min_idx = 0; + let mut min_val = f64::INFINITY; + for (i, &v) in self.closes.iter().enumerate() { + if v < min_val { + min_val = v; + min_idx = i; + } + } + let lo = self.period / 4; + let hi = self.period - self.period / 4; + let bowl = min_idx >= lo && min_idx < hi; + let recovered = last > first && last > min_val; + let v = if bowl && recovered { 1.0 } else { 0.0 }; + self.last = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.closes.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "FryPanBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64) -> Candle { + Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0) + } + + #[test] + fn rejects_small_period() { + assert!(matches!( + FryPanBottom::new(4), + Err(Error::InvalidPeriod { .. }) + )); + assert!(FryPanBottom::new(5).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let f = FryPanBottom::new(9).unwrap(); + assert_eq!(f.period(), 9); + assert_eq!(f.warmup_period(), 9); + assert_eq!(f.name(), "FryPanBottom"); + assert!(!f.is_ready()); + assert_eq!(f.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut f = FryPanBottom::new(5).unwrap(); + let out = f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0), c(102.0)]); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn rounded_bottom_then_recovery_signals() { + let mut f = FryPanBottom::new(9).unwrap(); + let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0]; + let candles: Vec = closes.iter().map(|&x| c(x)).collect(); + let last = f.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 1.0); + } + + #[test] + fn one_sided_drop_is_zero() { + // A straight decline (min at the end) is not a bowl. + let mut f = FryPanBottom::new(9).unwrap(); + let candles: Vec = (0..9).map(|i| c(100.0 - f64::from(i))).collect(); + let last = f.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn no_recovery_is_zero() { + // Bowl shape but the last close never climbs above the first. + let mut f = FryPanBottom::new(9).unwrap(); + let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 97.0, 98.0, 99.0, 99.5]; + let candles: Vec = closes.iter().map(|&x| c(x)).collect(); + let last = f.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut f = FryPanBottom::new(5).unwrap(); + f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0)]); + assert!(f.is_ready()); + f.reset(); + assert!(!f.is_ready()); + assert_eq!(f.value(), None); + assert_eq!(f.update(c(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0)) + .collect(); + let batch = FryPanBottom::new(9).unwrap().batch(&candles); + let mut b = FryPanBottom::new(9).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/funding_basis.rs b/crates/wickra-core/src/indicators/funding_basis.rs new file mode 100644 index 0000000..bb80477 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_basis.rs @@ -0,0 +1,137 @@ +//! Funding Basis — the perpetual mark's relative premium to the spot index. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Funding Basis — the relative basis between the perpetual mark price and the +/// spot index it tracks. +/// +/// ```text +/// basis = (markPrice − indexPrice) / indexPrice +/// ``` +/// +/// The basis is the spread that the funding mechanism continuously pulls toward +/// zero: a positive basis (perpetual above spot) goes hand in hand with positive +/// funding (longs pay), a negative basis with negative funding. Reading the +/// instantaneous basis alongside the [funding rate] separates a genuine premium +/// from a stale-funding artefact and sizes the carry available to a cash-and-carry +/// or basis-arbitrage trade. The output is a fraction (e.g. `0.001` = 10 bps); +/// multiply by `10_000` for basis points. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [funding rate]: crate::FundingRate +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingBasis, Indicator}; +/// +/// let mut fb = FundingBasis::new(); +/// // mark 100.5 vs index 100.0 -> (100.5 - 100.0) / 100.0 = 0.005. +/// let tick = DerivativesTick::new( +/// 0.0, 100.5, 100.0, 100.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, +/// ) +/// .unwrap(); +/// assert!((fb.update(tick).unwrap() - 0.005).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FundingBasis { + has_emitted: bool, +} + +impl FundingBasis { + /// Construct a new funding-basis indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for FundingBasis { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some((tick.mark_price - tick.index_price) / tick.index_price) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "FundingBasis" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(mark: f64, index: f64) -> DerivativesTick { + DerivativesTick::new_unchecked(0.0, mark, index, mark, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let fb = FundingBasis::new(); + assert_eq!(fb.name(), "FundingBasis"); + assert_eq!(fb.warmup_period(), 1); + assert!(!fb.is_ready()); + } + + #[test] + fn premium_is_positive() { + let mut fb = FundingBasis::new(); + let out = fb.update(tick(100.5, 100.0)).unwrap(); + assert!((out - 0.005).abs() < 1e-12); + assert!(fb.is_ready()); + } + + #[test] + fn discount_is_negative() { + let mut fb = FundingBasis::new(); + let out = fb.update(tick(99.5, 100.0)).unwrap(); + assert!((out + 0.005).abs() < 1e-12); + } + + #[test] + fn at_par_is_zero() { + let mut fb = FundingBasis::new(); + assert_eq!(fb.update(tick(100.0, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(100.0 + f64::from(i % 5) * 0.1, 100.0)) + .collect(); + let mut a = FundingBasis::new(); + let mut b = FundingBasis::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut fb = FundingBasis::new(); + fb.update(tick(100.5, 100.0)); + assert!(fb.is_ready()); + fb.reset(); + assert!(!fb.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/funding_implied_apr.rs b/crates/wickra-core/src/indicators/funding_implied_apr.rs new file mode 100644 index 0000000..dcdd9f8 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_implied_apr.rs @@ -0,0 +1,164 @@ +//! Funding-Implied APR — the per-interval funding rate annualised. + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Funding-Implied APR — the perpetual's per-interval funding rate scaled to an +/// annualised rate. +/// +/// ```text +/// APR = funding_rate · intervals_per_year +/// ``` +/// +/// Funding is paid in small per-interval amounts (commonly every 8 hours, i.e. +/// `1095` intervals per year). Annualising it converts the headline funding number +/// into the carry cost (or yield) of holding the position for a year, which is far +/// easier to reason about and to compare against spot lending rates, basis trades, +/// and other yields. A large positive APR means longs pay a steep carry to shorts +/// (and vice versa) — the economic incentive behind cash-and-carry and +/// funding-arbitrage strategies. +/// +/// The output is a fraction (multiply by `100` for percent) and may be negative. +/// It is stateless — each tick yields one value (no warmup). Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, FundingImpliedApr}; +/// +/// // 0.01% per 8h funding -> 0.0001 * 1095 ≈ 10.95% APR. +/// let mut indicator = FundingImpliedApr::new(1095.0).unwrap(); +/// let tick = DerivativesTick::new(0.0001, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap(); +/// let apr = indicator.update(tick).unwrap(); +/// assert!((apr - 0.1095).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct FundingImpliedApr { + intervals_per_year: f64, + ready: bool, +} + +impl FundingImpliedApr { + /// Construct a Funding-Implied APR with the number of funding intervals per + /// year (e.g. `1095` for 8-hour funding, `365` for daily). + /// + /// # Errors + /// + /// Returns [`Error::InvalidParameter`] if `intervals_per_year` is not finite + /// and positive. + pub fn new(intervals_per_year: f64) -> Result { + if !intervals_per_year.is_finite() || intervals_per_year <= 0.0 { + return Err(Error::InvalidParameter { + message: "intervals_per_year must be finite and positive", + }); + } + Ok(Self { + intervals_per_year, + ready: false, + }) + } + + /// Configured intervals per year. + pub const fn intervals_per_year(&self) -> f64 { + self.intervals_per_year + } +} + +impl Indicator for FundingImpliedApr { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.ready = true; + Some(tick.funding_rate * self.intervals_per_year) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "FundingImpliedApr" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn tick(funding: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + funding, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn rejects_invalid_intervals() { + assert!(matches!( + FundingImpliedApr::new(0.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + FundingImpliedApr::new(-1.0), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let f = FundingImpliedApr::new(1095.0).unwrap(); + assert_relative_eq!(f.intervals_per_year(), 1095.0, epsilon = 1e-12); + assert_eq!(f.warmup_period(), 1); + assert_eq!(f.name(), "FundingImpliedApr"); + assert!(!f.is_ready()); + } + + #[test] + fn apr_reference_value() { + let mut f = FundingImpliedApr::new(1095.0).unwrap(); + assert_relative_eq!(f.update(tick(0.0001)).unwrap(), 0.1095, epsilon = 1e-9); + } + + #[test] + fn negative_funding_is_negative_apr() { + let mut f = FundingImpliedApr::new(1095.0).unwrap(); + assert!(f.update(tick(-0.0001)).unwrap() < 0.0); + } + + #[test] + fn zero_funding_is_zero() { + let mut f = FundingImpliedApr::new(365.0).unwrap(); + assert_relative_eq!(f.update(tick(0.0)).unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut f = FundingImpliedApr::new(1095.0).unwrap(); + f.update(tick(0.0001)); + assert!(f.is_ready()); + f.reset(); + assert!(!f.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..40) + .map(|i| tick(0.0001 * (f64::from(i) * 0.3).sin())) + .collect(); + let batch = FundingImpliedApr::new(1095.0).unwrap().batch(&ticks); + let mut b = FundingImpliedApr::new(1095.0).unwrap(); + let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/funding_rate.rs b/crates/wickra-core/src/indicators/funding_rate.rs new file mode 100644 index 0000000..37c6d23 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_rate.rs @@ -0,0 +1,131 @@ +//! Funding Rate — the current perpetual funding rate. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Funding Rate — the funding rate carried by each derivatives tick. +/// +/// The funding rate is the periodic payment exchanged between long and short +/// perpetual-swap holders that tethers the perpetual mark to the spot index. A +/// positive rate means longs pay shorts (the perpetual trades at a premium); a +/// negative rate means shorts pay longs (a discount). This indicator simply +/// surfaces the rate from the [`DerivativesTick`] feed so it can be charted, +/// chained or fed to the rolling funding statistics ([`FundingRateMean`], +/// [`FundingRateZScore`]). +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [`FundingRateMean`]: crate::FundingRateMean +/// [`FundingRateZScore`]: crate::FundingRateZScore +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingRate, Indicator}; +/// +/// let mut fr = FundingRate::new(); +/// let tick = DerivativesTick::new( +/// 0.0001, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, +/// ) +/// .unwrap(); +/// assert_eq!(fr.update(tick), Some(0.0001)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct FundingRate { + has_emitted: bool, +} + +impl FundingRate { + /// Construct a new funding-rate indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for FundingRate { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some(tick.funding_rate) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "FundingRate" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(funding_rate: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + funding_rate, + 100.0, + 100.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let fr = FundingRate::new(); + assert_eq!(fr.name(), "FundingRate"); + assert_eq!(fr.warmup_period(), 1); + assert!(!fr.is_ready()); + } + + #[test] + fn passes_through_funding_rate() { + let mut fr = FundingRate::new(); + assert_eq!(fr.update(tick(0.0001)), Some(0.0001)); + assert_eq!(fr.update(tick(-0.0003)), Some(-0.0003)); + assert!(fr.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = + (0..20).map(|i| tick(0.0001 * f64::from(i - 10))).collect(); + let mut a = FundingRate::new(); + let mut b = FundingRate::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut fr = FundingRate::new(); + fr.update(tick(0.0001)); + assert!(fr.is_ready()); + fr.reset(); + assert!(!fr.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/funding_rate_mean.rs b/crates/wickra-core/src/indicators/funding_rate_mean.rs new file mode 100644 index 0000000..7f34233 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_rate_mean.rs @@ -0,0 +1,181 @@ +//! Funding Rate Rolling Mean — average funding rate over a trailing window. + +use std::collections::VecDeque; + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Funding Rate Rolling Mean — the arithmetic mean of the funding rate over the +/// trailing window of `window` ticks. +/// +/// ```text +/// mean = (1 / window) · Σ fundingRate over the last `window` ticks +/// ``` +/// +/// Smoothing the raw [funding rate] reveals the persistent carry regime — a +/// sustained positive mean marks a crowded-long market paying to hold the +/// perpetual, a sustained negative mean a crowded-short one. The indicator warms +/// up for `window` ticks — `update` returns `None` until the window is full — +/// then emits the rolling mean, maintained in O(1) per tick via a running sum. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// [funding rate]: crate::FundingRate +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingRateMean, Indicator}; +/// +/// fn tick(rate: f64) -> DerivativesTick { +/// DerivativesTick::new(rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut frm = FundingRateMean::new(2).unwrap(); +/// assert_eq!(frm.update(tick(0.001)), None); +/// // Window full: (0.001 + 0.003) / 2 = 0.002. +/// assert_eq!(frm.update(tick(0.003)), Some(0.002)); +/// ``` +#[derive(Debug, Clone)] +pub struct FundingRateMean { + window: usize, + history: VecDeque, + sum: f64, +} + +impl FundingRateMean { + /// Construct a funding-rate rolling mean over a window of `window` ticks. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window), + sum: 0.0, + }) + } + + /// The configured window length, in ticks. + #[must_use] + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for FundingRateMean { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.history.push_back(tick.funding_rate); + self.sum += tick.funding_rate; + if self.history.len() > self.window { + let old = self.history.pop_front().expect("window >= 1, len > window"); + self.sum -= old; + } + if self.history.len() < self.window { + return None; + } + Some(self.sum / self.window as f64) + } + + fn reset(&mut self) { + self.history.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.history.len() >= self.window + } + + fn name(&self) -> &'static str { + "FundingRateMean" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(rate: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(FundingRateMean::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let frm = FundingRateMean::new(5).unwrap(); + assert_eq!(frm.name(), "FundingRateMean"); + assert_eq!(frm.warmup_period(), 5); + assert_eq!(frm.window(), 5); + assert!(!frm.is_ready()); + } + + #[test] + fn warms_up_then_emits_mean() { + let mut frm = FundingRateMean::new(2).unwrap(); + assert_eq!(frm.update(tick(0.001)), None); + assert!(!frm.is_ready()); + assert_eq!(frm.update(tick(0.003)), Some(0.002)); + assert!(frm.is_ready()); + } + + #[test] + fn rolls_off_old_values() { + let mut frm = FundingRateMean::new(2).unwrap(); + frm.update(tick(0.001)); + frm.update(tick(0.003)); // mean 0.002 + let out = frm.update(tick(0.005)).unwrap(); // window [0.003, 0.005] -> 0.004 + assert!((out - 0.004).abs() < 1e-12); + } + + #[test] + fn handles_negative_rates() { + let mut frm = FundingRateMean::new(2).unwrap(); + frm.update(tick(-0.002)); + let out = frm.update(tick(0.004)).unwrap(); + assert!((out - 0.001).abs() < 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..30) + .map(|i| tick(0.0001 * f64::from(i % 7) - 0.0003)) + .collect(); + let mut a = FundingRateMean::new(5).unwrap(); + let mut b = FundingRateMean::new(5).unwrap(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut frm = FundingRateMean::new(2).unwrap(); + frm.update(tick(0.001)); + frm.update(tick(0.003)); + assert!(frm.is_ready()); + frm.reset(); + assert!(!frm.is_ready()); + assert_eq!(frm.update(tick(0.002)), None); + } +} diff --git a/crates/wickra-core/src/indicators/funding_rate_zscore.rs b/crates/wickra-core/src/indicators/funding_rate_zscore.rs new file mode 100644 index 0000000..de68712 --- /dev/null +++ b/crates/wickra-core/src/indicators/funding_rate_zscore.rs @@ -0,0 +1,201 @@ +//! Funding Rate Z-Score — how extreme the latest funding rate is versus its +//! recent history. + +use std::collections::VecDeque; + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Funding Rate Z-Score — the latest funding rate expressed in standard +/// deviations from its rolling mean over the trailing window of `window` ticks. +/// +/// ```text +/// zScore = (fundingRate − mean) / population_stddev over the last `window` ticks +/// ``` +/// +/// A reading of `+2` means funding is two standard deviations richer than its +/// recent norm — an unusually crowded long, a contrarian fade signal; `−2` is +/// the mirror. Normalising the [funding rate] this way makes funding extremes +/// comparable across regimes and assets. A window with zero dispersion (a flat +/// funding series) yields `0`. The indicator warms up for `window` ticks, then +/// emits the rolling z-score, maintained in O(1) per tick. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// [funding rate]: crate::FundingRate +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, FundingRateZScore, Indicator}; +/// +/// fn tick(rate: f64) -> DerivativesTick { +/// DerivativesTick::new(rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut z = FundingRateZScore::new(2).unwrap(); +/// assert_eq!(z.update(tick(0.001)), None); +/// // Window [0.001, 0.003]: mean 0.002, population stddev 0.001 -> (0.003 - 0.002) / 0.001 = 1. +/// assert!((z.update(tick(0.003)).unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct FundingRateZScore { + window: usize, + history: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl FundingRateZScore { + /// Construct a funding-rate z-score over a window of `window` ticks. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// The configured window length, in ticks. + #[must_use] + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for FundingRateZScore { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let value = tick.funding_rate; + if self.history.len() == self.window { + let old = self.history.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.history.push_back(value); + self.sum += value; + self.sum_sq += value * value; + if self.history.len() < self.window { + return None; + } + let n = self.window as f64; + let mean = self.sum / n; + // Population variance E[x²] − E[x]²; clamp away tiny negative drift. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let std = variance.sqrt(); + if std == 0.0 { + // A window with no dispersion: funding is exactly its own mean. + return Some(0.0); + } + Some((value - mean) / std) + } + + fn reset(&mut self) { + self.history.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.history.len() == self.window + } + + fn name(&self) -> &'static str { + "FundingRateZScore" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(rate: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + rate, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(FundingRateZScore::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let z = FundingRateZScore::new(5).unwrap(); + assert_eq!(z.name(), "FundingRateZScore"); + assert_eq!(z.warmup_period(), 5); + assert_eq!(z.window(), 5); + assert!(!z.is_ready()); + } + + #[test] + fn reference_value() { + let mut z = FundingRateZScore::new(2).unwrap(); + assert_eq!(z.update(tick(0.001)), None); + // Window [0.001, 0.003]: mean 0.002, var (1e-6 + 9e-6)/2 - 4e-6 = 1e-6, + // stddev 0.001; latest 0.003 is (0.003 - 0.002) / 0.001 = 1. + let out = z.update(tick(0.003)).unwrap(); + assert!((out - 1.0).abs() < 1e-9); + assert!(z.is_ready()); + } + + #[test] + fn flat_window_is_zero() { + let mut z = FundingRateZScore::new(3).unwrap(); + z.update(tick(0.002)); + z.update(tick(0.002)); + assert_eq!(z.update(tick(0.002)), Some(0.0)); + } + + #[test] + fn rolls_off_old_values() { + let mut z = FundingRateZScore::new(2).unwrap(); + z.update(tick(0.001)); + z.update(tick(0.003)); + // Window now [0.003, 0.005]: mean 0.004, stddev 0.001 -> +1. + let out = z.update(tick(0.005)).unwrap(); + assert!((out - 1.0).abs() < 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..30) + .map(|i| tick(0.0001 * f64::from(i % 5) - 0.0002)) + .collect(); + let mut a = FundingRateZScore::new(6).unwrap(); + let mut b = FundingRateZScore::new(6).unwrap(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut z = FundingRateZScore::new(2).unwrap(); + z.update(tick(0.001)); + z.update(tick(0.003)); + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + assert_eq!(z.update(tick(0.002)), None); + } +} diff --git a/crates/wickra-core/src/indicators/gain_loss_ratio.rs b/crates/wickra-core/src/indicators/gain_loss_ratio.rs new file mode 100644 index 0000000..23d333c --- /dev/null +++ b/crates/wickra-core/src/indicators/gain_loss_ratio.rs @@ -0,0 +1,184 @@ +//! Rolling Gain/Loss Ratio. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Gain/Loss Ratio. +/// +/// Over the trailing window: +/// +/// ```text +/// avg_win = mean(r for r in window if r > 0) +/// avg_loss = mean(−r for r in window if r < 0) +/// GLR = avg_win / avg_loss +/// ``` +/// +/// Where Profit Factor sums gains and losses, the Gain/Loss Ratio averages +/// them: it answers "for the typical winning bar, how big is the win +/// compared to the typical losing bar?". If there are no losers the +/// indicator returns `f64::INFINITY`; if there are no winners and no losers +/// it returns `0.0`. +/// +/// Each `update` is O(period). +#[derive(Debug, Clone)] +pub struct GainLossRatio { + period: usize, + window: VecDeque, +} + +impl GainLossRatio { + /// Construct a new rolling Gain/Loss Ratio. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for GainLossRatio { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut sum_win = 0.0_f64; + let mut n_win = 0_u32; + let mut sum_loss = 0.0_f64; + let mut n_loss = 0_u32; + for &r in &self.window { + if r > 0.0 { + sum_win += r; + n_win += 1; + } else if r < 0.0 { + sum_loss += -r; + n_loss += 1; + } + } + if n_loss == 0 { + return Some(if n_win == 0 { 0.0 } else { f64::INFINITY }); + } + let avg_win = if n_win == 0 { + 0.0 + } else { + sum_win / f64::from(n_win) + }; + let avg_loss = sum_loss / f64::from(n_loss); + Some(avg_win / avg_loss) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "GainLossRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let g = GainLossRatio::new(10).unwrap(); + assert_eq!(g.period(), 10); + assert_eq!(g.name(), "GainLossRatio"); + assert_eq!(g.warmup_period(), 10); + } + + #[test] + fn reference_value() { + // returns = [0.02, -0.01, 0.04, -0.03] + // avg_win = 0.03, avg_loss = 0.02, GLR = 1.5. + let mut g = GainLossRatio::new(4).unwrap(); + let out = g.batch(&[0.02, -0.01, 0.04, -0.03]); + assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9); + } + + #[test] + fn no_losses_yields_infinity() { + let mut g = GainLossRatio::new(3).unwrap(); + let out = g.batch(&[0.01, 0.02, 0.03]); + assert!(out[2].unwrap().is_infinite()); + } + + #[test] + fn flat_window_yields_zero() { + let mut g = GainLossRatio::new(3).unwrap(); + let out = g.batch(&[0.0_f64; 3]); + assert_eq!(out[2], Some(0.0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut g = GainLossRatio::new(3).unwrap(); + assert_eq!(g.update(f64::NAN), None); + assert_eq!(g.update(f64::INFINITY), None); + } + + #[test] + fn no_wins_but_losses_yields_zero() { + // Window with only losses: avg_win is 0, GLR = 0. + let mut g = GainLossRatio::new(3).unwrap(); + let out = g.batch(&[-0.01, -0.02, -0.03]); + assert_eq!(out[2], Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut g = GainLossRatio::new(3).unwrap(); + g.batch(&[0.01, -0.02, 0.03]); + assert!(g.is_ready()); + g.reset(); + assert!(!g.is_ready()); + assert_eq!(g.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect(); + let batch = GainLossRatio::new(10).unwrap().batch(&returns); + let mut s = GainLossRatio::new(10).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/gain_to_pain_ratio.rs b/crates/wickra-core/src/indicators/gain_to_pain_ratio.rs new file mode 100644 index 0000000..467df75 --- /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/gap_side_by_side_white.rs b/crates/wickra-core/src/indicators/gap_side_by_side_white.rs new file mode 100644 index 0000000..dd780ee --- /dev/null +++ b/crates/wickra-core/src/indicators/gap_side_by_side_white.rs @@ -0,0 +1,238 @@ +//! Gap Side-by-Side White Lines candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Gap Side-by-Side White Lines — a 3-bar continuation. After a gap away from the +/// first bar, two white candles of similar size open at roughly the same level +/// (side by side) and hold the gap open, signalling the trend resumes in the gap +/// direction. +/// +/// ```text +/// bar2, bar3 both white +/// bar2 body gaps away from bar1 body (up or down) +/// bar3 opens beside bar2 (|open3 − open2| <= 0.1 · range2) +/// bar3 body is similar in size to bar2 (neither more than twice the other) +/// gap up -> +1.0 (bullish continuation) +/// gap down -> −1.0 (bearish continuation — "downside" gap side-by-side white) +/// ``` +/// +/// Output is `+1.0` (gap up) or `−1.0` (gap down) when the pattern completes and +/// `0.0` otherwise. The first two bars always return `0.0` because the three-bar +/// window is not yet filled. Open-equality and body-similarity thresholds follow +/// the geometric house style rather than TA-Lib's rolling averages. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the two gap +/// directions occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, GapSideBySideWhite, Indicator}; +/// +/// let mut indicator = GapSideBySideWhite::new(); +/// indicator.update(Candle::new(10.0, 11.1, 9.9, 11.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(13.0, 14.1, 12.9, 14.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.0, 14.1, 12.9, 14.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct GapSideBySideWhite { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl GapSideBySideWhite { + /// Construct a new Gap Side-by-Side White Lines detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for GapSideBySideWhite { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.prev_prev; + let bar2 = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + let range2 = bar2.high - bar2.low; + if range2 <= 0.0 { + return Some(0.0); + } + // Both of the side-by-side bars must be white. + if bar2.close <= bar2.open || candle.close <= candle.open { + return Some(0.0); + } + // Side by side: opens level and bodies of comparable size. + if (candle.open - bar2.open).abs() > 0.1 * range2 { + return Some(0.0); + } + let body2 = bar2.close - bar2.open; + let body3 = candle.close - candle.open; + if body2 > 2.0 * body3 || body3 > 2.0 * body2 { + return Some(0.0); + } + let bar1_top = bar1.open.max(bar1.close); + let bar1_bottom = bar1.open.min(bar1.close); + let bar2_bottom = bar2.open.min(bar2.close); + let bar2_top = bar2.open.max(bar2.close); + if bar2_bottom > bar1_top { + return Some(1.0); // gap up -> bullish continuation + } + if bar2_top < bar1_bottom { + return Some(-1.0); // gap down -> bearish continuation + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "GapSideBySideWhite" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = GapSideBySideWhite::new(); + assert_eq!(t.name(), "GapSideBySideWhite"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn gap_up_is_plus_one() { + let mut t = GapSideBySideWhite::new(); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 1)), Some(0.0)); + assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 2)), Some(1.0)); + } + + #[test] + fn gap_down_is_minus_one() { + let mut t = GapSideBySideWhite::new(); + assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 1)), Some(0.0)); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 2)), Some(-1.0)); + } + + #[test] + fn second_bar_black_yields_zero() { + let mut t = GapSideBySideWhite::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + // bar3 is black -> not two white lines. + t.update(c(13.0, 14.1, 12.9, 14.0, 1)); + assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 2)), Some(0.0)); + } + + #[test] + fn not_side_by_side_yields_zero() { + let mut t = GapSideBySideWhite::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + t.update(c(13.0, 14.1, 12.9, 14.0, 1)); + // bar3 opens far from bar2's open -> not side by side. + assert_eq!(t.update(c(16.0, 17.1, 15.9, 17.0, 2)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = GapSideBySideWhite::new(); + t.update(c(10.0, 13.1, 9.9, 13.0, 0)); + // bar2 overlaps bar1 (no gap). + t.update(c(12.0, 13.1, 11.9, 13.0, 1)); + assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = GapSideBySideWhite::new(); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64 * 3.0; + c(base, base + 1.1, base - 0.1, base + 1.0, i) + }) + .collect(); + let mut a = GapSideBySideWhite::new(); + let mut b = GapSideBySideWhite::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = GapSideBySideWhite::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + t.update(c(13.0, 14.1, 12.9, 14.0, 1)); + t.update(c(13.0, 14.1, 12.9, 14.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = GapSideBySideWhite::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + // Flat second bar (range2 == 0) -> rejected. + t.update(c(13.0, 13.0, 13.0, 13.0, 1)); + assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 2)), Some(0.0)); + } + + #[test] + fn body_size_mismatch_yields_zero() { + let mut t = GapSideBySideWhite::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + t.update(c(13.0, 16.0, 12.9, 15.0, 1)); // white, body 2.0 + // Level open, white, but its body is more than 2x smaller -> rejected. + assert_eq!(t.update(c(13.0, 13.7, 12.9, 13.5, 2)), Some(0.0)); // body 0.5 + } +} diff --git a/crates/wickra-core/src/indicators/garch11.rs b/crates/wickra-core/src/indicators/garch11.rs new file mode 100644 index 0000000..3fdb8f0 --- /dev/null +++ b/crates/wickra-core/src/indicators/garch11.rs @@ -0,0 +1,325 @@ +//! GARCH(1,1) — conditional volatility with a long-run-variance anchor. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// GARCH(1,1) conditional volatility — the square root of the +/// generalized-autoregressive-conditional-heteroskedasticity variance recursion. +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// σ²_t = ω + α · r²_{t−1} + β · σ²_{t−1} +/// out = √σ²_t +/// ``` +/// +/// GARCH(1,1) (Bollerslev 1986) generalizes the +/// [`EwmaVolatility`](crate::EwmaVolatility) recursion by adding a constant `ω`, +/// which pins the process to a finite long-run (unconditional) variance +/// `ω / (1 − α − β)`. The `α` term gives weight to the latest squared return +/// (the "ARCH" shock) and `β` to the previous variance (the "GARCH" +/// persistence). When `ω = 0` and `α + β = 1` the model degenerates to EWMA; a +/// proper GARCH keeps `ω > 0` and `α + β < 1` so volatility mean-reverts rather +/// than drifting. +/// +/// The recursion is seeded with the unconditional variance (`σ²₁ = ω / (1 − α − +/// β)`) and emits from the first log return onward. Unlike EWMA — which decays to +/// zero on a flat series — a flat series here mean-reverts toward `ω / (1 − β)` +/// (the `α`-term vanishes but the `ω` floor and the `β` carry remain), so the +/// output is always strictly positive. Each `update` is O(1). +/// +/// Non-finite and non-positive prices are ignored (the log return would be +/// undefined): the tick is dropped, state is left untouched, and the last value +/// is returned. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Garch11, Indicator}; +/// +/// // Typical equity daily estimate. +/// let mut indicator = Garch11::new(0.000_002, 0.10, 0.88).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Garch11 { + omega: f64, + alpha: f64, + beta: f64, + unconditional: f64, + prev_price: Option, + /// `(σ²_{t−1}, r²_{t−1})` — previous variance and previous squared return. + state: Option<(f64, f64)>, + last: Option, +} + +impl Garch11 { + /// Construct a new GARCH(1,1) indicator from its three parameters. + /// + /// `omega` (`ω`) is the constant variance floor, `alpha` (`α`) the weight on + /// the latest squared return, and `beta` (`β`) the persistence of the + /// previous variance. + /// + /// # Errors + /// Returns [`Error::InvalidParameter`] unless every parameter is finite, + /// `omega > 0`, `alpha >= 0`, `beta >= 0`, and `alpha + beta < 1` (the + /// covariance-stationarity condition that gives a finite long-run variance). + pub fn new(omega: f64, alpha: f64, beta: f64) -> Result { + if !omega.is_finite() || !alpha.is_finite() || !beta.is_finite() { + return Err(Error::InvalidParameter { + message: "GARCH(1,1) parameters must be finite", + }); + } + if omega <= 0.0 { + return Err(Error::InvalidParameter { + message: "GARCH(1,1) omega must be > 0", + }); + } + if alpha < 0.0 || beta < 0.0 { + return Err(Error::InvalidParameter { + message: "GARCH(1,1) alpha and beta must be >= 0", + }); + } + if alpha + beta >= 1.0 { + return Err(Error::InvalidParameter { + message: "GARCH(1,1) requires alpha + beta < 1 (covariance stationarity)", + }); + } + Ok(Self { + omega, + alpha, + beta, + unconditional: omega / (1.0 - alpha - beta), + prev_price: None, + state: None, + last: None, + }) + } + + /// Configured `(omega, alpha, beta)`. + pub const fn params(&self) -> (f64, f64, f64) { + (self.omega, self.alpha, self.beta) + } + + /// Long-run (unconditional) variance `ω / (1 − α − β)`. + pub const fn unconditional_variance(&self) -> f64 { + self.unconditional + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Garch11 { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite / non-positive prices are skipped: `ln(input / prev)` is + // undefined, so the tick must not enter the variance recursion. + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + // `prev` came from `self.prev_price`, gated by the guard above, so it is + // finite and positive — the log return is always well-defined. + let r = (input / prev).ln(); + let r_sq = r * r; + let var = match self.state { + // Seed the recursion with the unconditional variance. + None => self.unconditional, + Some((prev_var, prev_r_sq)) => { + self.omega + self.alpha * prev_r_sq + self.beta * prev_var + } + }; + self.state = Some((var, r_sq)); + // `var` is `omega (> 0) + non-negative terms`, so it is strictly + // positive — the square root is always well-defined. + let vol = var.sqrt(); + self.last = Some(vol); + Some(vol) + } + + fn reset(&mut self) { + self.prev_price = None; + self.state = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first log return needs a previous price; the estimate is seeded + // with the unconditional variance and emitted on that first return. + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Garch11" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + Garch11::new(0.0, 0.1, 0.8), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Garch11::new(-1.0, 0.1, 0.8), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Garch11::new(0.001, -0.1, 0.8), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Garch11::new(0.001, 0.1, -0.8), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Garch11::new(0.001, 0.5, 0.5), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Garch11::new(f64::NAN, 0.1, 0.8), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Garch11::new(0.001, f64::INFINITY, 0.8), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let g = Garch11::new(0.001, 0.1, 0.85).unwrap(); + assert_eq!(g.params(), (0.001, 0.1, 0.85)); + assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12); + assert_eq!(g.warmup_period(), 2); + assert_eq!(g.name(), "Garch11"); + assert!(!g.is_ready()); + assert_eq!(g.value(), None); + } + + #[test] + fn first_emission_is_unconditional() { + // The first log return emits the seed = sqrt(unconditional variance), + // independent of the return value. + let g = Garch11::new(0.002, 0.1, 0.85); + let mut g = g.unwrap(); + assert_eq!(g.update(100.0), None); + let out = g.update(110.0).unwrap(); + assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12); + } + + #[test] + fn known_value() { + // σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond. + let (omega, alpha, beta) = (0.002, 0.1, 0.85); + let mut g = Garch11::new(omega, alpha, beta).unwrap(); + let out = g.batch(&[100.0, 110.0, 99.0]); + let uncond = omega / (1.0 - alpha - beta); + let r1 = (110.0_f64 / 100.0).ln(); + assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12); + let var2 = omega + alpha * r1 * r1 + beta * uncond; + assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12); + } + + #[test] + fn flat_series_converges_to_long_run() { + // With zero returns the alpha term vanishes; the variance mean-reverts + // to the fixed point ω / (1 − β), NOT to zero (the key GARCH/EWMA + // distinction). + let (omega, beta) = (0.002, 0.85); + let mut g = Garch11::new(omega, 0.10, beta).unwrap(); + let out = g.batch(&[100.0; 400]); + let fixed_point = (omega / (1.0 - beta)).sqrt(); + assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9); + } + + #[test] + fn output_is_strictly_positive() { + let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in g.batch(&prices).into_iter().flatten() { + assert!( + v > 0.0, + "GARCH volatility must be strictly positive, got {v}" + ); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap(); + let out = g.batch(&(1..=20).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(g.update(f64::NAN), last); + assert_eq!(g.update(f64::INFINITY), last); + } + + #[test] + fn skips_non_positive_prices() { + let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap(); + let warmup = g.batch(&(1..=20).map(f64::from).collect::>()); + let baseline = warmup.last().copied().flatten().expect("warmed up"); + assert_eq!(g.update(-5.0), Some(baseline)); + assert_eq!(g.update(0.0), Some(baseline)); + // State untouched: a clone advanced by the same real tick agrees. + let mut control = g.clone(); + let after = g.update(21.0).expect("ready"); + assert_eq!(control.update(21.0).expect("ready"), after); + } + + #[test] + fn skips_non_positive_before_first_price() { + let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap(); + assert_eq!(g.update(0.0), None); + assert_eq!(g.update(f64::NAN), None); + assert_eq!(g.update(100.0), None); + assert!(g.update(110.0).is_some()); + } + + #[test] + fn reset_clears_state() { + let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap(); + g.batch(&(1..=20).map(f64::from).collect::>()); + assert!(g.is_ready()); + g.reset(); + assert!(!g.is_ready()); + assert_eq!(g.value(), None); + assert_eq!(g.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices); + let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/garman_klass.rs b/crates/wickra-core/src/indicators/garman_klass.rs new file mode 100644 index 0000000..9f6d1a0 --- /dev/null +++ b/crates/wickra-core/src/indicators/garman_klass.rs @@ -0,0 +1,286 @@ +//! Garman-Klass Volatility (OHLC estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Garman-Klass Volatility — an OHLC realised-volatility estimator. +/// +/// Garman & Klass (1980) extended Parkinson's high-low estimator by adding +/// an open-to-close term, removing some of the bias introduced when the +/// closing price drifts within the bar. The per-bar sample is +/// +/// ```text +/// s_t = 0.5 · (ln(H_t / L_t))² − (2·ln 2 − 1) · (ln(C_t / O_t))² +/// ``` +/// +/// and the indicator returns the annualised square root of the rolling +/// mean of `s_t`: +/// +/// ```text +/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100 +/// ``` +/// +/// Garman & Klass showed the estimator is ~7.4× more statistically efficient +/// than the close-to-close estimator under driftless Geometric Brownian +/// Motion (Parkinson sits at ~5.0×). It is still biased when there is +/// significant overnight drift between bars — use the Yang-Zhang estimator +/// when the dataset has meaningful close-to-open gaps. +/// +/// The per-bar sample `s_t` can be slightly negative when the bar's range +/// is small relative to its open-to-close move; this matches the original +/// paper's algebra and is handled by clamping the rolling mean to zero +/// before taking the square root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, GarmanKlassVolatility, Indicator}; +/// +/// let mut indicator = GarmanKlassVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GarmanKlassVolatility { + period: usize, + trading_periods: usize, + window: VecDeque, + sum: f64, + last: Option, +} + +/// `2 · ln 2 − 1` — the Garman-Klass open-to-close weight. +const GK_OC_COEFF: f64 = 0.386_294_361_119_890_6; + +impl GarmanKlassVolatility { + /// Construct a Garman-Klass Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`. + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + trading_periods, + window: VecDeque::with_capacity(period), + sum: 0.0, + last: None, + }) + } + + /// Configured `(period, trading_periods)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.trading_periods) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for GarmanKlassVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // `Candle::new` enforces finite, positive OHLC with `high >= max(open, + // low, close)` and `low <= min(open, high, close)`, so every log + // ratio below is well-defined and `ln(H/L) >= 0`. + let log_hl = (candle.high / candle.low).ln(); + let log_co = (candle.close / candle.open).ln(); + let sample = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co; + + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + } + self.window.push_back(sample); + self.sum += sample; + + if self.window.len() < self.period { + return None; + } + + let n = self.period as f64; + // Rolling mean. Garman-Klass samples can be marginally negative on + // narrow-range bars with large O-to-C moves; the rolling mean is + // theoretically `>= 0` but a clamp absorbs FP cancellation and the + // pathological all-negative case. + let variance = (self.sum / n).max(0.0); + let sigma = variance.sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "GarmanKlassVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(o, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + GarmanKlassVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + GarmanKlassVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let gk = GarmanKlassVolatility::new(20, 252).unwrap(); + assert_eq!(gk.periods(), (20, 252)); + assert_eq!(gk.value(), None); + assert_eq!(gk.warmup_period(), 20); + assert_eq!(gk.name(), "GarmanKlassVolatility"); + assert!(!gk.is_ready()); + } + + #[test] + fn zero_movement_yields_zero() { + // O == H == L == C -> both log terms are zero -> sigma is zero. + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + let mut gk = GarmanKlassVolatility::new(14, 1).unwrap(); + for v in gk.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_bar_shape_yields_constant_sigma() { + // Every bar has identical O/H/L/C ratios -> per-bar sample is a + // constant `k`, so the rolling mean is `k` and the output is + // `sqrt(k) * 100` (trading_periods = 1). + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect(); + let log_hl = (11.0_f64 / 9.0_f64).ln(); + let log_co = (10.2_f64 / 10.0_f64).ln(); + let k = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co; + let expected = k.max(0.0).sqrt() * 100.0; + + let mut gk = GarmanKlassVolatility::new(10, 1).unwrap(); + let out = gk.batch(&candles); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut gk = GarmanKlassVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + let open = base - 0.1; + let close = base + 0.2; + candle(open, base + half, base - half, close, i64::from(i)) + }) + .collect(); + for v in gk.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Garman-Klass must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle(base, base + half, base - half, base + 0.3, i64::from(i)) + }) + .collect(); + let raw = GarmanKlassVolatility::new(10, 1).unwrap().batch(&candles); + let annual = GarmanKlassVolatility::new(10, 252).unwrap().batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect(); + let mut gk = GarmanKlassVolatility::new(5, 1).unwrap(); + let out = gk.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle(base, base + half, base - half, base + 0.5, i64::from(i)) + }) + .collect(); + let batch = GarmanKlassVolatility::new(14, 252).unwrap().batch(&candles); + let mut streamer = GarmanKlassVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect(); + let mut gk = GarmanKlassVolatility::new(14, 252).unwrap(); + gk.batch(&candles); + assert!(gk.is_ready()); + gk.reset(); + assert!(!gk.is_ready()); + assert_eq!(gk.value(), None); + assert_eq!(gk.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/gartley.rs b/crates/wickra-core/src/indicators/gartley.rs new file mode 100644 index 0000000..a941fd2 --- /dev/null +++ b/crates/wickra-core/src/indicators/gartley.rs @@ -0,0 +1,157 @@ +//! Gartley harmonic pattern. + +use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Gartley — the classic 5-point (X-A-B-C-D) harmonic pattern, recognised from +/// confirmed swing pivots when the legs fall inside the Gartley Fibonacci +/// windows: +/// +/// ```text +/// AB / XA ∈ [0.55, 0.70] (≈ 0.618 retracement of XA) +/// BC / AB ∈ [0.382, 0.886] +/// CD / BC ∈ [1.13, 1.618] +/// AD / XA ∈ [0.74, 0.84] (≈ 0.786 — the defining D completion) +/// ``` +/// +/// Output is `+1.0` when the terminal point D is a swing low (bullish +/// completion), `-1.0` when D is a swing high (bearish), and `0.0` otherwise; +/// never `None`. See `crates/wickra-core/src/indicators/gartley.rs`. +#[derive(Debug, Clone)] +pub struct Gartley { + swing: SwingTracker, + has_emitted: bool, +} + +impl Gartley { + /// Construct a new Gartley detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for Gartley { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Gartley { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let cd = (p.d - p.c).abs(); + let ad = (p.d - p.a).abs(); + let matched = ratios_in(&[ + (ab / xa, 0.55, 0.70), + (bc / ab, 0.382, 0.886), + (cd / bc, 1.13, 1.618), + (ad / xa, 0.74, 0.84), + ]); + if matched { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Gartley" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Gartley::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Gartley::new(); + assert_eq!(indicator.name(), "Gartley"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!Gartley::default().is_ready()); + } + + #[test] + fn bullish_gartley_is_plus_one() { + let out = run(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_gartley_is_minus_one() { + let out = run(&[150.0, 110.0, 134.7, 122.35, 141.44]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn out_of_ratio_does_not_trigger() { + // Five pivots but the D completion (AD/XA ≈ 0.25) is far from 0.786. + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Gartley::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]); + let mut a = Gartley::new(); + let mut b = Gartley::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/gator_oscillator.rs b/crates/wickra-core/src/indicators/gator_oscillator.rs new file mode 100644 index 0000000..449cbba --- /dev/null +++ b/crates/wickra-core/src/indicators/gator_oscillator.rs @@ -0,0 +1,205 @@ +//! Bill Williams' Gator Oscillator (derived from the Alligator). + +use crate::error::Result; +use crate::indicators::alligator::Alligator; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Gator Oscillator output: the two histogram bars drawn above and below the +/// zero line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GatorOscillatorOutput { + /// Upper histogram `|jaw - teeth|`, always `>= 0`. + pub upper: f64, + /// Lower histogram `-|teeth - lips|`, always `<= 0`. + pub lower: f64, +} + +/// Bill Williams' Gator Oscillator: a convergence/divergence view of the +/// [`Alligator`] lines. The upper bar is the absolute gap between Jaw and +/// Teeth; the lower bar is the negated absolute gap between Teeth and Lips. +/// +/// ```text +/// upper = |jaw - teeth| +/// lower = -|teeth - lips | +/// ``` +/// +/// Widening bars mean the Alligator's mouth is opening (a trending market); +/// shrinking bars mean it is closing (consolidation). Warmup matches the +/// underlying Alligator — the first value appears once the slowest line (Jaw) +/// has warmed up. +/// +/// Reference: Bill Williams, *Trading Chaos*, 1995. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, GatorOscillator, Indicator}; +/// +/// let mut indicator = GatorOscillator::classic(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GatorOscillator { + alligator: Alligator, +} + +impl GatorOscillator { + /// Construct a Gator Oscillator from explicit Alligator periods + /// `(jaw, teeth, lips)`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if any period is zero. + pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result { + Ok(Self { + alligator: Alligator::new(jaw_period, teeth_period, lips_period)?, + }) + } + + /// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`. + pub fn classic() -> Self { + Self { + alligator: Alligator::classic(), + } + } + + /// Configured `(jaw_period, teeth_period, lips_period)`. + pub const fn periods(&self) -> (usize, usize, usize) { + self.alligator.periods() + } +} + +impl Indicator for GatorOscillator { + type Input = Candle; + type Output = GatorOscillatorOutput; + + fn update(&mut self, candle: Candle) -> Option { + let lines = self.alligator.update(candle)?; + Some(GatorOscillatorOutput { + upper: (lines.jaw - lines.teeth).abs(), + lower: -(lines.teeth - lines.lips).abs(), + }) + } + + fn reset(&mut self) { + self.alligator.reset(); + } + + fn warmup_period(&self) -> usize { + self.alligator.warmup_period() + } + + fn is_ready(&self) -> bool { + self.alligator.is_ready() + } + + fn name(&self) -> &'static str { + "GatorOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, ts: i64) -> Candle { + let close = f64::midpoint(high, low); + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + GatorOscillator::new(0, 8, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + GatorOscillator::new(13, 0, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + GatorOscillator::new(13, 8, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let g = GatorOscillator::classic(); + assert_eq!(g.periods(), (13, 8, 5)); + assert_eq!(g.warmup_period(), 13); + assert_eq!(g.name(), "GatorOscillator"); + assert!(!g.is_ready()); + } + + #[test] + fn constant_series_collapses_both_bars() { + // All three Alligator lines equal the constant median -> zero spread. + let mut g = GatorOscillator::classic(); + let candles: Vec = (0..40).map(|i| candle(11.0, 9.0, i)).collect(); + let out = g.batch(&candles); + let last = out.last().unwrap().unwrap(); + assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12); + } + + #[test] + fn trending_series_opens_the_mouth() { + // On a clean trend the lines separate -> upper > 0, lower < 0. + let mut g = GatorOscillator::classic(); + let candles: Vec = (0_i64..80) + .map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i)) + .collect(); + let last = g.batch(&candles).last().unwrap().unwrap(); + assert!(last.upper > 0.0, "upper {} should be positive", last.upper); + assert!(last.lower < 0.0, "lower {} should be negative", last.lower); + } + + #[test] + fn warmup_emits_first_value_at_longest_period() { + let mut g = GatorOscillator::new(5, 3, 2).unwrap(); + let candles: Vec = (0..6).map(|i| candle(11.0, 9.0, i)).collect(); + let out = g.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn reset_clears_state() { + let mut g = GatorOscillator::classic(); + let candles: Vec = (0..40).map(|i| candle(11.0, 9.0, i)).collect(); + g.batch(&candles); + assert!(g.is_ready()); + g.reset(); + assert!(!g.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.2).sin() * 5.0; + candle(base + 1.0, base - 1.0, i) + }) + .collect(); + let mut a = GatorOscillator::classic(); + let mut b = GatorOscillator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/generalized_dema.rs b/crates/wickra-core/src/indicators/generalized_dema.rs new file mode 100644 index 0000000..8d6143a --- /dev/null +++ b/crates/wickra-core/src/indicators/generalized_dema.rs @@ -0,0 +1,222 @@ +//! Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Generalized DEMA — the building block of Tillson's [`T3`](crate::T3), +/// exposed on its own. +/// +/// ```text +/// GD = (1 + v) · EMA(price) − v · EMA(EMA(price)) +/// ``` +/// +/// where both EMAs share the same `period` and `v ∈ [0, 1]` is the *volume +/// factor*. `v` controls how much of the second-order lag correction is +/// applied: +/// +/// - `v = 0` collapses GD to a plain [`Ema`](crate::Ema) (no correction). +/// - `v = 1` recovers the standard [`Dema`](crate::Dema) `2·EMA − EMA(EMA)`. +/// - intermediate values (Tillson uses `0.7`) trade a little lag reduction for +/// less overshoot than DEMA. +/// +/// Because the coefficients `(1 + v)` and `−v` always sum to `1`, a constant +/// series maps to itself. The first output lands after `2·period − 1` inputs — +/// EMA1 seeds at `period`, then EMA2 needs another `period − 1` of EMA1's +/// outputs to seed, exactly like DEMA. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, GeneralizedDema}; +/// +/// let mut indicator = GeneralizedDema::new(5, 0.7).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GeneralizedDema { + ema1: Ema, + ema2: Ema, + period: usize, + v: f64, +} + +impl GeneralizedDema { + /// Construct a generalized DEMA with the given `period` and volume factor + /// `v`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`. + pub fn new(period: usize, v: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(Error::InvalidPeriod { + message: "GD volume factor must be a finite value in [0.0, 1.0]", + }); + } + Ok(Self { + ema1: Ema::new(period)?, + ema2: Ema::new(period)?, + period, + v, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured volume factor `v`. + pub const fn volume_factor(&self) -> f64 { + self.v + } +} + +impl Indicator for GeneralizedDema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let e1 = self.ema1.update(input)?; + let e2 = self.ema2.update(e1)?; + Some((1.0 + self.v) * e1 - self.v * e2) + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + } + + fn warmup_period(&self) -> usize { + // EMA1 seeds at period, then EMA2 needs another (period - 1) values. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.ema2.is_ready() + } + + fn name(&self) -> &'static str { + "GD" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::Dema; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + GeneralizedDema::new(0, 0.7), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_invalid_volume_factor() { + assert!(matches!( + GeneralizedDema::new(5, -0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + GeneralizedDema::new(5, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + GeneralizedDema::new(5, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + assert!(GeneralizedDema::new(5, 0.0).is_ok()); + assert!(GeneralizedDema::new(5, 1.0).is_ok()); + } + + /// Cover the const accessors `period` + `volume_factor` and the + /// Indicator-impl `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let gd = GeneralizedDema::new(5, 0.7).unwrap(); + assert_eq!(gd.period(), 5); + assert_relative_eq!(gd.volume_factor(), 0.7, epsilon = 1e-12); + // EMA1 seeds at 5, EMA2 needs another 4 -> 2*period - 1 = 9. + assert_eq!(gd.warmup_period(), 9); + assert_eq!(gd.name(), "GD"); + } + + #[test] + fn constant_series_yields_constant() { + let mut gd = GeneralizedDema::new(5, 0.7).unwrap(); + let out = gd.batch(&[100.0_f64; 60]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 100.0, epsilon = 1e-9); + } + + #[test] + fn v_one_equals_dema() { + // GD with v = 1 is exactly the standard DEMA. + let prices: Vec = (1..=80) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut gd = GeneralizedDema::new(7, 1.0).unwrap(); + let mut dema = Dema::new(7).unwrap(); + let gd_out = gd.batch(&prices); + let dema_out = dema.batch(&prices); + for (g, d) in gd_out.iter().zip(dema_out.iter()) { + assert_eq!(g.is_some(), d.is_some()); + if let (Some(a), Some(b)) = (g, d) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn v_zero_equals_ema() { + // GD with v = 0 is a plain EMA (no second-order correction). + let prices: Vec = (1..=60).map(|i| f64::from(i) * 0.5).collect(); + let mut gd = GeneralizedDema::new(6, 0.0).unwrap(); + let mut ema = Ema::new(6).unwrap(); + let gd_out = gd.batch(&prices); + for (i, (g, p)) in gd_out.iter().zip(prices.iter()).enumerate() { + // GD(v=0) feeds EMA1 into EMA2 but outputs EMA1 alone (coefficient + // 1 on e1, 0 on e2); it is only ready once EMA2 is, so compare + // against a standalone EMA chained the same way. + let want = ema.update(*p).filter(|_| i + 1 >= gd.warmup_period()); + if let (Some(a), Some(b)) = (g, want) { + assert_relative_eq!(*a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80).map(|i| f64::from(i) * 0.5).collect(); + let mut a = GeneralizedDema::new(7, 0.7).unwrap(); + let mut b = GeneralizedDema::new(7, 0.7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut gd = GeneralizedDema::new(5, 0.7).unwrap(); + gd.batch(&(1..=50).map(f64::from).collect::>()); + assert!(gd.is_ready()); + gd.reset(); + assert!(!gd.is_ready()); + assert_eq!(gd.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/geometric_ma.rs b/crates/wickra-core/src/indicators/geometric_ma.rs new file mode 100644 index 0000000..150701b --- /dev/null +++ b/crates/wickra-core/src/indicators/geometric_ma.rs @@ -0,0 +1,275 @@ +//! Geometric Moving Average (GMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Geometric Moving Average — the rolling geometric mean of the last `period` +/// inputs. +/// +/// ```text +/// GMA = (Π value_i)^(1/period) = exp( (1/period) · Σ ln(value_i) ) +/// ``` +/// +/// The geometric mean is the natural average for *multiplicative* quantities +/// such as prices and growth factors: averaging in log-space weights relative +/// (percentage) moves symmetrically, so a `+10%` followed by a `−10%` move +/// pulls the average below the start, exactly as compounded returns do. It is +/// always less than or equal to the arithmetic mean of the same window. +/// +/// Maintained incrementally in O(1): the running sum of natural logs is updated +/// by adding the newcomer's log and subtracting the departing value's log as +/// the window slides. +/// +/// The geometric mean is only defined for **strictly positive** inputs. A +/// non-finite or non-positive input is ignored (it leaves the window unchanged +/// and returns the current value), mirroring the non-finite handling of the +/// other moving averages. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, GeometricMa}; +/// +/// let mut indicator = GeometricMa::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GeometricMa { + period: usize, + /// Natural logs of the values currently in the window (oldest at front). + logs: VecDeque, + sum_logs: f64, +} + +impl GeometricMa { + /// Construct a new geometric moving average over `period` inputs. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + logs: VecDeque::with_capacity(period), + sum_logs: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.logs.len() == self.period { + Some((self.sum_logs / self.period as f64).exp()) + } else { + None + } + } +} + +impl Indicator for GeometricMa { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() || input <= 0.0 { + return self.value(); + } + if self.logs.len() == self.period { + let oldest = self.logs.pop_front().expect("window non-empty"); + self.sum_logs -= oldest; + } + let ln = input.ln(); + self.logs.push_back(ln); + self.sum_logs += ln; + self.value() + } + + fn reset(&mut self) { + self.logs.clear(); + self.sum_logs = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.logs.len() == self.period + } + + fn name(&self) -> &'static str { + "GMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Reference implementation: explicit geometric mean over a window. + fn gma_naive(prices: &[f64], period: usize) -> Vec> { + prices + .iter() + .enumerate() + .map(|(i, _)| { + if i + 1 < period { + None + } else { + let window = &prices[i + 1 - period..=i]; + let product: f64 = window.iter().product(); + Some(product.powf(1.0 / period as f64)) + } + }) + .collect() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(GeometricMa::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let gma = GeometricMa::new(7).unwrap(); + assert_eq!(gma.period(), 7); + assert_eq!(gma.warmup_period(), 7); + assert_eq!(gma.name(), "GMA"); + } + + #[test] + fn warmup_returns_none() { + let mut gma = GeometricMa::new(3).unwrap(); + assert_eq!(gma.update(1.0), None); + assert_eq!(gma.update(4.0), None); + // GMA(3) of [1, 4, 2] = (1·4·2)^(1/3) = 8^(1/3) = 2. + assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12); + } + + #[test] + fn known_value_period_2() { + // GMA(2) of [4, 9] = sqrt(36) = 6. + let mut gma = GeometricMa::new(2).unwrap(); + let v = gma.batch(&[4.0, 9.0]); + assert_relative_eq!(v[1].unwrap(), 6.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_returns_the_constant() { + let mut gma = GeometricMa::new(5).unwrap(); + for v in gma.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn period_one_is_pass_through() { + let mut gma = GeometricMa::new(1).unwrap(); + assert_relative_eq!(gma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(gma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn below_or_equal_arithmetic_mean() { + // The geometric mean never exceeds the arithmetic mean of the same set. + let mut gma = GeometricMa::new(4).unwrap(); + let prices = [10.0, 20.0, 5.0, 40.0]; + let g = gma.batch(&prices)[3].unwrap(); + let arithmetic = prices.iter().sum::() / 4.0; + assert!( + g < arithmetic, + "geometric {g} should be below arithmetic {arithmetic}" + ); + } + + #[test] + fn matches_naive_over_inputs() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 1.7 + 1.0).collect(); + let mut gma = GeometricMa::new(7).unwrap(); + let got = gma.batch(&prices); + let want = gma_naive(&prices, 7); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut gma = GeometricMa::new(4).unwrap(); + gma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(gma.is_ready()); + gma.reset(); + assert!(!gma.is_ready()); + assert_eq!(gma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| f64::from(i) * 0.5 + 1.0).collect(); + let mut a = GeometricMa::new(5).unwrap(); + let mut b = GeometricMa::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_and_non_positive_input() { + let mut gma = GeometricMa::new(3).unwrap(); + gma.update(1.0); + gma.update(4.0); + let ready = gma.update(2.0).expect("GMA(3) ready after three inputs"); + // Non-finite and non-positive inputs are skipped (geometric mean needs + // strictly positive values) and the window is left unchanged. + assert_eq!(gma.update(f64::NAN), Some(ready)); + assert_eq!(gma.update(0.0), Some(ready)); + assert_eq!(gma.update(-3.0), Some(ready)); + // The window still holds 1, 4, 2 -> next real input slides it to 4, 2, 16. + let want = (4.0_f64 * 2.0 * 16.0).powf(1.0 / 3.0); + assert_relative_eq!(gma.update(16.0).unwrap(), want, epsilon = 1e-9); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn proptest_matches_naive( + period in 1usize..15, + prices in proptest::collection::vec(0.01_f64..1000.0, 0..120), + ) { + let mut gma = GeometricMa::new(period).unwrap(); + let got = gma.batch(&prices); + let want = gma_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() <= 1e-6 * b.abs().max(1.0), + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/golden_pocket.rs b/crates/wickra-core/src/indicators/golden_pocket.rs new file mode 100644 index 0000000..5c15df1 --- /dev/null +++ b/crates/wickra-core/src/indicators/golden_pocket.rs @@ -0,0 +1,190 @@ +//! Golden Pocket — the 0.618-0.65 optimal-trade-entry zone of the last swing. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Lower bound of the golden pocket (the 61.8% retracement). +const RATIO_LOW: f64 = 0.618; +/// Upper bound of the golden pocket (the 65% retracement). +const RATIO_HIGH: f64 = 0.65; + +/// The golden-pocket zone of the most recent swing leg. +/// +/// `low`/`high` bracket the 0.618-0.65 retracement band (sorted, so `low <= +/// high` regardless of swing direction); `mid` is their midpoint. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GoldenPocketOutput { + /// Lower price of the golden-pocket band. + pub low: f64, + /// Midpoint of the band. + pub mid: f64, + /// Upper price of the golden-pocket band. + pub high: f64, +} + +/// Golden Pocket (`GoldenPocket`). +/// +/// The 0.618-0.65 retracement band of the most recent confirmed swing leg — the +/// "optimal trade entry" zone many swing traders watch for continuation. +/// +/// Parameter-free; construction is infallible. Returns `None` until the first +/// leg is complete. +/// +/// See `crates/wickra-core/src/indicators/golden_pocket.rs`. +/// # Example +/// +/// ``` +/// use wickra_core::{GoldenPocket, Candle, Indicator}; +/// +/// let mut indicator = GoldenPocket::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct GoldenPocket { + swing: SwingTracker, +} + +impl GoldenPocket { + /// Construct a new Golden Pocket tracker. + #[must_use] + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 2), + } + } + + fn zone(&self) -> Option { + let pivots = self.swing.pivots(); + let [start, end] = [pivots.first()?.price, pivots.get(1)?.price]; + let span = start - end; + let edge_low = end + RATIO_LOW * span; + let edge_high = end + RATIO_HIGH * span; + let low = edge_low.min(edge_high); + let high = edge_low.max(edge_high); + Some(GoldenPocketOutput { + low, + mid: f64::midpoint(low, high), + high, + }) + } +} + +impl Default for GoldenPocket { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for GoldenPocket { + type Input = Candle; + type Output = GoldenPocketOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.swing.update(candle); + self.zone() + } + + fn reset(&mut self) { + self.swing.reset(); + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.swing.pivots().len() >= 2 + } + + fn name(&self) -> &'static str { + "GoldenPocket" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let indicator = GoldenPocket::new(); + assert_eq!(indicator.name(), "GoldenPocket"); + assert_eq!(indicator.warmup_period(), 2); + assert!(!indicator.is_ready()); + assert!(!GoldenPocket::default().is_ready()); + } + + #[test] + fn no_output_before_two_pivots() { + let mut indicator = GoldenPocket::new(); + let outputs: Vec<_> = candles_for_pivots(&[120.0]) + .into_iter() + .map(|c| indicator.update(c)) + .collect(); + assert!(outputs.iter().all(Option::is_none)); + } + + #[test] + fn zone_of_a_down_leg() { + // Leg 200 (high) -> 100 (low), span = 100. + let mut indicator = GoldenPocket::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 100.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(indicator.is_ready()); + // 61.8% = 161.8, 65% = 165 → sorted band [161.8, 165], mid 163.4. + assert_relative_eq!(v.low, 161.8); + assert_relative_eq!(v.high, 165.0); + assert_relative_eq!(v.mid, 163.4); + } + + #[test] + fn band_is_sorted_for_an_up_leg() { + // Latest leg 100 (low) -> 250 (high): span negative, edges flip, but + // low <= high must still hold. + let mut indicator = GoldenPocket::new(); + let mut last = None; + for candle in candles_for_pivots(&[200.0, 100.0, 250.0]) { + last = indicator.update(candle); + } + let v = last.unwrap(); + assert!(v.low <= v.high); + assert_relative_eq!(v.mid, f64::midpoint(v.low, v.high)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = GoldenPocket::new(); + for candle in candles_for_pivots(&[200.0, 100.0]) { + let _ = indicator.update(candle); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert!(indicator.update(c).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[200.0, 100.0, 150.0]); + let mut a = GoldenPocket::new(); + let mut b = GoldenPocket::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/granger_causality.rs b/crates/wickra-core/src/indicators/granger_causality.rs new file mode 100644 index 0000000..2763619 --- /dev/null +++ b/crates/wickra-core/src/indicators/granger_causality.rs @@ -0,0 +1,350 @@ +//! Granger causality F-statistic: does series `b` help predict series `a`? + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Granger causality of `b` on `a` over a rolling window, as an F-statistic. +/// +/// Each `update` takes one `(a, b)` pair. Over the trailing window of `period` +/// observations the indicator fits two autoregressions of `a` and compares them +/// with an F-test: +/// +/// ```text +/// restricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ (a's own lags only) +/// unrestricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ + Σ ψᵢ·bₜ₋ᵢ (+ b's lags) +/// F = ((RSSᵣ − RSSᵤ) / lag) / (RSSᵤ / (n − 2·lag − 1)) +/// ``` +/// +/// If adding `b`'s lags significantly reduces the residual sum of squares, `b` +/// **Granger-causes** `a`: past values of `b` carry information about the future +/// of `a` beyond what `a`'s own past holds. A **larger** F means stronger +/// predictive causality (lead–lag structure a stat-arb model can trade); a +/// value near `0` means `b` adds nothing. Note Granger causality is purely +/// predictive — it is not structural cause and effect. +/// +/// The statistic is `0` when a regression is degenerate — a collinear or flat +/// window makes the normal equations singular. The output is always `≥ 0`. +/// +/// Each `update` is `O(period · lag² + lag³)`, bounded by the fixed parameters. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{GrangerCausality, Indicator}; +/// +/// let mut g = GrangerCausality::new(60, 1).unwrap(); +/// let mut last = None; +/// for t in 0..120 { +/// let drive = (f64::from(t) * 0.3).sin(); +/// // a echoes b's previous value plus noise ⇒ b Granger-causes a. +/// let b = drive; +/// let a = 0.5 * (f64::from(t.max(1) - 1) * 0.3).sin() + 0.1 * (f64::from(t) * 0.9).cos(); +/// last = g.update((a, b)); +/// } +/// assert!(last.unwrap() >= 0.0); +/// ``` +#[derive(Debug, Clone)] +pub struct GrangerCausality { + period: usize, + lag: usize, + window: VecDeque<(f64, f64)>, +} + +impl GrangerCausality { + /// Construct a new Granger causality test. + /// + /// `period` is the look-back window; `lag` is the autoregressive order + /// (number of own/cross lags in each model). + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `lag < 1` or if `period < 3·lag + 2` + /// (the smallest window that leaves the unrestricted regression at least one + /// residual degree of freedom). + pub fn new(period: usize, lag: usize) -> Result { + if lag < 1 { + return Err(Error::InvalidPeriod { + message: "granger causality needs lag >= 1", + }); + } + if period < 3 * lag + 2 { + return Err(Error::InvalidPeriod { + message: "granger causality needs period >= 3*lag + 2", + }); + } + Ok(Self { + period, + lag, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured look-back window. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured autoregressive order. + pub const fn lag(&self) -> usize { + self.lag + } +} + +impl Indicator for GrangerCausality { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let lag = self.lag; + let a: Vec = self.window.iter().map(|&(av, _)| av).collect(); + let b: Vec = self.window.iter().map(|&(_, bv)| bv).collect(); + let num_obs = self.period - lag; + + let mut target = Vec::with_capacity(num_obs); + let mut restricted = Vec::with_capacity(num_obs); + let mut unrestricted = Vec::with_capacity(num_obs); + for k in 0..num_obs { + let now = lag + k; + target.push(a[now]); + let mut row_r = Vec::with_capacity(lag + 1); + row_r.push(1.0); + for back in 1..=lag { + row_r.push(a[now - back]); + } + let mut row_u = row_r.clone(); + for back in 1..=lag { + row_u.push(b[now - back]); + } + restricted.push(row_r); + unrestricted.push(row_u); + } + + let Some(rss_r) = ols_rss(&restricted, &target, lag + 1) else { + return Some(0.0); + }; + let Some(rss_u) = ols_rss(&unrestricted, &target, 2 * lag + 1) else { + return Some(0.0); + }; + let dof = (num_obs - (2 * lag + 1)) as f64; + let numerator = (rss_r - rss_u) / lag as f64; + let denominator = rss_u / dof; + Some((numerator / denominator).max(0.0)) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "GrangerCausality" + } +} + +/// Residual sum of squares of the OLS fit of `target` on the design `rows` +/// (each a length-`num_reg` regressor vector). Returns `None` if the normal +/// equations are singular. +fn ols_rss(rows: &[Vec], target: &[f64], num_reg: usize) -> Option { + let mut xtx = vec![vec![0.0; num_reg]; num_reg]; + let mut xty = vec![0.0; num_reg]; + for (row, &observed) in rows.iter().zip(target) { + for (ri, &left) in row.iter().enumerate() { + xty[ri] += left * observed; + for (ci, &right) in row.iter().enumerate() { + xtx[ri][ci] += left * right; + } + } + } + let theta = solve(xtx, xty)?; + let mut rss = 0.0; + for (row, &observed) in rows.iter().zip(target) { + let pred: f64 = row + .iter() + .zip(&theta) + .map(|(coeff, value)| coeff * value) + .sum(); + let resid = observed - pred; + rss += resid * resid; + } + Some(rss) +} + +/// Solve the linear system `mat·x = rhs` by Gaussian elimination, returning +/// `None` if the matrix is (numerically) singular. `mat` is row-major. +fn solve(mut mat: Vec>, mut rhs: Vec) -> Option> { + let dim = rhs.len(); + for col in 0..dim { + let pivot = mat[col][col]; + if pivot.abs() < 1e-12 { + return None; + } + let pivot_row = mat[col].clone(); + for row in (col + 1)..dim { + let factor = mat[row][col] / pivot; + for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) { + *cell -= factor * above; + } + rhs[row] -= factor * rhs[col]; + } + } + let mut sol = vec![0.0; dim]; + for row in (0..dim).rev() { + let known: f64 = mat[row] + .iter() + .zip(&sol) + .skip(row + 1) + .map(|(coeff, value)| coeff * value) + .sum(); + sol[row] = (rhs[row] - known) / mat[row][row]; + } + Some(sol) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_bad_parameters() { + assert!(GrangerCausality::new(10, 0).is_err()); // lag must be >= 1 + assert!(GrangerCausality::new(4, 1).is_err()); // period must be >= 3*lag + 2 + assert!(GrangerCausality::new(5, 1).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let g = GrangerCausality::new(60, 2).unwrap(); + assert_eq!(g.period(), 60); + assert_eq!(g.lag(), 2); + assert_eq!(g.warmup_period(), 60); + assert_eq!(g.name(), "GrangerCausality"); + assert!(!g.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut g = GrangerCausality::new(5, 1).unwrap(); + for t in 0..4 { + assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None); + } + assert!(g.update((4.0, 2.0)).is_some()); + assert!(g.is_ready()); + } + + #[test] + fn b_leading_a_has_positive_statistic() { + // a[t] is driven by b[t-1] plus a little of its own past ⇒ b helps. + let mut prev_drive = 0.0; + let pairs: Vec<(f64, f64)> = (0..120) + .map(|t| { + let drive = (f64::from(t) * 0.3).sin() + 0.4 * (f64::from(t) * 0.11).cos(); + let a = 0.8 * prev_drive + 0.05 * (f64::from(t) * 0.7).sin(); + prev_drive = drive; + (a, drive) + }) + .collect(); + let last = GrangerCausality::new(60, 1) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last > 1.0, "F {last}"); + } + + #[test] + fn constant_b_is_singular_and_returns_zero() { + // b is constant ⇒ its lag columns are collinear with the intercept ⇒ + // the unrestricted normal equations are singular ⇒ 0. + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| (f64::from(t) + (f64::from(t) * 0.6).sin(), 3.0)) + .collect(); + let last = GrangerCausality::new(20, 1) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn constant_a_restricted_singular_returns_zero() { + // a is constant ⇒ its own lag columns are collinear with the intercept + // ⇒ the restricted normal equations are singular ⇒ 0. + let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, (f64::from(t) * 0.4).sin())).collect(); + let last = GrangerCausality::new(20, 1) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut g = GrangerCausality::new(8, 1).unwrap(); + for t in 0..12 { + g.update(( + f64::from(t) + (f64::from(t) * 0.7).sin(), + (f64::from(t) * 0.3).cos(), + )); + } + assert!(g.is_ready()); + g.reset(); + assert!(!g.is_ready()); + assert_eq!(g.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|t| { + let b = (f64::from(t) * 0.4).sin(); + ( + 0.6 * (f64::from(t.max(1) - 1) * 0.4).sin() + 0.1 * f64::from(t % 3), + b, + ) + }) + .collect(); + let batch = GrangerCausality::new(30, 2).unwrap().batch(&pairs); + let mut g = GrangerCausality::new(30, 2).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut g = GrangerCausality::new(5, 1).unwrap(); + assert_eq!(g.update((f64::NAN, 1.0)), None); + assert_eq!(g.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + for t in 0..4 { + assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None); + } + assert!(g.update((4.0, 2.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/gravestone_doji.rs b/crates/wickra-core/src/indicators/gravestone_doji.rs new file mode 100644 index 0000000..11dd424 --- /dev/null +++ b/crates/wickra-core/src/indicators/gravestone_doji.rs @@ -0,0 +1,163 @@ +//! Gravestone Doji candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Gravestone Doji — a single-bar bearish reversal. Open, close, and low sit at +/// the bottom of the bar while a long upper shadow shows price was pushed up hard +/// and then sold all the way back to the open — sellers rejecting the highs. +/// +/// ```text +/// range = high − low +/// doji = |close − open| <= 0.1 * range +/// no lower wick = min(open, close) − low <= 0.1 * range +/// long upper = high − max(open, close) >= 0.5 * range +/// ``` +/// +/// Output is `−1.0` when the gravestone prints and `0.0` otherwise. Gravestone +/// Doji is a single-direction (bearish-only) shape, so it never emits `+1.0`. +/// Body and shadow thresholds follow the geometric house style (fixed fractions +/// of the bar range) rather than TA-Lib's rolling averages. Pattern-shape check +/// only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, GravestoneDoji, Indicator}; +/// +/// let mut indicator = GravestoneDoji::new(); +/// // Body at the bottom, long upper shadow. +/// let candle = Candle::new(10.0, 14.0, 9.95, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct GravestoneDoji { + has_emitted: bool, +} + +impl GravestoneDoji { + /// Construct a new Gravestone Doji detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for GravestoneDoji { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + if (candle.close - candle.open).abs() > 0.1 * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if lower <= 0.1 * range && upper >= 0.5 * range { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "GravestoneDoji" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = GravestoneDoji::new(); + assert_eq!(t.name(), "GravestoneDoji"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn gravestone_is_minus_one() { + let mut t = GravestoneDoji::new(); + assert_eq!(t.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn lower_shadow_yields_zero() { + let mut t = GravestoneDoji::new(); + // Long lower shadow -> not a gravestone (this is a dragonfly shape). + assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn short_upper_shadow_yields_zero() { + let mut t = GravestoneDoji::new(); + // Body at the bottom but the upper shadow is too short. + assert_eq!(t.update(c(10.0, 10.4, 9.95, 10.0, 0)), Some(0.0)); + } + + #[test] + fn non_doji_yields_zero() { + let mut t = GravestoneDoji::new(); + assert_eq!(t.update(c(10.0, 14.0, 9.5, 13.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = GravestoneDoji::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 4.0, base - 0.05, base, i) + }) + .collect(); + let mut a = GravestoneDoji::new(); + let mut b = GravestoneDoji::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = GravestoneDoji::new(); + t.update(c(10.0, 14.0, 9.95, 10.0, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/hammer.rs b/crates/wickra-core/src/indicators/hammer.rs new file mode 100644 index 0000000..916831f --- /dev/null +++ b/crates/wickra-core/src/indicators/hammer.rs @@ -0,0 +1,168 @@ +//! Hammer candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hammer — a single-bar bullish reversal candidate. +/// +/// A Hammer has a small real body sitting near the top of the bar, a long +/// lower shadow at least twice the body, and a short or absent upper shadow. +/// It is traditionally read as a rejection of lower prices. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// hammer = lower_shadow >= 2 * body +/// && upper_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// A Hammer is bullish by definition, so under the uniform candlestick sign +/// convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it emits `+1.0` +/// when the shape matches and `0.0` otherwise — it never emits `−1.0`. The +/// same geometry read at the top of an uptrend is the bearish `HangingMan`, +/// which carries the opposite sign. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Hammer, Indicator}; +/// +/// let mut indicator = Hammer::new(); +/// // Open 10, close 10.5, low 5, high 10.6: long lower shadow, tiny upper. +/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Hammer { + has_emitted: bool, +} + +impl Hammer { + /// Construct a new Hammer detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Hammer { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if lower >= 2.0 * body && upper <= body { + 1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Hammer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = Hammer::new(); + assert_eq!(h.name(), "Hammer"); + assert_eq!(h.warmup_period(), 1); + assert!(!h.is_ready()); + } + + #[test] + fn clean_hammer_is_one() { + let mut h = Hammer::new(); + // body 0.5 (10 -> 10.5), lower shadow 5.0, upper shadow 0.1. + assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(1.0)); + } + + #[test] + fn marubozu_is_not_hammer() { + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn shooting_star_shape_is_not_hammer() { + // Long upper, short lower -> not a hammer. + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.5, 15.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_hammer() { + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut h = Hammer::new(); + assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 4.0, base + 0.5, i) + }) + .collect(); + let mut a = Hammer::new(); + let mut b = Hammer::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = Hammer::new(); + h.update(c(10.0, 10.6, 5.0, 10.5, 0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/hanging_man.rs b/crates/wickra-core/src/indicators/hanging_man.rs new file mode 100644 index 0000000..8e8c267 --- /dev/null +++ b/crates/wickra-core/src/indicators/hanging_man.rs @@ -0,0 +1,159 @@ +//! Hanging Man candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hanging Man — a single-bar bearish reversal candidate. +/// +/// A Hanging Man has the same geometry as a Hammer (small body near the top, +/// long lower shadow ≥ 2× body, short upper shadow) but is read bearishly +/// because it appears at the top of an uptrend. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// hanging = lower_shadow >= 2 * body +/// && upper_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// A Hanging Man is bearish by definition, so under the uniform candlestick +/// sign convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it emits +/// `−1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`. +/// The same geometry read at the bottom of a downtrend is the bullish +/// `Hammer`, which carries the opposite sign. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HangingMan, Indicator}; +/// +/// let mut indicator = HangingMan::new(); +/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HangingMan { + has_emitted: bool, +} + +impl HangingMan { + /// Construct a new Hanging Man detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for HangingMan { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if lower >= 2.0 * body && upper <= body { + -1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HangingMan" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = HangingMan::new(); + assert_eq!(h.name(), "HangingMan"); + assert_eq!(h.warmup_period(), 1); + assert!(!h.is_ready()); + } + + #[test] + fn clean_hanging_man_is_minus_one() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(-1.0)); + } + + #[test] + fn marubozu_is_not_hanging_man() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_hanging_man() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut h = HangingMan::new(); + assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 4.0, base + 0.5, i) + }) + .collect(); + let mut a = HangingMan::new(); + let mut b = HangingMan::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = HangingMan::new(); + h.update(c(10.0, 10.6, 5.0, 10.5, 0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/harami.rs b/crates/wickra-core/src/indicators/harami.rs new file mode 100644 index 0000000..cc9276d --- /dev/null +++ b/crates/wickra-core/src/indicators/harami.rs @@ -0,0 +1,194 @@ +//! Bullish / Bearish Harami candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Harami — a 2-bar reversal pattern. The current candle's body sits entirely +/// inside the previous candle's body and points in the opposite direction. +/// +/// ```text +/// prev_body = |prev.close − prev.open| +/// curr_body = |curr.close − curr.open| +/// bullish = prev red & curr green +/// & curr.open >= prev.close & curr.close <= prev.open +/// & curr_body < prev_body +/// bearish = prev green & curr red +/// & curr.open <= prev.close & curr.close >= prev.open +/// & curr_body < prev_body +/// ``` +/// +/// Output is `+1.0` for a bullish harami (small green inside a prior red), +/// `−1.0` for a bearish harami (small red inside a prior green), `0.0` +/// otherwise. The first bar always returns `0.0`. Pattern-shape check only — +/// no trend filter is applied; combine with a trend indicator for actionable +/// signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Harami, Indicator}; +/// +/// let mut indicator = Harami::new(); +/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Harami { + prev: Option, + has_emitted: bool, +} + +impl Harami { + /// Construct a new Harami detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for Harami { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let prev_body = (p.close - p.open).abs(); + let curr_body = (candle.close - candle.open).abs(); + if prev_body <= 0.0 || curr_body <= 0.0 || curr_body >= prev_body { + return Some(0.0); + } + let prev_red = p.close < p.open; + let prev_green = p.close > p.open; + let curr_green = candle.close > candle.open; + let curr_red = candle.close < candle.open; + // Bullish: small green strictly inside prior red body (open >= prev.close, close <= prev.open). + if prev_red && curr_green && candle.open >= p.close && candle.close <= p.open { + Some(1.0) + } else if prev_green && curr_red && candle.open <= p.close && candle.close >= p.open { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Harami" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = Harami::new(); + assert_eq!(h.name(), "Harami"); + assert_eq!(h.warmup_period(), 2); + assert!(!h.is_ready()); + } + + #[test] + fn bullish_harami_is_plus_one() { + let mut h = Harami::new(); + // Prior red 12 -> 10 (body 2). Current green 10.5 -> 11 inside. + assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(h.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(1.0)); + } + + #[test] + fn bearish_harami_is_minus_one() { + let mut h = Harami::new(); + // Prior green 10 -> 12 (body 2). Current red 11.5 -> 11 inside. + assert_eq!(h.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0)); + assert_eq!(h.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(-1.0)); + } + + #[test] + fn larger_body_is_not_harami() { + let mut h = Harami::new(); + h.update(c(11.0, 11.2, 9.8, 10.0, 0)); + // Current body bigger than prior. + assert_eq!(h.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + } + + #[test] + fn same_direction_is_not_harami() { + let mut h = Harami::new(); + h.update(c(10.0, 12.5, 9.5, 12.0, 0)); + // Smaller candle but also green -> 0. + assert_eq!(h.update(c(11.0, 11.6, 10.9, 11.5, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut h = Harami::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 2 == 0 { + c(base + 2.0, base + 2.5, base - 0.5, base, i) + } else { + c(base + 1.0, base + 1.5, base + 0.7, base + 1.3, i) + } + }) + .collect(); + let mut a = Harami::new(); + let mut b = Harami::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = Harami::new(); + h.update(c(12.0, 12.5, 9.5, 10.0, 0)); + h.update(c(10.5, 11.5, 10.4, 11.0, 1)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + // After reset the next bar again has no prev. + assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/harami_cross.rs b/crates/wickra-core/src/indicators/harami_cross.rs new file mode 100644 index 0000000..717b8ac --- /dev/null +++ b/crates/wickra-core/src/indicators/harami_cross.rs @@ -0,0 +1,208 @@ +#![allow(clippy::doc_markdown)] + +//! Harami Cross — a Harami whose second candle is a Doji. +//! +//! A Harami Cross is a stronger Harami: a large real body followed by a Doji whose +//! body sits *within* the prior body. The Doji's total indecision after a strong +//! move makes the reversal signal more potent than a plain Harami. +//! +//! - **Bullish** (`+1.0`): the prior candle is a large **bearish** body +//! (`close < open`) and the current candle is a Doji whose open and close lie +//! within the prior body. +//! - **Bearish** (`-1.0`): the prior candle is a large **bullish** body and the +//! current is a contained Doji. +//! - Otherwise the output is `0.0`. +//! +//! A doji is a candle whose body is `<= 0.1 * range`. The two-bar lookback means +//! the first value lands on the second candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +fn is_doji(candle: Candle) -> bool { + let body = (candle.close - candle.open).abs(); + let range = candle.high - candle.low; + range > 0.0 && body <= 0.1 * range +} + +/// Harami Cross — large-body-then-contained-doji reversal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{HaramiCross, Candle, Indicator}; +/// +/// let mut indicator = HaramiCross::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HaramiCross { + prev: Option, + last_value: Option, +} + +impl HaramiCross { + /// Construct a new `HaramiCross`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for HaramiCross { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let prev_body_low = prev.open.min(prev.close); + let prev_body_high = prev.open.max(prev.close); + let prev_is_solid = !is_doji(prev); + let curr_is_doji = is_doji(candle); + let contained = candle.open >= prev_body_low + && candle.open <= prev_body_high + && candle.close >= prev_body_low + && candle.close <= prev_body_high; + + let v = if prev_is_solid && curr_is_doji && contained { + if prev.close < prev.open { + 1.0 + } else { + -1.0 + } + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "HaramiCross" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn solid(open: f64, close: f64) -> Candle { + Candle::new_unchecked( + open, + open.max(close) + 0.2, + open.min(close) - 0.2, + close, + 0.0, + 0, + ) + } + + fn doji(mid: f64) -> Candle { + Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let h = HaramiCross::new(); + assert_eq!(h.warmup_period(), 2); + assert_eq!(h.name(), "HaramiCross"); + assert!(!h.is_ready()); + assert_eq!(h.value(), None); + } + + #[test] + fn first_bar_seeds_without_signal() { + let mut h = HaramiCross::new(); + assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0)); + assert!(h.update(doji(105.0)).is_some()); + } + + #[test] + fn bullish_harami_cross() { + // prior big bearish body [100, 110]; doji centred at 105 inside it -> +1. + let mut h = HaramiCross::new(); + h.update(solid(110.0, 100.0)); + assert_eq!(h.update(doji(105.0)), Some(1.0)); + } + + #[test] + fn bearish_harami_cross() { + // prior big bullish body [100, 110]; doji inside -> -1. + let mut h = HaramiCross::new(); + h.update(solid(100.0, 110.0)); + assert_eq!(h.update(doji(105.0)), Some(-1.0)); + } + + #[test] + fn doji_outside_body_is_zero() { + let mut h = HaramiCross::new(); + h.update(solid(110.0, 100.0)); + // doji centred at 120, outside the prior body -> 0. + assert_eq!(h.update(doji(120.0)), Some(0.0)); + } + + #[test] + fn non_doji_second_is_zero() { + let mut h = HaramiCross::new(); + h.update(solid(110.0, 100.0)); + assert_eq!(h.update(solid(104.0, 106.0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut h = HaramiCross::new(); + h.update(solid(110.0, 100.0)); + h.update(doji(105.0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + if i % 2 == 0 { + solid(110.0, 100.0) + } else { + doji(105.0) + } + }) + .collect(); + let batch = HaramiCross::new().batch(&candles); + let mut b = HaramiCross::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/hasbrouck_information_share.rs b/crates/wickra-core/src/indicators/hasbrouck_information_share.rs new file mode 100644 index 0000000..a7ea3d1 --- /dev/null +++ b/crates/wickra-core/src/indicators/hasbrouck_information_share.rs @@ -0,0 +1,265 @@ +//! Hasbrouck Information Share — each venue's contribution to price discovery. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Hasbrouck Information Share — the share of price-discovery attributable to the +/// **first** of two synchronised price series (e.g. the same asset on two venues). +/// +/// ```text +/// rx_t = x_t − x_{t−1}, ry_t = y_t − y_{t−1} (one-step price changes) +/// IS_x = var(rx) / ( var(rx) + var(ry) ) over the window, ∈ [0, 1] +/// ``` +/// +/// When the same instrument trades on several venues, Joel Hasbrouck's information +/// share measures how much each venue contributes to the common efficient price. +/// The venue whose innovations carry more of the variance leads price discovery. +/// This streaming form uses the **variance-ratio proxy**: the fraction of total +/// return variance contributed by series `x`. A reading above `0.5` means venue +/// `x` is the price leader; below `0.5`, the follower. (The full Hasbrouck measure +/// estimates a vector error-correction model and reports an upper/lower bound from +/// the Cholesky ordering; this proxy captures the leading idea without the VECM.) +/// +/// The output is in `[0, 1]`; if both series are flat it reports the neutral `0.5`. +/// The first value lands after `period + 1` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HasbrouckInformationShare}; +/// +/// let mut indicator = HasbrouckInformationShare::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// // Venue x moves a lot, venue y barely moves -> x leads. +/// let x = (f64::from(i) * 0.5).sin() * 10.0; +/// let y = (f64::from(i) * 0.5).sin() * 1.0; +/// last = indicator.update((x, y)); +/// } +/// assert!(last.unwrap() > 0.8); +/// ``` +#[derive(Debug, Clone)] +pub struct HasbrouckInformationShare { + period: usize, + prev: Option<(f64, f64)>, + window: VecDeque<(f64, f64)>, + sum_x: f64, + sum_y: f64, + sum_xx: f64, + sum_yy: f64, +} + +impl HasbrouckInformationShare { + /// Construct a Hasbrouck information share over `period` return pairs. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs two + /// returns). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "information share needs period >= 2", + }); + } + Ok(Self { + period, + prev: None, + window: VecDeque::with_capacity(period), + sum_x: 0.0, + sum_y: 0.0, + sum_xx: 0.0, + sum_yy: 0.0, + }) + } + + /// Configured window of return pairs. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for HasbrouckInformationShare { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } + let Some((px, py)) = self.prev else { + self.prev = Some((x, y)); + return None; + }; + self.prev = Some((x, y)); + let (rx, ry) = (x - px, y - py); + if self.window.len() == self.period { + let (ox, oy) = self.window.pop_front().expect("non-empty"); + self.sum_x -= ox; + self.sum_y -= oy; + self.sum_xx -= ox * ox; + self.sum_yy -= oy * oy; + } + self.window.push_back((rx, ry)); + self.sum_x += rx; + self.sum_y += ry; + self.sum_xx += rx * rx; + self.sum_yy += ry * ry; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let var_x = (self.sum_xx / n - (self.sum_x / n).powi(2)).max(0.0); + let var_y = (self.sum_yy / n - (self.sum_y / n).powi(2)).max(0.0); + let total = var_x + var_y; + Some(if total > 0.0 { var_x / total } else { 0.5 }) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum_x = 0.0; + self.sum_y = 0.0; + self.sum_xx = 0.0; + self.sum_yy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "HasbrouckInformationShare" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + HasbrouckInformationShare::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(HasbrouckInformationShare::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let h = HasbrouckInformationShare::new(20).unwrap(); + assert_eq!(h.period(), 20); + assert_eq!(h.warmup_period(), 21); + assert_eq!(h.name(), "HasbrouckInformationShare"); + assert!(!h.is_ready()); + } + + #[test] + fn warmup_needs_period_plus_one() { + let mut h = HasbrouckInformationShare::new(3).unwrap(); + assert_eq!(h.update((1.0, 1.0)), None); + assert_eq!(h.update((2.0, 2.0)), None); + assert_eq!(h.update((3.0, 2.5)), None); + assert!(h.update((4.0, 3.0)).is_some()); + } + + #[test] + fn loud_venue_leads() { + // x is far more volatile than y -> x holds nearly all the share. + let pairs: Vec<(f64, f64)> = (0..40) + .map(|i| { + ( + (f64::from(i) * 0.5).sin() * 10.0, + (f64::from(i) * 0.5).sin() * 1.0, + ) + }) + .collect(); + let last = HasbrouckInformationShare::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last > 0.8, "the loud venue should lead, got {last}"); + } + + #[test] + fn equal_venues_split_evenly() { + // Independent but equal-variance moves -> share near 0.5. + let pairs: Vec<(f64, f64)> = (0..200) + .map(|i| { + ( + (f64::from(i) * 0.5).sin() * 5.0, + (f64::from(i) * 0.5).cos() * 5.0, + ) + }) + .collect(); + for v in HasbrouckInformationShare::new(40) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + { + assert!((0.0..=1.0).contains(&v)); + } + } + + #[test] + fn flat_series_is_half() { + let pairs: Vec<(f64, f64)> = (0..20).map(|_| (7.0, 9.0)).collect(); + let last = HasbrouckInformationShare::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut h = HasbrouckInformationShare::new(4).unwrap(); + h.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..120) + .map(|i| { + let t = f64::from(i); + (t.sin() * 5.0, (t * 0.5).cos() * 3.0) + }) + .collect(); + let batch = HasbrouckInformationShare::new(20).unwrap().batch(&pairs); + let mut h = HasbrouckInformationShare::new(20).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut h = HasbrouckInformationShare::new(2).unwrap(); + assert_eq!(h.update((f64::NAN, 1.0)), None); + assert_eq!(h.update((1.0, f64::INFINITY)), None); + // First finite tick seeds prev; two more returns fill the window. + assert_eq!(h.update((1.0, 1.0)), None); + assert_eq!(h.update((2.0, 3.0)), None); + assert!(h.update((3.0, 4.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/head_and_shoulders.rs b/crates/wickra-core/src/indicators/head_and_shoulders.rs new file mode 100644 index 0000000..24d536c --- /dev/null +++ b/crates/wickra-core/src/indicators/head_and_shoulders.rs @@ -0,0 +1,191 @@ +//! Head-and-Shoulders (and Inverse) reversal chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Head-and-Shoulders / Inverse Head-and-Shoulders — a five-pivot reversal +/// pattern with a central extreme (the head) flanked by two lower/higher +/// shoulders at a similar level, joined by a roughly horizontal neckline. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised on the +/// bar that confirms the right shoulder: +/// +/// ```text +/// head-and-shoulders top (bearish, -1): +/// LeftShoulder(high) , Trough , Head(high) , Trough , RightShoulder(high) +/// Head > both shoulders ; LeftShoulder ≈ RightShoulder ; Trough₁ ≈ Trough₂ +/// +/// inverse head-and-shoulders (bullish, +1): +/// LeftShoulder(low) , Peak , Head(low) , Peak , RightShoulder(low) +/// Head < both shoulders ; LeftShoulder ≈ RightShoulder ; Peak₁ ≈ Peak₂ +/// ``` +/// +/// The shoulders must match within [`LEVEL_TOLERANCE`] (3%) and the two neckline +/// points within the same tolerance. Output is `-1.0` for a top, `+1.0` for an +/// inverse, `0.0` otherwise; never `None`. +#[derive(Debug, Clone)] +pub struct HeadAndShoulders { + swing: SwingTracker, + has_emitted: bool, +} + +impl HeadAndShoulders { + /// Construct a new Head-and-Shoulders detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for HeadAndShoulders { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for HeadAndShoulders { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let n = pivots.len(); + let left_shoulder = pivots[n - 5]; + let neck_1 = pivots[n - 4]; + let head = pivots[n - 3]; + let neck_2 = pivots[n - 2]; + let right_shoulder = pivots[n - 1]; + + let shoulders_match = + approx_equal(left_shoulder.price, right_shoulder.price, LEVEL_TOLERANCE); + let neckline_flat = approx_equal(neck_1.price, neck_2.price, LEVEL_TOLERANCE); + let head_is_peak = head.price > left_shoulder.price && head.price > right_shoulder.price; + let head_is_trough = head.price < left_shoulder.price && head.price < right_shoulder.price; + let frame_matches = shoulders_match && neckline_flat; + + if right_shoulder.direction > 0.0 { + // Head-and-shoulders top: head is the highest of the three highs. + if head_is_peak && frame_matches { + return Some(-1.0); + } + } else if head_is_trough && frame_matches { + // Inverse: head is the lowest of the three lows. + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Five confirmed pivots; the earliest confirmation of the fifth is bar 6. + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HeadAndShoulders" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = HeadAndShoulders::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = HeadAndShoulders::new(); + assert_eq!(indicator.name(), "HeadAndShoulders"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!HeadAndShoulders::default().is_ready()); + } + + #[test] + fn head_and_shoulders_top_is_minus_one() { + // LS 100, trough 90, head 120, trough 92, RS 101. + let out = run(&[100.0, 90.0, 120.0, 92.0, 101.0]); + assert_eq!(*out.last().unwrap(), -1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn inverse_head_and_shoulders_is_plus_one() { + // Lead high then LS 100, peak 110, head 80, peak 108, RS 101. + let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 101.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn mismatched_shoulders_do_not_trigger() { + // Right shoulder (115) far from left (100) → no pattern. + let out = run(&[100.0, 90.0, 130.0, 92.0, 115.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn inverse_mismatched_shoulders_do_not_trigger() { + // Inverse shape (ends on a low) but the right shoulder (90) diverges from + // the left (100) → enters the inverse branch yet reports no pattern. + let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 90.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn equal_highs_without_taller_head_do_not_trigger() { + // Three equal highs (no dominant head) → not H&S (that is a triple top). + let out = run(&[120.0, 90.0, 120.0, 92.0, 120.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = HeadAndShoulders::new(); + for c in candles_for_pivots(&[100.0, 90.0, 120.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[100.0, 90.0, 120.0, 92.0, 101.0]); + let mut a = HeadAndShoulders::new(); + let mut b = HeadAndShoulders::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/heikin_ashi.rs b/crates/wickra-core/src/indicators/heikin_ashi.rs new file mode 100644 index 0000000..ded483d --- /dev/null +++ b/crates/wickra-core/src/indicators/heikin_ashi.rs @@ -0,0 +1,198 @@ +//! Heikin-Ashi candle transform. +#![allow(clippy::manual_midpoint)] +//! +//! Heikin-Ashi ("average bar" in Japanese) smooths an OHLC candle stream so +//! trends are easier to read at a glance. The transform is purely local except +//! that `ha_open` depends on the *previous* Heikin-Ashi candle, so it remains +//! a streaming O(1) state machine. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// One Heikin-Ashi candle. +/// +/// Fields use the same names as the source `Candle` but represent the +/// transformed OHLC. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HeikinAshiOutput { + /// Heikin-Ashi open: midpoint of the previous Heikin-Ashi open and close. + pub open: f64, + /// Heikin-Ashi high: `max(real high, ha_open, ha_close)`. + pub high: f64, + /// Heikin-Ashi low: `min(real low, ha_open, ha_close)`. + pub low: f64, + /// Heikin-Ashi close: average of the real open, high, low, close. + pub close: f64, +} + +/// Streaming Heikin-Ashi transform. +/// +/// Emits a [`HeikinAshiOutput`] for every input bar starting with the very +/// first, so `warmup_period` is 1 and `batch` returns `n` outputs for `n` +/// inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HeikinAshi, Indicator}; +/// +/// let mut ha = HeikinAshi::new(); +/// let c = Candle::new(10.0, 11.0, 9.0, 10.5, 0.0, 0).unwrap(); +/// let out = ha.update(c).unwrap(); +/// // First bar: ha_open = (open + close) / 2 = 10.25. +/// assert!((out.open - 10.25).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HeikinAshi { + prev: Option, +} + +impl HeikinAshi { + /// Construct a fresh transform with no prior state. + #[must_use] + pub const fn new() -> Self { + Self { prev: None } + } + + /// Most recently emitted Heikin-Ashi candle, if any. + pub const fn value(&self) -> Option { + self.prev + } +} + +impl Indicator for HeikinAshi { + type Input = Candle; + type Output = HeikinAshiOutput; + + fn update(&mut self, candle: Candle) -> Option { + let ha_close = (candle.open + candle.high + candle.low + candle.close) / 4.0; + let ha_open = match self.prev { + Some(p) => f64::midpoint(p.open, p.close), + // Seed: average of the real open and close. + None => f64::midpoint(candle.open, candle.close), + }; + let ha_high = candle.high.max(ha_open).max(ha_close); + let ha_low = candle.low.min(ha_open).min(ha_close); + let out = HeikinAshiOutput { + open: ha_open, + high: ha_high, + low: ha_low, + close: ha_close, + }; + self.prev = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev.is_some() + } + + fn name(&self) -> &'static str { + "HeikinAshi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn cnd(o: f64, h: f64, l: f64, c: f64) -> Candle { + Candle::new(o, h, l, c, 0.0, 0).unwrap() + } + + #[test] + fn first_bar_seeds_open_from_real_open_close() { + let mut ha = HeikinAshi::new(); + let out = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap(); + assert_relative_eq!(out.open, (10.0 + 11.0) / 2.0, epsilon = 1e-12); + assert_relative_eq!(out.close, (10.0 + 12.0 + 9.0 + 11.0) / 4.0, epsilon = 1e-12); + // high/low must envelope ha_open & ha_close along with the real H/L. + assert!(out.high >= out.open); + assert!(out.high >= out.close); + assert!(out.low <= out.open); + assert!(out.low <= out.close); + } + + #[test] + fn second_bar_uses_previous_ha_midpoint_as_open() { + let mut ha = HeikinAshi::new(); + let first = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap(); + let second = ha.update(cnd(11.5, 13.0, 10.5, 12.0)).unwrap(); + assert_relative_eq!( + second.open, + (first.open + first.close) / 2.0, + epsilon = 1e-12 + ); + assert_relative_eq!( + second.close, + (11.5 + 13.0 + 10.5 + 12.0) / 4.0, + epsilon = 1e-12 + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| { + let p = 100.0 + f64::from(i); + cnd(p, p + 1.5, p - 1.5, p + 0.5) + }) + .collect(); + let mut a = HeikinAshi::new(); + let mut b = HeikinAshi::new(); + let batched = a.batch(&candles); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batched, streamed); + } + + #[test] + fn ready_after_first_update() { + let mut ha = HeikinAshi::new(); + assert!(!ha.is_ready()); + ha.update(cnd(10.0, 11.0, 9.0, 10.5)); + assert!(ha.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut ha = HeikinAshi::new(); + ha.update(cnd(10.0, 11.0, 9.0, 10.5)); + assert!(ha.is_ready()); + ha.reset(); + assert!(!ha.is_ready()); + assert!(ha.value().is_none()); + // After reset, the next bar re-seeds from real open/close. + let out = ha.update(cnd(20.0, 22.0, 18.0, 21.0)).unwrap(); + assert_relative_eq!(out.open, (20.0 + 21.0) / 2.0, epsilon = 1e-12); + } + + #[test] + fn metadata() { + let ha = HeikinAshi::new(); + assert_eq!(ha.warmup_period(), 1); + assert_eq!(ha.name(), "HeikinAshi"); + } + + #[test] + fn high_envelopes_open_and_close() { + // Real high below the synthetic ha_open/close still inflates ha_high. + let mut ha = HeikinAshi::new(); + // Bar 1 sets a baseline. + ha.update(cnd(100.0, 101.0, 99.0, 100.5)); + // Bar 2 with an extreme close — ha_close = (50+50+50+200)/4 = 87.5, + // ha_open = midpoint of prev open/close — and a real high of 200. + let out = ha.update(cnd(50.0, 200.0, 50.0, 200.0)).unwrap(); + assert_eq!(out.high, 200.0); + assert!(out.low <= out.open.min(out.close)); + } +} diff --git a/crates/wickra-core/src/indicators/heikin_ashi_oscillator.rs b/crates/wickra-core/src/indicators/heikin_ashi_oscillator.rs new file mode 100644 index 0000000..5d5c08f --- /dev/null +++ b/crates/wickra-core/src/indicators/heikin_ashi_oscillator.rs @@ -0,0 +1,231 @@ +//! Heikin-Ashi Oscillator — the (smoothed) Heikin-Ashi candle body as a zero-line oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::heikin_ashi::HeikinAshi; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Heikin-Ashi Oscillator — the body of the [`HeikinAshi`](crate::HeikinAshi) +/// candle (`ha_close − ha_open`), optionally EMA-smoothed, as an oscillator around +/// zero. +/// +/// ```text +/// body = ha_close − ha_open +/// HAO = EMA(body, period) +/// ``` +/// +/// A Heikin-Ashi candle is bullish when its close is above its open and bearish +/// when below; the size of that body measures conviction. Plotting the body as an +/// oscillator turns the visual HA colour/strength into a number: positive = +/// bullish HA candles, negative = bearish, and the magnitude is trend strength. +/// Smoothing the body with an EMA (`period`) damps single-bar noise so zero-line +/// crosses mark cleaner trend changes. With `period == 1` the oscillator is the raw +/// HA body. +/// +/// The output is centred on zero (price units). The first value lands after +/// `period` inputs (the HA transform itself needs only one). Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, HeikinAshiOscillator}; +/// +/// let mut indicator = HeikinAshiOscillator::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HeikinAshiOscillator { + period: usize, + ha: HeikinAshi, + ema: Ema, + last: Option, +} + +impl HeikinAshiOscillator { + /// Construct a Heikin-Ashi Oscillator with the given EMA smoothing `period` + /// (use `1` for the raw body). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + ha: HeikinAshi::new(), + ema: Ema::new(period)?, + last: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for HeikinAshiOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let ha = self.ha.update(candle).expect("HeikinAshi emits every bar"); + let body = ha.close - ha.open; + let v = self.ema.update(body)?; + self.last = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.ha.reset(); + self.ema.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "HeikinAshiOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(open, high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + HeikinAshiOscillator::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let h = HeikinAshiOscillator::new(5).unwrap(); + assert_eq!(h.period(), 5); + assert_eq!(h.warmup_period(), 5); + assert_eq!(h.name(), "HeikinAshiOscillator"); + assert!(!h.is_ready()); + assert_eq!(h.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut h = HeikinAshiOscillator::new(3).unwrap(); + let candles: Vec = (0..6) + .map(|i| { + let b = 100.0 + f64::from(i); + c(b, b + 1.0, b - 1.0, b + 0.5) + }) + .collect(); + let out = h.batch(&candles); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn uptrend_is_positive() { + let mut h = HeikinAshiOscillator::new(3).unwrap(); + let candles: Vec = (0..40) + .map(|i| { + let b = 100.0 + 2.0 * f64::from(i); + c(b, b + 1.0, b - 1.0, b + 1.5) + }) + .collect(); + let last = h.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last > 0.0, + "uptrend should give a positive HA body, got {last}" + ); + } + + #[test] + fn downtrend_is_negative() { + let mut h = HeikinAshiOscillator::new(3).unwrap(); + let candles: Vec = (0..40) + .map(|i| { + let b = 200.0 - 2.0 * f64::from(i); + c(b, b + 1.0, b - 1.0, b - 1.5) + }) + .collect(); + let last = h.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last < 0.0, + "downtrend should give a negative HA body, got {last}" + ); + } + + #[test] + fn flat_market_near_zero() { + let mut h = HeikinAshiOscillator::new(3).unwrap(); + let last = h + .batch(&[c(100.0, 100.5, 99.5, 100.0); 30]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut h = HeikinAshiOscillator::new(3).unwrap(); + h.batch( + &(0..10) + .map(|i| { + let b = 100.0 + f64::from(i); + c(b, b + 1.0, b - 1.0, b) + }) + .collect::>(), + ); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.value(), None); + assert_eq!(h.update(c(100.0, 101.0, 99.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + c(b, b + 1.0, b - 1.0, b + 0.3) + }) + .collect(); + let batch = HeikinAshiOscillator::new(5).unwrap().batch(&candles); + let mut b = HeikinAshiOscillator::new(5).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/high_low_index.rs b/crates/wickra-core/src/indicators/high_low_index.rs new file mode 100644 index 0000000..d426583 --- /dev/null +++ b/crates/wickra-core/src/indicators/high_low_index.rs @@ -0,0 +1,162 @@ +//! High-Low Index — a moving average of the record-high percentage. + +use crate::cross_section::CrossSection; +use crate::error::Result; +use crate::traits::Indicator; +use crate::Sma; + +/// High-Low Index — a simple moving average of the *record high percent*, +/// `100 * new_highs / (new_highs + new_lows)`. +/// +/// The record high percent is the share of new-extreme issues that are new +/// *highs* rather than new *lows*; smoothing it over a window (the classic period +/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate +/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The +/// 30 and 70 lines are watched as oversold / overbought breadth thresholds. +/// +/// Each tick floors the new-extreme count to one, so a tick with no new highs or +/// lows contributes a defined `0.0` instead of dividing by zero. The reading is +/// `None` until `period` ticks have been seen. +/// +/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`), +/// `warmup_period == period`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member}; +/// +/// let mut hli = HighLowIndex::new(2).unwrap(); +/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap(); +/// assert_eq!(hli.update(highs.clone()), None); // warming up +/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs +/// ``` +#[derive(Debug, Clone)] +pub struct HighLowIndex { + sma: Sma, +} + +impl HighLowIndex { + /// Construct a new High-Low Index over the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + sma: Sma::new(period)?, + }) + } + + /// Configured window length. + #[must_use] + pub const fn period(&self) -> usize { + self.sma.period() + } +} + +impl Indicator for HighLowIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let new_highs = section.new_highs(); + let new_lows = section.new_lows(); + let extremes = (new_highs + new_lows).max(1) as f64; + let record_high_percent = 100.0 * new_highs as f64 / extremes; + self.sma.update(record_high_percent) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma.period() + } + + fn is_ready(&self) -> bool { + self.sma.value().is_some() + } + + fn name(&self) -> &'static str { + "HighLowIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::error::Error; + use crate::traits::BatchExt; + + fn flags(highs: usize, lows: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..highs { + members.push(Member::new(1.0, 10.0, true, false)); + } + for _ in 0..lows { + members.push(Member::new(-1.0, 10.0, false, true)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let hli = HighLowIndex::new(10).unwrap(); + assert_eq!(hli.name(), "HighLowIndex"); + assert_eq!(hli.warmup_period(), 10); + assert_eq!(hli.period(), 10); + assert!(!hli.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn averages_the_record_high_percent() { + let mut hli = HighLowIndex::new(2).unwrap(); + // 8 highs / 10 extremes -> 80% ; window not full. + assert_eq!(hli.update(flags(8, 2)), None); + // 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70. + let value = hli.update(flags(6, 4)).unwrap(); + assert!((value - 70.0).abs() < 1e-9); + assert!(hli.is_ready()); + } + + #[test] + fn no_extremes_floors_to_zero_percent() { + let mut hli = HighLowIndex::new(1).unwrap(); + // No new highs or lows -> 0 / max(0, 1) -> 0%. + assert_eq!(hli.update(flags(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut hli = HighLowIndex::new(2).unwrap(); + hli.update(flags(8, 2)); + hli.update(flags(6, 4)); + assert!(hli.is_ready()); + hli.reset(); + assert!(!hli.is_ready()); + assert_eq!(hli.update(flags(8, 2)), None); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)]; + let mut a = HighLowIndex::new(2).unwrap(); + let mut b = HighLowIndex::new(2).unwrap(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/high_low_range.rs b/crates/wickra-core/src/indicators/high_low_range.rs new file mode 100644 index 0000000..2f3fc53 --- /dev/null +++ b/crates/wickra-core/src/indicators/high_low_range.rs @@ -0,0 +1,174 @@ +//! High-Low Range — the bar range as a fraction of close. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// High-Low Range — the bar's high-low range expressed as a fraction of its +/// close price. +/// +/// ```text +/// HighLowRange = (high − low) / close +/// ``` +/// +/// A scale-free, single-bar volatility proxy: the absolute range `high − low` +/// grows with the nominal price level, so dividing by the close makes a `2$` +/// range on a `100$` instrument (`0.02`) directly comparable to a `200$` range +/// on a `10000$` one (`0.02`). It is the per-bar cousin of average-true-range +/// style measures without the smoothing — useful as an instant intrabar +/// volatility read or a normaliser for other features. The output is `≥ 0` +/// for positive prices. A zero close carries no scale and yields `0`. +/// +/// This is a stateless per-bar transform: every candle produces one value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, HighLowRange}; +/// +/// let mut indicator = HighLowRange::new(); +/// // range 104 - 98 = 6, close 100 -> 0.06. +/// let c = Candle::new(99.0, 104.0, 98.0, 100.0, 10.0, 0).unwrap(); +/// assert!((indicator.update(c).unwrap() - 0.06).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HighLowRange { + has_emitted: bool, +} + +impl HighLowRange { + /// Construct a new High-Low Range transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for HighLowRange { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let out = if candle.close == 0.0 { + // A zero close carries no scale to normalise the range against. + 0.0 + } else { + (candle.high - candle.low) / candle.close + }; + Some(out) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HighLowRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (104 - 98) / 100 = 0.06. + let mut hlr = HighLowRange::new(); + assert_relative_eq!( + hlr.update(candle(99.0, 104.0, 98.0, 100.0, 0)).unwrap(), + 0.06, + epsilon = 1e-12 + ); + } + + #[test] + fn zero_range_bar_yields_zero() { + // high == low -> range 0 -> 0 regardless of close. + let mut hlr = HighLowRange::new(); + assert_relative_eq!( + hlr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn zero_close_yields_zero() { + // Candle permits a zero close (only finiteness + OHLC ordering checked): + // open 0, high 1, low 0, close 0 satisfies high >= all, low <= all. + let mut hlr = HighLowRange::new(); + assert_relative_eq!( + hlr.update(candle(0.0, 1.0, 0.0, 0.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn output_is_non_negative() { + let candles: Vec = (0..100) + .map(|i| { + let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0; + candle(mid, mid + 3.0, mid - 3.0, mid, i64::from(i)) + }) + .collect(); + let mut hlr = HighLowRange::new(); + for v in hlr.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "HighLowRange {v} must be non-negative"); + } + } + + #[test] + fn name_metadata() { + let hlr = HighLowRange::new(); + assert_eq!(hlr.name(), "HighLowRange"); + } + + #[test] + fn emits_from_first_candle() { + let mut hlr = HighLowRange::new(); + assert_eq!(hlr.warmup_period(), 1); + assert!(!hlr.is_ready()); + assert!(hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(hlr.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut hlr = HighLowRange::new(); + hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(hlr.is_ready()); + hlr.reset(); + assert!(!hlr.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i)) + }) + .collect(); + let mut a = HighLowRange::new(); + let mut b = HighLowRange::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/high_low_volume_nodes.rs b/crates/wickra-core/src/indicators/high_low_volume_nodes.rs new file mode 100644 index 0000000..72cd296 --- /dev/null +++ b/crates/wickra-core/src/indicators/high_low_volume_nodes.rs @@ -0,0 +1,307 @@ +//! High/Low Volume Nodes (HVN / LVN) — the busiest and quietest price levels. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`HighLowVolumeNodes`]: the price of the highest- and lowest-volume +/// node in the profile. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HighLowVolumeNodesOutput { + /// High Volume Node — the price level (bin centre) with the most volume. + pub hvn: f64, + /// Low Volume Node — the traded price level with the least volume. + pub lvn: f64, +} + +/// High/Low Volume Nodes — the price levels of greatest and least acceptance in a +/// rolling volume profile. +/// +/// ```text +/// build a `bins`-bucket volume profile over the last `period` candles +/// HVN = bin centre of the bucket with the most volume +/// LVN = bin centre of the traded bucket with the least volume +/// ``` +/// +/// A volume profile reveals where the market spent the most effort. A **High Volume +/// Node** (HVN) is a price the market accepted and traded heavily — it acts as a +/// magnet and as strong support/resistance. A **Low Volume Node** (LVN) is a price +/// the market rejected quickly — moves tend to accelerate through LVNs and they +/// often mark the edges between balance areas. Each candle's volume is spread +/// across the price bins its high-low range spans (as in +/// [`VolumeProfile`](crate::VolumeProfile)). +/// +/// The first value lands after `period` candles; each `update` rebuilds the profile +/// in O(`period · bins`). A degenerate flat window puts both nodes at the price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, HighLowVolumeNodes}; +/// +/// let mut indicator = HighLowVolumeNodes::new(20, 24).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HighLowVolumeNodes { + period: usize, + bins: usize, + window: VecDeque, + last: Option, +} + +impl HighLowVolumeNodes { + /// Construct a High/Low Volume Nodes indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero. + pub fn new(period: usize, bins: usize) -> Result { + if period == 0 || bins == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bins, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, bins)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bins) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + /// Build the volume histogram; returns `(low, bin_width, bins)`. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn profile(&self) -> (f64, f64, Vec) { + let mut low = f64::INFINITY; + let mut high = f64::NEG_INFINITY; + for c in &self.window { + low = low.min(c.low); + high = high.max(c.high); + } + let mut hist = vec![0.0; self.bins]; + let span = high - low; + if span <= 0.0 { + hist[0] = self.window.iter().map(|c| c.volume).sum(); + return (low, 0.0, hist); + } + let width = span / self.bins as f64; + for c in &self.window { + if c.volume == 0.0 { + continue; + } + let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1); + let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1); + let touched = hi_idx - lo_idx + 1; + let share = c.volume / touched as f64; + for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *bin += share; + } + } + (low, width, hist) + } +} + +impl Indicator for HighLowVolumeNodes { + type Input = Candle; + type Output = HighLowVolumeNodesOutput; + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let (low, width, hist) = self.profile(); + let centre = |idx: usize| low + (idx as f64 + 0.5) * width; + + let mut hvn_idx = 0; + let mut hvn_vol = f64::NEG_INFINITY; + let mut lvn_idx = 0; + let mut lvn_vol = f64::INFINITY; + for (idx, &vol) in hist.iter().enumerate() { + if vol > hvn_vol { + hvn_vol = vol; + hvn_idx = idx; + } + if vol > 0.0 && vol < lvn_vol { + lvn_vol = vol; + lvn_idx = idx; + } + } + // If no traded bin was found (all zero volume), both default to bin 0. + if !lvn_vol.is_finite() { + lvn_idx = hvn_idx; + } + let out = HighLowVolumeNodesOutput { + hvn: centre(hvn_idx), + lvn: centre(lvn_idx), + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "HighLowVolumeNodes" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked( + f64::midpoint(high, low), + high, + low, + f64::midpoint(high, low), + volume, + 0, + ) + } + + #[test] + fn rejects_zero_params() { + assert!(matches!( + HighLowVolumeNodes::new(0, 24), + Err(Error::PeriodZero) + )); + assert!(matches!( + HighLowVolumeNodes::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let h = HighLowVolumeNodes::new(20, 24).unwrap(); + assert_eq!(h.params(), (20, 24)); + assert_eq!(h.warmup_period(), 20); + assert_eq!(h.name(), "HighLowVolumeNodes"); + assert!(!h.is_ready()); + assert_eq!(h.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut h = HighLowVolumeNodes::new(4, 8).unwrap(); + let candles: Vec = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect(); + let out = h.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn hvn_at_heavy_price() { + // Most bars cluster at ~100 (heavy volume); one bar pokes up to 120 lightly. + let mut h = HighLowVolumeNodes::new(6, 24).unwrap(); + let mut candles: Vec = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect(); + candles.push(c(121.0, 119.0, 100.0)); + let out = h.batch(&candles).into_iter().flatten().last().unwrap(); + // HVN should sit near the heavy 100 cluster, well below the light 120 poke. + assert!( + out.hvn < 110.0, + "HVN should be at the heavy cluster, got {}", + out.hvn + ); + assert!(out.lvn >= out.hvn - 1e9); // lvn is a valid level + } + + #[test] + fn hvn_at_or_above_low() { + let mut h = HighLowVolumeNodes::new(10, 24).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.3).sin() * 5.0, + 90.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + for o in h.batch(&candles).into_iter().flatten() { + assert!(o.hvn.is_finite() && o.lvn.is_finite()); + } + } + + #[test] + fn reset_clears_state() { + let mut h = HighLowVolumeNodes::new(4, 8).unwrap(); + h.batch(&[c(110.0, 90.0, 1_000.0); 6]); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.value(), None); + assert_eq!(h.update(c(110.0, 90.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.25).sin() * 9.0, + 90.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = HighLowVolumeNodes::new(20, 24).unwrap().batch(&candles); + let mut b = HighLowVolumeNodes::new(20, 24).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn flat_window_is_handled() { + // Zero high-low span dumps all volume into bin 0 and returns early. + let mut h = HighLowVolumeNodes::new(2, 4).unwrap(); + h.update(c(50.0, 50.0, 10.0)); + assert!(h.update(c(50.0, 50.0, 10.0)).is_some()); + } + + #[test] + fn zero_volume_window_falls_back() { + // All-zero volume leaves no traded bin; the LVN falls back to the HVN. + let mut h = HighLowVolumeNodes::new(2, 4).unwrap(); + h.update(c(60.0, 40.0, 0.0)); + let out = h.update(c(60.0, 40.0, 0.0)).unwrap(); + assert_eq!(out.hvn, out.lvn); + } +} diff --git a/crates/wickra-core/src/indicators/high_wave.rs b/crates/wickra-core/src/indicators/high_wave.rs new file mode 100644 index 0000000..c67594c --- /dev/null +++ b/crates/wickra-core/src/indicators/high_wave.rs @@ -0,0 +1,160 @@ +//! High-Wave candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// High-Wave — a single-bar extreme-indecision signal. A small body with very +/// long shadows on *both* sides: price swung far up and far down yet finished +/// near the open, a sign that trend conviction has evaporated. +/// +/// ```text +/// range = high − low +/// long upper = high − max(open, close) >= 0.4 * range +/// long lower = min(open, close) − low >= 0.4 * range +/// ``` +/// +/// The two long-shadow conditions force the body below `0.2 * range`, so no +/// separate body test is needed. Output is `+1.0` when the high-wave prints and +/// `0.0` otherwise — a non-directional indecision flag, it never emits `−1.0`. +/// Shadow thresholds follow the geometric house style rather than TA-Lib's +/// rolling averages. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` detected, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HighWave, Indicator}; +/// +/// let mut indicator = HighWave::new(); +/// // Small body, long shadows both sides. +/// let candle = Candle::new(10.0, 12.0, 8.0, 10.3, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HighWave { + has_emitted: bool, +} + +impl HighWave { + /// Construct a new High-Wave detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for HighWave { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if upper >= 0.4 * range && lower >= 0.4 * range { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HighWave" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = HighWave::new(); + assert_eq!(t.name(), "HighWave"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn high_wave_is_plus_one() { + let mut t = HighWave::new(); + assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.3, 0)), Some(1.0)); + } + + #[test] + fn short_upper_shadow_yields_zero() { + let mut t = HighWave::new(); + // Long lower shadow but short upper -> not a high-wave. + assert_eq!(t.update(c(11.5, 12.0, 8.0, 11.7, 0)), Some(0.0)); + } + + #[test] + fn short_lower_shadow_yields_zero() { + let mut t = HighWave::new(); + // Long upper shadow but short lower -> not a high-wave. + assert_eq!(t.update(c(8.3, 12.0, 8.0, 8.5, 0)), Some(0.0)); + } + + #[test] + fn big_body_yields_zero() { + let mut t = HighWave::new(); + // A large body cannot leave both shadows long. + assert_eq!(t.update(c(8.5, 12.0, 8.0, 11.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = HighWave::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 3.0, base - 3.0, base + 0.2, i) + }) + .collect(); + let mut a = HighWave::new(); + let mut b = HighWave::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = HighWave::new(); + t.update(c(10.0, 12.0, 8.0, 10.3, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/highpass_filter.rs b/crates/wickra-core/src/indicators/highpass_filter.rs new file mode 100644 index 0000000..9db140e --- /dev/null +++ b/crates/wickra-core/src/indicators/highpass_filter.rs @@ -0,0 +1,215 @@ +//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles. +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price +/// series, leaving the higher-frequency cyclic and noise content. +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013): +/// +/// ```text +/// a = 0.707 · 2π / period +/// alpha1 = (cos(a) + sin(a) − 1) / cos(a) +/// HP_t = (1 − alpha1/2)² · (price_t − 2·price_{t−1} + price_{t−2}) +/// + 2·(1 − alpha1)·HP_{t−1} − (1 − alpha1)²·HP_{t−2} +/// ``` +/// +/// A highpass filter is the complement of a smoother: where a lowpass keeps the +/// trend, the highpass keeps everything *faster* than the cutoff `period`. The +/// two-pole design gives a steep roll-off so frequencies below the cutoff are +/// firmly removed, detrending the series into a zero-mean wave. This differs from +/// the [`Decycler`](crate::Decycler), which is `price − highpass` (the *trend* that +/// remains); the highpass is the cyclic part that the decycler discards. +/// +/// The recursion needs two prior prices and two prior outputs; until then it emits +/// `0`, so `warmup_period` is `1`. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HighpassFilter}; +/// +/// let mut indicator = HighpassFilter::new(48).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin() * 3.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HighpassFilter { + period: usize, + alpha1: f64, + prev_price_1: Option, + prev_price_2: Option, + hp1: f64, + hp2: f64, + last: Option, +} + +impl HighpassFilter { + /// Construct a two-pole highpass filter with the given cutoff `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let a = 0.707 * 2.0 * PI / period as f64; + let alpha1 = (a.cos() + a.sin() - 1.0) / a.cos(); + Ok(Self { + period, + alpha1, + prev_price_1: None, + prev_price_2: None, + hp1: 0.0, + hp2: 0.0, + last: None, + }) + } + + /// Configured cutoff period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for HighpassFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let hp = match (self.prev_price_1, self.prev_price_2) { + (Some(p1), Some(p2)) => { + let one_minus = 1.0 - self.alpha1; + let half = 1.0 - self.alpha1 / 2.0; + half * half * (price - 2.0 * p1 + p2) + 2.0 * one_minus * self.hp1 + - one_minus * one_minus * self.hp2 + } + _ => 0.0, + }; + self.prev_price_2 = self.prev_price_1; + self.prev_price_1 = Some(price); + self.hp2 = self.hp1; + self.hp1 = hp; + self.last = Some(hp); + Some(hp) + } + + fn reset(&mut self) { + self.prev_price_1 = None; + self.prev_price_2 = None; + self.hp1 = 0.0; + self.hp2 = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "HighpassFilter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(HighpassFilter::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let hp = HighpassFilter::new(48).unwrap(); + assert_eq!(hp.period(), 48); + assert_eq!(hp.warmup_period(), 1); + assert_eq!(hp.name(), "HighpassFilter"); + assert!(!hp.is_ready()); + assert_eq!(hp.value(), None); + } + + #[test] + fn first_bars_are_zero() { + let mut hp = HighpassFilter::new(48).unwrap(); + assert_eq!(hp.update(100.0), Some(0.0)); + assert_eq!(hp.update(101.0), Some(0.0)); + assert!(hp.is_ready()); + } + + #[test] + fn constant_input_stays_zero() { + let mut hp = HighpassFilter::new(48).unwrap(); + for v in hp.batch(&[50.0; 200]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn pure_trend_is_attenuated() { + // A straight ramp is low-frequency -> the highpass should drive its + // output small after warmup (the trend is removed). + let mut hp = HighpassFilter::new(20).unwrap(); + let out: Vec = hp + .batch(&(0..400).map(f64::from).collect::>()) + .into_iter() + .flatten() + .skip(200) + .collect(); + for v in out { + assert!(v.abs() < 5.0, "trend should be attenuated, got {v}"); + } + } + + #[test] + fn ignores_non_finite() { + let mut hp = HighpassFilter::new(48).unwrap(); + hp.batch(&(0..40).map(f64::from).collect::>()); + let before = hp.value(); + assert_eq!(hp.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut hp = HighpassFilter::new(48).unwrap(); + hp.batch(&(0..40).map(f64::from).collect::>()); + assert!(hp.is_ready()); + hp.reset(); + assert!(!hp.is_ready()); + assert_eq!(hp.update(100.0), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + f64::from(i) + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = HighpassFilter::new(48).unwrap().batch(&xs); + let mut b = HighpassFilter::new(48).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/hikkake.rs b/crates/wickra-core/src/indicators/hikkake.rs new file mode 100644 index 0000000..3c78314 --- /dev/null +++ b/crates/wickra-core/src/indicators/hikkake.rs @@ -0,0 +1,198 @@ +//! Hikkake candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hikkake — a 3-bar trap. An inside bar (bar2 fully contained by bar1) sets up a +/// breakout that immediately fails on bar3, trapping breakout traders and pointing +/// the opposite way. +/// +/// ```text +/// inside bar : bar2.high < bar1.high && bar2.low > bar1.low +/// bullish (+1.0): bar3 makes a LOWER high AND LOWER low than bar2 +/// (a false downside break -> expect a move up) +/// bearish (−1.0): bar3 makes a HIGHER high AND HIGHER low than bar2 +/// (a false upside break -> expect a move down) +/// ``` +/// +/// Output is `+1.0` (bullish setup), `−1.0` (bearish setup), or `0.0` otherwise. +/// The detector fires when the three-bar setup completes on bar3; it does not +/// separately flag the optional later confirmation bar. The first two bars always +/// return `0.0` because the window is not yet filled. Pattern-shape check only — +/// no trend filter is applied; combine with a trend indicator for actionable +/// signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish setups occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Hikkake, Indicator}; +/// +/// let mut indicator = Hikkake::new(); +/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(11.0, 13.0, 8.0, 12.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(9.0, 12.0, 6.0, 7.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Hikkake { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl Hikkake { + /// Construct a new Hikkake detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for Hikkake { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.prev_prev; + let bar2 = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + // bar2 must be an inside bar of bar1. + if !(bar2.high < bar1.high && bar2.low > bar1.low) { + return Some(0.0); + } + // Bullish: bar3 breaks below the inside bar (lower high and lower low). + if candle.high < bar2.high && candle.low < bar2.low { + return Some(1.0); + } + // Bearish: bar3 breaks above the inside bar (higher high and higher low). + if candle.high > bar2.high && candle.low > bar2.low { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Hikkake" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = Hikkake::new(); + assert_eq!(t.name(), "Hikkake"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_hikkake_is_plus_one() { + let mut t = Hikkake::new(); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(1.0)); + } + + #[test] + fn bearish_hikkake_is_minus_one() { + let mut t = Hikkake::new(); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(12.0, 14.0, 9.0, 13.0, 2)), Some(-1.0)); + } + + #[test] + fn not_inside_bar_yields_zero() { + let mut t = Hikkake::new(); + t.update(c(10.0, 15.0, 5.0, 12.0, 0)); + // bar2 is not contained by bar1 (higher high). + t.update(c(11.0, 16.0, 8.0, 12.0, 1)); + assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(0.0)); + } + + #[test] + fn outside_bar3_yields_zero() { + let mut t = Hikkake::new(); + t.update(c(10.0, 15.0, 5.0, 12.0, 0)); + t.update(c(11.0, 13.0, 8.0, 12.0, 1)); + // bar3 engulfs bar2 (higher high and lower low) -> neither direction. + assert_eq!(t.update(c(11.0, 14.0, 7.0, 9.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = Hikkake::new(); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + match i % 3 { + 0 => c(base, base + 6.0, base - 6.0, base, i), + 1 => c(base, base + 2.0, base - 2.0, base, i), + _ => c(base, base + 1.0, base - 5.0, base - 4.0, i), + } + }) + .collect(); + let mut a = Hikkake::new(); + let mut b = Hikkake::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Hikkake::new(); + t.update(c(10.0, 15.0, 5.0, 12.0, 0)); + t.update(c(11.0, 13.0, 8.0, 12.0, 1)); + t.update(c(9.0, 12.0, 6.0, 7.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/hikkake_modified.rs b/crates/wickra-core/src/indicators/hikkake_modified.rs new file mode 100644 index 0000000..e757800 --- /dev/null +++ b/crates/wickra-core/src/indicators/hikkake_modified.rs @@ -0,0 +1,196 @@ +//! Modified Hikkake candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Modified Hikkake — a close-confirmed variant of the [`Hikkake`](crate::Hikkake) +/// trap. An inside bar is followed by a bar that breaks out *and is immediately +/// rejected*: it pierces the inside bar's range intrabar but closes back inside, +/// a stronger signal than the plain breakout setup. +/// +/// ```text +/// inside bar : bar2.high < bar1.high && bar2.low > bar1.low +/// bullish (+1.0): bar3 makes a lower high AND lower low than bar2, +/// yet closes back above the inside-bar low (close3 > bar2.low) +/// bearish (−1.0): bar3 makes a higher high AND higher low than bar2, +/// yet closes back below the inside-bar high (close3 < bar2.high) +/// ``` +/// +/// Output is `+1.0` (bullish), `−1.0` (bearish), or `0.0` otherwise. The extra +/// close-recovery condition is what distinguishes it from the plain Hikkake, which +/// fires on the high/low break alone. The first two bars always return `0.0` +/// because the three-bar window is not yet filled. Pattern-shape check only — no +/// trend filter is applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HikkakeModified, Indicator}; +/// +/// let mut indicator = HikkakeModified::new(); +/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(11.0, 13.0, 8.0, 12.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(9.0, 12.0, 6.0, 9.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HikkakeModified { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl HikkakeModified { + /// Construct a new Modified Hikkake detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for HikkakeModified { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.prev_prev; + let bar2 = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + if !(bar2.high < bar1.high && bar2.low > bar1.low) { + return Some(0.0); + } + // Bullish: false downside break that closes back above the inside-bar low. + if candle.high < bar2.high && candle.low < bar2.low && candle.close > bar2.low { + return Some(1.0); + } + // Bearish: false upside break that closes back below the inside-bar high. + if candle.high > bar2.high && candle.low > bar2.low && candle.close < bar2.high { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HikkakeModified" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = HikkakeModified::new(); + assert_eq!(t.name(), "HikkakeModified"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_modified_hikkake_is_plus_one() { + let mut t = HikkakeModified::new(); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(9.0, 12.0, 6.0, 9.0, 2)), Some(1.0)); + } + + #[test] + fn bearish_modified_hikkake_is_minus_one() { + let mut t = HikkakeModified::new(); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(13.0, 14.0, 9.0, 10.0, 2)), Some(-1.0)); + } + + #[test] + fn break_without_close_recovery_yields_zero() { + let mut t = HikkakeModified::new(); + t.update(c(10.0, 15.0, 5.0, 12.0, 0)); + t.update(c(11.0, 13.0, 8.0, 12.0, 1)); + // Lower high and lower low, but closes below the inside-bar low -> plain + // Hikkake break, not the close-confirmed modified version. + assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(0.0)); + } + + #[test] + fn not_inside_bar_yields_zero() { + let mut t = HikkakeModified::new(); + t.update(c(10.0, 15.0, 5.0, 12.0, 0)); + t.update(c(11.0, 16.0, 8.0, 12.0, 1)); + assert_eq!(t.update(c(9.0, 12.0, 6.0, 9.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = HikkakeModified::new(); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + match i % 3 { + 0 => c(base, base + 6.0, base - 6.0, base, i), + 1 => c(base, base + 2.0, base - 2.0, base, i), + _ => c(base, base + 1.0, base - 5.0, base, i), + } + }) + .collect(); + let mut a = HikkakeModified::new(); + let mut b = HikkakeModified::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = HikkakeModified::new(); + t.update(c(10.0, 15.0, 5.0, 12.0, 0)); + t.update(c(11.0, 13.0, 8.0, 12.0, 1)); + t.update(c(9.0, 12.0, 6.0, 9.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs b/crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs new file mode 100644 index 0000000..1de7f9b --- /dev/null +++ b/crates/wickra-core/src/indicators/hilbert_dominant_cycle.rs @@ -0,0 +1,272 @@ +//! Ehlers Hilbert Transform Dominant Cycle period estimator. +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::traits::Indicator; + +/// Ehlers' Hilbert Transform–based Dominant Cycle period estimator. +/// +/// Decomposes price into in-phase and quadrature components via Ehlers' +/// truncated Hilbert transform, then derives the instantaneous phase. The +/// dominant cycle period is recovered from the phase rate of change and +/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7), +/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`. +/// +/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies +/// as the meaningful tradable cycle range. The estimator emits its first +/// value after ~50 inputs as the moving-average chain fills. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HilbertDominantCycle}; +/// +/// let mut ht = HilbertDominantCycle::new(); +/// let mut last = None; +/// for i in 0..200 { +/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HilbertDominantCycle { + // Rolling 7-tap smoother input buffer. + smooth_buf: Vec, + // Detrender / Q1 / I1 ring history (need 6 prior). + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + // Smoothed I/Q lines for phase computation. + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_smooth_period: f64, + count: usize, + last_value: Option, +} + +impl HilbertDominantCycle { + /// Construct a new dominant cycle estimator. + pub fn new() -> Self { + Self::default() + } + + /// Current period estimate if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for HilbertDominantCycle { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + // 4-bar weighted moving average of the input (smoothed price). + // Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10. + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 4 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + + // Adaptive coefficient based on the previous period estimate. + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + // We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps. + if self.smooth_buf.len() < 7 { + return None; + } + + // Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags). + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + + if self.detrender_buf.len() < 7 { + return None; + } + // In-phase and quadrature components. + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + // Advance the phase 90 deg via a second Hilbert pass. + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + // Phasor smoothing. + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + // Homodyne discriminator. + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + // Rate-of-change clamp per Ehlers. + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + + // EMA smoothing of the period. + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + // Second smoothing step (TA-Lib uses 0.33/0.67). + self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period; + + if self.count < 50 { + return None; + } + self.last_value = Some(self.prev_smooth_period); + Some(self.prev_smooth_period) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.prev_smooth_period = 0.0; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 50 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "HilbertDominantCycle" + } +} + +impl HilbertDominantCycle { + /// Push `v` at the front of `buf`, capping the length at `cap`. + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut ht = HilbertDominantCycle::new(); + assert_eq!(ht.warmup_period(), 50); + assert_eq!(ht.name(), "HilbertDominantCycle"); + assert!(!ht.is_ready()); + assert!(ht.value().is_none()); + for i in 0..120 { + ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(ht.is_ready()); + assert!(ht.value().is_some()); + } + + #[test] + fn output_within_clamp_band() { + let mut ht = HilbertDominantCycle::new(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + let out = ht.batch(&prices); + for v in out.iter().flatten() { + assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]"); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = HilbertDominantCycle::new(); + let mut b = HilbertDominantCycle::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ht = HilbertDominantCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ht.batch(&prices); + let before = ht.value(); + assert!(before.is_some()); + assert_eq!(ht.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut ht = HilbertDominantCycle::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + ht.batch(&prices); + assert!(ht.is_ready()); + ht.reset(); + assert!(!ht.is_ready()); + assert!(ht.value().is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/hilo_activator.rs b/crates/wickra-core/src/indicators/hilo_activator.rs new file mode 100644 index 0000000..0939bdd --- /dev/null +++ b/crates/wickra-core/src/indicators/hilo_activator.rs @@ -0,0 +1,264 @@ +//! `HiLo` Activator (Crabel). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `HiLo` Activator — Robert Krausz's adaptation of Linda Bradford Raschke and +/// Larry Connors' "`HiLo`" rule, popularised by Toby Crabel. Two simple moving +/// averages — of the high and of the low — bracket price; the trailing stop +/// for a long sits at the SMA-of-low, and for a short at the SMA-of-high. +/// +/// ```text +/// hi_sma = SMA(high, period) // potential short stop +/// lo_sma = SMA(low, period) // potential long stop +/// +/// state-machine: +/// long while close > hi_sma_prev -> emit lo_sma_prev +/// short while close < lo_sma_prev -> emit hi_sma_prev +/// else: hold the previous side +/// ``` +/// +/// Comparing the close to the *previous* bar's SMA avoids look-ahead and gives +/// the indicator a one-bar lag — the classic Crabel formulation. A long signal +/// fires the bar after price closes above the high-SMA; the stop then trails +/// at the low-SMA. The first input that fills the SMA window seeds a long. +/// A common configuration is a `3`-period window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, HiLoActivator}; +/// +/// let mut indicator = HiLoActivator::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HiLoActivator { + period: usize, + highs: VecDeque, + lows: VecDeque, + sum_high: f64, + sum_low: f64, + /// Last bar's `(hi_sma, lo_sma)`, used so today's signal is based on + /// yesterday's SMAs (no look-ahead). + prev_smas: Option<(f64, f64)>, + /// `true` while the current trail is on the long side. + long: bool, + /// `true` once a signal has been emitted at least once. + started: bool, +} + +impl HiLoActivator { + /// Construct a `HiLo` Activator with an explicit SMA window. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + sum_high: 0.0, + sum_low: 0.0, + prev_smas: None, + long: true, + started: false, + }) + } + + /// Crabel's classic configuration: a `3`-bar window. + pub fn classic() -> Self { + Self::new(3).expect("classic period is valid") + } + + /// Configured SMA window. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for HiLoActivator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.sum_high -= self.highs.pop_front().expect("non-empty by check"); + self.sum_low -= self.lows.pop_front().expect("non-empty by check"); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + self.sum_high += candle.high; + self.sum_low += candle.low; + + // Need today's SMA + yesterday's SMA to compare close vs the *previous* + // bar's bands — so the very first ready bar only computes today's SMA + // and stores it; emission begins on the next bar. + if self.highs.len() < self.period { + return None; + } + let p = self.period as f64; + let hi_sma = self.sum_high / p; + let lo_sma = self.sum_low / p; + + let out = if let Some((prev_hi, prev_lo)) = self.prev_smas { + if candle.close > prev_hi { + self.long = true; + } else if candle.close < prev_lo { + self.long = false; + } + self.started = true; + if self.long { + prev_lo + } else { + prev_hi + } + } else { + // First SMA-ready bar seeds yesterday's bands for the next call. + self.prev_smas = Some((hi_sma, lo_sma)); + return None; + }; + self.prev_smas = Some((hi_sma, lo_sma)); + Some(out) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.sum_high = 0.0; + self.sum_low = 0.0; + self.prev_smas = None; + self.long = true; + self.started = false; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.started + } + + fn name(&self) -> &'static str { + "HiLoActivator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(HiLoActivator::new(0).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = HiLoActivator::classic(); + assert_eq!(s.period(), 3); + assert_eq!(s.warmup_period(), 4); + assert_eq!(s.name(), "HiLoActivator"); + } + + #[test] + fn warmup_emits_none_until_period_plus_one() { + let mut s = HiLoActivator::new(3).unwrap(); + // The first 3 candles fill the SMA; the 4th is the first emission. + let candles: Vec = (0..6) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let out = s.batch(&candles); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_none()); + assert!(out[3].is_some(), "first emission lands at index period"); + } + + #[test] + fn constant_series_stays_long_on_lo_sma() { + let mut s = HiLoActivator::new(3).unwrap(); + // Flat candles: H=11, L=9, C=10. Both SMAs are constant. + let candles: Vec = (0..10).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + for v in s.batch(&candles).into_iter().flatten() { + // close (10) is not > 11 nor < 9, so the long seed persists -> lo_sma = 9. + assert_relative_eq!(v, 9.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_keeps_emitting_low_sma_below_close() { + let mut s = HiLoActivator::new(3).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let paired: Vec<(f64, f64)> = s + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + assert!( + paired.iter().all(|(stop, close)| stop < close), + "uptrend stop should sit below the close" + ); + } + + #[test] + fn reset_clears_state() { + let mut s = HiLoActivator::new(3).unwrap(); + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = HiLoActivator::classic(); + let mut b = HiLoActivator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/historical_volatility.rs b/crates/wickra-core/src/indicators/historical_volatility.rs new file mode 100644 index 0000000..3563c06 --- /dev/null +++ b/crates/wickra-core/src/indicators/historical_volatility.rs @@ -0,0 +1,309 @@ +//! Historical Volatility. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Historical Volatility — the annualised standard deviation of log returns. +/// +/// This is the realised (backward-looking) volatility used to price options +/// and size risk: +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// HV = stddev_sample(r over period) · √trading_periods · 100 +/// ``` +/// +/// The log returns over the window are measured with the **sample** standard +/// deviation (divisor `n − 1`, the unbiased estimator), then scaled to an +/// annual figure by `√trading_periods` — `252` for daily bars, `52` for +/// weekly, `12` for monthly — and expressed as a percentage. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HistoricalVolatility}; +/// +/// // 20-bar window, 252 trading days per year. +/// let mut indicator = HistoricalVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HistoricalVolatility { + period: usize, + trading_periods: usize, + prev_price: Option, + /// Rolling window of the last `period` log returns. + window: VecDeque, + sum: f64, + sum_sq: f64, + last: Option, +} + +impl HistoricalVolatility { + /// Construct a new Historical Volatility indicator. + /// + /// `period` is the number of log returns in the rolling window; + /// `trading_periods` is the annualisation factor (`252` daily, `52` + /// weekly, `12` monthly). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `trading_periods` is `0`, + /// or [`Error::InvalidPeriod`] if `period == 1` (the sample standard + /// deviation needs at least two returns). + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "historical volatility period must be >= 2", + }); + } + Ok(Self { + period, + trading_periods, + prev_price: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + last: None, + }) + } + + /// Configured `(period, trading_periods)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.trading_periods) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for HistoricalVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite *and* non-positive prices are both ignored: state is left + // untouched and `self.last` is returned. The log-return `ln(input / + // prev)` is undefined for non-positive prices, and silently + // substituting `0.0` (the previous behaviour, audit finding R13) would + // underreport realised volatility by treating bad ticks as "no + // movement". Skipping them entirely is consistent with how the rest + // of the library handles invalid inputs (see SMA / EMA / ROC). + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + // `prev` was assigned from `self.prev_price`, which only ever holds + // valid (finite, positive) inputs because the guard above gates every + // assignment to it — so `(input / prev).ln()` is always well-defined. + self.prev_price = Some(input); + + let log_return = (input / prev).ln(); + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(log_return); + self.sum += log_return; + self.sum_sq += log_return * log_return; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Sample variance (Bessel's correction): Σ(x−mean)² / (n−1). + let variance = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + let hv = variance.sqrt() * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(hv); + Some(hv) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first log return needs a previous price, then the window fills. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "HistoricalVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + HistoricalVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + HistoricalVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessors `periods` / `value` (80-88) and the + /// Indicator-impl `name` body (153-155). Existing tests inspect HV + /// output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut hv = HistoricalVolatility::new(20, 252).unwrap(); + assert_eq!(hv.periods(), (20, 252)); + assert_eq!(hv.name(), "HistoricalVolatility"); + assert_eq!(hv.value(), None); + for i in 1..=hv.warmup_period() { + hv.update(100.0 + f64::from(u32::try_from(i).unwrap())); + } + assert!(hv.value().is_some()); + } + + #[test] + fn new_rejects_period_one() { + assert!(matches!( + HistoricalVolatility::new(1, 252), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + assert_eq!(hv.warmup_period(), 6); + let out = hv.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Flat prices -> all log returns are 0 -> zero volatility. + let mut hv = HistoricalVolatility::new(10, 252).unwrap(); + let out = hv.batch(&[100.0; 40]); + for v in out.iter().skip(10).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn geometric_series_yields_zero() { + // A constant growth factor gives a constant log return -> zero stddev. + // The mathematical result is exactly zero, but `1.01_f64.powi(i)` and + // the subsequent log / std-dev cascade accumulate platform-sensitive + // floating-point drift on the order of 1e-7 (observed on x86_64 Linux + // and macOS; Windows happens to round closer to zero). The 1e-6 + // tolerance stays four decimal places below any realistic volatility + // value while absorbing this drift across every supported platform. + let mut hv = HistoricalVolatility::new(10, 252).unwrap(); + let prices: Vec = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = hv.batch(&prices); + for v in out.iter().skip(10).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-6); + } + } + + #[test] + fn output_is_non_negative() { + let mut hv = HistoricalVolatility::new(20, 252).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in hv.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "volatility must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + let out = hv.batch(&(1..=20).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(hv.update(f64::NAN), last); + assert_eq!(hv.update(f64::INFINITY), last); + } + + /// Audit finding R13. Non-positive prices are now skipped (state left + /// untouched) instead of silently treated as a `0.0` log-return — the old + /// behaviour underreported realised volatility by treating bad ticks as + /// "no movement". + #[test] + fn skips_non_positive_prices() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + // Warm up with positive prices. + let warmup_prices = (1..=20).map(f64::from).collect::>(); + let warmup = hv.batch(&warmup_prices); + let baseline = warmup + .last() + .copied() + .flatten() + .expect("warmed up by index 5"); + + // A negative tick must be ignored: returned value equals the previous + // baseline, and the next real positive tick must use the previous + // valid price as `prev` (not the bad one), so the next log return is + // exactly `ln(21 / 20)`, not `ln(21 / -5)` or anything else. + assert_eq!(hv.update(-5.0), Some(baseline)); + assert_eq!(hv.update(0.0), Some(baseline)); + + // Snapshot the indicator's state, then advance with a real positive + // tick on a clone. The clone must agree with a from-scratch run that + // simply skipped the bad ticks — proving the state was untouched. + let mut control = hv.clone(); + let after_real = hv.update(21.0).expect("ready"); + assert_eq!(control.update(21.0).expect("ready"), after_real); + } + + #[test] + fn reset_clears_state() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + hv.batch(&(1..=20).map(f64::from).collect::>()); + assert!(hv.is_ready()); + hv.reset(); + assert!(!hv.is_ready()); + assert_eq!(hv.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = HistoricalVolatility::new(20, 252).unwrap().batch(&prices); + let mut b = HistoricalVolatility::new(20, 252).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/hma.rs b/crates/wickra-core/src/indicators/hma.rs new file mode 100644 index 0000000..416fb80 --- /dev/null +++ b/crates/wickra-core/src/indicators/hma.rs @@ -0,0 +1,181 @@ +//! Hull Moving Average (HMA). + +use crate::error::{Error, Result}; +use crate::indicators::wma::Wma; +use crate::traits::Indicator; + +/// Hull Moving Average: `WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`. +/// +/// Designed by Alan Hull as a lag-free moving average that is also responsive. +/// The square root of the period is rounded to the nearest integer (minimum 1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Hma}; +/// +/// let mut indicator = Hma::new(9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Hma { + period: usize, + half_wma: Wma, + full_wma: Wma, + smooth_wma: Wma, +} + +impl Hma { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let half = (period / 2).max(1); + let smooth = (period as f64).sqrt().round() as usize; + let smooth = smooth.max(1); + Ok(Self { + period, + half_wma: Wma::new(half)?, + full_wma: Wma::new(period)?, + smooth_wma: Wma::new(smooth)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Hma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both windowed WMAs on every input so they warm up in parallel. + // Gating `full_wma.update` behind `half_wma.update(...)?` would starve + // the longer WMA during the shorter one's warmup, delaying the first + // emission past `warmup_period()`. + let h = self.half_wma.update(input); + let f = self.full_wma.update(input); + let (h, f) = (h?, f?); + let diff = 2.0 * h - f; + self.smooth_wma.update(diff) + } + + fn reset(&mut self) { + self.half_wma.reset(); + self.full_wma.reset(); + self.smooth_wma.reset(); + } + + fn warmup_period(&self) -> usize { + let sm = (self.period as f64).sqrt().round() as usize; + self.period + sm.max(1) - 1 + } + + fn is_ready(&self) -> bool { + self.smooth_wma.is_ready() + } + + fn name(&self) -> &'static str { + "HMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_constant_hma() { + let mut hma = Hma::new(9).unwrap(); + let out = hma.batch(&[10.0_f64; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 10.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100).map(|i| f64::from(i) * 0.7).collect(); + let mut a = Hma::new(9).unwrap(); + let mut b = Hma::new(9).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut hma = Hma::new(9).unwrap(); + hma.batch(&(1..=80).map(f64::from).collect::>()); + assert!(hma.is_ready()); + hma.reset(); + assert!(!hma.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Hma::new(0).is_err()); + } + + /// Cover the const accessor `period` (51-53) and the Indicator-impl + /// `name` body (87-89). `warmup_period` is covered by + /// `first_emission_matches_warmup_period`. + #[test] + fn accessors_and_metadata() { + let hma = Hma::new(9).unwrap(); + assert_eq!(hma.period(), 9); + assert_eq!(hma.name(), "HMA"); + } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut hma = Hma::new(9).unwrap(); + let out = hma.batch(&prices); + let warmup = hma.warmup_period(); + assert_eq!(warmup, 11); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first HMA value must land at warmup_period - 1" + ); + } + + #[test] + fn matches_independent_wmas() { + // The two inner WMAs run as independent siblings on the price stream; + // HMA must equal feeding three standalone WMAs and combining them. + let prices: Vec = (1..=50) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0) + .collect(); + let mut hma = Hma::new(9).unwrap(); + let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1) + let mut full = Wma::new(9).unwrap(); + let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9)) + for (i, &p) in prices.iter().enumerate() { + let got = hma.update(p); + let want = match (half.update(p), full.update(p)) { + (Some(h), Some(f)) => smooth.update(2.0 * h - f), + _ => None, + }; + // HMA and the independent WMA chain share a warmup formula. + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (got, want) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } +} diff --git a/crates/wickra-core/src/indicators/holt_winters.rs b/crates/wickra-core/src/indicators/holt_winters.rs new file mode 100644 index 0000000..c6258d5 --- /dev/null +++ b/crates/wickra-core/src/indicators/holt_winters.rs @@ -0,0 +1,315 @@ +//! Holt's linear (double exponential) smoothing. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Holt's linear method — double exponential smoothing with a level and a +/// trend component. +/// +/// A single [`Ema`](crate::Ema) tracks only a *level* and therefore lags any +/// sustained trend. Holt's method adds a second smoothed state, the trend, and +/// reports the one-step-ahead forecast `level + trend`, which removes that lag +/// on trending data while still smoothing noise. +/// +/// ```text +/// level_t = α · price_t + (1 − α) · (level_{t-1} + trend_{t-1}) +/// trend_t = β · (level_t − level_{t-1}) + (1 − β) · trend_{t-1} +/// output = level_t + trend_t (one-step-ahead forecast) +/// ``` +/// +/// `α ∈ (0, 1]` is the level smoothing constant and `β ∈ (0, 1]` the trend +/// smoothing constant. The state is seeded from the first two inputs +/// (`level = price_1`, `trend = price_1 − price_0`), so the first output lands +/// on the **second** input. +/// +/// On a perfectly linear series the forecast is exact from the second bar +/// onward (for any `α`, `β`): if the level equals the current value and the +/// trend equals the slope, both invariants are preserved and `level + trend` +/// equals the next value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{HoltWinters, Indicator}; +/// +/// let mut indicator = HoltWinters::new(0.2, 0.1).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HoltWinters { + alpha: f64, + beta: f64, + /// `(level, trend)` once seeded. + state: Option<(f64, f64)>, + /// First input, held until the second arrives to seed the trend. + prev_price: Option, +} + +impl HoltWinters { + /// Construct Holt's linear smoother with level constant `alpha` and trend + /// constant `beta`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if either constant is non-finite or + /// outside `(0.0, 1.0]`. + pub fn new(alpha: f64, beta: f64) -> Result { + if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 { + return Err(Error::InvalidPeriod { + message: "HoltWinters alpha must be in (0.0, 1.0]", + }); + } + if !beta.is_finite() || beta <= 0.0 || beta > 1.0 { + return Err(Error::InvalidPeriod { + message: "HoltWinters beta must be in (0.0, 1.0]", + }); + } + Ok(Self { + alpha, + beta, + state: None, + prev_price: None, + }) + } + + /// Level smoothing constant `alpha`. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Trend smoothing constant `beta`. + pub const fn beta(&self) -> f64 { + self.beta + } + + /// Current smoothed level, if seeded. + pub fn level(&self) -> Option { + self.state.map(|(level, _)| level) + } + + /// Current smoothed trend, if seeded. + pub fn trend(&self) -> Option { + self.state.map(|(_, trend)| trend) + } + + /// Current one-step-ahead forecast `level + trend`, if seeded. + pub fn value(&self) -> Option { + self.state.map(|(level, trend)| level + trend) + } +} + +impl Indicator for HoltWinters { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.value(); + } + match self.state { + None => { + if let Some(prev) = self.prev_price { + // Second input: seed level and trend. + let level = price; + let trend = price - prev; + self.state = Some((level, trend)); + Some(level + trend) + } else { + // First input: hold it to seed the trend next time. + self.prev_price = Some(price); + None + } + } + Some((level, trend)) => { + let level_new = self.alpha * price + (1.0 - self.alpha) * (level + trend); + let trend_new = self.beta * (level_new - level) + (1.0 - self.beta) * trend; + self.state = Some((level_new, trend_new)); + Some(level_new + trend_new) + } + } + } + + fn reset(&mut self) { + self.state = None; + self.prev_price = None; + } + + fn warmup_period(&self) -> usize { + // Two inputs are needed to seed the level and the trend. + 2 + } + + fn is_ready(&self) -> bool { + self.state.is_some() + } + + fn name(&self) -> &'static str { + "HoltWinters" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference for the steady-state recurrence. + fn naive(prices: &[f64], alpha: f64, beta: f64) -> Vec> { + let mut state: Option<(f64, f64)> = None; + let mut prev: Option = None; + let mut out = Vec::with_capacity(prices.len()); + for &price in prices { + let v = match state { + None => { + if let Some(p0) = prev { + let level = price; + let trend = price - p0; + state = Some((level, trend)); + Some(level + trend) + } else { + prev = Some(price); + None + } + } + Some((level, trend)) => { + let ln = alpha * price + (1.0 - alpha) * (level + trend); + let tn = beta * (ln - level) + (1.0 - beta) * trend; + state = Some((ln, tn)); + Some(ln + tn) + } + }; + out.push(v); + } + out + } + + #[test] + fn rejects_invalid_alpha() { + assert!(matches!( + HoltWinters::new(0.0, 0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(1.5, 0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(f64::NAN, 0.1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn rejects_invalid_beta() { + assert!(matches!( + HoltWinters::new(0.2, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(0.2, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + HoltWinters::new(0.2, f64::INFINITY), + Err(Error::InvalidPeriod { .. }) + )); + } + + /// Cover the const accessors `alpha` + `beta` and the Indicator-impl + /// `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let hw = HoltWinters::new(0.2, 0.1).unwrap(); + assert_relative_eq!(hw.alpha(), 0.2, epsilon = 1e-12); + assert_relative_eq!(hw.beta(), 0.1, epsilon = 1e-12); + assert_eq!(hw.warmup_period(), 2); + assert_eq!(hw.name(), "HoltWinters"); + } + + #[test] + fn warmup_then_seed_on_second_input() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + assert_eq!(hw.update(10.0), None); + // Second input seeds level = 12, trend = 12 - 10 = 2 -> forecast 14. + assert_relative_eq!(hw.update(12.0).unwrap(), 14.0, epsilon = 1e-12); + assert_relative_eq!(hw.level().unwrap(), 12.0, epsilon = 1e-12); + assert_relative_eq!(hw.trend().unwrap(), 2.0, epsilon = 1e-12); + } + + #[test] + fn linear_series_forecasts_exactly() { + // On a perfect ramp the one-step forecast equals the next value, for + // any alpha/beta, from the second bar onward. + let prices: Vec = (1..=20).map(f64::from).collect(); + let mut hw = HoltWinters::new(0.3, 0.4).unwrap(); + let out = hw.batch(&prices); + assert!(out[0].is_none()); + for (i, v) in out.iter().enumerate().skip(1) { + // forecast at index i is the price at index i + 1 = (i + 2). + assert_relative_eq!(v.unwrap(), (i + 2) as f64, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_constant() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + let out = hw.batch(&[42.0_f64; 30]); + for v in out.into_iter().skip(1).flatten() { + assert_relative_eq!(v, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn matches_naive_recurrence() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + f64::from(i) * 0.2) + .collect(); + let mut hw = HoltWinters::new(0.25, 0.15).unwrap(); + let got = hw.batch(&prices); + let want = naive(&prices, 0.25, 0.15); + for (g, w) in got.iter().zip(want.iter()) { + assert_eq!(g.is_some(), w.is_some()); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + hw.batch(&(1..=20).map(f64::from).collect::>()); + assert!(hw.is_ready()); + hw.reset(); + assert!(!hw.is_ready()); + assert_eq!(hw.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 0.5).collect(); + let mut a = HoltWinters::new(0.3, 0.2).unwrap(); + let mut b = HoltWinters::new(0.3, 0.2).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut hw = HoltWinters::new(0.2, 0.1).unwrap(); + // Non-finite before any state returns None. + assert_eq!(hw.update(f64::NAN), None); + hw.update(10.0); + let ready = hw.update(12.0).expect("seeded on second finite input"); + // Non-finite after seeding returns the current forecast unchanged. + assert_eq!(hw.update(f64::NAN), Some(ready)); + assert_eq!(hw.update(f64::INFINITY), Some(ready)); + } +} diff --git a/crates/wickra-core/src/indicators/homing_pigeon.rs b/crates/wickra-core/src/indicators/homing_pigeon.rs new file mode 100644 index 0000000..2d23137 --- /dev/null +++ b/crates/wickra-core/src/indicators/homing_pigeon.rs @@ -0,0 +1,169 @@ +//! Homing Pigeon candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Homing Pigeon — a 2-bar bullish reversal. Two black candles in a decline, the +/// second a small body sitting entirely inside the first body (a same-colour +/// harami). The shrinking range signals selling pressure is fading. +/// +/// ```text +/// bar1 black (close < open) +/// bar2 black & its body sits inside bar1's body +/// (open2 <= open1 && close2 >= close1) +/// bar2 body is smaller than bar1's +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Homing Pigeon +/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The +/// first bar always returns `0.0` because the two-bar window is not yet filled. +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HomingPigeon, Indicator}; +/// +/// let mut indicator = HomingPigeon::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(14.0, 14.1, 10.9, 11.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HomingPigeon { + prev: Option, + has_emitted: bool, +} + +impl HomingPigeon { + /// Construct a new Homing Pigeon detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for HomingPigeon { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + // Both bars black, bar2's body inside bar1's body and smaller. + if bar1.close < bar1.open + && candle.close < candle.open + && candle.open <= bar1.open + && candle.close >= bar1.close + && (candle.open - candle.close) < (bar1.open - bar1.close) + { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HomingPigeon" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = HomingPigeon::new(); + assert_eq!(t.name(), "HomingPigeon"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn homing_pigeon_is_plus_one() { + let mut t = HomingPigeon::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 10.9, 11.0, 1)), Some(1.0)); + } + + #[test] + fn second_bar_white_yields_zero() { + let mut t = HomingPigeon::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + // bar2 white -> not a homing pigeon. + assert_eq!(t.update(c(11.0, 14.1, 10.9, 14.0, 1)), Some(0.0)); + } + + #[test] + fn second_body_not_inside_yields_zero() { + let mut t = HomingPigeon::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + // bar2 opens above bar1's open -> body not contained. + assert_eq!(t.update(c(16.0, 16.1, 10.9, 11.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = HomingPigeon::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 5.0, base + 5.1, base - 0.1, base, i) + }) + .collect(); + let mut a = HomingPigeon::new(); + let mut b = HomingPigeon::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = HomingPigeon::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(14.0, 14.1, 10.9, 11.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/ht_dcphase.rs b/crates/wickra-core/src/indicators/ht_dcphase.rs new file mode 100644 index 0000000..e1d6bfc --- /dev/null +++ b/crates/wickra-core/src/indicators/ht_dcphase.rs @@ -0,0 +1,303 @@ +//! Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`). +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::traits::Indicator; + +/// Ehlers' Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`). +/// +/// Runs the same adaptive Hilbert-transform engine as +/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) to recover the dominant +/// cycle period, then measures the **phase angle** of that cycle (in degrees) by +/// correlating the smoothed price over one dominant-cycle window against a unit +/// phasor. The phase advances roughly linearly through a clean cycle and stalls +/// in a trend, which is the basis of Ehlers' trend-versus-cycle detection. +/// +/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's +/// `HT_DCPHASE`. The first value is emitted after ~50 inputs, once the engine's +/// moving-average chain has filled. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HtDcPhase}; +/// +/// let mut ht = HtDcPhase::new(); +/// let mut last = None; +/// for i in 0..120 { +/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HtDcPhase { + smooth_buf: Vec, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + // Longer history of the 4-bar smoothed price, used to integrate the phase + // over one dominant-cycle window (up to 50 bars). + smooth_price: Vec, + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_smooth_period: f64, + count: usize, + last_value: Option, +} + +impl HtDcPhase { + /// Construct a new Hilbert transform dominant-cycle phase estimator. + pub fn new() -> Self { + Self::default() + } + + /// Current dominant-cycle phase (degrees) if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for HtDcPhase { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 7 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + Self::push_front(&mut self.smooth_price, smooth, 50); + + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period; + + if self.count < 50 { + return None; + } + + // Integrate the smoothed price over one dominant-cycle window against a + // unit phasor to recover the instantaneous dominant-cycle phase. + let smooth_period = self.prev_smooth_period; + let dc_period = (smooth_period + 0.5) as usize; + let dc_period = dc_period.clamp(1, self.smooth_price.len()); + let mut real_part = 0.0; + let mut imag_part = 0.0; + for i in 0..dc_period { + let angle = (i as f64) * 2.0 * PI / (dc_period as f64); + let sp = self.smooth_price[i]; + real_part += angle.sin() * sp; + imag_part += angle.cos() * sp; + } + + let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period); + + self.last_value = Some(dc_phase); + Some(dc_phase) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.smooth_price.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.prev_smooth_period = 0.0; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 50 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "HT_DCPHASE" + } +} + +/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of +/// the one-cycle homodyne integration, then unwraps it into TA-Lib's +/// `[-45, 315)` output range with the 4-bar smoother group-delay correction. +/// +/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the +/// phase collapses to `±90°` by the sign of `real_part`. +fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 { + let mut dc_phase = if imag_part.abs() > 0.001 { + (real_part / imag_part).atan().to_degrees() + } else if real_part < 0.0 { + -90.0 + } else { + 90.0 + }; + dc_phase += 90.0; + // Compensate the group delay of the 4-bar weighted smoother. + dc_phase += 360.0 / smooth_period; + if imag_part < 0.0 { + dc_phase += 180.0; + } + if dc_phase > 315.0 { + dc_phase -= 360.0; + } + dc_phase +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn sine_prices(n: usize) -> Vec { + (0..n) + .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let ht = HtDcPhase::new(); + assert_eq!(ht.warmup_period(), 50); + assert_eq!(ht.name(), "HT_DCPHASE"); + assert!(!ht.is_ready()); + } + + #[test] + fn near_zero_imaginary_collapses_to_signed_ninety() { + // A near-zero imaginary part makes atan(real/imag) undefined, so the phase + // collapses to +90 for non-negative real and -90 for negative real before + // the +90 offset and group-delay correction unwrap it. + let pos = compute_dc_phase(1.0, 0.0, 20.0); + let neg = compute_dc_phase(-1.0, 0.0, 20.0); + assert!((pos - 198.0).abs() < 1e-9); + assert!((neg - 18.0).abs() < 1e-9); + // The normal path still flows through atan. + let mid = compute_dc_phase(1.0, 1.0, 20.0); + assert!((mid - 153.0).abs() < 1e-9); + } + + #[test] + fn emits_after_warmup_within_phase_band() { + let mut ht = HtDcPhase::new(); + let out: Vec> = ht.batch(&sine_prices(200)); + assert_eq!(out[0], None); + assert!(ht.is_ready()); + for v in out.into_iter().flatten() { + assert!(v.is_finite(), "phase must be finite"); + assert!((-360.0..=360.0).contains(&v), "phase {v} outside band"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut ht = HtDcPhase::new(); + let _ = ht.batch(&sine_prices(120)); + let before = ht.value(); + assert_eq!(ht.update(f64::NAN), before); + } + + #[test] + fn batch_equals_streaming() { + let prices = sine_prices(200); + let mut a = HtDcPhase::new(); + let mut b = HtDcPhase::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut ht = HtDcPhase::new(); + let _ = ht.batch(&sine_prices(120)); + assert!(ht.is_ready()); + ht.reset(); + assert!(!ht.is_ready()); + assert_eq!(ht.update(100.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/ht_phasor.rs b/crates/wickra-core/src/indicators/ht_phasor.rs new file mode 100644 index 0000000..6838072 --- /dev/null +++ b/crates/wickra-core/src/indicators/ht_phasor.rs @@ -0,0 +1,240 @@ +//! Ehlers Hilbert Transform Phasor components (`HT_PHASOR`). +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::traits::Indicator; + +/// In-phase and quadrature components of the Hilbert transform phasor. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HtPhasorOutput { + /// In-phase component (`I1`). + pub inphase: f64, + /// Quadrature component (`Q1`). + pub quadrature: f64, +} + +/// Ehlers' Hilbert Transform Phasor (`HT_PHASOR`). +/// +/// Runs the same adaptive Hilbert-transform engine as +/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) but reports the raw +/// in-phase (`I1`) and quadrature (`Q1`) components of the analytic signal rather +/// than the recovered cycle period. The two components are 90° out of phase, so +/// their ratio tracks the instantaneous phase of the dominant cycle. +/// +/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's +/// `HT_PHASOR`. The first value is emitted once the transform's tap buffers fill. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HtPhasor}; +/// +/// let mut ht = HtPhasor::new(); +/// let mut last = None; +/// for i in 0..120 { +/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HtPhasor { + smooth_buf: Vec, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + ready: bool, +} + +impl HtPhasor { + /// Construct a new Hilbert transform phasor. + pub fn new() -> Self { + Self::default() + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for HtPhasor { + type Input = f64; + type Output = HtPhasorOutput; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 7 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + // Continue the dominant-cycle period adaptation so the next bar's `adj` + // coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does. + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + + self.ready = true; + Some(HtPhasorOutput { + inphase: i1, + quadrature: q1, + }) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 19 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "HT_PHASOR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn sine_prices(n: usize) -> Vec { + (0..n) + .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let ht = HtPhasor::new(); + assert_eq!(ht.warmup_period(), 19); + assert_eq!(ht.name(), "HT_PHASOR"); + assert!(!ht.is_ready()); + } + + #[test] + fn emits_after_warmup_and_stays_finite() { + let mut ht = HtPhasor::new(); + let out: Vec> = ht.batch(&sine_prices(120)); + assert_eq!(out[0], None); + let first = out.iter().position(Option::is_some).expect("emits"); + assert!(first <= 19, "first phasor at index {first}"); + for o in out.into_iter().flatten() { + assert!(o.inphase.is_finite() && o.quadrature.is_finite()); + } + assert!(ht.is_ready()); + } + + #[test] + fn ignores_non_finite_input() { + let mut ht = HtPhasor::new(); + let _ = ht.batch(&sine_prices(120)); + // A non-finite input is skipped and produces no value. + assert_eq!(ht.update(f64::NAN), None); + } + + #[test] + fn batch_equals_streaming() { + let prices = sine_prices(150); + let mut a = HtPhasor::new(); + let mut b = HtPhasor::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut ht = HtPhasor::new(); + let _ = ht.batch(&sine_prices(120)); + assert!(ht.is_ready()); + ht.reset(); + assert!(!ht.is_ready()); + assert_eq!(ht.update(100.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/ht_trendmode.rs b/crates/wickra-core/src/indicators/ht_trendmode.rs new file mode 100644 index 0000000..568d7ae --- /dev/null +++ b/crates/wickra-core/src/indicators/ht_trendmode.rs @@ -0,0 +1,379 @@ +//! Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`). +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::traits::Indicator; + +/// Ehlers' Hilbert Transform Trend Mode (`HT_TRENDMODE`). +/// +/// Runs the same adaptive Hilbert-transform engine as +/// [`HilbertDominantCycle`](crate::HilbertDominantCycle), derives the dominant +/// cycle phase, its sine / lead-sine, and an instantaneous trendline, then +/// classifies the market into **trend mode (`1`)** or **cycle mode (`0`)**: +/// +/// - it is a *cycle* shortly after the sine and lead-sine cross, while the phase +/// advances at roughly the dominant-cycle rate; +/// - it is a *trend* otherwise, and is forced to trend whenever price separates +/// from the trendline by more than 1.5%. +/// +/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's +/// `HT_TRENDMODE`. The output is `1.0` or `0.0`; the first value is emitted after +/// ~50 inputs once the engine's moving-average chain has filled. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HtTrendMode}; +/// +/// let mut ht = HtTrendMode::new(); +/// let mut last = None; +/// for i in 0..120 { +/// last = ht.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HtTrendMode { + smooth_buf: Vec, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + smooth_price: Vec, + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_smooth_period: f64, + // Trend-mode state. + prev_dc_phase: f64, + prev_sine: f64, + prev_lead_sine: f64, + days_in_trend: f64, + it1: f64, + it2: f64, + it3: f64, + count: usize, + last_value: Option, +} + +impl HtTrendMode { + /// Construct a new Hilbert transform trend-mode classifier. + pub fn new() -> Self { + Self::default() + } + + /// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for HtTrendMode { + type Input = f64; + type Output = f64; + + #[allow(clippy::too_many_lines)] + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 7 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + Self::push_front(&mut self.smooth_price, smooth, 50); + + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period; + + let smooth_period = self.prev_smooth_period; + let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len()); + + // Dominant-cycle phase over one cycle window. + let mut real_part = 0.0; + let mut imag_part = 0.0; + for i in 0..dc_period { + let angle = (i as f64) * 2.0 * PI / (dc_period as f64); + let sp = self.smooth_price[i]; + real_part += angle.sin() * sp; + imag_part += angle.cos() * sp; + } + let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period); + + let sine = (dc_phase * PI / 180.0).sin(); + let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin(); + + // Instantaneous trendline: average smoothed price over the cycle window, + // then a 4-3-2-1 weighted smoothing of that running average. + let mut trend_sum = 0.0; + for i in 0..dc_period { + trend_sum += self.smooth_price[i]; + } + trend_sum /= dc_period as f64; + let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0; + self.it3 = self.it2; + self.it2 = self.it1; + self.it1 = trend_sum; + + // Trend / cycle decision (assume trend, override to cycle). + let mut trend = 1.0_f64; + + // A crossing of sine and lead-sine restarts the cycle clock. + if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine) + || (sine < lead_sine && self.prev_sine >= self.prev_lead_sine) + { + self.days_in_trend = 0.0; + trend = 0.0; + } + self.days_in_trend += 1.0; + if self.days_in_trend < 0.5 * smooth_period { + trend = 0.0; + } + + // Cycle mode while the phase advances at roughly the dominant-cycle rate. + let delta_phase = dc_phase - self.prev_dc_phase; + if smooth_period != 0.0 + && delta_phase > 0.67 * 360.0 / smooth_period + && delta_phase < 1.5 * 360.0 / smooth_period + { + trend = 0.0; + } + + // Force trend mode when price separates from the trendline. + if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 { + trend = 1.0; + } + + self.prev_dc_phase = dc_phase; + self.prev_sine = sine; + self.prev_lead_sine = lead_sine; + + if self.count < 50 { + return None; + } + self.last_value = Some(trend); + Some(trend) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.smooth_price.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.prev_smooth_period = 0.0; + self.prev_dc_phase = 0.0; + self.prev_sine = 0.0; + self.prev_lead_sine = 0.0; + self.days_in_trend = 0.0; + self.it1 = 0.0; + self.it2 = 0.0; + self.it3 = 0.0; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 50 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "HT_TRENDMODE" + } +} + +/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of +/// the one-cycle homodyne integration, then unwraps it into TA-Lib's +/// `[-45, 315)` output range with the 4-bar smoother group-delay correction. +/// +/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the +/// phase collapses to `±90°` by the sign of `real_part`. +fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 { + let mut dc_phase = if imag_part.abs() > 0.001 { + (real_part / imag_part).atan().to_degrees() + } else if real_part < 0.0 { + -90.0 + } else { + 90.0 + }; + dc_phase += 90.0; + dc_phase += 360.0 / smooth_period; + if imag_part < 0.0 { + dc_phase += 180.0; + } + if dc_phase > 315.0 { + dc_phase -= 360.0; + } + dc_phase +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + /// A trending ramp followed by a clean cycle, so both modes are exercised. + fn mixed_prices() -> Vec { + let mut v = Vec::new(); + for i in 0..150 { + v.push(100.0 + f64::from(i) * 0.8); + } + for i in 0..200 { + v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0); + } + v + } + + #[test] + fn accessors_and_metadata() { + let ht = HtTrendMode::new(); + assert_eq!(ht.warmup_period(), 50); + assert_eq!(ht.name(), "HT_TRENDMODE"); + assert!(!ht.is_ready()); + assert!(ht.value().is_none()); + } + + #[test] + fn near_zero_imaginary_collapses_to_signed_ninety() { + // A near-zero imaginary part makes atan(real/imag) undefined, so the phase + // collapses to +90 for non-negative real and -90 for negative real before + // the +90 offset and group-delay correction unwrap it. + let pos = compute_dc_phase(1.0, 0.0, 20.0); + let neg = compute_dc_phase(-1.0, 0.0, 20.0); + assert!((pos - 198.0).abs() < 1e-9); + assert!((neg - 18.0).abs() < 1e-9); + // The normal path still flows through atan. + let mid = compute_dc_phase(1.0, 1.0, 20.0); + assert!((mid - 153.0).abs() < 1e-9); + } + + #[test] + fn emits_binary_flag_and_visits_both_modes() { + let mut ht = HtTrendMode::new(); + let out: Vec> = ht.batch(&mixed_prices()); + assert_eq!(out[0], None); + assert!(ht.is_ready()); + let mut saw_trend = false; + let mut saw_cycle = false; + for v in out.into_iter().flatten() { + assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}"); + if v == 1.0 { + saw_trend = true; + } else { + saw_cycle = true; + } + } + assert!(saw_trend, "ramp segment should report trend mode"); + assert!(saw_cycle, "cycle segment should report cycle mode"); + } + + #[test] + fn ignores_non_finite_input() { + let mut ht = HtTrendMode::new(); + let _ = ht.batch(&mixed_prices()); + let before = ht.value(); + assert_eq!(ht.update(f64::NAN), before); + } + + #[test] + fn batch_equals_streaming() { + let prices = mixed_prices(); + let mut a = HtTrendMode::new(); + let mut b = HtTrendMode::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut ht = HtTrendMode::new(); + let _ = ht.batch(&mixed_prices()); + assert!(ht.is_ready()); + ht.reset(); + assert!(!ht.is_ready()); + assert_eq!(ht.update(100.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/hurst_channel.rs b/crates/wickra-core/src/indicators/hurst_channel.rs new file mode 100644 index 0000000..06f6cab --- /dev/null +++ b/crates/wickra-core/src/indicators/hurst_channel.rs @@ -0,0 +1,236 @@ +//! Hurst Channel (Brian Millard / Hurst-cycle channel). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Hurst Channel output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HurstChannelOutput { + /// Upper channel: `middle + multiplier · (highest_high − lowest_low)`. + pub upper: f64, + /// Middle line: SMA of close over the period. + pub middle: f64, + /// Lower channel: `middle − multiplier · (highest_high − lowest_low)`. + pub lower: f64, +} + +/// Hurst Channel: an SMA centerline wrapped by a rolling high-low range. +/// +/// ```text +/// middle = SMA(close, period) +/// range = max(high, period) − min(low, period) +/// upper = middle + multiplier · range +/// lower = middle − multiplier · range +/// ``` +/// +/// The Hurst Channel sizes its envelope by the *realised* high-low range of +/// the window — a simpler, range-based volatility proxy than Bollinger's +/// rolling stddev or Keltner's ATR. With a `multiplier` of `0.5` the channel +/// reduces to a centerline that hugs the midpoint of the Donchian envelope; +/// chart vendors that follow Hurst's cycle work commonly use `period = 10` and +/// `multiplier = 0.5` for the "inner" channel. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HurstChannel, Indicator}; +/// +/// let mut indicator = HurstChannel::new(10, 0.5).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HurstChannel { + period: usize, + multiplier: f64, + sma: Sma, + highs: VecDeque, + lows: VecDeque, +} + +impl HurstChannel { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on + /// invalid inputs. + pub fn new(period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + multiplier, + sma: Sma::new(period)?, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured range multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for HurstChannel { + type Input = Candle; + type Output = HurstChannelOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + + let middle = self.sma.update(candle.close)?; + let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let range = hi - lo; + Some(HurstChannelOutput { + upper: middle + self.multiplier * range, + middle, + lower: middle - self.multiplier * range, + }) + } + + fn reset(&mut self) { + self.sma.reset(); + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "HurstChannel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(HurstChannel::new(0, 0.5), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + HurstChannel::new(10, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + HurstChannel::new(10, -0.5), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + HurstChannel::new(10, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let h = HurstChannel::new(10, 0.5).unwrap(); + assert_eq!(h.period(), 10); + assert_relative_eq!(h.multiplier(), 0.5, epsilon = 1e-12); + assert_eq!(h.warmup_period(), 10); + assert_eq!(h.name(), "HurstChannel"); + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut h = HurstChannel::new(5, 0.5).unwrap(); + let last = h.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut h = HurstChannel::new(10, 0.5).unwrap(); + for o in h.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = HurstChannel::new(10, 0.5).unwrap(); + let mut b = HurstChannel::new(10, 0.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..10) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut h = HurstChannel::new(5, 0.5).unwrap(); + h.batch(&candles); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update(candles[0]), None); + } + + /// Reference: five identical candles `(high=12, low=8, close=10)`: + /// SMA(close, 5) = 10, range = 12 − 8 = 4, multiplier = 0.5 + /// upper = 10 + 0.5·4 = 12, lower = 10 − 0.5·4 = 8. + #[test] + fn reference_values() { + let candles: Vec = (0..5).map(|_| c(12.0, 8.0, 10.0)).collect(); + let mut h = HurstChannel::new(5, 0.5).unwrap(); + let out = h.batch(&candles); + assert!(out[0].is_none() && out[3].is_none()); + let v = out[4].unwrap(); + assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 12.0, epsilon = 1e-9); + assert_relative_eq!(v.lower, 8.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/hurst_exponent.rs b/crates/wickra-core/src/indicators/hurst_exponent.rs new file mode 100644 index 0000000..e667576 --- /dev/null +++ b/crates/wickra-core/src/indicators/hurst_exponent.rs @@ -0,0 +1,302 @@ +//! Rolling Hurst Exponent via simplified R/S analysis. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Hurst Exponent of the last `period` values, estimated by rescaled-range +/// (R/S) analysis. +/// +/// The classic Hurst-Mandelbrot estimator forms log-log pairs of `(n, +/// R(n)/S(n))` for several window lengths `n` and reports the slope of the +/// least-squares fit. Wickra uses a streaming-friendly variant that +/// partitions the trailing window into `chunks` of equal size, +/// computes `(R/S)` for each chunk length, and fits a log-log line to the +/// resulting points: +/// +/// ```text +/// for each chunk size m ∈ {n/2, n/3, …, n/chunks}: +/// mean_m = (1/m) · Σ x_i over the chunk +/// dev_m_i = (Σ_{j ≤ i} (x_j − mean_m)) // cumulative deviation +/// R_m = max(dev_m) − min(dev_m) +/// S_m = population_stddev(chunk) +/// pair = (log m, log(R_m / S_m)) +/// H = slope of OLS line through the (log m, log(R/S)) points +/// ``` +/// +/// The interpretation is unchanged from the textbook: +/// +/// - `H ≈ 0.5` → random walk; recent moves carry no information about +/// future direction (the efficient-markets baseline). +/// - `H > 0.5` → persistent / trending; up moves are likelier to be +/// followed by more up moves. +/// - `H < 0.5` → anti-persistent / mean-reverting; up moves tend to +/// reverse. +/// +/// Use it as a regime filter: trend-following strategies prefer +/// `H > 0.55`; mean-reversion prefers `H < 0.45`. The output is clamped +/// to `[0, 1]` to absorb degenerate fits on very small windows. +/// +/// `period` must be at least `2 · chunks` so every chunk has at least two +/// points (otherwise its stddev is zero). A perfectly flat window has all +/// `R/S = 0` and the indicator returns `0.5` (random-walk baseline) to +/// avoid divide-by-zero / log-zero failures. +/// +/// Each `update` is O(period); the window is stored in a deque and the +/// chunked R/S computation runs once per emission, not per input. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{HurstExponent, Indicator}; +/// +/// let mut indicator = HurstExponent::new(100, 4).unwrap(); +/// let mut last = None; +/// for i in 0..200 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HurstExponent { + period: usize, + chunks: usize, + window: VecDeque, +} + +impl HurstExponent { + /// Construct a new Hurst Exponent over a window of `period` inputs, + /// fitted across `chunks` log-log points. + /// + /// `chunks` controls the number of R/S pairs that go into the slope + /// fit; the typical value is `4` (the original Hurst paper used 5 — 9 + /// points; smaller windows constrain the choice). + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `chunks < 2` or + /// `period < 2 · chunks`. + pub fn new(period: usize, chunks: usize) -> Result { + if chunks < 2 { + return Err(Error::InvalidPeriod { + message: "Hurst chunks must be >= 2", + }); + } + if period < 2 * chunks { + return Err(Error::InvalidPeriod { + message: "Hurst period must be >= 2 * chunks", + }); + } + Ok(Self { + period, + chunks, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured chunk count. + pub const fn chunks(&self) -> usize { + self.chunks + } +} + +/// R/S over a single chunk; returns `None` if the chunk has zero dispersion +/// (its stddev is zero, so the ratio is undefined). +fn rescaled_range(chunk: &[f64]) -> Option { + let n = chunk.len() as f64; + let mean = chunk.iter().sum::() / n; + let mut cum = 0.0; + let mut hi = f64::NEG_INFINITY; + let mut lo = f64::INFINITY; + let mut sum_sq = 0.0; + for &x in chunk { + let d = x - mean; + cum += d; + if cum > hi { + hi = cum; + } + if cum < lo { + lo = cum; + } + sum_sq += d * d; + } + let r = hi - lo; + let s = (sum_sq / n).sqrt(); + if s == 0.0 || r == 0.0 { + return None; + } + Some(r / s) +} + +impl Indicator for HurstExponent { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + + // Materialise the window contiguously so chunk slicing is trivial. + let buf: Vec = self.window.iter().copied().collect(); + // Build (log m, log(R/S)) points. The chunk size sweeps from period + // (one big chunk) down to period / chunks (chunks small chunks). + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_xx = 0.0; + let mut count = 0usize; + for k in 1..=self.chunks { + // k chunks each of size m; ignore the integer-division leftover + // bars at the end of the window. The `period >= 2 * chunks` + // constructor invariant guarantees m >= 2 for every k in range. + let m = self.period / k; + // Average R/S across the k chunks of size m to reduce noise. + let mut acc = 0.0; + let mut chunks_used = 0; + for c in 0..k { + let start = c * m; + let end = start + m; + if let Some(rs) = rescaled_range(&buf[start..end]) { + acc += rs; + chunks_used += 1; + } + } + if chunks_used == 0 { + continue; + } + let avg_rs = acc / f64::from(chunks_used); + let x = (m as f64).ln(); + let y = avg_rs.ln(); + sum_x += x; + sum_y += y; + sum_xy += x * y; + sum_xx += x * x; + count += 1; + } + if count < 2 { + // A perfectly flat window yields no usable R/S point; the + // canonical fallback for R/S on white noise is H = 0.5. + return Some(0.5); + } + // With chunks >= 2 and period >= 2 * chunks, m_1 = period and + // m_2 = period / 2 are always distinct, so the variance of the + // log-m values is strictly positive and `denom > 0`. + let n = count as f64; + let denom = n * sum_xx - sum_x * sum_x; + let slope = (n * sum_xy - sum_x * sum_y) / denom; + Some(slope.clamp(0.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "HurstExponent" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_parameters() { + assert!(HurstExponent::new(10, 0).is_err()); + assert!(HurstExponent::new(10, 1).is_err()); + assert!(HurstExponent::new(3, 2).is_err()); + assert!(HurstExponent::new(4, 2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let h = HurstExponent::new(100, 4).unwrap(); + assert_eq!(h.period(), 100); + assert_eq!(h.chunks(), 4); + assert_eq!(h.warmup_period(), 100); + assert_eq!(h.name(), "HurstExponent"); + } + + #[test] + fn constant_series_is_one_half() { + let mut h = HurstExponent::new(40, 4).unwrap(); + for v in h.batch(&[42.0; 80]).into_iter().flatten() { + assert_relative_eq!(v, 0.5, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_in_zero_one_range() { + let prices: Vec = (0..400) + .map(|i| { + 100.0 + + (f64::from(i) * 0.05).sin() * 8.0 + + (f64::from(i) * 0.21).cos() * 3.0 + + f64::from(i) * 0.1 + }) + .collect(); + let mut h = HurstExponent::new(100, 4).unwrap(); + for v in h.batch(&prices).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}"); + } + } + + #[test] + fn trending_series_above_half() { + // A clean monotonic ramp is the textbook persistent series; the R/S + // pairs must lie above the random-walk baseline. + let prices: Vec = (0..200).map(f64::from).collect(); + let mut h = HurstExponent::new(100, 4).unwrap(); + let last = h.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last > 0.5, + "trending series should have H > 0.5, got {last}" + ); + } + + #[test] + fn reset_clears_state() { + let mut h = HurstExponent::new(20, 4).unwrap(); + for i in 0..20 { + h.update(f64::from(i)); + } + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0) + .collect(); + let batch = HurstExponent::new(50, 4).unwrap().batch(&prices); + let mut b = HurstExponent::new(50, 4).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/ichimoku.rs b/crates/wickra-core/src/indicators/ichimoku.rs new file mode 100644 index 0000000..cf5261e --- /dev/null +++ b/crates/wickra-core/src/indicators/ichimoku.rs @@ -0,0 +1,434 @@ +//! Ichimoku Kinko Hyo — the five-line cloud chart. +//! +//! The Ichimoku system bundles five distinct lines computed from highs, lows +//! and closes: +//! +//! - **Tenkan-sen** (Conversion Line): midpoint of the last `tenkan_period` +//! highs and lows (default 9). +//! - **Kijun-sen** (Base Line): midpoint over `kijun_period` (default 26). +//! - **Senkou Span A** (Leading A): `(tenkan + kijun) / 2`, shifted *forward* +//! `displacement` bars. +//! - **Senkou Span B** (Leading B): midpoint over `senkou_b_period` (default +//! 52), also shifted forward `displacement` bars. +//! - **Chikou Span** (Lagging Span): the current close, displayed `displacement` +//! bars *backwards*. +//! +//! The two Senkou Spans form the **Kumo** (cloud). At step *n* the visible +//! Senkou A/B are computed from data at step *n − displacement*; the visible +//! Chikou is the close from step *n + displacement* in a chart, but in a +//! streaming setting the only Chikou we can emit at step *n* is the close from +//! *n − displacement*. That convention matches every TA library that processes +//! candles in chronological order. + +#![allow(clippy::too_many_arguments)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// All five Ichimoku lines at one step. +/// +/// `tenkan` and `kijun` reflect data up to and including the current bar. +/// `senkou_a` / `senkou_b` are the leading-span values *visible at the current +/// bar*, computed from `displacement` bars ago. `chikou` is the close from +/// `displacement` bars ago (its "lagging" placement on charts). +/// +/// Any field that is not yet defined (insufficient history) is `None`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct IchimokuOutput { + /// Tenkan-sen — midpoint of the last `tenkan_period` highs/lows. + pub tenkan: Option, + /// Kijun-sen — midpoint of the last `kijun_period` highs/lows. + pub kijun: Option, + /// Senkou Span A as visible at the current bar (computed from + /// `(tenkan + kijun) / 2` at step `n - displacement`). + pub senkou_a: Option, + /// Senkou Span B as visible at the current bar (computed from the + /// `senkou_b_period` midpoint at step `n - displacement`). + pub senkou_b: Option, + /// Chikou Span — the close from `displacement` bars ago. + pub chikou: Option, +} + +/// Ichimoku Kinko Hyo indicator. +/// +/// Standard parameters are `(9, 26, 52, 26)`. The first fully-populated output +/// (every field `Some`) appears after `senkou_b_period + displacement - 1` +/// candles — 77 bars at the defaults — because Senkou B needs its own 52-bar +/// midpoint *and* a 26-bar history of those midpoints to displace from. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Ichimoku, Indicator}; +/// +/// let mut ichi = Ichimoku::classic(); +/// for i in 0..120 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 2.0, p - 2.0, p + 1.0, 0.0, i64::from(i)).unwrap(); +/// ichi.update(candle); +/// } +/// let out = ichi.value().unwrap(); +/// assert!(out.tenkan.is_some() && out.kijun.is_some()); +/// assert!(out.senkou_a.is_some() && out.senkou_b.is_some()); +/// assert!(out.chikou.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ichimoku { + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, + // Rolling window of recent highs/lows for the longest lookback we need. + highs: VecDeque, + lows: VecDeque, + // Past (tenkan+kijun)/2 values used to emit the displaced Senkou A. + senkou_a_history: VecDeque, + // Past Senkou B midpoint values used to emit the displaced Senkou B. + senkou_b_history: VecDeque, + // Past closes for the lagging Chikou span. + close_history: VecDeque, + last: Option, +} + +impl Ichimoku { + /// Construct an Ichimoku indicator with custom periods. + /// + /// `tenkan_period` is the short midpoint window (default 9), `kijun_period` + /// the medium (default 26), `senkou_b_period` the long (default 52), and + /// `displacement` the forward/backward shift in bars (default 26). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`, + /// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`] + /// if the periods are not in strictly increasing order + /// (`tenkan < kijun < senkou_b`). + pub fn new( + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, + ) -> Result { + if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 { + return Err(Error::PeriodZero); + } + if tenkan_period >= kijun_period || kijun_period >= senkou_b_period { + return Err(Error::InvalidPeriod { + message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b", + }); + } + let cap = senkou_b_period; + Ok(Self { + tenkan_period, + kijun_period, + senkou_b_period, + displacement, + highs: VecDeque::with_capacity(cap), + lows: VecDeque::with_capacity(cap), + senkou_a_history: VecDeque::with_capacity(displacement), + senkou_b_history: VecDeque::with_capacity(displacement), + close_history: VecDeque::with_capacity(displacement), + last: None, + }) + } + + /// Classical `(9, 26, 52, 26)` configuration. + pub fn classic() -> Self { + Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid") + } + + /// Configured periods as `(tenkan, kijun, senkou_b, displacement)`. + pub const fn periods(&self) -> (usize, usize, usize, usize) { + ( + self.tenkan_period, + self.kijun_period, + self.senkou_b_period, + self.displacement, + ) + } + + /// Most recent output if at least one bar has been consumed. + pub const fn value(&self) -> Option { + self.last + } + + /// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n` + /// (the caller checks). + fn midpoint(&self, n: usize) -> f64 { + let len = self.highs.len(); + let start = len - n; + let mut hi = f64::NEG_INFINITY; + let mut lo = f64::INFINITY; + for i in start..len { + hi = hi.max(self.highs[i]); + lo = lo.min(self.lows[i]); + } + f64::midpoint(hi, lo) + } +} + +impl Indicator for Ichimoku { + type Input = Candle; + type Output = IchimokuOutput; + + fn update(&mut self, candle: Candle) -> Option { + // Ring-buffer the new bar; cap at the longest lookback. + if self.highs.len() == self.senkou_b_period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + + let tenkan = + (self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period)); + let kijun = + (self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period)); + let senkou_b_now = + (self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period)); + + // Today's contribution to the leading spans (will become visible after + // `displacement` more bars). + let senkou_a_now = match (tenkan, kijun) { + (Some(t), Some(k)) => Some(f64::midpoint(t, k)), + _ => None, + }; + + // The currently-visible Senkou A/B at this bar are the values that were + // computed `displacement` bars ago. We always push the freshly-computed + // `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with + // bars; NaN encodes "no value yet" so the buffer indices stay simple. + let push_or_nan = |q: &mut VecDeque, v: Option, cap: usize| { + if q.len() == cap { + q.pop_front(); + } + q.push_back(v.unwrap_or(f64::NAN)); + }; + push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement); + push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement); + + // The visible Senkou A/B at the current bar were buffered exactly + // `displacement` updates ago, which is `self.senkou_*_history.front()` + // once the buffer is full. + let take_front = |q: &VecDeque, cap: usize| -> Option { + if q.len() == cap { + let v = q[0]; + if v.is_nan() { + None + } else { + Some(v) + } + } else { + None + } + }; + let senkou_a = take_front(&self.senkou_a_history, self.displacement); + let senkou_b = take_front(&self.senkou_b_history, self.displacement); + + // Chikou: close from `displacement` bars ago. + if self.close_history.len() == self.displacement { + self.close_history.pop_front(); + } + self.close_history.push_back(candle.close); + let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]); + + let out = IchimokuOutput { + tenkan, + kijun, + senkou_a, + senkou_b, + chikou, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.senkou_a_history.clear(); + self.senkou_b_history.clear(); + self.close_history.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // First fully-populated row needs senkou_b's midpoint to have travelled + // `displacement` bars forward. + self.senkou_b_period + self.displacement - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some_and(|o| { + o.tenkan.is_some() + && o.kijun.is_some() + && o.senkou_a.is_some() + && o.senkou_b.is_some() + && o.chikou.is_some() + }) + } + + fn name(&self) -> &'static str { + "Ichimoku" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle { + Candle::new(cl, h, l, cl, 0.0, i).unwrap() + } + + fn ramp(n: i64) -> Vec { + (0..n) + .map(|i| { + let p = 100.0 + f64::from(i32::try_from(i).unwrap()); + c(p + 2.0, p - 2.0, p + 1.0, i) + }) + .collect() + } + + #[test] + fn rejects_zero_periods() { + assert!(matches!( + Ichimoku::new(0, 26, 52, 26), + Err(Error::PeriodZero) + )); + assert!(matches!( + Ichimoku::new(9, 0, 52, 26), + Err(Error::PeriodZero) + )); + assert!(matches!( + Ichimoku::new(9, 26, 0, 26), + Err(Error::PeriodZero) + )); + assert!(matches!( + Ichimoku::new(9, 26, 52, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_increasing_periods() { + assert!(matches!( + Ichimoku::new(26, 26, 52, 26), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Ichimoku::new(9, 52, 52, 26), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Ichimoku::new(52, 26, 9, 26), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let ichi = Ichimoku::classic(); + assert_eq!(ichi.periods(), (9, 26, 52, 26)); + assert_eq!(ichi.warmup_period(), 77); + assert_eq!(ichi.name(), "Ichimoku"); + assert!(ichi.value().is_none()); + } + + #[test] + fn tenkan_emits_at_period() { + let mut ichi = Ichimoku::classic(); + let candles = ramp(10); + let out = ichi.batch(&candles); + // The 9th update is the first time tenkan has 9 highs/lows. + for (i, o) in out.iter().enumerate() { + let v = o.unwrap(); + if i < 8 { + assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars"); + } else { + assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on"); + } + } + } + + #[test] + fn fully_populated_after_warmup() { + let mut ichi = Ichimoku::classic(); + let candles = ramp(120); + let out = ichi.batch(&candles); + let last = out.last().unwrap().unwrap(); + assert!(last.tenkan.is_some()); + assert!(last.kijun.is_some()); + assert!(last.senkou_a.is_some()); + assert!(last.senkou_b.is_some()); + assert!(last.chikou.is_some()); + assert!(ichi.is_ready()); + } + + #[test] + fn ramp_tenkan_equals_window_midpoint() { + // On a strict ramp the midpoint of the last 9 (high, low) candles is + // the midpoint of the first and last bar in that window. + let mut ichi = Ichimoku::classic(); + let candles = ramp(20); + let out = ichi.batch(&candles); + // At index 8 (9th bar), the window is bars 0..=8 with highs 102..110 + // and lows 98..106. Midpoint = (110 + 98) / 2 = 104. + let v = out[8].unwrap(); + assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12); + } + + #[test] + fn chikou_is_close_displacement_bars_back() { + let mut ichi = Ichimoku::classic(); + let candles = ramp(60); + let out = ichi.batch(&candles); + // Displacement = 26; at bar index 25, chikou is the close from bar 0. + let v = out[25].unwrap(); + assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12); + let v = out[50].unwrap(); + assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles = ramp(120); + let mut a = Ichimoku::classic(); + let mut b = Ichimoku::classic(); + let batched = a.batch(&candles); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batched.len(), streamed.len()); + for (lhs, rhs) in batched.iter().zip(streamed.iter()) { + let (l, r) = (lhs.unwrap(), rhs.unwrap()); + assert_eq!(l.tenkan, r.tenkan); + assert_eq!(l.kijun, r.kijun); + assert_eq!(l.senkou_a, r.senkou_a); + assert_eq!(l.senkou_b, r.senkou_b); + assert_eq!(l.chikou, r.chikou); + } + } + + #[test] + fn reset_clears_state() { + let mut ichi = Ichimoku::classic(); + ichi.batch(&ramp(100)); + assert!(ichi.is_ready()); + ichi.reset(); + assert!(!ichi.is_ready()); + assert!(ichi.value().is_none()); + } + + #[test] + fn custom_periods_accepted() { + let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap(); + let out = ichi.batch(&ramp(40)); + let last = out.last().unwrap().unwrap(); + assert!(last.tenkan.is_some()); + assert!(last.senkou_a.is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/identical_three_crows.rs b/crates/wickra-core/src/indicators/identical_three_crows.rs new file mode 100644 index 0000000..22ed373 --- /dev/null +++ b/crates/wickra-core/src/indicators/identical_three_crows.rs @@ -0,0 +1,230 @@ +//! Identical Three Crows candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Identical Three Crows — a 3-bar bearish reversal: three consecutive red +/// candles with steadily lower closes where each candle opens at (or very near) +/// the prior candle's close, so the bodies stack in an identical staircase. +/// +/// ```text +/// tol_n = tolerance * max(|open|, |prev.close|) +/// all three red (close < open) +/// declining closes (bar2.close < bar1.close, bar3.close < bar2.close) +/// bar2 opens at bar1's close (|bar2.open − bar1.close| <= tol_2) +/// bar3 opens at bar2's close (|bar3.open − bar2.close| <= tol_3) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Identical +/// Three Crows is a single-direction (bearish-only) pattern, so it never emits +/// `+1.0`. The first two bars always return `0.0` because the three-bar window +/// is not yet filled. `tolerance` defaults to `0.001` (10 bps relative) and must +/// lie in `[0, 1)`. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, IdenticalThreeCrows, Indicator}; +/// +/// let mut indicator = IdenticalThreeCrows::new(); +/// indicator.update(Candle::new(13.0, 13.1, 11.9, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(12.0, 12.1, 10.9, 11.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.0, 11.1, 9.9, 10.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct IdenticalThreeCrows { + tolerance: f64, + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl Default for IdenticalThreeCrows { + fn default() -> Self { + Self::new() + } +} + +impl IdenticalThreeCrows { + /// Construct a detector with the default relative tolerance (1e-3). + pub const fn new() -> Self { + Self { + tolerance: 0.001, + prev: None, + prev_prev: None, + has_emitted: false, + } + } + + /// Construct a detector with a custom relative tolerance. + /// + /// `tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(tolerance: f64) -> Result { + if !(0.0..1.0).contains(&tolerance) { + return Err(Error::InvalidPeriod { + message: "identical three crows tolerance must lie in [0, 1)", + }); + } + Ok(Self { + tolerance, + prev: None, + prev_prev: None, + has_emitted: false, + }) + } + + /// Configured relative tolerance. + pub fn tolerance(&self) -> f64 { + self.tolerance + } +} + +impl Indicator for IdenticalThreeCrows { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (pp, p) else { + return Some(0.0); + }; + let tol2 = self.tolerance * bar2.open.abs().max(bar1.close.abs()); + let tol3 = self.tolerance * candle.open.abs().max(bar2.close.abs()); + if bar1.close < bar1.open + && bar2.close < bar2.open + && candle.close < candle.open + && bar2.close < bar1.close + && candle.close < bar2.close + && (bar2.open - bar1.close).abs() <= tol2 + && (candle.open - bar2.close).abs() <= tol3 + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "IdenticalThreeCrows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(IdenticalThreeCrows::with_tolerance(-0.01).is_err()); + assert!(IdenticalThreeCrows::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = IdenticalThreeCrows::with_tolerance(0.0).unwrap(); + assert!((t.tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = IdenticalThreeCrows::default(); + assert_eq!(t.name(), "IdenticalThreeCrows"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + assert!((t.tolerance() - 0.001).abs() < 1e-12); + } + + #[test] + fn identical_three_crows_is_minus_one() { + let mut t = IdenticalThreeCrows::new(); + // Three red candles, each opening at the prior close, declining. + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 12.1, 10.9, 11.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.0, 11.1, 9.9, 10.0, 2)), Some(-1.0)); + } + + #[test] + fn non_identical_opens_yield_zero() { + let mut t = IdenticalThreeCrows::new(); + t.update(c(13.0, 13.1, 11.9, 12.0, 0)); + t.update(c(12.0, 12.1, 10.9, 11.0, 1)); + // bar3 opens at 10.0, far from bar2's close (11.0) -> not identical. + assert_eq!(t.update(c(10.0, 10.1, 8.9, 9.0, 2)), Some(0.0)); + } + + #[test] + fn rising_close_yields_zero() { + let mut t = IdenticalThreeCrows::new(); + t.update(c(13.0, 13.1, 11.9, 12.0, 0)); + t.update(c(12.0, 12.1, 10.9, 11.0, 1)); + // bar3 is green -> not three crows. + assert_eq!(t.update(c(11.0, 12.2, 10.9, 12.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = IdenticalThreeCrows::new(); + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 12.1, 10.9, 11.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 - i as f64; + c(base, base + 0.1, base - 1.1, base - 1.0, i) + }) + .collect(); + let mut a = IdenticalThreeCrows::new(); + let mut b = IdenticalThreeCrows::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = IdenticalThreeCrows::new(); + t.update(c(13.0, 13.1, 11.9, 12.0, 0)); + t.update(c(12.0, 12.1, 10.9, 11.0, 1)); + t.update(c(11.0, 11.1, 9.9, 10.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/imbalance_bars.rs b/crates/wickra-core/src/indicators/imbalance_bars.rs new file mode 100644 index 0000000..b01741f --- /dev/null +++ b/crates/wickra-core/src/indicators/imbalance_bars.rs @@ -0,0 +1,263 @@ +//! Tick-imbalance bar builder (simplified López de Prado) — sample on cumulative signed order flow. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed imbalance bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ImbalanceBar { + /// Open of the first candle in the bar. + pub open: f64, + /// Highest high across the bar. + pub high: f64, + /// Lowest low across the bar. + pub low: f64, + /// Close of the candle that closed the bar. + pub close: f64, + /// Signed cumulative tick imbalance at the close (`Σ sign`). + pub imbalance: f64, + /// `+1` if buy-side imbalance closed the bar, `-1` if sell-side. + pub direction: i8, +} + +/// Tick-imbalance bar builder — a **simplified** form of López de Prado's +/// imbalance bars. +/// +/// Each candle is assigned a tick sign by the tick rule: `+1` if its close is above +/// the previous close, `-1` if below, and the previous sign is carried on an +/// unchanged close. The signed imbalance `θ = Σ sign` accumulates until its absolute +/// value reaches a fixed `threshold`, at which point a bar closes. Imbalance bars +/// therefore sample the market when order flow becomes *one-sided* — a burst of +/// persistent buying or selling — rather than on time, count, or volume. This makes +/// them sensitive to informed, directional trading. +/// +/// **Simplification.** The full method estimates a *dynamic* threshold +/// `E[T] · |2P − 1|` from an EWMA of the expected bar length `E[T]` and the buy-tick +/// probability `P`, and can weight each sign by volume (volume-imbalance bars) or +/// traded value (dollar-imbalance bars). This builder uses a **fixed** threshold on +/// the unweighted tick imbalance. For the adaptive estimator and the volume/dollar +/// variants, see López de Prado (2018), ch. 2. +/// +/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an +/// empty vector or a single [`ImbalanceBar`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, ImbalanceBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut bars = ImbalanceBars::new(3.0).unwrap(); +/// bars.update(flat(10.0)); // seed, no sign +/// bars.update(flat(11.0)); // +1 +/// bars.update(flat(12.0)); // +2 +/// let out = bars.update(flat(13.0)); // +3 -> close +/// assert_eq!(out.len(), 1); +/// assert_eq!(out[0].direction, 1); +/// ``` +#[derive(Debug, Clone)] +pub struct ImbalanceBars { + threshold: f64, + count: usize, + open: f64, + high: f64, + low: f64, + close: f64, + prev_close: Option, + last_sign: i8, + theta: f64, +} + +impl ImbalanceBars { + /// Construct an imbalance-bar builder with the given absolute imbalance threshold. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `threshold` is not finite and positive. + pub fn new(threshold: f64) -> Result { + if !threshold.is_finite() || threshold <= 0.0 { + return Err(Error::InvalidPeriod { + message: "threshold must be finite and positive", + }); + } + Ok(Self { + threshold, + count: 0, + open: 0.0, + high: 0.0, + low: 0.0, + close: 0.0, + prev_close: None, + last_sign: 0, + theta: 0.0, + }) + } + + /// Configured absolute imbalance threshold. + pub const fn threshold(&self) -> f64 { + self.threshold + } + + /// Signed imbalance accumulated into the in-progress bar. + pub const fn imbalance(&self) -> f64 { + self.theta + } +} + +impl BarBuilder for ImbalanceBars { + type Bar = ImbalanceBar; + + fn update(&mut self, candle: Candle) -> Vec { + if self.count == 0 { + self.open = candle.open; + self.high = candle.high; + self.low = candle.low; + } else { + self.high = self.high.max(candle.high); + self.low = self.low.min(candle.low); + } + self.close = candle.close; + self.count += 1; + if let Some(prev) = self.prev_close { + let sign = if candle.close > prev { + 1 + } else if candle.close < prev { + -1 + } else { + self.last_sign + }; + self.last_sign = sign; + self.theta += f64::from(sign); + } + self.prev_close = Some(candle.close); + if self.theta.abs() < self.threshold { + return Vec::new(); + } + let direction = if self.theta > 0.0 { 1 } else { -1 }; + let bar = ImbalanceBar { + open: self.open, + high: self.high, + low: self.low, + close: self.close, + imbalance: self.theta, + direction, + }; + self.count = 0; + self.theta = 0.0; + vec![bar] + } + + fn reset(&mut self) { + self.count = 0; + self.prev_close = None; + self.last_sign = 0; + self.theta = 0.0; + } + + fn name(&self) -> &'static str { + "ImbalanceBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(matches!( + ImbalanceBars::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ImbalanceBars::new(-3.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ImbalanceBars::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bars = ImbalanceBars::new(10.0).unwrap(); + assert_relative_eq!(bars.threshold(), 10.0, epsilon = 1e-12); + assert_relative_eq!(bars.imbalance(), 0.0, epsilon = 1e-12); + assert_eq!(bars.name(), "ImbalanceBars"); + } + + #[test] + fn buy_imbalance_closes_up_bar() { + let mut bars = ImbalanceBars::new(3.0).unwrap(); + bars.update(flat(10.0)); // seed + bars.update(flat(11.0)); // +1 + bars.update(flat(12.0)); // +2 + let out = bars.update(flat(13.0)); // +3 + assert_eq!(out.len(), 1); + assert_eq!(out[0].direction, 1); + assert_relative_eq!(out[0].imbalance, 3.0, epsilon = 1e-12); + } + + #[test] + fn sell_imbalance_closes_down_bar() { + let mut bars = ImbalanceBars::new(3.0).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(9.0)); // -1 + bars.update(flat(8.0)); // -2 + let out = bars.update(flat(7.0)); // -3 + assert_eq!(out.len(), 1); + assert_eq!(out[0].direction, -1); + } + + #[test] + fn flat_tick_carries_previous_sign() { + let mut bars = ImbalanceBars::new(3.0).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // +1 + bars.update(flat(11.0)); // flat -> carries +1 -> +2 + assert_relative_eq!(bars.imbalance(), 2.0, epsilon = 1e-12); + } + + #[test] + fn oscillation_does_not_reach_threshold() { + let mut bars = ImbalanceBars::new(3.0).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // +1 + bars.update(flat(10.0)); // -1 -> theta 0 + assert!(bars.update(flat(11.0)).is_empty()); // +1 + assert_relative_eq!(bars.imbalance(), 1.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut bars = ImbalanceBars::new(3.0).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); + bars.reset(); + assert_relative_eq!(bars.imbalance(), 0.0, epsilon = 1e-12); + // After reset the next candle re-seeds (no previous close). + assert!(bars.update(flat(50.0)).is_empty()); + } + + #[test] + fn batch_concatenates_completed_bars() { + let mut bars = ImbalanceBars::new(2.0).unwrap(); + let candles = [ + flat(10.0), + flat(11.0), // +1 + flat(12.0), // +2 -> close + flat(13.0), // +1 + flat(14.0), // +2 -> close + ]; + let out = bars.batch(&candles); + assert_eq!(out.len(), 2); + assert!(out.iter().all(|b| b.direction == 1)); + } +} diff --git a/crates/wickra-core/src/indicators/in_neck.rs b/crates/wickra-core/src/indicators/in_neck.rs new file mode 100644 index 0000000..422ef1c --- /dev/null +++ b/crates/wickra-core/src/indicators/in_neck.rs @@ -0,0 +1,191 @@ +//! In-Neck candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// In-Neck — a 2-bar bearish continuation, slightly stronger than On-Neck. A long +/// black candle in a decline is followed by a white candle that opens below the +/// black bar's low and closes just barely *into* the black body, around its close +/// level. The shallow recovery still favours the sellers. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1 black & long +/// bar2 white, opens below bar1's low (open2 < low1) +/// bar2 closes just into bar1's body (close1 <= close2 <= close1 + 0.1 · body1) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. In-Neck is a +/// single-direction (bearish-only) continuation, so it never emits `+1.0`. The +/// first bar always returns `0.0` because the two-bar window is not yet filled. +/// Body and neckline thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, InNeck, Indicator}; +/// +/// let mut indicator = InNeck::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(7.0, 10.3, 6.9, 10.2, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct InNeck { + prev: Option, + has_emitted: bool, +} + +impl InNeck { + /// Construct a new In-Neck detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for InNeck { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + if range1 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.open - bar1.close; + if bar1.close < bar1.open + && body1 >= 0.5 * range1 + && candle.close > candle.open + && candle.open < bar1.low + && candle.close >= bar1.close + && candle.close <= bar1.close + 0.1 * body1 + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "InNeck" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = InNeck::new(); + assert_eq!(t.name(), "InNeck"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn in_neck_is_minus_one() { + let mut t = InNeck::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(7.0, 10.3, 6.9, 10.2, 1)), Some(-1.0)); + } + + #[test] + fn close_at_low_yields_zero() { + let mut t = InNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + // Closes at the prior low, not into the body -> on-neck, not in-neck. + assert_eq!(t.update(c(7.0, 9.1, 6.9, 9.0, 1)), Some(0.0)); + } + + #[test] + fn close_past_neck_yields_zero() { + let mut t = InNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + // Closes well into the body -> thrusting, not in-neck. + assert_eq!(t.update(c(7.0, 11.6, 6.9, 11.5, 1)), Some(0.0)); + } + + #[test] + fn second_bar_black_yields_zero() { + let mut t = InNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + assert_eq!(t.update(c(10.4, 10.5, 6.9, 10.1, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = InNeck::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 5.0, base + 5.1, base - 1.0, base, i) + }) + .collect(); + let mut a = InNeck::new(); + let mut b = InNeck::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = InNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + t.update(c(7.0, 10.3, 6.9, 10.2, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_first_bar_yields_zero() { + let mut t = InNeck::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/inertia.rs b/crates/wickra-core/src/indicators/inertia.rs new file mode 100644 index 0000000..63ad825 --- /dev/null +++ b/crates/wickra-core/src/indicators/inertia.rs @@ -0,0 +1,179 @@ +//! Inertia (Donald Dorsey). + +use crate::error::{Error, Result}; +use crate::indicators::linreg::LinearRegression; +use crate::indicators::rvi::Rvi; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Donald Dorsey's Inertia — a Linear-Regression-smoothed `RVI` (Relative Vigor +/// Index). The endpoint of an `n`-bar least-squares fit of the `RVI` series is +/// taken as the indicator's reading, smoothing the underlying ratio while +/// preserving its trend direction. +/// +/// ```text +/// Inertia_t = LinearRegression(RVI(close - open, high - low; rvi_period), linreg_period)_t +/// ``` +/// +/// Dorsey's recommended defaults are `(rvi_period = 14, linreg_period = 20)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Inertia}; +/// +/// let mut inertia = Inertia::new(14, 20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let o = 100.0 + f64::from(i); +/// let c = o + 0.5; +/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap(); +/// last = inertia.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Inertia { + rvi_period: usize, + linreg_period: usize, + rvi: Rvi, + linreg: LinearRegression, +} + +impl Inertia { + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero. + pub fn new(rvi_period: usize, linreg_period: usize) -> Result { + if rvi_period == 0 || linreg_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + rvi_period, + linreg_period, + rvi: Rvi::new(rvi_period)?, + linreg: LinearRegression::new(linreg_period)?, + }) + } + + /// Dorsey's recommended defaults `(rvi_period = 14, linreg_period = 20)`. + pub fn classic() -> Self { + Self::new(14, 20).expect("classic Inertia parameters are valid") + } + + /// Configured `(rvi_period, linreg_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.rvi_period, self.linreg_period) + } +} + +impl Indicator for Inertia { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let rvi = self.rvi.update(candle)?; + self.linreg.update(rvi) + } + + fn reset(&mut self) { + self.rvi.reset(); + self.linreg.reset(); + } + + fn warmup_period(&self) -> usize { + // RVI emits at `rvi_period` candles; the LinearRegression then needs + // `linreg_period − 1` more RVI values to fill its window. + self.rvi_period + self.linreg_period - 1 + } + + fn is_ready(&self) -> bool { + self.linreg.is_ready() + } + + fn name(&self) -> &'static str { + "Inertia" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Inertia::new(0, 20), Err(Error::PeriodZero))); + assert!(matches!(Inertia::new(14, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let inertia = Inertia::classic(); + assert_eq!(inertia.periods(), (14, 20)); + assert_eq!(inertia.warmup_period(), 33); + assert_eq!(inertia.name(), "Inertia"); + } + + #[test] + fn classic_factory() { + assert_eq!(Inertia::classic().periods(), (14, 20)); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + // Smaller periods for a fast test: RVI(3) emits at 3 candles, then + // LinReg(4) needs 4 RVI values -> total 3 + 4 - 1 = 6. + let mut inertia = Inertia::new(3, 4).unwrap(); + assert_eq!(inertia.warmup_period(), 6); + for i in 0..5 { + assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)), None); + } + assert!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 5)).is_some()); + } + + #[test] + fn constant_rvi_yields_constant_inertia() { + // Every bar identical -> RVI is constant -> LinReg of a constant + // series equals that constant after warmup. + let mut inertia = Inertia::new(3, 4).unwrap(); + let mut last = None; + for i in 0..40 { + last = inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + // RVI = SMA(c-o, 3) / SMA(h-l, 3) = 0.5 / 2.0 = 0.25 on every bar. + let v = last.unwrap(); + assert_relative_eq!(v, 0.25, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let o = 100.0 + (i as f64 * 0.3).sin() * 5.0; + let c = o + (i as f64 * 0.1).cos(); + candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i) + }) + .collect(); + let batch = Inertia::classic().batch(&candles); + let mut b = Inertia::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut inertia = Inertia::classic(); + for i in 0..50 { + inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + assert!(inertia.is_ready()); + inertia.reset(); + assert!(!inertia.is_ready()); + assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/information_ratio.rs b/crates/wickra-core/src/indicators/information_ratio.rs new file mode 100644 index 0000000..339beca --- /dev/null +++ b/crates/wickra-core/src/indicators/information_ratio.rs @@ -0,0 +1,187 @@ +//! Rolling Information Ratio. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Information Ratio. +/// +/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over +/// the trailing window of `period` pairs: +/// +/// ```text +/// active_t = asset_t − benchmark_t +/// tracking_error = stddev(active over window) (sample) +/// IR = mean(active) / tracking_error +/// ``` +/// +/// The Information Ratio quantifies skill in beating a benchmark per unit +/// of active-return volatility. A high IR means consistent (low-noise) +/// outperformance; a near-zero IR means the asset moves with the benchmark +/// regardless of any small alpha. +/// +/// If the tracking error is zero (asset perfectly tracks the benchmark over +/// the window) the indicator returns `0.0` rather than `NaN`. +/// +/// Each `update` is O(1). +#[derive(Debug, Clone)] +pub struct InformationRatio { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl InformationRatio { + /// Construct a new rolling Information Ratio. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "information ratio needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for InformationRatio { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + let active = a - b; + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(active); + self.sum += active; + self.sum_sq += active * active; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let var = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + let te = var.sqrt(); + if te == 0.0 { + return Some(0.0); + } + Some(mean / te) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "InformationRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_less_than_two() { + assert!(matches!( + InformationRatio::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let i = InformationRatio::new(10).unwrap(); + assert_eq!(i.period(), 10); + assert_eq!(i.name(), "InformationRatio"); + assert_eq!(i.warmup_period(), 10); + } + + #[test] + fn perfect_tracking_yields_zero() { + // asset == benchmark every bar -> active = 0 -> te = 0 -> 0. + let mut i = InformationRatio::new(5).unwrap(); + let inputs: Vec<(f64, f64)> = (0..5) + .map(|j| (f64::from(j) * 0.01, f64::from(j) * 0.01)) + .collect(); + let out = i.batch(&inputs); + assert_eq!(out[4], Some(0.0)); + } + + #[test] + fn reference_value() { + // asset=[0.02,0.04,0.06,0.08], bench=[0.01,0.02,0.03,0.04]. + // active=[0.01,0.02,0.03,0.04]; mean=0.025; + // var = ((0.01-.025)^2 + ... ) / 3 = 0.0001666...; + // te = sqrt(0.0001666...); IR = 0.025/te. + let mut i = InformationRatio::new(4).unwrap(); + let inputs = vec![(0.02, 0.01), (0.04, 0.02), (0.06, 0.03), (0.08, 0.04)]; + let out = i.batch(&inputs); + let expected = 0.025 / (0.000_166_666_666_666_666_67_f64).sqrt(); + assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut i = InformationRatio::new(3).unwrap(); + assert_eq!(i.update((f64::NAN, 0.01)), None); + assert_eq!(i.update((0.01, f64::INFINITY)), None); + } + + #[test] + fn reset_clears_state() { + let mut i = InformationRatio::new(3).unwrap(); + i.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]); + assert!(i.is_ready()); + i.reset(); + assert!(!i.is_ready()); + assert_eq!(i.update((0.01, 0.005)), None); + } + + #[test] + fn batch_equals_streaming() { + let inputs: Vec<(f64, f64)> = (0..50) + .map(|j| { + let b = (f64::from(j) * 0.2).sin() * 0.01; + (b + 0.001, b) + }) + .collect(); + let batch = InformationRatio::new(10).unwrap().batch(&inputs); + let mut s = InformationRatio::new(10).unwrap(); + let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/initial_balance.rs b/crates/wickra-core/src/indicators/initial_balance.rs new file mode 100644 index 0000000..d9fee98 --- /dev/null +++ b/crates/wickra-core/src/indicators/initial_balance.rs @@ -0,0 +1,254 @@ +//! Initial Balance (IB): the high / low established over the first N bars of +//! a session. +//! +//! Tracks the running session high and session low across the first `period` +//! candles received since construction or [`InitialBalance::reset`]. Once the +//! `period`th candle has been ingested the value is frozen and every +//! subsequent call to [`Indicator::update`] returns the same locked +//! [`InitialBalanceOutput`] until the caller invokes `reset()` at the start of +//! a new session. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Initial Balance output: the high / low of the first N bars of a session. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InitialBalanceOutput { + /// Session-opening high established over the IB window. + pub high: f64, + /// Session-opening low established over the IB window. + pub low: f64, +} + +/// Session Initial Balance (first N bars). +/// +/// `period` defaults to **12** — the canonical one-hour IB on 5-minute bars +/// for U.S. equities. Callers MUST invoke [`Indicator::reset`] at every new +/// session boundary; otherwise the IB locks after the first `period` bars and +/// stays fixed for the entire lifetime of the instance. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, InitialBalance, Indicator}; +/// +/// let mut ib = InitialBalance::new(3).unwrap(); +/// let bars = [ +/// Candle::new(100.0, 102.0, 99.0, 101.0, 10.0, 0).unwrap(), +/// Candle::new(101.0, 103.0, 100.0, 102.0, 10.0, 1).unwrap(), +/// Candle::new(102.0, 104.0, 101.0, 103.0, 10.0, 2).unwrap(), +/// // Locked after period bars — subsequent bars do not modify IB. +/// Candle::new(103.0, 120.0, 80.0, 105.0, 10.0, 3).unwrap(), +/// ]; +/// for b in bars { +/// ib.update(b); +/// } +/// let v = ib.value().unwrap(); +/// assert_eq!(v.high, 104.0); +/// assert_eq!(v.low, 99.0); +/// ``` +#[derive(Debug, Clone)] +pub struct InitialBalance { + period: usize, + bars_seen: usize, + high: f64, + low: f64, + locked: bool, +} + +impl InitialBalance { + /// Construct an Initial Balance indicator with the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bars_seen: 0, + high: f64::NEG_INFINITY, + low: f64::INFINITY, + locked: false, + }) + } + + /// Classic 12-bar Initial Balance. + pub fn classic() -> Self { + Self::new(12).expect("classic IB period is valid") + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Most recent output if at least one bar has been seen. + pub fn value(&self) -> Option { + if self.bars_seen == 0 { + None + } else { + Some(InitialBalanceOutput { + high: self.high, + low: self.low, + }) + } + } + + /// True once `period` bars have been ingested and the IB is locked. + pub const fn is_locked(&self) -> bool { + self.locked + } +} + +impl Indicator for InitialBalance { + type Input = Candle; + type Output = InitialBalanceOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.locked { + return Some(InitialBalanceOutput { + high: self.high, + low: self.low, + }); + } + if candle.high > self.high { + self.high = candle.high; + } + if candle.low < self.low { + self.low = candle.low; + } + self.bars_seen += 1; + if self.bars_seen >= self.period { + self.locked = true; + } + Some(InitialBalanceOutput { + high: self.high, + low: self.low, + }) + } + + fn reset(&mut self) { + self.bars_seen = 0; + self.high = f64::NEG_INFINITY; + self.low = f64::INFINITY; + self.locked = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.bars_seen > 0 + } + + fn name(&self) -> &'static str { + "InitialBalance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + // open / close pinned inside [low, high] so the candle validates. + let mid = f64::midpoint(high, low); + Candle::new(mid, high, low, mid, 10.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(InitialBalance::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut ib = InitialBalance::new(12).unwrap(); + assert_eq!(ib.period(), 12); + assert_eq!(ib.name(), "InitialBalance"); + assert_eq!(ib.warmup_period(), 1); + assert!(ib.value().is_none()); + assert!(!ib.is_locked()); + // After the first bar, value() returns Some with that bar's H/L. + ib.update(c(102.0, 100.0, 0)); + let v = ib.value().unwrap(); + assert_relative_eq!(v.high, 102.0); + assert_relative_eq!(v.low, 100.0); + } + + #[test] + fn classic_is_constructible() { + let ib = InitialBalance::classic(); + assert_eq!(ib.period(), 12); + } + + #[test] + fn tracks_high_low_during_window() { + let mut ib = InitialBalance::new(3).unwrap(); + let o1 = ib.update(c(102.0, 100.0, 0)).unwrap(); + assert_relative_eq!(o1.high, 102.0); + assert_relative_eq!(o1.low, 100.0); + let o2 = ib.update(c(105.0, 99.0, 1)).unwrap(); + assert_relative_eq!(o2.high, 105.0); + assert_relative_eq!(o2.low, 99.0); + let o3 = ib.update(c(103.0, 99.5, 2)).unwrap(); + assert_relative_eq!(o3.high, 105.0); + assert_relative_eq!(o3.low, 99.0); + assert!(ib.is_locked()); + } + + #[test] + fn locks_after_period_and_ignores_subsequent_bars() { + let mut ib = InitialBalance::new(2).unwrap(); + ib.update(c(102.0, 100.0, 0)); + ib.update(c(103.0, 101.0, 1)); + assert!(ib.is_locked()); + // Wide bar after lock must not modify the IB. + let after = ib.update(c(200.0, 50.0, 2)).unwrap(); + assert_relative_eq!(after.high, 103.0); + assert_relative_eq!(after.low, 100.0); + } + + #[test] + fn reset_unlocks_and_clears_state() { + let mut ib = InitialBalance::new(2).unwrap(); + ib.update(c(102.0, 100.0, 0)); + ib.update(c(103.0, 101.0, 1)); + assert!(ib.is_locked()); + ib.reset(); + assert!(!ib.is_locked()); + assert!(!ib.is_ready()); + // After reset the next session's first bar drives the IB anew. + let o = ib.update(c(50.0, 49.0, 2)).unwrap(); + assert_relative_eq!(o.high, 50.0); + assert_relative_eq!(o.low, 49.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..20) + .map(|i| c(100.0 + i as f64, 99.0 + i as f64 * 0.5, i)) + .collect(); + let mut a = InitialBalance::new(5).unwrap(); + let mut b = InitialBalance::new(5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn is_ready_after_first_bar() { + let mut ib = InitialBalance::new(5).unwrap(); + assert!(!ib.is_ready()); + ib.update(c(101.0, 99.0, 0)); + assert!(ib.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/instantaneous_trendline.rs b/crates/wickra-core/src/indicators/instantaneous_trendline.rs new file mode 100644 index 0000000..85e107e --- /dev/null +++ b/crates/wickra-core/src/indicators/instantaneous_trendline.rs @@ -0,0 +1,213 @@ +//! Ehlers Instantaneous Trendline (ITrend). +#![allow(clippy::doc_markdown)] + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' Instantaneous Trendline (ITrend). +/// +/// A 2-pole IIR that approximates a lag-free trend line: +/// +/// ```text +/// itrend[t] = (alpha - alpha^2/4) * x[t] +/// + 0.5 * alpha^2 * x[t-1] +/// - (alpha - 0.75*alpha^2) * x[t-2] +/// + 2*(1 - alpha) * itrend[t-1] +/// - (1 - alpha)^2 * itrend[t-2] +/// ``` +/// +/// where `alpha = 2 / (period + 1)`. From *Cybernetic Analysis for Stocks +/// and Futures* (Ehlers 2004, ch. 8). During the first six bars the output +/// uses the EasyLanguage initial condition `(x[t] + 2*x[t-1] + x[t-2]) / 4`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, InstantaneousTrendline}; +/// +/// let mut it = InstantaneousTrendline::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = it.update(100.0 + f64::from(i) * 0.5); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct InstantaneousTrendline { + period: usize, + alpha: f64, + in_buf: [Option; 3], + out_buf: [Option; 2], + count: usize, + last_value: Option, +} + +impl InstantaneousTrendline { + /// Construct with the dominant-cycle period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let alpha = 2.0 / (period as f64 + 1.0); + Ok(Self { + period, + alpha, + in_buf: [None; 3], + out_buf: [None; 2], + count: 0, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Smoothing alpha. + pub const fn alpha(&self) -> f64 { + self.alpha + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for InstantaneousTrendline { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + // Shift input buffer (position 0 = most recent). + self.in_buf[2] = self.in_buf[1]; + self.in_buf[1] = self.in_buf[0]; + self.in_buf[0] = Some(input); + + let alpha = self.alpha; + let v = if self.count >= 7 { + // Full recursive formula. + let (x0, x1, x2) = ( + self.in_buf[0].expect("filled"), + self.in_buf[1].expect("filled"), + self.in_buf[2].expect("filled"), + ); + let (y1, y2) = ( + self.out_buf[0].expect("filled"), + self.out_buf[1].expect("filled"), + ); + (alpha - alpha * alpha / 4.0) * x0 + 0.5 * alpha * alpha * x1 + - (alpha - 0.75 * alpha * alpha) * x2 + + 2.0 * (1.0 - alpha) * y1 + - (1.0 - alpha) * (1.0 - alpha) * y2 + } else { + // Initial condition: 4-point weighted average of the most recent + // inputs (Ehlers EasyLanguage default). + let x0 = self.in_buf[0].expect("just pushed"); + let x1 = self.in_buf[1].unwrap_or(x0); + let x2 = self.in_buf[2].unwrap_or(x0); + (x0 + 2.0 * x1 + x2) / 4.0 + }; + + self.out_buf[1] = self.out_buf[0]; + self.out_buf[0] = Some(v); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.in_buf = [None; 3]; + self.out_buf = [None; 2]; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "InstantaneousTrendline" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + InstantaneousTrendline::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut it = InstantaneousTrendline::new(20).unwrap(); + assert_eq!(it.period(), 20); + assert_relative_eq!(it.alpha(), 2.0 / 21.0, epsilon = 1e-15); + assert_eq!(it.warmup_period(), 1); + assert_eq!(it.name(), "InstantaneousTrendline"); + assert!(!it.is_ready()); + it.update(100.0); + assert!(it.is_ready()); + } + + #[test] + fn constant_series_passes_through() { + // Coefficients sum to 1, so a flat input stays flat after warmup. + let mut it = InstantaneousTrendline::new(20).unwrap(); + let out = it.batch(&[42.0_f64; 200]); + for x in out.iter().skip(20).flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-6); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0) + .collect(); + let mut a = InstantaneousTrendline::new(15).unwrap(); + let mut b = InstantaneousTrendline::new(15).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut it = InstantaneousTrendline::new(20).unwrap(); + it.batch(&(1..=40).map(f64::from).collect::>()); + let before = it.value(); + assert!(before.is_some()); + assert_eq!(it.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut it = InstantaneousTrendline::new(20).unwrap(); + it.batch(&(1..=40).map(f64::from).collect::>()); + assert!(it.is_ready()); + it.reset(); + assert!(!it.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/intraday_intensity.rs b/crates/wickra-core/src/indicators/intraday_intensity.rs new file mode 100644 index 0000000..ba5f5dd --- /dev/null +++ b/crates/wickra-core/src/indicators/intraday_intensity.rs @@ -0,0 +1,184 @@ +//! Intraday Intensity (Bostian) — the per-bar volume-weighted close-location. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Intraday Intensity — David Bostian's per-bar measure that weights each bar's +/// volume by where the close lands inside the bar's range: +/// +/// ```text +/// II_t = volume * (2*close − high − low) / (high − low) (0 if high == low) +/// ``` +/// +/// The fraction `(2*close − high − low) / (high − low)` is `+1` when the bar +/// closes on its high, `−1` when it closes on its low, and `0` at the midpoint, +/// so `II_t` is the volume pushed toward the extremes on that single bar — +/// Bostian's proxy for per-bar accumulation (positive) or distribution +/// (negative). +/// +/// This emits the **raw per-bar** intensity, which is distinct from the two +/// derived forms Wickra ships separately: the **cumulative** running total is +/// the Accumulation/Distribution Line ([`Adl`](crate::Adl)), and the +/// volume-normalized windowed form ("Intraday Intensity %") is mathematically +/// the Chaikin Money Flow ([`Cmf`](crate::Cmf)). A doji whose `high == low` +/// contributes nothing. Each `update` is O(1) and the first bar already emits a +/// value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, IntradayIntensity}; +/// +/// let mut indicator = IntradayIntensity::new(); +/// let mut last = None; +/// for i in 0..20 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct IntradayIntensity { + last: Option, +} + +impl IntradayIntensity { + /// Construct a new Intraday Intensity. It is parameter-free. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for IntradayIntensity { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let ii = if range > 0.0 { + candle.volume * (2.0 * candle.close - candle.high - candle.low) / range + } else { + 0.0 + }; + self.last = Some(ii); + Some(ii) + } + + fn reset(&mut self) { + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "IntradayIntensity" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, volume, 0) + } + + #[test] + fn accessors_and_metadata() { + let iii = IntradayIntensity::new(); + assert_eq!(iii.warmup_period(), 1); + assert_eq!(iii.name(), "IntradayIntensity"); + assert!(!iii.is_ready()); + assert_eq!(iii.value(), None); + } + + #[test] + fn first_bar_emits() { + // close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0. + let mut iii = IntradayIntensity::new(); + // close on the high -> +1 * volume. + let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap(); + assert_relative_eq!(v, 500.0, epsilon = 1e-9); + } + + #[test] + fn close_on_high_adds_full_volume() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, 1_000.0, epsilon = 1e-9); + } + + #[test] + fn close_on_low_subtracts_full_volume() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, -1_000.0, epsilon = 1e-9); + } + + #[test] + fn close_at_midpoint_adds_nothing() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn zero_range_adds_nothing() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn each_bar_is_independent() { + // Per-bar (non-cumulative): each output depends only on that bar, so a + // close-on-high +1000 bar is not carried into the next close-on-low bar. + let mut iii = IntradayIntensity::new(); + let a = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap(); + let b = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); + assert_relative_eq!(a, 1_000.0, epsilon = 1e-9); + assert_relative_eq!(b, -400.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut iii = IntradayIntensity::new(); + iii.batch(&[ + candle(110.0, 100.0, 108.0, 1.0), + candle(110.0, 100.0, 102.0, 1.0), + ]); + assert!(iii.is_ready()); + iii.reset(); + assert!(!iii.is_ready()); + assert_eq!(iii.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0; + candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i)) + }) + .collect(); + let batch = IntradayIntensity::new().batch(&candles); + let mut b = IntradayIntensity::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/intraday_momentum_index.rs b/crates/wickra-core/src/indicators/intraday_momentum_index.rs new file mode 100644 index 0000000..a54b32a --- /dev/null +++ b/crates/wickra-core/src/indicators/intraday_momentum_index.rs @@ -0,0 +1,228 @@ +//! Intraday Momentum Index (IMI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close +/// move instead of the close-to-close move. +/// +/// For each bar the body is an up-move when `close > open` and a down-move +/// otherwise; the IMI sums those bodies over `period` bars and forms the +/// RSI-style ratio: +/// +/// ```text +/// gain = max(close - open, 0), loss = max(open - close, 0) +/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars +/// ``` +/// +/// Because it measures *intraday* (body) momentum rather than the gap-inclusive +/// close-to-close change, the IMI is a candle-pattern-flavoured overbought / +/// oversold gauge: persistent white bodies push it up, black bodies down. It is +/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the +/// neutral `50`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator}; +/// +/// let mut imi = IntradayMomentumIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap(); +/// last = imi.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct IntradayMomentumIndex { + period: usize, + /// Per-bar `(gain, loss)` bodies, oldest at the front. + window: VecDeque<(f64, f64)>, + sum_gain: f64, + sum_loss: f64, +} + +impl IntradayMomentumIndex { + /// Construct an IMI over `period` bars. + /// + /// # 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_gain: 0.0, + sum_loss: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.window.len() != self.period { + return None; + } + let denom = self.sum_gain + self.sum_loss; + if denom == 0.0 { + Some(50.0) + } else { + Some(100.0 * self.sum_gain / denom) + } + } +} + +impl Indicator for IntradayMomentumIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let body = candle.close - candle.open; + let gain = if body > 0.0 { body } else { 0.0 }; + let loss = if body < 0.0 { -body } else { 0.0 }; + + if self.window.len() == self.period { + let (old_g, old_l) = self.window.pop_front().expect("window full"); + self.sum_gain -= old_g; + self.sum_loss -= old_l; + } + self.window.push_back((gain, loss)); + self.sum_gain += gain; + self.sum_loss += loss; + self.value() + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_gain = 0.0; + self.sum_loss = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "IMI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, close: f64) -> Candle { + let hi = open.max(close) + 1.0; + let lo = open.min(close) - 1.0; + Candle::new(open, hi, lo, close, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + IntradayMomentumIndex::new(0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let imi = IntradayMomentumIndex::new(14).unwrap(); + assert_eq!(imi.period(), 14); + assert_eq!(imi.warmup_period(), 14); + assert_eq!(imi.name(), "IMI"); + } + + #[test] + fn all_up_bodies_is_one_hundred() { + let mut imi = IntradayMomentumIndex::new(3).unwrap(); + let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)]; + let out = imi.batch(&bars); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn all_down_bodies_is_zero() { + let mut imi = IntradayMomentumIndex::new(3).unwrap(); + let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)]; + assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn known_value_mixed_bodies() { + // bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75. + let mut imi = IntradayMomentumIndex::new(3).unwrap(); + let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)]; + assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12); + } + + #[test] + fn doji_window_is_neutral() { + // close == open every bar -> no bodies -> neutral 50. + let mut imi = IntradayMomentumIndex::new(3).unwrap(); + let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)]; + assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12); + } + + #[test] + fn slides_window() { + // After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67. + let mut imi = IntradayMomentumIndex::new(3).unwrap(); + let bars = [ + candle(10.0, 11.0), + candle(11.0, 10.0), + candle(10.0, 12.0), + candle(12.0, 12.0), + ]; + let out = imi.batch(&bars); + assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut imi = IntradayMomentumIndex::new(3).unwrap(); + imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]); + assert!(imi.is_ready()); + imi.reset(); + assert!(!imi.is_ready()); + assert_eq!(imi.update(candle(1.0, 2.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let bars: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base, base + (f64::from(i) * 0.5).sin()) + }) + .collect(); + let mut a = IntradayMomentumIndex::new(7).unwrap(); + let mut b = IntradayMomentumIndex::new(7).unwrap(); + assert_eq!( + a.batch(&bars), + bars.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/intraday_volatility_profile.rs b/crates/wickra-core/src/indicators/intraday_volatility_profile.rs new file mode 100644 index 0000000..c4f24af --- /dev/null +++ b/crates/wickra-core/src/indicators/intraday_volatility_profile.rs @@ -0,0 +1,236 @@ +//! Intraday Volatility Profile — the return volatility in each intraday bucket. + +use crate::calendar::civil_from_timestamp; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Intraday Volatility Profile output: the per-bucket return standard deviation. +/// +/// `bins[i]` is the sample standard deviation of the simple returns of all bars +/// whose local time-of-day fell in bucket `i`. Buckets with fewer than two +/// samples read `0.0`. +#[derive(Debug, Clone, PartialEq)] +pub struct IntradayVolatilityProfileOutput { + /// Per-bucket return standard deviation, earliest bucket first. + pub bins: Vec, +} + +/// Return volatility bucketed by local time of day. +/// +/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle) +/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each +/// bar's simple return `close / previous_close - 1` updates the per-bucket +/// running variance (Welford), and the profile reports the per-bucket sample +/// standard deviation. The first bar produces no output. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, IntradayVolatilityProfile}; +/// +/// let hour = 3_600_000; +/// let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap(); +/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none()); +/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap(); +/// assert_eq!(out.bins.len(), 24); +/// ``` +#[derive(Debug, Clone)] +pub struct IntradayVolatilityProfile { + buckets: usize, + utc_offset_minutes: i32, + prev_close: Option, + count: Vec, + mean: Vec, + m2: Vec, + last: Option, +} + +impl IntradayVolatilityProfile { + /// Construct an Intraday Volatility Profile with `buckets` intraday slices. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `buckets == 0`. + pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result { + if buckets == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + buckets, + utc_offset_minutes, + prev_close: None, + count: vec![0; buckets], + mean: vec![0.0; buckets], + m2: vec![0.0; buckets], + last: None, + }) + } + + /// Configured `(buckets, utc_offset_minutes)`. + pub const fn params(&self) -> (usize, i32) { + (self.buckets, self.utc_offset_minutes) + } + + /// Most recent profile if at least one return has been recorded. + pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> { + self.last.as_ref() + } + + fn bucket_of(&self, minute_of_day: u32) -> usize { + let raw = (minute_of_day as usize * self.buckets) / 1440; + raw.min(self.buckets - 1) + } + + fn snapshot(&self) -> IntradayVolatilityProfileOutput { + let bins = self + .count + .iter() + .zip(&self.m2) + .map(|(n, m2)| { + if *n >= 2 { + (m2 / (*n - 1) as f64).sqrt() + } else { + 0.0 + } + }) + .collect(); + IntradayVolatilityProfileOutput { bins } + } +} + +impl Indicator for IntradayVolatilityProfile { + type Input = Candle; + type Output = IntradayVolatilityProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let result = if let Some(prev) = self.prev_close { + let ret = if prev == 0.0 { + 0.0 + } else { + candle.close / prev - 1.0 + }; + let bucket = self.bucket_of(civil.minute_of_day()); + self.count[bucket] += 1; + let delta = ret - self.mean[bucket]; + self.mean[bucket] += delta / self.count[bucket] as f64; + let delta2 = ret - self.mean[bucket]; + self.m2[bucket] += delta * delta2; + let out = self.snapshot(); + self.last = Some(out.clone()); + Some(out) + } else { + None + }; + self.prev_close = Some(candle.close); + result + } + + fn reset(&mut self) { + self.prev_close = None; + self.count.iter_mut().for_each(|x| *x = 0); + self.mean.iter_mut().for_each(|x| *x = 0.0); + self.m2.iter_mut().for_each(|x| *x = 0.0); + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "IntradayVolatilityProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + const DAY: i64 = 24 * HOUR; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_buckets() { + assert!(matches!( + IntradayVolatilityProfile::new(0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn metadata_and_accessors() { + let prof = IntradayVolatilityProfile::new(24, 90).unwrap(); + assert_eq!(prof.params(), (24, 90)); + assert_eq!(prof.name(), "IntradayVolatilityProfile"); + assert_eq!(prof.warmup_period(), 2); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + } + + #[test] + fn single_sample_bucket_has_zero_vol() { + let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap(); + assert!(prof.update(c(100.0, 0)).is_none()); + let out = prof.update(c(101.0, HOUR)).unwrap(); + assert_eq!(out.bins.len(), 24); + assert_relative_eq!(out.bins[1], 0.0); // only one sample in bucket 1 + assert!(prof.is_ready()); + } + + #[test] + fn std_matches_manual_two_samples() { + let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap(); + prof.update(c(100.0, 0)); // 00:00 + prof.update(c(101.0, HOUR)); // 01:00 r=0.01 into bucket 1 + // Next day 01:00, r2 = 0.03 into bucket 1. + let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap(); + // sample std of {0.01, 0.03} = sqrt(((.01-.02)^2+(.03-.02)^2)/1) = 0.01414.. + let mean = 0.02; + let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt(); + assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9); + } + + #[test] + fn zero_prev_close_uses_zero_return() { + let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap(); + prof.update(c(0.0, 0)); + let out = prof.update(c(5.0, HOUR)).unwrap(); + assert_relative_eq!(out.bins[0], 0.0); + } + + #[test] + fn reset_clears_state() { + let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap(); + prof.update(c(100.0, 0)); + prof.update(c(101.0, HOUR)); + prof.reset(); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + assert!(prof.update(c(100.0, DAY)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR)) + .collect(); + let mut a = IntradayVolatilityProfile::new(12, 0).unwrap(); + let mut b = IntradayVolatilityProfile::new(12, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/inverse_fisher_transform.rs b/crates/wickra-core/src/indicators/inverse_fisher_transform.rs new file mode 100644 index 0000000..2dc9c48 --- /dev/null +++ b/crates/wickra-core/src/indicators/inverse_fisher_transform.rs @@ -0,0 +1,164 @@ +//! Inverse Fisher Transform (Ehlers). + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Inverse Fisher Transform of a scaled scalar input. +/// +/// Compresses the input through `(e^{2x} - 1) / (e^{2x} + 1) = tanh(x)`, the +/// algebraic inverse of the Fisher transform. The output is bounded in +/// `[-1, +1]` (saturating to exactly `±1` for `|scale * input| >= ~19.06` +/// under IEEE 754 doubles), which makes overbought/oversold thresholds at, say, `±0.5` +/// universal across markets and timeframes — the classic use described by +/// Ehlers in *Cybernetic Analysis for Stocks and Futures* (2004). +/// +/// The constructor takes a `scale` multiplier so callers can feed raw +/// oscillator readings (e.g. RSI in `[0, 100]`, mapped to `[-5, +5]` with +/// `scale = 0.1` after a `-50` shift) without writing their own scaler. +/// Internally the indicator just computes `tanh(scale * input)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, InverseFisherTransform}; +/// +/// let mut ift = InverseFisherTransform::new(1.0).unwrap(); +/// // Large positive input saturates to +1, large negative to -1. +/// assert!(ift.update(10.0).unwrap() > 0.999); +/// assert!(ift.update(-10.0).unwrap() < -0.999); +/// ``` +#[derive(Debug, Clone)] +pub struct InverseFisherTransform { + scale: f64, + last_value: Option, +} + +impl InverseFisherTransform { + /// Construct with a multiplicative scale applied before the tanh squash. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `scale` is not finite or non-positive. + pub fn new(scale: f64) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err(Error::InvalidPeriod { + message: "scale must be a positive finite number", + }); + } + Ok(Self { + scale, + last_value: None, + }) + } + + /// Configured scale. + pub const fn scale(&self) -> f64 { + self.scale + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for InverseFisherTransform { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let scaled = self.scale * input; + // tanh is numerically safe for any finite input. + let v = scaled.tanh(); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "InverseFisherTransform" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_non_positive_scale() { + assert!(matches!( + InverseFisherTransform::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + InverseFisherTransform::new(-1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + InverseFisherTransform::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut ift = InverseFisherTransform::new(0.5).unwrap(); + assert_relative_eq!(ift.scale(), 0.5, epsilon = 1e-15); + assert_eq!(ift.warmup_period(), 1); + assert_eq!(ift.name(), "InverseFisherTransform"); + assert!(!ift.is_ready()); + assert!(ift.update(1.0).is_some()); + assert!(ift.is_ready()); + assert!(ift.value().is_some()); + } + + #[test] + fn zero_input_yields_zero() { + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + assert_relative_eq!(ift.update(0.0).unwrap(), 0.0, epsilon = 1e-15); + } + + #[test] + fn output_bounded_in_closed_unit_interval() { + // tanh saturates to exactly ±1.0 in IEEE 754 once |x| >= ~19.06, so the + // output is in the closed interval [-1, +1] rather than strictly open. + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + for i in -100..=100 { + let v = ift.update(f64::from(i)).unwrap(); + assert!((-1.0..=1.0).contains(&v), "v={v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + ift.update(2.0); + assert!(ift.is_ready()); + ift.reset(); + assert!(!ift.is_ready()); + } + + #[test] + fn ignores_non_finite_input() { + let mut ift = InverseFisherTransform::new(1.0).unwrap(); + ift.update(1.0); + let before = ift.value(); + assert_eq!(ift.update(f64::NAN), before); + assert_eq!(ift.update(f64::INFINITY), before); + } +} diff --git a/crates/wickra-core/src/indicators/inverted_hammer.rs b/crates/wickra-core/src/indicators/inverted_hammer.rs new file mode 100644 index 0000000..dd6492b --- /dev/null +++ b/crates/wickra-core/src/indicators/inverted_hammer.rs @@ -0,0 +1,161 @@ +//! Inverted Hammer candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Inverted Hammer — a single-bar bullish reversal candidate. +/// +/// An Inverted Hammer is the mirror of a Hammer: small real body near the +/// bottom of the bar, a long upper shadow at least twice the body and a +/// short or absent lower shadow. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// inverted = upper_shadow >= 2 * body +/// && lower_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// An Inverted Hammer is bullish by definition, so under the uniform +/// candlestick sign convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it +/// emits `+1.0` when the shape matches and `0.0` otherwise — it never emits +/// `−1.0`. The same geometry read at the top of an uptrend is the bearish +/// `ShootingStar`, which carries the opposite sign. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, InvertedHammer}; +/// +/// let mut indicator = InvertedHammer::new(); +/// // Open 10, close 10.5, low 9.9, high 15: long upper shadow, tiny lower. +/// let candle = Candle::new(10.0, 15.0, 9.9, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct InvertedHammer { + has_emitted: bool, +} + +impl InvertedHammer { + /// Construct a new Inverted Hammer detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for InvertedHammer { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if upper >= 2.0 * body && lower <= body { + 1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "InvertedHammer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let h = InvertedHammer::new(); + assert_eq!(h.name(), "InvertedHammer"); + assert_eq!(h.warmup_period(), 1); + assert!(!h.is_ready()); + } + + #[test] + fn clean_inverted_hammer_is_one() { + let mut h = InvertedHammer::new(); + // body 0.5, upper shadow 4.5, lower shadow 0.1. + assert_eq!(h.update(c(10.0, 15.0, 9.9, 10.5, 0)), Some(1.0)); + } + + #[test] + fn hammer_shape_is_not_inverted() { + let mut h = InvertedHammer::new(); + assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_inverted_hammer() { + let mut h = InvertedHammer::new(); + assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut h = InvertedHammer::new(); + assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 4.0, base - 0.1, base + 0.5, i) + }) + .collect(); + let mut a = InvertedHammer::new(); + let mut b = InvertedHammer::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut h = InvertedHammer::new(); + h.update(c(10.0, 15.0, 9.9, 10.5, 0)); + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/jarque_bera.rs b/crates/wickra-core/src/indicators/jarque_bera.rs new file mode 100644 index 0000000..97647b9 --- /dev/null +++ b/crates/wickra-core/src/indicators/jarque_bera.rs @@ -0,0 +1,263 @@ +//! Jarque-Bera — a normality-test statistic on a rolling window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's +/// distribution departs from normal, via its **skewness** and **excess +/// kurtosis**. +/// +/// ```text +/// S = skewness = m3 / m2^(3/2) +/// K = excess kurtosis = m4 / m2² − 3 +/// JB = (period / 6) · ( S² + K²/4 ) +/// ``` +/// +/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the +/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so +/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero +/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of +/// normality `JB` is asymptotically χ² with two degrees of freedom, so values +/// above roughly `6` reject normality at the 95% level — a useful streaming flag +/// for fat-tail / crash-risk regimes in a return series. +/// +/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`) +/// returns `0`. The first value lands after `period` inputs; each `update` +/// recomputes the four moments over the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, JarqueBera}; +/// +/// let mut indicator = JarqueBera::new(50).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update((f64::from(i) * 0.3).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct JarqueBera { + period: usize, + window: VecDeque, + last: Option, +} + +impl JarqueBera { + /// Construct a rolling Jarque-Bera over `period` values. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on + /// fewer than four points). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 4 { + return Err(Error::InvalidPeriod { + message: "Jarque-Bera needs period >= 4", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let n = self.period as f64; + let mean = self.window.iter().sum::() / n; + let mut m2 = 0.0; + let mut m3 = 0.0; + let mut m4 = 0.0; + for &v in &self.window { + let d = v - mean; + let d2 = d * d; + m2 += d2; + m3 += d2 * d; + m4 += d2 * d2; + } + m2 /= n; + m3 /= n; + m4 /= n; + if m2 == 0.0 { + return 0.0; + } + let skew = m3 / m2.powf(1.5); + let excess_kurt = m4 / (m2 * m2) - 3.0; + (n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0) + } +} + +impl Indicator for JarqueBera { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "JarqueBera" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_period() { + assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero))); + assert!(matches!( + JarqueBera::new(3), + Err(Error::InvalidPeriod { .. }) + )); + assert!(JarqueBera::new(4).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let jb = JarqueBera::new(50).unwrap(); + assert_eq!(jb.period(), 50); + assert_eq!(jb.warmup_period(), 50); + assert_eq!(jb.name(), "JarqueBera"); + assert!(!jb.is_ready()); + assert_eq!(jb.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut jb = JarqueBera::new(4).unwrap(); + let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn constant_window_is_zero() { + let mut jb = JarqueBera::new(8).unwrap(); + let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_is_non_negative() { + let mut jb = JarqueBera::new(30).unwrap(); + for v in jb + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!(v >= 0.0, "JB must be non-negative, got {v}"); + } + } + + #[test] + fn skewed_window_exceeds_symmetric() { + // A symmetric window vs. one with a heavy outlier (high skew + kurtosis). + let symmetric: Vec = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0]; + let skewed: Vec = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0]; + let jb_sym = JarqueBera::new(8) + .unwrap() + .batch(&symmetric) + .into_iter() + .flatten() + .last() + .unwrap(); + let jb_skew = JarqueBera::new(8) + .unwrap() + .batch(&skewed) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!( + jb_skew > jb_sym, + "skewed ({jb_skew}) should exceed symmetric ({jb_sym})" + ); + } + + #[test] + fn ignores_non_finite() { + let mut jb = JarqueBera::new(4).unwrap(); + let ready = jb + .batch(&[1.0, 2.0, 3.0, 5.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(jb.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut jb = JarqueBera::new(4).unwrap(); + jb.batch(&[1.0, 2.0, 3.0, 5.0]); + assert!(jb.is_ready()); + jb.reset(); + assert!(!jb.is_ready()); + assert_eq!(jb.value(), None); + assert_eq!(jb.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = JarqueBera::new(30).unwrap().batch(&xs); + let mut b = JarqueBera::new(30).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/jma.rs b/crates/wickra-core/src/indicators/jma.rs new file mode 100644 index 0000000..84238b4 --- /dev/null +++ b/crates/wickra-core/src/indicators/jma.rs @@ -0,0 +1,286 @@ +//! Jurik Moving Average (JMA). + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Mark Jurik's adaptive moving average. The original algorithm is proprietary +/// and Jurik Research has never published the full source. This implementation +/// follows the widely-used three-stage filter reconstruction circulated since +/// the 1999 TASC article on the indicator — the same form used by most +/// open-source ports (`TradingView` Pine, `pandas-ta`, various MQL ports): +/// +/// ```text +/// beta = 0.45 * (period - 1) / (0.45 * (period - 1) + 2) +/// alpha = beta ^ power +/// phase_ratio = clamp(phase / 100 + 1.5, 0.5, 2.5) +/// +/// e0_t = (1 - alpha) * x_t + alpha * e0_{t-1} +/// e1_t = (x_t - e0_t) * (1 - beta) + beta * e1_{t-1} +/// e2_t = (e0_t + phase_ratio * e1_t - JMA_{t-1}) * (1 - alpha)^2 + alpha^2 * e2_{t-1} +/// JMA_t = JMA_{t-1} + e2_t +/// ``` +/// +/// The state is seeded by setting `e0 = JMA = first input`, so a constant +/// input stream is reproduced exactly from the first output onward. +/// +/// # Parameters +/// +/// - `period`: smoothing length (default 14). +/// - `phase`: phase shift in `[-100, 100]`. Values outside this range are +/// clamped to the boundary `phase_ratio` so the constructor never fails on +/// a finite `phase`. +/// - `power`: kernel exponent in `1..=4` (default 2 matches the popular +/// reconstruction). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Jma}; +/// +/// let mut jma = Jma::new(14, 0.0, 2).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = jma.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Jma { + period: usize, + phase: f64, + power: u32, + beta: f64, + alpha: f64, + phase_ratio: f64, + e0: f64, + e1: f64, + e2: f64, + output: Option, +} + +impl Jma { + /// # Errors + /// - [`Error::PeriodZero`] if `period == 0`. + /// - [`Error::InvalidPeriod`] if `phase` is non-finite or `power` is + /// outside `1..=4`. + pub fn new(period: usize, phase: f64, power: u32) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !phase.is_finite() { + return Err(Error::InvalidPeriod { + message: "JMA phase must be a finite value", + }); + } + if !(1..=4).contains(&power) { + return Err(Error::InvalidPeriod { + message: "JMA power must be in 1..=4", + }); + } + let len = period as f64 - 1.0; + let beta = 0.45 * len / (0.45 * len + 2.0); + let alpha = beta.powi(i32::try_from(power).expect("power is in 1..=4")); + let phase_ratio = (phase / 100.0 + 1.5).clamp(0.5, 2.5); + Ok(Self { + period, + phase, + power, + beta, + alpha, + phase_ratio, + e0: 0.0, + e1: 0.0, + e2: 0.0, + output: None, + }) + } + + /// Construct JMA with the popular defaults `(period = 14, phase = 0, power = 2)`. + pub fn classic() -> Self { + Self::new(14, 0.0, 2).expect("classic JMA parameters are valid") + } + + /// Configured `(period, phase, power)`. + pub const fn params(&self) -> (usize, f64, u32) { + (self.period, self.phase, self.power) + } +} + +impl Indicator for Jma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.output; + } + let Some(prev_jma) = self.output else { + // Seed e0 and JMA to the first input so a flat series is + // reproduced exactly. + self.e0 = input; + self.output = Some(input); + return self.output; + }; + self.e0 = (1.0 - self.alpha) * input + self.alpha * self.e0; + self.e1 = (input - self.e0) * (1.0 - self.beta) + self.beta * self.e1; + let one_minus_alpha = 1.0 - self.alpha; + self.e2 = + (self.e0 + self.phase_ratio * self.e1 - prev_jma) * one_minus_alpha * one_minus_alpha + + self.alpha * self.alpha * self.e2; + let next = prev_jma + self.e2; + self.output = Some(next); + Some(next) + } + + fn reset(&mut self) { + self.e0 = 0.0; + self.e1 = 0.0; + self.e2 = 0.0; + self.output = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.output.is_some() + } + + fn name(&self) -> &'static str { + "JMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Jma::new(0, 0.0, 2), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_finite_phase() { + assert!(matches!( + Jma::new(14, f64::NAN, 2), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Jma::new(14, f64::INFINITY, 2), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn rejects_invalid_power() { + assert!(matches!( + Jma::new(14, 0.0, 0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Jma::new(14, 0.0, 5), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let jma = Jma::new(14, 0.0, 2).unwrap(); + assert_eq!(jma.params(), (14, 0.0, 2)); + assert_eq!(jma.warmup_period(), 1); + assert_eq!(jma.name(), "JMA"); + } + + #[test] + fn classic_factory() { + let jma = Jma::classic(); + assert_eq!(jma.params(), (14, 0.0, 2)); + } + + #[test] + fn constant_series_yields_the_constant() { + // Seeding e0 = JMA = first input means the recurrence stays exactly + // on the constant from the very first sample. + let mut jma = Jma::new(14, 0.0, 2).unwrap(); + let out = jma.batch(&[42.0_f64; 60]); + for x in out.iter().flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn extreme_phase_is_clamped() { + // phase outside [-100, 100] must produce a finite JMA series (phase + // ratio clamps to [0.5, 2.5]) rather than blow up the recurrence. + let mut a = Jma::new(14, 250.0, 2).unwrap(); + let mut b = Jma::new(14, -250.0, 2).unwrap(); + let prices: Vec = (1..=40).map(f64::from).collect(); + for &p in &prices { + let va = a.update(p).unwrap(); + let vb = b.update(p).unwrap(); + assert!(va.is_finite(), "JMA(phase=+250) emitted {va}"); + assert!(vb.is_finite(), "JMA(phase=-250) emitted {vb}"); + } + } + + #[test] + fn pure_uptrend_tracks_close() { + // Monotonic uptrend, period 5, power 2 — after enough samples the + // smoothed JMA sits close to the latest input. + let mut jma = Jma::new(5, 0.0, 2).unwrap(); + let prices: Vec = (1..=80).map(f64::from).collect(); + let out = jma.batch(&prices); + let last = out.last().unwrap().unwrap(); + let latest = *prices.last().unwrap(); + assert!( + (latest - last).abs() < 5.0, + "JMA on a long clean uptrend should track close: {last} vs {latest}" + ); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Jma::new(14, 0.0, 2).unwrap(); + let mut b = Jma::new(14, 0.0, 2).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut jma = Jma::new(14, 0.0, 2).unwrap(); + jma.batch(&(1..=30).map(f64::from).collect::>()); + assert!(jma.is_ready()); + jma.reset(); + assert!(!jma.is_ready()); + assert_eq!(jma.e0, 0.0); + } + + #[test] + fn ignores_non_finite_input() { + let mut jma = Jma::new(14, 0.0, 2).unwrap(); + jma.batch(&(1..=15).map(f64::from).collect::>()); + let before = jma.update(16.0).unwrap(); + assert_eq!(jma.update(f64::NAN), Some(before)); + assert_eq!(jma.update(f64::INFINITY), Some(before)); + } + + #[test] + fn period_one_is_pass_through() { + // beta = 0, alpha = 0 -> e2 collapses to (input - prev) and the + // recurrence reduces to JMA_t = input. + let mut jma = Jma::new(1, 0.0, 2).unwrap(); + assert_eq!(jma.update(5.0), Some(5.0)); + assert_relative_eq!(jma.update(10.0).unwrap(), 10.0, epsilon = 1e-12); + assert_relative_eq!(jma.update(7.0).unwrap(), 7.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/jump_indicator.rs b/crates/wickra-core/src/indicators/jump_indicator.rs new file mode 100644 index 0000000..3521b35 --- /dev/null +++ b/crates/wickra-core/src/indicators/jump_indicator.rs @@ -0,0 +1,291 @@ +//! Jump Indicator — detects return outliers relative to trailing volatility. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Jump Indicator — a discrete `{−1, 0, +1}` flag for whether the current log +/// return is an outlier relative to the trailing volatility of returns. +/// +/// ```text +/// rₜ = ln(priceₜ / priceₜ₋₁) +/// μ, σ = sample mean and stddev of the `period` returns *before* rₜ (trailing) +/// flag = +1 if rₜ − μ > threshold · σ +/// −1 if rₜ − μ < −threshold · σ +/// 0 otherwise +/// ``` +/// +/// The baseline is the trailing return distribution and **excludes** the current +/// return, so a genuine jump cannot inflate the band it is tested against. +/// Measuring the deviation from the trailing mean `μ` (not the raw return) means +/// a steady drift is *not* flagged — only moves that are large relative to the +/// recent return distribution count. `+1` marks an up jump, `−1` a down jump, +/// and `0` an ordinary move. When the trailing window has zero dispersion +/// (`σ = 0`, e.g. a perfectly constant drift) there is no defined baseline and +/// the indicator returns `0` rather than flagging every move. +/// +/// This is the generic, threshold-tunable detector; downstream models keep any +/// regime-specific sensitivity by choosing `threshold`. Non-finite and +/// non-positive prices are ignored (the log return is undefined): the tick is +/// dropped and the last value returned. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, JumpIndicator}; +/// +/// let mut indicator = JumpIndicator::new(20, 3.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin()); +/// } +/// // A calm sinusoid produces no jumps. +/// assert_eq!(last, Some(0.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct JumpIndicator { + period: usize, + threshold: f64, + prev_price: Option, + /// Trailing window of the `period` returns preceding the current one. + window: VecDeque, + sum: f64, + sum_sq: f64, + last: Option, +} + +impl JumpIndicator { + /// Construct a new Jump Indicator. + /// + /// `threshold` is the number of trailing standard deviations a return must + /// exceed to be flagged. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` (the sample standard + /// deviation needs at least two returns), or [`Error::InvalidParameter`] if + /// `threshold` is not finite and positive. + pub fn new(period: usize, threshold: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "jump indicator needs period >= 2", + }); + } + if !threshold.is_finite() || threshold <= 0.0 { + return Err(Error::InvalidParameter { + message: "jump indicator threshold must be finite and positive", + }); + } + Ok(Self { + period, + threshold, + prev_price: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + last: None, + }) + } + + /// Configured `(period, threshold)`. + pub const fn params(&self) -> (usize, f64) { + (self.period, self.threshold) + } +} + +impl Indicator for JumpIndicator { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + let r = (input / prev).ln(); + if self.window.len() < self.period { + // Still filling the trailing window; no baseline yet. + self.window.push_back(r); + self.sum += r; + self.sum_sq += r * r; + return None; + } + // Trailing window is full: classify `r` against the volatility of the + // `period` returns that precede it. + let n = self.period as f64; + let mean = self.sum / n; + let var = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + let sd = var.sqrt(); + let deviation = r - mean; + let label = if sd == 0.0 { + 0.0 + } else if deviation > self.threshold * sd { + 1.0 + } else if deviation < -self.threshold * sd { + -1.0 + } else { + 0.0 + }; + // Slide the trailing window forward to include `r`. + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + self.window.push_back(r); + self.sum += r; + self.sum_sq += r * r; + self.last = Some(label); + Some(label) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One price seeds `prev`, `period` returns fill the trailing window, + // then the next return is the first one classified. + self.period + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "JumpIndicator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_bad_params() { + assert!(matches!( + JumpIndicator::new(1, 3.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + JumpIndicator::new(20, 0.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + JumpIndicator::new(20, f64::NAN), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let ji = JumpIndicator::new(20, 3.0).unwrap(); + assert_eq!(ji.params(), (20, 3.0)); + assert_eq!(ji.warmup_period(), 22); + assert_eq!(ji.name(), "JumpIndicator"); + assert!(!ji.is_ready()); + } + + #[test] + fn detects_upward_jump() { + let mut ji = JumpIndicator::new(10, 3.0).unwrap(); + // Calm oscillating warmup (small, varied returns), then a +20% spike. + let mut prices: Vec = (0..20) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2) + .collect(); + let last_calm = *prices.last().unwrap(); + prices.push(last_calm * 1.2); + let out = ji.batch(&prices); + assert_eq!(out.last().copied().flatten(), Some(1.0)); + } + + #[test] + fn detects_downward_jump() { + let mut ji = JumpIndicator::new(10, 3.0).unwrap(); + let mut prices: Vec = (0..20) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2) + .collect(); + let last_calm = *prices.last().unwrap(); + prices.push(last_calm * 0.8); + let out = ji.batch(&prices); + assert_eq!(out.last().copied().flatten(), Some(-1.0)); + } + + #[test] + fn calm_series_has_no_jumps() { + let mut ji = JumpIndicator::new(20, 3.0).unwrap(); + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin()) + .collect(); + for v in ji.batch(&prices).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn zero_trailing_volatility_returns_zero() { + // A constant price has exactly-zero returns => zero trailing dispersion + // => no defined baseline => label 0. (Pins the `sd == 0` branch with an + // exact-zero series; a geometric drift is conceptually zero-vol too but + // floating-point rounding of the log returns leaves ~1e-16 noise.) + let mut ji = JumpIndicator::new(10, 3.0).unwrap(); + for v in ji.batch(&[100.0; 30]).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn steady_drift_is_not_flagged() { + // A near-constant positive drift (small, equal-ish returns) must not be + // flagged: the deviation from the trailing mean stays well inside the + // band even though the raw return is non-zero every bar. + let mut ji = JumpIndicator::new(10, 3.0).unwrap(); + let prices: Vec = (0..40).map(|i| 100.0 + f64::from(i) * 0.5).collect(); + for v in ji.batch(&prices).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn ignores_non_finite_and_non_positive() { + let mut ji = JumpIndicator::new(5, 3.0).unwrap(); + let prices: Vec = (0..20) + .map(|i| 100.0 + (f64::from(i) * 0.6).sin()) + .collect(); + let out = ji.batch(&prices); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(ji.update(f64::NAN), last); + assert_eq!(ji.update(-1.0), last); + assert_eq!(ji.update(0.0), last); + } + + #[test] + fn reset_clears_state() { + let mut ji = JumpIndicator::new(5, 3.0).unwrap(); + ji.batch(&(1..=20).map(f64::from).collect::>()); + assert!(ji.is_ready()); + ji.reset(); + assert!(!ji.is_ready()); + assert_eq!(ji.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 3.0) + .collect(); + let batch = JumpIndicator::new(20, 3.0).unwrap().batch(&prices); + let mut b = JumpIndicator::new(20, 3.0).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).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 0000000..e70886d --- /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/kagi_bars.rs b/crates/wickra-core/src/indicators/kagi_bars.rs new file mode 100644 index 0000000..a47f63c --- /dev/null +++ b/crates/wickra-core/src/indicators/kagi_bars.rs @@ -0,0 +1,283 @@ +//! Kagi bar builder — reversal-amount line segments on close prices. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed Kagi line segment (the vertical run between two reversals). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KagiBar { + /// Price where the segment began (the previous reversal point). + pub start: f64, + /// Extreme price the segment reached before reversing. + pub end: f64, + /// `+1` for a rising segment, `-1` for a falling segment. + pub direction: i8, +} + +/// Kagi bar builder using the fixed reversal-amount method on close prices. +/// +/// A Kagi chart is one continuous line that extends in its current direction as +/// long as price makes new extremes, and turns when price retraces by at least +/// `reversal` from the latest extreme. This builder emits the **completed +/// segment** each time the line turns: +/// +/// - The first candle seeds the start price; the first subsequent move (of any +/// size) sets the initial direction. +/// - While the trend holds, new extremes extend the current segment silently. +/// - A retracement of `>= reversal` closes the current segment (returned from +/// [`BarBuilder::update`]) and starts a new one in the opposite direction. +/// +/// At most one segment completes per candle, so `update` returns either an empty +/// vector or a single [`KagiBar`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, KagiBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut kagi = KagiBars::new(2.0).unwrap(); +/// kagi.update(flat(10.0)); // seed +/// kagi.update(flat(15.0)); // rise to 15 +/// let bars = kagi.update(flat(12.0)); // retrace >= 2 -> closes the up segment +/// assert_eq!(bars.len(), 1); +/// assert_eq!(bars[0].direction, 1); +/// ``` +#[derive(Debug, Clone)] +pub struct KagiBars { + reversal: f64, + dir: i8, + extreme: Option, + segment_start: f64, +} + +impl KagiBars { + /// Construct a Kagi builder with the given reversal amount. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `reversal` is not finite and positive. + pub fn new(reversal: f64) -> Result { + if !reversal.is_finite() || reversal <= 0.0 { + return Err(Error::InvalidPeriod { + message: "reversal must be finite and positive", + }); + } + Ok(Self { + reversal, + dir: 0, + extreme: None, + segment_start: 0.0, + }) + } + + /// Configured reversal amount. + pub const fn reversal(&self) -> f64 { + self.reversal + } + + /// Current extreme price (or the seed price before any move). + pub const fn extreme(&self) -> Option { + self.extreme + } +} + +impl BarBuilder for KagiBars { + type Bar = KagiBar; + + fn update(&mut self, candle: Candle) -> Vec { + let close = candle.close; + let Some(mut ext) = self.extreme else { + self.extreme = Some(close); + self.segment_start = close; + return Vec::new(); + }; + let mut bars = Vec::new(); + match self.dir { + 0 => { + if close > ext { + self.dir = 1; + ext = close; + } else if close < ext { + self.dir = -1; + ext = close; + } + } + 1 => { + if close > ext { + ext = close; + } else if close <= ext - self.reversal { + bars.push(KagiBar { + start: self.segment_start, + end: ext, + direction: 1, + }); + self.segment_start = ext; + self.dir = -1; + ext = close; + } + } + _ => { + if close < ext { + ext = close; + } else if close >= ext + self.reversal { + bars.push(KagiBar { + start: self.segment_start, + end: ext, + direction: -1, + }); + self.segment_start = ext; + self.dir = 1; + ext = close; + } + } + } + self.extreme = Some(ext); + bars + } + + fn reset(&mut self) { + self.dir = 0; + self.extreme = None; + self.segment_start = 0.0; + } + + fn name(&self) -> &'static str { + "KagiBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_reversal() { + assert!(matches!( + KagiBars::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + KagiBars::new(-2.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + KagiBars::new(f64::INFINITY), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let kagi = KagiBars::new(2.0).unwrap(); + assert_eq!(kagi.name(), "KagiBars"); + assert_relative_eq!(kagi.reversal(), 2.0, epsilon = 1e-12); + assert_eq!(kagi.extreme(), None); + } + + #[test] + fn seeds_then_establishes_up_direction() { + let mut kagi = KagiBars::new(2.0).unwrap(); + assert!(kagi.update(flat(10.0)).is_empty()); // seed + assert_eq!(kagi.extreme(), Some(10.0)); + assert!(kagi.update(flat(11.0)).is_empty()); // first move sets dir up + assert_eq!(kagi.extreme(), Some(11.0)); + } + + #[test] + fn establishes_down_direction_from_seed() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + assert!(kagi.update(flat(9.0)).is_empty()); // first move sets dir down + assert_eq!(kagi.extreme(), Some(9.0)); + } + + #[test] + fn extends_without_emitting() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + kagi.update(flat(11.0)); + assert!(kagi.update(flat(15.0)).is_empty()); // new high, extend + assert_eq!(kagi.extreme(), Some(15.0)); + } + + #[test] + fn reversal_closes_up_segment() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + kagi.update(flat(11.0)); + kagi.update(flat(15.0)); + let bars = kagi.update(flat(12.0)); // retrace 3 >= 2 + assert_eq!(bars.len(), 1); + assert_eq!(bars[0].direction, 1); + assert_relative_eq!(bars[0].start, 10.0, epsilon = 1e-12); + assert_relative_eq!(bars[0].end, 15.0, epsilon = 1e-12); + assert_eq!(kagi.extreme(), Some(12.0)); + } + + #[test] + fn reversal_closes_down_segment() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + kagi.update(flat(11.0)); + kagi.update(flat(15.0)); + kagi.update(flat(12.0)); // now dir down, segment_start 15, extreme 12 + let bars = kagi.update(flat(20.0)); // rise 8 >= 2 -> closes down segment + assert_eq!(bars.len(), 1); + assert_eq!(bars[0].direction, -1); + assert_relative_eq!(bars[0].start, 15.0, epsilon = 1e-12); + assert_relative_eq!(bars[0].end, 12.0, epsilon = 1e-12); + } + + #[test] + fn small_pullback_does_not_reverse() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + kagi.update(flat(11.0)); + kagi.update(flat(15.0)); + assert!(kagi.update(flat(14.0)).is_empty()); // retrace 1 < 2 + assert_eq!(kagi.extreme(), Some(15.0)); + } + + #[test] + fn down_trend_small_bounce_does_not_reverse() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + kagi.update(flat(9.0)); // dir down + kagi.update(flat(5.0)); // extreme 5 + assert!(kagi.update(flat(6.0)).is_empty()); // bounce 1 < 2 + assert_eq!(kagi.extreme(), Some(5.0)); + } + + #[test] + fn reset_clears_state() { + let mut kagi = KagiBars::new(2.0).unwrap(); + kagi.update(flat(10.0)); + kagi.update(flat(15.0)); + kagi.reset(); + assert_eq!(kagi.extreme(), None); + assert!(kagi.update(flat(99.0)).is_empty()); + assert_eq!(kagi.extreme(), Some(99.0)); + } + + #[test] + fn batch_collects_completed_segments() { + let mut kagi = KagiBars::new(2.0).unwrap(); + let candles = [ + flat(10.0), + flat(15.0), + flat(12.0), // closes up segment + flat(20.0), // closes down segment + ]; + let bars = kagi.batch(&candles); + assert_eq!(bars.len(), 2); + assert_eq!(bars[0].direction, 1); + assert_eq!(bars[1].direction, -1); + } +} diff --git a/crates/wickra-core/src/indicators/kalman_hedge_ratio.rs b/crates/wickra-core/src/indicators/kalman_hedge_ratio.rs new file mode 100644 index 0000000..9733f56 --- /dev/null +++ b/crates/wickra-core/src/indicators/kalman_hedge_ratio.rs @@ -0,0 +1,293 @@ +//! Kalman-filter dynamic hedge ratio between two series. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Output of [`KalmanHedgeRatio`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KalmanHedgeRatioOutput { + /// Current hedge ratio `β` — the filtered slope of `a` on `b`. + pub hedge_ratio: f64, + /// Current intercept `α` — the filtered level offset. + pub intercept: f64, + /// Forecast error `a − (α + β·b)`: how far the latest `a` sits from the + /// Kalman-predicted relationship. This is the tradeable spread signal. + pub spread: f64, +} + +/// Dynamic hedge ratio between two series, estimated online with a Kalman filter. +/// +/// Each `update` takes one `(a, b)` price pair and treats the linear relation +/// `aₜ = αₜ + βₜ·bₜ + noise` as a state-space model whose hidden state +/// `[βₜ, αₜ]` follows a random walk. The filter updates the state from every +/// observation, so the hedge ratio **adapts continuously** instead of being a +/// flat OLS slope over a fixed window: +/// +/// ```text +/// state xₜ = [βₜ, αₜ], drifts as a random walk with covariance Vw·I +/// observe aₜ = [bₜ, 1]·xₜ + εₜ, Var(εₜ) = observation_var +/// Vw = delta / (1 − delta) +/// ``` +/// +/// `delta` controls how fast the hedge ratio is allowed to move: a larger +/// `delta` tracks regime changes faster but is noisier; a smaller `delta` is +/// smoother but slower. `observation_var` is the measurement-noise variance. +/// The reported `spread` (the filter's forecast error) is the mean-reverting +/// signal a pairs trade fades — the Kalman analogue of the +/// [`crate::Cointegration`] residual, but with a hedge ratio that breathes. +/// +/// The filter emits an estimate from the **first** update (warmup of one bar); +/// early estimates are diffuse and settle as observations accumulate. Each +/// `update` is `O(1)` over the fixed 2×2 covariance. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, KalmanHedgeRatio}; +/// +/// let mut k = KalmanHedgeRatio::new(1e-2, 1e-3).unwrap(); +/// let mut last = None; +/// for t in 0..400 { +/// // `b` sweeps a wide range so the slope and intercept are identifiable. +/// let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0; +/// last = k.update((2.0 * b + 5.0, b)); // a = 2·b + 5 +/// } +/// let out = last.unwrap(); +/// assert!((out.hedge_ratio - 2.0).abs() < 0.05); +/// assert!(out.spread.abs() < 0.05); +/// ``` +#[derive(Debug, Clone)] +pub struct KalmanHedgeRatio { + delta: f64, + transition_var: f64, + observation_var: f64, + beta: f64, + alpha: f64, + // State covariance, row-major 2×2: [[p00, p01], [p10, p11]]. + cov: [[f64; 2]; 2], + count: usize, +} + +impl KalmanHedgeRatio { + /// Construct a new Kalman hedge-ratio filter. + /// + /// `delta` is the state-drift ratio in `(0, 1)`; `observation_var` is the + /// measurement-noise variance (`> 0`). + /// + /// # Errors + /// Returns [`Error::InvalidParameter`] if `delta` is not in `(0, 1)` or if + /// `observation_var` is not strictly positive (both must also be finite). + pub fn new(delta: f64, observation_var: f64) -> Result { + if !delta.is_finite() || delta <= 0.0 || delta >= 1.0 { + return Err(Error::InvalidParameter { + message: "kalman hedge ratio needs delta in (0, 1)", + }); + } + if !observation_var.is_finite() || observation_var <= 0.0 { + return Err(Error::InvalidParameter { + message: "kalman hedge ratio needs observation_var > 0", + }); + } + Ok(Self { + delta, + transition_var: delta / (1.0 - delta), + observation_var, + beta: 0.0, + alpha: 0.0, + cov: [[0.0; 2]; 2], + count: 0, + }) + } + + /// Configured state-drift ratio `delta`. + pub const fn delta(&self) -> f64 { + self.delta + } + + /// Configured measurement-noise variance. + pub const fn observation_var(&self) -> f64 { + self.observation_var + } +} + +impl Indicator for KalmanHedgeRatio { + type Input = (f64, f64); + type Output = KalmanHedgeRatioOutput; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + // Predicted state covariance: add the transition noise to the diagonal + // (the very first observation starts from a zero prior). + let mut cov_pred = self.cov; + if self.count > 0 { + cov_pred[0][0] += self.transition_var; + cov_pred[1][1] += self.transition_var; + } + // Observation row is F = [b, 1]. + let predicted = self.beta * b + self.alpha; + let innovation = a - predicted; + // F·cov_pred (a 1×2 row). + let fr0 = b * cov_pred[0][0] + cov_pred[1][0]; + let fr1 = b * cov_pred[0][1] + cov_pred[1][1]; + // Innovation variance S = F·cov_pred·Fᵀ + observation_var ≥ observation_var > 0. + let innovation_var = fr0 * b + fr1 + self.observation_var; + // Kalman gain = cov_pred·Fᵀ / S. + let rft0 = cov_pred[0][0] * b + cov_pred[0][1]; + let rft1 = cov_pred[1][0] * b + cov_pred[1][1]; + let gain0 = rft0 / innovation_var; + let gain1 = rft1 / innovation_var; + // State update. + self.beta += gain0 * innovation; + self.alpha += gain1 * innovation; + // Covariance update P = cov_pred − gain·(F·cov_pred). + self.cov[0][0] = cov_pred[0][0] - gain0 * fr0; + self.cov[0][1] = cov_pred[0][1] - gain0 * fr1; + self.cov[1][0] = cov_pred[1][0] - gain1 * fr0; + self.cov[1][1] = cov_pred[1][1] - gain1 * fr1; + self.count += 1; + Some(KalmanHedgeRatioOutput { + hedge_ratio: self.beta, + intercept: self.alpha, + spread: innovation, + }) + } + + fn reset(&mut self) { + self.beta = 0.0; + self.alpha = 0.0; + self.cov = [[0.0; 2]; 2]; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.count >= 1 + } + + fn name(&self) -> &'static str { + "KalmanHedgeRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_bad_parameters() { + assert!(KalmanHedgeRatio::new(0.0, 1.0).is_err()); + assert!(KalmanHedgeRatio::new(1.0, 1.0).is_err()); + assert!(KalmanHedgeRatio::new(-0.1, 1.0).is_err()); + assert!(KalmanHedgeRatio::new(f64::NAN, 1.0).is_err()); + assert!(KalmanHedgeRatio::new(0.001, 0.0).is_err()); + assert!(KalmanHedgeRatio::new(0.001, -1.0).is_err()); + assert!(KalmanHedgeRatio::new(0.001, f64::INFINITY).is_err()); + assert!(KalmanHedgeRatio::new(0.001, 0.001).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let k = KalmanHedgeRatio::new(0.001, 0.01).unwrap(); + assert_eq!(k.delta(), 0.001); + assert_eq!(k.observation_var(), 0.01); + assert_eq!(k.warmup_period(), 1); + assert_eq!(k.name(), "KalmanHedgeRatio"); + assert!(!k.is_ready()); + } + + #[test] + fn emits_from_first_update() { + let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap(); + let first = k.update((10.0, 5.0)).unwrap(); + // The diffuse prior leaves the first state at the origin. + assert_eq!(first.hedge_ratio, 0.0); + assert_eq!(first.intercept, 0.0); + assert_eq!(first.spread, 10.0); + assert!(k.is_ready()); + } + + #[test] + fn converges_to_static_relationship() { + // a = 2·b + 5 ⇒ the filter should recover β ≈ 2, α ≈ 5, spread ≈ 0. + // `b` sweeps a wide range so β and α are jointly identifiable. + let pairs: Vec<(f64, f64)> = (0..500) + .map(|t| { + let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0; + (2.0 * b + 5.0, b) + }) + .collect(); + let out = KalmanHedgeRatio::new(1e-2, 1e-3) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!( + (out.hedge_ratio - 2.0).abs() < 0.05, + "beta {}", + out.hedge_ratio + ); + assert!((out.intercept - 5.0).abs() < 1.0, "alpha {}", out.intercept); + assert!(out.spread.abs() < 0.05, "spread {}", out.spread); + } + + #[test] + fn tracks_a_changing_hedge_ratio() { + // Hedge ratio steps from 2 to 3 partway through; the filter should move + // toward the new ratio. + let mut pairs: Vec<(f64, f64)> = (0..300) + .map(|t| { + let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0; + (2.0 * b + 5.0, b) + }) + .collect(); + pairs.extend((0..300).map(|t| { + let b = 100.0 + (f64::from(t) * 0.5).cos() * 95.0; + (3.0 * b + 5.0, b) + })); + let out = KalmanHedgeRatio::new(1e-2, 1e-3) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(out.hedge_ratio > 2.5, "beta {}", out.hedge_ratio); + } + + #[test] + fn reset_clears_state() { + let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap(); + for t in 0..50 { + let b = 100.0 + f64::from(t); + k.update((2.0 * b, b)); + } + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + let first = k.update((10.0, 5.0)).unwrap(); + assert_eq!(first.hedge_ratio, 0.0); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..120) + .map(|t| { + let b = 30.0 + 0.7 * f64::from(t); + (1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b) + }) + .collect(); + let batch = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap().batch(&pairs); + let mut k = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| k.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/kama.rs b/crates/wickra-core/src/indicators/kama.rs new file mode 100644 index 0000000..5ddb3ca --- /dev/null +++ b/crates/wickra-core/src/indicators/kama.rs @@ -0,0 +1,195 @@ +//! Kaufman's Adaptive Moving Average (KAMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Kaufman's Adaptive Moving Average. +/// +/// KAMA adapts its smoothing constant to volatility: efficient (trending) markets +/// get a fast smoothing constant, choppy markets get a slow one. Parameters are +/// the efficiency-ratio lookback (`er_period`, default 10), the fast EMA period +/// (`fast`, default 2) and the slow EMA period (`slow`, default 30). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Kama}; +/// +/// let mut indicator = Kama::new(10, 2, 30).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kama { + er_period: usize, + fast_sc: f64, + slow_sc: f64, + window: VecDeque, + state: Option, +} + +impl Kama { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::InvalidPeriod`] for bad parameters. + pub fn new(er_period: usize, fast: usize, slow: usize) -> Result { + if er_period == 0 || fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "KAMA fast period must be strictly less than slow", + }); + } + let fast_sc = 2.0 / (fast as f64 + 1.0); + let slow_sc = 2.0 / (slow as f64 + 1.0); + Ok(Self { + er_period, + fast_sc, + slow_sc, + window: VecDeque::with_capacity(er_period + 1), + state: None, + }) + } + + /// Classic Kaufman parameters: (10, 2, 30). + pub fn classic() -> Self { + Self::new(10, 2, 30).expect("classic KAMA parameters are valid") + } + + /// Configured `(er_period, fast, slow)` periods. + pub fn periods(&self) -> (usize, f64, f64) { + (self.er_period, self.fast_sc, self.slow_sc) + } +} + +impl Indicator for Kama { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.state; + } + if self.window.len() == self.er_period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + + if self.window.len() < self.er_period + 1 { + return None; + } + + let first = *self.window.front().expect("non-empty"); + let last = *self.window.back().expect("non-empty"); + let direction = (last - first).abs(); + let volatility: f64 = self + .window + .iter() + .zip(self.window.iter().skip(1)) + .map(|(a, b)| (b - a).abs()) + .sum(); + + let er = if volatility == 0.0 { + 0.0 + } else { + direction / volatility + }; + let sc = (er * (self.fast_sc - self.slow_sc) + self.slow_sc).powi(2); + + let prev = self.state.unwrap_or(first); + let new = prev + sc * (input - prev); + self.state = Some(new); + Some(new) + } + + fn reset(&mut self) { + self.window.clear(); + self.state = None; + } + + fn warmup_period(&self) -> usize { + self.er_period + 1 + } + + fn is_ready(&self) -> bool { + self.state.is_some() + } + + fn name(&self) -> &'static str { + "KAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Cover the `periods` accessor (65-67) and the Indicator-impl + /// `warmup_period` (115-117) + `name` (123-125). Existing tests + /// inspect KAMA output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let k = Kama::classic(); + let (er, fast, slow) = k.periods(); + assert_eq!(er, 10); + assert!((fast - 2.0 / (2.0 + 1.0)).abs() < 1e-12); + assert!((slow - 2.0 / (30.0 + 1.0)).abs() < 1e-12); + assert_eq!(k.warmup_period(), 11); + assert_eq!(k.name(), "KAMA"); + } + + #[test] + fn constant_series_yields_constant_kama() { + let mut k = Kama::classic(); + let out = k.batch(&[100.0_f64; 100]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 100.0, epsilon = 1e-9); + } + + #[test] + fn rejects_invalid_periods() { + assert!(Kama::new(0, 2, 30).is_err()); + assert!(Kama::new(10, 30, 2).is_err()); // fast >= slow + assert!(Kama::new(10, 2, 2).is_err()); // fast == slow + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = Kama::classic(); + let mut b = Kama::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut k = Kama::classic(); + k.batch(&(1..=50).map(f64::from).collect::>()); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + } + + #[test] + fn ignores_non_finite_input() { + let mut k = Kama::classic(); + k.batch(&(1..=40).map(f64::from).collect::>()); + let before = k.update(41.0); + assert!(before.is_some()); + // Non-finite inputs return the last state without sliding the window. + assert_eq!(k.update(f64::NAN), before); + assert_eq!(k.update(f64::INFINITY), before); + } +} diff --git a/crates/wickra-core/src/indicators/kase_devstop.rs b/crates/wickra-core/src/indicators/kase_devstop.rs new file mode 100644 index 0000000..55227a0 --- /dev/null +++ b/crates/wickra-core/src/indicators/kase_devstop.rs @@ -0,0 +1,341 @@ +//! Kase `DevStop` — a volatility trailing stop on the standard deviation of the +//! two-bar true range. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend +/// direction it protects. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KaseDevStopOutput { + /// The `DevStop` level — below price in an uptrend, above price in a downtrend. + pub value: f64, + /// Trend direction: `+1.0` long (stop below price), `-1.0` short. + pub direction: f64, +} + +/// Sample standard deviation from a running `(sum, sum_of_squares, count)`. +fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 { + let n = count as f64; + let mean = sum / n; + (((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt() +} + +/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard +/// deviation of the two-bar true range** rather than a single-bar ATR. +/// +/// ```text +/// DTR_t = max(high_t, high_{t−1}) − min(low_t, low_{t−1}) (two-bar range) +/// band = mean(DTR, period) + dev · stddev(DTR, period) +/// long stop = ratchet_up( highest_high_since_flip − band ) +/// short stop = ratchet_down( lowest_low_since_flip + band ) +/// ``` +/// +/// Kase observed that range expansion is better captured by a two-bar range than +/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed +/// ATR multiple) adapts the stop to changing volatility. The stop trails the +/// extreme reached since the last reversal — ratcheting only in the trend's favour +/// — and flips sides when price closes through it. `dev` selects which `DevStop` +/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines). +/// +/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the +/// mean and standard deviation, so the first stop lands after `period + 1` inputs. +/// Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, KaseDevStop}; +/// +/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct KaseDevStop { + period: usize, + dev: f64, + prev: Option, + window: VecDeque, + sum: f64, + sum_sq: f64, + direction: f64, + extreme: f64, + stop: f64, + last: Option, +} + +impl KaseDevStop { + /// Construct a Kase `DevStop` with the given lookback `period` and + /// standard-deviation multiplier `dev`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation + /// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev` + /// is not finite and positive. + pub fn new(period: usize, dev: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "Kase DevStop period must be >= 2", + }); + } + if !dev.is_finite() || dev <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + dev, + prev: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + direction: 0.0, + extreme: 0.0, + stop: 0.0, + last: None, + }) + } + + /// Configured `(period, dev)`. + pub const fn params(&self) -> (usize, f64) { + (self.period, self.dev) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for KaseDevStop { + type Input = Candle; + type Output = KaseDevStopOutput; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let dtr = candle.high.max(prev.high) - candle.low.min(prev.low); + self.prev = Some(candle); + + 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(dtr); + self.sum += dtr; + self.sum_sq += dtr * dtr; + if self.window.len() < self.period { + return None; + } + let mean = self.sum / self.period as f64; + let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period); + + if self.direction == 0.0 { + // Seed the trend as long off the first fully-warmed bar. + self.direction = 1.0; + self.extreme = candle.high; + self.stop = candle.high - band; + } else if self.direction > 0.0 { + self.extreme = self.extreme.max(candle.high); + let raw = self.extreme - band; + self.stop = self.stop.max(raw); + if candle.close < self.stop { + self.direction = -1.0; + self.extreme = candle.low; + self.stop = candle.low + band; + } + } else { + self.extreme = self.extreme.min(candle.low); + let raw = self.extreme + band; + self.stop = self.stop.min(raw); + if candle.close > self.stop { + self.direction = 1.0; + self.extreme = candle.high; + self.stop = candle.high - band; + } + } + + let out = KaseDevStopOutput { + value: self.stop, + direction: self.direction, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.direction = 0.0; + self.extreme = 0.0; + self.stop = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "KaseDevStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + KaseDevStop::new(1, 1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + KaseDevStop::new(30, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + KaseDevStop::new(30, -1.0), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let k = KaseDevStop::new(30, 1.0).unwrap(); + assert_eq!(k.params(), (30, 1.0)); + assert_eq!(k.warmup_period(), 31); + assert_eq!(k.name(), "KaseDevStop"); + assert!(!k.is_ready()); + assert_eq!(k.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut k = KaseDevStop::new(3, 1.0).unwrap(); + let candles: Vec = (0..8) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let out = k.batch(&candles); + let warmup = k.warmup_period(); // 4 + assert_eq!(warmup, 4); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn uptrend_keeps_stop_below_price() { + let mut k = KaseDevStop::new(5, 1.0).unwrap(); + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + 2.0 * f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) { + if let Some(o) = o { + assert_eq!(o.direction, 1.0, "pure uptrend stays long"); + assert!(o.value < candle.close, "stop below price"); + } + } + } + + #[test] + fn stop_ratchets_up_in_uptrend() { + let mut k = KaseDevStop::new(5, 1.0).unwrap(); + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + 2.0 * f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + let mut prev = f64::NEG_INFINITY; + for o in k.batch(&candles).into_iter().flatten() { + assert!(o.value >= prev, "long stop must not fall"); + prev = o.value; + } + } + + #[test] + fn flips_on_reversal() { + let mut candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + candles.extend((0..40).map(|i| { + let base = 140.0 - f64::from(i); + c(base + 1.0, base - 1.0, base - 0.5) + })); + let mut k = KaseDevStop::new(5, 1.0).unwrap(); + let dirs: Vec = k + .batch(&candles) + .into_iter() + .flatten() + .map(|o| o.direction) + .collect(); + assert!(dirs.iter().any(|&d| d > 0.0)); + assert!(dirs.iter().any(|&d| d < 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut k = KaseDevStop::new(5, 1.0).unwrap(); + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base + 0.5) + }) + .collect(); + k.batch(&candles); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.value(), None); + assert_eq!(k.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + c(base + 2.0, base - 1.5, base + 0.5) + }) + .collect(); + let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles); + let mut b = KaseDevStop::new(20, 2.0).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/kase_permission_stochastic.rs b/crates/wickra-core/src/indicators/kase_permission_stochastic.rs new file mode 100644 index 0000000..47a4e74 --- /dev/null +++ b/crates/wickra-core/src/indicators/kase_permission_stochastic.rs @@ -0,0 +1,234 @@ +//! Kase Permission Stochastic — a double-smoothed stochastic used as a +//! trade-permission filter. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Kase Permission Stochastic output: a fast and a slow line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KasePermissionStochasticOutput { + /// Fast line: EMA of the raw `%K` over the smoothing period. + pub fast: f64, + /// Slow line: EMA of the fast line over the smoothing period. + pub slow: f64, +} + +/// Cynthia Kase's Permission Stochastic: a stochastic oscillator smoothed twice, +/// whose fast/slow relationship grants or denies "permission" to trade in the +/// direction of a higher-timeframe signal. +/// +/// ```text +/// raw%K = 100 * (close - LL) / (HH - LL) over `length` (50 when HH == LL) +/// fast = EMA(raw%K, smooth) +/// slow = EMA(fast, smooth) +/// ``` +/// +/// The raw stochastic is the usual `%K`, then an EMA produces the *fast* line +/// and a second EMA of that produces the *slow* line. Kase uses the pair as a +/// gate: a fast line above the slow line (and rising) gives permission for +/// longs, the reverse for shorts. When the lookback window is perfectly flat +/// (`HH == LL`), the raw stochastic is undefined and defaults to the neutral +/// `50`. +/// +/// Reference: Cynthia Kase, *Trading with the Odds*, 1996. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, KasePermissionStochastic}; +/// +/// let mut indicator = KasePermissionStochastic::new(9, 3).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct KasePermissionStochastic { + length: usize, + smooth: usize, + window: VecDeque<(f64, f64)>, + fast_ema: Ema, + slow_ema: Ema, +} + +impl KasePermissionStochastic { + /// Construct with the stochastic `length` and the EMA `smooth` period + /// applied twice. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `length == 0` or `smooth == 0`. + pub fn new(length: usize, smooth: usize) -> Result { + if length == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + length, + smooth, + window: VecDeque::with_capacity(length), + fast_ema: Ema::new(smooth)?, + slow_ema: Ema::new(smooth)?, + }) + } + + /// Cynthia Kase's classic parameters: `length = 9`, `smooth = 3`. + pub fn classic() -> Self { + Self::new(9, 3).expect("classic Kase Permission Stochastic parameters are valid") + } + + /// Configured `(length, smooth)`. + pub const fn periods(&self) -> (usize, usize) { + (self.length, self.smooth) + } +} + +impl Indicator for KasePermissionStochastic { + type Input = Candle; + type Output = KasePermissionStochasticOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.window.push_back((candle.high, candle.low)); + if self.window.len() > self.length { + self.window.pop_front(); + } + if self.window.len() < self.length { + return None; + } + + let highest = self.window.iter().map(|w| w.0).fold(f64::MIN, f64::max); + let lowest = self.window.iter().map(|w| w.1).fold(f64::MAX, f64::min); + let raw_k = if highest > lowest { + 100.0 * (candle.close - lowest) / (highest - lowest) + } else { + 50.0 + }; + + let fast = self.fast_ema.update(raw_k)?; + let slow = self.slow_ema.update(fast)?; + Some(KasePermissionStochasticOutput { fast, slow }) + } + + fn reset(&mut self) { + self.window.clear(); + self.fast_ema.reset(); + self.slow_ema.reset(); + } + + fn warmup_period(&self) -> usize { + // raw%K ready after `length` bars; each EMA seeds over `smooth` values. + self.length + 2 * self.smooth - 2 + } + + fn is_ready(&self) -> bool { + self.slow_ema.is_ready() + } + + fn name(&self) -> &'static str { + "KasePermissionStochastic" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + KasePermissionStochastic::new(0, 3), + Err(Error::PeriodZero) + )); + assert!(matches!( + KasePermissionStochastic::new(9, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let k = KasePermissionStochastic::classic(); + assert_eq!(k.periods(), (9, 3)); + // 9 + 2*3 - 2 = 13. + assert_eq!(k.warmup_period(), 13); + assert_eq!(k.name(), "KasePermissionStochastic"); + assert!(!k.is_ready()); + } + + #[test] + fn warmup_emits_at_expected_bar() { + let mut k = KasePermissionStochastic::new(3, 2).unwrap(); + // warmup = 3 + 2*2 - 2 = 5 -> first value at input 5 (index 4). + let candles: Vec = (0..8).map(|i| candle(11.0, 9.0, 10.5, i)).collect(); + let out = k.batch(&candles); + assert!(out[3].is_none()); + assert!(out[4].is_some()); + } + + #[test] + fn top_of_range_is_high() { + // Close pinned at the top of a rising range -> raw%K near 100, both + // smoothed lines high. + let mut k = KasePermissionStochastic::new(5, 3).unwrap(); + let candles: Vec = (0_i64..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base + 2.0, base - 2.0, base + 2.0, i) + }) + .collect(); + let last = k.batch(&candles).last().unwrap().unwrap(); + assert!(last.fast > 80.0, "fast {} should be high", last.fast); + assert!(last.slow > 80.0, "slow {} should be high", last.slow); + } + + #[test] + fn flat_window_defaults_to_neutral() { + // Constant high/low/close -> HH == LL -> raw%K defaults to 50, so both + // EMAs converge to 50. + let mut k = KasePermissionStochastic::new(4, 2).unwrap(); + let candles: Vec = (0..20).map(|i| candle(10.0, 10.0, 10.0, i)).collect(); + let last = k.batch(&candles).last().unwrap().unwrap(); + assert_relative_eq!(last.fast, 50.0, epsilon = 1e-9); + assert_relative_eq!(last.slow, 50.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut k = KasePermissionStochastic::classic(); + let candles: Vec = (0..40).map(|i| candle(11.0, 9.0, 10.5, i)).collect(); + k.batch(&candles); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.2).sin() * 5.0; + candle(base + 2.0, base - 2.0, base + (i as f64 * 0.3).cos(), i) + }) + .collect(); + let mut a = KasePermissionStochastic::classic(); + let mut b = KasePermissionStochastic::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/kelly_criterion.rs b/crates/wickra-core/src/indicators/kelly_criterion.rs new file mode 100644 index 0000000..63432c3 --- /dev/null +++ b/crates/wickra-core/src/indicators/kelly_criterion.rs @@ -0,0 +1,202 @@ +//! Rolling Kelly Criterion. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Kelly Criterion fraction. +/// +/// Input is treated as a per-period (or per-trade) return. Over the trailing +/// window the indicator estimates the optimal capital fraction to allocate +/// using the **even-money** Kelly formula generalised by the payoff ratio: +/// +/// ```text +/// win_rate = P(r > 0) over window +/// avg_win = mean(r for r > 0) +/// avg_loss = mean(−r for r < 0) +/// payoff_ratio = avg_win / avg_loss +/// Kelly = win_rate − (1 − win_rate) / payoff_ratio +/// ``` +/// +/// The output is the recommended **fraction** of capital to bet (typically +/// `(0, 1)`; can go negative if the estimated edge is negative, in which +/// case the position should be reversed or sized to zero). Most +/// practitioners use a "half-Kelly" or "quarter-Kelly" multiplier in +/// practice to reduce variance — Wickra reports raw Kelly and leaves the +/// scaling to the caller. +/// +/// Edge cases: +/// * No winners and no losers ⇒ `0.0` (no information). +/// * No losers (`payoff_ratio = ∞`) ⇒ Kelly collapses to the win rate. +/// * No winners but losers present ⇒ Kelly = `−(1 − 0) / payoff = …`, +/// which is negative — bet nothing (or short). +/// +/// Each `update` is O(period). +#[derive(Debug, Clone)] +pub struct KellyCriterion { + period: usize, + window: VecDeque, +} + +impl KellyCriterion { + /// Construct a new rolling Kelly Criterion. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for KellyCriterion { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut sum_win = 0.0_f64; + let mut n_win = 0_u32; + let mut sum_loss = 0.0_f64; + let mut n_loss = 0_u32; + for &r in &self.window { + if r > 0.0 { + sum_win += r; + n_win += 1; + } else if r < 0.0 { + sum_loss += -r; + n_loss += 1; + } + } + let n = self.period as f64; + let win_rate = f64::from(n_win) / n; + if n_loss == 0 { + // No losses in window: payoff ratio is infinite; Kelly collapses + // to the win rate (limit of w - (1-w)/r as r -> ∞). + return Some(win_rate); + } + let avg_loss = sum_loss / f64::from(n_loss); + if n_win == 0 { + // All losses: avg_win = 0 -> payoff = 0 -> -(1)/0 -> -inf. + // Bet nothing (or reverse); clamp to -1 for sanity. + return Some(-1.0); + } + let avg_win = sum_win / f64::from(n_win); + let payoff = avg_win / avg_loss; + Some(win_rate - (1.0 - win_rate) / payoff) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "KellyCriterion" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(KellyCriterion::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let k = KellyCriterion::new(10).unwrap(); + assert_eq!(k.period(), 10); + assert_eq!(k.name(), "KellyCriterion"); + assert_eq!(k.warmup_period(), 10); + } + + #[test] + fn reference_value() { + // returns = [0.02, 0.04, -0.01, -0.02] (n=4). + // n_win=2, n_loss=2; win_rate = 0.5. + // avg_win=0.03, avg_loss=0.015, payoff=2. + // Kelly = 0.5 - (0.5/2) = 0.25. + let mut k = KellyCriterion::new(4).unwrap(); + let out = k.batch(&[0.02, 0.04, -0.01, -0.02]); + assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-9); + } + + #[test] + fn all_winners_returns_win_rate() { + let mut k = KellyCriterion::new(3).unwrap(); + let out = k.batch(&[0.01, 0.02, 0.03]); + assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn all_losers_returns_negative_one() { + let mut k = KellyCriterion::new(3).unwrap(); + let out = k.batch(&[-0.01, -0.02, -0.03]); + assert_relative_eq!(out[2].unwrap(), -1.0, epsilon = 1e-12); + } + + #[test] + fn flat_window_yields_zero() { + let mut k = KellyCriterion::new(3).unwrap(); + let out = k.batch(&[0.0_f64; 3]); + assert_eq!(out[2], Some(0.0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut k = KellyCriterion::new(3).unwrap(); + assert_eq!(k.update(f64::NAN), None); + assert_eq!(k.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut k = KellyCriterion::new(3).unwrap(); + k.batch(&[0.01, -0.02, 0.03]); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect(); + let batch = KellyCriterion::new(10).unwrap().batch(&returns); + let mut s = KellyCriterion::new(10).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/keltner.rs b/crates/wickra-core/src/indicators/keltner.rs new file mode 100644 index 0000000..7c7bac0 --- /dev/null +++ b/crates/wickra-core/src/indicators/keltner.rs @@ -0,0 +1,244 @@ +//! Keltner Channels. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Keltner Channels output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KeltnerOutput { + /// Upper band = middle + multiplier * ATR. + pub upper: f64, + /// Middle band = EMA of typical price. + pub middle: f64, + /// Lower band = middle - multiplier * ATR. + pub lower: f64, +} + +/// Keltner Channels: an EMA centerline with bands sized by ATR. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Keltner}; +/// +/// let mut indicator = Keltner::new(5, 5, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Keltner { + ema: Ema, + atr: Atr, + multiplier: f64, + ema_period: usize, + atr_period: usize, +} + +impl Keltner { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on invalid inputs. + pub fn new(ema_period: usize, atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + ema: Ema::new(ema_period)?, + atr: Atr::new(atr_period)?, + multiplier, + ema_period, + atr_period, + }) + } + + /// Classic configuration: EMA(20), ATR(10), 2.0x multiplier. + pub fn classic() -> Self { + Self::new(20, 10, 2.0).expect("classic Keltner parameters are valid") + } + + /// Configured `(ema_period, atr_period, multiplier)`. + pub const fn periods(&self) -> (usize, usize, f64) { + (self.ema_period, self.atr_period, self.multiplier) + } +} + +impl Indicator for Keltner { + type Input = Candle; + type Output = KeltnerOutput; + + fn update(&mut self, candle: Candle) -> Option { + // Feed both sub-indicators on every candle so they warm up in parallel. + // Gating `atr.update` behind `ema.update(...)?` would starve the ATR of + // every candle consumed during the EMA's warmup, delaying the first + // emission past `warmup_period()` and seeding the ATR over the wrong + // window. + let mid = self.ema.update(candle.typical_price()); + let atr = self.atr.update(candle); + let (mid, atr) = (mid?, atr?); + Some(KeltnerOutput { + upper: mid + self.multiplier * atr, + middle: mid, + lower: mid - self.multiplier * atr, + }) + } + + fn reset(&mut self) { + self.ema.reset(); + self.atr.reset(); + } + + fn warmup_period(&self) -> usize { + self.ema_period.max(self.atr_period) + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() && self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "KeltnerChannels" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..50).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut k = Keltner::new(20, 10, 2.0).unwrap(); + let last = k.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, last.middle, epsilon = 1e-9); + assert_relative_eq!(last.lower, last.middle, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..100) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut k = Keltner::classic(); + for o in k.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = Keltner::classic(); + let mut b = Keltner::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_input() { + assert!(Keltner::new(0, 10, 2.0).is_err()); + assert!(Keltner::new(20, 10, 0.0).is_err()); + assert!(Keltner::new(20, 10, -1.0).is_err()); + } + + /// Cover the const accessor `periods` (68-70) and the Indicator-impl + /// `name` body (106-108). Existing tests inspect band output but + /// never query the metadata. + #[test] + fn accessors_and_metadata() { + let k = Keltner::new(20, 10, 2.0).unwrap(); + let (ema, atr, mult) = k.periods(); + assert_eq!(ema, 20); + assert_eq!(atr, 10); + assert!((mult - 2.0).abs() < 1e-12); + assert_eq!(k.name(), "KeltnerChannels"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..50) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut k = Keltner::classic(); + k.batch(&candles); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(candles[0]), None); + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut k = Keltner::classic(); + let out = k.batch(&candles); + let warmup = k.warmup_period(); + assert_eq!(warmup, 20); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first KeltnerOutput must land at warmup_period - 1" + ); + } + + #[test] + fn matches_independent_ema_and_atr() { + // The EMA (on typical price) and the ATR (on the candle) run as + // independent siblings; Keltner must equal feeding two standalone + // instances and combining them once both are ready. + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.5, m - 1.5, m) + }) + .collect(); + let mut k = Keltner::classic(); + let mut ema = Ema::new(20).unwrap(); + let mut atr = Atr::new(10).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = k.update(*candle); + let mid = ema.update(candle.typical_price()); + let a = atr.update(*candle); + match (mid, a) { + (Some(m), Some(av)) => { + let o = got.expect("Keltner emits once EMA and ATR are both ready"); + assert_relative_eq!(o.middle, m, epsilon = 1e-9); + assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9); + assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9); + } + _ => assert!( + got.is_none(), + "Keltner must be None until both ready (i={i})" + ), + } + } + } +} diff --git a/crates/wickra-core/src/indicators/kendall_tau.rs b/crates/wickra-core/src/indicators/kendall_tau.rs new file mode 100644 index 0000000..6c9d861 --- /dev/null +++ b/crates/wickra-core/src/indicators/kendall_tau.rs @@ -0,0 +1,309 @@ +//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// `+1` / `0` / `-1` sign of `a − b`. +fn sign(a: f64, b: f64) -> i32 { + if a > b { + 1 + } else if a < b { + -1 + } else { + 0 + } +} + +/// Kendall's tau-b — a rank correlation between two synchronised series based on +/// the balance of **concordant** and **discordant** pairs, with a tie correction. +/// +/// ```text +/// over all pairs (i < j) in the window: +/// concordant if (x_j − x_i) and (y_j − y_i) share a sign +/// discordant if they have opposite signs +/// tie_x / tie_y if the respective difference is zero +/// n0 = N(N−1)/2 +/// tau_b = (n_concordant − n_discordant) / sqrt((n0 − tie_x)(n0 − tie_y)) +/// ``` +/// +/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear* +/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates +/// ranks via their differences, Kendall's tau counts how often the two series move +/// the **same direction** between every pair of observations. It is the most +/// robust of the three to outliers and to non-linear-but-monotonic +/// relationships, and the tau-b form corrects for ties so repeated values do not +/// bias it. The output is in `[−1, +1]`: `+1` perfectly concordant, `−1` +/// perfectly discordant, `0` no monotonic association. +/// +/// The window holds the last `period` pairs and is recomputed each bar in +/// O(`period²`). A window with no untied pairs on one side returns `0`. The first +/// value lands after `period` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, KendallTau}; +/// +/// let mut indicator = KendallTau::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let x = f64::from(i); +/// last = indicator.update((x, 2.0 * x)); // perfectly concordant +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct KendallTau { + period: usize, + window: VecDeque<(f64, f64)>, + last: Option, +} + +impl KendallTau { + /// Construct a rolling Kendall's tau-b over `period` pairs. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at + /// least two pairs). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "Kendall tau needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window of pairs. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let pairs: Vec<(f64, f64)> = self.window.iter().copied().collect(); + let len = pairs.len(); + let mut concordant: i64 = 0; + let mut discordant: i64 = 0; + let mut tie_x: i64 = 0; + let mut tie_y: i64 = 0; + for i in 0..len { + for j in (i + 1)..len { + let sx = sign(pairs[j].0, pairs[i].0); + let sy = sign(pairs[j].1, pairs[i].1); + if sx == 0 { + tie_x += 1; + } + if sy == 0 { + tie_y += 1; + } + let prod = sx * sy; + if prod > 0 { + concordant += 1; + } else if prod < 0 { + discordant += 1; + } + } + } + let n0 = (len * (len - 1) / 2) as f64; + let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt(); + if denom == 0.0 { + return 0.0; + } + ((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0) + } +} + +impl Indicator for KendallTau { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "KendallTau" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + KendallTau::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(KendallTau::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let k = KendallTau::new(20).unwrap(); + assert_eq!(k.period(), 20); + assert_eq!(k.warmup_period(), 20); + assert_eq!(k.name(), "KendallTau"); + assert!(!k.is_ready()); + assert_eq!(k.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut k = KendallTau::new(4).unwrap(); + let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn monotone_increasing_is_one() { + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0)) + .collect(); + let last = KendallTau::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn monotone_decreasing_is_minus_one() { + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| (f64::from(i), -3.0 * f64::from(i))) + .collect(); + let last = KendallTau::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_channel_yields_zero() { + // y constant -> every y-difference is a tie -> denom 0 -> 0. + let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect(); + let last = KendallTau::new(8) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0) + }) + .collect(); + for v in KendallTau::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut k = KendallTau::new(4).unwrap(); + k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.value(), None); + assert_eq!(k.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin(), (t * 0.5).cos()) + }) + .collect(); + let batch = KendallTau::new(14).unwrap().batch(&pairs); + let mut b = KendallTau::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ties_are_corrected() { + // Tied x values (points 0 and 1) and tied y values (points 1 and 2) + // exercise the tie_x / tie_y correction counters. + let mut k = KendallTau::new(4).unwrap(); + assert_eq!(k.update((1.0, 1.0)), None); + assert_eq!(k.update((1.0, 2.0)), None); + assert_eq!(k.update((2.0, 2.0)), None); + let v = k.update((3.0, 3.0)).unwrap(); + assert!((-1.0..=1.0).contains(&v), "got {v}"); + } + + #[test] + fn non_finite_input_returns_none() { + let mut k = KendallTau::new(2).unwrap(); + assert_eq!(k.update((f64::NAN, 1.0)), None); + assert_eq!(k.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(k.update((1.0, 2.0)), None); + assert!(k.update((2.0, 5.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/kicking.rs b/crates/wickra-core/src/indicators/kicking.rs new file mode 100644 index 0000000..d676647 --- /dev/null +++ b/crates/wickra-core/src/indicators/kicking.rs @@ -0,0 +1,189 @@ +//! Kicking candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Kicking — a 2-bar reversal of two opposite-coloured marubozu separated by a +/// gap. A shadowless candle is "kicked" the other way by a shadowless candle of +/// the opposite colour that gaps clear of it — a violent change of control. It is +/// trend-agnostic: the gap direction alone defines the signal. +/// +/// ```text +/// marubozu = |close − open| >= 0.95 * (high − low) (no meaningful shadows) +/// bullish (+1.0): black marubozu, then a white marubozu gapping UP (low2 > high1) +/// bearish (−1.0): white marubozu, then a black marubozu gapping DOWN (high2 < low1) +/// ``` +/// +/// Output is `+1.0` (bullish) or `−1.0` (bearish) when the pattern completes and +/// `0.0` otherwise. The first bar always returns `0.0` because the two-bar window +/// is not yet filled. The marubozu threshold follows the geometric house style +/// rather than TA-Lib's rolling averages. Pattern-shape check only — no trend +/// filter is applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the two directions +/// occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Kicking}; +/// +/// let mut indicator = Kicking::new(); +/// indicator.update(Candle::new(12.0, 12.0, 10.0, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(14.0, 16.0, 14.0, 16.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Kicking { + prev: Option, + has_emitted: bool, +} + +impl Kicking { + /// Construct a new Kicking detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +/// Whether `candle` is a marubozu (body fills at least 95 % of its range). +fn is_marubozu(candle: &Candle) -> bool { + let range = candle.high - candle.low; + range > 0.0 && (candle.close - candle.open).abs() >= 0.95 * range +} + +impl Indicator for Kicking { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + if !is_marubozu(&bar1) || !is_marubozu(&candle) { + return Some(0.0); + } + // Bullish: black marubozu kicked up by a white marubozu gapping above it. + if bar1.close < bar1.open && candle.close > candle.open && candle.low > bar1.high { + return Some(1.0); + } + // Bearish: white marubozu kicked down by a black marubozu gapping below it. + if bar1.close > bar1.open && candle.close < candle.open && candle.high < bar1.low { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Kicking" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = Kicking::new(); + assert_eq!(t.name(), "Kicking"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_kicking_is_plus_one() { + let mut t = Kicking::new(); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 16.0, 14.0, 16.0, 1)), Some(1.0)); + } + + #[test] + fn bearish_kicking_is_minus_one() { + let mut t = Kicking::new(); + assert_eq!(t.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(8.0, 8.0, 6.0, 6.0, 1)), Some(-1.0)); + } + + #[test] + fn not_marubozu_yields_zero() { + let mut t = Kicking::new(); + // bar1 has long shadows -> not a marubozu. + t.update(c(12.0, 14.0, 8.0, 10.0, 0)); + assert_eq!(t.update(c(14.0, 16.0, 14.0, 16.0, 1)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = Kicking::new(); + t.update(c(12.0, 12.0, 10.0, 10.0, 0)); + // White marubozu but it overlaps bar1 (no gap up). + assert_eq!(t.update(c(11.0, 13.0, 11.0, 13.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = Kicking::new(); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64 * 5.0; + if i % 2 == 0 { + c(base + 2.0, base + 2.0, base, base, i) + } else { + c(base + 3.0, base + 5.0, base + 3.0, base + 5.0, i) + } + }) + .collect(); + let mut a = Kicking::new(); + let mut b = Kicking::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Kicking::new(); + t.update(c(12.0, 12.0, 10.0, 10.0, 0)); + t.update(c(14.0, 16.0, 14.0, 16.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/kicking_by_length.rs b/crates/wickra-core/src/indicators/kicking_by_length.rs new file mode 100644 index 0000000..f39e715 --- /dev/null +++ b/crates/wickra-core/src/indicators/kicking_by_length.rs @@ -0,0 +1,197 @@ +//! Kicking-by-Length candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Kicking-by-Length — the [`Kicking`](crate::Kicking) pattern with the signal +/// taken from the *longer* of the two marubozu rather than from the gap direction. +/// When the two shadowless candles differ in size, the bigger one is treated as +/// the dominant force. +/// +/// ```text +/// marubozu = |close − open| >= 0.95 * (high − low) +/// setup: two opposite-coloured marubozu separated by a gap +/// black then white gapping UP, or white then black gapping DOWN +/// signal = colour of the LONGER marubozu (white -> +1.0, black -> −1.0) +/// ``` +/// +/// Output is `+1.0` or `−1.0` when the kicking setup is present and `0.0` +/// otherwise. Note this can disagree with [`Kicking`](crate::Kicking): a black +/// marubozu kicked up by a *shorter* white marubozu reports `−1.0` here. The first +/// bar always returns `0.0` because the two-bar window is not yet filled. The +/// marubozu threshold follows the geometric house style rather than TA-Lib's +/// rolling averages. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, KickingByLength}; +/// +/// let mut indicator = KickingByLength::new(); +/// indicator.update(Candle::new(12.0, 12.0, 10.0, 10.0, 1.0, 0).unwrap()); +/// // White marubozu gaps up and is the longer body -> +1. +/// let out = indicator +/// .update(Candle::new(14.0, 20.0, 14.0, 20.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct KickingByLength { + prev: Option, + has_emitted: bool, +} + +impl KickingByLength { + /// Construct a new Kicking-by-Length detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +fn is_marubozu(candle: &Candle) -> bool { + let range = candle.high - candle.low; + range > 0.0 && (candle.close - candle.open).abs() >= 0.95 * range +} + +impl Indicator for KickingByLength { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + if !is_marubozu(&bar1) || !is_marubozu(&candle) { + return Some(0.0); + } + let body1 = bar1.close - bar1.open; + let body2 = candle.close - candle.open; + let bullish_setup = body1 < 0.0 && body2 > 0.0 && candle.low > bar1.high; + let bearish_setup = body1 > 0.0 && body2 < 0.0 && candle.high < bar1.low; + if !(bullish_setup || bearish_setup) { + return Some(0.0); + } + // The longer marubozu's colour is the signal. + let longer_is_white = if body1.abs() >= body2.abs() { + body1 > 0.0 + } else { + body2 > 0.0 + }; + Some(if longer_is_white { 1.0 } else { -1.0 }) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "KickingByLength" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = KickingByLength::new(); + assert_eq!(t.name(), "KickingByLength"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn longer_white_is_plus_one() { + let mut t = KickingByLength::new(); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0)); + // White marubozu (length 6) longer than the black one (length 2). + assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(1.0)); + } + + #[test] + fn longer_black_is_minus_one() { + let mut t = KickingByLength::new(); + // Black marubozu (length 6), then a shorter white marubozu (length 2) + // gapping up -> the longer black body wins, so -1. + assert_eq!(t.update(c(16.0, 16.0, 10.0, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(18.0, 20.0, 18.0, 20.0, 1)), Some(-1.0)); + } + + #[test] + fn not_marubozu_yields_zero() { + let mut t = KickingByLength::new(); + t.update(c(12.0, 14.0, 8.0, 10.0, 0)); + assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = KickingByLength::new(); + t.update(c(12.0, 12.0, 10.0, 10.0, 0)); + assert_eq!(t.update(c(11.0, 13.0, 11.0, 13.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = KickingByLength::new(); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64 * 5.0; + if i % 2 == 0 { + c(base + 2.0, base + 2.0, base, base, i) + } else { + c(base + 3.0, base + 5.0, base + 3.0, base + 5.0, i) + } + }) + .collect(); + let mut a = KickingByLength::new(); + let mut b = KickingByLength::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = KickingByLength::new(); + t.update(c(12.0, 12.0, 10.0, 10.0, 0)); + t.update(c(14.0, 20.0, 14.0, 20.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/kst.rs b/crates/wickra-core/src/indicators/kst.rs new file mode 100644 index 0000000..ff3e1f9 --- /dev/null +++ b/crates/wickra-core/src/indicators/kst.rs @@ -0,0 +1,303 @@ +//! Know Sure Thing (KST). + +use crate::error::{Error, Result}; +use crate::indicators::roc::Roc; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// `KST` output: the indicator line and its `SMA` signal line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KstOutput { + /// Weighted sum of four smoothed `ROC` series. + pub kst: f64, + /// `SMA` of `kst` over the signal period. + pub signal: f64, +} + +/// Pring's Know Sure Thing — a long-horizon momentum oscillator that combines +/// four `ROC` series at different lookbacks, each smoothed by its own `SMA`, +/// summed with Pring's fixed weights `1, 2, 3, 4`: +/// +/// ```text +/// RCMA_i = SMA(ROC(close, roc_i), sma_i) for i = 1..=4 +/// KST = 1·RCMA_1 + 2·RCMA_2 + 3·RCMA_3 + 4·RCMA_4 +/// Signal = SMA(KST, signal_period) +/// ``` +/// +/// Pring's recommended defaults are +/// `(roc1, roc2, roc3, roc4) = (10, 15, 20, 30)`, +/// `(sma1, sma2, sma3, sma4) = (10, 10, 10, 15)`, +/// `signal_period = 9`. `Kst::classic()` constructs that configuration. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Kst}; +/// +/// let mut kst = Kst::classic(); +/// let mut last = None; +/// for i in 0..200 { +/// last = kst.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kst { + roc1_period: usize, + roc2_period: usize, + roc3_period: usize, + roc4_period: usize, + sma1_period: usize, + sma2_period: usize, + sma3_period: usize, + sma4_period: usize, + signal_period: usize, + roc1: Roc, + roc2: Roc, + roc3: Roc, + roc4: Roc, + sma1: Sma, + sma2: Sma, + sma3: Sma, + sma4: Sma, + signal_sma: Sma, + last_line: Option, + last_signal: Option, +} + +impl Kst { + /// # Errors + /// Returns [`Error::PeriodZero`] if any of the nine periods is zero. + #[allow(clippy::too_many_arguments)] + pub fn new( + roc1: usize, + roc2: usize, + roc3: usize, + roc4: usize, + sma1: usize, + sma2: usize, + sma3: usize, + sma4: usize, + signal: usize, + ) -> Result { + if [roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal].contains(&0) { + return Err(Error::PeriodZero); + } + Ok(Self { + roc1_period: roc1, + roc2_period: roc2, + roc3_period: roc3, + roc4_period: roc4, + sma1_period: sma1, + sma2_period: sma2, + sma3_period: sma3, + sma4_period: sma4, + signal_period: signal, + roc1: Roc::new(roc1)?, + roc2: Roc::new(roc2)?, + roc3: Roc::new(roc3)?, + roc4: Roc::new(roc4)?, + sma1: Sma::new(sma1)?, + sma2: Sma::new(sma2)?, + sma3: Sma::new(sma3)?, + sma4: Sma::new(sma4)?, + signal_sma: Sma::new(signal)?, + last_line: None, + last_signal: None, + }) + } + + /// Pring's recommended defaults: `KST(10, 15, 20, 30, 10, 10, 10, 15, 9)`. + pub fn classic() -> Self { + Self::new(10, 15, 20, 30, 10, 10, 10, 15, 9).expect("classic KST parameters are valid") + } + + /// Configured `(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)`. + pub const fn periods( + &self, + ) -> ( + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + ) { + ( + self.roc1_period, + self.roc2_period, + self.roc3_period, + self.roc4_period, + self.sma1_period, + self.sma2_period, + self.sma3_period, + self.sma4_period, + self.signal_period, + ) + } +} + +impl Indicator for Kst { + type Input = f64; + type Output = KstOutput; + + fn update(&mut self, input: f64) -> Option { + // Feed every inner state machine on every input so they warm up in + // parallel. The KST line waits for all four RCMA branches; the signal + // line additionally waits for its own SMA to fill. + let r1 = self.roc1.update(input); + let r2 = self.roc2.update(input); + let r3 = self.roc3.update(input); + let r4 = self.roc4.update(input); + let rcma1 = r1.and_then(|x| self.sma1.update(x)); + let rcma2 = r2.and_then(|x| self.sma2.update(x)); + let rcma3 = r3.and_then(|x| self.sma3.update(x)); + let rcma4 = r4.and_then(|x| self.sma4.update(x)); + let (rcma1, rcma2, rcma3, rcma4) = (rcma1?, rcma2?, rcma3?, rcma4?); + let kst = rcma1 + 2.0 * rcma2 + 3.0 * rcma3 + 4.0 * rcma4; + self.last_line = Some(kst); + let signal = self.signal_sma.update(kst); + let signal = signal?; + self.last_signal = Some(signal); + Some(KstOutput { kst, signal }) + } + + fn reset(&mut self) { + self.roc1.reset(); + self.roc2.reset(); + self.roc3.reset(); + self.roc4.reset(); + self.sma1.reset(); + self.sma2.reset(); + self.sma3.reset(); + self.sma4.reset(); + self.signal_sma.reset(); + self.last_line = None; + self.last_signal = None; + } + + fn warmup_period(&self) -> usize { + // Each RCMA_i emits once the inner ROC has warmed up (roc_i + 1 + // inputs) AND the SMA has filled (sma_i inputs through it). All four + // run in parallel so the slowest branch dominates, and the signal SMA + // adds signal_period − 1 inputs on top of the slowest branch. + let branch = |roc: usize, sma: usize| roc + sma; + let slowest = branch(self.roc1_period, self.sma1_period) + .max(branch(self.roc2_period, self.sma2_period)) + .max(branch(self.roc3_period, self.sma3_period)) + .max(branch(self.roc4_period, self.sma4_period)); + slowest + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.last_signal.is_some() + } + + fn name(&self) -> &'static str { + "KST" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + Kst::new(0, 15, 20, 30, 10, 10, 10, 15, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + Kst::new(10, 15, 20, 30, 10, 10, 10, 15, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let kst = Kst::classic(); + assert_eq!(kst.periods(), (10, 15, 20, 30, 10, 10, 10, 15, 9)); + assert_eq!(kst.name(), "KST"); + // The slowest branch is ROC(30) + SMA(15) = 45; signal_period - 1 = 8. + assert_eq!(kst.warmup_period(), 53); + } + + #[test] + fn classic_factory_matches_pring_defaults() { + let kst = Kst::classic(); + let (r1, r2, r3, r4, s1, s2, s3, s4, sig) = kst.periods(); + assert_eq!((r1, r2, r3, r4), (10, 15, 20, 30)); + assert_eq!((s1, s2, s3, s4), (10, 10, 10, 15)); + assert_eq!(sig, 9); + } + + #[test] + fn constant_series_yields_zero() { + // ROC is zero on a flat series, so every RCMA collapses to zero and + // KST itself is zero. The signal SMA inherits that. + let mut kst = Kst::classic(); + let prices = vec![42.0_f64; 80]; + let out = kst.batch(&prices); + for v in out.iter().skip(kst.warmup_period() - 1).flatten() { + assert_relative_eq!(v.kst, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut kst = Kst::new(2, 3, 4, 5, 2, 2, 2, 3, 2).unwrap(); + // Slowest branch is ROC(5) + SMA(3) = 8; signal − 1 = 1; total 9. + assert_eq!(kst.warmup_period(), 9); + let prices: Vec = (1..=15).map(f64::from).collect(); + let out = kst.batch(&prices); + for v in out.iter().take(8) { + assert!(v.is_none()); + } + assert!(out[8].is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Monotonic uptrend -> every ROC > 0 -> every RCMA > 0 -> KST > 0. + let mut kst = Kst::classic(); + let prices: Vec = (1..=120).map(|i| f64::from(i) * 2.0).collect(); + let out = kst.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!( + last.kst > 0.0, + "KST on a clean uptrend should be positive: {}", + last.kst + ); + assert!(last.signal > 0.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = Kst::classic(); + let mut b = Kst::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut kst = Kst::classic(); + let prices: Vec = (1..=120).map(f64::from).collect(); + kst.batch(&prices); + assert!(kst.is_ready()); + kst.reset(); + assert!(!kst.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/kurtosis.rs b/crates/wickra-core/src/indicators/kurtosis.rs new file mode 100644 index 0000000..ea79587 --- /dev/null +++ b/crates/wickra-core/src/indicators/kurtosis.rs @@ -0,0 +1,206 @@ +//! Rolling excess kurtosis (Pearson's fourth standardised central moment − 3). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling **excess** kurtosis of the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ x +/// m2 = (1/n) · Σ (x − mean)² +/// m4 = (1/n) · Σ (x − mean)⁴ +/// Kurtosis = m4 / m2² − 3 +/// ``` +/// +/// The unshifted kurtosis `m4 / m2²` equals `3` for the normal distribution; +/// subtracting `3` gives **excess** kurtosis so that `0` is the Gaussian +/// baseline. Positive readings flag fat tails (heavy outliers compared to +/// normal); negative readings flag light tails (more concentrated than +/// normal). This is the population definition with divisor `n`. A window +/// with zero dispersion yields `0`. +/// +/// Each `update` is O(1): four running sums (`Σ x`, `Σ x²`, `Σ x³`, `Σ x⁴`) +/// are maintained as the window slides; the central moments are derived +/// from them via the binomial-expansion identities, so no inner loop runs +/// per bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Kurtosis}; +/// +/// let mut indicator = Kurtosis::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kurtosis { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, + sum_cu: f64, + sum_qu: f64, +} + +impl Kurtosis { + /// Construct a new rolling excess kurtosis with the given period. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 4`. + pub fn new(period: usize) -> Result { + if period < 4 { + return Err(Error::InvalidPeriod { + message: "kurtosis needs period >= 4", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + sum_cu: 0.0, + sum_qu: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Kurtosis { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + let sq = old * old; + self.sum -= old; + self.sum_sq -= sq; + self.sum_cu -= old * sq; + self.sum_qu -= sq * sq; + } + self.window.push_back(value); + let sq = value * value; + self.sum += value; + self.sum_sq += sq; + self.sum_cu += value * sq; + self.sum_qu += sq * sq; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let m2 = (self.sum_sq / n - mean * mean).max(0.0); + if m2 == 0.0 { + // Flat window: kurtosis is undefined, return 0 (Gaussian baseline). + return Some(0.0); + } + // m4 = E[x⁴] − 4·mean·E[x³] + 6·mean²·E[x²] − 3·mean⁴. + let mean_sq = mean * mean; + let m4 = self.sum_qu / n - 4.0 * mean * (self.sum_cu / n) + + 6.0 * mean_sq * (self.sum_sq / n) + - 3.0 * mean_sq * mean_sq; + Some(m4 / (m2 * m2) - 3.0) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.sum_cu = 0.0; + self.sum_qu = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Kurtosis" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_four() { + assert!(Kurtosis::new(0).is_err()); + assert!(Kurtosis::new(3).is_err()); + assert!(Kurtosis::new(4).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let k = Kurtosis::new(14).unwrap(); + assert_eq!(k.period(), 14); + assert_eq!(k.warmup_period(), 14); + assert_eq!(k.name(), "Kurtosis"); + } + + #[test] + fn two_point_distribution_is_negative_two() { + // A {a, b, a, b} window has m4/m2² = 1, so excess kurtosis = −2. + // This is the theoretical minimum for any real distribution. + let mut k = Kurtosis::new(4).unwrap(); + let out = k.batch(&[-1.0, 1.0, -1.0, 1.0]); + assert_relative_eq!(out[3].unwrap(), -2.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_yields_zero() { + let mut k = Kurtosis::new(5).unwrap(); + for v in k.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn outlier_window_is_leptokurtic() { + // A single large outlier amid otherwise-flat samples has positive + // excess kurtosis (a heavy tail). + let mut k = Kurtosis::new(5).unwrap(); + let out = k.batch(&[0.0, 0.0, 0.0, 0.0, 100.0]); + assert!(out[4].unwrap() > 0.0); + } + + #[test] + fn reset_clears_state() { + let mut k = Kurtosis::new(5).unwrap(); + k.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = Kurtosis::new(14).unwrap().batch(&prices); + let mut b = Kurtosis::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/kvo.rs b/crates/wickra-core/src/indicators/kvo.rs new file mode 100644 index 0000000..b2c249a --- /dev/null +++ b/crates/wickra-core/src/indicators/kvo.rs @@ -0,0 +1,263 @@ +//! Klinger Volume Oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Stephen J. Klinger's Volume Oscillator — a long/short-term volume-force +/// MACD with trend-aware cumulative-money-flow weighting. +/// +/// Each bar produces a "volume force" (`vf`) whose sign tracks the daily trend +/// (`+1` on an up day, `−1` on a down day, carry-over otherwise) and whose +/// magnitude scales with how the current accumulation horizon compares to the +/// previous trend's. The KVO line is the difference of two EMAs of `vf`: +/// +/// ```text +/// dm_t = high_t + low_t + close_t (the "daily measurement") +/// trend = sign(dm_t − dm_{t−1}) if differs from previous trend, reset cm +/// cm_t = cm_{t−1} + dm_t if trend unchanged +/// cm_t = dm_{t−1} + dm_t if trend just flipped +/// vf_t = volume_t · |2·(dm_t/cm_t − 1)| · trend · 100 +/// KVO_t = EMA(vf, fast)_t − EMA(vf, slow)_t +/// ``` +/// +/// Klinger's textbook configuration is `fast = 34, slow = 55` on daily bars. +/// The first bar only seeds `dm_{t−1}`, so the very first `vf` lands at bar 2; +/// the slow EMA then needs `slow` raw `vf` values to seed, putting the first +/// KVO emission at bar `slow + 1`. A zero `cm_t` (which only happens on the +/// trend-flip branch when both the prior and current `dm` are zero) collapses +/// `vf` to `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Kvo}; +/// +/// let mut indicator = Kvo::new(34, 55).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kvo { + fast_period: usize, + slow_period: usize, + fast: Ema, + slow: Ema, + prev_dm: Option, + trend: i8, + cm: f64, +} + +impl Kvo { + /// Construct a new KVO with the given EMA periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "KVO needs fast < slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + prev_dm: None, + trend: 0, + cm: 0.0, + }) + } + + /// Klinger's classic configuration: `EMA(vf, 34) − EMA(vf, 55)`. + pub fn classic() -> Self { + Self::new(34, 55).expect("classic Klinger periods are valid") + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for Kvo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let dm = candle.high + candle.low + candle.close; + let Some(prev_dm) = self.prev_dm else { + // The first bar only establishes the previous daily measurement. + self.prev_dm = Some(dm); + return None; + }; + + // Determine the bar's trend sign relative to the previous bar. + let new_trend: i8 = if dm > prev_dm { + 1 + } else if dm < prev_dm { + -1 + } else { + self.trend + }; + + // Cumulative measurement resets to (prev_dm + dm) whenever the trend + // flips. On the very first sign read (trend was 0) we also seed from + // the two-bar sum, matching the textbook definition. + if new_trend != self.trend || self.trend == 0 { + self.cm = prev_dm + dm; + } else { + self.cm += dm; + } + self.trend = new_trend; + + let vf = if self.cm == 0.0 { + // Pathological all-zero OHLC stretch — no force to register. + 0.0 + } else { + candle.volume * (2.0 * (dm / self.cm - 1.0)).abs() * f64::from(new_trend) * 100.0 + }; + + self.prev_dm = Some(dm); + + let fast = self.fast.update(vf); + let slow = self.slow.update(vf); + Some(fast? - slow?) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.prev_dm = None; + self.trend = 0; + self.cm = 0.0; + } + + fn warmup_period(&self) -> usize { + // One bar to seed `prev_dm`, then the slow EMA needs `slow` raw `vf` values. + self.slow_period + 1 + } + + fn is_ready(&self) -> bool { + self.fast.is_ready() && self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "KVO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(low, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Kvo::new(0, 10), Err(Error::PeriodZero))); + assert!(matches!(Kvo::new(3, 0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!(Kvo::new(34, 34), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Kvo::new(55, 34), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let k = Kvo::classic(); + assert_eq!(k.periods(), (34, 55)); + assert_eq!(k.name(), "KVO"); + assert_eq!(k.warmup_period(), 56); + } + + #[test] + fn zero_ohlc_collapses_vf_to_zero() { + // Two consecutive all-zero bars: dm = 0 for both, so prev_dm + dm = 0 + // and `cm == 0.0` fires the defensive branch, holding vf at zero. + let mut k = Kvo::new(3, 6).unwrap(); + let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 100.0, 0).unwrap(); + assert_eq!(k.update(zero), None); + assert_eq!(k.update(zero), None); + assert_eq!(k.update(zero), None); + } + + #[test] + fn constant_series_yields_zero() { + // dm flat -> trend never sets to a nonzero sign and vf collapses to 0 + // for every bar; both EMAs hold at 0 once seeded. + let candles: Vec = (0..120).map(|i| c(10.0, 10.0, 10.0, 100.0, i)).collect(); + let mut k = Kvo::new(3, 6).unwrap(); + for v in k.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_at_slow_plus_one() { + let candles: Vec = (0..30i64) + .map(|i| { + let f = i as f64; + c(10.0 + f, 8.0 + f, 9.0 + f, 100.0, i) + }) + .collect(); + let mut k = Kvo::new(3, 5).unwrap(); + let out = k.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + // First emission lands at index slow_period (one seed bar + slow EMA seeding from there). + assert!(out[5].is_some(), "first value lands at slow_period"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.2).sin() * 4.0; + c(mid + 1.0, mid - 1.0, mid, 10.0 + ((i % 5) as f64), i) + }) + .collect(); + let mut a = Kvo::classic(); + let mut b = Kvo::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c(11.0 + f, 9.0 + f, 10.0 + f, 100.0, i) + }) + .collect(); + let mut k = Kvo::classic(); + k.batch(&candles); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/kyles_lambda.rs b/crates/wickra-core/src/indicators/kyles_lambda.rs new file mode 100644 index 0000000..b6c0f99 --- /dev/null +++ b/crates/wickra-core/src/indicators/kyles_lambda.rs @@ -0,0 +1,281 @@ +//! Kyle's Lambda — rolling price impact per unit of signed order flow. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::TradeQuote; +use crate::traits::Indicator; + +/// Kyle's Lambda — the rolling ordinary-least-squares slope of mid-price changes +/// on signed trade volume, the canonical measure of market depth / price +/// impact. +/// +/// Each `update` receives a [`TradeQuote`] — a trade plus the mid prevailing at +/// execution. Internally the indicator forms, per trade, the mid change since +/// the previous trade (`Δmid = midₜ − midₜ₋₁`) and the signed volume +/// (`q = size · D`, with `D` the aggressor sign), then runs a rolling OLS +/// regression of `Δmid` on `q` over the trailing window of `window` trades: +/// +/// ```text +/// cov = (1/n) · Σ q·Δmid − q̄·Δ̄mid +/// var = (1/n) · Σ q² − q̄² +/// λ = cov / var +/// ``` +/// +/// `λ` is the estimated price move per unit of signed volume: a deep, liquid +/// book absorbs flow with little movement and reads a small `λ`; a thin book +/// moves sharply per unit traded and reads a large `λ`. It is a direct, +/// model-light proxy for the slope of the demand curve in Kyle's microstructure +/// model. +/// +/// Each `update` is O(1): four running sums (`Σq`, `ΣΔmid`, `Σq²`, `Σq·Δmid`) +/// are maintained as the window slides. A window of constant signed volume has +/// zero variance and `λ` is undefined; the indicator returns `0` in that case +/// rather than producing `NaN`. +/// +/// `Input = TradeQuote`, `Output = f64`. It warms up for `window + 1` +/// trade-quotes: one to seed the previous mid, then `window` paired +/// observations. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, KylesLambda, Side, Trade, TradeQuote}; +/// +/// // A book where each trade moves the mid by exactly 0.5 per unit of signed +/// // volume gives λ = 0.5. +/// let mut lambda = KylesLambda::new(8).unwrap(); +/// let mut mid = 100.0; +/// let mut last = None; +/// for i in 0..20 { +/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; +/// let size = 1.0 + f64::from(i % 3); +/// let signed = size * side.sign(); +/// mid += 0.5 * signed; +/// let trade = Trade::new(mid, size, side, 0).unwrap(); +/// last = lambda.update(TradeQuote::new(trade, mid).unwrap()); +/// } +/// assert!((last.unwrap() - 0.5).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct KylesLambda { + window: usize, + prev_mid: Option, + pairs: VecDeque<(f64, f64)>, + sum_q: f64, + sum_dm: f64, + sum_qq: f64, + sum_qdm: f64, +} + +impl KylesLambda { + /// Construct a rolling Kyle's lambda over `window` paired observations. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `window < 2` (the regression + /// variance needs at least two observations). + pub fn new(window: usize) -> Result { + if window < 2 { + return Err(Error::InvalidPeriod { + message: "kyle's lambda needs window >= 2", + }); + } + Ok(Self { + window, + prev_mid: None, + pairs: VecDeque::with_capacity(window), + sum_q: 0.0, + sum_dm: 0.0, + sum_qq: 0.0, + sum_qdm: 0.0, + }) + } + + /// The configured window length, in paired observations. + pub const fn window(&self) -> usize { + self.window + } + + fn push_pair(&mut self, signed_vol: f64, delta_mid: f64) -> Option { + if self.pairs.len() == self.window { + let (old_q, old_dm) = self.pairs.pop_front().expect("non-empty"); + self.sum_q -= old_q; + self.sum_dm -= old_dm; + self.sum_qq -= old_q * old_q; + self.sum_qdm -= old_q * old_dm; + } + self.pairs.push_back((signed_vol, delta_mid)); + self.sum_q += signed_vol; + self.sum_dm += delta_mid; + self.sum_qq += signed_vol * signed_vol; + self.sum_qdm += signed_vol * delta_mid; + if self.pairs.len() < self.window { + return None; + } + let n = self.window as f64; + let mean_q = self.sum_q / n; + let mean_dm = self.sum_dm / n; + let var_q = (self.sum_qq / n - mean_q * mean_q).max(0.0); + let cov = self.sum_qdm / n - mean_q * mean_dm; + if var_q == 0.0 { + // Constant signed-volume window has no defined slope. + return Some(0.0); + } + Some(cov / var_q) + } +} + +impl Indicator for KylesLambda { + type Input = TradeQuote; + type Output = f64; + + fn update(&mut self, quote: TradeQuote) -> Option { + let mid = quote.mid; + let signed_vol = quote.trade.size * quote.trade.side.sign(); + let Some(prev) = self.prev_mid else { + self.prev_mid = Some(mid); + return None; + }; + self.prev_mid = Some(mid); + self.push_pair(signed_vol, mid - prev) + } + + fn reset(&mut self) { + self.prev_mid = None; + self.pairs.clear(); + self.sum_q = 0.0; + self.sum_dm = 0.0; + self.sum_qq = 0.0; + self.sum_qdm = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + 1 + } + + fn is_ready(&self) -> bool { + self.pairs.len() == self.window + } + + fn name(&self) -> &'static str { + "KylesLambda" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::{Side, Trade}; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn quotes_with_impact(n: usize, impact: f64) -> Vec { + let mut mid = 100.0; + (0..n) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + let size = 1.0 + (i % 3) as f64; + let signed = size * side.sign(); + mid += impact * signed; + let trade = Trade::new(mid, size, side, 0).unwrap(); + TradeQuote::new(trade, mid).unwrap() + }) + .collect() + } + + #[test] + fn rejects_window_below_two() { + assert!(KylesLambda::new(0).is_err()); + assert!(KylesLambda::new(1).is_err()); + assert!(KylesLambda::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let kl = KylesLambda::new(14).unwrap(); + assert_eq!(kl.name(), "KylesLambda"); + assert_eq!(kl.window(), 14); + assert_eq!(kl.warmup_period(), 15); + assert!(!kl.is_ready()); + } + + #[test] + fn recovers_constant_impact_slope() { + // mid moves exactly 0.5 per unit signed volume -> lambda = 0.5. + let last = KylesLambda::new(6) + .unwrap() + .batch("es_with_impact(20, 0.5)) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-9); + } + + #[test] + fn negative_impact_reads_negative() { + let last = KylesLambda::new(6) + .unwrap() + .batch("es_with_impact(20, -0.3)) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -0.3, epsilon = 1e-9); + } + + #[test] + fn constant_signed_volume_is_zero() { + // Every trade is a buy of size 1: signed volume is constant -> var 0 -> 0. + let mut mid = 100.0; + let quotes: Vec = (0..10) + .map(|_| { + mid += 0.01; + let trade = Trade::new(mid, 1.0, Side::Buy, 0).unwrap(); + TradeQuote::new(trade, mid).unwrap() + }) + .collect(); + let last = KylesLambda::new(5) + .unwrap() + .batch("es) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn warms_up_after_window_plus_one() { + let mut kl = KylesLambda::new(3).unwrap(); + let quotes = quotes_with_impact(4, 0.2); + assert_eq!(kl.update(quotes[0]), None); // seeds prev mid + assert_eq!(kl.update(quotes[1]), None); + assert_eq!(kl.update(quotes[2]), None); + assert!(!kl.is_ready()); + assert!(kl.update(quotes[3]).is_some()); + assert!(kl.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let quotes = quotes_with_impact(40, 0.15); + let batch = KylesLambda::new(10).unwrap().batch("es); + let mut kl = KylesLambda::new(10).unwrap(); + let streamed: Vec<_> = quotes.iter().map(|q| kl.update(*q)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut kl = KylesLambda::new(3).unwrap(); + for q in quotes_with_impact(6, 0.2) { + kl.update(q); + } + assert!(kl.is_ready()); + kl.reset(); + assert!(!kl.is_ready()); + assert_eq!(kl.update(quotes_with_impact(1, 0.2)[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/ladder_bottom.rs b/crates/wickra-core/src/indicators/ladder_bottom.rs new file mode 100644 index 0000000..38f0a7e --- /dev/null +++ b/crates/wickra-core/src/indicators/ladder_bottom.rs @@ -0,0 +1,207 @@ +//! Ladder Bottom candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Ladder Bottom — a 5-bar bullish reversal. Three long black candles step the +/// market down like rungs of a ladder, a fourth black candle finally shows an +/// upper shadow (the first sign of buying), and a white candle then gaps up into +/// its body to confirm the turn. +/// +/// ```text +/// bar1, bar2, bar3 black, with consecutively lower opens AND closes +/// bar4 black with an upper shadow (high4 > open4) +/// bar5 white, opens above bar4's body (open5 > open4) and closes up +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Ladder Bottom +/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The +/// first four bars always return `0.0` because the five-bar window is not yet +/// filled. Pattern-shape check only — no trend filter is applied; combine with a +/// trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, LadderBottom}; +/// +/// let mut indicator = LadderBottom::new(); +/// indicator.update(Candle::new(20.0, 20.1, 17.9, 18.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(18.0, 18.1, 15.9, 16.0, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(16.0, 16.1, 13.9, 14.0, 1.0, 2).unwrap()); +/// indicator.update(Candle::new(14.0, 15.0, 12.4, 12.5, 1.0, 3).unwrap()); +/// let out = indicator +/// .update(Candle::new(15.0, 17.1, 14.9, 17.0, 1.0, 4).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct LadderBottom { + c1: Option, + c2: Option, + c3: Option, + c4: Option, + has_emitted: bool, +} + +impl LadderBottom { + /// Construct a new Ladder Bottom detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + c3: None, + c4: None, + has_emitted: false, + } + } +} + +impl Indicator for LadderBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + let bar4 = self.c4; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = self.c4; + self.c4 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else { + return Some(0.0); + }; + if bar1.close < bar1.open + && bar2.close < bar2.open + && bar3.close < bar3.open + && bar2.open < bar1.open + && bar2.close < bar1.close + && bar3.open < bar2.open + && bar3.close < bar2.close + && bar4.close < bar4.open + && bar4.high > bar4.open + && candle.close > candle.open + && candle.open > bar4.open + { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.c4 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "LadderBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = LadderBottom::new(); + assert_eq!(t.name(), "LadderBottom"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + } + + #[test] + fn ladder_bottom_is_plus_one() { + let mut t = LadderBottom::new(); + assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), Some(0.0)); + assert_eq!(t.update(c(18.0, 18.1, 15.9, 16.0, 1)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0)); + assert_eq!(t.update(c(14.0, 15.0, 12.4, 12.5, 3)), Some(0.0)); + assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(1.0)); + } + + #[test] + fn fourth_bar_without_upper_shadow_yields_zero() { + let mut t = LadderBottom::new(); + t.update(c(20.0, 20.1, 17.9, 18.0, 0)); + t.update(c(18.0, 18.1, 15.9, 16.0, 1)); + t.update(c(16.0, 16.1, 13.9, 14.0, 2)); + // bar4 opens at its high -> no upper shadow. + t.update(c(14.0, 14.0, 12.4, 12.5, 3)); + assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(0.0)); + } + + #[test] + fn not_three_descending_blacks_yields_zero() { + let mut t = LadderBottom::new(); + // bar2 is not lower than bar1. + t.update(c(20.0, 20.1, 17.9, 18.0, 0)); + t.update(c(21.0, 21.1, 18.9, 19.0, 1)); + t.update(c(16.0, 16.1, 13.9, 14.0, 2)); + t.update(c(14.0, 15.0, 12.4, 12.5, 3)); + assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(0.0)); + } + + #[test] + fn first_four_bars_return_zero() { + let mut t = LadderBottom::new(); + assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), Some(0.0)); + assert_eq!(t.update(c(18.0, 18.1, 15.9, 16.0, 1)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0)); + assert_eq!(t.update(c(14.0, 15.0, 12.4, 12.5, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 200.0 - i as f64; + c(base, base + 0.1, base - 2.1, base - 2.0, i) + }) + .collect(); + let mut a = LadderBottom::new(); + let mut b = LadderBottom::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = LadderBottom::new(); + t.update(c(20.0, 20.1, 17.9, 18.0, 0)); + t.update(c(18.0, 18.1, 15.9, 16.0, 1)); + t.update(c(16.0, 16.1, 13.9, 14.0, 2)); + t.update(c(14.0, 15.0, 12.4, 12.5, 3)); + t.update(c(15.0, 17.1, 14.9, 17.0, 4)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/laguerre_rsi.rs b/crates/wickra-core/src/indicators/laguerre_rsi.rs new file mode 100644 index 0000000..e7efaae --- /dev/null +++ b/crates/wickra-core/src/indicators/laguerre_rsi.rs @@ -0,0 +1,283 @@ +//! Ehlers' Laguerre RSI. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// John Ehlers' Laguerre RSI — a four-stage Laguerre polynomial filter wrapped +/// in an `RSI`-style up/down accumulator. The single tuning parameter `gamma` +/// in `[0, 1]` trades lag for smoothness: small `gamma` is fast and noisy, +/// large `gamma` is slow and smooth (Ehlers recommends `0.5`). +/// +/// ```text +/// alpha = 1 − gamma +/// L0_t = alpha · price_t + gamma · L0_{t-1} +/// L1_t = −gamma · L0_t + L0_{t-1} + gamma · L1_{t-1} +/// L2_t = −gamma · L1_t + L1_{t-1} + gamma · L2_{t-1} +/// L3_t = −gamma · L2_t + L2_{t-1} + gamma · L3_{t-1} +/// +/// cu, cd = 0 +/// for each pair (L0, L1), (L1, L2), (L2, L3): +/// if upper ≥ lower: cu += upper − lower +/// else : cd += lower − upper +/// +/// LRSI = 100 · cu / (cu + cd) +/// ``` +/// +/// The output is bounded in `[0, 100]`. State is seeded by setting all four +/// `L_i` to the first input, so the first emission lands on input #1. +/// +/// Reference: John F. Ehlers, *Time Warp — Without Space Travel*, 2002. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LaguerreRsi}; +/// +/// let mut lrsi = LaguerreRsi::new(0.5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = lrsi.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LaguerreRsi { + gamma: f64, + alpha: f64, + l0: f64, + l1: f64, + l2: f64, + l3: f64, + seeded: bool, + current: Option, +} + +impl LaguerreRsi { + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `gamma` is non-finite or outside `[0, 1]`. + pub fn new(gamma: f64) -> Result { + if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) { + return Err(Error::InvalidPeriod { + message: "LaguerreRSI gamma must be a finite value in [0, 1]", + }); + } + Ok(Self { + gamma, + alpha: 1.0 - gamma, + l0: 0.0, + l1: 0.0, + l2: 0.0, + l3: 0.0, + seeded: false, + current: None, + }) + } + + /// Ehlers' recommended `gamma = 0.5`. + pub fn classic() -> Self { + Self::new(0.5).expect("classic LaguerreRSI gamma is valid") + } + + /// Configured `gamma`. + pub const fn gamma(&self) -> f64 { + self.gamma + } +} + +impl Indicator for LaguerreRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + if !self.seeded { + // Seed all four polynomial stages with the first input so a + // constant series produces zero up/down accumulators (which we + // map to 50.0 below — the canonical neutral mid-band reading). + self.l0 = input; + self.l1 = input; + self.l2 = input; + self.l3 = input; + self.seeded = true; + self.current = Some(50.0); + return self.current; + } + let (l0_prev, l1_prev, l2_prev) = (self.l0, self.l1, self.l2); + let l0_new = self.alpha * input + self.gamma * l0_prev; + let l1_new = -self.gamma * l0_new + l0_prev + self.gamma * self.l1; + let l2_new = -self.gamma * l1_new + l1_prev + self.gamma * self.l2; + let l3_new = -self.gamma * l2_new + l2_prev + self.gamma * self.l3; + self.l0 = l0_new; + self.l1 = l1_new; + self.l2 = l2_new; + self.l3 = l3_new; + + let mut cu = 0.0; + let mut cd = 0.0; + let pairs = [(l0_new, l1_new), (l1_new, l2_new), (l2_new, l3_new)]; + for (upper, lower) in pairs { + if upper >= lower { + cu += upper - lower; + } else { + cd += lower - upper; + } + } + let total = cu + cd; + let value = if total > 0.0 { + // Floating-point rounding can push `cu / total` a hair above 1.0; + // clamp to the algebraic bound to keep the output strictly inside + // [0, 100]. + (100.0 * cu / total).clamp(0.0, 100.0) + } else { + // No up- or down-displacements between stages: stay at the + // neutral mid-band rather than report 0 / 0. + 50.0 + }; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.l0 = 0.0; + self.l1 = 0.0; + self.l2 = 0.0; + self.l3 = 0.0; + self.seeded = false; + self.current = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "LaguerreRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_gamma() { + assert!(matches!( + LaguerreRsi::new(-0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + LaguerreRsi::new(1.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + LaguerreRsi::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let lrsi = LaguerreRsi::new(0.5).unwrap(); + assert_eq!(lrsi.gamma(), 0.5); + assert_eq!(lrsi.warmup_period(), 1); + assert_eq!(lrsi.name(), "LaguerreRSI"); + } + + #[test] + fn classic_factory() { + assert_eq!(LaguerreRsi::classic().gamma(), 0.5); + } + + #[test] + fn constant_series_stays_at_mid_band() { + // All four L_i seed to the constant; on subsequent flat inputs they + // stay equal, so cu = cd = 0 and LRSI reports the neutral 50. + let mut lrsi = LaguerreRsi::classic(); + let out = lrsi.batch(&[42.0_f64; 60]); + for v in out.iter().flatten() { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_bounded() { + let mut lrsi = LaguerreRsi::classic(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 25.0) + .collect(); + for v in lrsi.batch(&prices).iter().flatten() { + assert!(*v >= 0.0 && *v <= 100.0, "out of range: {v}"); + } + } + + #[test] + fn pure_uptrend_saturates_high() { + let mut lrsi = LaguerreRsi::classic(); + for i in 0..200 { + lrsi.update(100.0 + f64::from(i)); + } + let v = lrsi.current.unwrap(); + assert!(v > 80.0, "uptrend should drive LRSI well above 50: {v}"); + } + + #[test] + fn pure_downtrend_saturates_low() { + let mut lrsi = LaguerreRsi::classic(); + for i in 0..200 { + lrsi.update(300.0 - f64::from(i)); + } + let v = lrsi.current.unwrap(); + assert!(v < 20.0, "downtrend should drive LRSI well below 50: {v}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = LaguerreRsi::classic(); + let mut b = LaguerreRsi::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut lrsi = LaguerreRsi::classic(); + lrsi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lrsi.is_ready()); + lrsi.reset(); + assert!(!lrsi.is_ready()); + assert!(!lrsi.seeded); + } + + #[test] + fn ignores_non_finite_input() { + let mut lrsi = LaguerreRsi::classic(); + let before = lrsi.update(10.0).unwrap(); + assert_eq!(lrsi.update(f64::NAN), Some(before)); + assert_eq!(lrsi.update(f64::INFINITY), Some(before)); + } + + #[test] + fn gamma_zero_passes_through_l0() { + // gamma = 0 -> alpha = 1, so L0 mirrors the input exactly each step. + // The polynomial chain then lags by one stage; the up/down accumulator + // still produces a bounded reading and the first non-seed step shifts + // off 50 as soon as input changes. + let mut lrsi = LaguerreRsi::new(0.0).unwrap(); + assert_eq!(lrsi.update(10.0), Some(50.0)); + let v = lrsi.update(11.0).unwrap(); + assert!((0.0..=100.0).contains(&v)); + } +} diff --git a/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs b/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs new file mode 100644 index 0000000..2a303f5 --- /dev/null +++ b/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs @@ -0,0 +1,340 @@ +//! Lead–Lag Cross-Correlation — which of two assets leads the other, and by how much. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Output of [`LeadLagCrossCorrelation`]: the lead/lag offset and its correlation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LeadLagCrossCorrelationOutput { + /// The offset `k ∈ [−max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`. + /// + /// A **positive** lag means `a` leads `b` by `lag` samples (a's pattern + /// shows up in `b` that many steps later); a **negative** lag means `b` + /// leads `a`; `0` means the two are most correlated contemporaneously. + pub lag: i64, + /// The (signed) Pearson correlation at that lag, in `[−1, +1]`. + pub correlation: f64, +} + +/// Rolling lead–lag cross-correlation between two synchronised series. +/// +/// Each `update` receives one `(a, b)` pair. The indicator keeps the most +/// recent `window + 2·max_lag` samples of each series and, once full, reports +/// the integer offset `k ∈ [−max_lag, +max_lag]` that maximises the absolute +/// Pearson correlation between `a` and a copy of `b` shifted by `k`: +/// +/// ```text +/// lag = argmax_k | corr( a[t], b[t+k] ) | +/// ``` +/// +/// This answers "does BTC lead ETH on this timescale, and by how many bars?". +/// A positive lag means `a` leads `b`; a negative lag means `b` leads `a`. The +/// reported `correlation` is the signed correlation at that lag, so its sign +/// tells you whether the lead relationship is positive or inverse. +/// +/// The comparison is fully causal: `a`'s window is held fixed in the centre of +/// the buffer and `b`'s window slides across it, so every lag — positive and +/// negative — is evaluated only against data already seen. The candidate lags +/// are scanned in order of increasing `|k|`, so ties resolve to the smallest +/// absolute offset (lag `0` wins an exact tie). +/// +/// Each `update` is `O(window · max_lag)` — proportional to the fixed +/// parameters, not the series length. A flat window in either channel makes a +/// correlation undefined; it is reported as `0` rather than `NaN`. +/// +/// Feed raw prices or returns depending on your convention; lead–lag on +/// returns is the more common choice for relating two assets. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LeadLagCrossCorrelation}; +/// +/// let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap(); +/// let mut last = None; +/// for t in 0..60 { +/// let a = (f64::from(t) * 0.4).sin() + 0.4 * (f64::from(t) * 1.1).sin(); +/// // `b` is `a` delayed by 3 samples, so `a` leads `b` by 3. +/// let b = (f64::from(t - 3) * 0.4).sin() + 0.4 * (f64::from(t - 3) * 1.1).sin(); +/// last = ll.update((a, b)); +/// } +/// let out = last.unwrap(); +/// assert_eq!(out.lag, 3); +/// assert!(out.correlation > 0.99); +/// ``` +#[derive(Debug, Clone)] +pub struct LeadLagCrossCorrelation { + window: usize, + max_lag: usize, + len: usize, + a_buf: VecDeque, + b_buf: VecDeque, +} + +impl LeadLagCrossCorrelation { + /// Construct a new lead–lag cross-correlation. + /// + /// `window` is the number of overlapping points each correlation is + /// computed over; `max_lag` is the largest offset (in either direction) + /// that is searched. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `window < 2` or `max_lag == 0`. + pub fn new(window: usize, max_lag: usize) -> Result { + if window < 2 { + return Err(Error::InvalidPeriod { + message: "lead-lag cross-correlation needs window >= 2", + }); + } + if max_lag == 0 { + return Err(Error::InvalidPeriod { + message: "lead-lag cross-correlation needs max_lag >= 1", + }); + } + let len = window + 2 * max_lag; + Ok(Self { + window, + max_lag, + len, + a_buf: VecDeque::with_capacity(len), + b_buf: VecDeque::with_capacity(len), + }) + } + + /// Number of overlapping points per correlation. + pub const fn window(&self) -> usize { + self.window + } + + /// Largest offset searched in either direction. + pub const fn max_lag(&self) -> usize { + self.max_lag + } + + /// Pearson correlation between `a[a_start .. a_start+window]` and + /// `b[b_start .. b_start+window]`, clamped to `[−1, 1]`. Returns `0` when + /// either window has zero variance. + fn corr_at(&self, a_start: usize, b_start: usize) -> f64 { + let n = self.window as f64; + let mut sa = 0.0; + let mut sb = 0.0; + let mut saa = 0.0; + let mut sbb = 0.0; + let mut sab = 0.0; + for j in 0..self.window { + let x = self.a_buf[a_start + j]; + let y = self.b_buf[b_start + j]; + sa += x; + sb += y; + saa += x * x; + sbb += y * y; + sab += x * y; + } + let mean_a = sa / n; + let mean_b = sb / n; + let var_a = (saa / n - mean_a * mean_a).max(0.0); + let var_b = (sbb / n - mean_b * mean_b).max(0.0); + let denom = (var_a * var_b).sqrt(); + if denom == 0.0 { + return 0.0; + } + let cov = sab / n - mean_a * mean_b; + (cov / denom).clamp(-1.0, 1.0) + } +} + +impl Indicator for LeadLagCrossCorrelation { + /// `(a, b)` pair. + type Input = (f64, f64); + type Output = LeadLagCrossCorrelationOutput; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.a_buf.len() == self.len { + self.a_buf.pop_front(); + self.b_buf.pop_front(); + } + self.a_buf.push_back(a); + self.b_buf.push_back(b); + if self.a_buf.len() < self.len { + return None; + } + // `a`'s window sits in the centre; `b`'s window slides ±max_lag. + let a_start = self.max_lag; + // Start at lag 0, then widen outward so ties prefer the smallest |lag|. + // The lag is tracked as a signed counter incremented by ±1, so no + // unsigned index is ever cast to a signed type. + let mut best_lag: i64 = 0; + let mut best_corr = self.corr_at(a_start, a_start); + let mut best_abs = best_corr.abs(); + let mut lag_neg: i64 = 0; + let mut lag_pos: i64 = 0; + for d in 1..=self.max_lag { + lag_neg -= 1; + lag_pos += 1; + // Negative lag: b shifted earlier (b leads a). + let c_neg = self.corr_at(a_start, a_start - d); + if c_neg.abs() > best_abs { + best_abs = c_neg.abs(); + best_corr = c_neg; + best_lag = lag_neg; + } + // Positive lag: b shifted later (a leads b). + let c_pos = self.corr_at(a_start, a_start + d); + if c_pos.abs() > best_abs { + best_abs = c_pos.abs(); + best_corr = c_pos; + best_lag = lag_pos; + } + } + Some(LeadLagCrossCorrelationOutput { + lag: best_lag, + correlation: best_corr, + }) + } + + fn reset(&mut self) { + self.a_buf.clear(); + self.b_buf.clear(); + } + + fn warmup_period(&self) -> usize { + self.len + } + + fn is_ready(&self) -> bool { + self.a_buf.len() == self.len + } + + fn name(&self) -> &'static str { + "LeadLagCrossCorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn signal(t: i64) -> f64 { + let t = t as f64; + (t * 0.4).sin() + 0.4 * (t * 1.1).sin() + 0.2 * (t * 0.27).cos() + } + + #[test] + fn rejects_invalid_params() { + assert!(LeadLagCrossCorrelation::new(1, 5).is_err()); + assert!(LeadLagCrossCorrelation::new(10, 0).is_err()); + assert!(LeadLagCrossCorrelation::new(10, 5).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let ll = LeadLagCrossCorrelation::new(10, 4).unwrap(); + assert_eq!(ll.window(), 10); + assert_eq!(ll.max_lag(), 4); + // len = window + 2*max_lag = 10 + 8 = 18. + assert_eq!(ll.warmup_period(), 18); + assert_eq!(ll.name(), "LeadLagCrossCorrelation"); + } + + #[test] + fn detects_positive_lead() { + // b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1. + let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t - 3))).collect(); + let out = LeadLagCrossCorrelation::new(12, 5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(out.lag, 3); + assert!(out.correlation > 0.99, "corr was {}", out.correlation); + } + + #[test] + fn detects_negative_lead() { + // a is a delayed copy of b ⇒ b leads a ⇒ lag = −2. + let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t - 2), signal(t))).collect(); + let out = LeadLagCrossCorrelation::new(12, 5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(out.lag, -2); + assert!(out.correlation > 0.99, "corr was {}", out.correlation); + } + + #[test] + fn contemporaneous_is_lag_zero() { + // Identical streams correlate best at lag 0 with correlation 1. + let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t))).collect(); + let out = LeadLagCrossCorrelation::new(12, 5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(out.lag, 0); + assert_relative_eq!(out.correlation, 1.0, epsilon = 1e-9); + } + + #[test] + fn flat_channel_yields_zero_correlation() { + // A constant `a` has no variance ⇒ every correlation is 0 ⇒ lag 0. + let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, signal(t))).collect(); + let out = LeadLagCrossCorrelation::new(10, 4) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(out.lag, 0); + assert_relative_eq!(out.correlation, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut ll = LeadLagCrossCorrelation::new(10, 4).unwrap(); + for t in 0..40 { + ll.update((signal(t), signal(t - 2))); + } + assert!(ll.is_ready()); + ll.reset(); + assert!(!ll.is_ready()); + assert_eq!(ll.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..80).map(|t| (signal(t), signal(t - 1))).collect(); + let batch = LeadLagCrossCorrelation::new(12, 5).unwrap().batch(&pairs); + let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + // len = window + 2*max_lag = 2 + 2 = 4 finite ticks fill the buffers. + let mut ll = LeadLagCrossCorrelation::new(2, 1).unwrap(); + assert_eq!(ll.update((f64::NAN, 1.0)), None); + assert_eq!(ll.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(ll.update((1.0, 2.0)), None); + assert_eq!(ll.update((2.0, 1.0)), None); + assert_eq!(ll.update((3.0, 4.0)), None); + assert!(ll.update((4.0, 2.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/linreg.rs b/crates/wickra-core/src/indicators/linreg.rs new file mode 100644 index 0000000..b425abd --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg.rs @@ -0,0 +1,297 @@ +//! Linear Regression (rolling least-squares endpoint). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression — the endpoint of a rolling least-squares fit. +/// +/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits +/// the line `y = a + b·x` by ordinary least squares and reports the line's +/// value at the most recent point: +/// +/// ```text +/// b (slope) = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// a (intercept) = (Σy − b·Σx) / n +/// LinearReg = a + b·(period − 1) +/// ``` +/// +/// This is TA-Lib's `LINEARREG`: a smoothed price that lags less than an SMA +/// because it extrapolates the *local trend* forward to the current bar +/// instead of averaging it away. +/// +/// Each `update` is O(1): the `Σx` and `Σxx` terms depend only on `period` and +/// are precomputed once, while `Σy` and `Σxy` are maintained incrementally as +/// the window slides. The closed-form sliding-window identity for +/// `x = 0, 1, …, period − 1` is +/// +/// ```text +/// new_sum_xy = old_sum_xy − old_sum_y + popped_y0 // index shift by −1 +/// new_sum_y = old_sum_y − popped_y0 +/// // then push the new value at index n−1: +/// sum_xy += (n − 1) · new_value +/// sum_y += new_value +/// ``` +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinearRegression}; +/// +/// let mut indicator = LinearRegression::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinearRegression { + period: usize, + window: VecDeque, + /// Closed form of `Σx` over `x = 0, 1, …, period − 1` — constant in `period`. + sum_x: f64, + /// Closed form of `n · Σxx − (Σx)²` — constant in `period`, the OLS + /// denominator. + denom: f64, + /// Running sum of the values currently in the window. + sum_y: f64, + /// Running `Σ(x · y)` where `x` is the position of each value within the + /// trailing window (`0` for the oldest, `period − 1` for the newest). + sum_xy: f64, +} + +impl LinearRegression { + /// Construct a new rolling linear regression over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression needs period >= 2", + }); + } + let n = period as f64; + // Closed forms for x = 0, 1, …, period − 1. + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for LinearRegression { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + // Sliding phase: pop the oldest, then shift every remaining index + // down by 1 in the running `sum_xy`. The identity + // Σ((i − 1) · y_i for i = 1..n−1) = Σ(i · y_i) − Σ(y_i) + y_0 + // gives the closed-form update below. + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + } + // Append at position `k = current length` before the push. During + // warmup `k` ranges over `0..period − 1`; once the window is full it + // is always `period − 1`. + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let intercept = (self.sum_y - slope * self.sum_x) / n; + Some(intercept + slope * (n - 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "LinearRegression" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, endpoint = 0 + 4·2 = 8. + let mut lr = LinearRegression::new(3).unwrap(); + let out = lr.batch(&[1.0, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 8.0, epsilon = 1e-9); + } + + #[test] + fn perfect_line_returns_current_value() { + // The regression of a perfectly linear series is that line itself, so + // its endpoint equals the current value. + let prices: Vec = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut lr = LinearRegression::new(10).unwrap(); + for (i, v) in lr.batch(&prices).into_iter().enumerate() { + if let Some(v) = v { + assert_relative_eq!(v, 2.0 * i as f64 + 5.0, epsilon = 1e-6); + } + } + } + + #[test] + fn constant_series_returns_the_constant() { + let mut lr = LinearRegression::new(8).unwrap(); + for v in lr.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 42.0, epsilon = 1e-9); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut lr = LinearRegression::new(5).unwrap(); + let out = lr.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first value lands at index period - 1"); + assert_eq!(lr.warmup_period(), 5); + } + + #[test] + fn rejects_period_below_two() { + assert!(LinearRegression::new(0).is_err()); + assert!(LinearRegression::new(1).is_err()); + assert!(LinearRegression::new(2).is_ok()); + } + + /// Cover the const accessor `period` (92-94) and the Indicator-impl + /// `name` body (142-144). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let lr = LinearRegression::new(14).unwrap(); + assert_eq!(lr.period(), 14); + assert_eq!(lr.name(), "LinearRegression"); + } + + #[test] + fn reset_clears_state() { + let mut lr = LinearRegression::new(5).unwrap(); + lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lr.is_ready()); + lr.reset(); + assert!(!lr.is_ready()); + assert_eq!(lr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinearRegression::new(14).unwrap(); + let mut b = LinearRegression::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } + + /// Incremental OLS equivalence: the O(1) implementation must agree to + /// `1e-9` with a fresh-from-scratch O(n) refit on every bar, on inputs + /// chosen to stress every code path: a noisy ramp (sliding phase + /// dominates), a step function (the new value differs sharply from the + /// popped one), and constants (the floating-point accumulators must not + /// drift). + #[test] + fn incremental_matches_naive_fit_bar_by_bar() { + fn naive_endpoint(window: &[f64]) -> f64 { + let n = window.len() as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_x = 0.0; + let mut sum_xx = 0.0; + for (i, &y) in window.iter().enumerate() { + let x = i as f64; + sum_y += y; + sum_xy += x * y; + sum_x += x; + sum_xx += x * x; + } + let denom = n * sum_xx - sum_x * sum_x; + let slope = (n * sum_xy - sum_x * sum_y) / denom; + let intercept = (sum_y - slope * sum_x) / n; + intercept + slope * (n - 1.0) + } + + fn check(prices: &[f64], period: usize) { + let mut lr = LinearRegression::new(period).unwrap(); + for (t, p) in prices.iter().enumerate() { + let streaming = lr.update(*p); + if t + 1 >= period { + let lo = t + 1 - period; + let expected = naive_endpoint(&prices[lo..=t]); + let got = streaming.expect("warmed up"); + assert!( + (got - expected).abs() < 1e-9, + "endpoint diverges at t={t}, period={period}: got={got}, expected={expected}", + ); + } + } + } + + let noisy_ramp: Vec = (0..120) + .map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0) + .collect(); + check(&noisy_ramp, 5); + check(&noisy_ramp, 14); + check(&noisy_ramp, 30); + + let mut step = vec![1.0; 30]; + step.extend(std::iter::repeat_n(100.0, 30)); + step.extend(std::iter::repeat_n(0.001, 30)); + check(&step, 5); + check(&step, 14); + + let constant = vec![42.0; 50]; + check(&constant, 8); + check(&constant, 25); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_angle.rs b/crates/wickra-core/src/indicators/linreg_angle.rs new file mode 100644 index 0000000..1ebc2a2 --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_angle.rs @@ -0,0 +1,177 @@ +//! Linear Regression Angle. + +use crate::error::Result; +use crate::indicators::linreg_slope::LinRegSlope; +use crate::traits::Indicator; + +/// Linear Regression Angle — the slope of the rolling least-squares fit, +/// expressed as an angle in degrees. +/// +/// ```text +/// LinRegAngle = atan(LinRegSlope) · 180 / π +/// ``` +/// +/// It carries exactly the same information as [`LinRegSlope`](crate::LinRegSlope) +/// — positive while price trends up, negative while it trends down — but maps +/// the unbounded slope through `atan` onto `(−90°, +90°)`. That bounded, +/// price-unit-free scale makes "how steep is the trend" comparable at a glance +/// and across instruments. This is TA-Lib's `LINEARREG_ANGLE`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegAngle}; +/// +/// let mut indicator = LinRegAngle::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinRegAngle { + slope: LinRegSlope, +} + +impl LinRegAngle { + /// Construct a new rolling linear-regression angle over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if + /// `period < 2` — a regression line is undefined for fewer than two points. + pub fn new(period: usize) -> Result { + Ok(Self { + slope: LinRegSlope::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.slope.period() + } +} + +impl Indicator for LinRegAngle { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + self.slope.update(value).map(|s| s.atan().to_degrees()) + } + + fn reset(&mut self) { + self.slope.reset(); + } + + fn warmup_period(&self) -> usize { + self.slope.warmup_period() + } + + fn is_ready(&self) -> bool { + self.slope.is_ready() + } + + fn name(&self) -> &'static str { + "LinRegAngle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn unit_slope_is_forty_five_degrees() { + // A series rising by exactly 1 per step has slope 1, and atan(1) = 45°. + let mut angle = LinRegAngle::new(5).unwrap(); + let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9); + assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9); + } + + #[test] + fn reference_value_steep_slope() { + // period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees. + let mut angle = LinRegAngle::new(3).unwrap(); + let out = angle.batch(&[1.0, 2.0, 9.0]); + assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9); + } + + #[test] + fn constant_series_has_zero_angle() { + let mut angle = LinRegAngle::new(8).unwrap(); + for v in angle.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn falling_series_has_negative_angle() { + let prices: Vec = (0..30).map(|i| 100.0 - f64::from(i)).collect(); + let mut angle = LinRegAngle::new(10).unwrap(); + for v in angle.batch(&prices).into_iter().flatten() { + assert!(v < 0.0, "a falling series must have a negative angle"); + } + } + + #[test] + fn stays_within_ninety_degrees() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0) + .collect(); + let mut angle = LinRegAngle::new(14).unwrap(); + for v in angle.batch(&prices).into_iter().flatten() { + assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)"); + } + } + + #[test] + fn rejects_period_below_two() { + assert!(LinRegAngle::new(0).is_err()); + assert!(LinRegAngle::new(1).is_err()); + assert!(LinRegAngle::new(2).is_ok()); + } + + /// Cover the const accessor `period` (50-52) and the Indicator-impl + /// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect + /// angle output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let a = LinRegAngle::new(14).unwrap(); + assert_eq!(a.period(), 14); + assert_eq!(a.warmup_period(), 14); + assert_eq!(a.name(), "LinRegAngle"); + } + + #[test] + fn reset_clears_state() { + let mut angle = LinRegAngle::new(5).unwrap(); + angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(angle.is_ready()); + angle.reset(); + assert!(!angle.is_ready()); + assert_eq!(angle.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinRegAngle::new(14).unwrap(); + let mut b = LinRegAngle::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_channel.rs b/crates/wickra-core/src/indicators/linreg_channel.rs new file mode 100644 index 0000000..4408d74 --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_channel.rs @@ -0,0 +1,269 @@ +//! Linear Regression Channel — OLS endpoint ± k · stddev of residuals. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression Channel output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LinRegChannelOutput { + /// Upper channel: regression endpoint plus `multiplier · stddev` of the + /// residuals. + pub upper: f64, + /// Middle line: OLS endpoint over the window. + pub middle: f64, + /// Lower channel: regression endpoint minus `multiplier · stddev` of the + /// residuals. + pub lower: f64, +} + +/// Linear Regression Channel: rolling least-squares line with `±k·σ` bands +/// sized by the residuals about the fitted line. +/// +/// ```text +/// fit y = a + b·x by OLS over the last `period` closes +/// residual_i = y_i − (a + b · x_i) +/// sigma = sqrt( Σ residual_i² / period ) // population stddev +/// middle = a + b · (period − 1) // endpoint of the line +/// upper = middle + multiplier · sigma +/// lower = middle − multiplier · sigma +/// ``` +/// +/// Where [`BollingerBands`](crate::BollingerBands) measures dispersion about +/// the *mean*, the `LinReg` Channel measures it about the *trend*: detrended +/// residuals, so a steady drift up or down does not bias the band width. The +/// resulting envelope tracks the trend without flaring on momentum bursts — +/// breakouts are statistically meaningful in the direction of trend, not just +/// in absolute price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegChannel}; +/// +/// let mut indicator = LinRegChannel::new(20, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinRegChannel { + period: usize, + multiplier: f64, + window: VecDeque, + sum_x: f64, + sum_xx: f64, +} + +impl LinRegChannel { + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(period: usize, multiplier: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression channel needs period >= 2", + }); + } + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let n = period as f64; + Ok(Self { + period, + multiplier, + window: VecDeque::with_capacity(period), + sum_x: n * (n - 1.0) / 2.0, + sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for LinRegChannel { + type Input = f64; + type Output = LinRegChannelOutput; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + // Recompute over the live window every bar. The OLS endpoint *could* + // be maintained incrementally (see `LinearRegression`) but the + // residual-stddev cannot be slid in closed form without storing each + // residual; recomputing both keeps the code simple and is O(period) + // per update — entirely acceptable for the periods used in practice. + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let x = i as f64; + sum_y += y; + sum_xy += x * y; + } + let denom = n * self.sum_xx - self.sum_x * self.sum_x; + let slope = (n * sum_xy - self.sum_x * sum_y) / denom; + let intercept = (sum_y - slope * self.sum_x) / n; + + // Residuals about the fitted line. + let mut sum_sq = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let fitted = intercept + slope * (i as f64); + let r = y - fitted; + sum_sq += r * r; + } + let sigma = (sum_sq / n).sqrt(); + let middle = intercept + slope * (n - 1.0); + Some(LinRegChannelOutput { + upper: middle + self.multiplier * sigma, + middle, + lower: middle - self.multiplier * sigma, + }) + } + + 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 { + "LinRegChannel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(LinRegChannel::new(0, 2.0).is_err()); + assert!(LinRegChannel::new(1, 2.0).is_err()); + assert!(LinRegChannel::new(2, 2.0).is_ok()); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + LinRegChannel::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + LinRegChannel::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + LinRegChannel::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let lc = LinRegChannel::new(20, 2.0).unwrap(); + assert_eq!(lc.period(), 20); + assert_relative_eq!(lc.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(lc.warmup_period(), 20); + assert_eq!(lc.name(), "LinRegChannel"); + } + + #[test] + fn perfect_line_collapses_channel() { + // A perfectly linear series has zero residuals, so upper == middle == lower. + let prices: Vec = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut lc = LinRegChannel::new(10, 2.0).unwrap(); + for o in lc.batch(&prices).into_iter().flatten() { + assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9); + assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_collapses_channel() { + let mut lc = LinRegChannel::new(8, 2.0).unwrap(); + let out = lc.batch(&[42.0; 20]); + let v = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(v.middle, 42.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 42.0, epsilon = 1e-9); + assert_relative_eq!(v.lower, 42.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut lc = LinRegChannel::new(20, 2.0).unwrap(); + for o in lc.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinRegChannel::new(14, 2.0).unwrap(); + let mut b = LinRegChannel::new(14, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut lc = LinRegChannel::new(5, 2.0).unwrap(); + lc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lc.is_ready()); + lc.reset(); + assert!(!lc.is_ready()); + assert_eq!(lc.update(1.0), None); + } + + /// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`, + /// endpoint at `x = 2` is `8`. Residuals: `1 − 0 = 1`, `2 − 4 = −2`, + /// `9 − 8 = 1`. Population variance = (1 + 4 + 1) / 3 = 2, sigma = sqrt(2). + /// With multiplier 2.0, upper = 8 + 2·sqrt(2), lower = 8 − 2·sqrt(2). + #[test] + fn reference_values() { + let mut lc = LinRegChannel::new(3, 2.0).unwrap(); + let out = lc.batch(&[1.0, 2.0, 9.0]); + let v = out[2].unwrap(); + let s2 = f64::sqrt(2.0); + assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 8.0 + 2.0 * s2, epsilon = 1e-9); + assert_relative_eq!(v.lower, 8.0 - 2.0 * s2, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_intercept.rs b/crates/wickra-core/src/indicators/linreg_intercept.rs new file mode 100644 index 0000000..91acb8d --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_intercept.rs @@ -0,0 +1,173 @@ +//! Linear Regression Intercept (`LINEARREG_INTERCEPT`). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression Intercept (`LINEARREG_INTERCEPT`): the intercept `a` of the +/// rolling least-squares fit `y = a + b·x` over the last `period` inputs, indexed +/// `x = 0, 1, …, period − 1`. +/// +/// ```text +/// b (slope) = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// a (intercept) = (Σy − b·Σx) / n +/// ``` +/// +/// Where [`LinearRegression`](crate::LinearRegression) reports the fitted line at +/// the most recent bar (`a + b·(period − 1)`), this reports its value at the +/// *start* of the window (`x = 0`). Each update is O(1), maintaining the same +/// closed-form sliding-window sums as `LinearRegression`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegIntercept}; +/// +/// let mut indicator = LinRegIntercept::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinRegIntercept { + period: usize, + window: VecDeque, + sum_x: f64, + denom: f64, + sum_y: f64, + sum_xy: f64, +} + +impl LinRegIntercept { + /// Construct a new rolling linear-regression intercept over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression intercept needs period >= 2", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for LinRegIntercept { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let intercept = (self.sum_y - slope * self.sum_x) / n; + Some(intercept) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "LINEARREG_INTERCEPT" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_short_period() { + assert!(matches!( + LinRegIntercept::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_report_config() { + let lr = LinRegIntercept::new(5).unwrap(); + assert_eq!(lr.period(), 5); + assert_eq!(lr.name(), "LINEARREG_INTERCEPT"); + assert_eq!(lr.warmup_period(), 5); + assert!(!lr.is_ready()); + } + + #[test] + fn reference_value() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, intercept = 0. + let mut lr = LinRegIntercept::new(3).unwrap(); + let out: Vec> = lr.batch(&[1.0, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-9); + assert!(lr.is_ready()); + } + + #[test] + fn slides_and_tracks_a_shifted_line() { + // After sliding to window [2, 9, 4]... intercept stays finite and the + // fit is exact for a clean line [10, 12, 14]: y = 10 + 2x, intercept 10. + let mut lr = LinRegIntercept::new(3).unwrap(); + let out: Vec> = lr.batch(&[1.0, 10.0, 12.0, 14.0]); + assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut lr = LinRegIntercept::new(3).unwrap(); + let _ = lr.batch(&[1.0, 2.0, 9.0]); + assert!(lr.is_ready()); + lr.reset(); + assert!(!lr.is_ready()); + assert_eq!(lr.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_slope.rs b/crates/wickra-core/src/indicators/linreg_slope.rs new file mode 100644 index 0000000..05f821f --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_slope.rs @@ -0,0 +1,273 @@ +//! Linear Regression Slope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Linear Regression Slope — the slope of a rolling least-squares fit. +/// +/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits +/// the line `y = a + b·x` by ordinary least squares and reports the slope: +/// +/// ```text +/// b = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// ``` +/// +/// This is TA-Lib's `LINEARREG_SLOPE`: a momentum-like reading of how steeply +/// price is trending over the window — positive while it rises, negative +/// while it falls, near zero when it is flat — without the band-pass quirks +/// of a difference-based oscillator. +/// +/// Each `update` is O(1): the same incremental OLS state as +/// [`LinearRegression`](crate::LinearRegression) is maintained — `Σx` and +/// `Σxx` are precomputed once from `period`, while `Σy` and `Σxy` are slid +/// forward in closed form on every push. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegSlope}; +/// +/// let mut indicator = LinRegSlope::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LinRegSlope { + period: usize, + window: VecDeque, + /// Closed form of `Σx` over `x = 0, 1, …, period − 1` — constant in `period`. + sum_x: f64, + /// Closed form of `n · Σxx − (Σx)²` — constant in `period`. + denom: f64, + /// Running sum of the values currently in the window. + sum_y: f64, + /// Running `Σ(x · y)` where `x` is the position within the trailing window. + sum_xy: f64, +} + +impl LinRegSlope { + /// Construct a new rolling linear-regression slope over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "linear regression slope needs period >= 2", + }); + } + let n = period as f64; + // Closed forms for x = 0, 1, …, period − 1. + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for LinRegSlope { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + // Sliding-window identity: when the window slides one step forward + // the indices `x` for every kept entry shift down by 1, so + // new_sum_xy = old_sum_xy − old_sum_y + y0 + // (`y0` is the popped front value). + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + Some((n * self.sum_xy - self.sum_x * self.sum_y) / self.denom) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "LinRegSlope" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, so the slope is 4. + let mut ls = LinRegSlope::new(3).unwrap(); + let out = ls.batch(&[1.0, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 4.0, epsilon = 1e-9); + } + + #[test] + fn perfect_line_returns_its_step() { + // A series rising by a fixed step has exactly that slope. + let prices: Vec = (0..40).map(|i| 2.5 * f64::from(i) + 7.0).collect(); + let mut ls = LinRegSlope::new(10).unwrap(); + for v in ls.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 2.5, epsilon = 1e-6); + } + } + + #[test] + fn constant_series_has_zero_slope() { + let mut ls = LinRegSlope::new(8).unwrap(); + for v in ls.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn falling_series_has_negative_slope() { + let prices: Vec = (0..30).map(|i| 100.0 - f64::from(i)).collect(); + let mut ls = LinRegSlope::new(10).unwrap(); + for v in ls.batch(&prices).into_iter().flatten() { + assert!(v < 0.0, "a falling series must have a negative slope"); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut ls = LinRegSlope::new(5).unwrap(); + let out = ls.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first value lands at index period - 1"); + assert_eq!(ls.warmup_period(), 5); + } + + #[test] + fn rejects_period_below_two() { + assert!(LinRegSlope::new(0).is_err()); + assert!(LinRegSlope::new(1).is_err()); + assert!(LinRegSlope::new(2).is_ok()); + } + + /// Cover the const accessor `period` (80-82) and the Indicator-impl + /// `name` body (125-127). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let ls = LinRegSlope::new(14).unwrap(); + assert_eq!(ls.period(), 14); + assert_eq!(ls.name(), "LinRegSlope"); + } + + #[test] + fn reset_clears_state() { + let mut ls = LinRegSlope::new(5).unwrap(); + ls.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(ls.is_ready()); + ls.reset(); + assert!(!ls.is_ready()); + assert_eq!(ls.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = LinRegSlope::new(14).unwrap(); + let mut b = LinRegSlope::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } + + /// Incremental OLS equivalence for the slope: the O(1) implementation must + /// agree bar-by-bar with a fresh-from-scratch O(n) refit, on a noisy ramp + /// (sliding-phase dominated) and a step function (large pop/push deltas). + #[test] + fn incremental_matches_naive_slope_bar_by_bar() { + fn naive_slope(window: &[f64]) -> f64 { + let n = window.len() as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_x = 0.0; + let mut sum_xx = 0.0; + for (i, &y) in window.iter().enumerate() { + let x = i as f64; + sum_y += y; + sum_xy += x * y; + sum_x += x; + sum_xx += x * x; + } + (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x) + } + + fn check(prices: &[f64], period: usize) { + let mut ls = LinRegSlope::new(period).unwrap(); + for (t, p) in prices.iter().enumerate() { + let streaming = ls.update(*p); + if t + 1 >= period { + let lo = t + 1 - period; + let expected = naive_slope(&prices[lo..=t]); + let got = streaming.expect("warmed up"); + assert!( + (got - expected).abs() < 1e-9, + "slope diverges at t={t}, period={period}: got={got}, expected={expected}", + ); + } + } + } + + let noisy_ramp: Vec = (0..120) + .map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0) + .collect(); + check(&noisy_ramp, 5); + check(&noisy_ramp, 14); + + let mut step = vec![1.0; 30]; + step.extend(std::iter::repeat_n(100.0, 30)); + check(&step, 7); + } +} diff --git a/crates/wickra-core/src/indicators/liquidation_features.rs b/crates/wickra-core/src/indicators/liquidation_features.rs new file mode 100644 index 0000000..24d10d0 --- /dev/null +++ b/crates/wickra-core/src/indicators/liquidation_features.rs @@ -0,0 +1,179 @@ +//! Liquidation Features — per-tick long/short liquidation breakdown. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// The liquidation feature vector emitted by [`LiquidationFeatures`] for one +/// tick. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct LiquidationFeaturesOutput { + /// Long-side liquidation notional on this tick. + pub long: f64, + /// Short-side liquidation notional on this tick. + pub short: f64, + /// Net liquidation `long − short` (positive = longs being liquidated). + pub net: f64, + /// Total liquidation `long + short`. + pub total: f64, + /// Liquidation imbalance `(long − short) / (long + short)`, in `[−1, +1]`; + /// `0.0` when there is no liquidation. + pub imbalance: f64, +} + +/// Liquidation Features — decomposes the long- and short-side liquidation +/// notional carried by each tick into a small feature vector. +/// +/// ```text +/// net = longLiquidation − shortLiquidation +/// total = longLiquidation + shortLiquidation +/// imbalance = net / total (0 when total == 0) +/// ``` +/// +/// Liquidation cascades are a perpetual-market-specific tail risk: a wave of +/// long liquidations forces market sells that beget more liquidations. Splitting +/// the flow into net, total and a bounded imbalance turns the raw venue feed +/// into model-ready features — `total` sizes the stress, `imbalance` (and its +/// sign) says which side is being flushed. A positive imbalance means longs are +/// being liquidated (downside cascade), a negative one shorts (upside squeeze). +/// +/// `Input = DerivativesTick`, `Output = LiquidationFeaturesOutput`. Stateless; +/// ready after the first tick. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, LiquidationFeatures}; +/// +/// fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick { +/// DerivativesTick::new( +/// 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0, +/// ) +/// .unwrap() +/// } +/// +/// let mut liq = LiquidationFeatures::new(); +/// // 30 long vs 10 short liquidated: net 20, total 40, imbalance 0.5. +/// let out = liq.update(tick(30.0, 10.0)).unwrap(); +/// assert_eq!(out.net, 20.0); +/// assert_eq!(out.total, 40.0); +/// assert_eq!(out.imbalance, 0.5); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct LiquidationFeatures { + has_emitted: bool, +} + +impl LiquidationFeatures { + /// Construct a new liquidation-features indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for LiquidationFeatures { + type Input = DerivativesTick; + type Output = LiquidationFeaturesOutput; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + let long = tick.long_liquidation; + let short = tick.short_liquidation; + let net = long - short; + let total = long + short; + let imbalance = if total == 0.0 { 0.0 } else { net / total }; + Some(LiquidationFeaturesOutput { + long, + short, + net, + total, + imbalance, + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "LiquidationFeatures" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let liq = LiquidationFeatures::new(); + assert_eq!(liq.name(), "LiquidationFeatures"); + assert_eq!(liq.warmup_period(), 1); + assert!(!liq.is_ready()); + } + + #[test] + fn decomposes_liquidations() { + let mut liq = LiquidationFeatures::new(); + let out = liq.update(tick(30.0, 10.0)).unwrap(); + assert_eq!(out.long, 30.0); + assert_eq!(out.short, 10.0); + assert_eq!(out.net, 20.0); + assert_eq!(out.total, 40.0); + assert_eq!(out.imbalance, 0.5); + assert!(liq.is_ready()); + } + + #[test] + fn short_cascade_is_negative_imbalance() { + let mut liq = LiquidationFeatures::new(); + let out = liq.update(tick(0.0, 50.0)).unwrap(); + assert_eq!(out.net, -50.0); + assert_eq!(out.imbalance, -1.0); + } + + #[test] + fn no_liquidation_is_zero_imbalance() { + let mut liq = LiquidationFeatures::new(); + let out = liq.update(tick(0.0, 0.0)).unwrap(); + assert_eq!(out.total, 0.0); + assert_eq!(out.imbalance, 0.0); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(f64::from(i % 5) * 10.0, f64::from(i % 3) * 10.0)) + .collect(); + let mut a = LiquidationFeatures::new(); + let mut b = LiquidationFeatures::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut liq = LiquidationFeatures::new(); + liq.update(tick(30.0, 10.0)); + assert!(liq.is_ready()); + liq.reset(); + assert!(!liq.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/log_return.rs b/crates/wickra-core/src/indicators/log_return.rs new file mode 100644 index 0000000..9758b6d --- /dev/null +++ b/crates/wickra-core/src/indicators/log_return.rs @@ -0,0 +1,218 @@ +//! Logarithmic Return over a fixed lag. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Logarithmic return over a `period`-bar lag: `ln(price_t / price_{t−period})`. +/// +/// The natural-log analogue of [`Roc`](crate::Roc) (which reports the simple +/// percentage change). Log returns are the canonical input for volatility and +/// statistical models because they are additive across time — the log return +/// over `k` bars equals the sum of the `k` one-bar log returns — and symmetric +/// around zero (a `+x` move and the reverse `−x` move cancel exactly). +/// +/// ```text +/// r_t = ln(price_t / price_{t−period}) +/// ``` +/// +/// Non-finite and non-positive prices are ignored: the input is dropped, state +/// is left untouched, and the last computed value is returned instead. The log +/// of a non-positive price is undefined, so such ticks must not enter the +/// window — mirroring [`HistoricalVolatility`](crate::HistoricalVolatility). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LogReturn}; +/// +/// let mut indicator = LogReturn::new(1).unwrap(); +/// indicator.update(100.0); +/// // ln(110 / 100) ≈ 0.09531 +/// let r = indicator.update(110.0).unwrap(); +/// assert!((r - (110.0_f64 / 100.0).ln()).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone)] +pub struct LogReturn { + period: usize, + window: VecDeque, + last: Option, +} + +impl LogReturn { + /// Construct a new log-return indicator with the given lag. + /// + /// # 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 + 1), + last: None, + }) + } + + /// Configured lag. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for LogReturn { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite or non-positive prices are ignored: `ln` of a non-positive + // price is undefined, so the tick must not enter the window. Return the + // last value and leave state untouched (SMA / EMA / HV convention). + if !input.is_finite() || input <= 0.0 { + return self.last; + } + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period + 1 { + return None; + } + // `prev` was pushed through the same guard, so it is finite and > 0 and + // `(input / prev).ln()` is always well-defined. + let prev = *self.window.front().expect("non-empty"); + let r = (input / prev).ln(); + self.last = Some(r); + Some(r) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "LogReturn" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(LogReturn::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let lr = LogReturn::new(5).unwrap(); + assert_eq!(lr.period(), 5); + assert_eq!(lr.warmup_period(), 6); + assert_eq!(lr.name(), "LogReturn"); + assert!(!lr.is_ready()); + } + + #[test] + fn known_value() { + // LogReturn(1): ln(110 / 100). + let mut lr = LogReturn::new(1).unwrap(); + let out = lr.batch(&[100.0, 110.0]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), (110.0_f64 / 100.0).ln(), epsilon = 1e-12); + } + + #[test] + fn multi_bar_lag() { + // LogReturn(3): at index 3, ln(price_3 / price_0). + let mut lr = LogReturn::new(3).unwrap(); + let out = lr.batch(&[100.0, 105.0, 108.0, 121.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert_relative_eq!(out[3].unwrap(), (121.0_f64 / 100.0).ln(), epsilon = 1e-12); + } + + #[test] + fn additive_across_time() { + // The 2-bar log return equals the sum of the two 1-bar log returns. + let prices = [50.0, 55.0, 60.5]; + let mut lag2 = LogReturn::new(2).unwrap(); + let two_bar = lag2.batch(&prices)[2].unwrap(); + let mut lag1 = LogReturn::new(1).unwrap(); + let ones = lag1.batch(&prices); + let sum = ones[1].unwrap() + ones[2].unwrap(); + assert_relative_eq!(two_bar, sum, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut lr = LogReturn::new(4).unwrap(); + for v in lr.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut lr = LogReturn::new(1).unwrap(); + let out = lr.batch(&[100.0, 110.0]); + let ready = out[1].expect("ready after two inputs"); + assert_eq!(lr.update(f64::NAN), Some(ready)); + assert_eq!(lr.update(f64::INFINITY), Some(ready)); + // Window untouched: the next finite price still references prev = 110. + assert_relative_eq!( + lr.update(121.0).unwrap(), + (121.0_f64 / 110.0).ln(), + epsilon = 1e-12 + ); + } + + #[test] + fn skips_non_positive_prices() { + let mut lr = LogReturn::new(1).unwrap(); + let out = lr.batch(&[100.0, 110.0]); + let baseline = out[1].expect("ready"); + // A non-positive tick is ignored and the previous valid price is kept. + assert_eq!(lr.update(-5.0), Some(baseline)); + assert_eq!(lr.update(0.0), Some(baseline)); + let mut control = lr.clone(); + let after = lr.update(121.0).expect("ready"); + assert_eq!(control.update(121.0).expect("ready"), after); + assert_relative_eq!(after, (121.0_f64 / 110.0).ln(), epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut lr = LogReturn::new(3).unwrap(); + lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lr.is_ready()); + lr.reset(); + assert!(!lr.is_ready()); + assert_eq!(lr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = LogReturn::new(5).unwrap().batch(&prices); + let mut b = LogReturn::new(5).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/long_legged_doji.rs b/crates/wickra-core/src/indicators/long_legged_doji.rs new file mode 100644 index 0000000..21ca48d --- /dev/null +++ b/crates/wickra-core/src/indicators/long_legged_doji.rs @@ -0,0 +1,157 @@ +//! Long-Legged Doji candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Long-Legged Doji — a single-bar indecision signal. A doji with long shadows on +/// *both* sides: price ranged widely up and down yet closed essentially where it +/// opened, a tug-of-war that often precedes a turn. +/// +/// ```text +/// range = high − low +/// doji = |close − open| <= 0.1 * range +/// long upper = high − max(open, close) >= 0.3 * range +/// long lower = min(open, close) − low >= 0.3 * range +/// ``` +/// +/// Output is `+1.0` when the long-legged doji prints and `0.0` otherwise. This is +/// a non-directional indecision flag — it never emits `−1.0` (use +/// `DragonflyDoji` / `GravestoneDoji` for the directional single-shadow variants). +/// Body and shadow thresholds follow the geometric house style (fixed fractions +/// of the bar range) rather than TA-Lib's rolling averages. Pattern-shape check +/// only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` detected, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, LongLeggedDoji, Indicator}; +/// +/// let mut indicator = LongLeggedDoji::new(); +/// // Tiny body, long shadows on both sides. +/// let candle = Candle::new(10.0, 12.0, 8.0, 10.05, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct LongLeggedDoji { + has_emitted: bool, +} + +impl LongLeggedDoji { + /// Construct a new Long-Legged Doji detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for LongLeggedDoji { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + if (candle.close - candle.open).abs() > 0.1 * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if upper >= 0.3 * range && lower >= 0.3 * range { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "LongLeggedDoji" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = LongLeggedDoji::new(); + assert_eq!(t.name(), "LongLeggedDoji"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn long_legged_is_plus_one() { + let mut t = LongLeggedDoji::new(); + assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.05, 0)), Some(1.0)); + } + + #[test] + fn one_sided_shadow_yields_zero() { + let mut t = LongLeggedDoji::new(); + // Dragonfly shape: long lower shadow but no upper -> not long-legged. + assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn non_doji_yields_zero() { + let mut t = LongLeggedDoji::new(); + assert_eq!(t.update(c(10.0, 12.0, 8.0, 11.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = LongLeggedDoji::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 3.0, base - 3.0, base + 0.05, i) + }) + .collect(); + let mut a = LongLeggedDoji::new(); + let mut b = LongLeggedDoji::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = LongLeggedDoji::new(); + t.update(c(10.0, 12.0, 8.0, 10.05, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/long_line.rs b/crates/wickra-core/src/indicators/long_line.rs new file mode 100644 index 0000000..f3e98fe --- /dev/null +++ b/crates/wickra-core/src/indicators/long_line.rs @@ -0,0 +1,234 @@ +//! Long Line candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; +use std::collections::VecDeque; + +/// Long Line — a single candle whose range is *longer* than the recent average and +/// whose body dominates that range (a solid directional bar). Because "long" only +/// has meaning relative to recent activity, the detector compares each candle's +/// range against a rolling average of the previous `period` ranges. +/// +/// ```text +/// avg = mean range of the previous `period` candles +/// long line = range > avg AND |close − open| >= 0.5 * range +/// white -> +1.0, black -> −1.0 +/// ``` +/// +/// Output is `+1.0` (long white line), `−1.0` (long black line), or `0.0` +/// otherwise. The first `period` candles return `0.0` while the rolling average +/// fills. `period` defaults to `5` and must be at least `1`. This rolling baseline +/// is the one place the family departs from a purely intra-candle rule, since a +/// short/long classification is inherently scale-relative. Pattern-shape check +/// only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, LongLine}; +/// +/// let mut indicator = LongLine::new(); +/// // Five quiet bars fill the rolling average. +/// for ts in 0..5 { +/// indicator.update(Candle::new(10.0, 10.5, 9.5, 10.2, 1.0, ts).unwrap()); +/// } +/// // A wide solid white bar is a long white line. +/// let out = indicator +/// .update(Candle::new(10.0, 13.0, 9.9, 12.9, 1.0, 5).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct LongLine { + period: usize, + ranges: VecDeque, +} + +impl Default for LongLine { + fn default() -> Self { + Self::new() + } +} + +impl LongLine { + /// Construct a Long Line detector with the default 5-candle rolling average. + pub const fn new() -> Self { + Self { + period: 5, + ranges: VecDeque::new(), + } + } + + /// Construct a Long Line detector with a custom averaging period. + /// + /// `period` must be at least `1`. + pub fn with_period(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + ranges: VecDeque::new(), + }) + } + + /// Configured averaging period. + pub fn period(&self) -> usize { + self.period + } +} + +impl Indicator for LongLine { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let body = candle.close - candle.open; + if self.ranges.len() < self.period { + self.ranges.push_back(range); + return Some(0.0); + } + let avg = self.ranges.iter().sum::() / self.period as f64; + self.ranges.push_back(range); + self.ranges.pop_front(); + if range > avg && body.abs() >= 0.5 * range { + return Some(if body > 0.0 { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.ranges.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.ranges.len() >= self.period + } + + fn name(&self) -> &'static str { + "LongLine" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + fn warm(t: &mut LongLine) { + for ts in 0..5 { + assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, ts)), Some(0.0)); + } + } + + #[test] + fn rejects_zero_period() { + assert!(LongLine::with_period(0).is_err()); + } + + #[test] + fn accepts_valid_period() { + let t = LongLine::with_period(10).unwrap(); + assert_eq!(t.period(), 10); + } + + #[test] + fn accessors_and_metadata() { + let t = LongLine::new(); + assert_eq!(t.name(), "LongLine"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + assert_eq!(t.period(), 5); + } + + #[test] + fn long_white_line_is_plus_one() { + let mut t = LongLine::new(); + warm(&mut t); + assert!(t.is_ready()); + assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 5)), Some(1.0)); + } + + #[test] + fn long_black_line_is_minus_one() { + let mut t = LongLine::new(); + warm(&mut t); + assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 5)), Some(-1.0)); + } + + #[test] + fn short_range_yields_zero() { + let mut t = LongLine::new(); + warm(&mut t); + // Range no bigger than the average -> not a long line. + assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, 5)), Some(0.0)); + } + + #[test] + fn wide_range_small_body_yields_zero() { + let mut t = LongLine::new(); + warm(&mut t); + // Wide range but a tiny body -> a spinning top, not a long line. + assert_eq!(t.update(c(10.5, 13.0, 9.9, 10.6, 5)), Some(0.0)); + } + + #[test] + fn warmup_returns_zero() { + let mut t = LongLine::new(); + for ts in 0..5 { + assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, ts)), Some(0.0)); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 7 == 0 { + c(base, base + 4.0, base - 0.1, base + 3.9, i) + } else { + c(base, base + 0.5, base - 0.5, base + 0.2, i) + } + }) + .collect(); + let mut a = LongLine::new(); + let mut b = LongLine::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = LongLine::new(); + warm(&mut t); + t.update(c(10.0, 13.0, 9.9, 12.9, 5)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 0)), Some(0.0)); + } + + #[test] + fn default_matches_new() { + assert_eq!(LongLine::default().period(), LongLine::new().period()); + } +} diff --git a/crates/wickra-core/src/indicators/long_short_ratio.rs b/crates/wickra-core/src/indicators/long_short_ratio.rs new file mode 100644 index 0000000..0136785 --- /dev/null +++ b/crates/wickra-core/src/indicators/long_short_ratio.rs @@ -0,0 +1,138 @@ +//! Long/Short Ratio — aggregate long size relative to short size. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Long/Short Ratio — the aggregate long size divided by the aggregate short +/// size carried by each tick. +/// +/// ```text +/// longShortRatio = longSize / shortSize +/// ``` +/// +/// Exchanges publish the long/short account (or position) ratio as a crowd +/// positioning gauge: a ratio above `1` means longs outweigh shorts, below `1` +/// the reverse. Extremes are a contrarian signal — an overwhelmingly long crowd +/// is fuel for a long squeeze. When the short side is zero the ratio is +/// undefined and the indicator reports `0.0`. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, LongShortRatio}; +/// +/// fn tick(long: f64, short: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, long, short, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut lsr = LongShortRatio::new(); +/// // 600 longs vs 400 shorts -> 1.5. +/// assert_eq!(lsr.update(tick(600.0, 400.0)), Some(1.5)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct LongShortRatio { + has_emitted: bool, +} + +impl LongShortRatio { + /// Construct a new long/short ratio indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for LongShortRatio { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + if tick.short_size == 0.0 { + // No short side to divide by: the ratio is undefined. + return Some(0.0); + } + Some(tick.long_size / tick.short_size) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "LongShortRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(long: f64, short: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, 0.0, long, short, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let lsr = LongShortRatio::new(); + assert_eq!(lsr.name(), "LongShortRatio"); + assert_eq!(lsr.warmup_period(), 1); + assert!(!lsr.is_ready()); + } + + #[test] + fn divides_long_by_short() { + let mut lsr = LongShortRatio::new(); + assert_eq!(lsr.update(tick(600.0, 400.0)), Some(1.5)); + assert_eq!(lsr.update(tick(400.0, 800.0)), Some(0.5)); + assert!(lsr.is_ready()); + } + + #[test] + fn zero_short_is_zero() { + let mut lsr = LongShortRatio::new(); + assert_eq!(lsr.update(tick(600.0, 0.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| { + tick( + 500.0 + f64::from(i % 5) * 10.0, + 400.0 + f64::from(i % 3) * 10.0, + ) + }) + .collect(); + let mut a = LongShortRatio::new(); + let mut b = LongShortRatio::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut lsr = LongShortRatio::new(); + lsr.update(tick(600.0, 400.0)); + assert!(lsr.is_ready()); + lsr.reset(); + assert!(!lsr.is_ready()); + } +} 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 0000000..e9ac1b5 --- /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/ma_envelope.rs b/crates/wickra-core/src/indicators/ma_envelope.rs new file mode 100644 index 0000000..01cf5b3 --- /dev/null +++ b/crates/wickra-core/src/indicators/ma_envelope.rs @@ -0,0 +1,216 @@ +//! Moving Average Envelope. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// Moving Average Envelope output: SMA middle line wrapped by a fixed-percent +/// envelope on either side. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MaEnvelopeOutput { + /// Upper envelope: `middle · (1 + percent)`. + pub upper: f64, + /// Middle band: SMA over the window. + pub middle: f64, + /// Lower envelope: `middle · (1 − percent)`. + pub lower: f64, +} + +/// Moving Average Envelope: an SMA centerline with constant-percent bands on +/// each side. +/// +/// ```text +/// middle = SMA(period) +/// upper = middle · (1 + percent) +/// lower = middle · (1 − percent) +/// ``` +/// +/// The envelope is a fixed multiplicative offset around the moving average, +/// so the band width scales with price rather than with realised volatility +/// (contrast Bollinger Bands, whose width is `2·k·σ`, or Keltner Channels, +/// whose width is `2·k·ATR`). It is the oldest band-style overlay still in +/// regular use; chart vendors typically default to `period = 20`, +/// `percent = 0.025` (2.5 %). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MaEnvelope}; +/// +/// let mut indicator = MaEnvelope::new(20, 0.025).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MaEnvelope { + sma: Sma, + percent: f64, +} + +impl MaEnvelope { + /// Construct a new Moving Average Envelope. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `percent` is not strictly positive + /// and finite. + pub fn new(period: usize, percent: f64) -> Result { + if !percent.is_finite() || percent <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + sma: Sma::new(period)?, + percent, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.sma.period() + } + + /// Configured envelope percent (e.g. `0.025` for ±2.5 %). + pub const fn percent(&self) -> f64 { + self.percent + } +} + +impl Indicator for MaEnvelope { + type Input = f64; + type Output = MaEnvelopeOutput; + + fn update(&mut self, input: f64) -> Option { + let middle = self.sma.update(input)?; + Some(MaEnvelopeOutput { + upper: middle * (1.0 + self.percent), + middle, + lower: middle * (1.0 - self.percent), + }) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma.warmup_period() + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "MaEnvelope" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(MaEnvelope::new(0, 0.025), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_non_positive_percent() { + assert!(matches!( + MaEnvelope::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + MaEnvelope::new(20, -0.1), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + MaEnvelope::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let env = MaEnvelope::new(20, 0.025).unwrap(); + assert_eq!(env.period(), 20); + assert_relative_eq!(env.percent(), 0.025, epsilon = 1e-12); + assert_eq!(env.warmup_period(), 20); + assert_eq!(env.name(), "MaEnvelope"); + assert!(!env.is_ready()); + } + + #[test] + fn constant_series_yields_flat_envelope() { + let mut env = MaEnvelope::new(5, 0.01).unwrap(); + let last = env + .batch(&[100.0_f64; 20]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last.middle, 100.0, epsilon = 1e-12); + assert_relative_eq!(last.upper, 101.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 99.0, epsilon = 1e-12); + } + + #[test] + fn warmup_returns_none() { + let mut env = MaEnvelope::new(5, 0.05).unwrap(); + for v in [1.0, 2.0, 3.0, 4.0] { + assert!(env.update(v).is_none()); + } + assert!(env.update(5.0).is_some()); + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut env = MaEnvelope::new(20, 0.025).unwrap(); + for o in env.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=50).map(|i| f64::from(i) * 0.7 + 100.0).collect(); + let mut a = MaEnvelope::new(10, 0.03).unwrap(); + let mut b = MaEnvelope::new(10, 0.03).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut env = MaEnvelope::new(5, 0.02).unwrap(); + env.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(env.is_ready()); + env.reset(); + assert!(!env.is_ready()); + assert_eq!(env.update(1.0), None); + } + + /// Reference value: SMA over [10, 20, 30] is 20; with percent = 0.10 the + /// upper band is 22 and the lower band is 18. + #[test] + fn reference_values() { + let mut env = MaEnvelope::new(3, 0.10).unwrap(); + let out = env.batch(&[10.0, 20.0, 30.0]); + assert!(out[0].is_none() && out[1].is_none()); + let v = out[2].unwrap(); + assert_relative_eq!(v.middle, 20.0, epsilon = 1e-12); + assert_relative_eq!(v.upper, 22.0, epsilon = 1e-12); + assert_relative_eq!(v.lower, 18.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/macd.rs b/crates/wickra-core/src/indicators/macd.rs new file mode 100644 index 0000000..a586247 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd.rs @@ -0,0 +1,453 @@ +//! Moving Average Convergence Divergence (MACD). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// MACD output: the three classic series at a given step. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacdOutput { + /// Fast EMA − slow EMA. + pub macd: f64, + /// EMA of `macd` over the signal period. + pub signal: f64, + /// `macd − signal`. + pub histogram: f64, +} + +/// MACD = EMA(fast) − EMA(slow), with a signal EMA on top. +/// +/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`. The signal EMA +/// is seeded from the first `signal` raw MACD values, so the first full +/// [`MacdOutput`] is emitted after `slow + signal − 1` inputs (assuming the +/// slow EMA seeded by then). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MacdIndicator}; +/// +/// let mut indicator = MacdIndicator::new(3, 6, 3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MacdIndicator { + fast: Ema, + slow: Ema, + signal_ema: Ema, + fast_period: usize, + slow_period: usize, + signal_period: usize, + last: Option, +} + +impl MacdIndicator { + /// Construct a MACD with the given periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is zero, and + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + if fast == 0 || slow == 0 || signal == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "fast period must be strictly less than slow period", + }); + } + Ok(Self { + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + signal_ema: Ema::new(signal)?, + fast_period: fast, + slow_period: slow, + signal_period: signal, + last: None, + }) + } + + /// Default `(12, 26, 9)` configuration, matching every classical chart package. + pub fn classic() -> Self { + Self::new(12, 26, 9).expect("classic MACD periods are valid") + } + + /// Configured periods as `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.signal_period) + } + + /// Most recent fully-computed output if available. + pub const fn value(&self) -> Option { + self.last + } + + /// Vectorized flat batch for bindings: `n * 3` values laid out as + /// `[macd, signal, histogram]` per input row, warmup rows all `NaN`. + /// + /// For a fresh, all-finite slice long enough for a full output it runs the + /// fast EMA, slow EMA and signal EMA as three recurrences fused into a single + /// pass with one allocation — no `Option` per tick, no per-EMA intermediate + /// buffers, identical SMA-mean seeds (division) and `mul_add` recurrences. The + /// result is *bit-for-bit* equal to replaying `update`. Anything else (not + /// fresh, non-finite, or too short to emit) defers to the exact `update` + /// replay. + /// + /// Separate from the trait [`batch`](crate::BatchExt::batch), which stays a + /// bit-identical `update` replay; only the bindings call this. + pub fn batch_macd(&mut self, inputs: &[f64]) -> Vec { + let n = inputs.len(); + let (fp, sp, gp) = (self.fast_period, self.slow_period, self.signal_period); + // First full output needs the slow EMA seeded (index sp-1) plus gp signal + // values: index sp + gp - 2. Below that, or non-fresh/non-finite, replay. + if self.last.is_some() + || !self.fast.is_fresh() + || !self.slow.is_fresh() + || !self.signal_ema.is_fresh() + || n < sp + gp - 1 + || !inputs.iter().all(|x| x.is_finite()) + { + let mut out = vec![f64::NAN; n * 3]; + for (i, &x) in inputs.iter().enumerate() { + if let Some(o) = self.update(x) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + return out; + } + + // Pre-sized output: warmup rows stay NaN, full-output rows are written in + // place by index — no per-row `push` length/capacity check. + let mut out = vec![f64::NAN; n * 3]; + let (fa, fo) = (self.fast.alpha(), 1.0 - self.fast.alpha()); + let (sa, so) = (self.slow.alpha(), 1.0 - self.slow.alpha()); + let (ga, go) = (self.signal_ema.alpha(), 1.0 - self.signal_ema.alpha()); + let (fp_f, sp_f, gp_f) = (fp as f64, sp as f64, gp as f64); + + let (mut fast_val, mut slow_val, mut sig) = (0.0_f64, 0.0_f64, 0.0_f64); + let (mut fsum, mut ssum, mut gsum) = (0.0_f64, 0.0_f64, 0.0_f64); + let mut sig_count = 0usize; // signal-EMA seed progress (raw MACD values seen) + let mut sig_seeded = false; + let mut last = MacdOutput { + macd: 0.0, + signal: 0.0, + histogram: 0.0, + }; + + for (i, &x) in inputs.iter().enumerate() { + // Fast EMA: SMA-seeded at index fp-1, then recurrence. + if i < fp { + fsum += x; + if i == fp - 1 { + fast_val = fsum / fp_f; + } + } else { + fast_val = fa.mul_add(x, fo * fast_val); + } + // Slow EMA: SMA-seeded at index sp-1, then recurrence. + if i < sp { + ssum += x; + if i == sp - 1 { + slow_val = ssum / sp_f; + } + } else { + slow_val = sa.mul_add(x, so * slow_val); + } + if i + 1 < sp { + continue; // slow EMA not seeded yet → no raw MACD line + } + let macd = fast_val - slow_val; + // Signal EMA over the MACD line: SMA-seeded over its first gp values. + let signal = if sig_seeded { + sig = ga.mul_add(macd, go * sig); + sig + } else { + gsum += macd; + sig_count += 1; + if sig_count < gp { + continue; // signal EMA still seeding → no full output + } + sig = gsum / gp_f; + sig_seeded = true; + sig + }; + let histogram = macd - signal; + out[i * 3] = macd; + out[i * 3 + 1] = signal; + out[i * 3 + 2] = histogram; + last = MacdOutput { + macd, + signal, + histogram, + }; + } + + // Leave every sub-EMA and `last` where a full `update` replay would. + self.fast.seed_to(fast_val); + self.slow.seed_to(slow_val); + self.signal_ema.seed_to(sig); + self.last = Some(last); + out + } +} + +impl Indicator for MacdIndicator { + type Input = f64; + type Output = MacdOutput; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + + let fast = self.fast.update(input); + let slow = self.slow.update(input); + + match (fast, slow) { + (Some(f), Some(s)) => { + let macd = f - s; + let signal = self.signal_ema.update(macd)?; + let out = MacdOutput { + macd, + signal, + histogram: macd - signal, + }; + self.last = Some(out); + Some(out) + } + _ => None, + } + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal_ema.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // Slow EMA needs `slow` inputs to seed; signal EMA needs another `signal - 1`. + self.slow_period + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "MACD" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + MacdIndicator::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + MacdIndicator::new(12, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + /// Cover the const accessors `periods` / `value` (81-88) and the + /// Indicator-impl `name` body (135-137). `warmup_period` is exercised + /// elsewhere. + #[test] + fn accessors_and_metadata() { + let mut m = MacdIndicator::new(12, 26, 9).unwrap(); + assert_eq!(m.periods(), (12, 26, 9)); + assert_eq!(m.name(), "MACD"); + assert!(m.value().is_none()); + for i in 1..=m.warmup_period() { + m.update(100.0 + f64::from(u32::try_from(i).unwrap())); + } + assert!(m.value().is_some()); + } + + #[test] + fn rejects_zero_periods() { + assert!(matches!( + MacdIndicator::new(0, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + MacdIndicator::new(12, 0, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + MacdIndicator::new(12, 26, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn first_emission_matches_warmup_period() { + let prices: Vec = (1..=60).map(f64::from).collect(); + let mut macd = MacdIndicator::classic(); + let out = macd.batch(&prices); + let warmup = macd.warmup_period(); + // Indices 0..warmup-1 are None, index warmup-1 might be Some or might still need + // the signal EMA's seeding. Our warmup_period is the index at which the first + // signal value appears: slow + signal - 1. + for x in out.iter().take(warmup - 1) { + assert!(x.is_none(), "expected None within warmup"); + } + assert!( + out[warmup - 1].is_some(), + "expected first emission at warmup_period - 1 ({warmup} idx)" + ); + } + + #[test] + fn histogram_equals_macd_minus_signal() { + let prices: Vec = (1..=80).map(|i| f64::from(i) * 0.5).collect(); + let mut macd = MacdIndicator::classic(); + for v in macd.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12); + } + } + + #[test] + fn constant_series_yields_zero_macd_eventually() { + let mut macd = MacdIndicator::classic(); + let out = macd.batch(&[100.0_f64; 200]); + // Both EMAs converge to 100, so MACD must approach 0. + let last = out.iter().rev().flatten().next().expect("emits a value"); + assert_relative_eq!(last.macd, 0.0, epsilon = 1e-9); + assert_relative_eq!(last.signal, 0.0, epsilon = 1e-9); + assert_relative_eq!(last.histogram, 0.0, epsilon = 1e-9); + } + + #[test] + fn rising_series_macd_positive_then_signal_catches_up() { + let prices: Vec = (1..=200).map(f64::from).collect(); + let mut macd = MacdIndicator::classic(); + let out = macd.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!(last.macd > 0.0, "rising series must yield positive MACD"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100) + .map(|i| (f64::from(i) * 0.4).cos() * 10.0) + .collect(); + let mut a = MacdIndicator::classic(); + let mut b = MacdIndicator::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut macd = MacdIndicator::classic(); + macd.batch(&(1..=80).map(f64::from).collect::>()); + assert!(macd.is_ready()); + macd.reset(); + assert!(!macd.is_ready()); + assert_eq!(macd.update(1.0), None); + } + + fn bits_eq(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x == y || (x.is_nan() && y.is_nan())) + } + + /// Flat `n*3` `[macd, signal, histogram]` replay of `update`. + fn macd_replay(series: &[f64]) -> Vec { + let mut m = MacdIndicator::classic(); + let mut out = Vec::with_capacity(series.len() * 3); + for &x in series { + match m.update(x) { + Some(o) => out.extend_from_slice(&[o.macd, o.signal, o.histogram]), + None => out.extend_from_slice(&[f64::NAN; 3]), + } + } + out + } + + #[test] + fn batch_macd_fast_path_is_bit_identical() { + let series: Vec = (0..300) + .map(|i| (f64::from(i) * 0.4).cos() * 10.0 + 100.0) + .collect(); + let mut macd = MacdIndicator::classic(); + let got = macd.batch_macd(&series); + assert!(bits_eq(&got, &macd_replay(&series))); + // Sub-EMA + last state left where the replay would: continued update agrees. + let mut ref_macd = MacdIndicator::classic(); + for &x in &series { + ref_macd.update(x); + } + let (a, b) = (macd.update(101.0), ref_macd.update(101.0)); + assert_eq!(a.is_some(), b.is_some()); + assert_relative_eq!(a.unwrap().macd, b.unwrap().macd, epsilon = 1e-12); + } + + #[test] + fn batch_macd_falls_back_on_non_finite() { + let mut series: Vec = (0..60).map(|i| f64::from(i) + 100.0).collect(); + series[40] = f64::NAN; + let mut macd = MacdIndicator::classic(); + assert!(bits_eq(&macd.batch_macd(&series), &macd_replay(&series))); + } + + #[test] + fn batch_macd_falls_back_when_not_fresh() { + let series: Vec = (0..60).map(|i| f64::from(i) + 100.0).collect(); + let mut macd = MacdIndicator::classic(); + macd.update(50.0); + let mut ref_macd = MacdIndicator::classic(); + ref_macd.update(50.0); + let mut want = Vec::new(); + for &x in &series { + match ref_macd.update(x) { + Some(o) => want.extend_from_slice(&[o.macd, o.signal, o.histogram]), + None => want.extend_from_slice(&[f64::NAN; 3]), + } + } + assert!(bits_eq(&macd.batch_macd(&series), &want)); + } + + #[test] + fn batch_macd_too_short_for_output_falls_back() { + // n < slow + signal - 1 (= 34): no full output, routed to the replay. + let series: Vec = (0..20).map(|i| f64::from(i) + 100.0).collect(); + let mut macd = MacdIndicator::classic(); + let got = macd.batch_macd(&series); + assert!(bits_eq(&got, &macd_replay(&series))); + assert!(got.iter().all(|x| x.is_nan())); + } + + #[test] + fn ignores_non_finite_input() { + let mut macd = MacdIndicator::classic(); + macd.batch(&(1..=80).map(f64::from).collect::>()); + let before = macd.value(); + assert!(before.is_some()); + // Non-finite inputs return the last value without advancing any EMA. + assert_eq!(macd.update(f64::NAN), before); + assert_eq!(macd.update(f64::INFINITY), before); + assert_eq!(macd.value(), before); + } +} diff --git a/crates/wickra-core/src/indicators/macd_ext.rs b/crates/wickra-core/src/indicators/macd_ext.rs new file mode 100644 index 0000000..96ffa47 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd_ext.rs @@ -0,0 +1,286 @@ +//! MACD with selectable moving-average types (MACDEXT). + +use crate::error::{Error, Result}; +use crate::indicators::dema::Dema; +use crate::indicators::ema::Ema; +use crate::indicators::macd::MacdOutput; +use crate::indicators::sma::Sma; +use crate::indicators::tema::Tema; +use crate::indicators::trima::Trima; +use crate::indicators::wma::Wma; +use crate::traits::Indicator; + +/// Moving-average type selector for [`MacdExt`] and other multi-MA indicators. +/// +/// The variants map to TA-Lib's `MA_Type` codes `0..=5` — the period-only +/// moving averages. (TA-Lib's KAMA / MAMA / T3 take additional shape parameters +/// and are not selectable here.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaType { + /// Simple moving average (TA-Lib code `0`). + Sma, + /// Exponential moving average (TA-Lib code `1`). + Ema, + /// Weighted moving average (TA-Lib code `2`). + Wma, + /// Double exponential moving average (TA-Lib code `3`). + Dema, + /// Triple exponential moving average (TA-Lib code `4`). + Tema, + /// Triangular moving average (TA-Lib code `5`). + Trima, +} + +impl MaType { + /// Map a TA-Lib `MA_Type` integer code (`0..=5`) to a [`MaType`]. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] for codes outside `0..=5` (the period-only + /// moving averages); codes `6..=8` (KAMA / MAMA / T3) are not supported. + pub fn from_code(code: u32) -> Result { + match code { + 0 => Ok(Self::Sma), + 1 => Ok(Self::Ema), + 2 => Ok(Self::Wma), + 3 => Ok(Self::Dema), + 4 => Ok(Self::Tema), + 5 => Ok(Self::Trima), + _ => Err(Error::InvalidPeriod { + message: "unsupported moving-average type code (expected 0..=5)", + }), + } + } +} + +/// A concrete period-only moving average instance, dispatched by [`MaType`]. +#[derive(Debug, Clone)] +enum Ma { + Sma(Sma), + Ema(Ema), + Wma(Wma), + Dema(Dema), + Tema(Tema), + Trima(Trima), +} + +impl Ma { + fn new(kind: MaType, period: usize) -> Result { + Ok(match kind { + MaType::Sma => Self::Sma(Sma::new(period)?), + MaType::Ema => Self::Ema(Ema::new(period)?), + MaType::Wma => Self::Wma(Wma::new(period)?), + MaType::Dema => Self::Dema(Dema::new(period)?), + MaType::Tema => Self::Tema(Tema::new(period)?), + MaType::Trima => Self::Trima(Trima::new(period)?), + }) + } + + fn update(&mut self, value: f64) -> Option { + match self { + Self::Sma(m) => m.update(value), + Self::Ema(m) => m.update(value), + Self::Wma(m) => m.update(value), + Self::Dema(m) => m.update(value), + Self::Tema(m) => m.update(value), + Self::Trima(m) => m.update(value), + } + } + + fn reset(&mut self) { + match self { + Self::Sma(m) => m.reset(), + Self::Ema(m) => m.reset(), + Self::Wma(m) => m.reset(), + Self::Dema(m) => m.reset(), + Self::Tema(m) => m.reset(), + Self::Trima(m) => m.reset(), + } + } + + fn warmup_period(&self) -> usize { + match self { + Self::Sma(m) => m.warmup_period(), + Self::Ema(m) => m.warmup_period(), + Self::Wma(m) => m.warmup_period(), + Self::Dema(m) => m.warmup_period(), + Self::Tema(m) => m.warmup_period(), + Self::Trima(m) => m.warmup_period(), + } + } +} + +/// MACD Extended (`MACDEXT`): MACD with an independently selectable +/// [`MaType`] for each of the fast, slow and signal lines. +/// +/// Classic [`MacdIndicator`](crate::MacdIndicator) hard-wires the exponential +/// moving average everywhere; `MACDEXT` lets each line use any period-only +/// moving average. The MACD line is `fast_ma(price) − slow_ma(price)`, the signal +/// line is `signal_ma(macd)`, and the histogram is `macd − signal`. The first +/// full [`MacdOutput`] is emitted once the slow and signal averages are both warm. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MacdExt, MaType}; +/// +/// let mut indicator = +/// MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MacdExt { + fast: Ma, + slow: Ma, + signal: Ma, + has_emitted: bool, +} + +impl MacdExt { + /// Construct a MACDEXT with per-line periods and moving-average types. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if any period is zero and + /// [`Error::InvalidPeriod`] if `fast >= slow`, propagating any moving-average + /// construction error. + pub fn new( + fast: usize, + fast_type: MaType, + slow: usize, + slow_type: MaType, + signal: usize, + signal_type: MaType, + ) -> Result { + if fast == 0 || slow == 0 || signal == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "fast period must be < slow period", + }); + } + Ok(Self { + fast: Ma::new(fast_type, fast)?, + slow: Ma::new(slow_type, slow)?, + signal: Ma::new(signal_type, signal)?, + has_emitted: false, + }) + } +} + +impl Indicator for MacdExt { + type Input = f64; + type Output = MacdOutput; + + fn update(&mut self, value: f64) -> Option { + let fast_v = self.fast.update(value); + let slow_v = self.slow.update(value); + let (Some(fast_v), Some(slow_v)) = (fast_v, slow_v) else { + return None; + }; + let macd = fast_v - slow_v; + let signal = self.signal.update(macd)?; + self.has_emitted = true; + Some(MacdOutput { + macd, + signal, + histogram: macd - signal, + }) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + self.slow.warmup_period() + self.signal.warmup_period() + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MACDEXT" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + const TYPES: [MaType; 6] = [ + MaType::Sma, + MaType::Ema, + MaType::Wma, + MaType::Dema, + MaType::Tema, + MaType::Trima, + ]; + + #[test] + fn from_code_maps_all_supported_types() { + assert_eq!(MaType::from_code(0).unwrap(), MaType::Sma); + assert_eq!(MaType::from_code(1).unwrap(), MaType::Ema); + assert_eq!(MaType::from_code(2).unwrap(), MaType::Wma); + assert_eq!(MaType::from_code(3).unwrap(), MaType::Dema); + assert_eq!(MaType::from_code(4).unwrap(), MaType::Tema); + assert_eq!(MaType::from_code(5).unwrap(), MaType::Trima); + assert!(MaType::from_code(6).is_err()); + } + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + MacdExt::new(0, MaType::Ema, 26, MaType::Ema, 9, MaType::Ema), + Err(Error::PeriodZero) + )); + assert!(matches!( + MacdExt::new(26, MaType::Ema, 12, MaType::Ema, 9, MaType::Ema), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let m = MacdExt::new(12, MaType::Ema, 26, MaType::Sma, 9, MaType::Sma).unwrap(); + assert_eq!(m.name(), "MACDEXT"); + assert!(!m.is_ready()); + assert!(m.warmup_period() >= 26); + } + + #[test] + fn every_ma_type_produces_a_consistent_histogram() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0) + .collect(); + for &t in &TYPES { + let mut m = MacdExt::new(5, t, 10, t, 4, t).unwrap(); + let out: Vec> = m.batch(&prices); + assert!(out.iter().any(Option::is_some), "{t:?} never emitted"); + for o in out.into_iter().flatten() { + assert!((o.histogram - (o.macd - o.signal)).abs() < 1e-9); + } + // Exercise the warmup accessor for this variant's inner averages. + assert!(m.warmup_period() >= 10); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + } + } + + #[test] + fn mixed_ma_types_per_line() { + let prices: Vec = (0..120).map(|i| 100.0 + f64::from(i)).collect(); + let mut m = MacdExt::new(12, MaType::Wma, 26, MaType::Dema, 9, MaType::Trima).unwrap(); + let last = m.batch(&prices).into_iter().flatten().last(); + assert!(last.is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/macd_fix.rs b/crates/wickra-core/src/indicators/macd_fix.rs new file mode 100644 index 0000000..3fe75b6 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd_fix.rs @@ -0,0 +1,117 @@ +//! MACD with fixed 12/26 periods (MACDFIX). + +use crate::error::Result; +use crate::indicators::macd::{MacdIndicator, MacdOutput}; +use crate::traits::Indicator; + +/// MACD Fix (`MACDFIX`): the classic MACD with the fast and slow EMAs fixed at +/// 12 and 26, leaving only the signal period configurable. +/// +/// This is TA-Lib's `MACDFIX` — identical output to +/// [`MacdIndicator::new(12, 26, signal)`](crate::MacdIndicator), packaged as a +/// single-parameter constructor for the common case. The output is the usual +/// [`MacdOutput`] triple `{ macd, signal, histogram }`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MacdFix}; +/// +/// let mut indicator = MacdFix::new(9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MacdFix { + inner: MacdIndicator, +} + +impl MacdFix { + /// Construct a MACDFIX with fast = 12, slow = 26 and the given signal period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `signal == 0`. + pub fn new(signal: usize) -> Result { + Ok(Self { + inner: MacdIndicator::new(12, 26, signal)?, + }) + } + + /// Configured signal period. + pub fn signal_period(&self) -> usize { + self.inner.periods().2 + } +} + +impl Indicator for MacdFix { + type Input = f64; + type Output = MacdOutput; + + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + + fn reset(&mut self) { + self.inner.reset(); + } + + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + + fn name(&self) -> &'static str { + "MACDFIX" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_signal() { + assert!(MacdFix::new(0).is_err()); + } + + #[test] + fn accessors_report_config() { + let m = MacdFix::new(9).unwrap(); + assert_eq!(m.signal_period(), 9); + assert_eq!(m.name(), "MACDFIX"); + assert!(!m.is_ready()); + assert_eq!( + m.warmup_period(), + MacdIndicator::new(12, 26, 9).unwrap().warmup_period() + ); + } + + #[test] + fn matches_macd_with_fixed_periods() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let fix: Vec> = MacdFix::new(9).unwrap().batch(&prices); + let classic: Vec> = + MacdIndicator::new(12, 26, 9).unwrap().batch(&prices); + assert_eq!(fix, classic); + assert!(fix.iter().any(Option::is_some)); + } + + #[test] + fn reset_clears_state() { + let prices: Vec = (0..80).map(|i| 100.0 + f64::from(i)).collect(); + let mut m = MacdFix::new(9).unwrap(); + let _ = m.batch(&prices); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/macd_histogram.rs b/crates/wickra-core/src/indicators/macd_histogram.rs new file mode 100644 index 0000000..dc26fe9 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd_histogram.rs @@ -0,0 +1,184 @@ +//! MACD Histogram (standalone). + +use crate::error::Result; +use crate::indicators::macd::MacdIndicator; +use crate::traits::Indicator; + +/// MACD Histogram — the `macd − signal` bar of [`MacdIndicator`] as a +/// standalone scalar indicator. +/// +/// ```text +/// macd = EMA(fast) − EMA(slow) +/// signal = EMA(macd, signal) +/// histogram = macd − signal +/// ``` +/// +/// The histogram is the most actively traded part of MACD: it crosses zero +/// exactly when the MACD line crosses its signal, and its slope measures +/// whether that momentum is accelerating or fading. This wrapper exposes just +/// that series for pipelines that want a plain `f64` stream rather than the +/// full [`MacdOutput`](crate::MacdOutput); for the line and signal alongside +/// it, use [`MacdIndicator`](crate::MacdIndicator) directly. +/// +/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`, so the +/// first value lands after `slow + signal − 1` inputs — exactly when +/// [`MacdIndicator`] emits its first full output. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MacdHistogram}; +/// +/// let mut indicator = MacdHistogram::new(12, 26, 9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MacdHistogram { + macd: MacdIndicator, +} + +impl MacdHistogram { + /// Construct a MACD histogram with the given periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is zero, and + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + Ok(Self { + macd: MacdIndicator::new(fast, slow, signal)?, + }) + } + + /// Default `(12, 26, 9)` configuration, matching every classical chart package. + pub fn classic() -> Self { + Self::new(12, 26, 9).expect("classic MACD periods are valid") + } + + /// Configured periods as `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + self.macd.periods() + } +} + +impl Indicator for MacdHistogram { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + self.macd.update(input).map(|out| out.histogram) + } + + fn reset(&mut self) { + self.macd.reset(); + } + + fn warmup_period(&self) -> usize { + self.macd.warmup_period() + } + + fn is_ready(&self) -> bool { + self.macd.is_ready() + } + + fn name(&self) -> &'static str { + "MacdHistogram" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + MacdHistogram::new(0, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + MacdHistogram::new(12, 26, 0), + Err(Error::PeriodZero) + )); + assert!(matches!( + MacdHistogram::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let osc = MacdHistogram::classic(); + assert_eq!(osc.periods(), (12, 26, 9)); + assert_eq!(osc.name(), "MacdHistogram"); + assert_eq!(osc.warmup_period(), 26 + 9 - 1); + assert!(!osc.is_ready()); + } + + #[test] + fn equals_macd_histogram_field() { + // The standalone series must be exactly MacdIndicator's histogram bar. + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0) + .collect(); + let hist = MacdHistogram::classic().batch(&prices); + let full = MacdIndicator::classic().batch(&prices); + assert_eq!(hist.len(), full.len()); + for (h, m) in hist.iter().zip(full.iter()) { + assert_eq!(h.is_some(), m.is_some()); + if let (Some(h), Some(m)) = (h, m) { + assert_relative_eq!(*h, m.histogram, epsilon = 1e-12); + } + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut osc = MacdHistogram::new(3, 6, 3).unwrap(); + let warmup = osc.warmup_period(); + assert_eq!(warmup, 6 + 3 - 1); + for i in 1..warmup { + assert!(osc.update(100.0 + i as f64).is_none()); + } + assert!(osc.update(100.0 + warmup as f64).is_some()); + assert!(osc.is_ready()); + } + + #[test] + fn constant_series_converges_to_zero() { + let mut osc = MacdHistogram::classic(); + let out = osc.batch(&[100.0_f64; 200]); + let last = out.iter().rev().flatten().next().expect("emits a value"); + assert_relative_eq!(*last, 0.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100) + .map(|i| (f64::from(i) * 0.4).cos() * 10.0) + .collect(); + let mut a = MacdHistogram::classic(); + let mut b = MacdHistogram::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut osc = MacdHistogram::classic(); + osc.batch(&(1..=80).map(f64::from).collect::>()); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + assert_eq!(osc.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/mama.rs b/crates/wickra-core/src/indicators/mama.rs new file mode 100644 index 0000000..61046e2 --- /dev/null +++ b/crates/wickra-core/src/indicators/mama.rs @@ -0,0 +1,381 @@ +//! Ehlers MESA Adaptive Moving Average (MAMA) and its follower (FAMA). +#![allow( + clippy::doc_markdown, + clippy::doc_lazy_continuation, + clippy::struct_field_names, + clippy::manual_clamp +)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// MAMA + FAMA output pair. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MamaOutput { + /// MESA Adaptive Moving Average. + pub mama: f64, + /// Following Adaptive Moving Average (slower companion). + pub fama: f64, +} + +/// Ehlers' MESA Adaptive Moving Average (MAMA). +/// +/// MAMA adapts its smoothing constant from the rate-of-change of price phase, +/// derived via a truncated Hilbert transform — full math in "Cycle Analytics +/// for Traders" (Ehlers 2013, ch. 8) and the original 2001 MESA paper. +/// +/// The two-parameter `(fast_limit, slow_limit)` is the range over which the +/// adaptive alpha can vary; defaults `(0.5, 0.05)` match the canonical +/// EasyLanguage implementation. The companion FAMA is `mama * 0.5 * fast_limit +/// + fama_prev * (1 - 0.5 * fast_limit)`, lagging MAMA so crossovers signal +/// trend reversals. +/// +/// The indicator emits both lines as a [`MamaOutput`]. Use the [`crate::Fama`] wrapper +/// in this module to expose just the slow line if needed (e.g. for chaining). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Mama}; +/// +/// let mut mama = Mama::new(0.5, 0.05).unwrap(); +/// let mut last = None; +/// for i in 0..100 { +/// last = mama.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Mama { + fast_limit: f64, + slow_limit: f64, + smooth_buf: Vec, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_phase: f64, + prev_mama: f64, + prev_fama: f64, + count: usize, + last_value: Option, +} + +impl Mama { + /// Construct with custom `(fast_limit, slow_limit)` adaptive alpha bounds. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if either limit is outside `(0, 1]` + /// or if `slow_limit > fast_limit`. + pub fn new(fast_limit: f64, slow_limit: f64) -> Result { + if !fast_limit.is_finite() + || !slow_limit.is_finite() + || fast_limit <= 0.0 + || fast_limit > 1.0 + || slow_limit <= 0.0 + || slow_limit > 1.0 + || slow_limit > fast_limit + { + return Err(Error::InvalidPeriod { + message: "fast_limit, slow_limit must satisfy 0 < slow_limit <= fast_limit <= 1", + }); + } + Ok(Self { + fast_limit, + slow_limit, + smooth_buf: Vec::with_capacity(7), + detrender_buf: Vec::with_capacity(7), + q1_buf: Vec::with_capacity(7), + i1_buf: Vec::with_capacity(7), + prev_i2: 0.0, + prev_q2: 0.0, + prev_re: 0.0, + prev_im: 0.0, + prev_period: 0.0, + prev_phase: 0.0, + prev_mama: 0.0, + prev_fama: 0.0, + count: 0, + last_value: None, + }) + } + + /// Default `(0.5, 0.05)` parameters from Ehlers' original publication. + pub fn classic() -> Self { + Self::new(0.5, 0.05).expect("classic MAMA limits are valid") + } + + /// Configured `(fast_limit, slow_limit)`. + pub const fn limits(&self) -> (f64, f64) { + (self.fast_limit, self.slow_limit) + } + + /// Current `(mama, fama)` pair if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for Mama { + type Input = f64; + type Output = MamaOutput; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + self.count += 1; + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 4 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + + let period = self.prev_period.max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + + if self.smooth_buf.len() < 7 { + // Seed the EMA outputs with the smoothed price so early bars are + // well-behaved without producing a public value. + self.prev_mama = smooth; + self.prev_fama = smooth; + return None; + } + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + Self::push_front(&mut self.q1_buf, q1, 7); + Self::push_front(&mut self.i1_buf, i1, 7); + if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 { + return None; + } + + let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2] + - 0.5769 * self.i1_buf[4] + - 0.0962 * self.i1_buf[6]) + * adj; + let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2] + - 0.5769 * self.q1_buf[4] + - 0.0962 * self.q1_buf[6]) + * adj; + + let mut i2 = i1 - jq; + let mut q2 = q1 + ji; + i2 = 0.2 * i2 + 0.8 * self.prev_i2; + q2 = 0.2 * q2 + 0.8 * self.prev_q2; + + let mut re = i2 * self.prev_i2 + q2 * self.prev_q2; + let mut im = i2 * self.prev_q2 - q2 * self.prev_i2; + re = 0.2 * re + 0.8 * self.prev_re; + im = 0.2 * im + 0.8 * self.prev_im; + + self.prev_i2 = i2; + self.prev_q2 = q2; + self.prev_re = re; + self.prev_im = im; + + let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON { + 2.0 * PI / im.atan2(re) + } else { + self.prev_period + }; + new_period = new_period.min(1.5 * self.prev_period); + new_period = new_period.max(0.67 * self.prev_period); + new_period = new_period.clamp(6.0, 50.0); + self.prev_period = 0.2 * new_period + 0.8 * self.prev_period; + + // Adaptive alpha derived from phase rate-of-change. + let phase = if i1.abs() > f64::EPSILON { + (q1 / i1).atan().to_degrees() + } else { + self.prev_phase + }; + let mut delta_phase = self.prev_phase - phase; + self.prev_phase = phase; + if delta_phase < 1.0 { + delta_phase = 1.0; + } + // `delta_phase` is clamped to >= 1.0 above, so `fast_limit / delta_phase` + // never exceeds `fast_limit`; only the lower bound can bind. + let mut alpha = self.fast_limit / delta_phase; + if alpha < self.slow_limit { + alpha = self.slow_limit; + } + + self.prev_mama = alpha * input + (1.0 - alpha) * self.prev_mama; + let fama_alpha = 0.5 * alpha; + self.prev_fama = fama_alpha * self.prev_mama + (1.0 - fama_alpha) * self.prev_fama; + + if self.count < 33 { + return None; + } + let out = MamaOutput { + mama: self.prev_mama, + fama: self.prev_fama, + }; + self.last_value = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.q1_buf.clear(); + self.i1_buf.clear(); + self.prev_i2 = 0.0; + self.prev_q2 = 0.0; + self.prev_re = 0.0; + self.prev_im = 0.0; + self.prev_period = 0.0; + self.prev_phase = 0.0; + self.prev_mama = 0.0; + self.prev_fama = 0.0; + self.count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 33 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "MAMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_invalid_limits() { + assert!(matches!( + Mama::new(0.0, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(0.5, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(0.05, 0.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(1.5, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Mama::new(f64::NAN, 0.05), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut mama = Mama::classic(); + assert_eq!(mama.limits(), (0.5, 0.05)); + assert_eq!(mama.warmup_period(), 33); + assert_eq!(mama.name(), "MAMA"); + assert!(!mama.is_ready()); + for i in 0..60 { + mama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(mama.is_ready()); + assert!(mama.value().is_some()); + } + + #[test] + fn fama_lags_or_equals_mama_on_constant_series() { + let mut mama = Mama::classic(); + let out = mama.batch(&[100.0_f64; 200]); + let last = out.iter().flatten().last().unwrap(); + // On a flat series both lines converge to the price. + assert!((last.mama - 100.0).abs() < 1.0); + assert!((last.fama - 100.0).abs() < 1.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0) + .collect(); + let mut a = Mama::classic(); + let mut b = Mama::classic(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut mama = Mama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + mama.batch(&prices); + let before = mama.value(); + assert!(before.is_some()); + assert_eq!(mama.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut mama = Mama::classic(); + let prices: Vec = (0..100) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + mama.batch(&prices); + assert!(mama.is_ready()); + mama.reset(); + assert!(!mama.is_ready()); + } + + #[test] + fn flat_input_uses_phase_fallback() { + // Zero inputs make every smooth/detrender term arithmetically exact + // zero, so `i1 == 0.0` and the phase calculation takes the + // `self.prev_phase` fallback rather than `atan(q1/i1)`. A non-zero + // constant like `50.0` leaves a sub-EPSILON cancellation residue + // that flips the branch back to the `atan` path on real hardware. + let mut mama = Mama::classic(); + let out = mama.batch(&[0.0_f64; 200]); + assert!(out.iter().flatten().count() > 100); + } +} diff --git a/crates/wickra-core/src/indicators/market_facilitation_index.rs b/crates/wickra-core/src/indicators/market_facilitation_index.rs new file mode 100644 index 0000000..0f0aad0 --- /dev/null +++ b/crates/wickra-core/src/indicators/market_facilitation_index.rs @@ -0,0 +1,185 @@ +//! Market Facilitation Index (Bill Williams). + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Bill Williams' Market Facilitation Index — how much price movement the +/// market produces per unit of volume. +/// +/// ```text +/// MFI_BW_t = (high_t − low_t) / volume_t +/// ``` +/// +/// A rising MFI on rising volume ("green") signals strong participation behind +/// the move; a rising MFI on falling volume ("fake") suggests a low-volume push +/// that may not hold. Williams pairs MFI with a "Squat" or "Fade" classification +/// against the prior bar's MFI/volume — a downstream concern; this struct only +/// emits the per-bar ratio. A bar with zero volume returns `None` (no +/// facilitation can be defined). Output is emitted from the very first bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MarketFacilitationIndex}; +/// +/// let mut indicator = MarketFacilitationIndex::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MarketFacilitationIndex { + has_emitted: bool, + last_value: f64, +} + +impl MarketFacilitationIndex { + /// Construct a new Market Facilitation Index. + pub const fn new() -> Self { + Self { + has_emitted: false, + last_value: 0.0, + } + } + + /// Most recent value if at least one bar with non-zero volume has been + /// observed. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.last_value) + } else { + None + } + } +} + +impl Indicator for MarketFacilitationIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if candle.volume == 0.0 { + // No trade activity -> facilitation is undefined. + return None; + } + let v = (candle.high - candle.low) / candle.volume; + self.last_value = v; + self.has_emitted = true; + Some(v) + } + + fn reset(&mut self) { + self.has_emitted = false; + self.last_value = 0.0; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MarketFacilitationIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let m = MarketFacilitationIndex::new(); + assert_eq!(m.name(), "MarketFacilitationIndex"); + assert_eq!(m.warmup_period(), 1); + assert_eq!(m.value(), None); + } + + #[test] + fn reference_value() { + // (12 − 8) / 200 = 0.02. + let mut m = MarketFacilitationIndex::new(); + let v = m.update(c(10.0, 12.0, 8.0, 11.0, 200.0, 0)).unwrap(); + assert_relative_eq!(v, 0.02, epsilon = 1e-12); + assert_relative_eq!(m.value().unwrap(), 0.02, epsilon = 1e-12); + } + + #[test] + fn constant_series_is_constant() { + // Same OHLCV every bar -> same ratio every bar. + let candles: Vec = (0..30) + .map(|i| c(10.0, 11.0, 9.0, 10.0, 100.0, i)) + .collect(); + let mut m = MarketFacilitationIndex::new(); + for v in m.batch(&candles).into_iter().flatten() { + // 2/100 = 0.02. + assert_relative_eq!(v, 0.02, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_returns_none() { + let mut m = MarketFacilitationIndex::new(); + assert_eq!(m.update(c(10.0, 11.0, 9.0, 10.0, 0.0, 0)), None); + assert!(!m.is_ready()); + // Subsequent non-zero-volume bar still works. + let v = m.update(c(10.0, 12.0, 8.0, 10.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 0.04, epsilon = 1e-12); + } + + #[test] + fn zero_range_bar_yields_zero() { + // high == low -> ratio = 0. + let mut m = MarketFacilitationIndex::new(); + let v = m.update(c(10.0, 10.0, 10.0, 10.0, 100.0, 0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.3).sin() * 5.0; + c( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 50.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = MarketFacilitationIndex::new(); + let mut b = MarketFacilitationIndex::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut m = MarketFacilitationIndex::new(); + m.update(c(10.0, 12.0, 8.0, 11.0, 100.0, 0)); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + } +} 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 0000000..eabc8e6 --- /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/marubozu.rs b/crates/wickra-core/src/indicators/marubozu.rs new file mode 100644 index 0000000..debdda0 --- /dev/null +++ b/crates/wickra-core/src/indicators/marubozu.rs @@ -0,0 +1,214 @@ +//! Marubozu candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Marubozu — a single-bar strong-continuation candle with body equal to range +/// and (almost) no shadows. +/// +/// ```text +/// range = high − low +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// shadows OK = upper_shadow <= tol * range && lower_shadow <= tol * range +/// ``` +/// +/// When the shadow tolerance is satisfied the output is `+1.0` for a bullish +/// Marubozu (close > open) and `−1.0` for a bearish one (close < open). Any +/// candle whose shadows exceed the tolerance — or whose body is zero — yields +/// `0.0`. +/// +/// `shadow_tolerance` defaults to `0.05` (5 % of the bar range allowed on each +/// side) and must lie in `[0, 1)`. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Marubozu}; +/// +/// let mut indicator = Marubozu::new(); +/// // Bullish marubozu: open == low, close == high. +/// let candle = Candle::new(10.0, 12.0, 10.0, 12.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Marubozu { + shadow_tolerance: f64, + has_emitted: bool, +} + +impl Default for Marubozu { + fn default() -> Self { + Self::new() + } +} + +impl Marubozu { + /// Construct a Marubozu detector with the default 5 % shadow tolerance. + pub const fn new() -> Self { + Self { + shadow_tolerance: 0.05, + has_emitted: false, + } + } + + /// Construct a Marubozu detector with a custom shadow tolerance. + /// + /// `shadow_tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(shadow_tolerance: f64) -> Result { + if !(0.0..1.0).contains(&shadow_tolerance) { + return Err(Error::InvalidPeriod { + message: "marubozu shadow tolerance must lie in [0, 1)", + }); + } + Ok(Self { + shadow_tolerance, + has_emitted: false, + }) + } + + /// Configured shadow tolerance. + pub fn shadow_tolerance(&self) -> f64 { + self.shadow_tolerance + } +} + +impl Indicator for Marubozu { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = candle.close - candle.open; + if body == 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + let tol = self.shadow_tolerance * range; + if upper <= tol && lower <= tol { + Some(if body > 0.0 { 1.0 } else { -1.0 }) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Marubozu" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(Marubozu::with_tolerance(-0.01).is_err()); + assert!(Marubozu::with_tolerance(1.0).is_err()); + assert!(Marubozu::with_tolerance(2.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let m = Marubozu::with_tolerance(0.0).unwrap(); + assert!((m.shadow_tolerance() - 0.0).abs() < 1e-12); + let m = Marubozu::with_tolerance(0.5).unwrap(); + assert!((m.shadow_tolerance() - 0.5).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let m = Marubozu::default(); + assert_eq!(m.name(), "Marubozu"); + assert_eq!(m.warmup_period(), 1); + assert!(!m.is_ready()); + assert!((m.shadow_tolerance() - 0.05).abs() < 1e-12); + } + + #[test] + fn bullish_marubozu_is_plus_one() { + let mut m = Marubozu::new(); + assert_eq!(m.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(1.0)); + } + + #[test] + fn bearish_marubozu_is_minus_one() { + let mut m = Marubozu::new(); + assert_eq!(m.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn candle_with_long_shadows_is_zero() { + let mut m = Marubozu::new(); + // Big upper shadow violates tolerance. + assert_eq!(m.update(c(10.0, 15.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_zero() { + let mut m = Marubozu::new(); + // body == 0 -> not a marubozu. + assert_eq!(m.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut m = Marubozu::new(); + assert_eq!(m.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base, base + 2.0, i) + }) + .collect(); + let mut a = Marubozu::new(); + let mut b = Marubozu::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut m = Marubozu::new(); + m.update(c(10.0, 12.0, 10.0, 12.0, 0)); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mass_index.rs b/crates/wickra-core/src/indicators/mass_index.rs new file mode 100644 index 0000000..a6a2960 --- /dev/null +++ b/crates/wickra-core/src/indicators/mass_index.rs @@ -0,0 +1,234 @@ +//! Mass Index. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +use super::Ema; + +/// Mass Index — Donald Dorsey's range-expansion indicator. +/// +/// The Mass Index watches the high–low range, not direction. It smooths the +/// range with an EMA, smooths that again, takes the ratio of the two, and sums +/// the ratio over a window: +/// +/// ```text +/// range_t = high_t − low_t +/// ratio_t = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period) +/// MassIndex = Σ ratio over sum_period +/// ``` +/// +/// When the range widens, the single EMA pulls ahead of the double EMA, the +/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the +/// Mass Index rising above `27` and then falling back below `26.5` — a sign +/// that a range expansion is about to resolve into a trend reversal. With the +/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at +/// `25`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MassIndex}; +/// +/// let mut indicator = MassIndex::new(9, 25).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + i as f64; +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MassIndex { + ema_period: usize, + sum_period: usize, + ema1: Ema, + ema2: Ema, + /// Rolling window of the last `sum_period` EMA ratios. + window: VecDeque, + sum: f64, + last: Option, +} + +impl MassIndex { + /// Construct a new Mass Index with the EMA smoothing period and the sum + /// window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`. + pub fn new(ema_period: usize, sum_period: usize) -> Result { + if ema_period == 0 || sum_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + ema_period, + sum_period, + ema1: Ema::new(ema_period)?, + ema2: Ema::new(ema_period)?, + window: VecDeque::with_capacity(sum_period), + sum: 0.0, + last: None, + }) + } + + /// The `(ema_period, sum_period)` pair. + pub const fn periods(&self) -> (usize, usize) { + (self.ema_period, self.sum_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for MassIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let single = self.ema1.update(range)?; + let double = self.ema2.update(single)?; + let ratio = if double == 0.0 { + // A zero-range market: no expansion, neutral ratio. + 1.0 + } else { + single / double + }; + if self.window.len() == self.sum_period { + self.sum -= self.window.pop_front().expect("window is non-empty"); + } + self.window.push_back(ratio); + self.sum += ratio; + if self.window.len() < self.sum_period { + return None; + } + self.last = Some(self.sum); + Some(self.sum) + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum + // window needs `sum_period` ratios. + 2 * self.ema_period + self.sum_period - 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "MassIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// A candle with a fixed high–low range `span` centred on `mid`. + fn candle(mid: f64, span: f64, ts: i64) -> Candle { + Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero))); + assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `periods` / `value` (80-87) and the + /// Indicator-impl `name` body (134-136). `warmup_period` is already + /// covered by `warmup_period_formula`. + #[test] + fn accessors_and_metadata() { + let mut mi = MassIndex::new(9, 25).unwrap(); + assert_eq!(mi.periods(), (9, 25)); + assert_eq!(mi.name(), "MassIndex"); + assert_eq!(mi.value(), None); + for i in 0..mi.warmup_period() { + mi.update(candle(100.0, 2.0, i64::try_from(i).unwrap())); + } + assert!(mi.value().is_some()); + } + + #[test] + fn warmup_period_formula() { + let mi = MassIndex::new(9, 25).unwrap(); + assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut mi = MassIndex::new(3, 4).unwrap(); + let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8 + assert_eq!(warmup, 8); + let candles: Vec = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect(); + let out = mi.batch(&candles); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn constant_range_sums_to_sum_period() { + // A constant high–low range makes both EMAs converge to the same + // value, so every ratio is 1 and the Mass Index equals `sum_period`. + let mut mi = MassIndex::new(3, 4).unwrap(); + let candles: Vec = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect(); + for v in mi.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 4.0, epsilon = 1e-9); + } + } + + #[test] + fn zero_range_market_sums_to_sum_period() { + let mut mi = MassIndex::new(3, 4).unwrap(); + let candles: Vec = (0..40).map(|i| candle(100.0, 0.0, i)).collect(); + for v in mi.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 4.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut mi = MassIndex::new(3, 4).unwrap(); + let candles: Vec = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect(); + mi.batch(&candles); + assert!(mi.is_ready()); + mi.reset(); + assert!(!mi.is_ready()); + assert_eq!(mi.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0; + candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i) + }) + .collect(); + let batch = MassIndex::new(9, 25).unwrap().batch(&candles); + let mut b = MassIndex::new(9, 25).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mat_hold.rs b/crates/wickra-core/src/indicators/mat_hold.rs new file mode 100644 index 0000000..b9f0c09 --- /dev/null +++ b/crates/wickra-core/src/indicators/mat_hold.rs @@ -0,0 +1,308 @@ +//! Mat Hold candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Mat Hold — a 5-bar bullish continuation. A long white candle is followed by a +/// brief three-bar pullback that gaps up and then drifts on small bodies *without* +/// surrendering much ground, after which a white candle breaks to a new high and +/// the uptrend resumes. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1 white & long +/// bar2 small body gapping up above bar1 (min(o2,c2) > close1) +/// bar2, bar3, bar4 each small (|body| <= 0.5 · body1) +/// the pullback holds (min low of bars 2..4 > close1 − penetration·body1) +/// bar5 white, closing at a new high (close5 > max high of bars 1..4) +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Mat Hold is a +/// single-direction (bullish-only) continuation, so it never emits `−1.0`. The +/// first four bars always return `0.0` because the five-bar window is not yet +/// filled. `penetration` is how far the pullback may retrace into the first body; +/// it defaults to `0.5` (TA-Lib's `CDLMATHOLD` default) and must lie in `[0, 1)`. +/// Body thresholds follow the geometric house style rather than TA-Lib's rolling +/// averages. Pattern-shape check only — no trend filter is applied; combine with a +/// trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MatHold}; +/// +/// let mut indicator = MatHold::new(); +/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(16.0, 16.1, 15.4, 15.5, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(15.5, 15.6, 14.9, 15.0, 1.0, 2).unwrap()); +/// indicator.update(Candle::new(15.0, 15.1, 14.4, 14.5, 1.0, 3).unwrap()); +/// let out = indicator +/// .update(Candle::new(14.5, 17.1, 14.4, 17.0, 1.0, 4).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct MatHold { + penetration: f64, + c1: Option, + c2: Option, + c3: Option, + c4: Option, + has_emitted: bool, +} + +impl Default for MatHold { + fn default() -> Self { + Self::new() + } +} + +impl MatHold { + /// Construct a Mat Hold detector with the default 0.5 penetration. + pub const fn new() -> Self { + Self { + penetration: 0.5, + c1: None, + c2: None, + c3: None, + c4: None, + has_emitted: false, + } + } + + /// Construct a Mat Hold detector with a custom penetration fraction. + /// + /// `penetration` must lie in `[0, 1)`. + pub fn with_penetration(penetration: f64) -> Result { + if !(0.0..1.0).contains(&penetration) { + return Err(Error::InvalidPeriod { + message: "mat hold penetration must lie in [0, 1)", + }); + } + Ok(Self { + penetration, + c1: None, + c2: None, + c3: None, + c4: None, + has_emitted: false, + }) + } + + /// Configured penetration fraction. + pub fn penetration(&self) -> f64 { + self.penetration + } +} + +impl Indicator for MatHold { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + let bar4 = self.c4; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = self.c4; + self.c4 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + if range1 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.close - bar1.open; + if body1 < 0.5 * range1 { + return Some(0.0); // bar1 must be a long white body + } + let small = 0.5 * body1; + if (bar2.close - bar2.open).abs() > small + || (bar3.close - bar3.open).abs() > small + || (bar4.close - bar4.open).abs() > small + { + return Some(0.0); // the three pullback bars must be small + } + // bar2 gaps up above bar1's body. + if bar2.open.min(bar2.close) <= bar1.close { + return Some(0.0); + } + // The pullback must hold above the penetration line. + let hold_line = bar1.close - self.penetration * body1; + if bar2.low.min(bar3.low).min(bar4.low) <= hold_line { + return Some(0.0); + } + // bar5 breaks to a new high on a white body. + let max_high = bar1.high.max(bar2.high).max(bar3.high).max(bar4.high); + if candle.close > candle.open && candle.close > max_high { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.c4 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MatHold" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_penetration() { + assert!(MatHold::with_penetration(-0.01).is_err()); + assert!(MatHold::with_penetration(1.0).is_err()); + } + + #[test] + fn accepts_valid_penetration() { + let t = MatHold::with_penetration(0.3).unwrap(); + assert!((t.penetration() - 0.3).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = MatHold::default(); + assert_eq!(t.name(), "MatHold"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + assert!((t.penetration() - 0.5).abs() < 1e-12); + } + + #[test] + fn mat_hold_is_plus_one() { + let mut t = MatHold::new(); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 15.4, 15.5, 1)), Some(0.0)); + assert_eq!(t.update(c(15.5, 15.6, 14.9, 15.0, 2)), Some(0.0)); + assert_eq!(t.update(c(15.0, 15.1, 14.4, 14.5, 3)), Some(0.0)); + assert_eq!(t.update(c(14.5, 17.1, 14.4, 17.0, 4)), Some(1.0)); + } + + #[test] + fn pullback_breaks_hold_yields_zero() { + let mut t = MatHold::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(16.0, 16.1, 15.4, 15.5, 1)); + t.update(c(15.5, 15.6, 14.9, 15.0, 2)); + // bar4 dips below the hold line (close1 - 0.5*body1 = 12.5). + t.update(c(13.0, 13.1, 12.0, 12.4, 3)); + assert_eq!(t.update(c(14.5, 17.1, 12.0, 17.0, 4)), Some(0.0)); + } + + #[test] + fn no_new_high_yields_zero() { + let mut t = MatHold::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(16.0, 16.1, 15.4, 15.5, 1)); + t.update(c(15.5, 15.6, 14.9, 15.0, 2)); + t.update(c(15.0, 15.1, 14.4, 14.5, 3)); + // bar5 white but closes below the prior max high (16.1). + assert_eq!(t.update(c(14.5, 16.0, 14.4, 15.9, 4)), Some(0.0)); + } + + #[test] + fn first_four_bars_return_zero() { + let mut t = MatHold::new(); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(16.0, 16.1, 15.4, 15.5, 1)), Some(0.0)); + assert_eq!(t.update(c(15.5, 15.6, 14.9, 15.0, 2)), Some(0.0)); + assert_eq!(t.update(c(15.0, 15.1, 14.4, 14.5, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 5.2, base - 0.1, base + 5.0, i) + }) + .collect(); + let mut a = MatHold::new(); + let mut b = MatHold::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = MatHold::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(16.0, 16.1, 15.4, 15.5, 1)); + t.update(c(15.5, 15.6, 14.9, 15.0, 2)); + t.update(c(15.0, 15.1, 14.4, 14.5, 3)); + t.update(c(14.5, 17.1, 14.4, 17.0, 4)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_first_bar_yields_zero() { + let mut t = MatHold::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + t.update(c(16.0, 16.1, 15.4, 15.5, 1)); + t.update(c(15.5, 15.6, 14.9, 15.0, 2)); + t.update(c(15.0, 15.1, 14.4, 14.5, 3)); + assert_eq!(t.update(c(14.5, 17.1, 14.4, 17.0, 4)), Some(0.0)); + } + + #[test] + fn short_first_body_yields_zero() { + let mut t = MatHold::new(); + // bar1 has a wide range but a tiny body -> not a long white body. + t.update(c(10.0, 16.0, 9.0, 10.5, 0)); + t.update(c(16.0, 16.1, 15.4, 15.5, 1)); + t.update(c(15.5, 15.6, 14.9, 15.0, 2)); + t.update(c(15.0, 15.1, 14.4, 14.5, 3)); + assert_eq!(t.update(c(14.5, 17.1, 14.4, 17.0, 4)), Some(0.0)); + } + + #[test] + fn no_gap_up_yields_zero() { + let mut t = MatHold::new(); + // Long white bar1 with small pullbacks, but bar2 fails to gap up above + // bar1's close. + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(14.5, 14.7, 14.3, 14.5, 1)); + t.update(c(14.5, 14.7, 14.3, 14.6, 2)); + t.update(c(14.6, 14.8, 14.4, 14.7, 3)); + assert_eq!(t.update(c(14.7, 17.1, 14.6, 17.0, 4)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/matching_low.rs b/crates/wickra-core/src/indicators/matching_low.rs new file mode 100644 index 0000000..4cc46cf --- /dev/null +++ b/crates/wickra-core/src/indicators/matching_low.rs @@ -0,0 +1,166 @@ +//! Matching Low candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Matching Low — a 2-bar bullish reversal. Two black candles in a decline close +/// at the *same* level: the second sell-off cannot push price any lower, so the +/// matching closes mark a support floor. +/// +/// ```text +/// bar1, bar2 both black +/// equal closes = |close2 − close1| <= 0.05 · mean(range1, range2) +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Matching Low +/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The +/// first bar always returns `0.0` because the two-bar window is not yet filled. +/// The close-equality tolerance follows the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MatchingLow}; +/// +/// let mut indicator = MatchingLow::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.0, 13.1, 9.9, 10.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MatchingLow { + prev: Option, + has_emitted: bool, +} + +impl MatchingLow { + /// Construct a new Matching Low detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for MatchingLow { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let mean_range = 0.5 * ((bar1.high - bar1.low) + (candle.high - candle.low)); + let tol = 0.05 * mean_range; + if bar1.close < bar1.open + && candle.close < candle.open + && (candle.close - bar1.close).abs() <= tol + { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MatchingLow" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = MatchingLow::new(); + assert_eq!(t.name(), "MatchingLow"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn matching_low_is_plus_one() { + let mut t = MatchingLow::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 1)), Some(1.0)); + } + + #[test] + fn different_close_yields_zero() { + let mut t = MatchingLow::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + // Second close well away from the first. + assert_eq!(t.update(c(13.0, 13.1, 11.4, 11.5, 1)), Some(0.0)); + } + + #[test] + fn second_bar_white_yields_zero() { + let mut t = MatchingLow::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + assert_eq!(t.update(c(9.0, 10.1, 8.9, 10.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = MatchingLow::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 - i as f64; + c(base + 2.0, base + 2.1, base - 0.1, base, i) + }) + .collect(); + let mut a = MatchingLow::new(); + let mut b = MatchingLow::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = MatchingLow::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(13.0, 13.1, 9.9, 10.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/max_drawdown.rs b/crates/wickra-core/src/indicators/max_drawdown.rs new file mode 100644 index 0000000..8e32cb2 --- /dev/null +++ b/crates/wickra-core/src/indicators/max_drawdown.rs @@ -0,0 +1,245 @@ +//! Maximum Drawdown over a rolling window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Maximum Drawdown — the deepest peak-to-trough decline within the +/// trailing window. +/// +/// The input is treated as an equity-curve sample (or any non-negative value +/// series). For each bar the indicator computes the largest fractional decline +/// from any prior peak inside the trailing `period`-bar window: +/// +/// ```text +/// drawdown_t = (equity_t − peak_t) / peak_t (a negative number) +/// MaxDrawdown = min(drawdown_t over window) (most-negative value) +/// ``` +/// +/// Output is the magnitude of the worst drawdown as a non-negative fraction +/// (`0.20` = 20 % drop from peak). A monotonically rising equity curve has a +/// max drawdown of `0`. Setting `period` greater than or equal to the number of +/// bars you will ever feed makes the metric effectively *cumulative* — the +/// indicator never forgets the global peak. +/// +/// Each `update` is amortised O(1): the running peak is tracked with a +/// monotonically-decreasing deque. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MaxDrawdown}; +/// +/// let mut mdd = MaxDrawdown::new(10).unwrap(); +/// // Equity peaks at 110 then drops to 88 — a 20% drawdown. +/// for v in [100.0, 110.0, 100.0, 95.0, 88.0, 90.0, 92.0, 95.0, 100.0, 105.0] { +/// mdd.update(v); +/// } +/// assert!((mdd.update(106.0).unwrap() - 0.20).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct MaxDrawdown { + period: usize, + count: u64, + /// Monotonically-decreasing deque of `(index, value)` over the trailing + /// window. Front is the trailing peak in O(1). + peak_dq: VecDeque<(u64, f64)>, + window: VecDeque, + last: Option, +} + +impl MaxDrawdown { + /// Construct a new rolling Max Drawdown. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + count: 0, + peak_dq: VecDeque::with_capacity(period), + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured rolling-window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for MaxDrawdown { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + self.count += 1; + // Drop tail entries dominated by the new value (running peak from the + // back side of the window). + while let Some(&(_, back)) = self.peak_dq.back() { + if back <= input { + self.peak_dq.pop_back(); + } else { + break; + } + } + self.peak_dq.push_back((self.count, input)); + // Window slide. + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + let window_lo = self.count.saturating_sub(self.period as u64 - 1); + while let Some(&(idx, _)) = self.peak_dq.front() { + if idx < window_lo { + self.peak_dq.pop_front(); + } else { + break; + } + } + if self.window.len() < self.period { + return None; + } + // Scan the window for the deepest drawdown vs running peak so far. + let mut peak = f64::NEG_INFINITY; + let mut worst = 0.0_f64; + for &v in &self.window { + if v > peak { + peak = v; + } + if peak > 0.0 { + let dd = (peak - v) / peak; + if dd > worst { + worst = dd; + } + } + } + self.last = Some(worst); + Some(worst) + } + + fn reset(&mut self) { + self.count = 0; + self.peak_dq.clear(); + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "MaxDrawdown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut mdd = MaxDrawdown::new(10).unwrap(); + assert_eq!(mdd.period(), 10); + assert_eq!(mdd.name(), "MaxDrawdown"); + assert_eq!(mdd.value(), None); + assert_eq!(mdd.warmup_period(), 10); + for v in 1..=10 { + mdd.update(f64::from(v)); + } + assert!(mdd.value().is_some()); + } + + #[test] + fn pure_uptrend_yields_zero() { + let mut mdd = MaxDrawdown::new(5).unwrap(); + let out = mdd.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_drawdown() { + // Window [100, 120, 90]: peak 120, trough 90 -> 25% drawdown. + let mut mdd = MaxDrawdown::new(3).unwrap(); + let out = mdd.batch(&[100.0, 120.0, 90.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut mdd = MaxDrawdown::new(4).unwrap(); + let out = mdd.batch(&[50.0; 12]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut mdd = MaxDrawdown::new(3).unwrap(); + mdd.batch(&[100.0, 90.0, 80.0]); + let last = mdd.value(); + assert_eq!(mdd.update(f64::NAN), last); + assert_eq!(mdd.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut mdd = MaxDrawdown::new(3).unwrap(); + mdd.batch(&[100.0, 90.0, 80.0]); + assert!(mdd.is_ready()); + mdd.reset(); + assert!(!mdd.is_ready()); + assert_eq!(mdd.update(100.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let batch = MaxDrawdown::new(10).unwrap().batch(&prices); + let mut s = MaxDrawdown::new(10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_positive_peak_yields_zero() { + // All-zero stream: peak is 0, division skipped, result stays 0. + let mut mdd = MaxDrawdown::new(3).unwrap(); + let out = mdd.batch(&[0.0_f64; 6]); + for v in out.into_iter().flatten() { + assert_eq!(v, 0.0); + } + } +} diff --git a/crates/wickra-core/src/indicators/mcclellan_oscillator.rs b/crates/wickra-core/src/indicators/mcclellan_oscillator.rs new file mode 100644 index 0000000..308bbc8 --- /dev/null +++ b/crates/wickra-core/src/indicators/mcclellan_oscillator.rs @@ -0,0 +1,204 @@ +//! McClellan Oscillator — the spread between a fast and slow EMA of breadth. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Fast EMA smoothing constant — the classic McClellan 19-period weight +/// `2 / (19 + 1)`. +const ALPHA_FAST: f64 = 0.1; +/// Slow EMA smoothing constant — the classic McClellan 39-period weight +/// `2 / (39 + 1)`. +const ALPHA_SLOW: f64 = 0.05; +/// Scale applied to the ratio-adjusted net advances so readings land on the +/// familiar McClellan amplitude. +const RANA_SCALE: f64 = 1000.0; + +/// McClellan Oscillator — the difference between a 19-period and a 39-period +/// exponential moving average of *ratio-adjusted net advances*. +/// +/// Each tick's breadth is reduced to ratio-adjusted net advances (RANA), +/// `(advancers - decliners) / (advancers + decliners) * 1000`. Dividing by the +/// number of participating issues makes the reading independent of universe size, +/// so the oscillator stays comparable as the universe grows or shrinks. The +/// oscillator is then the fast EMA minus the slow EMA of that series, using the +/// classic McClellan smoothing constants `0.10` (19-period) and `0.05` +/// (39-period). Both EMAs are seeded from the first tick's RANA, so the +/// oscillator is defined from the first update (`warmup_period == 1`); it starts +/// at `0.0` and crosses zero as breadth momentum shifts. +/// +/// A tick with no advancing or declining issues yields a RANA of `0.0` (the +/// participating count is floored to one). +/// +/// `Input = CrossSection`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, McClellanOscillator, Member}; +/// +/// let mut osc = McClellanOscillator::new(); +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// // First tick seeds both EMAs to the same value -> oscillator 0. +/// assert_eq!(osc.update(tick), Some(0.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct McClellanOscillator { + ema_fast: f64, + ema_slow: f64, + seeded: bool, + has_emitted: bool, +} + +impl McClellanOscillator { + /// Construct a new McClellan Oscillator with the classic 19/39 smoothing. + #[must_use] + pub const fn new() -> Self { + Self { + ema_fast: 0.0, + ema_slow: 0.0, + seeded: false, + has_emitted: false, + } + } + + /// Feed a cross-section tick and return the oscillator value, which is defined + /// on every tick. Shared with [`McClellanSummationIndex`] so the summation + /// index can accumulate the oscillator without an `Option` round-trip. + /// + /// [`McClellanSummationIndex`]: crate::McClellanSummationIndex + pub(crate) fn step(&mut self, section: &CrossSection) -> f64 { + let advancers = section.advancers(); + let decliners = section.decliners(); + let net = advancers as f64 - decliners as f64; + let participating = (advancers + decliners).max(1) as f64; + let rana = net / participating * RANA_SCALE; + if self.seeded { + self.ema_fast += ALPHA_FAST * (rana - self.ema_fast); + self.ema_slow += ALPHA_SLOW * (rana - self.ema_slow); + } else { + self.ema_fast = rana; + self.ema_slow = rana; + self.seeded = true; + } + self.has_emitted = true; + self.ema_fast - self.ema_slow + } +} + +impl Indicator for McClellanOscillator { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + Some(self.step(§ion)) + } + + fn reset(&mut self) { + self.ema_fast = 0.0; + self.ema_slow = 0.0; + self.seeded = false; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "McClellanOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let osc = McClellanOscillator::new(); + assert_eq!(osc.name(), "McClellanOscillator"); + assert_eq!(osc.warmup_period(), 1); + assert!(!osc.is_ready()); + } + + #[test] + fn seeds_to_zero_on_first_tick() { + let mut osc = McClellanOscillator::new(); + // RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0. + assert_eq!(osc.update(section(3, 1)), Some(0.0)); + assert!(osc.is_ready()); + } + + #[test] + fn tracks_breadth_momentum_after_seeding() { + let mut osc = McClellanOscillator::new(); + osc.update(section(3, 1)); // seed at RANA 500 + // RANA = (1 - 3) / 4 * 1000 = -500. + // fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450. + let value = osc.update(section(1, 3)).unwrap(); + assert!((value - (-50.0)).abs() < 1e-9); + // RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5. + let value = osc.update(section(2, 2)).unwrap(); + assert!((value - (-67.5)).abs() < 1e-9); + } + + #[test] + fn empty_participation_yields_zero_rana() { + let mut osc = McClellanOscillator::new(); + // No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0. + assert_eq!(osc.update(section(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut osc = McClellanOscillator::new(); + osc.update(section(3, 1)); + osc.update(section(1, 3)); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + // After reset the next tick re-seeds to spread 0. + assert_eq!(osc.update(section(1, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)]; + let mut a = McClellanOscillator::new(); + let mut b = McClellanOscillator::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mcclellan_summation_index.rs b/crates/wickra-core/src/indicators/mcclellan_summation_index.rs new file mode 100644 index 0000000..bbf093a --- /dev/null +++ b/crates/wickra-core/src/indicators/mcclellan_summation_index.rs @@ -0,0 +1,160 @@ +//! McClellan Summation Index — the running total of the McClellan Oscillator. + +use crate::cross_section::CrossSection; +use crate::indicators::mcclellan_oscillator::McClellanOscillator; +use crate::traits::Indicator; + +/// McClellan Summation Index — the running cumulative sum of the +/// [`McClellanOscillator`]. +/// +/// Where the oscillator measures the *momentum* of breadth, the summation index +/// integrates it into a longer-term breadth trend: it rises while the oscillator +/// is positive and falls while it is negative, so it behaves like a slow, +/// smoothed advance/decline line. Sustained readings far above or below zero mark +/// strong bull or bear breadth regimes, and crosses of the zero line are read as +/// major trend changes. +/// +/// The index embeds a [`McClellanOscillator`] and adds its value on every tick. +/// Because the oscillator seeds to `0.0` on the first tick, the summation index +/// also starts at `0.0` and is defined from the first update +/// (`warmup_period == 1`). +/// +/// `Input = CrossSection`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, McClellanSummationIndex, Member}; +/// +/// let mut msi = McClellanSummationIndex::new(); +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// // First tick: oscillator seeds to 0, so the summation index is 0. +/// assert_eq!(msi.update(tick), Some(0.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct McClellanSummationIndex { + oscillator: McClellanOscillator, + sum: f64, + has_emitted: bool, +} + +impl McClellanSummationIndex { + /// Construct a new McClellan Summation Index. + #[must_use] + pub fn new() -> Self { + Self { + oscillator: McClellanOscillator::new(), + sum: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for McClellanSummationIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let oscillator = self.oscillator.step(§ion); + self.sum += oscillator; + self.has_emitted = true; + Some(self.sum) + } + + fn reset(&mut self) { + self.oscillator.reset(); + self.sum = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "McClellanSummationIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let msi = McClellanSummationIndex::new(); + assert_eq!(msi.name(), "McClellanSummationIndex"); + assert_eq!(msi.warmup_period(), 1); + assert!(!msi.is_ready()); + } + + #[test] + fn first_tick_starts_at_zero() { + let mut msi = McClellanSummationIndex::new(); + assert_eq!(msi.update(section(3, 1)), Some(0.0)); + assert!(msi.is_ready()); + } + + #[test] + fn accumulates_the_oscillator() { + let mut msi = McClellanSummationIndex::new(); + assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0 + // osc -50 -> sum -50. + let value = msi.update(section(1, 3)).unwrap(); + assert!((value - (-50.0)).abs() < 1e-9); + // osc -67.5 -> sum -117.5. + let value = msi.update(section(2, 2)).unwrap(); + assert!((value - (-117.5)).abs() < 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut msi = McClellanSummationIndex::new(); + msi.update(section(3, 1)); + msi.update(section(1, 3)); + assert!(msi.is_ready()); + msi.reset(); + assert!(!msi.is_ready()); + // Oscillator re-seeds, so the summation index restarts at 0. + assert_eq!(msi.update(section(1, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(3, 1), section(1, 3), section(2, 2)]; + let mut a = McClellanSummationIndex::new(); + let mut b = McClellanSummationIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mcginley_dynamic.rs b/crates/wickra-core/src/indicators/mcginley_dynamic.rs new file mode 100644 index 0000000..80ea56a --- /dev/null +++ b/crates/wickra-core/src/indicators/mcginley_dynamic.rs @@ -0,0 +1,224 @@ +//! `McGinley` Dynamic — self-adjusting moving average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// John `McGinley`'s "Dynamic" — a self-adjusting moving average that speeds up +/// in downtrends and slows down in uptrends to track price more closely than +/// a fixed-period MA. +/// +/// The recurrence is +/// +/// ```text +/// MD_t = MD_{t-1} + (price_t - MD_{t-1}) / (K * period * (price_t / MD_{t-1})^4) +/// ``` +/// +/// where `K = 0.6` is `McGinley`'s original constant. The fourth-power ratio +/// term shrinks the divisor when price falls below the indicator (faster +/// catch-up) and inflates it when price runs above (more smoothing). The +/// indicator is seeded with the simple average of the first `period` inputs. +/// +/// Reference: John R. `McGinley` Jr., *Technical Analysis of Stocks & +/// Commodities*, 1990. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, McGinleyDynamic}; +/// +/// let mut md = McGinleyDynamic::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = md.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct McGinleyDynamic { + period: usize, + seed: VecDeque, + seed_sum: f64, + current: Option, +} + +/// `McGinley`'s original constant `K` in the recurrence denominator. +const K: f64 = 0.6; + +impl McGinleyDynamic { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + seed: VecDeque::with_capacity(period), + seed_sum: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for McGinleyDynamic { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + if let Some(prev) = self.current { + // The recurrence divides by `(price / prev)^4`; if either side is + // zero or negative the formula blows up, so we hold the previous + // value as a defensive fallback against degenerate price series. + if prev <= 0.0 || input <= 0.0 { + return self.current; + } + let ratio = input / prev; + let divisor = K * (self.period as f64) * ratio.powi(4); + let next = prev + (input - prev) / divisor; + self.current = Some(next); + } else { + self.seed.push_back(input); + self.seed_sum += input; + if self.seed.len() == self.period { + self.current = Some(self.seed_sum / self.period as f64); + } + } + self.current + } + + fn reset(&mut self) { + self.seed.clear(); + self.seed_sum = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "McGinleyDynamic" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(McGinleyDynamic::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut md = McGinleyDynamic::new(10).unwrap(); + assert_eq!(md.period(), 10); + assert_eq!(md.warmup_period(), 10); + assert_eq!(md.name(), "McGinleyDynamic"); + assert_eq!(md.value(), None); + for i in 1..=10 { + md.update(f64::from(i)); + } + assert!(md.value().is_some()); + } + + #[test] + fn constant_series_yields_the_constant() { + // ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD. + let mut md = McGinleyDynamic::new(5).unwrap(); + let out = md.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut md = McGinleyDynamic::new(3).unwrap(); + // Seed = SMA([10, 20, 30]) = 20.0. + assert_eq!(md.update(10.0), None); + assert_eq!(md.update(20.0), None); + assert_eq!(md.update(30.0), Some(20.0)); + } + + #[test] + fn reference_value_recurrence() { + // Period 3, seed = SMA([10, 20, 30]) = 20.0. Then on price = 40.0: + // ratio = 40 / 20 = 2 + // divisor = 0.6 * 3 * 2^4 = 0.6 * 3 * 16 = 28.8 + // next = 20 + (40 - 20) / 28.8 = 20.694444... + let mut md = McGinleyDynamic::new(3).unwrap(); + md.batch(&[10.0_f64, 20.0, 30.0]); + let v = md.update(40.0).unwrap(); + let expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0); + assert_relative_eq!(v, expected, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = McGinleyDynamic::new(10).unwrap(); + let mut b = McGinleyDynamic::new(10).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut md = McGinleyDynamic::new(5).unwrap(); + md.batch(&(1..=30).map(f64::from).collect::>()); + assert!(md.is_ready()); + md.reset(); + assert!(!md.is_ready()); + assert_eq!(md.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut md = McGinleyDynamic::new(3).unwrap(); + md.batch(&[10.0_f64, 20.0, 30.0]); + let before = md.value().unwrap(); + assert_eq!(md.update(f64::NAN), Some(before)); + assert_eq!(md.update(f64::INFINITY), Some(before)); + } + + #[test] + fn holds_value_when_input_is_non_positive() { + // Defensive: a zero or negative price would make the (price/prev)^4 + // divisor zero or otherwise blow up; the recurrence holds steady. + let mut md = McGinleyDynamic::new(3).unwrap(); + md.batch(&[10.0_f64, 20.0, 30.0]); + let before = md.value().unwrap(); + assert_eq!(md.update(0.0), Some(before)); + assert_eq!(md.update(-5.0), Some(before)); + // Once a positive price arrives the recurrence resumes normally. + let after = md.update(40.0).unwrap(); + assert!(after > before); + } +} diff --git a/crates/wickra-core/src/indicators/median_absolute_deviation.rs b/crates/wickra-core/src/indicators/median_absolute_deviation.rs new file mode 100644 index 0000000..cb06e95 --- /dev/null +++ b/crates/wickra-core/src/indicators/median_absolute_deviation.rs @@ -0,0 +1,204 @@ +//! Rolling Median Absolute Deviation (MAD), a robust dispersion estimator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Median Absolute Deviation of the last `period` values. +/// +/// ```text +/// med = median(window) +/// MAD = median( |x_i − med| for x_i in window ) +/// ``` +/// +/// MAD is the median analogue of the standard deviation: it is a robust +/// dispersion measure that ignores extreme outliers (a single huge spike +/// barely moves the result) and is widely used as a sturdier alternative +/// to `StdDev` for risk reporting on heavy-tailed return distributions. +/// Multiplying MAD by `1.4826` produces a consistent estimator of the +/// underlying Gaussian standard deviation (the "robust σ"); Wickra returns +/// the raw MAD so the caller chooses whether to scale. +/// +/// Each `update` is O(period log period): the window is kept as a deque +/// and copied into a small scratch buffer that is sorted twice (once to +/// pick the median, once to pick the median of absolute deviations). The +/// rolling structure makes the constant factor low; for the typical +/// period range (10–100) this is dwarfed by the streaming overhead. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MedianAbsoluteDeviation}; +/// +/// let mut indicator = MedianAbsoluteDeviation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MedianAbsoluteDeviation { + period: usize, + window: VecDeque, + /// Reusable scratch buffer to avoid allocating per `update`. + scratch: Vec, +} + +impl MedianAbsoluteDeviation { + /// Construct a new rolling MAD with the given period. + /// + /// # 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), + scratch: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +/// Sort a slice of `f64` in-place using total ordering (NaN-safe). +fn sort_finite(buf: &mut [f64]) { + buf.sort_by(f64::total_cmp); +} + +/// Median of a sorted, non-empty slice. +fn median_sorted(sorted: &[f64]) -> f64 { + let n = sorted.len(); + let mid = n / 2; + if n % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) * 0.5 + } else { + sorted[mid] + } +} + +impl Indicator for MedianAbsoluteDeviation { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + // Copy into scratch and sort to find the window median. + self.scratch.clear(); + self.scratch.extend(self.window.iter().copied()); + sort_finite(&mut self.scratch); + let med = median_sorted(&self.scratch); + // Replace with absolute deviations and sort again. + for x in &mut self.scratch { + *x = (*x - med).abs(); + } + sort_finite(&mut self.scratch); + Some(median_sorted(&self.scratch)) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "MedianAbsoluteDeviation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + MedianAbsoluteDeviation::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let m = MedianAbsoluteDeviation::new(14).unwrap(); + assert_eq!(m.period(), 14); + assert_eq!(m.warmup_period(), 14); + assert_eq!(m.name(), "MedianAbsoluteDeviation"); + } + + #[test] + fn reference_value() { + // [1, 1, 2, 2, 4, 6, 9]: median = 2, deviations [1,1,0,0,2,4,7], + // sorted [0,0,1,1,2,4,7] → median = 1. + let mut m = MedianAbsoluteDeviation::new(7).unwrap(); + let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]); + assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut m = MedianAbsoluteDeviation::new(5).unwrap(); + for v in m.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_single_extreme_outlier() { + // A window of 9 equal values plus 1 huge outlier still has MAD = 0, + // because more than half the window agrees on the median and the + // deviations majority are zero. + let mut m = MedianAbsoluteDeviation::new(10).unwrap(); + let mut prices = vec![5.0; 9]; + prices.push(1_000.0); + let last = m.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut m = MedianAbsoluteDeviation::new(5).unwrap(); + m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices); + let mut b = MedianAbsoluteDeviation::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/median_channel.rs b/crates/wickra-core/src/indicators/median_channel.rs new file mode 100644 index 0000000..e08d5d8 --- /dev/null +++ b/crates/wickra-core/src/indicators/median_channel.rs @@ -0,0 +1,240 @@ +//! Median Channel — a robust median ± MAD envelope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rolling_quantile::quantile_sorted; +use crate::traits::Indicator; + +/// Median Channel output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MedianChannelOutput { + /// Upper band: `median + multiplier · MAD`. + pub upper: f64, + /// Middle line: the rolling median. + pub middle: f64, + /// Lower band: `median − multiplier · MAD`. + pub lower: f64, +} + +/// Median Channel: a robust analogue of Bollinger Bands built from the rolling +/// median and the median absolute deviation (MAD). +/// +/// ```text +/// middle = median(close, period) +/// MAD = median( | close_i − middle | ) +/// upper = middle + multiplier · MAD +/// lower = middle − multiplier · MAD +/// ``` +/// +/// Where [`BollingerBands`](crate::BollingerBands) centre on the mean and scale +/// by the standard deviation — both of which a single spike can drag +/// arbitrarily far — the Median Channel uses two order statistics. The +/// breakdown point of the median and MAD is 50%: up to half the window can be +/// contaminated before the centre or width is materially distorted. That makes +/// the channel well suited to noisy, gap-prone, or fat-tailed series where +/// Bollinger Bands flare on every outlier. Both quantiles use the type-7 +/// interpolation shared with [`RollingQuantile`](crate::RollingQuantile). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MedianChannel}; +/// +/// let mut indicator = MedianChannel::new(20, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i % 5)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MedianChannel { + period: usize, + multiplier: f64, + window: VecDeque, + scratch: Vec, + deviations: Vec, +} + +impl MedianChannel { + /// Construct a new Median Channel. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(period: usize, multiplier: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + multiplier, + window: VecDeque::with_capacity(period), + scratch: Vec::with_capacity(period), + deviations: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for MedianChannel { + type Input = f64; + type Output = MedianChannelOutput; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + self.scratch.clear(); + self.scratch.extend(self.window.iter().copied()); + self.scratch.sort_by(f64::total_cmp); + let median = quantile_sorted(&self.scratch, 0.5); + + self.deviations.clear(); + for &v in &self.window { + self.deviations.push((v - median).abs()); + } + self.deviations.sort_by(f64::total_cmp); + let mad = quantile_sorted(&self.deviations, 0.5); + let offset = self.multiplier * mad; + + Some(MedianChannelOutput { + upper: median + offset, + middle: median, + lower: median - offset, + }) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + self.deviations.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "MedianChannel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero))); + assert!(MedianChannel::new(1, 2.0).is_ok()); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + MedianChannel::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + MedianChannel::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + MedianChannel::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let mc = MedianChannel::new(20, 2.0).unwrap(); + assert_eq!(mc.period(), 20); + assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(mc.warmup_period(), 20); + assert_eq!(mc.name(), "MedianChannel"); + assert!(!mc.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut mc = MedianChannel::new(5, 2.0).unwrap(); + for v in [1.0, 2.0, 3.0, 4.0] { + assert!(mc.update(v).is_none()); + } + assert!(mc.update(5.0).is_some()); + assert!(mc.is_ready()); + } + + #[test] + fn known_channel() { + // [1,2,3,4,5]: median 3; |dev| sorted [0,1,1,2,2] -> MAD 1. + // upper = 3 + 2*1 = 5; lower = 3 - 2*1 = 1. + let mut mc = MedianChannel::new(5, 2.0).unwrap(); + let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = out[4].unwrap(); + assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12); + assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12); + } + + #[test] + fn robust_to_outlier() { + // Replacing the last value with a huge spike leaves the median centre + // unchanged (still the middle order statistic). + let mut mc = MedianChannel::new(5, 2.0).unwrap(); + let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]); + assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12); + } + + #[test] + fn rolling_window_evicts_oldest() { + // Ten values through a period-5 window: only the last five survive, + // reproducing the `known_channel` window. + let mut mc = MedianChannel::new(5, 2.0).unwrap(); + let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]); + let last = out[9].unwrap(); + assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12); + assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12); + assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut mc = MedianChannel::new(5, 2.0).unwrap(); + for v in [1.0, 2.0, 3.0, 4.0, 5.0] { + mc.update(v); + } + assert!(mc.is_ready()); + mc.reset(); + assert!(!mc.is_ready()); + assert!(mc.update(1.0).is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/median_ma.rs b/crates/wickra-core/src/indicators/median_ma.rs new file mode 100644 index 0000000..2d5b8a8 --- /dev/null +++ b/crates/wickra-core/src/indicators/median_ma.rs @@ -0,0 +1,205 @@ +//! Median Moving Average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Median Moving Average — the rolling median of the last `period` inputs. +/// +/// For an odd `period` the output is the middle order statistic of the window; +/// for an even `period` it is the average of the two central values. Because it +/// is a rank statistic rather than a sum, the median MA is far more robust to +/// single outliers than the [`Sma`](crate::Sma): a lone spike shifts the rank +/// by at most one position instead of dragging the whole average. +/// +/// Each `update` slides the window and computes the median by sorting a copy of +/// the `period` buffered values — O(`period` · log `period`) per step, with the +/// period fixed and bounded. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MedianMa}; +/// +/// let mut indicator = MedianMa::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MedianMa { + period: usize, + window: VecDeque, +} + +impl MedianMa { + /// Construct a new median moving average over `period` inputs. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.window.len() != self.period { + return None; + } + let mut sorted: Vec = self.window.iter().copied().collect(); + sorted.sort_by(|a, b| a.partial_cmp(b).expect("window holds only finite values")); + let mid = self.period / 2; + if self.period % 2 == 1 { + Some(sorted[mid]) + } else { + Some(f64::midpoint(sorted[mid - 1], sorted[mid])) + } + } +} + +impl Indicator for MedianMa { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + self.value() + } + + 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 { + "MedianMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(MedianMa::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let mma = MedianMa::new(7).unwrap(); + assert_eq!(mma.period(), 7); + assert_eq!(mma.warmup_period(), 7); + assert_eq!(mma.name(), "MedianMA"); + } + + #[test] + fn warmup_returns_none_then_odd_median() { + let mut mma = MedianMa::new(3).unwrap(); + assert_eq!(mma.update(5.0), None); + assert_eq!(mma.update(1.0), None); + // median of [5, 1, 3] = 3 (middle order statistic). + assert_relative_eq!(mma.update(3.0).unwrap(), 3.0, epsilon = 1e-12); + } + + #[test] + fn even_period_averages_two_central_values() { + // median of [1, 2, 3, 4] = (2 + 3) / 2 = 2.5. + let mut mma = MedianMa::new(4).unwrap(); + let v = mma.batch(&[1.0, 2.0, 3.0, 4.0]); + assert_relative_eq!(v[3].unwrap(), 2.5, epsilon = 1e-12); + } + + #[test] + fn robust_to_single_outlier() { + // A lone spike does not move the median of an odd window the way it + // would move an SMA. median of [10, 11, 9999] = 11. + let mut mma = MedianMa::new(3).unwrap(); + let v = mma.batch(&[10.0, 11.0, 9999.0]); + assert_relative_eq!(v[2].unwrap(), 11.0, epsilon = 1e-12); + } + + #[test] + fn period_one_is_pass_through() { + let mut mma = MedianMa::new(1).unwrap(); + assert_relative_eq!(mma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(mma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn slides_window_correctly() { + // After [1,2,3] the window slides to [2,3,4] -> median 3, then [3,4,5] -> 4. + let mut mma = MedianMa::new(3).unwrap(); + let v = mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_relative_eq!(v[2].unwrap(), 2.0, epsilon = 1e-12); + assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12); + assert_relative_eq!(v[4].unwrap(), 4.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut mma = MedianMa::new(4).unwrap(); + mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(mma.is_ready()); + mma.reset(); + assert!(!mma.is_ready()); + assert_eq!(mma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| (f64::from(i) * 0.7).sin() * 5.0).collect(); + let mut a = MedianMa::new(5).unwrap(); + let mut b = MedianMa::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut mma = MedianMa::new(3).unwrap(); + mma.update(5.0); + mma.update(1.0); + let ready = mma + .update(3.0) + .expect("MedianMA(3) ready after three inputs"); + assert_eq!(mma.update(f64::NAN), Some(ready)); + assert_eq!(mma.update(f64::INFINITY), Some(ready)); + // Window still [5, 1, 3] -> next real input slides to [1, 3, 8] -> median 3. + assert_relative_eq!(mma.update(8.0).unwrap(), 3.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/median_price.rs b/crates/wickra-core/src/indicators/median_price.rs new file mode 100644 index 0000000..9b761a5 --- /dev/null +++ b/crates/wickra-core/src/indicators/median_price.rs @@ -0,0 +1,128 @@ +//! Median Price. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Median Price — the bar's `(high + low) / 2`. +/// +/// The midpoint of the bar's range, ignoring where it opened or closed. It is +/// the price series Bill Williams' [`AwesomeOscillator`](crate::AwesomeOscillator) +/// is built on, and a smoother stand-in for the close when feeding other +/// indicators. As a stateless per-bar transform it emits a value from the +/// very first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MedianPrice}; +/// +/// let mut indicator = MedianPrice::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MedianPrice { + has_emitted: bool, +} + +impl MedianPrice { + /// Construct a new Median Price transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for MedianPrice { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.median_price()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MedianPrice" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (high + low) / 2 = (12 + 8) / 2 = 10. + let mut mp = MedianPrice::new(); + assert_relative_eq!( + mp.update(candle(10.0, 12.0, 8.0, 11.0, 0)).unwrap(), + 10.0, + epsilon = 1e-12 + ); + } + + /// Cover the Indicator-impl `name` body (62-64). + #[test] + fn name_metadata() { + let mp = MedianPrice::new(); + assert_eq!(mp.name(), "MedianPrice"); + } + + #[test] + fn emits_from_first_candle() { + let mut mp = MedianPrice::new(); + assert_eq!(mp.warmup_period(), 1); + assert!(!mp.is_ready()); + assert!(mp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(mp.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut mp = MedianPrice::new(); + mp.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(mp.is_ready()); + mp.reset(); + assert!(!mp.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = MedianPrice::new(); + let mut b = MedianPrice::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mfi.rs b/crates/wickra-core/src/indicators/mfi.rs new file mode 100644 index 0000000..492f5b2 --- /dev/null +++ b/crates/wickra-core/src/indicators/mfi.rs @@ -0,0 +1,247 @@ +//! Money Flow Index (MFI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Money Flow Index: a volume-weighted version of RSI. +/// +/// `MFI = 100 - 100 / (1 + positive_money_flow / negative_money_flow)` where +/// money flow is `typical_price * volume`, classified positive when TP increases +/// and negative when it decreases. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Mfi}; +/// +/// let mut indicator = Mfi::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Mfi { + period: usize, + prev_tp: Option, + pos_window: VecDeque, + neg_window: VecDeque, + pos_sum: f64, + neg_sum: f64, +} + +impl Mfi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_tp: None, + pos_window: VecDeque::with_capacity(period), + neg_window: VecDeque::with_capacity(period), + pos_sum: 0.0, + neg_sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Mfi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tp = candle.typical_price(); + + // The very first candle only establishes the previous typical price. + // It carries no money-flow direction, so it is not pushed into the + // window. This matches TA-Lib / pandas-ta, which need `period + 1` + // candles before the first MFI value. + let Some(prev) = self.prev_tp else { + self.prev_tp = Some(tp); + return None; + }; + + let mf = tp * candle.volume; + let (pos_flow, neg_flow) = if tp > prev { + (mf, 0.0) + } else if tp < prev { + (0.0, mf) + } else { + (0.0, 0.0) + }; + + if self.pos_window.len() == self.period { + self.pos_sum -= self.pos_window.pop_front().expect("non-empty"); + self.neg_sum -= self.neg_window.pop_front().expect("non-empty"); + } + self.pos_window.push_back(pos_flow); + self.neg_window.push_back(neg_flow); + self.pos_sum += pos_flow; + self.neg_sum += neg_flow; + + self.prev_tp = Some(tp); + + if self.pos_window.len() < self.period { + return None; + } + // A fully flat window (every typical price equal) has zero flow on + // both sides; by convention MFI is then 50. + if self.pos_sum == 0.0 && self.neg_sum == 0.0 { + return Some(50.0); + } + if self.neg_sum == 0.0 { + return Some(100.0); + } + let mr = self.pos_sum / self.neg_sum; + Some(100.0 - 100.0 / (1.0 + mr)) + } + + fn reset(&mut self) { + self.prev_tp = None; + self.pos_window.clear(); + self.neg_window.clear(); + self.pos_sum = 0.0; + self.neg_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // One seed candle establishes the first previous typical price, then + // `period` flow comparisons fill the window. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.pos_window.len() == self.period + } + + fn name(&self) -> &'static str { + "MFI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(price: f64, volume: f64) -> Candle { + Candle::new(price, price, price, price, volume, 0).unwrap() + } + + #[test] + fn pure_uptrend_yields_high_mfi() { + let candles: Vec = (1..30).map(|i| c(f64::from(i), 100.0)).collect(); + let mut mfi = Mfi::new(14).unwrap(); + let last = mfi.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn pure_downtrend_yields_low_mfi() { + let candles: Vec = (1..30).rev().map(|i| c(f64::from(i), 100.0)).collect(); + let mut mfi = Mfi::new(14).unwrap(); + let last = mfi.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40).map(|i| c(f64::from(i) + 10.0, 50.0)).collect(); + let mut a = Mfi::new(14).unwrap(); + let mut b = Mfi::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..30).map(|i| c(f64::from(i), 100.0)).collect(); + let mut mfi = Mfi::new(14).unwrap(); + mfi.batch(&candles); + assert!(mfi.is_ready()); + mfi.reset(); + assert!(!mfi.is_ready()); + } + + /// Cover the const accessor `period` (58-60) and the Indicator-impl + /// `name` body (132-134). `warmup_period` is already covered elsewhere. + #[test] + fn accessors_and_metadata() { + let mfi = Mfi::new(14).unwrap(); + assert_eq!(mfi.period(), 14); + assert_eq!(mfi.name(), "MFI"); + } + + /// Cover the `tp == prev` arm (line 85) — when typical price equals + /// the previous typical price, both flows are 0 — and the all-zero- + /// flow fallback `Some(50.0)` (line 105). Existing tests use varying + /// candles so the flat-TP arm and the zero-flow fallback never fired. + #[test] + fn flat_typical_prices_default_to_50() { + let mut mfi = Mfi::new(3).unwrap(); + let candles: Vec = (0..6) + .map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, i).unwrap()) + .collect(); + let last = mfi + .batch(&candles) + .into_iter() + .flatten() + .last() + .expect("emits"); + assert_eq!(last, 50.0); + } + + #[test] + fn rejects_zero_period() { + assert!(Mfi::new(0).is_err()); + } + + #[test] + fn first_value_emitted_on_period_plus_one_candle() { + // The seed candle plus `period` flow comparisons -> first MFI on the + // (period + 1)-th candle (index `period`). + let candles: Vec = (1..=20).map(|i| c(f64::from(i), 100.0)).collect(); + let mut mfi = Mfi::new(5).unwrap(); + let out = mfi.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "candle index {i} must be None during warmup"); + } + assert!( + out[5].is_some(), + "first MFI value lands at index period (5)" + ); + assert_eq!(mfi.warmup_period(), 6); + } + + #[test] + fn known_value_period_2() { + // Three candles, MFI(2). Candle 1 (tp=10) only seeds the previous TP. + // Candle 2 (tp=12 > 10): positive money flow 12 * 100 = 1200. + // Candle 3 (tp=11 < 12): negative money flow 11 * 100 = 1100. + // money ratio = 1200 / 1100; MFI = 100 - 100 / (1 + 1200/1100) = 1200/23. + let candles = vec![c(10.0, 100.0), c(12.0, 100.0), c(11.0, 100.0)]; + let mut mfi = Mfi::new(2).unwrap(); + let out = mfi.batch(&candles); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 1200.0 / 23.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/microprice.rs b/crates/wickra-core/src/indicators/microprice.rs new file mode 100644 index 0000000..e817326 --- /dev/null +++ b/crates/wickra-core/src/indicators/microprice.rs @@ -0,0 +1,170 @@ +//! Microprice — size-weighted fair value of the top of book. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Microprice — the size-weighted mid of the top of book. +/// +/// The microprice tilts the mid toward the side that is *more likely to be +/// hit*: it weights each touch price by the size resting on the **opposite** +/// side, so a heavy ask (sell pressure) pulls the fair value down toward the +/// bid, and vice versa: +/// +/// ```text +/// microprice = (bidPrice₁·askSize₁ + askPrice₁·bidSize₁) / (bidSize₁ + askSize₁) +/// ``` +/// +/// When both top sizes are zero the weighting is undefined and the plain mid +/// `(bidPrice₁ + askPrice₁) / 2` is returned. An empty book yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, Microprice, OrderBook}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 1.0).unwrap()], +/// vec![Level::new(101.0, 3.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut mp = Microprice::new(); +/// // (100·3 + 101·1) / (1 + 3) = 401 / 4 = 100.25 — pulled toward the bid. +/// assert_eq!(mp.update(book), Some(100.25)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Microprice { + has_emitted: bool, +} + +impl Microprice { + /// Construct a new microprice indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Microprice { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return Some(0.0); + }; + let total = bid.size + ask.size; + if total <= 0.0 { + return Some(f64::midpoint(bid.price, ask.price)); + } + Some((bid.price * ask.size + ask.price * bid.size) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Microprice" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let mp = Microprice::new(); + assert_eq!(mp.name(), "Microprice"); + assert_eq!(mp.warmup_period(), 1); + assert!(!mp.is_ready()); + } + + #[test] + fn weights_toward_thin_side() { + let mut mp = Microprice::new(); + // Heavy ask -> microprice pulled toward bid. + assert_eq!( + mp.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])), + Some(100.25) + ); + assert!(mp.is_ready()); + } + + #[test] + fn balanced_top_equals_mid() { + let mut mp = Microprice::new(); + assert_eq!( + mp.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])), + Some(100.5) + ); + } + + #[test] + fn zero_size_falls_back_to_mid() { + let mut mp = Microprice::new(); + assert_eq!( + mp.update(book(&[(100.0, 0.0)], &[(102.0, 0.0)])), + Some(101.0) + ); + } + + #[test] + fn empty_book_is_zero() { + let mut mp = Microprice::new(); + assert_eq!( + mp.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let ask = 1.0 + f64::from(i % 4); + book(&[(100.0, 2.0)], &[(101.0, ask)]) + }) + .collect(); + let mut a = Microprice::new(); + let mut b = Microprice::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut mp = Microprice::new(); + mp.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(mp.is_ready()); + mp.reset(); + assert!(!mp.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/mid_point.rs b/crates/wickra-core/src/indicators/mid_point.rs new file mode 100644 index 0000000..9504a35 --- /dev/null +++ b/crates/wickra-core/src/indicators/mid_point.rs @@ -0,0 +1,147 @@ +//! Midpoint (MIDPOINT) over a rolling window of a scalar series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Midpoint (`MIDPOINT`): the average of the highest and lowest value of the +/// input series over the last `period` points. +/// +/// ```text +/// MIDPOINT = (highest(value, period) + lowest(value, period)) / 2 +/// ``` +/// +/// Where [`MidPrice`](crate::MidPrice) takes the window extremes from a candle's +/// high/low, `MIDPOINT` works on a single scalar stream (typically the close), +/// taking the max and min of that stream over the window. The first value is +/// emitted once `period` points have been seen. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, MidPoint}; +/// +/// let mut indicator = MidPoint::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MidPoint { + period: usize, + window: VecDeque, +} + +impl MidPoint { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for MidPoint { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let highest = self + .window + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let lowest = self.window.iter().copied().fold(f64::INFINITY, f64::min); + Some(f64::midpoint(highest, lowest)) + } + + 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 { + "MIDPOINT" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(MidPoint::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let mp = MidPoint::new(7).unwrap(); + assert_eq!(mp.period(), 7); + assert_eq!(mp.name(), "MIDPOINT"); + assert_eq!(mp.warmup_period(), 7); + assert!(!mp.is_ready()); + } + + #[test] + fn averages_window_min_and_max() { + // Window {8, 12, 10}: highest 12, lowest 8 -> 10. + let mut mp = MidPoint::new(3).unwrap(); + let out: Vec> = mp.batch(&[8.0, 12.0, 10.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 10.0, epsilon = 1e-12); + assert!(mp.is_ready()); + } + + #[test] + fn window_slides_and_drops_old_values() { + // After the 30 spike leaves the window, the midpoint falls back. + let mut mp = MidPoint::new(3).unwrap(); + let out: Vec> = mp.batch(&[30.0, 8.0, 12.0, 10.0]); + // Last window {8, 12, 10}: (12 + 8) / 2 = 10. + assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut mp = MidPoint::new(3).unwrap(); + let _ = mp.batch(&[8.0, 12.0, 10.0]); + assert!(mp.is_ready()); + mp.reset(); + assert!(!mp.is_ready()); + assert_eq!(mp.update(8.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/mid_price.rs b/crates/wickra-core/src/indicators/mid_price.rs new file mode 100644 index 0000000..26b3326 --- /dev/null +++ b/crates/wickra-core/src/indicators/mid_price.rs @@ -0,0 +1,165 @@ +//! Midpoint Price (MIDPRICE) over a rolling window of high/low extremes. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Midpoint Price (`MIDPRICE`): the average of the highest high and the lowest +/// low over the last `period` candles. +/// +/// ```text +/// MIDPRICE = (highest(high, period) + lowest(low, period)) / 2 +/// ``` +/// +/// Unlike [`MedianPrice`](crate::MedianPrice), which averages a single bar's own +/// high and low, `MIDPRICE` averages the *window* extremes — it is numerically +/// the centre line of [`Donchian`](crate::Donchian) channels, exposed as a +/// standalone scalar for TA-Lib parity. The first value is emitted once `period` +/// candles have been seen. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MidPrice}; +/// +/// let mut indicator = MidPrice::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MidPrice { + period: usize, + candles: VecDeque, +} + +impl MidPrice { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for MidPrice { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.candles.len() == self.period { + self.candles.pop_front(); + } + self.candles.push_back(candle); + if self.candles.len() < self.period { + return None; + } + let highest = self + .candles + .iter() + .map(|c| c.high) + .fold(f64::NEG_INFINITY, f64::max); + let lowest = self + .candles + .iter() + .map(|c| c.low) + .fold(f64::INFINITY, f64::min); + Some(f64::midpoint(highest, lowest)) + } + + fn reset(&mut self) { + self.candles.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.candles.len() == self.period + } + + fn name(&self) -> &'static str { + "MIDPRICE" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(MidPrice::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let mp = MidPrice::new(7).unwrap(); + assert_eq!(mp.period(), 7); + assert_eq!(mp.name(), "MIDPRICE"); + assert_eq!(mp.warmup_period(), 7); + assert!(!mp.is_ready()); + } + + #[test] + fn averages_window_extremes() { + // Window highs {12, 14, 16}, lows {8, 9, 10}: highest 16, lowest 8 -> 12. + let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)]; + let mut mp = MidPrice::new(3).unwrap(); + let out: Vec> = mp.batch(&candles); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-12); + assert!(mp.is_ready()); + } + + #[test] + fn window_slides_and_drops_old_extremes() { + // After the spike leaves the window the midpoint falls back. + let candles = [ + c(30.0, 10.0, 20.0), + c(12.0, 8.0, 10.0), + c(14.0, 9.0, 11.0), + c(16.0, 10.0, 12.0), + ]; + let mut mp = MidPrice::new(3).unwrap(); + let out: Vec> = mp.batch(&candles); + // Last window {12,14,16}/{8,9,10}: (16 + 8) / 2 = 12. + assert_relative_eq!(out[3].unwrap(), 12.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)]; + let mut mp = MidPrice::new(3).unwrap(); + let _ = mp.batch(&candles); + assert!(mp.is_ready()); + mp.reset(); + assert!(!mp.is_ready()); + assert_eq!(mp.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/minus_di.rs b/crates/wickra-core/src/indicators/minus_di.rs new file mode 100644 index 0000000..44cb530 --- /dev/null +++ b/crates/wickra-core/src/indicators/minus_di.rs @@ -0,0 +1,199 @@ +//! Minus Directional Indicator (-DI), Wilder-smoothed. + +use crate::error::{Error, Result}; +use crate::indicators::adx::directional_movement; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wilder's Minus Directional Indicator (`MINUS_DI`). +/// +/// `-DI = 100 · smoothed(-DM) / smoothed(TR)`, where both the minus directional +/// movement and the true range are Wilder-smoothed over `period` bars. It is the +/// bearish half of the directional system that drives [`Adx`](crate::Adx); +/// readings above [`PlusDi`](crate::PlusDi) mark a down-trending regime. +/// +/// The first `period` raw values seed the two running sums; from then on each +/// applies the Wilder recursion `smoothed − smoothed / period + raw`. Because a +/// bar's directional movement and true range both need the previous bar, the +/// first value is emitted after `period + 1` candles. When the smoothed true +/// range is zero (a perfectly flat market) the indicator returns `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MinusDi}; +/// +/// let mut indicator = MinusDi::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 - f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MinusDi { + period: usize, + prev: Option, + dm_seed: f64, + tr_seed: f64, + seed_count: usize, + dm_smooth: Option, + tr_smooth: Option, +} + +impl MinusDi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + dm_seed: 0.0, + tr_seed: 0.0, + seed_count: 0, + dm_smooth: None, + tr_smooth: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for MinusDi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + self.prev = Some(candle); + + let (_, minus_dm) = directional_movement(&prev, &candle); + let tr = candle.true_range(Some(prev.close)); + let n = self.period as f64; + + let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) { + let d_new = d - d / n + minus_dm; + let t_new = t - t / n + tr; + self.dm_smooth = Some(d_new); + self.tr_smooth = Some(t_new); + (d_new, t_new) + } else { + self.dm_seed += minus_dm; + self.tr_seed += tr; + self.seed_count += 1; + if self.seed_count < self.period { + return None; + } + self.dm_smooth = Some(self.dm_seed); + self.tr_smooth = Some(self.tr_seed); + (self.dm_seed, self.tr_seed) + }; + + let di = if tr_v == 0.0 { + 0.0 + } else { + 100.0 * dm_v / tr_v + }; + Some(di) + } + + fn reset(&mut self) { + self.prev = None; + self.dm_seed = 0.0; + self.tr_seed = 0.0; + self.seed_count = 0; + self.dm_smooth = None; + self.tr_smooth = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.dm_smooth.is_some() + } + + fn name(&self) -> &'static str { + "MINUS_DI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(MinusDi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let di = MinusDi::new(7).unwrap(); + assert_eq!(di.period(), 7); + assert_eq!(di.name(), "MINUS_DI"); + assert_eq!(di.warmup_period(), 7); + assert!(!di.is_ready()); + } + + #[test] + fn downtrend_drives_minus_di_high() { + // Strict downtrend: -DM dominates, so -DI is large and bounded by 100. + let candles: Vec = (0..12) + .map(|i| { + let base = 140.0 - f64::from(i) * 2.0; + c(base + 0.5, base - 1.0, base - 0.5) + }) + .collect(); + let mut di = MinusDi::new(3).unwrap(); + let out: Vec> = di.batch(&candles); + assert_eq!(out[0], None); + assert!(out[3].is_some()); + let last = out.into_iter().flatten().last().unwrap(); + assert!(last > 0.0 && last <= 100.0); + assert!(di.is_ready()); + } + + #[test] + fn flat_market_returns_zero() { + let candles: Vec = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect(); + let mut di = MinusDi::new(3).unwrap(); + let last = di.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_restores_initial_state() { + let candles: Vec = (0..6) + .map(|i| { + let base = 140.0 - f64::from(i) * 2.0; + c(base + 0.5, base - 1.0, base - 0.5) + }) + .collect(); + let mut di = MinusDi::new(3).unwrap(); + let _ = di.batch(&candles); + assert!(di.is_ready()); + di.reset(); + assert!(!di.is_ready()); + assert_eq!(di.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/minus_dm.rs b/crates/wickra-core/src/indicators/minus_dm.rs new file mode 100644 index 0000000..35a2cc1 --- /dev/null +++ b/crates/wickra-core/src/indicators/minus_dm.rs @@ -0,0 +1,195 @@ +//! Minus Directional Movement (-DM), Wilder-smoothed. + +use crate::error::{Error, Result}; +use crate::indicators::adx::directional_movement; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wilder's Minus Directional Movement (`MINUS_DM`). +/// +/// The raw minus directional movement of a bar is `max(low_prev − low, 0)` when +/// the down-move exceeds the up-move `high − high_prev`, and `0` otherwise. This +/// indicator returns the Wilder-smoothed running total of that raw `-DM` over +/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and +/// [`MinusDi`](crate::MinusDi). +/// +/// The first `period` raw values seed the sum; from then on each update applies +/// the Wilder recursion `smoothed − smoothed / period + raw`. Because a bar's +/// directional movement needs the previous bar, the first value is emitted after +/// `period + 1` candles. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MinusDm}; +/// +/// let mut indicator = MinusDm::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 - f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MinusDm { + period: usize, + prev: Option, + seed: f64, + seed_count: usize, + smooth: Option, +} + +impl MinusDm { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + seed: 0.0, + seed_count: 0, + smooth: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for MinusDm { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + self.prev = Some(candle); + + let (_, minus_dm) = directional_movement(&prev, &candle); + let n = self.period as f64; + + if let Some(s) = self.smooth { + let s_new = s - s / n + minus_dm; + self.smooth = Some(s_new); + return Some(s_new); + } + + self.seed += minus_dm; + self.seed_count += 1; + if self.seed_count < self.period { + return None; + } + self.smooth = Some(self.seed); + Some(self.seed) + } + + fn reset(&mut self) { + self.prev = None; + self.seed = 0.0; + self.seed_count = 0; + self.smooth = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.smooth.is_some() + } + + fn name(&self) -> &'static str { + "MINUS_DM" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Candle with explicit high/low; open and close are pinned to `cl`. + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(MinusDm::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let dm = MinusDm::new(7).unwrap(); + assert_eq!(dm.period(), 7); + assert_eq!(dm.name(), "MINUS_DM"); + assert_eq!(dm.warmup_period(), 7); + assert!(!dm.is_ready()); + } + + #[test] + fn seeds_then_smooths_a_constant_minus_dm() { + // Low falls by 1 each bar (down = +1); high falls by 0.5 each bar, so the + // up-move is negative and -DM equals the down-move (1.0) on every bar. + let candles: Vec = (0..5) + .map(|i| { + c( + 20.0 - 0.5 * f64::from(i), + 18.0 - f64::from(i), + 19.0 - f64::from(i), + ) + }) + .collect(); + let mut dm = MinusDm::new(3).unwrap(); + let out: Vec> = dm.batch(&candles); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_eq!(out[2], None); + // Seed = sum of three unit -DM values. + assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12); + // Wilder step: 3 - 3/3 + 1 = 3. + assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12); + assert!(dm.is_ready()); + } + + #[test] + fn up_moves_contribute_zero() { + // Strict uptrend: lows rise, so every raw -DM is zero. + let candles: Vec = (0..6) + .map(|i| c(20.0 + f64::from(i), 5.0 + f64::from(i), 12.0 + f64::from(i))) + .collect(); + let mut dm = MinusDm::new(3).unwrap(); + let last = dm.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_restores_initial_state() { + let candles: Vec = (0..5) + .map(|i| { + c( + 20.0 - 0.5 * f64::from(i), + 18.0 - f64::from(i), + 19.0 - f64::from(i), + ) + }) + .collect(); + let mut dm = MinusDm::new(3).unwrap(); + let _ = dm.batch(&candles); + assert!(dm.is_ready()); + dm.reset(); + assert!(!dm.is_ready()); + assert_eq!(dm.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs new file mode 100644 index 0000000..a3bbaf2 --- /dev/null +++ b/crates/wickra-core/src/indicators/mod.rs @@ -0,0 +1,1711 @@ +//! Built-in indicators. Every indicator implements [`crate::Indicator`]. +//! +//! Modules are listed alphabetically; the canonical family taxonomy lives in +//! [`FAMILIES`]. Every public name is re-exported flat from this module and +//! from the crate root for convenience. + +// Internal shared building block for the chart- and harmonic-pattern detectors. +// Declared `pub(crate)` (not `mod`) so it is excluded from the public-catalogue +// counter (`grep -c '^mod '`) and re-exported nowhere. +pub(crate) mod pattern_swing; + +mod abandoned_baby; +mod abcd; +mod absolute_breadth_index; +mod acceleration_bands; +mod accelerator_oscillator; +mod ad_oscillator; +mod ad_volume_line; +mod adaptive_cci; +mod adaptive_cycle; +mod adaptive_laguerre_filter; +mod adaptive_rsi; +mod adl; +mod advance_block; +mod advance_decline; +mod advance_decline_ratio; +mod adx; +mod adxr; +mod alligator; +mod alma; +mod alpha; +mod amihud_illiquidity; +mod anchored_rsi; +mod anchored_vwap; +mod andrews_pitchfork; +mod apo; +mod aroon; +mod aroon_oscillator; +mod atr; +mod atr_bands; +mod atr_ratchet; +mod atr_trailing_stop; +mod auto_fib; +mod autocorrelation; +mod autocorrelation_periodogram; +mod average_daily_range; +mod average_drawdown; +mod avg_price; +mod awesome_oscillator; +mod awesome_oscillator_histogram; +mod balance_of_power; +mod bandpass_filter; +mod bat; +mod belt_hold; +mod beta; +mod beta_neutral_spread; +mod better_volume; +mod bipower_variation; +mod body_size_pct; +mod bollinger; +mod bollinger_bandwidth; +mod bomar_bands; +mod breadth_thrust; +mod breakaway; +mod bullish_percent_index; +mod burke_ratio; +mod butterfly; +mod calendar_spread; +mod calmar_ratio; +mod camarilla_pivots; +mod candle_volume; +mod cci; +mod center_of_gravity; +mod central_pivot_range; +mod cfo; +mod chaikin_oscillator; +mod chaikin_volatility; +mod chande_kroll_stop; +mod chandelier_exit; +mod choppiness_index; +mod classic_pivots; +mod close_vs_open; +mod closing_marubozu; +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; +mod connors_rsi; +mod coppock; +mod correlation_trend_indicator; +mod counterattack; +mod crab; +mod cumulative_volume_index; +mod cup_and_handle; +mod cvd; +mod cybernetic_cycle; +mod cypher; +mod day_of_week_profile; +mod decycler; +mod decycler_oscillator; +mod dema; +mod demand_index; +mod demark_pivots; +mod depth_slope; +mod derivative_oscillator; +mod detrended_std_dev; +mod disparity_index; +mod distance_ssd; +mod doji; +mod doji_star; +mod dollar_bars; +mod donchian; +mod donchian_stop; +mod double_bollinger; +mod double_top_bottom; +mod downside_gap_three_methods; +mod dpo; +mod dragonfly_doji; +mod drawdown_duration; +mod dumpling_top; +mod dx; +mod dynamic_momentum_index; +mod ease_of_movement; +mod effective_spread; +mod ehlers_stochastic; +mod ehma; +mod elder_impulse; +mod elder_ray; +mod elder_safezone; +mod ema; +mod empirical_mode_decomposition; +mod engulfing; +mod equivolume; +mod estimated_leverage_ratio; +mod even_better_sinewave; +mod evening_doji_star; +mod evwma; +mod ewma_volatility; +mod expectancy; +mod falling_three_methods; +mod fama; +mod fib_arcs; +mod fib_channel; +mod fib_confluence; +mod fib_extension; +mod fib_fan; +mod fib_projection; +mod fib_retracement; +mod fib_time_zones; +mod fibonacci_pivots; +mod fisher_rsi; +mod fisher_transform; +mod flag_pennant; +mod footprint; +mod force_index; +mod fractal_chaos_bands; +mod frama; +mod fry_pan_bottom; +mod funding_basis; +mod funding_implied_apr; +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; +mod gartley; +mod gator_oscillator; +mod generalized_dema; +mod geometric_ma; +mod golden_pocket; +mod granger_causality; +mod gravestone_doji; +mod hammer; +mod hanging_man; +mod harami; +mod harami_cross; +mod hasbrouck_information_share; +mod head_and_shoulders; +mod heikin_ashi; +mod heikin_ashi_oscillator; +mod high_low_index; +mod high_low_range; +mod high_low_volume_nodes; +mod high_wave; +mod highpass_filter; +mod hikkake; +mod hikkake_modified; +mod hilbert_dominant_cycle; +mod hilo_activator; +mod historical_volatility; +mod hma; +mod holt_winters; +mod homing_pigeon; +mod ht_dcphase; +mod ht_phasor; +mod ht_trendmode; +mod hurst_channel; +mod hurst_exponent; +mod ichimoku; +mod identical_three_crows; +mod imbalance_bars; +mod in_neck; +mod inertia; +mod information_ratio; +mod initial_balance; +mod instantaneous_trendline; +mod intraday_intensity; +mod intraday_momentum_index; +mod intraday_volatility_profile; +mod inverse_fisher_transform; +mod inverted_hammer; +mod jarque_bera; +mod jma; +mod jump_indicator; +mod k_ratio; +mod kagi_bars; +mod kalman_hedge_ratio; +mod kama; +mod kase_devstop; +mod kase_permission_stochastic; +mod kelly_criterion; +mod keltner; +mod kendall_tau; +mod kicking; +mod kicking_by_length; +mod kst; +mod kurtosis; +mod kvo; +mod kyles_lambda; +mod ladder_bottom; +mod laguerre_rsi; +mod lead_lag_cross_correlation; +mod linreg; +mod linreg_angle; +mod linreg_channel; +mod linreg_intercept; +mod linreg_slope; +mod liquidation_features; +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; +mod macd_fix; +mod macd_histogram; +mod mama; +mod market_facilitation_index; +mod martin_ratio; +mod marubozu; +mod mass_index; +mod mat_hold; +mod matching_low; +mod max_drawdown; +mod mcclellan_oscillator; +mod mcclellan_summation_index; +mod mcginley_dynamic; +mod median_absolute_deviation; +mod median_channel; +mod median_ma; +mod median_price; +mod mfi; +mod microprice; +mod mid_point; +mod mid_price; +mod minus_di; +mod minus_dm; +mod modified_ma_stop; +mod mom; +mod morning_doji_star; +mod morning_evening_star; +mod murrey_math_lines; +mod naked_poc; +mod natr; +mod new_highs_new_lows; +mod new_price_lines; +mod nrtr; +mod nvi; +mod ob_imbalance_full; +mod ob_imbalance_top1; +mod ob_imbalance_topn; +mod obv; +mod oi_delta; +mod oi_price_divergence; +mod oi_to_volume_ratio; +mod oi_weighted; +mod omega_ratio; +mod on_neck; +mod open_interest_momentum; +mod opening_marubozu; +mod opening_range; +mod order_flow_imbalance; +mod ou_half_life; +mod overnight_gap; +mod overnight_intraday_return; +mod pain_index; +mod pair_spread_zscore; +mod pairwise_beta; +mod parkinson; +mod pearson_correlation; +mod percent_above_ma; +mod percent_b; +mod percentage_trailing_stop; +mod perpetual_premium_index; +mod pgo; +mod piercing_dark_cloud; +mod pin; +mod pivot_reversal; +mod plus_di; +mod plus_dm; +mod pmo; +mod point_and_figure_bars; +mod polarized_fractal_efficiency; +mod ppo; +mod ppo_histogram; +mod profile_shape; +mod profit_factor; +mod projection_bands; +mod projection_oscillator; +mod psar; +mod pvi; +mod qqe; +mod qstick; +mod quartile_bands; +mod quoted_spread; +mod r_squared; +mod range_bars; +mod realized_spread; +mod realized_volatility; +mod recovery_factor; +mod rectangle_range; +mod reflex; +mod regime_label; +mod relative_strength_ab; +mod renko_bars; +mod renko_trailing_stop; +mod rickshaw_man; +mod rising_three_methods; +mod rmi; +mod roc; +mod rocp; +mod rocr; +mod rocr100; +mod rogers_satchell; +mod roll_measure; +mod rolling_correlation; +mod rolling_covariance; +mod rolling_iqr; +mod rolling_min_max_scaler; +mod rolling_percentile_rank; +mod rolling_quantile; +mod roofing_filter; +mod rsi; +mod rsx; +mod run_bars; +mod rvi; +mod rvi_volatility; +mod rwi; +mod sample_entropy; +mod sar_ext; +mod seasonal_z_score; +mod separating_lines; +mod session_high_low; +mod session_range; +mod session_vwap; +mod shannon_entropy; +mod shark; +mod sharpe_ratio; +mod shooting_star; +mod short_line; +mod signed_volume; +mod sine_wave; +mod sine_weighted_ma; +mod single_prints; +mod skewness; +mod sma; +mod smi; +mod smma; +mod smoothed_heikin_ashi; +mod sortino_ratio; +mod spearman_correlation; +mod spinning_top; +mod spread_ar1_coefficient; +mod spread_bollinger_bands; +mod spread_hurst; +mod stalled_pattern; +mod standard_error; +mod standard_error_bands; +mod starc_bands; +mod stc; +mod std_dev; +mod step_trailing_stop; +mod sterling_ratio; +mod stick_sandwich; +mod stoch_rsi; +mod stochastic; +mod stochastic_cci; +mod super_smoother; +mod super_trend; +mod t3; +mod tail_ratio; +mod taker_buy_sell_ratio; +mod takuri; +mod tasuki_gap; +mod td_camouflage; +mod td_clop; +mod td_clopwin; +mod td_combo; +mod td_countdown; +mod td_demarker; +mod td_differential; +mod td_dwave; +mod td_lines; +mod td_moving_average; +mod td_open; +mod td_pressure; +mod td_propulsion; +mod td_range_projection; +mod td_rei; +mod td_risk_level; +mod td_sequential; +mod td_setup; +mod td_trap; +mod tema; +mod term_structure_basis; +mod three_drives; +mod three_inside; +mod three_line_break; +mod three_line_break_bars; +mod three_line_strike; +mod three_outside; +mod three_soldiers_or_crows; +mod three_stars_in_south; +mod thrusting; +mod tick_bars; +mod tick_index; +mod tii; +mod time_based_stop; +mod time_of_day_return_profile; +mod tower_top_bottom; +mod tpo_profile; +mod trade_imbalance; +mod trade_sign_autocorrelation; +mod trade_volume_index; +mod trend_label; +mod trend_strength_index; +mod trendflex; +mod treynor_ratio; +mod triangle; +mod trima; +mod trin; +mod triple_top_bottom; +mod tristar; +mod trix; +mod true_range; +mod tsf; +mod tsf_oscillator; +mod tsi; +mod tsv; +mod ttm_squeeze; +mod ttm_trend; +mod turn_of_month; +mod tweezer; +mod twiggs_money_flow; +mod two_crows; +mod typical_price; +mod ulcer_index; +mod ultimate_oscillator; +mod unique_three_river; +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; +mod variance_ratio; +mod vertical_horizontal_filter; +mod vidya; +mod volatility_cone; +mod volatility_of_volatility; +mod volatility_ratio; +mod volty_stop; +mod volume_bars; +mod volume_by_time_profile; +mod volume_oscillator; +mod volume_profile; +mod volume_rsi; +mod volume_weighted_macd; +mod volume_weighted_sr; +mod vortex; +mod vpin; +mod vpt; +mod vwap; +mod vwap_stddev_bands; +mod vwma; +mod vzo; +mod wad; +mod wave_pm; +mod wave_trend; +mod wedge; +mod weighted_close; +mod wick_ratio; +mod williams_fractals; +mod williams_r; +mod win_rate; +mod wma; +mod woodie_pivots; +mod yang_zhang; +mod yoyo_exit; +mod z_score; +mod zero_lag_macd; +mod zig_zag; +mod zlema; + +pub use abandoned_baby::AbandonedBaby; +pub use abcd::Abcd; +pub use absolute_breadth_index::AbsoluteBreadthIndex; +pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput}; +pub use accelerator_oscillator::AcceleratorOscillator; +pub use ad_oscillator::AdOscillator; +pub use ad_volume_line::AdVolumeLine; +pub use adaptive_cci::AdaptiveCci; +pub use adaptive_cycle::AdaptiveCycle; +pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter; +pub use adaptive_rsi::AdaptiveRsi; +pub use adl::Adl; +pub use advance_block::AdvanceBlock; +pub use advance_decline::AdvanceDecline; +pub use advance_decline_ratio::AdvanceDeclineRatio; +pub use adx::{Adx, AdxOutput}; +pub use adxr::Adxr; +pub use alligator::{Alligator, AlligatorOutput}; +pub use alma::Alma; +pub use alpha::Alpha; +pub use amihud_illiquidity::AmihudIlliquidity; +pub use anchored_rsi::AnchoredRsi; +pub use anchored_vwap::AnchoredVwap; +pub use andrews_pitchfork::{AndrewsPitchfork, AndrewsPitchforkOutput}; +pub use apo::Apo; +pub use aroon::{Aroon, AroonOutput}; +pub use aroon_oscillator::AroonOscillator; +pub use atr::Atr; +pub use atr_bands::{AtrBands, AtrBandsOutput}; +pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput}; +pub use atr_trailing_stop::AtrTrailingStop; +pub use auto_fib::{AutoFib, AutoFibOutput}; +pub use autocorrelation::Autocorrelation; +pub use autocorrelation_periodogram::AutocorrelationPeriodogram; +pub use average_daily_range::AverageDailyRange; +pub use average_drawdown::AverageDrawdown; +pub use avg_price::AvgPrice; +pub use awesome_oscillator::AwesomeOscillator; +pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram; +pub use balance_of_power::BalanceOfPower; +pub use bandpass_filter::BandpassFilter; +pub use bat::Bat; +pub use belt_hold::BeltHold; +pub use beta::Beta; +pub use beta_neutral_spread::BetaNeutralSpread; +pub use better_volume::BetterVolume; +pub use bipower_variation::BipowerVariation; +pub use body_size_pct::BodySizePct; +pub use bollinger::{BollingerBands, BollingerOutput}; +pub use bollinger_bandwidth::BollingerBandwidth; +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; +pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput}; +pub use candle_volume::{CandleVolume, CandleVolumeOutput}; +pub use cci::Cci; +pub use center_of_gravity::CenterOfGravity; +pub use central_pivot_range::{CentralPivotRange, CentralPivotRangeOutput}; +pub use cfo::Cfo; +pub use chaikin_oscillator::ChaikinOscillator; +pub use chaikin_volatility::ChaikinVolatility; +pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput}; +pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput}; +pub use choppiness_index::ChoppinessIndex; +pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput}; +pub use close_vs_open::CloseVsOpen; +pub use closing_marubozu::ClosingMarubozu; +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; +pub use connors_rsi::ConnorsRsi; +pub use coppock::Coppock; +pub use correlation_trend_indicator::CorrelationTrendIndicator; +pub use counterattack::Counterattack; +pub use crab::Crab; +pub use cumulative_volume_index::CumulativeVolumeIndex; +pub use cup_and_handle::CupAndHandle; +pub use cvd::CumulativeVolumeDelta; +pub use cybernetic_cycle::CyberneticCycle; +pub use cypher::Cypher; +pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput}; +pub use decycler::Decycler; +pub use decycler_oscillator::DecyclerOscillator; +pub use dema::Dema; +pub use demand_index::DemandIndex; +pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput}; +pub use depth_slope::DepthSlope; +pub use derivative_oscillator::DerivativeOscillator; +pub use detrended_std_dev::DetrendedStdDev; +pub use disparity_index::DisparityIndex; +pub use distance_ssd::DistanceSsd; +pub use doji::Doji; +pub use doji_star::DojiStar; +pub use dollar_bars::{DollarBar, DollarBars}; +pub use donchian::{Donchian, DonchianOutput}; +pub use donchian_stop::{DonchianStop, DonchianStopOutput}; +pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; +pub use double_top_bottom::DoubleTopBottom; +pub use downside_gap_three_methods::DownsideGapThreeMethods; +pub use dpo::Dpo; +pub use dragonfly_doji::DragonflyDoji; +pub use drawdown_duration::DrawdownDuration; +pub use dumpling_top::DumplingTop; +pub use dx::Dx; +pub use dynamic_momentum_index::DynamicMomentumIndex; +pub use ease_of_movement::EaseOfMovement; +pub use effective_spread::EffectiveSpread; +pub use ehlers_stochastic::EhlersStochastic; +pub use ehma::Ehma; +pub use elder_impulse::ElderImpulse; +pub use elder_ray::{ElderRay, ElderRayOutput}; +pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput}; +pub use ema::Ema; +pub use empirical_mode_decomposition::EmpiricalModeDecomposition; +pub use engulfing::Engulfing; +pub use equivolume::{Equivolume, EquivolumeOutput}; +pub use estimated_leverage_ratio::EstimatedLeverageRatio; +pub use even_better_sinewave::EvenBetterSinewave; +pub use evening_doji_star::EveningDojiStar; +pub use evwma::Evwma; +pub use ewma_volatility::EwmaVolatility; +pub use expectancy::Expectancy; +pub use falling_three_methods::FallingThreeMethods; +pub use fama::Fama; +pub use fib_arcs::{FibArcs, FibArcsOutput}; +pub use fib_channel::{FibChannel, FibChannelOutput}; +pub use fib_confluence::{FibConfluence, FibConfluenceOutput}; +pub use fib_extension::{FibExtension, FibExtensionOutput}; +pub use fib_fan::{FibFan, FibFanOutput}; +pub use fib_projection::{FibProjection, FibProjectionOutput}; +pub use fib_retracement::{FibRetracement, FibRetracementOutput}; +pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput}; +pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput}; +pub use fisher_rsi::FisherRsi; +pub use fisher_transform::FisherTransform; +pub use flag_pennant::FlagPennant; +pub use footprint::{Footprint, FootprintLevel, FootprintOutput}; +pub use force_index::ForceIndex; +pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; +pub use frama::Frama; +pub use fry_pan_bottom::FryPanBottom; +pub use funding_basis::FundingBasis; +pub use funding_implied_apr::FundingImpliedApr; +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; +pub use gartley::Gartley; +pub use gator_oscillator::{GatorOscillator, GatorOscillatorOutput}; +pub use generalized_dema::GeneralizedDema; +pub use geometric_ma::GeometricMa; +pub use golden_pocket::{GoldenPocket, GoldenPocketOutput}; +pub use granger_causality::GrangerCausality; +pub use gravestone_doji::GravestoneDoji; +pub use hammer::Hammer; +pub use hanging_man::HangingMan; +pub use harami::Harami; +pub use harami_cross::HaramiCross; +pub use hasbrouck_information_share::HasbrouckInformationShare; +pub use head_and_shoulders::HeadAndShoulders; +pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput}; +pub use heikin_ashi_oscillator::HeikinAshiOscillator; +pub use high_low_index::HighLowIndex; +pub use high_low_range::HighLowRange; +pub use high_low_volume_nodes::{HighLowVolumeNodes, HighLowVolumeNodesOutput}; +pub use high_wave::HighWave; +pub use highpass_filter::HighpassFilter; +pub use hikkake::Hikkake; +pub use hikkake_modified::HikkakeModified; +pub use hilbert_dominant_cycle::HilbertDominantCycle; +pub use hilo_activator::HiLoActivator; +pub use historical_volatility::HistoricalVolatility; +pub use hma::Hma; +pub use holt_winters::HoltWinters; +pub use homing_pigeon::HomingPigeon; +pub use ht_dcphase::HtDcPhase; +pub use ht_phasor::{HtPhasor, HtPhasorOutput}; +pub use ht_trendmode::HtTrendMode; +pub use hurst_channel::{HurstChannel, HurstChannelOutput}; +pub use hurst_exponent::HurstExponent; +pub use ichimoku::{Ichimoku, IchimokuOutput}; +pub use identical_three_crows::IdenticalThreeCrows; +pub use imbalance_bars::{ImbalanceBar, ImbalanceBars}; +pub use in_neck::InNeck; +pub use inertia::Inertia; +pub use information_ratio::InformationRatio; +pub use initial_balance::{InitialBalance, InitialBalanceOutput}; +pub use instantaneous_trendline::InstantaneousTrendline; +pub use intraday_intensity::IntradayIntensity; +pub use intraday_momentum_index::IntradayMomentumIndex; +pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput}; +pub use inverse_fisher_transform::InverseFisherTransform; +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; +pub use kase_devstop::{KaseDevStop, KaseDevStopOutput}; +pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput}; +pub use kelly_criterion::KellyCriterion; +pub use keltner::{Keltner, KeltnerOutput}; +pub use kendall_tau::KendallTau; +pub use kicking::Kicking; +pub use kicking_by_length::KickingByLength; +pub use kst::{Kst, KstOutput}; +pub use kurtosis::Kurtosis; +pub use kvo::Kvo; +pub use kyles_lambda::KylesLambda; +pub use ladder_bottom::LadderBottom; +pub use laguerre_rsi::LaguerreRsi; +pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput}; +pub use linreg::LinearRegression; +pub use linreg_angle::LinRegAngle; +pub use linreg_channel::{LinRegChannel, LinRegChannelOutput}; +pub use linreg_intercept::LinRegIntercept; +pub use linreg_slope::LinRegSlope; +pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput}; +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}; +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; +pub use matching_low::MatchingLow; +pub use max_drawdown::MaxDrawdown; +pub use mcclellan_oscillator::McClellanOscillator; +pub use mcclellan_summation_index::McClellanSummationIndex; +pub use mcginley_dynamic::McGinleyDynamic; +pub use median_absolute_deviation::MedianAbsoluteDeviation; +pub use median_channel::{MedianChannel, MedianChannelOutput}; +pub use median_ma::MedianMa; +pub use median_price::MedianPrice; +pub use mfi::Mfi; +pub use microprice::Microprice; +pub use mid_point::MidPoint; +pub use mid_price::MidPrice; +pub use minus_di::MinusDi; +pub use minus_dm::MinusDm; +pub use modified_ma_stop::{ModifiedMaStop, ModifiedMaStopOutput}; +pub use mom::Mom; +pub use morning_doji_star::MorningDojiStar; +pub use morning_evening_star::MorningEveningStar; +pub use murrey_math_lines::{MurreyMathLines, MurreyMathLinesOutput}; +pub use naked_poc::NakedPoc; +pub use natr::Natr; +pub use new_highs_new_lows::NewHighsNewLows; +pub use new_price_lines::NewPriceLines; +pub use nrtr::{Nrtr, NrtrOutput}; +pub use nvi::Nvi; +pub use ob_imbalance_full::OrderBookImbalanceFull; +pub use ob_imbalance_top1::OrderBookImbalanceTop1; +pub use ob_imbalance_topn::OrderBookImbalanceTopN; +pub use obv::Obv; +pub use oi_delta::OpenInterestDelta; +pub use oi_price_divergence::OIPriceDivergence; +pub use oi_to_volume_ratio::OiToVolumeRatio; +pub use oi_weighted::OIWeighted; +pub use omega_ratio::OmegaRatio; +pub use on_neck::OnNeck; +pub use open_interest_momentum::OpenInterestMomentum; +pub use opening_marubozu::OpeningMarubozu; +pub use opening_range::{OpeningRange, OpeningRangeOutput}; +pub use order_flow_imbalance::OrderFlowImbalance; +pub use ou_half_life::OuHalfLife; +pub use overnight_gap::OvernightGap; +pub use overnight_intraday_return::{OvernightIntradayReturn, OvernightIntradayReturnOutput}; +pub use pain_index::PainIndex; +pub use pair_spread_zscore::PairSpreadZScore; +pub use pairwise_beta::PairwiseBeta; +pub use parkinson::ParkinsonVolatility; +pub use pearson_correlation::PearsonCorrelation; +pub use percent_above_ma::PercentAboveMa; +pub use percent_b::PercentB; +pub use percentage_trailing_stop::PercentageTrailingStop; +pub use perpetual_premium_index::PerpetualPremiumIndex; +pub use pgo::Pgo; +pub use piercing_dark_cloud::PiercingDarkCloud; +pub use pin::Pin; +pub use pivot_reversal::PivotReversal; +pub use plus_di::PlusDi; +pub use plus_dm::PlusDm; +pub use pmo::Pmo; +pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars}; +pub use polarized_fractal_efficiency::PolarizedFractalEfficiency; +pub use ppo::Ppo; +pub use ppo_histogram::PpoHistogram; +pub use profile_shape::ProfileShape; +pub use profit_factor::ProfitFactor; +pub use projection_bands::{ProjectionBands, ProjectionBandsOutput}; +pub use projection_oscillator::ProjectionOscillator; +pub use psar::Psar; +pub use pvi::Pvi; +pub use qqe::{Qqe, QqeOutput}; +pub use qstick::Qstick; +pub use quartile_bands::{QuartileBands, QuartileBandsOutput}; +pub use quoted_spread::QuotedSpread; +pub use r_squared::RSquared; +pub use range_bars::{RangeBar, RangeBars}; +pub use realized_spread::RealizedSpread; +pub use realized_volatility::RealizedVolatility; +pub use recovery_factor::RecoveryFactor; +pub use rectangle_range::RectangleRange; +pub use reflex::Reflex; +pub use regime_label::RegimeLabel; +pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput}; +pub use renko_bars::{RenkoBars, RenkoBrick}; +pub use renko_trailing_stop::RenkoTrailingStop; +pub use rickshaw_man::RickshawMan; +pub use rising_three_methods::RisingThreeMethods; +pub use rmi::Rmi; +pub use roc::Roc; +pub use rocp::Rocp; +pub use rocr::Rocr; +pub use rocr100::Rocr100; +pub use rogers_satchell::RogersSatchellVolatility; +pub use roll_measure::RollMeasure; +pub use rolling_correlation::RollingCorrelation; +pub use rolling_covariance::RollingCovariance; +pub use rolling_iqr::RollingIqr; +pub use rolling_min_max_scaler::RollingMinMaxScaler; +pub use rolling_percentile_rank::RollingPercentileRank; +pub use rolling_quantile::RollingQuantile; +pub use roofing_filter::RoofingFilter; +pub use rsi::Rsi; +pub use rsx::Rsx; +pub use run_bars::{RunBar, RunBars}; +pub use rvi::Rvi; +pub use rvi_volatility::RviVolatility; +pub use rwi::{Rwi, RwiOutput}; +pub use sample_entropy::SampleEntropy; +pub use sar_ext::SarExt; +pub use seasonal_z_score::SeasonalZScore; +pub use separating_lines::SeparatingLines; +pub use session_high_low::{SessionHighLow, SessionHighLowOutput}; +pub use session_range::{SessionRange, SessionRangeOutput}; +pub use session_vwap::SessionVwap; +pub use shannon_entropy::ShannonEntropy; +pub use shark::Shark; +pub use sharpe_ratio::SharpeRatio; +pub use shooting_star::ShootingStar; +pub use short_line::ShortLine; +pub use signed_volume::SignedVolume; +pub use sine_wave::SineWave; +pub use sine_weighted_ma::SineWeightedMa; +pub use single_prints::SinglePrints; +pub use skewness::Skewness; +pub use sma::Sma; +pub use smi::Smi; +pub use smma::Smma; +pub use smoothed_heikin_ashi::{SmoothedHeikinAshi, SmoothedHeikinAshiOutput}; +pub use sortino_ratio::SortinoRatio; +pub use spearman_correlation::SpearmanCorrelation; +pub use spinning_top::SpinningTop; +pub use spread_ar1_coefficient::SpreadAr1Coefficient; +pub use spread_bollinger_bands::{SpreadBollingerBands, SpreadBollingerBandsOutput}; +pub use spread_hurst::SpreadHurst; +pub use stalled_pattern::StalledPattern; +pub use standard_error::StandardError; +pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput}; +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}; +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; +pub use td_camouflage::TdCamouflage; +pub use td_clop::TdClop; +pub use td_clopwin::TdClopwin; +pub use td_combo::TdCombo; +pub use td_countdown::TdCountdown; +pub use td_demarker::TdDeMarker; +pub use td_differential::TdDifferential; +pub use td_dwave::TdDWave; +pub use td_lines::{TdLines, TdLinesOutput}; +pub use td_moving_average::{TdMovingAverage, TdMovingAverageOutput}; +pub use td_open::TdOpen; +pub use td_pressure::TdPressure; +pub use td_propulsion::TdPropulsion; +pub use td_range_projection::{TdRangeProjection, TdRangeProjectionOutput}; +pub use td_rei::TdRei; +pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput}; +pub use td_sequential::{TdSequential, TdSequentialOutput}; +pub use td_setup::TdSetup; +pub use td_trap::TdTrap; +pub use tema::Tema; +pub use term_structure_basis::TermStructureBasis; +pub use three_drives::ThreeDrives; +pub use three_inside::ThreeInside; +pub use three_line_break::ThreeLineBreak; +pub use three_line_break_bars::{LineBreakBar, ThreeLineBreakBars}; +pub use three_line_strike::ThreeLineStrike; +pub use three_outside::ThreeOutside; +pub use three_soldiers_or_crows::ThreeSoldiersOrCrows; +pub use three_stars_in_south::ThreeStarsInSouth; +pub use thrusting::Thrusting; +pub use tick_bars::{TickBar, TickBars}; +pub use tick_index::TickIndex; +pub use tii::Tii; +pub use time_based_stop::TimeBasedStop; +pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput}; +pub use tower_top_bottom::TowerTopBottom; +pub use tpo_profile::{TpoProfile, TpoProfileOutput}; +pub use trade_imbalance::TradeImbalance; +pub use trade_sign_autocorrelation::TradeSignAutocorrelation; +pub use trade_volume_index::TradeVolumeIndex; +pub use trend_label::TrendLabel; +pub use trend_strength_index::TrendStrengthIndex; +pub use trendflex::Trendflex; +pub use treynor_ratio::TreynorRatio; +pub use triangle::Triangle; +pub use trima::Trima; +pub use trin::Trin; +pub use triple_top_bottom::TripleTopBottom; +pub use tristar::Tristar; +pub use trix::Trix; +pub use true_range::TrueRange; +pub use tsf::Tsf; +pub use tsf_oscillator::TsfOscillator; +pub use tsi::Tsi; +pub use tsv::Tsv; +pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput}; +pub use ttm_trend::TtmTrend; +pub use turn_of_month::TurnOfMonth; +pub use tweezer::Tweezer; +pub use twiggs_money_flow::TwiggsMoneyFlow; +pub use two_crows::TwoCrows; +pub use typical_price::TypicalPrice; +pub use ulcer_index::UlcerIndex; +pub use ultimate_oscillator::UltimateOscillator; +pub use unique_three_river::UniqueThreeRiver; +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; +pub use variance_ratio::VarianceRatio; +pub use vertical_horizontal_filter::VerticalHorizontalFilter; +pub use vidya::Vidya; +pub use volatility_cone::{VolatilityCone, VolatilityConeOutput}; +pub use volatility_of_volatility::VolatilityOfVolatility; +pub use volatility_ratio::VolatilityRatio; +pub use volty_stop::VoltyStop; +pub use volume_bars::{VolumeBar, VolumeBars}; +pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput}; +pub use volume_oscillator::VolumeOscillator; +pub use volume_profile::{VolumeProfile, VolumeProfileOutput}; +pub use volume_rsi::VolumeRsi; +pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput}; +pub use volume_weighted_sr::{VolumeWeightedSr, VolumeWeightedSrOutput}; +pub use vortex::{Vortex, VortexOutput}; +pub use vpin::Vpin; +pub use vpt::VolumePriceTrend; +pub use vwap::{RollingVwap, Vwap}; +pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput}; +pub use vwma::Vwma; +pub use vzo::Vzo; +pub use wad::Wad; +pub use wave_pm::WavePm; +pub use wave_trend::{WaveTrend, WaveTrendOutput}; +pub use wedge::Wedge; +pub use weighted_close::WeightedClose; +pub use wick_ratio::WickRatio; +pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput}; +pub use williams_r::WilliamsR; +pub use win_rate::WinRate; +pub use wma::Wma; +pub use woodie_pivots::{WoodiePivots, WoodiePivotsOutput}; +pub use yang_zhang::YangZhangVolatility; +pub use yoyo_exit::YoyoExit; +pub use z_score::ZScore; +pub use zero_lag_macd::{ZeroLagMacd, ZeroLagMacdOutput}; +pub use zig_zag::{ZigZag, ZigZagOutput}; +pub use zlema::Zlema; + +/// Family classification of every built-in indicator. The (family, +/// indicators) list is the single source of truth used by `family_tests` +/// below; README and Wiki taxonomy tables should be kept in sync with it. +/// +/// Each indicator appears in exactly one family. Names are the public +/// struct identifiers re-exported from this module (and the crate root). +pub const FAMILIES: &[(&str, &[&str])] = &[ + ( + "Moving Averages", + &[ + "Sma", + "Ema", + "Wma", + "Dema", + "Tema", + "Hma", + "Kama", + "Smma", + "Trima", + "Zlema", + "T3", + "Vwma", + "Alma", + "McGinleyDynamic", + "Frama", + "Vidya", + "Jma", + "Alligator", + "Evwma", + "SineWeightedMa", + "GeometricMa", + "Ehma", + "MedianMa", + "AdaptiveLaguerreFilter", + "GeneralizedDema", + "HoltWinters", + ], + ), + ( + "Momentum Oscillators", + &[ + "Rsi", + "AnchoredRsi", + "Stochastic", + "Cci", + "Roc", + "WilliamsR", + "Mfi", + "AwesomeOscillator", + "Mom", + "Cmo", + "Tsi", + "Pmo", + "StochRsi", + "UltimateOscillator", + "Rvi", + "Pgo", + "Kst", + "Smi", + "LaguerreRsi", + "ConnorsRsi", + "Inertia", + "Rocp", + "Rocr", + "Rocr100", + "DisparityIndex", + "FisherRsi", + "Rsx", + "DynamicMomentumIndex", + "StochasticCci", + "Rmi", + "DerivativeOscillator", + "ElderRay", + "IntradayMomentumIndex", + "Qqe", + ], + ), + ( + "Trend & Directional", + &[ + "MacdIndicator", + "MacdFix", + "MacdExt", + "Adx", + "Adxr", + "Aroon", + "Trix", + "AroonOscillator", + "Vortex", + "Rwi", + "Tii", + "WaveTrend", + "MassIndex", + "ChoppinessIndex", + "VerticalHorizontalFilter", + "PlusDm", + "MinusDm", + "PlusDi", + "MinusDi", + "Dx", + "TrendLabel", + "TtmTrend", + "TrendStrengthIndex", + "Qstick", + "PolarizedFractalEfficiency", + "WavePm", + "GatorOscillator", + "KasePermissionStochastic", + ], + ), + ( + "Price Oscillators", + &[ + "Ppo", + "Dpo", + "Coppock", + "AcceleratorOscillator", + "BalanceOfPower", + "Apo", + "AwesomeOscillatorHistogram", + "Cfo", + "ZeroLagMacd", + "ElderImpulse", + "Stc", + "TsfOscillator", + "MacdHistogram", + "PpoHistogram", + ], + ), + ( + "Volatility & Bands", + &[ + "Atr", + "BollingerBands", + "Keltner", + "Donchian", + "Natr", + "StdDev", + "UlcerIndex", + "HistoricalVolatility", + "BollingerBandwidth", + "PercentB", + "TrueRange", + "ChaikinVolatility", + "RviVolatility", + "ParkinsonVolatility", + "GarmanKlassVolatility", + "RogersSatchellVolatility", + "YangZhangVolatility", + "JumpIndicator", + "RegimeLabel", + "EwmaVolatility", + "Garch11", + "VolatilityOfVolatility", + "BipowerVariation", + "VolatilityRatio", + "VolatilityCone", + ], + ), + ( + "Bands & Channels", + &[ + "MaEnvelope", + "AccelerationBands", + "StarcBands", + "AtrBands", + "HurstChannel", + "LinRegChannel", + "StandardErrorBands", + "DoubleBollinger", + "TtmSqueeze", + "FractalChaosBands", + "VwapStdDevBands", + "QuartileBands", + "BomarBands", + "MedianChannel", + "ProjectionBands", + "ProjectionOscillator", + ], + ), + ( + "Trailing Stops", + &[ + "Psar", + "SuperTrend", + "ChandelierExit", + "ChandeKrollStop", + "AtrTrailingStop", + "HiLoActivator", + "VoltyStop", + "YoyoExit", + "DonchianStop", + "PercentageTrailingStop", + "StepTrailingStop", + "RenkoTrailingStop", + "SarExt", + "KaseDevStop", + "ElderSafeZone", + "AtrRatchet", + "Nrtr", + "TimeBasedStop", + "ModifiedMaStop", + ], + ), + ( + "Volume", + &[ + "Obv", + "Vwap", + "RollingVwap", + "Adl", + "VolumePriceTrend", + "ChaikinMoneyFlow", + "ChaikinOscillator", + "ForceIndex", + "EaseOfMovement", + "Kvo", + "VolumeOscillator", + "Nvi", + "Pvi", + "AdOscillator", + "AnchoredVwap", + "DemandIndex", + "Tsv", + "Vzo", + "MarketFacilitationIndex", + "VolumeRsi", + "Wad", + "TwiggsMoneyFlow", + "TradeVolumeIndex", + "IntradayIntensity", + "BetterVolume", + "VolumeWeightedMacd", + ], + ), + ( + "Price Statistics", + &[ + "TypicalPrice", + "MedianPrice", + "WeightedClose", + "LinearRegression", + "LinRegSlope", + "ZScore", + "LinRegAngle", + "Variance", + "CoefficientOfVariation", + "Skewness", + "Kurtosis", + "StandardError", + "DetrendedStdDev", + "RSquared", + "MedianAbsoluteDeviation", + "Autocorrelation", + "HurstExponent", + "PearsonCorrelation", + "Beta", + "SpearmanCorrelation", + "Cointegration", + "LeadLagCrossCorrelation", + "PairSpreadZScore", + "PairwiseBeta", + "RelativeStrengthAB", + "MidPrice", + "MidPoint", + "AvgPrice", + "LinRegIntercept", + "Tsf", + "RollingCorrelation", + "RollingCovariance", + "OuHalfLife", + "SpreadHurst", + "DistanceSsd", + "BetaNeutralSpread", + "VarianceRatio", + "GrangerCausality", + "KalmanHedgeRatio", + "SpreadBollingerBands", + "LogReturn", + "RealizedVolatility", + "RollingIqr", + "RollingPercentileRank", + "RollingQuantile", + "SpreadAr1Coefficient", + "CloseVsOpen", + "BodySizePct", + "WickRatio", + "HighLowRange", + "JarqueBera", + "RollingMinMaxScaler", + "ShannonEntropy", + "SampleEntropy", + "KendallTau", + ], + ), + ( + "Ehlers / Cycle (DSP)", + &[ + "Mama", + "Fama", + "FisherTransform", + "InverseFisherTransform", + "SuperSmoother", + "HilbertDominantCycle", + "HtDcPhase", + "HtPhasor", + "HtTrendMode", + "SineWave", + "Decycler", + "DecyclerOscillator", + "RoofingFilter", + "CenterOfGravity", + "CyberneticCycle", + "AdaptiveCycle", + "EmpiricalModeDecomposition", + "EhlersStochastic", + "InstantaneousTrendline", + "HighpassFilter", + "Reflex", + "Trendflex", + "CorrelationTrendIndicator", + "AdaptiveRsi", + "UniversalOscillator", + "AdaptiveCci", + "BandpassFilter", + "EvenBetterSinewave", + "AutocorrelationPeriodogram", + ], + ), + ( + "Pivots & S/R", + &[ + "ClassicPivots", + "FibonacciPivots", + "Camarilla", + "WoodiePivots", + "DemarkPivots", + "WilliamsFractals", + "ZigZag", + "CentralPivotRange", + "MurreyMathLines", + "AndrewsPitchfork", + "VolumeWeightedSr", + "PivotReversal", + ], + ), + ( + "DeMark", + &[ + "TdSetup", + "TdSequential", + "TdDeMarker", + "TdRei", + "TdPressure", + "TdCombo", + "TdCountdown", + "TdLines", + "TdRangeProjection", + "TdDifferential", + "TdOpen", + "TdRiskLevel", + "TdCamouflage", + "TdClop", + "TdClopwin", + "TdPropulsion", + "TdTrap", + "TdDWave", + "TdMovingAverage", + ], + ), + ( + "Ichimoku & Charts", + &[ + "Ichimoku", + "HeikinAshi", + "HeikinAshiOscillator", + "ThreeLineBreak", + "SmoothedHeikinAshi", + "Equivolume", + "CandleVolume", + ], + ), + ( + "Candlestick Patterns", + &[ + "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", + "Tristar", + "HaramiCross", + "TowerTopBottom", + "FryPanBottom", + "DumplingTop", + "NewPriceLines", + ], + ), + ( + "Microstructure", + &[ + "OrderBookImbalanceTop1", + "OrderBookImbalanceTopN", + "OrderBookImbalanceFull", + "Microprice", + "QuotedSpread", + "DepthSlope", + "SignedVolume", + "CumulativeVolumeDelta", + "TradeImbalance", + "EffectiveSpread", + "RealizedSpread", + "KylesLambda", + "Footprint", + "OrderFlowImbalance", + "Vpin", + "AmihudIlliquidity", + "RollMeasure", + "TradeSignAutocorrelation", + "Pin", + "HasbrouckInformationShare", + ], + ), + ( + "Derivatives", + &[ + "FundingRate", + "FundingRateMean", + "FundingRateZScore", + "FundingBasis", + "OpenInterestDelta", + "OIPriceDivergence", + "OIWeighted", + "LongShortRatio", + "TakerBuySellRatio", + "LiquidationFeatures", + "TermStructureBasis", + "CalendarSpread", + "EstimatedLeverageRatio", + "OiToVolumeRatio", + "PerpetualPremiumIndex", + "FundingImpliedApr", + "OpenInterestMomentum", + ], + ), + ( + "Market Profile", + &[ + "ValueArea", + "InitialBalance", + "OpeningRange", + "VolumeProfile", + "TpoProfile", + "NakedPoc", + "SinglePrints", + "ProfileShape", + "HighLowVolumeNodes", + "CompositeProfile", + ], + ), + ( + "Risk / Performance", + &[ + "SharpeRatio", + "SortinoRatio", + "CalmarRatio", + "OmegaRatio", + "MaxDrawdown", + "AverageDrawdown", + "DrawdownDuration", + "PainIndex", + "ValueAtRisk", + "ConditionalValueAtRisk", + "ProfitFactor", + "GainLossRatio", + "RecoveryFactor", + "KellyCriterion", + "TreynorRatio", + "InformationRatio", + "Alpha", + "WinRate", + "Expectancy", + "SterlingRatio", + "BurkeRatio", + "MartinRatio", + "TailRatio", + "KRatio", + "CommonSenseRatio", + "GainToPainRatio", + "UpsidePotentialRatio", + "M2Measure", + ], + ), + ( + "Alt-Chart Bars", + &[ + "RenkoBars", + "KagiBars", + "PointAndFigureBars", + "RangeBars", + "TickBars", + "VolumeBars", + "DollarBars", + "ImbalanceBars", + "RunBars", + "ThreeLineBreakBars", + ], + ), + ( + "Market Breadth", + &[ + "AdvanceDecline", + "AdvanceDeclineRatio", + "AdVolumeLine", + "McClellanOscillator", + "McClellanSummationIndex", + "Trin", + "BreadthThrust", + "NewHighsNewLows", + "HighLowIndex", + "PercentAboveMa", + "UpDownVolumeRatio", + "BullishPercentIndex", + "CumulativeVolumeIndex", + "AbsoluteBreadthIndex", + "TickIndex", + ], + ), + ( + "Seasonality & Session", + &[ + "SessionVwap", + "SessionHighLow", + "SessionRange", + "AverageDailyRange", + "OvernightGap", + "OvernightIntradayReturn", + "TurnOfMonth", + "SeasonalZScore", + "TimeOfDayReturnProfile", + "DayOfWeekProfile", + "IntradayVolatilityProfile", + "VolumeByTimeProfile", + ], + ), + ( + "Chart Patterns", + &[ + "DoubleTopBottom", + "TripleTopBottom", + "HeadAndShoulders", + "Triangle", + "Wedge", + "FlagPennant", + "RectangleRange", + "CupAndHandle", + ], + ), + ( + "Harmonic Patterns", + &[ + "Abcd", + "Gartley", + "Butterfly", + "Bat", + "Crab", + "Shark", + "Cypher", + "ThreeDrives", + ], + ), + ( + "Fibonacci", + &[ + "FibRetracement", + "FibExtension", + "FibProjection", + "AutoFib", + "GoldenPocket", + "FibConfluence", + "FibFan", + "FibArcs", + "FibChannel", + "FibTimeZones", + ], + ), +]; + +#[cfg(test)] +mod family_tests { + use super::FAMILIES; + + #[test] + fn no_duplicates_across_families() { + let mut names: Vec<&str> = FAMILIES + .iter() + .flat_map(|(_, ns)| ns.iter().copied()) + .collect(); + let len_before = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!( + names.len(), + len_before, + "duplicate indicator across families" + ); + } + + #[test] + fn total_count_matches_expected() { + // Bump together with new indicators. Drift between this number and + // 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, 514, "FAMILIES total drifted from indicator count"); + } +} diff --git a/crates/wickra-core/src/indicators/modified_ma_stop.rs b/crates/wickra-core/src/indicators/modified_ma_stop.rs new file mode 100644 index 0000000..b132913 --- /dev/null +++ b/crates/wickra-core/src/indicators/modified_ma_stop.rs @@ -0,0 +1,238 @@ +//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA). + +use crate::error::{Error, Result}; +use crate::indicators::smma::Smma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ModifiedMaStopOutput { + /// The stop level (a directionally-ratcheted Modified Moving Average). + pub value: f64, + /// Trend direction: `+1.0` long (stop below price), `-1.0` short. + pub direction: f64, +} + +/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving +/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's +/// favour. +/// +/// ```text +/// ma = SMMA(close, period) (Modified Moving Average) +/// long: stop = max(prev_stop, ma); flip short when close < stop +/// short: stop = min(prev_stop, ma); flip long when close > stop +/// ``` +/// +/// The Modified Moving Average (also called the smoothed or running moving +/// average) is the slow, low-lag average Wilder used throughout his systems. Using +/// it directly as a trailing line — but **ratcheting** so the long stop never +/// falls and the short stop never rises — turns the smooth average into a stop +/// that hugs price in a trend and flips when price decisively crosses it. Because +/// the SMMA lags, the stop gives trends room while still exiting clean reversals. +/// +/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is +/// O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ModifiedMaStop}; +/// +/// let mut indicator = ModifiedMaStop::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ModifiedMaStop { + smma: Smma, + period: usize, + direction: f64, + stop: f64, + last: Option, +} + +impl ModifiedMaStop { + /// Construct a Modified-MA stop with the given SMMA `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + smma: Smma::new(period)?, + period, + direction: 0.0, + stop: 0.0, + last: None, + }) + } + + /// Configured SMMA period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for ModifiedMaStop { + type Input = Candle; + type Output = ModifiedMaStopOutput; + + fn update(&mut self, candle: Candle) -> Option { + let ma = self.smma.update(candle.close)?; + let close = candle.close; + + if self.direction == 0.0 { + self.direction = if close >= ma { 1.0 } else { -1.0 }; + self.stop = ma; + } else if self.direction > 0.0 { + self.stop = self.stop.max(ma); + if close < self.stop { + self.direction = -1.0; + self.stop = ma; + } + } else { + self.stop = self.stop.min(ma); + if close > self.stop { + self.direction = 1.0; + self.stop = ma; + } + } + + let out = ModifiedMaStopOutput { + value: self.stop, + direction: self.direction, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.smma.reset(); + self.direction = 0.0; + self.stop = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ModifiedMaStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64) -> Candle { + Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let m = ModifiedMaStop::new(14).unwrap(); + assert_eq!(m.period(), 14); + assert_eq!(m.warmup_period(), 14); + assert_eq!(m.name(), "ModifiedMaStop"); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut m = ModifiedMaStop::new(5).unwrap(); + let candles: Vec = (0..12).map(|i| c(100.0 + f64::from(i))).collect(); + let out = m.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn uptrend_keeps_stop_below_price() { + let mut m = ModifiedMaStop::new(5).unwrap(); + let candles: Vec = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect(); + for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) { + if let Some(o) = o { + assert_eq!(o.direction, 1.0); + assert!(o.value < candle.close); + } + } + } + + #[test] + fn long_stop_ratchets_up() { + let mut m = ModifiedMaStop::new(5).unwrap(); + let candles: Vec = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect(); + let mut prev = f64::NEG_INFINITY; + for o in m.batch(&candles).into_iter().flatten() { + assert_eq!(o.direction, 1.0, "pure uptrend stays long"); + assert!(o.value >= prev, "long stop must not fall"); + prev = o.value; + } + } + + #[test] + fn flips_on_reversal() { + let mut candles: Vec = (0..40).map(|i| c(100.0 + f64::from(i))).collect(); + candles.extend((0..40).map(|i| c(140.0 - f64::from(i)))); + let mut m = ModifiedMaStop::new(5).unwrap(); + let dirs: Vec = m + .batch(&candles) + .into_iter() + .flatten() + .map(|o| o.direction) + .collect(); + assert!(dirs.iter().any(|&d| d > 0.0)); + assert!(dirs.iter().any(|&d| d < 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut m = ModifiedMaStop::new(5).unwrap(); + m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::>()); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + assert_eq!(m.update(c(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0)) + .collect(); + let batch = ModifiedMaStop::new(14).unwrap().batch(&candles); + let mut b = ModifiedMaStop::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mom.rs b/crates/wickra-core/src/indicators/mom.rs new file mode 100644 index 0000000..8ea529a --- /dev/null +++ b/crates/wickra-core/src/indicators/mom.rs @@ -0,0 +1,182 @@ +//! Momentum (absolute price change over a fixed lookback). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Momentum: the raw price change over `period` bars, `price_t − price_{t−period}`. +/// +/// Unlike [`Roc`](crate::Roc), which divides by the old price to give a +/// percentage, `Mom` reports the change in absolute price units. It is the +/// simplest momentum primitive: positive values mean price is higher than it +/// was `period` bars ago, negative values mean lower. +/// +/// Non-finite inputs are ignored and leave the window untouched; the last +/// computed value is returned instead. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Mom}; +/// +/// let mut indicator = Mom::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Mom { + period: usize, + /// Rolling buffer of the last `period + 1` inputs, oldest at the front. + window: VecDeque, + last: Option, +} + +impl Mom { + /// Construct a new momentum indicator with the given lookback period. + /// + /// # 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 + 1), + last: None, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Mom { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the window is left untouched. + return self.last; + } + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period + 1 { + return None; + } + let prev = *self.window.front().expect("window is non-empty"); + let mom = input - prev; + self.last = Some(mom); + Some(mom) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "MOM" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Mom::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (56-63) and the + /// Indicator-impl `name` body (101-103). Existing tests inspect + /// momentum output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut m = Mom::new(5).unwrap(); + assert_eq!(m.period(), 5); + assert_eq!(m.name(), "MOM"); + assert_eq!(m.value(), None); + for i in 1..=6 { + m.update(f64::from(i)); + } + assert!(m.value().is_some()); + } + + #[test] + fn reference_values() { + // MOM(3): price_t − price_{t-3}. + let mut mom = Mom::new(3).unwrap(); + let out = mom.batch(&[1.0, 2.0, 3.0, 4.0, 7.0]); + assert_eq!(mom.warmup_period(), 4); + assert_eq!(out[0], None); + assert_eq!(out[2], None); + assert_relative_eq!(out[3].unwrap(), 4.0 - 1.0, epsilon = 1e-12); + assert_relative_eq!(out[4].unwrap(), 7.0 - 2.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut mom = Mom::new(5).unwrap(); + let out = mom.batch(&[10.0; 20]); + for v in out.iter().skip(5).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut mom = Mom::new(3).unwrap(); + let out = mom.batch(&[1.0, 2.0, 3.0, 4.0]); + let ready = out[3].expect("MOM(3) ready after four inputs"); + assert_eq!(mom.update(f64::NAN), Some(ready)); + assert_eq!(mom.update(f64::INFINITY), Some(ready)); + // Window untouched: the next finite input still references price 2. + assert_relative_eq!(mom.update(10.0).unwrap(), 10.0 - 2.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut mom = Mom::new(3).unwrap(); + mom.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(mom.is_ready()); + mom.reset(); + assert!(!mom.is_ready()); + assert_eq!(mom.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40).map(|i| f64::from(i) * 1.5).collect(); + let batch = Mom::new(7).unwrap().batch(&prices); + let mut b = Mom::new(7).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/morning_doji_star.rs b/crates/wickra-core/src/indicators/morning_doji_star.rs new file mode 100644 index 0000000..d58a8c0 --- /dev/null +++ b/crates/wickra-core/src/indicators/morning_doji_star.rs @@ -0,0 +1,259 @@ +//! Morning Doji Star candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Morning Doji Star — a 3-bar bullish bottom reversal. A long black bar extends +/// the decline, a doji gaps down below it (the star of indecision), then a white +/// bar gaps back up and closes deep into the first body, confirming the turn. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// doji = |close − open| <= 0.1 * (high − low) +/// bar1 black & long +/// bar2 doji, body gaps DOWN below bar1 body (max(o2,c2) < close1) +/// bar3 white, body gaps UP above the doji (min(o3,c3) > max(o2,c2)) +/// bar3 closes deep into bar1 body (close3 > close1 + penetration·body1) +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Morning Doji +/// Star is a single-direction (bullish-only) reversal, so it never emits `−1.0`. +/// The first two bars always return `0.0` because the three-bar window is not yet +/// filled. `penetration` is how far into the first body the third bar must close; +/// it defaults to `0.3` (TA-Lib's `CDLMORNINGDOJISTAR` default) and must lie in +/// `[0, 1)`. Body and doji thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MorningDojiStar}; +/// +/// let mut indicator = MorningDojiStar::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(8.0, 8.1, 7.9, 8.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(9.0, 13.1, 8.9, 13.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct MorningDojiStar { + penetration: f64, + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl Default for MorningDojiStar { + fn default() -> Self { + Self::new() + } +} + +impl MorningDojiStar { + /// Construct a Morning Doji Star detector with the default 0.3 penetration. + pub const fn new() -> Self { + Self { + penetration: 0.3, + prev: None, + prev_prev: None, + has_emitted: false, + } + } + + /// Construct a Morning Doji Star detector with a custom penetration fraction. + /// + /// `penetration` must lie in `[0, 1)`. + pub fn with_penetration(penetration: f64) -> Result { + if !(0.0..1.0).contains(&penetration) { + return Err(Error::InvalidPeriod { + message: "morning doji star penetration must lie in [0, 1)", + }); + } + Ok(Self { + penetration, + prev: None, + prev_prev: None, + has_emitted: false, + }) + } + + /// Configured penetration fraction. + pub fn penetration(&self) -> f64 { + self.penetration + } +} + +impl Indicator for MorningDojiStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.prev_prev; + let bar2 = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + let range2 = bar2.high - bar2.low; + if range1 <= 0.0 || range2 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.open - bar1.close; + if body1 < 0.5 * range1 { + return Some(0.0); // bar1 must be a long black body + } + if (bar2.close - bar2.open).abs() > 0.1 * range2 { + return Some(0.0); // bar2 must be a doji + } + let star_top = bar2.open.max(bar2.close); + let bar3_bottom = candle.open.min(candle.close); + if star_top < bar1.close + && candle.close > candle.open + && bar3_bottom > star_top + && candle.close > bar1.close + self.penetration * body1 + { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MorningDojiStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_penetration() { + assert!(MorningDojiStar::with_penetration(-0.01).is_err()); + assert!(MorningDojiStar::with_penetration(1.0).is_err()); + } + + #[test] + fn accepts_valid_penetration() { + let t = MorningDojiStar::with_penetration(0.5).unwrap(); + assert!((t.penetration() - 0.5).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = MorningDojiStar::default(); + assert_eq!(t.name(), "MorningDojiStar"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + assert!((t.penetration() - 0.3).abs() < 1e-12); + } + + #[test] + fn morning_doji_star_is_plus_one() { + let mut t = MorningDojiStar::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(8.0, 8.1, 7.9, 8.0, 1)), Some(0.0)); + assert_eq!(t.update(c(9.0, 13.1, 8.9, 13.0, 2)), Some(1.0)); + } + + #[test] + fn middle_not_doji_yields_zero() { + let mut t = MorningDojiStar::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + // Wide-bodied star, not a doji. + t.update(c(8.0, 10.1, 7.9, 10.0, 1)); + assert_eq!(t.update(c(9.0, 13.1, 8.9, 13.0, 2)), Some(0.0)); + } + + #[test] + fn shallow_close_yields_zero() { + let mut t = MorningDojiStar::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(8.0, 8.1, 7.9, 8.0, 1)); + // bar3 white but closes at 11.0 -> only 1.0 into the 5.0 body (< 0.3·5). + assert_eq!(t.update(c(9.0, 11.1, 8.9, 11.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = MorningDojiStar::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(8.0, 8.1, 7.9, 8.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 5.0, base + 5.1, base - 0.1, base, i) + }) + .collect(); + let mut a = MorningDojiStar::new(); + let mut b = MorningDojiStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = MorningDojiStar::new(); + t.update(c(15.0, 15.1, 9.9, 10.0, 0)); + t.update(c(8.0, 8.1, 7.9, 8.0, 1)); + t.update(c(9.0, 13.1, 8.9, 13.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = MorningDojiStar::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + t.update(c(8.0, 8.1, 7.9, 8.0, 1)); + assert_eq!(t.update(c(9.0, 13.1, 8.9, 13.0, 2)), Some(0.0)); + } + + #[test] + fn short_first_body_yields_zero() { + let mut t = MorningDojiStar::new(); + // bar1 has a wide range but a tiny body -> not a long black body. + t.update(c(10.0, 16.0, 9.0, 9.8, 0)); + t.update(c(8.0, 8.1, 7.9, 8.0, 1)); + assert_eq!(t.update(c(9.0, 13.1, 8.9, 13.0, 2)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/morning_evening_star.rs b/crates/wickra-core/src/indicators/morning_evening_star.rs new file mode 100644 index 0000000..6710c96 --- /dev/null +++ b/crates/wickra-core/src/indicators/morning_evening_star.rs @@ -0,0 +1,218 @@ +//! Morning Star / Evening Star candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Morning Star / Evening Star — a 3-bar reversal pattern. +/// +/// **Morning Star** (bullish, `+1.0`): +/// 1. Bar 1 is a long red candle. +/// 2. Bar 2 has a small body (the "star") — colour does not matter. +/// 3. Bar 3 is a long green candle that closes above the midpoint of Bar 1. +/// +/// **Evening Star** (bearish, `−1.0`): the mirror image — long green, small +/// body, long red closing below Bar 1's midpoint. +/// +/// The "long" qualifier is enforced by requiring the outer bars' bodies to be +/// at least twice the size of the star's body. Pattern-shape check only — no +/// trend filter is applied; combine with a trend indicator for actionable +/// signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MorningEveningStar}; +/// +/// let mut indicator = MorningEveningStar::new(); +/// indicator.update(Candle::new(12.0, 12.2, 9.5, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(9.9, 10.1, 9.7, 9.95, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.1, 12.0, 10.0, 11.8, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MorningEveningStar { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl MorningEveningStar { + /// Construct a new Morning / Evening Star detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for MorningEveningStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = (b1.close - b1.open).abs(); + let body2 = (b2.close - b2.open).abs(); + let body3 = (candle.close - candle.open).abs(); + if body1 <= 0.0 || body3 <= 0.0 { + return Some(0.0); + } + // Star body must be small relative to the outer bars. + if body1 < 2.0 * body2 || body3 < 2.0 * body2 { + return Some(0.0); + } + let mid1 = f64::midpoint(b1.open, b1.close); + let bar1_red = b1.close < b1.open; + let bar1_green = b1.close > b1.open; + let bar3_green = candle.close > candle.open; + let bar3_red = candle.close < candle.open; + if bar1_red && bar3_green && candle.close > mid1 { + Some(1.0) + } else if bar1_green && bar3_red && candle.close < mid1 { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MorningEveningStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let m = MorningEveningStar::new(); + assert_eq!(m.name(), "MorningEveningStar"); + assert_eq!(m.warmup_period(), 3); + assert!(!m.is_ready()); + } + + #[test] + fn morning_star_is_plus_one() { + let mut m = MorningEveningStar::new(); + // Long red 12 -> 10 (body 2). Star small body. Long green 10.1 -> 11.8 (body 1.7). + // Mid of bar 1 = 11. Bar 3 closes at 11.8 > 11. + assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(m.update(c(9.9, 10.1, 9.7, 9.95, 1)), Some(0.0)); + assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(1.0)); + } + + #[test] + fn evening_star_is_minus_one() { + let mut m = MorningEveningStar::new(); + // Long green 10 -> 12, star, long red 11.9 -> 10.2 (body 1.7). + // Mid of bar 1 = 11. Bar 3 closes at 10.2 < 11. + assert_eq!(m.update(c(10.0, 12.2, 9.8, 12.0, 0)), Some(0.0)); + assert_eq!(m.update(c(12.1, 12.3, 11.9, 12.05, 1)), Some(0.0)); + assert_eq!(m.update(c(11.9, 12.0, 10.1, 10.2, 2)), Some(-1.0)); + } + + #[test] + fn big_star_body_is_not_star() { + let mut m = MorningEveningStar::new(); + m.update(c(12.0, 12.2, 9.5, 10.0, 0)); + // Star body 1.5 -> body1=2, body3 needs to be >= 3 to satisfy 2*body2. + m.update(c(9.5, 11.5, 9.5, 11.0, 1)); + assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut m = MorningEveningStar::new(); + assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(m.update(c(9.9, 10.1, 9.7, 9.95, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + match i % 3 { + 0 => c(base + 2.0, base + 2.5, base - 0.5, base, i), + 1 => c(base + 0.1, base + 0.3, base - 0.1, base + 0.15, i), + _ => c(base, base + 2.5, base - 0.5, base + 2.0, i), + } + }) + .collect(); + let mut a = MorningEveningStar::new(); + let mut b = MorningEveningStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut m = MorningEveningStar::new(); + m.update(c(12.0, 12.2, 9.5, 10.0, 0)); + m.update(c(9.9, 10.1, 9.7, 9.95, 1)); + m.update(c(10.1, 12.0, 10.0, 11.8, 2)); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0)); + } + + #[test] + fn doji_outer_bar_yields_zero() { + // Bar1 is a doji (body == 0): body1 == 0 -> guard triggers, returns 0. + let mut m = MorningEveningStar::new(); + m.update(c(10.0, 11.0, 9.0, 10.0, 0)); // doji bar1 + m.update(c(9.9, 10.1, 9.7, 9.95, 1)); + assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(0.0)); + } + + #[test] + fn same_direction_bars_yield_zero() { + // Bar1 red, star small, bar3 also red (wrong direction) -> falls through to else 0. + let mut m = MorningEveningStar::new(); + m.update(c(12.0, 12.2, 9.5, 10.0, 0)); // long red (body 2) + m.update(c(9.9, 10.1, 9.7, 9.95, 1)); // small star + // Bar3 red, closes below mid (11); doesn't match morning star (bar3 must be green) + // and also doesn't match evening star (bar1 must be green). + assert_eq!(m.update(c(11.0, 11.2, 9.0, 9.5, 2)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/murrey_math_lines.rs b/crates/wickra-core/src/indicators/murrey_math_lines.rs new file mode 100644 index 0000000..dc4eaef --- /dev/null +++ b/crates/wickra-core/src/indicators/murrey_math_lines.rs @@ -0,0 +1,272 @@ +//! Murrey Math Lines — the eighths grid over the recent trading range. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`MurreyMathLines`]: the nine Murrey Math levels from the bottom +/// (`mm0_8`, ultimate support) to the top (`mm8_8`, ultimate resistance). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MurreyMathLinesOutput { + /// 8/8 — ultimate resistance (top of the frame). + pub mm8_8: f64, + /// 7/8 — "weak, stall and reverse" (overbought). + pub mm7_8: f64, + /// 6/8 — upper pivot / reversal line. + pub mm6_8: f64, + /// 5/8 — top of the normal trading range. + pub mm5_8: f64, + /// 4/8 — the major pivot (mean) line. + pub mm4_8: f64, + /// 3/8 — bottom of the normal trading range. + pub mm3_8: f64, + /// 2/8 — lower pivot / reversal line. + pub mm2_8: f64, + /// 1/8 — "weak, stall and reverse" (oversold). + pub mm1_8: f64, + /// 0/8 — ultimate support (bottom of the frame). + pub mm0_8: f64, +} + +/// Murrey Math Lines — T. H. Murrey's grid that divides the recent trading range +/// into eighths, each acting as support/resistance. +/// +/// ```text +/// HH = highest high over `period`, LL = lowest low over `period` +/// step = (HH − LL) / 8 +/// mm{i}_8 = LL + i · step for i = 0..8 +/// ``` +/// +/// Murrey Math (a Gann-derived framework) holds that price gravitates to and +/// reverses at the eighth divisions of its range. The **4/8** line is the major +/// pivot (mean); **0/8** and **8/8** are the strongest support and resistance; +/// **3/8** and **5/8** bound the "normal" trading range, while **1/8**/**7/8** are +/// the weak "stall and reverse" lines. This implementation uses the price-derived +/// eighths over a rolling high-low frame (the practical core of the method) rather +/// than Murrey's full octave-quantised frame sizing, so the levels track the +/// instrument's actual recent range. +/// +/// The first value lands after `period` inputs; each `update` rescans the frame in +/// O(`period`). A degenerate flat frame (`HH == LL`) collapses every line onto the +/// price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MurreyMathLines}; +/// +/// let mut indicator = MurreyMathLines::new(64).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MurreyMathLines { + period: usize, + highs: VecDeque, + lows: VecDeque, + last: Option, +} + +impl MurreyMathLines { + /// Construct Murrey Math Lines over a `period`-bar high-low frame. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured frame period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for MurreyMathLines { + type Input = Candle; + type Output = MurreyMathLinesOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.period { + return None; + } + let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let step = (hh - ll) / 8.0; + let level = |i: f64| ll + i * step; + let out = MurreyMathLinesOutput { + mm0_8: level(0.0), + mm1_8: level(1.0), + mm2_8: level(2.0), + mm3_8: level(3.0), + mm4_8: level(4.0), + mm5_8: level(5.0), + mm6_8: level(6.0), + mm7_8: level(7.0), + mm8_8: level(8.0), + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "MurreyMathLines" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64) -> Candle { + Candle::new_unchecked(low, high, low, f64::midpoint(high, low), 1_000.0, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(MurreyMathLines::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let m = MurreyMathLines::new(64).unwrap(); + assert_eq!(m.period(), 64); + assert_eq!(m.warmup_period(), 64); + assert_eq!(m.name(), "MurreyMathLines"); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut m = MurreyMathLines::new(4).unwrap(); + let candles: Vec = (0..6) + .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i))) + .collect(); + let out = m.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn eighths_are_evenly_spaced() { + // Frame [100, 180] over the window -> step = 10. + let mut m = MurreyMathLines::new(2).unwrap(); + let out = m + .batch(&[c(180.0, 100.0), c(180.0, 100.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.mm0_8, 100.0, epsilon = 1e-9); + assert_relative_eq!(out.mm4_8, 140.0, epsilon = 1e-9); + assert_relative_eq!(out.mm8_8, 180.0, epsilon = 1e-9); + assert_relative_eq!(out.mm1_8 - out.mm0_8, 10.0, epsilon = 1e-9); + } + + #[test] + fn levels_are_ordered() { + let mut m = MurreyMathLines::new(10).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.3).sin() * 8.0, + 90.0 + (f64::from(i) * 0.3).cos() * 8.0, + ) + }) + .collect(); + for o in m.batch(&candles).into_iter().flatten() { + assert!(o.mm0_8 <= o.mm4_8 && o.mm4_8 <= o.mm8_8); + assert!(o.mm3_8 <= o.mm5_8); + } + } + + #[test] + fn flat_frame_collapses() { + let mut m = MurreyMathLines::new(3).unwrap(); + let out = m + .batch(&[c(50.0, 50.0), c(50.0, 50.0), c(50.0, 50.0)]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.mm0_8, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.mm8_8, 50.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut m = MurreyMathLines::new(4).unwrap(); + m.batch( + &(0..6) + .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i))) + .collect::>(), + ); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + assert_eq!(m.update(c(101.0, 99.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.25).sin() * 9.0, + 90.0 + (f64::from(i) * 0.25).cos() * 9.0, + ) + }) + .collect(); + let batch = MurreyMathLines::new(64).unwrap().batch(&candles); + let mut b = MurreyMathLines::new(64).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/naked_poc.rs b/crates/wickra-core/src/indicators/naked_poc.rs new file mode 100644 index 0000000..58b0e74 --- /dev/null +++ b/crates/wickra-core/src/indicators/naked_poc.rs @@ -0,0 +1,318 @@ +//! Naked POC — the nearest prior-session point of control price has not yet revisited. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Naked (Virgin) POC — the nearest **untested** point of control from a prior +/// session: a heavily-traded price the market has not traded back through since. +/// +/// ```text +/// every `session_len` candles forms a session; its POC (heaviest-volume price) is +/// recorded as "naked" +/// a naked POC becomes "tested" once a later candle's high-low range covers it +/// output = the nearest still-naked POC to the current close (or the close itself +/// if every prior POC has been revisited) +/// ``` +/// +/// A point of control is a magnet — price tends to return to fair value. A *naked* +/// (or virgin) POC is one that has not yet been revisited, so it carries an +/// outstanding "pull": untested POCs are high-probability targets and +/// support/resistance on the approach. This indicator records each completed +/// session's POC, marks them tested as price trades through them, and reports the +/// closest one still outstanding. +/// +/// The first value lands after `session_len` candles (the first session's POC). +/// Each `update` is O(`session_len · bins` + naked-count). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, NakedPoc}; +/// +/// let mut indicator = NakedPoc::new(20, 24).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct NakedPoc { + session_len: usize, + bins: usize, + session: VecDeque, + naked: Vec, + last_close: f64, + ready: bool, + last: Option, +} + +impl NakedPoc { + /// Construct a Naked POC tracker with the given `session_len` and profile + /// `bins`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `session_len` or `bins` is zero. + pub fn new(session_len: usize, bins: usize) -> Result { + if session_len == 0 || bins == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + session_len, + bins, + session: VecDeque::with_capacity(session_len), + naked: Vec::new(), + last_close: 0.0, + ready: false, + last: None, + }) + } + + /// Configured `(session_len, bins)`. + pub const fn params(&self) -> (usize, usize) { + (self.session_len, self.bins) + } + + /// Number of currently-naked POCs. + pub fn naked_count(&self) -> usize { + self.naked.len() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn session_poc(&self) -> f64 { + let mut low = f64::INFINITY; + let mut high = f64::NEG_INFINITY; + for c in &self.session { + low = low.min(c.low); + high = high.max(c.high); + } + let span = high - low; + if span <= 0.0 { + return low; + } + let width = span / self.bins as f64; + let mut hist = vec![0.0; self.bins]; + for c in &self.session { + if c.volume == 0.0 { + continue; + } + let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1); + let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1); + let share = c.volume / (hi_idx - lo_idx + 1) as f64; + for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *bin += share; + } + } + let mut poc = 0; + let mut poc_vol = f64::NEG_INFINITY; + for (idx, &vol) in hist.iter().enumerate() { + if vol > poc_vol { + poc_vol = vol; + poc = idx; + } + } + low + (poc as f64 + 0.5) * width + } +} + +impl Indicator for NakedPoc { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // Test outstanding naked POCs against this candle's range. + self.naked + .retain(|&poc| !(candle.low <= poc && poc <= candle.high)); + self.last_close = candle.close; + + // Accumulate the session; finalize a POC at the boundary. + self.session.push_back(candle); + if self.session.len() == self.session_len { + let poc = self.session_poc(); + self.naked.push(poc); + self.session.clear(); + self.ready = true; + } + + if !self.ready { + return None; + } + let nearest = self + .naked + .iter() + .copied() + .min_by(|a, b| { + (a - self.last_close) + .abs() + .total_cmp(&(b - self.last_close).abs()) + }) + .unwrap_or(self.last_close); + self.last = Some(nearest); + Some(nearest) + } + + fn reset(&mut self) { + self.session.clear(); + self.naked.clear(); + self.last_close = 0.0; + self.ready = false; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.session_len + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "NakedPoc" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new_unchecked(f64::midpoint(high, low), high, low, close, volume, 0) + } + + #[test] + fn rejects_zero_params() { + assert!(matches!(NakedPoc::new(0, 24), Err(Error::PeriodZero))); + assert!(matches!(NakedPoc::new(20, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let n = NakedPoc::new(20, 24).unwrap(); + assert_eq!(n.params(), (20, 24)); + assert_eq!(n.naked_count(), 0); + assert_eq!(n.warmup_period(), 20); + assert_eq!(n.name(), "NakedPoc"); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + } + + #[test] + fn first_emission_at_session_end() { + let mut n = NakedPoc::new(4, 8).unwrap(); + let candles: Vec = (0..6).map(|_| c(101.0, 99.0, 100.0, 1_000.0)).collect(); + let out = n.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn records_session_poc() { + let mut n = NakedPoc::new(4, 16).unwrap(); + // A session clustered around 100 -> POC near 100. + n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]); + assert_eq!(n.naked_count(), 1); + let poc = n.value().unwrap(); + assert!( + (poc - 100.0).abs() < 2.0, + "POC should be near 100, got {poc}" + ); + } + + #[test] + fn revisit_marks_poc_tested() { + let mut n = NakedPoc::new(4, 16).unwrap(); + // Session 1 around 100 -> naked POC ~100. + n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]); + assert_eq!(n.naked_count(), 1); + // Trade away at 120 (does not cover 100) -> still naked. + n.update(c(121.0, 119.0, 120.0, 1_000.0)); + assert_eq!(n.naked_count(), 1); + // A candle whose range covers 100 -> POC tested -> removed. + n.update(c(121.0, 95.0, 100.0, 1_000.0)); + assert_eq!(n.naked_count(), 0); + } + + #[test] + fn empty_naked_reports_close() { + let mut n = NakedPoc::new(4, 16).unwrap(); + n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]); + // Wipe the naked POC with a covering candle. + let out = n.update(c(121.0, 95.0, 117.0, 1_000.0)).unwrap(); + assert_eq!(n.naked_count(), 0); + assert!( + (out - 117.0).abs() < 1e-9, + "with no naked POC, output is the close" + ); + } + + #[test] + fn reset_clears_state() { + let mut n = NakedPoc::new(4, 8).unwrap(); + n.batch(&[c(101.0, 99.0, 100.0, 1_000.0); 6]); + assert!(n.is_ready()); + n.reset(); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + assert_eq!(n.naked_count(), 0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + c(b + 1.0, b - 1.0, b, 1_000.0 + f64::from(i)) + }) + .collect(); + let batch = NakedPoc::new(20, 24).unwrap().batch(&candles); + let mut b = NakedPoc::new(20, 24).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn flat_session_reports_price() { + // A session with zero high-low span returns the session price directly. + let mut n = NakedPoc::new(2, 4).unwrap(); + n.update(c(50.0, 50.0, 50.0, 10.0)); + assert_eq!(n.update(c(50.0, 50.0, 50.0, 10.0)), Some(50.0)); + } + + #[test] + fn zero_volume_session_is_handled() { + // Zero-volume candles are skipped in the histogram; a POC still emits. + let mut n = NakedPoc::new(2, 4).unwrap(); + n.update(c(60.0, 40.0, 50.0, 0.0)); + assert!(n.update(c(60.0, 40.0, 50.0, 0.0)).is_some()); + } + + #[test] + fn nearest_of_two_naked_pocs() { + // Two untouched POCs at distant prices accumulate; the one nearest the + // last close is reported (exercises the min-by comparison). + let mut n = NakedPoc::new(2, 4).unwrap(); + n.update(c(11.0, 9.0, 10.0, 100.0)); + n.update(c(11.0, 9.0, 10.0, 100.0)); // POC near 10 + n.update(c(101.0, 99.0, 100.0, 100.0)); + let v = n.update(c(101.0, 99.0, 100.0, 100.0)).unwrap(); // POC near 100 + assert!( + v > 50.0, + "nearest to close 100 should be the upper POC, got {v}" + ); + } +} diff --git a/crates/wickra-core/src/indicators/natr.rs b/crates/wickra-core/src/indicators/natr.rs new file mode 100644 index 0000000..df5775e --- /dev/null +++ b/crates/wickra-core/src/indicators/natr.rs @@ -0,0 +1,216 @@ +//! Normalized Average True Range. + +use crate::error::Result; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +use super::Atr; + +/// Normalized Average True Range — [`Atr`] expressed as a percentage of price. +/// +/// `Atr` reports volatility in raw price units, which makes its readings +/// impossible to compare across instruments at different price levels. NATR +/// fixes that by dividing by the current close: +/// +/// ```text +/// NATR = 100 · ATR / close +/// ``` +/// +/// A NATR of `2.0` always means "the average true range is 2 % of price", +/// whether the instrument trades at $10 or $10 000 — so NATR values are +/// directly comparable, and stop distances or position sizes expressed as a +/// NATR multiple behave consistently across a portfolio. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Natr}; +/// +/// let mut indicator = Natr::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Natr { + atr: Atr, + last: Option, +} + +impl Natr { + /// Construct a new NATR with the given ATR period. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + atr: Atr::new(period)?, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.atr.period() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Natr { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let natr = if candle.close == 0.0 { + // NATR is undefined against a zero close. + 0.0 + } else { + 100.0 * atr / candle.close + }; + self.last = Some(natr); + Some(natr) + } + + fn reset(&mut self) { + self.atr.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.atr.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "NATR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(Natr::new(0).is_err()); + } + + #[test] + fn warmup_period_matches_atr() { + let natr = Natr::new(14).unwrap(); + assert_eq!(natr.warmup_period(), 14); + } + + /// Cover the const accessors `period` / `value` (lines 59-66) and the + /// Indicator-impl `name` body (98-100). `warmup_period` is covered + /// already by `warmup_period_matches_atr`. + #[test] + fn accessors_and_metadata() { + let mut natr = Natr::new(14).unwrap(); + assert_eq!(natr.period(), 14); + assert_eq!(natr.name(), "NATR"); + assert_eq!(natr.value(), None); + let candles: Vec = (0..14) + .map(|i| candle(100.0, 102.0, 98.0, 101.0, i)) + .collect(); + for c in &candles { + natr.update(*c); + } + assert!(natr.value().is_some()); + } + + /// Cover the `candle.close == 0.0` defensive branch (line 77). All + /// other tests feed candles with close ≈ 100, so the zero-close + /// fallback never fired. Feed an all-zero candle series — the Candle + /// validator accepts open == high == low == close == 0 with positive + /// volume, and ATR is 0 each bar, so the indicator must emit exactly + /// 0.0 rather than computing 100 * 0 / 0 = NaN. + #[test] + fn zero_close_yields_zero_natr() { + let candles: Vec = (0..15).map(|i| candle(0.0, 0.0, 0.0, 0.0, i)).collect(); + let mut natr = Natr::new(5).unwrap(); + let out = natr.batch(&candles); + let last = out.into_iter().flatten().last().expect("emits"); + assert_eq!(last, 0.0); + } + + #[test] + fn natr_is_atr_over_close_as_percent() { + // NATR must equal 100 * ATR / close, bar for bar. + let candles: Vec = (0..60) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0; + candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i) + }) + .collect(); + let natr_out = Natr::new(14).unwrap().batch(&candles); + let atr_out = Atr::new(14).unwrap().batch(&candles); + for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() { + // Same warmup period — emission shape must agree at every index. + assert_eq!(n.is_some(), a.is_some(), "warmup mismatch at index {i}"); + if let (Some(nv), Some(av)) = (n, a) { + let want = 100.0 * av / candles[i].close; + assert_relative_eq!(*nv, want, epsilon = 1e-9); + } + } + } + + #[test] + fn flat_market_yields_zero() { + // No range -> ATR is 0 -> NATR is 0. + let mut natr = Natr::new(5).unwrap(); + let candles: Vec = (0..30) + .map(|i| candle(100.0, 100.0, 100.0, 100.0, i)) + .collect(); + for v in natr.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut natr = Natr::new(5).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0, 102.0, 98.0, 101.0, i)) + .collect(); + natr.batch(&candles); + assert!(natr.is_ready()); + natr.reset(); + assert!(!natr.is_ready()); + assert_eq!(natr.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0; + candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i) + }) + .collect(); + let batch = Natr::new(14).unwrap().batch(&candles); + let mut b = Natr::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/new_highs_new_lows.rs b/crates/wickra-core/src/indicators/new_highs_new_lows.rs new file mode 100644 index 0000000..b33b8fd --- /dev/null +++ b/crates/wickra-core/src/indicators/new_highs_new_lows.rs @@ -0,0 +1,142 @@ +//! New Highs − New Lows — net count of fresh period extremes across a universe. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// New Highs − New Lows — the number of symbols printing a new period high minus +/// the number printing a new period low across a universe. +/// +/// On each [`CrossSection`] tick the value is `new_highs - new_lows`, read from the +/// per-symbol `new_high` / `new_low` flags. A persistently positive reading means +/// fresh leadership is broad (many names making new highs); a negative reading +/// during an index advance is a classic breadth divergence warning that the rally +/// is narrowing. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, NewHighsNewLows}; +/// +/// let mut nhnl = NewHighsNewLows::new(); +/// // 2 new highs, 1 new low -> net +1. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, true, false), +/// Member::new(1.0, 10.0, true, false), +/// Member::new(-1.0, 10.0, false, true), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(nhnl.update(tick), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct NewHighsNewLows { + has_emitted: bool, +} + +impl NewHighsNewLows { + /// Construct a new New Highs − New Lows indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for NewHighsNewLows { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.new_highs() as f64 - section.new_lows() as f64; + self.has_emitted = true; + Some(net) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "NewHighsNewLows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn flags(highs: usize, lows: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..highs { + members.push(Member::new(1.0, 10.0, true, false)); + } + for _ in 0..lows { + members.push(Member::new(-1.0, 10.0, false, true)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.name(), "NewHighsNewLows"); + assert_eq!(nhnl.warmup_period(), 1); + assert!(!nhnl.is_ready()); + } + + #[test] + fn first_tick_emits_net_extremes() { + let mut nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.update(flags(5, 2)), Some(3.0)); + assert!(nhnl.is_ready()); + } + + #[test] + fn more_lows_than_highs_is_negative() { + let mut nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.update(flags(1, 4)), Some(-3.0)); + } + + #[test] + fn no_extremes_yields_zero() { + let mut nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.update(flags(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut nhnl = NewHighsNewLows::new(); + nhnl.update(flags(3, 1)); + assert!(nhnl.is_ready()); + nhnl.reset(); + assert!(!nhnl.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![flags(5, 2), flags(1, 4), flags(0, 0)]; + let mut a = NewHighsNewLows::new(); + let mut b = NewHighsNewLows::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/new_price_lines.rs b/crates/wickra-core/src/indicators/new_price_lines.rs new file mode 100644 index 0000000..b6a66af --- /dev/null +++ b/crates/wickra-core/src/indicators/new_price_lines.rs @@ -0,0 +1,234 @@ +//! New Price Lines — the "eight/ten new price lines" exhaustion count. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// New Price Lines — the Japanese "shinne" (new-price) exhaustion count: when the +/// close has made `count` consecutive new highs (or lows), the trend is considered +/// stretched and ripe for a pause or reversal. +/// +/// ```text +/// consecutive higher closes form "new price lines" up +/// consecutive lower closes form "new price lines" down +/// signal = −1 once `count` consecutive higher closes (overbought / sell warning) +/// signal = +1 once `count` consecutive lower closes (oversold / buy warning) +/// signal = 0 otherwise +/// ``` +/// +/// Traditional Japanese practice flags **eight** new price lines (and a stronger +/// **ten** or twelve) as the point where a directional run becomes exhausted — +/// the market has gone up (or down) so many bars in a row that a corrective pause +/// is statistically due. The signal stays active for every bar the streak remains +/// at or above `count`, and clears the moment a close breaks the streak. +/// +/// The first value lands on the second bar (one prior close is needed). The +/// output is `+1` / `0` / `−1`. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, NewPriceLines}; +/// +/// let mut indicator = NewPriceLines::new(8).unwrap(); +/// let mut last = None; +/// for i in 0..12 { +/// let close = 100.0 + f64::from(i); // 11 consecutive higher closes +/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert_eq!(last, Some(-1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct NewPriceLines { + count: usize, + prev_close: Option, + consec_up: usize, + consec_down: usize, + last: Option, +} + +impl NewPriceLines { + /// Construct a New Price Lines counter that fires at `count` consecutive new + /// closes (classic `8`, stronger `10`/`12`). + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `count < 2`. + pub fn new(count: usize) -> Result { + if count < 2 { + return Err(Error::InvalidPeriod { + message: "new price lines count must be >= 2", + }); + } + Ok(Self { + count, + prev_close: None, + consec_up: 0, + consec_down: 0, + last: None, + }) + } + + /// Configured count threshold. + pub const fn count(&self) -> usize { + self.count + } + + /// Current consecutive streak `(up, down)`. + pub const fn streak(&self) -> (usize, usize) { + (self.consec_up, self.consec_down) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for NewPriceLines { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + let Some(prev) = self.prev_close else { + self.prev_close = Some(close); + return None; + }; + if close > prev { + self.consec_up += 1; + self.consec_down = 0; + } else if close < prev { + self.consec_down += 1; + self.consec_up = 0; + } else { + self.consec_up = 0; + self.consec_down = 0; + } + self.prev_close = Some(close); + + let v = if self.consec_up >= self.count { + -1.0 + } else if self.consec_down >= self.count { + 1.0 + } else { + 0.0 + }; + self.last = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev_close = None; + self.consec_up = 0; + self.consec_down = 0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "NewPriceLines" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, 1_000.0, 0) + } + + #[test] + fn rejects_small_count() { + assert!(matches!( + NewPriceLines::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(NewPriceLines::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let n = NewPriceLines::new(8).unwrap(); + assert_eq!(n.count(), 8); + assert_eq!(n.streak(), (0, 0)); + assert_eq!(n.warmup_period(), 2); + assert_eq!(n.name(), "NewPriceLines"); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + } + + #[test] + fn first_bar_seeds_without_signal() { + let mut n = NewPriceLines::new(3).unwrap(); + assert_eq!(n.update(c(100.0)), None); + assert!(n.update(c(101.0)).is_some()); + } + + #[test] + fn eight_higher_closes_signal_sell() { + let mut n = NewPriceLines::new(8).unwrap(); + // 11 consecutive higher closes -> by the 9th the count reaches 8 -> -1. + let candles: Vec = (0..12).map(|i| c(100.0 + f64::from(i))).collect(); + let last = n.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, -1.0); + } + + #[test] + fn eight_lower_closes_signal_buy() { + let mut n = NewPriceLines::new(8).unwrap(); + let candles: Vec = (0..12).map(|i| c(200.0 - f64::from(i))).collect(); + let last = n.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 1.0); + } + + #[test] + fn break_in_streak_clears_signal() { + let mut n = NewPriceLines::new(3).unwrap(); + n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // streak 3 -> -1 + assert_eq!(n.value(), Some(-1.0)); + // A lower close breaks the up streak. + assert_eq!(n.update(c(102.0)), Some(0.0)); + assert_eq!(n.streak(), (0, 1)); + } + + #[test] + fn unchanged_close_resets_streak() { + let mut n = NewPriceLines::new(3).unwrap(); + n.batch(&[c(100.0), c(101.0), c(102.0)]); + assert_eq!(n.update(c(102.0)), Some(0.0)); // equal -> reset + assert_eq!(n.streak(), (0, 0)); + } + + #[test] + fn reset_clears_state() { + let mut n = NewPriceLines::new(3).unwrap(); + n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); + assert!(n.is_ready()); + n.reset(); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + assert_eq!(n.streak(), (0, 0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0)) + .collect(); + let batch = NewPriceLines::new(8).unwrap().batch(&candles); + let mut b = NewPriceLines::new(8).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/nrtr.rs b/crates/wickra-core/src/indicators/nrtr.rs new file mode 100644 index 0000000..3fa471a --- /dev/null +++ b/crates/wickra-core/src/indicators/nrtr.rs @@ -0,0 +1,259 @@ +//! NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`Nrtr`]: the trailing-reverse line and the trend direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NrtrOutput { + /// The NRTR line — below price in an uptrend, above price in a downtrend. + pub value: f64, + /// Trend direction: `+1.0` up (line below price), `-1.0` down. + pub direction: f64, +} + +/// NRTR (Nick Rypock Trailing Reverse) — a **percentage** trailing-reverse stop +/// that follows the trend extreme and flips when price retraces by a fixed +/// percentage. +/// +/// ```text +/// uptrend: high_water = max(high_water, close) +/// line = high_water · (1 − pct/100) +/// flip down when close < line (reseed low_water = close) +/// downtrend: low_water = min(low_water, close) +/// line = low_water · (1 + pct/100) +/// flip up when close > line (reseed high_water = close) +/// ``` +/// +/// Unlike volatility stops (ATR, σ-of-range), NRTR uses a pure **percentage** +/// retracement: the line trails the highest close reached in the up-leg at a +/// fixed `pct` below it, and a close that gives back that percentage reverses the +/// trend, handing the line to the opposite extreme. This makes it scale-free and +/// trivially tunable — one number sets how much retracement you tolerate. It +/// differs from a fixed percentage *stop-loss* in that it **reverses** (tracks +/// both directions) rather than just exiting. +/// +/// The first bar seeds the up-trend and emits a line immediately. Each `update` is +/// O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Nrtr}; +/// +/// let mut indicator = Nrtr::new(2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let close = 100.0 + f64::from(i); +/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Nrtr { + pct: f64, + direction: f64, + water: f64, + last: Option, +} + +impl Nrtr { + /// Construct an NRTR with the given trailing percentage (e.g. `2.0` for 2%). + /// + /// # Errors + /// + /// Returns [`Error::InvalidParameter`] if `pct` is not finite or is outside + /// `(0, 100)`. + pub fn new(pct: f64) -> Result { + if !pct.is_finite() || pct <= 0.0 || pct >= 100.0 { + return Err(Error::InvalidParameter { + message: "NRTR percentage must be in (0, 100)", + }); + } + Ok(Self { + pct, + direction: 0.0, + water: 0.0, + last: None, + }) + } + + /// Configured trailing percentage. + pub const fn pct(&self) -> f64 { + self.pct + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Nrtr { + type Input = Candle; + type Output = NrtrOutput; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + let down = self.pct / 100.0; + let up = self.pct / 100.0; + + if self.direction == 0.0 { + self.direction = 1.0; + self.water = close; + } else if self.direction > 0.0 { + self.water = self.water.max(close); + let line = self.water * (1.0 - down); + if close < line { + self.direction = -1.0; + self.water = close; + } + } else { + self.water = self.water.min(close); + let line = self.water * (1.0 + up); + if close > line { + self.direction = 1.0; + self.water = close; + } + } + + let line = if self.direction > 0.0 { + self.water * (1.0 - down) + } else { + self.water * (1.0 + up) + }; + let out = NrtrOutput { + value: line, + direction: self.direction, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.direction = 0.0; + self.water = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Nrtr" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, 1_000.0, 0) + } + + #[test] + fn rejects_invalid_pct() { + assert!(matches!( + Nrtr::new(0.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Nrtr::new(100.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Nrtr::new(f64::NAN), + Err(Error::InvalidParameter { .. }) + )); + assert!(Nrtr::new(2.0).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let n = Nrtr::new(2.0).unwrap(); + assert_eq!(n.pct(), 2.0); + assert_eq!(n.warmup_period(), 1); + assert_eq!(n.name(), "Nrtr"); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + } + + #[test] + fn first_bar_emits_up_line() { + let mut n = Nrtr::new(10.0).unwrap(); + let o = n.update(c(100.0)).unwrap(); + assert_eq!(o.direction, 1.0); + // line = 100 * (1 - 0.10) = 90. + assert!((o.value - 90.0).abs() < 1e-9); + } + + #[test] + fn uptrend_keeps_line_below_price() { + let mut n = Nrtr::new(5.0).unwrap(); + let candles: Vec = (0..40).map(|i| c(100.0 + f64::from(i))).collect(); + for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) { + let o = o.unwrap(); + assert_eq!(o.direction, 1.0); + assert!(o.value < candle.close); + } + } + + #[test] + fn reverses_on_retracement() { + let mut n = Nrtr::new(5.0).unwrap(); + // Rise to 120, then drop sharply -> a >5% retracement reverses the trend. + let mut candles: Vec = (0..20).map(|i| c(100.0 + f64::from(i))).collect(); + candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i)))); + let dirs: Vec = n + .batch(&candles) + .into_iter() + .flatten() + .map(|o| o.direction) + .collect(); + assert!(dirs.iter().any(|&d| d > 0.0)); + assert!(dirs.iter().any(|&d| d < 0.0)); + } + + #[test] + fn downtrend_keeps_line_above_price() { + let mut n = Nrtr::new(5.0).unwrap(); + // Establish a downtrend after an initial bar. + let mut candles = vec![c(100.0)]; + candles.extend((0..30).map(|i| c(80.0 - f64::from(i)))); + let out = n.batch(&candles); + let o = out.last().unwrap().unwrap(); + let candle = candles.last().unwrap(); + assert_eq!(o.direction, -1.0); + assert!(o.value > candle.close); + } + + #[test] + fn reset_clears_state() { + let mut n = Nrtr::new(2.0).unwrap(); + n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::>()); + assert!(n.is_ready()); + n.reset(); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0)) + .collect(); + let batch = Nrtr::new(3.0).unwrap().batch(&candles); + let mut b = Nrtr::new(3.0).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/nvi.rs b/crates/wickra-core/src/indicators/nvi.rs new file mode 100644 index 0000000..29a7c8f --- /dev/null +++ b/crates/wickra-core/src/indicators/nvi.rs @@ -0,0 +1,240 @@ +//! Negative Volume Index. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Default starting value for both NVI and PVI; matches Norman Fosback's +/// textbook convention. +const STARTING_INDEX: f64 = 1000.0; + +/// Negative Volume Index (Paul Dysart, popularised by Norman Fosback). +/// +/// A cumulative index that only updates when **volume contracts** — the +/// hypothesis is that smart-money accumulation happens on quiet days, so the +/// NVI tracks the "smart money" leg of price action while ignoring the +/// volume-spike days that retail tends to chase. When today's volume is at or +/// above yesterday's, the NVI is left unchanged. +/// +/// ```text +/// NVI_t = NVI_{t−1} · (1 + (close_t − close_{t−1}) / close_{t−1}) if volume_t < volume_{t−1} +/// NVI_t = NVI_{t−1} otherwise +/// ``` +/// +/// The first bar establishes the baseline at `1000.0` (Fosback's convention). +/// A bar whose previous close is zero contributes no return (avoids dividing +/// by zero). Output is `Some` from the very first bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Nvi}; +/// +/// let mut indicator = Nvi::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Nvi { + prev_close: Option, + prev_volume: Option, + index: f64, + has_emitted: bool, +} + +impl Nvi { + /// Construct a new NVI starting at `1000.0`. + pub const fn new() -> Self { + Self { + prev_close: None, + prev_volume: None, + index: STARTING_INDEX, + has_emitted: false, + } + } + + /// Construct a new NVI with a custom starting baseline. + pub const fn with_baseline(baseline: f64) -> Self { + Self { + prev_close: None, + prev_volume: None, + index: baseline, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.index) + } else { + None + } + } +} + +impl Default for Nvi { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Nvi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // First bar establishes the baseline at `index`; the `if let` handles + // every later bar, which has both predecessors recorded by construction. + if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) { + if candle.volume < pv && pc != 0.0 { + let ret = (candle.close - pc) / pc; + self.index += self.index * ret; + } + } + self.prev_close = Some(candle.close); + self.prev_volume = Some(candle.volume); + self.has_emitted = true; + Some(self.index) + } + + fn reset(&mut self) { + self.prev_close = None; + self.prev_volume = None; + self.index = STARTING_INDEX; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "NVI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let mut n = Nvi::new(); + assert_eq!(n.warmup_period(), 1); + assert_eq!(n.name(), "NVI"); + assert_eq!(n.value(), None); + n.update(c(10.0, 100.0, 0)); + assert_eq!(n.value(), Some(1000.0)); + } + + #[test] + fn default_matches_new() { + let a = Nvi::default(); + let b = Nvi::new(); + assert_eq!(a.warmup_period(), b.warmup_period()); + assert_eq!(a.value(), b.value()); + assert_eq!(a.is_ready(), b.is_ready()); + } + + #[test] + fn first_bar_seeds_baseline() { + let mut n = Nvi::new(); + assert_relative_eq!( + n.update(c(10.0, 100.0, 0)).unwrap(), + 1000.0, + epsilon = 1e-12 + ); + } + + #[test] + fn volume_rise_leaves_index_unchanged() { + // Bar 2 has higher volume than bar 1, so NVI does not update even though + // the close changed. + let mut n = Nvi::new(); + n.update(c(10.0, 100.0, 0)); + let v = n.update(c(11.0, 200.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn volume_fall_applies_percent_change() { + // Bar 2 has lower volume; NVI absorbs the percent close change. + // 1000 * (1 + (11 - 10)/10) = 1100. + let mut n = Nvi::new(); + n.update(c(10.0, 200.0, 0)); + let v = n.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1100.0, epsilon = 1e-12); + } + + #[test] + fn equal_volume_leaves_index_unchanged() { + // The textbook rule says "strictly less"; equal volume is skipped. + let mut n = Nvi::new(); + n.update(c(10.0, 100.0, 0)); + let v = n.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn zero_previous_close_contributes_no_return() { + // The previous close is exactly zero — guarded against div-by-zero. + let mut n = Nvi::new(); + n.update(c(0.0, 200.0, 0)); + let v = n.update(c(5.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn custom_baseline() { + let mut n = Nvi::with_baseline(100.0); + assert_relative_eq!(n.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + ((i % 7) as f64) * 10.0, + i, + ) + }) + .collect(); + let mut a = Nvi::new(); + let mut b = Nvi::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut n = Nvi::new(); + n.batch(&[c(10.0, 200.0, 0), c(11.0, 100.0, 1)]); + assert!(n.is_ready()); + n.reset(); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + // After reset, first bar re-seeds at the default baseline. + assert_relative_eq!(n.update(c(50.0, 1.0, 2)).unwrap(), 1000.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/ob_imbalance_full.rs b/crates/wickra-core/src/indicators/ob_imbalance_full.rs new file mode 100644 index 0000000..38d62ef --- /dev/null +++ b/crates/wickra-core/src/indicators/ob_imbalance_full.rs @@ -0,0 +1,157 @@ +//! Order-Book Imbalance over the full visible depth. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Order-Book Imbalance aggregated over the full visible depth of each side. +/// +/// Sums the resting size of every bid level and every ask level in the +/// snapshot and compares them: +/// +/// ```text +/// bidDepth = Σ size of all bids +/// askDepth = Σ size of all asks +/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth) +/// ``` +/// +/// The output lies in `[−1, +1]`. A book with zero total size yields `0`. Use +/// [`crate::OrderBookImbalanceTopN`] to bound the depth to the most relevant +/// near-touch levels instead of the full visible book. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceFull}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()], +/// vec![Level::new(101.0, 0.5).unwrap(), Level::new(102.0, 0.5).unwrap()], +/// ) +/// .unwrap(); +/// let mut obi = OrderBookImbalanceFull::new(); +/// assert_eq!(obi.update(book), Some(0.5)); // (3 − 1) / (3 + 1) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OrderBookImbalanceFull { + has_emitted: bool, +} + +impl OrderBookImbalanceFull { + /// Construct a new full-depth imbalance indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for OrderBookImbalanceFull { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let bid_depth: f64 = book.bids.iter().map(|l| l.size).sum(); + let ask_depth: f64 = book.asks.iter().map(|l| l.size).sum(); + let total = bid_depth + ask_depth; + if total <= 0.0 { + return Some(0.0); + } + Some((bid_depth - ask_depth) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OrderBookImbalanceFull" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let obi = OrderBookImbalanceFull::new(); + assert_eq!(obi.name(), "OrderBookImbalanceFull"); + assert_eq!(obi.warmup_period(), 1); + assert!(!obi.is_ready()); + } + + #[test] + fn sums_full_depth() { + let mut obi = OrderBookImbalanceFull::new(); + let b = book(&[(100.0, 2.0), (99.0, 2.0)], &[(101.0, 1.0), (102.0, 1.0)]); + // bidDepth 4, askDepth 2 -> (4 - 2) / 6 = 1/3. + assert_eq!(obi.update(b), Some(1.0 / 3.0)); + assert!(obi.is_ready()); + } + + #[test] + fn ask_heavy_full_depth_is_negative() { + let mut obi = OrderBookImbalanceFull::new(); + let b = book(&[(100.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]); + // (1 - 3) / 4 = -0.5. + assert_eq!(obi.update(b), Some(-0.5)); + } + + #[test] + fn zero_size_is_zero() { + let mut obi = OrderBookImbalanceFull::new(); + assert_eq!( + obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let bid = 1.0 + f64::from(i % 3); + book(&[(100.0, bid), (99.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]) + }) + .collect(); + let mut a = OrderBookImbalanceFull::new(); + let mut b = OrderBookImbalanceFull::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obi = OrderBookImbalanceFull::new(); + obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(obi.is_ready()); + obi.reset(); + assert!(!obi.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/ob_imbalance_top1.rs b/crates/wickra-core/src/indicators/ob_imbalance_top1.rs new file mode 100644 index 0000000..7224aba --- /dev/null +++ b/crates/wickra-core/src/indicators/ob_imbalance_top1.rs @@ -0,0 +1,176 @@ +//! Order-Book Imbalance at the top of book. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Order-Book Imbalance (top-of-book). +/// +/// Measures the pressure between the best bid and best ask by comparing their +/// resting sizes: +/// +/// ```text +/// imbalance = (bidSize₁ − askSize₁) / (bidSize₁ + askSize₁) +/// ``` +/// +/// The output lies in `[−1, +1]`: `+1` means all size sits on the bid (buy +/// pressure), `−1` means all size sits on the ask (sell pressure), `0` means a +/// balanced top of book. A book with zero size on both top levels yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. The indicator is stateless and ready +/// after the first snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTop1}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 3.0).unwrap()], +/// vec![Level::new(101.0, 1.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut obi = OrderBookImbalanceTop1::new(); +/// assert_eq!(obi.update(book), Some(0.5)); // (3 − 1) / (3 + 1) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OrderBookImbalanceTop1 { + has_emitted: bool, +} + +impl OrderBookImbalanceTop1 { + /// Construct a new top-of-book imbalance indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for OrderBookImbalanceTop1 { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return Some(0.0); + }; + let total = bid.size + ask.size; + if total <= 0.0 { + return Some(0.0); + } + Some((bid.size - ask.size) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OrderBookImbalanceTop1" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let obi = OrderBookImbalanceTop1::new(); + assert_eq!(obi.name(), "OrderBookImbalanceTop1"); + assert_eq!(obi.warmup_period(), 1); + assert!(!obi.is_ready()); + } + + #[test] + fn balanced_top_is_zero() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])), + Some(0.0) + ); + assert!(obi.is_ready()); + } + + #[test] + fn bid_heavy_is_positive() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])), + Some(0.5) + ); + } + + #[test] + fn ask_heavy_is_negative() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])), + Some(-0.5) + ); + } + + #[test] + fn zero_size_top_is_zero() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])), + Some(0.0) + ); + } + + #[test] + fn empty_book_is_zero() { + let mut obi = OrderBookImbalanceTop1::new(); + assert_eq!( + obi.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let bid = 1.0 + f64::from(i % 5); + book(&[(100.0, bid)], &[(101.0, 2.0)]) + }) + .collect(); + let mut a = OrderBookImbalanceTop1::new(); + let mut b = OrderBookImbalanceTop1::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obi = OrderBookImbalanceTop1::new(); + obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(obi.is_ready()); + obi.reset(); + assert!(!obi.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/ob_imbalance_topn.rs b/crates/wickra-core/src/indicators/ob_imbalance_topn.rs new file mode 100644 index 0000000..de0c2d5 --- /dev/null +++ b/crates/wickra-core/src/indicators/ob_imbalance_topn.rs @@ -0,0 +1,186 @@ +//! Order-Book Imbalance over the top-N levels. + +use crate::error::{Error, Result}; +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Order-Book Imbalance aggregated over the top-N levels of each side. +/// +/// Generalises [`crate::OrderBookImbalanceTop1`] to a configurable depth: it +/// sums the resting size of the best `levels` bids and the best `levels` asks +/// and compares them: +/// +/// ```text +/// bidDepth = Σ size of the best `levels` bids +/// askDepth = Σ size of the best `levels` asks +/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth) +/// ``` +/// +/// If a side has fewer than `levels` levels, all available levels are summed. +/// The output lies in `[−1, +1]`; a book with zero size across the summed +/// levels yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTopN}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()], +/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 1.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); +/// assert_eq!(obi.update(book), Some(0.2)); // (3 − 2) / (3 + 2) +/// ``` +#[derive(Debug, Clone)] +pub struct OrderBookImbalanceTopN { + levels: usize, + has_emitted: bool, +} + +impl OrderBookImbalanceTopN { + /// Construct a top-N imbalance indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `levels` is zero. + pub fn new(levels: usize) -> Result { + if levels == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + levels, + has_emitted: false, + }) + } + + /// The configured number of levels summed per side. + pub fn levels(&self) -> usize { + self.levels + } +} + +impl Indicator for OrderBookImbalanceTopN { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum(); + let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum(); + let total = bid_depth + ask_depth; + if total <= 0.0 { + return Some(0.0); + } + Some((bid_depth - ask_depth) / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OrderBookImbalanceTopN" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn rejects_zero_levels() { + assert!(matches!( + OrderBookImbalanceTopN::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let obi = OrderBookImbalanceTopN::new(3).unwrap(); + assert_eq!(obi.name(), "OrderBookImbalanceTopN"); + assert_eq!(obi.warmup_period(), 1); + assert_eq!(obi.levels(), 3); + assert!(!obi.is_ready()); + } + + #[test] + fn sums_top_two_levels() { + let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); + let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]); + // bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2. + assert_eq!(obi.update(b), Some(0.2)); + assert!(obi.is_ready()); + } + + #[test] + fn caps_at_available_depth() { + // Only one level per side, N = 5 -> uses what exists. + let mut obi = OrderBookImbalanceTopN::new(5).unwrap(); + assert_eq!( + obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])), + Some(0.5) + ); + } + + #[test] + fn zero_size_is_zero() { + let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); + assert_eq!( + obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let ask = 1.0 + f64::from(i % 4); + book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)]) + }) + .collect(); + let mut a = OrderBookImbalanceTopN::new(2).unwrap(); + let mut b = OrderBookImbalanceTopN::new(2).unwrap(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obi = OrderBookImbalanceTopN::new(2).unwrap(); + obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(obi.is_ready()); + obi.reset(); + assert!(!obi.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/obv.rs b/crates/wickra-core/src/indicators/obv.rs new file mode 100644 index 0000000..df0986c --- /dev/null +++ b/crates/wickra-core/src/indicators/obv.rs @@ -0,0 +1,190 @@ +//! On-Balance Volume. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// On-Balance Volume: a cumulative signed-volume series. +/// +/// Each candle adds `+volume`, `-volume`, or `0` depending on whether its close +/// is above, below, or equal to the previous close. The first value (after the +/// first candle) is conventionally `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Obv}; +/// +/// let mut indicator = Obv::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Obv { + prev_close: Option, + total: f64, + has_emitted: bool, +} + +impl Obv { + /// Construct a new OBV instance starting at zero. + pub const fn new() -> Self { + Self { + prev_close: None, + total: 0.0, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.total) + } else { + None + } + } +} + +impl Indicator for Obv { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // The first candle establishes the baseline at 0; subsequent candles + // add/subtract their volume based on close direction. Equal closes do nothing. + if let Some(prev) = self.prev_close { + if candle.close > prev { + self.total += candle.volume; + } else if candle.close < prev { + self.total -= candle.volume; + } + } + self.prev_close = Some(candle.close); + self.has_emitted = true; + Some(self.total) + } + + fn reset(&mut self) { + self.prev_close = None; + self.total = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OBV" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64) -> Candle { + Candle::new(close, close, close, close, volume, 0).unwrap() + } + + /// Cover the `value()` Some branch (line 47) and the Indicator-impl + /// `warmup_period` (79-81) + `name` (87-89). `reset_clears_state` + /// hits only the None branch of `value()`; the metadata methods were + /// never queried. + #[test] + fn accessors_and_metadata() { + let mut obv = Obv::new(); + assert_eq!(obv.warmup_period(), 1); + assert_eq!(obv.name(), "OBV"); + assert_eq!(obv.value(), None); + obv.update(c(10.0, 100.0)); + // Baseline 0 — value() Some branch. + assert_eq!(obv.value(), Some(0.0)); + } + + #[test] + fn first_candle_baseline_zero() { + let mut obv = Obv::new(); + assert_relative_eq!(obv.update(c(10.0, 100.0)).unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn up_close_adds_volume() { + let mut obv = Obv::new(); + obv.update(c(10.0, 100.0)); // baseline 0 + let v = obv.update(c(11.0, 50.0)).unwrap(); + assert_relative_eq!(v, 50.0, epsilon = 1e-12); + } + + #[test] + fn down_close_subtracts_volume() { + let mut obv = Obv::new(); + obv.update(c(10.0, 100.0)); + let v = obv.update(c(9.0, 50.0)).unwrap(); + assert_relative_eq!(v, -50.0, epsilon = 1e-12); + } + + #[test] + fn equal_close_does_nothing() { + let mut obv = Obv::new(); + obv.update(c(10.0, 100.0)); + let v = obv.update(c(10.0, 50.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn cumulative_sequence() { + let candles = vec![ + c(10.0, 100.0), // baseline + c(11.0, 20.0), // +20 + c(10.5, 30.0), // -30 + c(10.5, 40.0), // unchanged + c(12.0, 10.0), // +10 + ]; + let mut obv = Obv::new(); + let out = obv.batch(&candles); + assert_relative_eq!(out[0].unwrap(), 0.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 20.0, epsilon = 1e-12); + assert_relative_eq!(out[2].unwrap(), -10.0, epsilon = 1e-12); + assert_relative_eq!(out[3].unwrap(), -10.0, epsilon = 1e-12); + assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..20) + .map(|i| { + let cl = 10.0 + (f64::from(i) * 0.5).sin(); + c(cl, 1.0) + }) + .collect(); + let mut a = Obv::new(); + let mut b = Obv::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut obv = Obv::new(); + obv.batch(&[c(10.0, 50.0), c(11.0, 30.0)]); + assert!(obv.is_ready()); + obv.reset(); + assert!(!obv.is_ready()); + assert_eq!(obv.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/oi_delta.rs b/crates/wickra-core/src/indicators/oi_delta.rs new file mode 100644 index 0000000..99a1254 --- /dev/null +++ b/crates/wickra-core/src/indicators/oi_delta.rs @@ -0,0 +1,142 @@ +//! Open-Interest Delta — the tick-over-tick change in open interest. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Open-Interest Delta — the change in open interest from the previous tick. +/// +/// ```text +/// delta = openInterestₜ − openInterestₜ₋₁ +/// ``` +/// +/// Open interest is the count of outstanding contracts; its change separates new +/// positioning from mere turnover. Read together with price, rising OI confirms +/// a trend (fresh money entering) while falling OI flags an unwind (positions +/// closing) — the raw input to the [OI / price divergence] signal. A positive +/// delta is net position-building, a negative delta net liquidation/closing. +/// +/// The first tick only seeds the previous value and returns `None`; from the +/// second tick on the indicator emits the delta. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// [OI / price divergence]: crate::OIPriceDivergence +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, OpenInterestDelta}; +/// +/// fn tick(oi: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut oid = OpenInterestDelta::new(); +/// assert_eq!(oid.update(tick(1_000.0)), None); // seeds the previous OI +/// assert_eq!(oid.update(tick(1_250.0)), Some(250.0)); +/// assert_eq!(oid.update(tick(1_100.0)), Some(-150.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OpenInterestDelta { + prev: Option, + has_emitted: bool, +} + +impl OpenInterestDelta { + /// Construct a new open-interest delta indicator. + #[must_use] + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for OpenInterestDelta { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let oi = tick.open_interest; + let delta = self.prev.map(|prev| oi - prev); + self.prev = Some(oi); + if delta.is_some() { + self.has_emitted = true; + } + delta + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OpenInterestDelta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(oi: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let oid = OpenInterestDelta::new(); + assert_eq!(oid.name(), "OpenInterestDelta"); + assert_eq!(oid.warmup_period(), 2); + assert!(!oid.is_ready()); + } + + #[test] + fn seeds_then_emits_delta() { + let mut oid = OpenInterestDelta::new(); + assert_eq!(oid.update(tick(1_000.0)), None); + assert!(!oid.is_ready()); + assert_eq!(oid.update(tick(1_250.0)), Some(250.0)); + assert!(oid.is_ready()); + assert_eq!(oid.update(tick(1_100.0)), Some(-150.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(1_000.0 + f64::from(i * i % 13) * 10.0)) + .collect(); + let mut a = OpenInterestDelta::new(); + let mut b = OpenInterestDelta::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut oid = OpenInterestDelta::new(); + oid.update(tick(1_000.0)); + oid.update(tick(1_250.0)); + assert!(oid.is_ready()); + oid.reset(); + assert!(!oid.is_ready()); + // After reset the next tick only re-seeds, returning None. + assert_eq!(oid.update(tick(2_000.0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/oi_price_divergence.rs b/crates/wickra-core/src/indicators/oi_price_divergence.rs new file mode 100644 index 0000000..08e0d47 --- /dev/null +++ b/crates/wickra-core/src/indicators/oi_price_divergence.rs @@ -0,0 +1,193 @@ +//! Open-Interest / Price Divergence — relative OI change minus relative price +//! change over a window. + +use std::collections::VecDeque; + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Open-Interest / Price Divergence — the gap between how fast open interest and +/// the mark price have moved over the trailing window of `window` ticks. +/// +/// ```text +/// oiChange = (openInterestₜ − openInterestₜ₋ₙ) / openInterestₜ₋ₙ +/// priceChange = (markPriceₜ − markPriceₜ₋ₙ) / markPriceₜ₋ₙ +/// divergence = oiChange − priceChange (n = window) +/// ``` +/// +/// Reading the two together is a classic positioning signal: open interest +/// rising while price falls (a positive divergence) marks fresh shorts piling +/// in; open interest falling while price rises marks a short squeeze / unwind. +/// A value near zero means OI and price moved in step. If the reference open +/// interest is zero, the OI term contributes zero (no base to grow from). +/// +/// The indicator warms up for `window + 1` ticks — `update` returns `None` until +/// the window spans a full `window`-tick lookback — then emits the divergence, +/// maintained in O(1) per tick via a ring buffer. +/// +/// `Input = DerivativesTick`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, OIPriceDivergence}; +/// +/// fn tick(oi: f64, mark: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut div = OIPriceDivergence::new(1).unwrap(); +/// assert_eq!(div.update(tick(1_000.0, 100.0)), None); +/// // OI +10% while price flat -> divergence +0.1. +/// assert!((div.update(tick(1_100.0, 100.0)).unwrap() - 0.1).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone)] +pub struct OIPriceDivergence { + window: usize, + history: VecDeque<(f64, f64)>, +} + +impl OIPriceDivergence { + /// Construct an OI / price divergence over a window of `window` ticks. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window + 1), + }) + } + + /// The configured window length, in ticks. + #[must_use] + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for OIPriceDivergence { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.history + .push_back((tick.open_interest, tick.mark_price)); + if self.history.len() > self.window + 1 { + self.history.pop_front(); + } + if self.history.len() < self.window + 1 { + return None; + } + let (old_oi, old_mark) = *self.history.front().expect("len == window + 1"); + let (cur_oi, cur_mark) = *self.history.back().expect("len == window + 1"); + // Open interest can legitimately be zero; with no base there is no + // relative change to report from it. + let oi_change = if old_oi == 0.0 { + 0.0 + } else { + (cur_oi - old_oi) / old_oi + }; + // The mark price is finite and positive by `DerivativesTick` + // construction, so the denominator is always well-defined. + let price_change = (cur_mark - old_mark) / old_mark; + Some(oi_change - price_change) + } + + fn reset(&mut self) { + self.history.clear(); + } + + fn warmup_period(&self) -> usize { + self.window + 1 + } + + fn is_ready(&self) -> bool { + self.history.len() == self.window + 1 + } + + fn name(&self) -> &'static str { + "OIPriceDivergence" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(oi: f64, mark: f64) -> DerivativesTick { + DerivativesTick::new_unchecked(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(OIPriceDivergence::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let div = OIPriceDivergence::new(5).unwrap(); + assert_eq!(div.name(), "OIPriceDivergence"); + assert_eq!(div.warmup_period(), 6); + assert_eq!(div.window(), 5); + assert!(!div.is_ready()); + } + + #[test] + fn oi_up_price_flat_is_positive() { + let mut div = OIPriceDivergence::new(1).unwrap(); + assert_eq!(div.update(tick(1_000.0, 100.0)), None); + let out = div.update(tick(1_100.0, 100.0)).unwrap(); + assert!((out - 0.1).abs() < 1e-12); + assert!(div.is_ready()); + } + + #[test] + fn oi_flat_price_up_is_negative() { + let mut div = OIPriceDivergence::new(1).unwrap(); + div.update(tick(1_000.0, 100.0)); + // OI flat, price +10% -> divergence -0.1. + let out = div.update(tick(1_000.0, 110.0)).unwrap(); + assert!((out + 0.1).abs() < 1e-12); + } + + #[test] + fn zero_reference_oi_drops_oi_term() { + let mut div = OIPriceDivergence::new(1).unwrap(); + div.update(tick(0.0, 100.0)); + // Reference OI is zero -> only the price term contributes: -(110-100)/100. + let out = div.update(tick(500.0, 110.0)).unwrap(); + assert!((out + 0.1).abs() < 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..30) + .map(|i| tick(1_000.0 + f64::from(i % 7) * 10.0, 100.0 + f64::from(i % 5))) + .collect(); + let mut a = OIPriceDivergence::new(4).unwrap(); + let mut b = OIPriceDivergence::new(4).unwrap(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut div = OIPriceDivergence::new(1).unwrap(); + div.update(tick(1_000.0, 100.0)); + div.update(tick(1_100.0, 100.0)); + assert!(div.is_ready()); + div.reset(); + assert!(!div.is_ready()); + assert_eq!(div.update(tick(1_000.0, 100.0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/oi_to_volume_ratio.rs b/crates/wickra-core/src/indicators/oi_to_volume_ratio.rs new file mode 100644 index 0000000..510a84f --- /dev/null +++ b/crates/wickra-core/src/indicators/oi_to_volume_ratio.rs @@ -0,0 +1,154 @@ +//! OI-to-Volume Ratio — open interest relative to traded volume. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// OI-to-Volume Ratio — open interest divided by the tick's total taker volume, a +/// measure of how much position is *held* versus *turned over*. +/// +/// ```text +/// OIVR = open_interest / (taker_buy_volume + taker_sell_volume) +/// ``` +/// +/// A high ratio means open interest dwarfs the volume trading it — positions are +/// being held, not churned (low participation, potential complacency or a coiling +/// market). A low ratio means heavy volume relative to outstanding interest — +/// active churn, often around breakouts or capitulation. Watching the ratio change +/// distinguishes new-money trends (OI and volume both rising) from short-covering +/// or position rolls. +/// +/// The ratio is non-negative; a tick with zero taker volume reports `0` rather than +/// dividing by zero. It is stateless — each tick yields one value (no warmup). Each +/// `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, OiToVolumeRatio}; +/// +/// let mut indicator = OiToVolumeRatio::new(); +/// let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 5_000.0, 0.0, 0.0, 400.0, 600.0, 0.0, 0.0, 0).unwrap(); +/// let oivr = indicator.update(tick).unwrap(); +/// assert!((oivr - 5.0).abs() < 1e-12); // 5000 / (400 + 600) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OiToVolumeRatio { + ready: bool, +} + +impl OiToVolumeRatio { + /// Construct a new OI-to-Volume Ratio. The indicator is parameter-free. + #[must_use] + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for OiToVolumeRatio { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let volume = tick.taker_buy_volume + tick.taker_sell_volume; + let ratio = if volume > 0.0 { + tick.open_interest / volume + } else { + 0.0 + }; + self.ready = true; + Some(ratio) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "OiToVolumeRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn tick(oi: f64, buy: f64, sell: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, buy, sell, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let o = OiToVolumeRatio::new(); + assert_eq!(o.warmup_period(), 1); + assert_eq!(o.name(), "OiToVolumeRatio"); + assert!(!o.is_ready()); + } + + #[test] + fn ratio_reference_value() { + let mut o = OiToVolumeRatio::new(); + assert_relative_eq!( + o.update(tick(5_000.0, 400.0, 600.0)).unwrap(), + 5.0, + epsilon = 1e-12 + ); + } + + #[test] + fn more_volume_lowers_ratio() { + let mut o = OiToVolumeRatio::new(); + let held = o.update(tick(5_000.0, 100.0, 100.0)).unwrap(); + let churned = o.update(tick(5_000.0, 1_000.0, 1_000.0)).unwrap(); + assert!(churned < held); + } + + #[test] + fn zero_volume_is_zero() { + let mut o = OiToVolumeRatio::new(); + assert_relative_eq!( + o.update(tick(5_000.0, 0.0, 0.0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn ready_after_first_update() { + let mut o = OiToVolumeRatio::new(); + assert!(!o.is_ready()); + o.update(tick(5_000.0, 100.0, 100.0)); + assert!(o.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut o = OiToVolumeRatio::new(); + o.update(tick(5_000.0, 100.0, 100.0)); + assert!(o.is_ready()); + o.reset(); + assert!(!o.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..40) + .map(|i| tick(5_000.0, 100.0 + f64::from(i), 100.0)) + .collect(); + let batch = OiToVolumeRatio::new().batch(&ticks); + let mut b = OiToVolumeRatio::new(); + let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/oi_weighted.rs b/crates/wickra-core/src/indicators/oi_weighted.rs new file mode 100644 index 0000000..635f59b --- /dev/null +++ b/crates/wickra-core/src/indicators/oi_weighted.rs @@ -0,0 +1,151 @@ +//! Open-Interest-Weighted Price — cumulative mark price weighted by open +//! interest. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Open-Interest-Weighted Price — the running mean mark price, weighting each +/// tick by its open interest. +/// +/// ```text +/// oiWeighted = Σ(markPrice · openInterest) / Σ openInterest +/// ``` +/// +/// Where a plain mean treats every tick equally, the OI-weighted price pulls +/// toward the levels at which the most contracts were actually outstanding — the +/// price the bulk of open positioning sits around, a fair-value anchor for +/// liquidations and mean-reversion. The accumulation runs from construction; +/// call [`reset`] at each session boundary to re-anchor. Until any open interest +/// has accrued the indicator returns the current mark price. +/// +/// `Input = DerivativesTick`, `Output = f64`. Ready after the first tick. +/// +/// [`reset`]: crate::Indicator::reset +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, OIWeighted}; +/// +/// fn tick(mark: f64, oi: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut oiw = OIWeighted::new(); +/// assert_eq!(oiw.update(tick(100.0, 10.0)), Some(100.0)); +/// // (100·10 + 110·30) / (10 + 30) = 4300 / 40 = 107.5. +/// assert_eq!(oiw.update(tick(110.0, 30.0)), Some(107.5)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OIWeighted { + sum_weighted: f64, + sum_oi: f64, + has_emitted: bool, +} + +impl OIWeighted { + /// Construct a new OI-weighted price indicator. + #[must_use] + pub const fn new() -> Self { + Self { + sum_weighted: 0.0, + sum_oi: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for OIWeighted { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + self.sum_weighted += tick.mark_price * tick.open_interest; + self.sum_oi += tick.open_interest; + if self.sum_oi == 0.0 { + // No open interest has accrued yet: fall back to the mark price. + return Some(tick.mark_price); + } + Some(self.sum_weighted / self.sum_oi) + } + + fn reset(&mut self) { + self.sum_weighted = 0.0; + self.sum_oi = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OIWeighted" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(mark: f64, oi: f64) -> DerivativesTick { + DerivativesTick::new_unchecked(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let oiw = OIWeighted::new(); + assert_eq!(oiw.name(), "OIWeighted"); + assert_eq!(oiw.warmup_period(), 1); + assert!(!oiw.is_ready()); + } + + #[test] + fn weights_by_open_interest() { + let mut oiw = OIWeighted::new(); + assert_eq!(oiw.update(tick(100.0, 10.0)), Some(100.0)); + // (100·10 + 110·30) / 40 = 107.5. + assert_eq!(oiw.update(tick(110.0, 30.0)), Some(107.5)); + assert!(oiw.is_ready()); + } + + #[test] + fn zero_open_interest_falls_back_to_mark() { + let mut oiw = OIWeighted::new(); + assert_eq!(oiw.update(tick(123.0, 0.0)), Some(123.0)); + // Still no OI on the second zero-OI tick. + assert_eq!(oiw.update(tick(125.0, 0.0)), Some(125.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4))) + .collect(); + let mut a = OIWeighted::new(); + let mut b = OIWeighted::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_re_anchors() { + let mut oiw = OIWeighted::new(); + oiw.update(tick(100.0, 10.0)); + oiw.update(tick(110.0, 30.0)); + assert!(oiw.is_ready()); + oiw.reset(); + assert!(!oiw.is_ready()); + // After reset the accumulation starts again from the next tick. + assert_eq!(oiw.update(tick(200.0, 5.0)), Some(200.0)); + } +} diff --git a/crates/wickra-core/src/indicators/omega_ratio.rs b/crates/wickra-core/src/indicators/omega_ratio.rs new file mode 100644 index 0000000..faaa892 --- /dev/null +++ b/crates/wickra-core/src/indicators/omega_ratio.rs @@ -0,0 +1,194 @@ +//! Rolling Omega Ratio — gain-to-loss ratio above a threshold. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Omega Ratio. +/// +/// Over the trailing window of `period` returns and a target `threshold`: +/// +/// ```text +/// gains = Σ max(0, r − threshold) +/// losses = Σ max(0, threshold − r) +/// Omega = gains / losses +/// ``` +/// +/// Omega expresses how many units of "above-threshold" return the strategy +/// produces per unit of "below-threshold" shortfall. By construction `Omega +/// ≥ 0`; a window where every return clears the threshold has zero losses and +/// the indicator returns `f64::INFINITY` (in keeping with the standard +/// definition). The Sharpe Ratio collapses risk into a single second-moment +/// number; Omega keeps the full shape of the loss tail. +/// +/// Each `update` is O(period) because the partial sums are recomputed across +/// the window — adequate for typical backtest windows (`period ≤ 252`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, OmegaRatio}; +/// +/// let mut o = OmegaRatio::new(20, 0.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = o.update((f64::from(i) * 0.2).sin() * 0.01); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct OmegaRatio { + period: usize, + threshold: f64, + window: VecDeque, +} + +impl OmegaRatio { + /// Construct a new rolling Omega Ratio. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize, threshold: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + threshold, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured threshold (per-period). + pub const fn threshold(&self) -> f64 { + self.threshold + } +} + +impl Indicator for OmegaRatio { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut gains = 0.0_f64; + let mut losses = 0.0_f64; + for &r in &self.window { + let d = r - self.threshold; + if d >= 0.0 { + gains += d; + } else { + losses += -d; + } + } + if losses == 0.0 { + return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY }); + } + Some(gains / losses) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "OmegaRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(OmegaRatio::new(0, 0.0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let o = OmegaRatio::new(10, 0.001).unwrap(); + assert_eq!(o.period(), 10); + assert_relative_eq!(o.threshold(), 0.001, epsilon = 1e-12); + assert_eq!(o.name(), "OmegaRatio"); + assert_eq!(o.warmup_period(), 10); + } + + #[test] + fn all_above_threshold_yields_infinity() { + let mut o = OmegaRatio::new(4, 0.0).unwrap(); + let out = o.batch(&[0.01, 0.02, 0.03, 0.04]); + assert!(out[3].unwrap().is_infinite()); + } + + #[test] + fn flat_at_threshold_yields_zero() { + // Every return equals threshold -> gains = losses = 0 -> 0 by + // convention. + let mut o = OmegaRatio::new(4, 0.01).unwrap(); + let out = o.batch(&[0.01; 4]); + assert_eq!(out[3], Some(0.0)); + } + + #[test] + fn reference_value() { + // returns = [-0.02, 0.01, -0.01, 0.03], threshold = 0. + // gains = 0.01 + 0.03 = 0.04 + // losses = 0.02 + 0.01 = 0.03 + // Omega = 0.04 / 0.03 ≈ 1.3333... + let mut o = OmegaRatio::new(4, 0.0).unwrap(); + let out = o.batch(&[-0.02, 0.01, -0.01, 0.03]); + assert_relative_eq!(out[3].unwrap(), 0.04 / 0.03, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut o = OmegaRatio::new(3, 0.0).unwrap(); + assert_eq!(o.update(f64::NAN), None); + assert_eq!(o.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut o = OmegaRatio::new(3, 0.0).unwrap(); + o.batch(&[0.01, -0.02, 0.005]); + assert!(o.is_ready()); + o.reset(); + assert!(!o.is_ready()); + assert_eq!(o.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..50).map(|i| (f64::from(i) * 0.4).sin() * 0.01).collect(); + let batch = OmegaRatio::new(10, 0.0).unwrap().batch(&returns); + let mut s = OmegaRatio::new(10, 0.0).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/on_neck.rs b/crates/wickra-core/src/indicators/on_neck.rs new file mode 100644 index 0000000..8864982 --- /dev/null +++ b/crates/wickra-core/src/indicators/on_neck.rs @@ -0,0 +1,189 @@ +//! On-Neck candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// On-Neck — a 2-bar bearish continuation. In a decline a long black candle is +/// followed by a white candle that opens below the black bar's low yet rallies +/// only as far as the black bar's *low* (the "neckline"). The feeble bounce shows +/// sellers remain in control. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1 black & long +/// bar2 white, opens below bar1's low (open2 < low1) +/// bar2 closes at bar1's low (the neckline) (|close2 − low1| <= 0.05 · range1) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. On-Neck is a +/// single-direction (bearish-only) continuation, so it never emits `+1.0`. The +/// first bar always returns `0.0` because the two-bar window is not yet filled. +/// Body and neckline thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, OnNeck}; +/// +/// let mut indicator = OnNeck::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(7.0, 9.1, 6.9, 9.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OnNeck { + prev: Option, + has_emitted: bool, +} + +impl OnNeck { + /// Construct a new On-Neck detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for OnNeck { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + if range1 <= 0.0 { + return Some(0.0); + } + if bar1.close < bar1.open + && (bar1.open - bar1.close) >= 0.5 * range1 + && candle.close > candle.open + && candle.open < bar1.low + && (candle.close - bar1.low).abs() <= 0.05 * range1 + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OnNeck" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = OnNeck::new(); + assert_eq!(t.name(), "OnNeck"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn on_neck_is_minus_one() { + let mut t = OnNeck::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(7.0, 9.1, 6.9, 9.0, 1)), Some(-1.0)); + } + + #[test] + fn close_into_body_yields_zero() { + let mut t = OnNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + // Closes at the prior close, not the low -> in-neck, not on-neck. + assert_eq!(t.update(c(7.0, 10.2, 6.9, 10.1, 1)), Some(0.0)); + } + + #[test] + fn second_bar_black_yields_zero() { + let mut t = OnNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + assert_eq!(t.update(c(9.5, 9.6, 6.9, 9.0, 1)), Some(0.0)); + } + + #[test] + fn opens_above_low_yields_zero() { + let mut t = OnNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + // Opens above bar1's low. + assert_eq!(t.update(c(9.5, 10.1, 9.4, 10.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = OnNeck::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 5.0, base + 5.1, base - 1.0, base, i) + }) + .collect(); + let mut a = OnNeck::new(); + let mut b = OnNeck::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = OnNeck::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + t.update(c(7.0, 9.1, 6.9, 9.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_first_bar_yields_zero() { + let mut t = OnNeck::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/open_interest_momentum.rs b/crates/wickra-core/src/indicators/open_interest_momentum.rs new file mode 100644 index 0000000..f43a9c6 --- /dev/null +++ b/crates/wickra-core/src/indicators/open_interest_momentum.rs @@ -0,0 +1,221 @@ +//! Open-Interest Momentum — the rate of change of open interest over a lookback. + +use std::collections::VecDeque; + +use crate::derivatives::DerivativesTick; +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Open-Interest Momentum — the percentage rate of change of open interest over a +/// `period`-tick lookback. +/// +/// ```text +/// OIM = 100 · (OI_t − OI_{t−period}) / OI_{t−period} +/// ``` +/// +/// Where [`OIDelta`](crate::OIDelta) reports the single-tick change in open +/// interest, OI Momentum measures the trend in positioning over a window: positive +/// values mean open interest is expanding (new money entering — a position build +/// that fuels the prevailing move), negative values mean it is contracting +/// (positions being closed — deleveraging or short-covering). Read alongside price: +/// rising OI with rising price is a strong new-long trend, while rising price with +/// falling OI is a short-covering rally on borrowed time. +/// +/// The output is a percentage and may be negative. A zero base open interest +/// `period` ticks ago reports `0` rather than dividing by zero. The first value +/// lands after `period + 1` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, OpenInterestMomentum}; +/// +/// let mut indicator = OpenInterestMomentum::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let oi = 1_000.0 + f64::from(i) * 100.0; +/// let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap(); +/// last = indicator.update(tick); +/// } +/// assert!(last.unwrap() > 0.0); // expanding OI +/// ``` +#[derive(Debug, Clone)] +pub struct OpenInterestMomentum { + period: usize, + window: VecDeque, + last: Option, +} + +impl OpenInterestMomentum { + /// Construct an OI Momentum over a `period`-tick lookback. + /// + /// # 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 + 1), + last: None, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for OpenInterestMomentum { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(tick.open_interest); + if self.window.len() < self.period + 1 { + return None; + } + let base = *self.window.front().expect("non-empty"); + let current = tick.open_interest; + let oim = if base > 0.0 { + 100.0 * (current - base) / base + } else { + 0.0 + }; + self.last = Some(oim); + Some(oim) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "OpenInterestMomentum" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn tick(oi: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + OpenInterestMomentum::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let o = OpenInterestMomentum::new(5).unwrap(); + assert_eq!(o.period(), 5); + assert_eq!(o.warmup_period(), 6); + assert_eq!(o.name(), "OpenInterestMomentum"); + assert!(!o.is_ready()); + assert_eq!(o.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut o = OpenInterestMomentum::new(3).unwrap(); + let ticks: Vec = (0..6) + .map(|i| tick(1_000.0 + f64::from(i) * 100.0)) + .collect(); + let out = o.batch(&ticks); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn reference_value() { + // period 2: OI 1000 -> 1200 over the window -> +20%. + let mut o = OpenInterestMomentum::new(2).unwrap(); + let out = o.batch(&[tick(1_000.0), tick(1_100.0), tick(1_200.0)]); + assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-9); + } + + #[test] + fn expanding_oi_is_positive() { + let mut o = OpenInterestMomentum::new(5).unwrap(); + let ticks: Vec = (0..20) + .map(|i| tick(1_000.0 + f64::from(i) * 100.0)) + .collect(); + let last = o.batch(&ticks).into_iter().flatten().last().unwrap(); + assert!(last > 0.0); + } + + #[test] + fn contracting_oi_is_negative() { + let mut o = OpenInterestMomentum::new(5).unwrap(); + let ticks: Vec = (0..20) + .map(|i| tick(3_000.0 - f64::from(i) * 100.0)) + .collect(); + let last = o.batch(&ticks).into_iter().flatten().last().unwrap(); + assert!(last < 0.0); + } + + #[test] + fn zero_base_is_zero() { + let mut o = OpenInterestMomentum::new(2).unwrap(); + let out = o.batch(&[tick(0.0), tick(100.0), tick(200.0)]); + assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut o = OpenInterestMomentum::new(3).unwrap(); + o.batch( + &(0..10) + .map(|i| tick(1_000.0 + f64::from(i) * 50.0)) + .collect::>(), + ); + assert!(o.is_ready()); + o.reset(); + assert!(!o.is_ready()); + assert_eq!(o.value(), None); + assert_eq!(o.update(tick(1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..80) + .map(|i| tick(1_000.0 + (f64::from(i) * 0.25).sin() * 300.0)) + .collect(); + let batch = OpenInterestMomentum::new(10).unwrap().batch(&ticks); + let mut b = OpenInterestMomentum::new(10).unwrap(); + let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/opening_marubozu.rs b/crates/wickra-core/src/indicators/opening_marubozu.rs new file mode 100644 index 0000000..90dea1d --- /dev/null +++ b/crates/wickra-core/src/indicators/opening_marubozu.rs @@ -0,0 +1,178 @@ +//! Opening Marubozu candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Opening Marubozu — a single-bar strong-momentum candle with a long body and no +/// shadow on the *open* end. A white opening marubozu opens right at the low (no +/// lower shadow) and may carry a closing shadow above; a black one opens right at +/// the high (no upper shadow) and may carry a closing shadow below. The shaved +/// open end shows the move took off from the bell without hesitation. +/// +/// ```text +/// range = high − low +/// long body: |close − open| >= 0.7 * range +/// white: close > open and open − low <= 0.05 * range (open at the low) +/// black: close < open and high − open <= 0.05 * range (open at the high) +/// ``` +/// +/// Output is `+1.0` for a white opening marubozu, `−1.0` for a black one, and +/// `0.0` otherwise. Body and shadow thresholds follow the geometric house style +/// rather than TA-Lib's rolling averages. TA-Lib has no direct equivalent; this +/// completes the pair with [`crate::ClosingMarubozu`], which shaves the close end. +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it drops +/// straight into a machine-learning feature matrix where the bullish and bearish +/// variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, OpeningMarubozu}; +/// +/// let mut indicator = OpeningMarubozu::new(); +/// // White: opens at the low, small closing shadow above. +/// let candle = Candle::new(10.0, 15.0, 10.0, 14.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct OpeningMarubozu { + has_emitted: bool, +} + +impl OpeningMarubozu { + /// Construct a new Opening Marubozu detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for OpeningMarubozu { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = candle.close - candle.open; + if body.abs() < 0.7 * range { + return Some(0.0); + } + let tol = 0.05 * range; + if body > 0.0 && candle.open - candle.low <= tol { + return Some(1.0); + } + if body < 0.0 && candle.high - candle.open <= tol { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "OpeningMarubozu" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = OpeningMarubozu::new(); + assert_eq!(t.name(), "OpeningMarubozu"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn white_opening_marubozu_is_plus_one() { + let mut t = OpeningMarubozu::new(); + // Opens at the low, closing shadow above. + assert_eq!(t.update(c(10.0, 15.0, 10.0, 14.5, 0)), Some(1.0)); + } + + #[test] + fn black_opening_marubozu_is_minus_one() { + let mut t = OpeningMarubozu::new(); + // Opens at the high, closing shadow below. + assert_eq!(t.update(c(15.0, 15.0, 10.0, 10.5, 0)), Some(-1.0)); + } + + #[test] + fn white_with_lower_shadow_yields_zero() { + let mut t = OpeningMarubozu::new(); + // Long white body but a clear lower shadow -> open is not at the low. + assert_eq!(t.update(c(11.0, 15.0, 10.0, 15.0, 0)), Some(0.0)); + } + + #[test] + fn black_with_upper_shadow_yields_zero() { + let mut t = OpeningMarubozu::new(); + // Long black body but a clear upper shadow -> open is not at the high. + assert_eq!(t.update(c(14.0, 16.0, 10.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn short_body_yields_zero() { + let mut t = OpeningMarubozu::new(); + // Body is short relative to range. + assert_eq!(t.update(c(10.0, 15.0, 10.0, 12.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = OpeningMarubozu::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 5.0, base, base + 4.5, i) + }) + .collect(); + let mut a = OpeningMarubozu::new(); + let mut b = OpeningMarubozu::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = OpeningMarubozu::new(); + t.update(c(10.0, 15.0, 10.0, 14.5, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/opening_range.rs b/crates/wickra-core/src/indicators/opening_range.rs new file mode 100644 index 0000000..f0f53f1 --- /dev/null +++ b/crates/wickra-core/src/indicators/opening_range.rs @@ -0,0 +1,270 @@ +//! Opening Range (OR): high / low of the first N session bars plus the +//! current bar's breakout distance from the range midpoint. +//! +//! Conceptually identical to [`crate::InitialBalance`] but with two +//! differences: the default window is shorter (6 = 30 min on 5-minute bars) +//! and the output carries a third field, `breakout_distance`, which is the +//! signed distance from the current candle's close to the range midpoint — +//! positive for breakouts above the OR, negative for breakdowns. Callers +//! MUST invoke [`Indicator::reset`] at every new session boundary to start +//! a fresh OR. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Opening Range output: high, low and breakout distance from the OR midpoint. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OpeningRangeOutput { + /// Session-opening high established over the OR window. + pub high: f64, + /// Session-opening low established over the OR window. + pub low: f64, + /// Current bar's close minus the OR midpoint. Positive once price + /// trades above the range mid, negative below. + pub breakout_distance: f64, +} + +/// Session Opening Range (first N bars + breakout distance). +/// +/// `period` defaults to **6** — the canonical 30-minute opening range on +/// 5-minute bars. Callers MUST invoke [`Indicator::reset`] at session +/// boundaries; otherwise the OR locks after the first `period` bars and +/// stays fixed for the remainder of the instance's life. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, OpeningRange}; +/// +/// let mut or = OpeningRange::new(2).unwrap(); +/// let bars = [ +/// Candle::new(100.0, 102.0, 99.0, 101.0, 10.0, 0).unwrap(), +/// Candle::new(101.0, 103.0, 100.0, 102.0, 10.0, 1).unwrap(), +/// // Now locked — breakout distance reflects close - (high + low) / 2. +/// Candle::new(102.0, 110.0, 102.0, 105.0, 10.0, 2).unwrap(), +/// ]; +/// for b in bars { +/// or.update(b); +/// } +/// let v = or.value().unwrap(); +/// assert_eq!(v.high, 103.0); +/// assert_eq!(v.low, 99.0); +/// assert_eq!(v.breakout_distance, 105.0 - (103.0 + 99.0) / 2.0); +/// ``` +#[derive(Debug, Clone)] +pub struct OpeningRange { + period: usize, + bars_seen: usize, + high: f64, + low: f64, + last_close: f64, + locked: bool, + last: Option, +} + +impl OpeningRange { + /// Construct an Opening Range indicator with the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bars_seen: 0, + high: f64::NEG_INFINITY, + low: f64::INFINITY, + last_close: 0.0, + locked: false, + last: None, + }) + } + + /// Classic 6-bar Opening Range. + pub fn classic() -> Self { + Self::new(6).expect("classic OR period is valid") + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Most recent output if at least one bar has been seen. + pub const fn value(&self) -> Option { + self.last + } + + /// True once `period` bars have been ingested and the OR is locked. + pub const fn is_locked(&self) -> bool { + self.locked + } + + fn snapshot(&self) -> OpeningRangeOutput { + let mid = f64::midpoint(self.high, self.low); + OpeningRangeOutput { + high: self.high, + low: self.low, + breakout_distance: self.last_close - mid, + } + } +} + +impl Indicator for OpeningRange { + type Input = Candle; + type Output = OpeningRangeOutput; + + fn update(&mut self, candle: Candle) -> Option { + if !self.locked { + if candle.high > self.high { + self.high = candle.high; + } + if candle.low < self.low { + self.low = candle.low; + } + self.bars_seen += 1; + if self.bars_seen >= self.period { + self.locked = true; + } + } + self.last_close = candle.close; + let out = self.snapshot(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.bars_seen = 0; + self.high = f64::NEG_INFINITY; + self.low = f64::INFINITY; + self.last_close = 0.0; + self.locked = false; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.bars_seen > 0 + } + + fn name(&self) -> &'static str { + "OpeningRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + let open = f64::midpoint(high, low); + Candle::new(open, high, low, close, 10.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(OpeningRange::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let or = OpeningRange::new(6).unwrap(); + assert_eq!(or.period(), 6); + assert_eq!(or.name(), "OpeningRange"); + assert_eq!(or.warmup_period(), 1); + assert!(or.value().is_none()); + assert!(!or.is_locked()); + } + + #[test] + fn classic_is_constructible() { + let or = OpeningRange::classic(); + assert_eq!(or.period(), 6); + } + + #[test] + fn tracks_range_during_window() { + let mut or = OpeningRange::new(3).unwrap(); + let o1 = or.update(c(102.0, 100.0, 101.0, 0)).unwrap(); + assert_relative_eq!(o1.high, 102.0); + assert_relative_eq!(o1.low, 100.0); + // close 101 vs mid 101 → breakout 0. + assert_relative_eq!(o1.breakout_distance, 0.0, epsilon = 1e-12); + let o2 = or.update(c(105.0, 99.0, 104.0, 1)).unwrap(); + assert_relative_eq!(o2.high, 105.0); + assert_relative_eq!(o2.low, 99.0); + // close 104 vs mid 102 → breakout 2. + assert_relative_eq!(o2.breakout_distance, 2.0, epsilon = 1e-12); + } + + #[test] + fn locks_after_period_and_breakout_reflects_close_minus_mid() { + let mut or = OpeningRange::new(2).unwrap(); + or.update(c(102.0, 100.0, 101.0, 0)); + or.update(c(103.0, 101.0, 102.0, 1)); + assert!(or.is_locked()); + // OR locked at high 103, low 100, mid 101.5. + // Bar 2: wide candle ignored for high/low; close 105 -> breakout 3.5. + let after = or.update(c(200.0, 50.0, 105.0, 2)).unwrap(); + assert_relative_eq!(after.high, 103.0); + assert_relative_eq!(after.low, 100.0); + assert_relative_eq!(after.breakout_distance, 3.5, epsilon = 1e-12); + } + + #[test] + fn breakout_distance_is_negative_below_range() { + let mut or = OpeningRange::new(2).unwrap(); + or.update(c(102.0, 100.0, 101.0, 0)); + or.update(c(103.0, 101.0, 102.0, 1)); + // mid 101.5, close 90 -> -11.5. + let out = or.update(c(110.0, 89.0, 90.0, 2)).unwrap(); + assert_relative_eq!(out.breakout_distance, -11.5, epsilon = 1e-12); + } + + #[test] + fn reset_unlocks_and_clears_state() { + let mut or = OpeningRange::new(2).unwrap(); + or.update(c(102.0, 100.0, 101.0, 0)); + or.update(c(103.0, 101.0, 102.0, 1)); + assert!(or.is_locked()); + or.reset(); + assert!(!or.is_locked()); + assert!(!or.is_ready()); + let o = or.update(c(50.0, 49.0, 49.5, 2)).unwrap(); + assert_relative_eq!(o.high, 50.0); + assert_relative_eq!(o.low, 49.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64 * 0.25; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut a = OpeningRange::new(5).unwrap(); + let mut b = OpeningRange::new(5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn is_ready_after_first_bar() { + let mut or = OpeningRange::new(5).unwrap(); + assert!(!or.is_ready()); + or.update(c(101.0, 99.0, 100.0, 0)); + assert!(or.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/order_flow_imbalance.rs b/crates/wickra-core/src/indicators/order_flow_imbalance.rs new file mode 100644 index 0000000..ae6158a --- /dev/null +++ b/crates/wickra-core/src/indicators/order_flow_imbalance.rs @@ -0,0 +1,242 @@ +//! Order Flow Imbalance (OFI) from best-level order-book changes. + +use std::collections::VecDeque; + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; +use crate::{Error, Result}; + +/// Order Flow Imbalance — the rolling sum of best-level order-flow events over +/// the last `period` order-book snapshots. +/// +/// Following Cont, Kukanov & Stoikov (2014), each new snapshot contributes a +/// signed event from how the best bid and ask moved versus the previous one: +/// +/// ```text +/// Δᵇ = qᵇₙ·1{Pᵇₙ ≥ Pᵇₙ₋₁} − qᵇₙ₋₁·1{Pᵇₙ ≤ Pᵇₙ₋₁} (bid pressure) +/// Δᵃ = qᵃₙ·1{Pᵃₙ ≤ Pᵃₙ₋₁} − qᵃₙ₋₁·1{Pᵃₙ ≥ Pᵃₙ₋₁} (ask pressure) +/// eₙ = Δᵇ − Δᵃ +/// OFI = Σ eₙ over the last `period` snapshots +/// ``` +/// +/// A rising bid (or replenished bid size) and a falling/depleting ask both add +/// positive flow; the mirror subtracts. The rolling sum is a strong +/// short-horizon predictor of price moves: a large positive `OFI` reflects net +/// buying pressure at the top of book, a large negative `OFI` net selling. +/// +/// `Input = OrderBook`. Each `update` is O(1) (only the best levels are read). +/// The first snapshot only seeds the reference quotes and emits `None`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, OrderFlowImbalance}; +/// +/// let mut ofi = OrderFlowImbalance::new(20).unwrap(); +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 5.0).unwrap()], +/// vec![Level::new(101.0, 4.0).unwrap()], +/// ) +/// .unwrap(); +/// assert_eq!(ofi.update(book), None); // first snapshot seeds the reference +/// ``` +#[derive(Debug, Clone)] +pub struct OrderFlowImbalance { + period: usize, + prev: Option<(f64, f64, f64, f64)>, // (bid_px, bid_sz, ask_px, ask_sz) + window: VecDeque, + sum: f64, +} + +impl OrderFlowImbalance { + /// Construct a new Order Flow Imbalance over the given snapshot window. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for OrderFlowImbalance { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + // A book with no levels on a side carries no best-level information. + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return None; + }; + let curr = (bid.price, bid.size, ask.price, ask.size); + let Some((pb_px, pb_sz, pa_px, pa_sz)) = self.prev else { + self.prev = Some(curr); + return None; + }; + self.prev = Some(curr); + let (bid_px, bid_sz, ask_px, ask_sz) = curr; + // Bid pressure: size added when the bid does not retreat, minus size + // removed when the bid does not advance. + let delta_b = f64::from(u8::from(bid_px >= pb_px)) * bid_sz + - f64::from(u8::from(bid_px <= pb_px)) * pb_sz; + // Ask pressure: size added when the ask does not advance, minus size + // removed when the ask does not retreat. + let delta_a = f64::from(u8::from(ask_px <= pa_px)) * ask_sz + - f64::from(u8::from(ask_px >= pa_px)) * pa_sz; + let event = delta_b - delta_a; + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + } + self.window.push_back(event); + self.sum += event; + if self.window.len() < self.period { + return None; + } + Some(self.sum) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // One snapshot seeds the reference quotes, then `period` events fill the + // window. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "OrderFlowImbalance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn book(bid_px: f64, bid_sz: f64, ask_px: f64, ask_sz: f64) -> OrderBook { + OrderBook::new( + vec![Level::new(bid_px, bid_sz).unwrap()], + vec![Level::new(ask_px, ask_sz).unwrap()], + ) + .unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(OrderFlowImbalance::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let ofi = OrderFlowImbalance::new(20).unwrap(); + assert_eq!(ofi.period(), 20); + assert_eq!(ofi.warmup_period(), 21); + assert_eq!(ofi.name(), "OrderFlowImbalance"); + assert!(!ofi.is_ready()); + } + + #[test] + fn first_snapshot_is_none() { + let mut ofi = OrderFlowImbalance::new(2).unwrap(); + assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None); + } + + #[test] + fn empty_book_side_is_none() { + // A book with no levels on a side (only constructible via + // `new_unchecked`, since `OrderBook::new` rejects empty sides) carries + // no best-level information and emits `None` without advancing state. + let mut ofi = OrderFlowImbalance::new(2).unwrap(); + let empty = OrderBook::new_unchecked(vec![], vec![]); + assert_eq!(ofi.update(empty), None); + // A real book afterwards still seeds the reference (state untouched). + assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None); + } + + #[test] + fn rising_bid_adds_positive_flow() { + // period 1. Reference book, then the bid lifts (price up) with size 6: + // Δᵇ = 6 (bid_px > prev), Δᵃ = (ask unchanged px=) ask_sz - ask_sz = 0 + // when ask is identical => e = 6. + let mut ofi = OrderFlowImbalance::new(1).unwrap(); + ofi.update(book(100.0, 5.0, 101.0, 4.0)); + let out = ofi.update(book(100.5, 6.0, 101.0, 4.0)).unwrap(); + assert_relative_eq!(out, 6.0, epsilon = 1e-12); + } + + #[test] + fn falling_bid_adds_negative_flow() { + // The bid drops in price: Δᵇ = −prev_bid_sz (bid_px < prev) = −5, + // ask identical => Δᵃ = 0 => e = −5. + let mut ofi = OrderFlowImbalance::new(1).unwrap(); + ofi.update(book(100.0, 5.0, 101.0, 4.0)); + let out = ofi.update(book(99.5, 3.0, 101.0, 4.0)).unwrap(); + assert_relative_eq!(out, -5.0, epsilon = 1e-12); + } + + #[test] + fn rolling_sum_accumulates() { + let mut ofi = OrderFlowImbalance::new(2).unwrap(); + ofi.update(book(100.0, 5.0, 101.0, 4.0)); + let a = ofi.update(book(100.5, 6.0, 101.0, 4.0)); // warming (1 event) + assert!(a.is_none()); + let b = ofi.update(book(101.0, 2.0, 101.5, 4.0)).unwrap(); // 2 events + // Second event: bid_px 101 > 100.5 => Δᵇ = 2; ask_px 101.5 > 101 => + // Δᵃ = −prev_ask_sz = −4 => e2 = 2 − (−4) = 6. Sum = 6 + 6 = 12. + assert_relative_eq!(b, 12.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut ofi = OrderFlowImbalance::new(2).unwrap(); + ofi.update(book(100.0, 5.0, 101.0, 4.0)); + ofi.update(book(100.5, 6.0, 101.0, 4.0)); + ofi.update(book(101.0, 2.0, 101.5, 4.0)); + assert!(ofi.is_ready()); + ofi.reset(); + assert!(!ofi.is_ready()); + assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..30) + .map(|i| { + let f = f64::from(i); + book( + 100.0 + (f * 0.3).sin(), + 5.0 + (f * 0.5).cos().abs(), + 101.0 + (f * 0.3).sin(), + 4.0 + (f * 0.4).sin().abs(), + ) + }) + .collect(); + let batch = OrderFlowImbalance::new(10).unwrap().batch(&books); + let mut b = OrderFlowImbalance::new(10).unwrap(); + let streamed: Vec<_> = books.iter().map(|x| b.update(x.clone())).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/ou_half_life.rs b/crates/wickra-core/src/indicators/ou_half_life.rs new file mode 100644 index 0000000..c9c40bc --- /dev/null +++ b/crates/wickra-core/src/indicators/ou_half_life.rs @@ -0,0 +1,260 @@ +//! Ornstein–Uhlenbeck half-life of mean reversion for the spread of two series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Half-life of mean reversion of the spread `a − b`, from an Ornstein–Uhlenbeck +/// fit. +/// +/// Each `update` takes one `(a, b)` price pair and forms the spread +/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator +/// fits the discrete Ornstein–Uhlenbeck (mean-reverting AR(1)) model by +/// ordinary least squares of the change on the level: +/// +/// ```text +/// Δsₜ = λ · sₜ₋₁ + c + εₜ +/// half_life = −ln(2) / λ (only when λ < 0) +/// ``` +/// +/// `λ` is the speed of mean reversion: a more negative `λ` pulls the spread back +/// to its mean faster. The **half-life** is the number of bars for a deviation +/// to decay by half — the single most useful number for sizing a pairs trade's +/// holding period and look-back. When the spread is not mean-reverting +/// (`λ ≥ 0`, a random walk or a trend) or the regression is degenerate (a flat +/// spread), the indicator returns `0`, meaning "no finite half-life". +/// +/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's +/// running geometry. Output is in bars and is always `≥ 0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, OuHalfLife}; +/// +/// let mut hl = OuHalfLife::new(40).unwrap(); +/// let mut last = None; +/// for t in 0..120 { +/// let b = 100.0 + f64::from(t); +/// // `a` hugs `b` with a fast mean-reverting wobble ⇒ short half-life. +/// let a = b + 2.0 * (f64::from(t) * 0.9).sin(); +/// last = hl.update((a, b)); +/// } +/// let half_life = last.unwrap(); +/// assert!(half_life > 0.0 && half_life < 40.0); +/// ``` +#[derive(Debug, Clone)] +pub struct OuHalfLife { + period: usize, + window: VecDeque, +} + +impl OuHalfLife { + /// Construct a new Ornstein–Uhlenbeck half-life estimator. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression + /// needs at least two observations (a slope and an intercept). + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "OU half-life needs period >= 3", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured look-back window of spreads. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for OuHalfLife { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(a - b); + if self.window.len() < self.period { + return None; + } + // OLS slope λ of Δsₜ on sₜ₋₁ over the window. + let spreads: Vec = self.window.iter().copied().collect(); + let count = (spreads.len() - 1) as f64; + let mut sum_level = 0.0; + let mut sum_delta = 0.0; + let mut sum_ll = 0.0; + let mut sum_ld = 0.0; + for pair in spreads.windows(2) { + let level = pair[0]; + let delta = pair[1] - pair[0]; + sum_level += level; + sum_delta += delta; + sum_ll += level * level; + sum_ld += level * delta; + } + let mean_level = sum_level / count; + let mean_delta = sum_delta / count; + let var_level = sum_ll / count - mean_level * mean_level; + if var_level <= 0.0 { + // Flat spread: the regression has no defined slope. + return Some(0.0); + } + let cov = sum_ld / count - mean_level * mean_delta; + let lambda = cov / var_level; + if lambda >= 0.0 { + // Not mean-reverting (random walk or diverging): no finite half-life. + return Some(0.0); + } + Some(-std::f64::consts::LN_2 / lambda) + } + + 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 { + "OuHalfLife" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_period_below_three() { + assert!(OuHalfLife::new(2).is_err()); + assert!(OuHalfLife::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let hl = OuHalfLife::new(30).unwrap(); + assert_eq!(hl.period(), 30); + assert_eq!(hl.warmup_period(), 30); + assert_eq!(hl.name(), "OuHalfLife"); + assert!(!hl.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut hl = OuHalfLife::new(4).unwrap(); + assert_eq!(hl.update((1.0, 0.0)), None); + assert_eq!(hl.update((2.0, 0.0)), None); + assert_eq!(hl.update((3.0, 0.0)), None); + assert!(hl.update((4.0, 0.0)).is_some()); + assert!(hl.is_ready()); + } + + #[test] + fn mean_reverting_spread_has_positive_half_life() { + // Fast sinusoidal spread around zero ⇒ strong mean reversion. + let pairs: Vec<(f64, f64)> = (0..120) + .map(|t| { + let b = 100.0 + f64::from(t); + let a = b + 2.0 * (f64::from(t) * 0.9).sin(); + (a, b) + }) + .collect(); + let last = OuHalfLife::new(40) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last > 0.0 && last < 40.0, "half-life {last}"); + } + + #[test] + fn trending_spread_has_zero_half_life() { + // Spread = a − b grows monotonically (λ ≥ 0) ⇒ no finite half-life. + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| (2.0 * f64::from(t), f64::from(t))) + .collect(); + let last = OuHalfLife::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn flat_spread_returns_zero() { + // a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0. + let pairs: Vec<(f64, f64)> = (0..30) + .map(|t| (5.0 + f64::from(t), f64::from(t))) + .collect(); + let last = OuHalfLife::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut hl = OuHalfLife::new(5).unwrap(); + for t in 0..10 { + hl.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t))); + } + assert!(hl.is_ready()); + hl.reset(); + assert!(!hl.is_ready()); + assert_eq!(hl.update((1.0, 0.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|t| { + let b = 50.0 + 0.5 * f64::from(t); + (b + (f64::from(t) * 0.6).sin(), b) + }) + .collect(); + let batch = OuHalfLife::new(25).unwrap().batch(&pairs); + let mut hl = OuHalfLife::new(25).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut hl = OuHalfLife::new(4).unwrap(); + assert_eq!(hl.update((f64::NAN, 1.0)), None); + assert_eq!(hl.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(hl.update((1.0, 0.0)), None); + assert_eq!(hl.update((2.0, 0.0)), None); + assert_eq!(hl.update((3.0, 0.0)), None); + assert!(hl.update((4.0, 0.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/overnight_gap.rs b/crates/wickra-core/src/indicators/overnight_gap.rs new file mode 100644 index 0000000..7eb5886 --- /dev/null +++ b/crates/wickra-core/src/indicators/overnight_gap.rs @@ -0,0 +1,191 @@ +//! Overnight Gap — the return from the previous session's close to the current +//! session's open, detected automatically at each day boundary. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Close-to-open overnight gap as a simple return. +/// +/// At every local day boundary the indicator computes +/// `open / previous_close - 1`, where `previous_close` is the close of the last +/// bar of the prior session and `open` is the open of the first bar of the new +/// session. The value holds for the rest of the session until the next boundary. +/// The boundary is the wall-clock day of [`Candle::timestamp`](crate::Candle) +/// shifted by `utc_offset_minutes`. The first session yields no gap (there is no +/// prior close to compare against). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, OvernightGap}; +/// +/// let hour = 3_600_000; +/// let mut gap = OvernightGap::new(0); +/// // Day 1 closes at 100. +/// assert!(gap.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none()); +/// // Day 2 opens at 105 -> gap = 105 / 100 - 1 = 0.05. +/// let g = gap.update(Candle::new(105.0, 106.0, 104.0, 105.5, 1.0, 24 * hour).unwrap()).unwrap(); +/// assert!((g - 0.05).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct OvernightGap { + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + last_close: Option, + gap: Option, +} + +impl OvernightGap { + /// Construct an Overnight Gap indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + day_key: None, + last_close: None, + gap: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent overnight gap if at least one day boundary has been crossed. + pub const fn value(&self) -> Option { + self.gap + } +} + +impl Indicator for OvernightGap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + if self.day_key != Some(key) { + if let Some(prev_close) = self.last_close { + self.gap = Some(if prev_close == 0.0 { + 0.0 + } else { + candle.open / prev_close - 1.0 + }); + } + self.day_key = Some(key); + } + self.last_close = Some(candle.close); + self.gap + } + + fn reset(&mut self) { + self.day_key = None; + self.last_close = None; + self.gap = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.gap.is_some() + } + + fn name(&self) -> &'static str { + "OvernightGap" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(open: f64, close: f64, ts: i64) -> Candle { + let high = open.max(close); + let low = open.min(close); + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let gap = OvernightGap::new(330); + assert_eq!(gap.utc_offset_minutes(), 330); + assert_eq!(gap.name(), "OvernightGap"); + assert_eq!(gap.warmup_period(), 2); + assert!(!gap.is_ready()); + assert!(gap.value().is_none()); + } + + #[test] + fn first_session_has_no_gap() { + let mut gap = OvernightGap::new(0); + assert!(gap.update(c(99.0, 100.0, 0)).is_none()); + // Same day, still no gap. + assert!(gap.update(c(100.0, 101.0, HOUR)).is_none()); + assert!(!gap.is_ready()); + } + + #[test] + fn computes_gap_at_day_boundary() { + let mut gap = OvernightGap::new(0); + gap.update(c(99.0, 100.0, 0)); // day 1 closes 100 + let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap(); + assert_relative_eq!(g, 0.05); + assert!(gap.is_ready()); + // Holds for the rest of the session. + let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap(); + assert_relative_eq!(same, 0.05); + } + + #[test] + fn negative_gap_down() { + let mut gap = OvernightGap::new(0); + gap.update(c(99.0, 100.0, 0)); + let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap(); + assert_relative_eq!(g, -0.1); + } + + #[test] + fn zero_prev_close_yields_zero_gap() { + let mut gap = OvernightGap::new(0); + gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0 + let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap(); + assert_relative_eq!(g, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut gap = OvernightGap::new(0); + gap.update(c(99.0, 100.0, 0)); + gap.update(c(105.0, 105.5, 24 * HOUR)); + gap.reset(); + assert!(!gap.is_ready()); + assert!(gap.value().is_none()); + assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| { + c( + 100.0 + f64::from(i % 7), + 100.0 + f64::from(i % 5), + i64::from(i) * 6 * HOUR, + ) + }) + .collect(); + let mut a = OvernightGap::new(0); + let mut b = OvernightGap::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/overnight_intraday_return.rs b/crates/wickra-core/src/indicators/overnight_intraday_return.rs new file mode 100644 index 0000000..b27e582 --- /dev/null +++ b/crates/wickra-core/src/indicators/overnight_intraday_return.rs @@ -0,0 +1,225 @@ +//! Overnight vs. Intraday Return — decomposes a session's total return into its +//! overnight (close-to-open) and intraday (open-to-close) components. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// The two return components of the current session. +/// +/// `overnight` is fixed at the session open (`open / previous_close - 1`); +/// `intraday` updates with every bar (`close / open - 1`). Compounding the two — +/// `(1 + overnight)(1 + intraday) - 1` — reconstructs the full previous-close to +/// latest-close return. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OvernightIntradayReturnOutput { + /// Close-to-open return carried into the session. + pub overnight: f64, + /// Open-to-latest-close return accumulated within the session. + pub intraday: f64, +} + +/// Overnight / intraday return decomposition, re-anchored at each local day +/// boundary of [`Candle::timestamp`](crate::Candle) shifted by +/// `utc_offset_minutes`. +/// +/// The first session yields no output (there is no prior close to anchor the +/// overnight leg); from the second session onward every bar reports both +/// components. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, OvernightIntradayReturn}; +/// +/// let hour = 3_600_000; +/// let mut oi = OvernightIntradayReturn::new(0); +/// // Day 1 closes at 100. +/// assert!(oi.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none()); +/// // Day 2 opens 110 (overnight +10%), closes 121 (intraday +10%). +/// let v = oi.update(Candle::new(110.0, 122.0, 109.0, 121.0, 1.0, 24 * hour).unwrap()).unwrap(); +/// assert!((v.overnight - 0.10).abs() < 1e-9); +/// assert!((v.intraday - 0.10).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct OvernightIntradayReturn { + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + last_close: Option, + today_open: f64, + overnight: Option, + last: Option, +} + +impl OvernightIntradayReturn { + /// Construct the indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + day_key: None, + last_close: None, + today_open: 0.0, + overnight: None, + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent decomposition if at least one day boundary has been crossed. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for OvernightIntradayReturn { + type Input = Candle; + type Output = OvernightIntradayReturnOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + if self.day_key != Some(key) { + if let Some(prev_close) = self.last_close { + self.overnight = Some(if prev_close == 0.0 { + 0.0 + } else { + candle.open / prev_close - 1.0 + }); + } + self.today_open = candle.open; + self.day_key = Some(key); + } + self.last_close = Some(candle.close); + let overnight = self.overnight?; + let intraday = if self.today_open == 0.0 { + 0.0 + } else { + candle.close / self.today_open - 1.0 + }; + let out = OvernightIntradayReturnOutput { + overnight, + intraday, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.day_key = None; + self.last_close = None; + self.today_open = 0.0; + self.overnight = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "OvernightIntradayReturn" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(open: f64, close: f64, ts: i64) -> Candle { + let high = open.max(close) + 1.0; + let low = open.min(close) - 1.0; + Candle::new(open, high, low.max(0.0), close, 1.0, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let oi = OvernightIntradayReturn::new(-300); + assert_eq!(oi.utc_offset_minutes(), -300); + assert_eq!(oi.name(), "OvernightIntradayReturn"); + assert_eq!(oi.warmup_period(), 2); + assert!(!oi.is_ready()); + assert!(oi.value().is_none()); + } + + #[test] + fn first_session_yields_none() { + let mut oi = OvernightIntradayReturn::new(0); + assert!(oi.update(c(99.0, 100.0, 0)).is_none()); + assert!(oi.update(c(100.0, 102.0, HOUR)).is_none()); + assert!(!oi.is_ready()); + } + + #[test] + fn decomposes_overnight_and_intraday() { + let mut oi = OvernightIntradayReturn::new(0); + oi.update(c(99.0, 100.0, 0)); // day 1 close 100 + let v = oi.update(c(110.0, 121.0, 24 * HOUR)).unwrap(); + assert_relative_eq!(v.overnight, 0.10); + assert_relative_eq!(v.intraday, 0.10); + assert!(oi.is_ready()); + } + + #[test] + fn intraday_updates_through_the_session() { + let mut oi = OvernightIntradayReturn::new(0); + oi.update(c(99.0, 100.0, 0)); + oi.update(c(110.0, 110.0, 24 * HOUR)); // open 110, close 110 -> intraday 0 + let later = oi.update(c(111.0, 132.0, 25 * HOUR)).unwrap(); + assert_relative_eq!(later.overnight, 0.10); // fixed at open + assert_relative_eq!(later.intraday, 0.20); // 132 / 110 - 1 + } + + #[test] + fn zero_anchors_yield_zero_components() { + let mut oi = OvernightIntradayReturn::new(0); + oi.update(c(1.0, 0.0, 0)); // day 1 closes at 0 + // Day 2 opens at 0: overnight uses zero prev_close -> 0; intraday uses + // zero today_open -> 0. + let candle = Candle::new(0.0, 5.0, 0.0, 4.0, 1.0, 24 * HOUR).unwrap(); + let v = oi.update(candle).unwrap(); + assert_relative_eq!(v.overnight, 0.0); + assert_relative_eq!(v.intraday, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut oi = OvernightIntradayReturn::new(0); + oi.update(c(99.0, 100.0, 0)); + oi.update(c(110.0, 121.0, 24 * HOUR)); + oi.reset(); + assert!(!oi.is_ready()); + assert!(oi.value().is_none()); + assert!(oi.update(c(50.0, 55.0, 48 * HOUR)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..48) + .map(|i| { + c( + 100.0 + f64::from(i % 6), + 100.0 + f64::from(i % 4), + i64::from(i) * 8 * HOUR, + ) + }) + .collect(); + let mut a = OvernightIntradayReturn::new(0); + let mut b = OvernightIntradayReturn::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/pain_index.rs b/crates/wickra-core/src/indicators/pain_index.rs new file mode 100644 index 0000000..49bc1c4 --- /dev/null +++ b/crates/wickra-core/src/indicators/pain_index.rs @@ -0,0 +1,171 @@ +//! Rolling Pain Index — mean depth of drawdowns. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Pain Index — Thomas Becker's continuous-pain risk measure. +/// +/// Input is treated as an equity-curve sample. The Pain Index is the **mean** +/// drawdown depth over the trailing window of `period` bars, expressed as a +/// non-negative fraction: +/// +/// ```text +/// peak_t = running max over window up to t +/// dd_t = (peak_t − equity_t) / peak_t (0 if no drawdown) +/// PainIdx = mean(dd_t over window) +/// ``` +/// +/// Where Ulcer Index uses an RMS aggregation that punishes deep drawdowns +/// disproportionately, the Pain Index uses a plain arithmetic mean. The two +/// are normally similar; the Pain Index reads slightly lower on stresses with +/// a few large drawdowns and similar elsewhere. +/// +/// Each `update` is O(period). +#[derive(Debug, Clone)] +pub struct PainIndex { + period: usize, + window: VecDeque, +} + +impl PainIndex { + /// Construct a new rolling Pain Index. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for PainIndex { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut peak = f64::NEG_INFINITY; + let mut sum_dd = 0.0_f64; + for &v in &self.window { + if v > peak { + peak = v; + } + if peak > 0.0 { + sum_dd += (peak - v) / peak; + } + } + Some(sum_dd / self.period as f64) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "PainIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(PainIndex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let p = PainIndex::new(10).unwrap(); + assert_eq!(p.period(), 10); + assert_eq!(p.name(), "PainIndex"); + assert_eq!(p.warmup_period(), 10); + } + + #[test] + fn pure_uptrend_yields_zero() { + let mut p = PainIndex::new(5).unwrap(); + let out = p.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_value() { + // window [100, 120, 90]: peaks 100,120,120; dd: 0, 0, 0.25. + // Pain = 0.25 / 3 ≈ 0.08333... + let mut p = PainIndex::new(3).unwrap(); + let out = p.batch(&[100.0, 120.0, 90.0]); + assert_relative_eq!(out[2].unwrap(), 0.25 / 3.0, epsilon = 1e-12); + } + + #[test] + fn ignores_non_finite_input() { + let mut p = PainIndex::new(3).unwrap(); + assert_eq!(p.update(f64::NAN), None); + assert_eq!(p.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut p = PainIndex::new(3).unwrap(); + p.batch(&[100.0, 90.0, 110.0]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(100.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let batch = PainIndex::new(10).unwrap().batch(&prices); + let mut s = PainIndex::new(10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_positive_peak_yields_zero() { + let mut p = PainIndex::new(3).unwrap(); + let out = p.batch(&[0.0_f64; 6]); + for v in out.into_iter().flatten() { + assert_eq!(v, 0.0); + } + } +} diff --git a/crates/wickra-core/src/indicators/pair_spread_zscore.rs b/crates/wickra-core/src/indicators/pair_spread_zscore.rs new file mode 100644 index 0000000..f3bedd7 --- /dev/null +++ b/crates/wickra-core/src/indicators/pair_spread_zscore.rs @@ -0,0 +1,299 @@ +//! Pair Spread Z-Score — the standardised log-spread of two cointegrated assets. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Z-score of the log-spread `ln(a) − β·ln(b)` between two assets. +/// +/// This is the canonical mean-reversion / statistical-arbitrage signal for a +/// pair. Each `update` receives one `(a, b)` pair of raw **prices** and the +/// indicator does two things: +/// +/// 1. **Hedge ratio.** A rolling ordinary-least-squares regression of +/// `ln(a)` on `ln(b)` over the trailing `beta_period` samples gives the +/// slope `β = cov(ln a, ln b) / var(ln b)`. The instantaneous spread is the +/// residual against the origin, `s = ln(a) − β·ln(b)`. +/// 2. **Standardisation.** The spread is then z-scored over the trailing +/// `z_period` spreads: `z = (s − mean_s) / std_s`. +/// +/// A large positive `z` means `a` is rich relative to `b` (sell the spread); a +/// large negative `z` means `a` is cheap (buy the spread); `z` near zero means +/// the pair is at its typical relationship. The two windows are independent: +/// `beta_period` controls how much history the hedge ratio adapts over, and +/// `z_period` controls the look-back for the mean and dispersion of the spread. +/// +/// Each `update` is O(1): five running sums maintain the rolling OLS and two +/// more maintain the rolling spread mean/variance. A flat `ln(b)` window has +/// zero variance and the hedge ratio is undefined; `β` is then taken as `0`, +/// reducing the spread to `ln(a)`. A flat spread window (zero dispersion) +/// yields a z-score of `0` rather than `NaN`. +/// +/// Prices must be strictly positive and finite for the logarithm to be +/// defined; a non-positive or non-finite price is skipped (it does not enter +/// either window), exactly as a real feed would discard a bad tick. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PairSpreadZScore}; +/// +/// let mut zs = PairSpreadZScore::new(2, 2).unwrap(); +/// // A flat benchmark gives hedge ratio 0, so the spread is just ln(a); with +/// // a 2-sample z-window the z-score collapses to the sign of the last move. +/// let mut last = None; +/// for a in [100.0, 100.0, 110.0, 120.0] { +/// last = zs.update((a, 100.0)); +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct PairSpreadZScore { + beta_period: usize, + z_period: usize, + // Rolling OLS of y = ln(a) on x = ln(b). + reg: VecDeque<(f64, f64)>, + sum_x: f64, + sum_y: f64, + sum_xx: f64, + sum_xy: f64, + // Rolling mean/variance of the spread. + spreads: VecDeque, + sum_s: f64, + sum_ss: f64, +} + +impl PairSpreadZScore { + /// Construct a new pair spread z-score. + /// + /// `beta_period` is the look-back for the rolling hedge ratio; `z_period` + /// is the look-back for standardising the spread. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if either period is below `2` + /// (variance needs at least two points). + pub fn new(beta_period: usize, z_period: usize) -> Result { + if beta_period < 2 { + return Err(Error::InvalidPeriod { + message: "pair spread z-score needs beta_period >= 2", + }); + } + if z_period < 2 { + return Err(Error::InvalidPeriod { + message: "pair spread z-score needs z_period >= 2", + }); + } + Ok(Self { + beta_period, + z_period, + reg: VecDeque::with_capacity(beta_period), + sum_x: 0.0, + sum_y: 0.0, + sum_xx: 0.0, + sum_xy: 0.0, + spreads: VecDeque::with_capacity(z_period), + sum_s: 0.0, + sum_ss: 0.0, + }) + } + + /// Look-back of the rolling hedge-ratio regression. + pub const fn beta_period(&self) -> usize { + self.beta_period + } + + /// Look-back of the rolling spread standardisation. + pub const fn z_period(&self) -> usize { + self.z_period + } + + /// The current hedge ratio `β`, or `None` while the regression is warming + /// up. A flat `ln(b)` window reports `0`. + fn hedge_ratio(&self) -> Option { + if self.reg.len() < self.beta_period { + return None; + } + let n = self.beta_period as f64; + let mean_x = self.sum_x / n; + let mean_y = self.sum_y / n; + let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0); + if var_x == 0.0 { + return Some(0.0); + } + let cov = self.sum_xy / n - mean_x * mean_y; + Some(cov / var_x) + } + + fn push_spread(&mut self, s: f64) -> Option { + if self.spreads.len() == self.z_period { + let old = self.spreads.pop_front().expect("non-empty"); + self.sum_s -= old; + self.sum_ss -= old * old; + } + self.spreads.push_back(s); + self.sum_s += s; + self.sum_ss += s * s; + if self.spreads.len() < self.z_period { + return None; + } + let m = self.z_period as f64; + let mean_s = self.sum_s / m; + let var_s = (self.sum_ss / m - mean_s * mean_s).max(0.0); + let std_s = var_s.sqrt(); + if std_s == 0.0 { + // A flat spread window has no dispersion to standardise against. + return Some(0.0); + } + Some((s - mean_s) / std_s) + } +} + +impl Indicator for PairSpreadZScore { + /// `(a, b)` price pair. + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) { + // Bad tick: skip it without disturbing either window. + return None; + } + let x = b.ln(); + let y = a.ln(); + if self.reg.len() == self.beta_period { + let (ox, oy) = self.reg.pop_front().expect("non-empty"); + self.sum_x -= ox; + self.sum_y -= oy; + self.sum_xx -= ox * ox; + self.sum_xy -= ox * oy; + } + self.reg.push_back((x, y)); + self.sum_x += x; + self.sum_y += y; + self.sum_xx += x * x; + self.sum_xy += x * y; + let beta = self.hedge_ratio()?; + let spread = y - beta * x; + self.push_spread(spread) + } + + fn reset(&mut self) { + self.reg.clear(); + self.sum_x = 0.0; + self.sum_y = 0.0; + self.sum_xx = 0.0; + self.sum_xy = 0.0; + self.spreads.clear(); + self.sum_s = 0.0; + self.sum_ss = 0.0; + } + + fn warmup_period(&self) -> usize { + // `beta_period` samples to define the hedge ratio (and the first + // spread), then `z_period − 1` more to fill the spread window. + self.beta_period + self.z_period - 1 + } + + fn is_ready(&self) -> bool { + self.spreads.len() == self.z_period + } + + fn name(&self) -> &'static str { + "PairSpreadZScore" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_periods_below_two() { + assert!(PairSpreadZScore::new(1, 5).is_err()); + assert!(PairSpreadZScore::new(5, 1).is_err()); + assert!(PairSpreadZScore::new(2, 2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let z = PairSpreadZScore::new(10, 20).unwrap(); + assert_eq!(z.beta_period(), 10); + assert_eq!(z.z_period(), 20); + assert_eq!(z.warmup_period(), 29); + assert_eq!(z.name(), "PairSpreadZScore"); + } + + #[test] + fn flat_benchmark_two_sample_window_is_sign_of_move() { + // Flat b ⇒ β = 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of last move. + let mut z = PairSpreadZScore::new(2, 2).unwrap(); + assert_eq!(z.update((100.0, 100.0)), None); + assert_eq!(z.update((100.0, 100.0)), None); + // The ±1 result is exact in real arithmetic; the variance is computed + // via Σs²−mean² so a few ulps of cancellation error remain. + assert_relative_eq!(z.update((110.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9); + assert_relative_eq!(z.update((105.0, 100.0)).unwrap(), -1.0, epsilon = 1e-9); + assert_relative_eq!(z.update((130.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9); + } + + #[test] + fn constant_spread_yields_zero() { + // Both legs flat ⇒ spread constant ⇒ zero dispersion ⇒ z = 0. + let pairs: Vec<(f64, f64)> = (0..10).map(|_| (50.0, 100.0)).collect(); + let last = PairSpreadZScore::new(3, 4) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn bad_tick_is_skipped() { + let mut z = PairSpreadZScore::new(2, 2).unwrap(); + // A non-positive or non-finite price never enters the windows. + assert_eq!(z.update((0.0, 100.0)), None); + assert_eq!(z.update((100.0, f64::NAN)), None); + assert!(!z.is_ready()); + // Valid ticks then warm the indicator normally. + z.update((100.0, 100.0)); + z.update((100.0, 100.0)); + z.update((110.0, 100.0)); + assert!(z.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut z = PairSpreadZScore::new(3, 3).unwrap(); + for i in 0..10 { + let b = 100.0 + 5.0 * f64::from(i).sin(); + z.update((b * 1.5, b)); + } + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + assert_eq!(z.update((100.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|i| { + let t = f64::from(i); + let b = 100.0 + 10.0 * (t * 0.2).sin(); + let a = b * (1.0 + 0.05 * (t * 0.5).cos()); + (a, b) + }) + .collect(); + let batch = PairSpreadZScore::new(14, 10).unwrap().batch(&pairs); + let mut z = PairSpreadZScore::new(14, 10).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| z.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/pairwise_beta.rs b/crates/wickra-core/src/indicators/pairwise_beta.rs new file mode 100644 index 0000000..f0e7891 --- /dev/null +++ b/crates/wickra-core/src/indicators/pairwise_beta.rs @@ -0,0 +1,292 @@ +//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns. +/// +/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the +/// indicator differences consecutive prices into log-returns +/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of +/// `a`'s returns on `b`'s returns over the trailing window of `period` return +/// pairs: +/// +/// ```text +/// cov_ab = (1/n) · Σ rₐ·r_b − r̄ₐ·r̄_b +/// var_b = (1/n) · Σ r_b² − r̄_b² +/// Beta = cov_ab / var_b +/// ``` +/// +/// This is the slope of the OLS line and measures how much asset `a` moves, in +/// return space, for a unit return of asset `b`. A reading of `1.0` means the +/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s +/// moves; negative readings signal an inverse relationship and the basis for a +/// hedge. +/// +/// This differs from [`crate::Beta`], which regresses the raw inputs it is +/// fed. `PairwiseBeta` always works in return space: feed it raw price levels +/// and it computes the returns for you, which is the conventional way to +/// measure cross-asset Beta (a Beta on price *levels* is dominated by the +/// shared trend and rarely what you want). +/// +/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`, +/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b` +/// window has zero return variance and Beta is undefined; the indicator +/// returns `0` in that case rather than producing `NaN`. +/// +/// Prices must be strictly positive and finite for the log-return to be +/// defined. A non-positive or non-finite price breaks the return chain: that +/// sample is dropped and the next valid price re-seeds the previous-price +/// reference, exactly as a real feed would resume after a bad tick. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PairwiseBeta}; +/// +/// let mut indicator = PairwiseBeta::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// // A varying (non-constant-return) positive price path. +/// let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin(); +/// // `a = b²`, so a's log-returns are exactly twice b's. +/// last = indicator.update((b * b, b)); +/// } +/// assert!((last.unwrap() - 2.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct PairwiseBeta { + period: usize, + prev: Option<(f64, f64)>, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl PairwiseBeta { + /// Construct a new rolling pairwise Beta over `period` return pairs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at + /// least two returns). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "pairwise beta needs period >= 2", + }); + } + Ok(Self { + period, + prev: None, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Configured period (number of return pairs in the rolling window). + pub const fn period(&self) -> usize { + self.period + } + + fn push_return(&mut self, ra: f64, rb: f64) -> Option { + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((ra, rb)); + self.sum_a += ra; + self.sum_b += rb; + self.sum_bb += rb * rb; + self.sum_ab += ra * rb; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0); + let cov = self.sum_ab / n - mean_a * mean_b; + if var_b == 0.0 { + // A flat benchmark-return window has no defined beta. + return Some(0.0); + } + Some(cov / var_b) + } +} + +impl Indicator for PairwiseBeta { + /// `(a, b)` price pair. + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) { + // Bad tick: drop it and restart the return chain. + self.prev = None; + return None; + } + let Some((pa, pb)) = self.prev else { + self.prev = Some((a, b)); + return None; + }; + self.prev = Some((a, b)); + let ra = (a / pa).ln(); + let rb = (b / pb).ln(); + self.push_return(ra, rb) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + // One prior price to seed, then `period` return pairs. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "PairwiseBeta" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(PairwiseBeta::new(0).is_err()); + assert!(PairwiseBeta::new(1).is_err()); + assert!(PairwiseBeta::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let b = PairwiseBeta::new(14).unwrap(); + assert_eq!(b.period(), 14); + assert_eq!(b.warmup_period(), 15); + assert_eq!(b.name(), "PairwiseBeta"); + } + + #[test] + fn squared_price_gives_beta_two() { + // a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2. + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| { + let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin(); + (b * b, b) + }) + .collect(); + let last = PairwiseBeta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 2.0, epsilon = 1e-9); + } + + #[test] + fn inverse_price_gives_beta_minus_one() { + // a = 1/b ⇒ a's log-returns are −1× b's ⇒ beta = −1. + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| { + let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin(); + (1.0 / b, b) + }) + .collect(); + let last = PairwiseBeta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn flat_benchmark_returns_zero() { + // b constant ⇒ zero return variance ⇒ beta defined as 0. + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect(); + let last = PairwiseBeta::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn bad_tick_breaks_return_chain() { + let mut b = PairwiseBeta::new(3).unwrap(); + // Seed, one good return, then a non-positive price drops the chain. + assert_eq!(b.update((100.0, 100.0)), None); + assert_eq!(b.update((101.0, 101.0)), None); + assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset + assert!(!b.is_ready()); + // A non-finite price is rejected the same way. + assert_eq!(b.update((f64::NAN, 50.0)), None); + assert!(!b.is_ready()); + // Recovery: subsequent valid prices rebuild the window cleanly. + for i in 0..5 { + let p = 100.0 * 1.01_f64.powi(i); + b.update((p * p, p)); + } + assert!(b.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut b = PairwiseBeta::new(3).unwrap(); + for i in 0..6 { + let p = 100.0 * 1.01_f64.powi(i); + b.update((p * p, p)); + } + assert!(b.is_ready()); + b.reset(); + assert!(!b.is_ready()); + assert_eq!(b.update((100.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + let b = 100.0 + 5.0 * t.sin(); + let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos(); + (a, b) + }) + .collect(); + let batch = PairwiseBeta::new(14).unwrap().batch(&pairs); + let mut b = PairwiseBeta::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/parkinson.rs b/crates/wickra-core/src/indicators/parkinson.rs new file mode 100644 index 0000000..b81ee07 --- /dev/null +++ b/crates/wickra-core/src/indicators/parkinson.rs @@ -0,0 +1,276 @@ +//! Parkinson Volatility (high-low estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Parkinson Volatility — a high-low realised-volatility estimator. +/// +/// Michael Parkinson (1980) noted that the extreme range of a bar carries +/// more variance information than the closing price alone: a wide bar that +/// closes near its open is far more "volatile" than a narrow bar that +/// happens to close at the same level. The estimator is +/// +/// ```text +/// sigma² = (1 / (4n · ln 2)) · Σ_{i=1..n} (ln(H_i / L_i))² +/// sigma = √sigma² +/// out = sigma · √trading_periods · 100 +/// ``` +/// +/// The output is annualised to a percent in the same style as +/// [`HistoricalVolatility`](crate::HistoricalVolatility) — `trading_periods` +/// of `252` for daily bars, `52` for weekly, `12` for monthly. Pass +/// `trading_periods = 1` for the raw per-bar `sigma · 100` figure. +/// +/// Under a driftless Geometric-Brownian-Motion assumption, Parkinson's +/// estimator has roughly `1/5` the variance of the close-to-close +/// estimator — i.e. five close-to-close samples give the same statistical +/// efficiency as one Parkinson sample. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ParkinsonVolatility}; +/// +/// let mut indicator = ParkinsonVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ParkinsonVolatility { + period: usize, + trading_periods: usize, + window: VecDeque, + sum_sq: f64, + last: Option, +} + +/// `1 / (4 · ln 2)` — the Parkinson normalisation constant, evaluated once at +/// `const` to keep the per-update path branch-free. +const PARKINSON_FACTOR: f64 = 0.360_673_760_222_241_2; + +impl ParkinsonVolatility { + /// Construct a Parkinson Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`. + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + trading_periods, + window: VecDeque::with_capacity(period), + sum_sq: 0.0, + last: None, + }) + } + + /// Configured `(period, trading_periods)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.trading_periods) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for ParkinsonVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // `Candle::new` already guarantees finite, positive `high` and `low` + // with `high >= low`, so the log ratio is always well-defined and + // non-negative. + let log_hl = (candle.high / candle.low).ln(); + let sample = log_hl * log_hl; + + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum_sq -= old; + } + self.window.push_back(sample); + self.sum_sq += sample; + + if self.window.len() < self.period { + return None; + } + + let n = self.period as f64; + let variance = (PARKINSON_FACTOR * self.sum_sq / n).max(0.0); + let sigma = variance.sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ParkinsonVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(h, l), h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + ParkinsonVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + ParkinsonVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let pv = ParkinsonVolatility::new(20, 252).unwrap(); + assert_eq!(pv.periods(), (20, 252)); + assert_eq!(pv.value(), None); + assert_eq!(pv.warmup_period(), 20); + assert_eq!(pv.name(), "ParkinsonVolatility"); + assert!(!pv.is_ready()); + } + + #[test] + fn zero_range_yields_zero() { + // H == L every bar -> ln(H/L) = 0 -> sigma = 0. + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(14, 1).unwrap(); + for v in pv.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_range_yields_constant_sigma() { + // Every bar has the same H/L ratio -> every (ln H/L)² is the same + // constant -> the rolling sum is `n * k` and the variance simplifies + // to `factor * k`. The output is `sqrt(factor * k) * 100` (with + // trading_periods = 1). + let candles: Vec = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(10, 1).unwrap(); + let out = pv.batch(&candles); + + let k = (11.0_f64 / 9.0_f64).ln().powi(2); + let expected = (PARKINSON_FACTOR * k).sqrt() * 100.0; + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut pv = ParkinsonVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + candle(base + half, base - half, base, i64::from(i)) + }) + .collect(); + for v in pv.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Parkinson volatility must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + // Same candles run through (period, 1) and (period, 252) -> the + // 252-version is `sqrt(252)` times the raw version, bar-for-bar. + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle(base + half, base - half, base, i64::from(i)) + }) + .collect(); + let raw = ParkinsonVolatility::new(10, 1).unwrap().batch(&candles); + let annual = ParkinsonVolatility::new(10, 252).unwrap().batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..20).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(5, 1).unwrap(); + let out = pv.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle(base + half, base - half, base, i64::from(i)) + }) + .collect(); + let batch = ParkinsonVolatility::new(14, 252).unwrap().batch(&candles); + let mut streamer = ParkinsonVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(14, 252).unwrap(); + pv.batch(&candles); + assert!(pv.is_ready()); + pv.reset(); + assert!(!pv.is_ready()); + assert_eq!(pv.value(), None); + assert_eq!(pv.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/pattern_swing.rs b/crates/wickra-core/src/indicators/pattern_swing.rs new file mode 100644 index 0000000..c2b233f --- /dev/null +++ b/crates/wickra-core/src/indicators/pattern_swing.rs @@ -0,0 +1,542 @@ +//! Internal swing-pivot tracker shared by the chart-pattern and harmonic-pattern +//! detectors. Not a public indicator — it carries no `Indicator` impl and is +//! re-exported nowhere, so it is excluded from the public catalogue counter. +//! +//! The tracker mirrors [`crate::indicators::ZigZag`]'s non-repainting +//! percent-threshold confirmation logic, but differs in two ways that make it a +//! reusable building block rather than a standalone indicator: +//! +//! * It is **parameter-free at the call site** — the reversal threshold is baked +//! in by each detector as a compile-time constant, so construction is +//! infallible (`const fn new`) and there is no user-facing validation branch. +//! * It **accumulates a bounded history** of the most recently confirmed pivots +//! (capped at `cap`), so a detector can inspect the last few swings to match a +//! geometric template (double top, head-and-shoulders, the XABCD legs of a +//! harmonic pattern, …). + +use crate::ohlcv::Candle; + +/// Default fractional reversal threshold for pattern swing detection (5%). A +/// pivot is confirmed once price reverses by this fraction away from the running +/// extreme. Baked in so the pattern detectors stay parameter-free, mirroring the +/// candlestick-pattern family's fixed geometric thresholds. +pub(crate) const SWING_THRESHOLD: f64 = 0.05; + +/// Default relative tolerance for two swing levels to count as "equal" (3%) — +/// the twin tops of a double top, the shoulders of a head-and-shoulders, the +/// flat boundary of a rectangle. +pub(crate) const LEVEL_TOLERANCE: f64 = 0.03; + +/// A confirmed swing pivot: the extreme price the swing turned from, its +/// direction (`+1.0` for a swing high, `-1.0` for a swing low) and the bar index +/// at which that extreme occurred. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Pivot { + /// Price of the confirmed swing extreme. + pub price: f64, + /// `+1.0` if the pivot is a swing high, `-1.0` if it is a swing low. + pub direction: f64, + /// Zero-based index of the candle at which the swing extreme occurred (not + /// the later bar that confirmed it). Used by the geometric Fibonacci tools + /// (fan, arcs, channel, time zones) to place trendlines and time offsets. + pub bar: usize, +} + +/// Non-repainting percent-threshold swing tracker with a bounded pivot history. +/// +/// Feeding a candle returns `true` exactly on the bar where a new pivot is +/// confirmed (price has reversed by the configured fraction away from the +/// running extreme); the newly confirmed pivot is appended to [`pivots`] and the +/// oldest is dropped once the cap is exceeded. Bars that merely extend the +/// running extreme, or that move less than the threshold, return `false`. +/// +/// [`pivots`]: SwingTracker::pivots +#[derive(Debug, Clone)] +pub(crate) struct SwingTracker { + threshold: f64, + cap: usize, + /// Number of candles fed so far; the current bar index is `bars_seen - 1`. + bars_seen: usize, + state: Option, + pivots: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct State { + /// `+1.0` while tracking a candidate high (uptrend), `-1.0` while tracking a + /// candidate low (downtrend). + direction: f64, + /// The running candidate extreme price. + extreme: f64, + /// Bar index at which the running extreme was last set. + extreme_bar: usize, +} + +impl SwingTracker { + /// Construct a tracker with a fractional reversal `threshold` (e.g. `0.05` + /// for 5%) and a pivot history capped at `cap` entries. + /// + /// The threshold is supplied by the detectors as a compile-time constant in + /// `(0, 1)`, so no runtime validation is performed — an out-of-range + /// constant would be a library bug caught by the unit tests, not invalid + /// caller input. + pub(crate) const fn new(threshold: f64, cap: usize) -> Self { + Self { + threshold, + cap, + bars_seen: 0, + state: None, + pivots: Vec::new(), + } + } + + /// Feed one candle. Returns `true` when a new pivot was confirmed this bar. + pub(crate) fn update(&mut self, candle: Candle) -> bool { + let bar = self.bars_seen; + self.bars_seen += 1; + let Some(s) = self.state else { + // Bootstrap: seed an uptrend tracking the first candle's high. + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + extreme_bar: bar, + }); + return false; + }; + + if s.direction > 0.0 { + if candle.high > s.extreme { + // Extend the candidate high. + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + extreme_bar: bar, + }); + return false; + } + if candle.low <= s.extreme * (1.0 - self.threshold) { + // Confirm the swing high; flip to tracking this bar's low. + self.push(Pivot { + price: s.extreme, + direction: 1.0, + bar: s.extreme_bar, + }); + self.state = Some(State { + direction: -1.0, + extreme: candle.low, + extreme_bar: bar, + }); + return true; + } + false + } else { + if candle.low < s.extreme { + // Extend the candidate low. + self.state = Some(State { + direction: -1.0, + extreme: candle.low, + extreme_bar: bar, + }); + return false; + } + if candle.high >= s.extreme * (1.0 + self.threshold) { + // Confirm the swing low; flip to tracking this bar's high. + self.push(Pivot { + price: s.extreme, + direction: -1.0, + bar: s.extreme_bar, + }); + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + extreme_bar: bar, + }); + return true; + } + false + } + } + + /// Zero-based index of the most recently fed candle. Saturates at `0` before + /// any candle has been seen. + pub(crate) fn current_bar(&self) -> usize { + self.bars_seen.saturating_sub(1) + } + + fn push(&mut self, pivot: Pivot) { + self.pivots.push(pivot); + if self.pivots.len() > self.cap { + self.pivots.remove(0); + } + } + + /// The confirmed pivots in chronological order (oldest first, newest last). + pub(crate) fn pivots(&self) -> &[Pivot] { + &self.pivots + } + + /// Clear all state, returning the tracker to its just-constructed condition. + pub(crate) fn reset(&mut self) { + self.bars_seen = 0; + self.state = None; + self.pivots.clear(); + } +} + +/// The two most recent swing highs and lows from the last four (strictly +/// alternating) pivots, returned as `(high_old, high_new, low_old, low_new)`. +/// Used by the converging/diverging trendline patterns (triangle, wedge, +/// rectangle). The slice must hold at least four pivots. +pub(crate) fn recent_legs(pivots: &[Pivot]) -> (f64, f64, f64, f64) { + let n = pivots.len(); + if pivots[n - 1].direction > 0.0 { + // … low_old, high_old, low_new, high_new (newest is a high) + ( + pivots[n - 3].price, + pivots[n - 1].price, + pivots[n - 4].price, + pivots[n - 2].price, + ) + } else { + // … high_old, low_old, high_new, low_new (newest is a low) + ( + pivots[n - 4].price, + pivots[n - 2].price, + pivots[n - 3].price, + pivots[n - 1].price, + ) + } +} + +/// Relative-tolerance equality: `true` when `a` and `b` are within `tol` +/// (a fraction) of the larger magnitude. Used to decide whether two swing +/// levels (the twin highs of a double top, the shoulders of a head-and-shoulders, +/// a harmonic Fibonacci ratio) count as "the same". +pub(crate) fn approx_equal(a: f64, b: f64, tol: f64) -> bool { + let scale = a.abs().max(b.abs()).max(f64::MIN_POSITIVE); + (a - b).abs() <= tol * scale +} + +/// The five most recent pivots interpreted as the X-A-B-C-D points of a harmonic +/// pattern, with the terminal direction. The slice must hold at least five +/// pivots. Each detector derives the leg lengths and Fibonacci ratios it needs +/// from these five prices. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Xabcd { + pub x: f64, + pub a: f64, + pub b: f64, + pub c: f64, + pub d: f64, + /// `true` when the terminal point D is a swing low (a bullish, buy-side + /// completion); `false` when D is a swing high (bearish). + pub bullish: bool, +} + +/// Read the last five pivots as an [`Xabcd`]. Pivots are guaranteed nonzero-leg +/// (the swing tracker only confirms moves of at least the threshold), so the +/// leg-ratio divisions in the detectors never divide by zero. +pub(crate) fn xabcd(pivots: &[Pivot]) -> Xabcd { + let n = pivots.len(); + Xabcd { + x: pivots[n - 5].price, + a: pivots[n - 4].price, + b: pivots[n - 3].price, + c: pivots[n - 2].price, + d: pivots[n - 1].price, + bullish: pivots[n - 1].direction < 0.0, + } +} + +/// `true` when every `(value, low, high)` triple satisfies `low <= value <= high`. +/// Harmonic detectors express their Fibonacci windows as a list of these triples; +/// evaluating them in one expression keeps the per-triple comparison on a single +/// line (no multi-line `&&` coverage gaps). +pub(crate) fn ratios_in(checks: &[(f64, f64, f64)]) -> bool { + checks + .iter() + .all(|&(value, low, high)| value >= low && value <= high) +} + +/// Build a candle sequence that drives a `SwingTracker` (or any detector built +/// on one) to confirm exactly the given alternating pivot prices, in order. +/// +/// `pivots` must start with a **high** and strictly alternate high/low, with +/// each consecutive pair differing by at least the swing threshold (5%) in the +/// correct direction (`high > adjacent low * 1.05`). The returned vector has one +/// seed candle plus one confirming candle per pivot; pivot `k` is confirmed by +/// candle `k + 1`. Only the high/low of each candle is meaningful — the pattern +/// detectors read swings, not bodies. +#[cfg(test)] +pub(crate) fn candles_for_pivots(pivots: &[f64]) -> Vec { + fn bar(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + let mut out = vec![bar(pivots[0], pivots[0] * 0.999, 0)]; + let mut ts: i64 = 0; + for (k, &price) in pivots.iter().enumerate() { + ts += 1; + let is_high = k % 2 == 0; + let next = if k + 1 < pivots.len() { + pivots[k + 1] + } else if is_high { + price * 0.90 + } else { + price * 1.10 + }; + let candle = if is_high { + // Reverse down from the candidate high `price` to confirm it. + bar(price * 0.99, next, ts) + } else { + // Reverse up from the candidate low `price` to confirm it. + bar(next, price * 1.01, ts) + }; + out.push(candle); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c_hl(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + + #[test] + fn first_bar_only_bootstraps_no_pivot() { + let mut t = SwingTracker::new(0.05, 6); + assert!(!t.update(c_hl(100.0, 99.5, 0))); + assert!(t.pivots().is_empty()); + } + + #[test] + fn extends_candidate_high_without_confirming() { + let mut t = SwingTracker::new(0.10, 6); + assert!(!t.update(c_hl(100.0, 99.5, 0))); + // A higher high merely raises the candidate — no pivot yet. + assert!(!t.update(c_hl(110.0, 109.0, 1))); + assert!(t.pivots().is_empty()); + } + + #[test] + fn uptrend_small_move_does_not_confirm() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); + // A 1% dip is below the 10% threshold — neither extends nor confirms. + assert!(!t.update(c_hl(99.8, 99.0, 1))); + assert!(t.pivots().is_empty()); + } + + #[test] + fn confirms_high_then_low_alternating() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); // seed uptrend + let _ = t.update(c_hl(120.0, 119.5, 1)); // raise candidate high to 120 + // Drop ≥10% below 120 → confirm the high at 120, flip to downtrend. + assert!(t.update(c_hl(101.0, 100.0, 2))); + assert_eq!( + t.pivots().last().copied(), + // The high extreme was set at bar 1, confirmed at bar 2. + Some(Pivot { + price: 120.0, + direction: 1.0, + bar: 1, + }) + ); + // Now in a downtrend: a lower low extends the candidate low. + assert!(!t.update(c_hl(100.5, 90.0, 3))); + // Rise ≥10% above 90 → confirm the low at 90. + assert!(t.update(c_hl(100.0, 99.0, 4))); + assert_eq!( + t.pivots().last().copied(), + // The low extreme was set at bar 3, confirmed at bar 4. + Some(Pivot { + price: 90.0, + direction: -1.0, + bar: 3, + }) + ); + } + + #[test] + fn downtrend_tiny_rise_does_not_confirm() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); + let _ = t.update(c_hl(120.0, 119.5, 1)); + let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high, now downtrend at 90 + // A 1% bounce is below threshold — no confirmation, no new candidate low. + assert!(!t.update(c_hl(91.0, 90.5, 3))); + assert_eq!(t.pivots().len(), 1); + } + + #[test] + fn history_is_capped() { + let mut t = SwingTracker::new(0.10, 2); + // Drive an oscillation that confirms several pivots; only the last 2 stay. + let path = [ + (100.0, 99.5), + (120.0, 119.5), + (101.0, 90.0), // confirm 120 (high) + (91.0, 90.5), + (110.0, 109.0), // confirm 90 (low) + (109.0, 95.0), // confirm 110 (high) + ]; + for (i, (h, l)) in path.iter().enumerate() { + let _ = t.update(c_hl(*h, *l, i64::try_from(i).unwrap())); + } + assert_eq!(t.pivots().len(), 2); + // The two most recent confirmations: low 90 then high 110. + assert_eq!(t.pivots()[0].price, 90.0); + assert_eq!(t.pivots()[1].price, 110.0); + } + + #[test] + fn reset_clears_state_and_history() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); + let _ = t.update(c_hl(120.0, 119.5, 1)); + let _ = t.update(c_hl(101.0, 90.0, 2)); + assert_eq!(t.pivots().len(), 1); + t.reset(); + assert!(t.pivots().is_empty()); + // After reset the next bar bootstraps again (returns false). + assert!(!t.update(c_hl(100.0, 99.5, 0))); + } + + #[test] + fn recent_legs_extracts_highs_and_lows_either_ending() { + // Newest pivot a high: [low_old, high_old, low_new, high_new]. + let ending_high = [ + Pivot { + price: 100.0, + direction: -1.0, + bar: 0, + }, + Pivot { + price: 120.0, + direction: 1.0, + bar: 0, + }, + Pivot { + price: 110.0, + direction: -1.0, + bar: 0, + }, + Pivot { + price: 121.0, + direction: 1.0, + bar: 0, + }, + ]; + assert_eq!(recent_legs(&ending_high), (120.0, 121.0, 100.0, 110.0)); + // Newest pivot a low: [high_old, low_old, high_new, low_new]. + let ending_low = [ + Pivot { + price: 120.0, + direction: 1.0, + bar: 0, + }, + Pivot { + price: 100.0, + direction: -1.0, + bar: 0, + }, + Pivot { + price: 110.0, + direction: 1.0, + bar: 0, + }, + Pivot { + price: 99.0, + direction: -1.0, + bar: 0, + }, + ]; + assert_eq!(recent_legs(&ending_low), (120.0, 110.0, 100.0, 99.0)); + } + + #[test] + fn xabcd_reads_last_five_pivots_and_direction() { + let pivots = [ + Pivot { + price: 50.0, + direction: 1.0, + bar: 0, + }, + Pivot { + price: 100.0, + direction: -1.0, + bar: 0, + }, // X + Pivot { + price: 140.0, + direction: 1.0, + bar: 0, + }, // A + Pivot { + price: 115.0, + direction: -1.0, + bar: 0, + }, // B + Pivot { + price: 128.0, + direction: 1.0, + bar: 0, + }, // C + Pivot { + price: 108.0, + direction: -1.0, + bar: 0, + }, // D (low → bullish) + ]; + let p = xabcd(&pivots); + assert_eq!( + (p.x, p.a, p.b, p.c, p.d), + (100.0, 140.0, 115.0, 128.0, 108.0) + ); + assert!(p.bullish); + } + + #[test] + fn ratios_in_checks_every_window() { + assert!(ratios_in(&[(0.6, 0.5, 0.7), (1.5, 1.0, 2.0)])); + assert!(!ratios_in(&[(0.6, 0.5, 0.7), (3.0, 1.0, 2.0)])); // second out of range + assert!(!ratios_in(&[(0.4, 0.5, 0.7)])); // below the window + } + + #[test] + fn candles_for_pivots_realizes_the_requested_swings() { + let want = [120.0, 100.0, 125.0, 95.0]; + let mut t = SwingTracker::new(0.05, 6); + for candle in candles_for_pivots(&want) { + let _ = t.update(candle); + } + let got: Vec = t.pivots().iter().map(|p| p.price).collect(); + assert_eq!(got, want); + // Directions alternate starting from a high. + assert_eq!(t.pivots()[0].direction, 1.0); + assert_eq!(t.pivots()[1].direction, -1.0); + } + + #[test] + fn approx_equal_relative_tolerance() { + assert!(approx_equal(100.0, 102.0, 0.03)); // 2% apart, within 3% + assert!(!approx_equal(100.0, 110.0, 0.03)); // 10% apart, outside 3% + assert!(approx_equal(0.0, 0.0, 0.01)); // both zero + assert!(approx_equal(-50.0, -49.0, 0.05)); // negative magnitudes + } + + #[test] + fn tracks_extreme_bar_and_current_bar() { + let mut t = SwingTracker::new(0.10, 6); + // Before any candle, the current bar saturates at 0. + assert_eq!(t.current_bar(), 0); + let _ = t.update(c_hl(100.0, 99.5, 0)); + let _ = t.update(c_hl(120.0, 119.5, 1)); // candidate high at bar 1 + let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high @120 (extreme bar 1) + assert_eq!(t.current_bar(), 2); + assert_eq!(t.pivots().last().unwrap().bar, 1); + } +} diff --git a/crates/wickra-core/src/indicators/pearson_correlation.rs b/crates/wickra-core/src/indicators/pearson_correlation.rs new file mode 100644 index 0000000..39a6d57 --- /dev/null +++ b/crates/wickra-core/src/indicators/pearson_correlation.rs @@ -0,0 +1,260 @@ +//! Rolling Pearson correlation between two synchronised series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Pearson correlation between two synchronised series. +/// +/// Each `update` receives one `(x, y)` pair (e.g. the latest close of the +/// asset and of the benchmark). Over the trailing window of `period` +/// pairs: +/// +/// ```text +/// cov_xy = (1/n) · Σ x·y − x̄·ȳ +/// var_x = (1/n) · Σ x² − x̄² +/// var_y = (1/n) · Σ y² − ȳ² +/// Pearson = cov_xy / √(var_x · var_y) +/// ``` +/// +/// Output is in `[−1, +1]`. `+1` means a perfect positive linear +/// relationship; `−1` is a perfect inverse one; `0` means no linear +/// relationship. It is the same statistic `SciPy` / `NumPy` report as +/// `pearsonr` and the standardised relative of [`crate::Beta`] — Beta +/// scales Pearson by the ratio of standard deviations. +/// +/// Each `update` is O(1): five running sums (`Σx`, `Σy`, `Σx²`, `Σy²`, +/// `Σxy`) are maintained as the window slides. A flat series in either +/// channel gives an undefined ratio; the indicator returns `0` in that +/// case rather than producing `NaN`. The output is clamped to `[−1, +1]` +/// to absorb tiny floating-point overshoots near the boundaries. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PearsonCorrelation}; +/// +/// let mut indicator = PearsonCorrelation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update((f64::from(i), 2.0 * f64::from(i) + 1.0)); +/// } +/// // A perfectly linear pair → +1. +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct PearsonCorrelation { + period: usize, + window: VecDeque<(f64, f64)>, + sum_x: f64, + sum_y: f64, + sum_xx: f64, + sum_yy: f64, + sum_xy: f64, +} + +impl PearsonCorrelation { + /// Construct a new rolling Pearson correlation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is + /// undefined for fewer than two pairs. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "pearson correlation needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x: 0.0, + sum_y: 0.0, + sum_xx: 0.0, + sum_yy: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for PearsonCorrelation { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } + if self.window.len() == self.period { + let (ox, oy) = self.window.pop_front().expect("non-empty"); + self.sum_x -= ox; + self.sum_y -= oy; + self.sum_xx -= ox * ox; + self.sum_yy -= oy * oy; + self.sum_xy -= ox * oy; + } + self.window.push_back((x, y)); + self.sum_x += x; + self.sum_y += y; + self.sum_xx += x * x; + self.sum_yy += y * y; + self.sum_xy += x * y; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_x = self.sum_x / n; + let mean_y = self.sum_y / n; + let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0); + let var_y = (self.sum_yy / n - mean_y * mean_y).max(0.0); + let cov = self.sum_xy / n - mean_x * mean_y; + let denom = (var_x * var_y).sqrt(); + if denom == 0.0 { + // At least one channel is flat: correlation is undefined. + return Some(0.0); + } + Some((cov / denom).clamp(-1.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_x = 0.0; + self.sum_y = 0.0; + self.sum_xx = 0.0; + self.sum_yy = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "PearsonCorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(PearsonCorrelation::new(0).is_err()); + assert!(PearsonCorrelation::new(1).is_err()); + assert!(PearsonCorrelation::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let p = PearsonCorrelation::new(14).unwrap(); + assert_eq!(p.period(), 14); + assert_eq!(p.warmup_period(), 14); + assert_eq!(p.name(), "PearsonCorrelation"); + } + + #[test] + fn perfect_positive_is_one() { + let pairs: Vec<(f64, f64)> = (0..10) + .map(|i| (f64::from(i), 3.0 * f64::from(i) + 1.0)) + .collect(); + let last = PearsonCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn perfect_negative_is_minus_one() { + let pairs: Vec<(f64, f64)> = (0..10) + .map(|i| (f64::from(i), -2.0 * f64::from(i) + 5.0)) + .collect(); + let last = PearsonCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_channel_yields_zero() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect(); + let last = PearsonCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_minus_one_to_one_range() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0) + }) + .collect(); + let mut p = PearsonCorrelation::new(20).unwrap(); + for v in p.batch(&pairs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut p = PearsonCorrelation::new(5).unwrap(); + p.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin(), (t * 0.5).cos()) + }) + .collect(); + let batch = PearsonCorrelation::new(14).unwrap().batch(&pairs); + let mut b = PearsonCorrelation::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut p = PearsonCorrelation::new(3).unwrap(); + assert_eq!(p.update((f64::NAN, 1.0)), None); + assert_eq!(p.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(p.update((1.0, 2.0)), None); + assert_eq!(p.update((2.0, 5.0)), None); + assert!(p.update((3.0, 7.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/percent_above_ma.rs b/crates/wickra-core/src/indicators/percent_above_ma.rs new file mode 100644 index 0000000..12480d1 --- /dev/null +++ b/crates/wickra-core/src/indicators/percent_above_ma.rs @@ -0,0 +1,146 @@ +//! Percent Above Moving Average — share of a universe trading above its MA. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Percent Above Moving Average — the percentage of symbols in a universe that +/// are trading above their reference moving average. +/// +/// On each [`CrossSection`] tick the value is `100 * above_ma_count / universe +/// size`, read from the per-symbol `above_ma` flag (the caller decides which MA — +/// 50-day, 200-day — when it builds the tick). It is a bounded `0..=100` breadth +/// gauge: readings near 100 mean almost the whole universe is in an uptrend +/// (broad participation, but also a potential overbought extreme), readings near +/// zero mark washouts. Crosses of the 50 line are read as bull/bear regime flips. +/// +/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`), +/// `warmup_period == 1`. The universe is non-empty by construction, so the share +/// is always defined. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, PercentAboveMa}; +/// +/// let mut pct = PercentAboveMa::new(); +/// // 3 of 4 symbols above their MA -> 75%. +/// let tick = CrossSection::new( +/// vec![ +/// Member::with_signals(1.0, 10.0, false, false, true, false), +/// Member::with_signals(1.0, 10.0, false, false, true, false), +/// Member::with_signals(-1.0, 10.0, false, false, true, false), +/// Member::with_signals(-1.0, 10.0, false, false, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(pct.update(tick), Some(75.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PercentAboveMa { + has_emitted: bool, +} + +impl PercentAboveMa { + /// Construct a new Percent Above Moving Average indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for PercentAboveMa { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let above = section.above_ma_count() as f64; + let total = section.members.len() as f64; + self.has_emitted = true; + Some(100.0 * above / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "PercentAboveMa" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(above: usize, below: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..above { + members.push(Member::with_signals(1.0, 10.0, false, false, true, false)); + } + for _ in 0..below { + members.push(Member::with_signals(-1.0, 10.0, false, false, false, false)); + } + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let pct = PercentAboveMa::new(); + assert_eq!(pct.name(), "PercentAboveMa"); + assert_eq!(pct.warmup_period(), 1); + assert!(!pct.is_ready()); + } + + #[test] + fn first_tick_emits_percentage() { + let mut pct = PercentAboveMa::new(); + assert_eq!(pct.update(tick(3, 1)), Some(75.0)); + assert!(pct.is_ready()); + } + + #[test] + fn all_above_is_one_hundred() { + let mut pct = PercentAboveMa::new(); + assert_eq!(pct.update(tick(4, 0)), Some(100.0)); + } + + #[test] + fn none_above_is_zero() { + let mut pct = PercentAboveMa::new(); + assert_eq!(pct.update(tick(0, 5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut pct = PercentAboveMa::new(); + pct.update(tick(3, 1)); + assert!(pct.is_ready()); + pct.reset(); + assert!(!pct.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![tick(3, 1), tick(4, 0), tick(0, 5)]; + let mut a = PercentAboveMa::new(); + let mut b = PercentAboveMa::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/percent_b.rs b/crates/wickra-core/src/indicators/percent_b.rs new file mode 100644 index 0000000..a8133f4 --- /dev/null +++ b/crates/wickra-core/src/indicators/percent_b.rs @@ -0,0 +1,203 @@ +//! Bollinger %b. + +use crate::error::Result; +use crate::traits::Indicator; + +use super::BollingerBands; + +/// Bollinger %b — where price sits within the Bollinger Bands. +/// +/// ```text +/// %b = (price − lower) / (upper − lower) +/// ``` +/// +/// `%b = 1` means price is exactly on the upper band, `%b = 0` on the lower +/// band, `%b = 0.5` on the middle band. The value is **not** clamped: price +/// breaking above the upper band gives `%b > 1`, breaking below the lower band +/// gives `%b < 0`. That makes %b a clean, scale-free way to compare a price's +/// band position across instruments and to spot band overshoots. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PercentB}; +/// +/// let mut indicator = PercentB::new(20, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PercentB { + bands: BollingerBands, + last: Option, +} + +impl PercentB { + /// Construct a new %b indicator. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] for `period == 0` and + /// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`. + pub fn new(period: usize, multiplier: f64) -> Result { + Ok(Self { + bands: BollingerBands::new(period, multiplier)?, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.bands.period() + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.bands.multiplier() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for PercentB { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let o = self.bands.update(input)?; + let width = o.upper - o.lower; + let percent_b = if width == 0.0 { + // Bands collapsed onto the middle: price is exactly mid-band. + 0.5 + } else { + (input - o.lower) / width + }; + self.last = Some(percent_b); + Some(percent_b) + } + + fn reset(&mut self) { + self.bands.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.bands.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "PercentB" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_invalid_parameters() { + assert!(PercentB::new(0, 2.0).is_err()); + assert!(PercentB::new(20, 0.0).is_err()); + assert!(PercentB::new(20, -1.0).is_err()); + } + + /// Cover the public const accessors `period`, `multiplier`, `value` + /// and the Indicator-impl `warmup_period` + `name` methods. Existing + /// tests only exercise the numeric output of `update` / `batch` / + /// `reset` / `is_ready`, so the five getter bodies (lines 53-65, + /// 90-92, 98-100) were dead. + #[test] + fn accessors_and_metadata() { + let mut pb = PercentB::new(20, 2.0).unwrap(); + assert_eq!(pb.period(), 20); + assert_relative_eq!(pb.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(pb.value(), None); + assert_eq!(pb.warmup_period(), 20); + assert_eq!(pb.name(), "PercentB"); + for i in 1..=20 { + pb.update(f64::from(i)); + } + assert!(pb.value().is_some()); + } + + #[test] + fn constant_series_yields_midpoint() { + // Flat prices: bands collapse, price is exactly mid-band -> 0.5. + let mut pb = PercentB::new(5, 2.0).unwrap(); + let out = pb.batch(&[100.0; 20]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.5, epsilon = 1e-12); + } + } + + #[test] + fn matches_bands_definition() { + // %b must equal (price - lower) / (upper - lower) from BollingerBands. + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices); + let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices); + for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() { + // Same warmup — emission shape must agree at every index. + assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}"); + if let (Some(pv), Some(bv)) = (p, b) { + let want = (prices[i] - bv.lower) / (bv.upper - bv.lower); + assert_relative_eq!(*pv, want, epsilon = 1e-12); + } + } + } + + /// Deterministic price-at-middle assertion. With period=3, multiplier=2, + /// inputs `[1.0, 5.0, 3.0]` give SMA = (1+5+3)/3 = 3.0 at index 2, which + /// equals the third price exactly. The stddev is √(8/3) ≈ 1.633, so the + /// bands have non-zero width (the width==0 fallback at line 77 is NOT + /// taken) and the divide path at line 79 runs. Because price sits on + /// the centre line of symmetric bands, %b lands on exactly 0.5. + /// + /// The previous oscillation-based variant of this test never landed + /// `prices[i]` within 1e-9 of the rolling SMA, so its inner + /// `assert_relative_eq!` line was never executed. + #[test] + fn price_at_middle_is_half() { + let mut pb = PercentB::new(3, 2.0).unwrap(); + let out = pb.batch(&[1.0, 5.0, 3.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + let v = out[2].expect("warmed up at index 2"); + assert_relative_eq!(v, 0.5, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut pb = PercentB::new(5, 2.0).unwrap(); + pb.batch(&(1..=20).map(f64::from).collect::>()); + assert!(pb.is_ready()); + pb.reset(); + assert!(!pb.is_ready()); + assert_eq!(pb.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0) + .collect(); + let batch = PercentB::new(20, 2.0).unwrap().batch(&prices); + let mut b = PercentB::new(20, 2.0).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/percentage_trailing_stop.rs b/crates/wickra-core/src/indicators/percentage_trailing_stop.rs new file mode 100644 index 0000000..70a198b --- /dev/null +++ b/crates/wickra-core/src/indicators/percentage_trailing_stop.rs @@ -0,0 +1,224 @@ +//! Percentage Trailing Stop. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Percentage Trailing Stop — a fixed-percentage stop that ratchets with the +/// trend and flips to the opposite side on a close-through. +/// +/// ```text +/// step = price · percent / 100 +/// +/// long: stop_t = max(stop_{t−1}, close − step) while close ≥ stop_{t−1} +/// short: stop_t = min(stop_{t−1}, close + step) while close ≤ stop_{t−1} +/// flip-to-long on a close > prev short-stop -> stop = close − step +/// flip-to-short on a close < prev long-stop -> stop = close + step +/// ``` +/// +/// The first input seeds a long stop `percent` below price. Unlike the ATR +/// trailing stop the band is purely proportional to the latest price, so it +/// scales naturally across instruments without re-tuning. A common +/// configuration is `5.0` (a 5% trail). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PercentageTrailingStop}; +/// +/// let mut indicator = PercentageTrailingStop::new(5.0).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PercentageTrailingStop { + percent: f64, + prev_stop: Option, + /// `true` while a long trail is active; flipped on a close-through. + long: bool, +} + +impl PercentageTrailingStop { + /// Construct a Percentage Trailing Stop with an explicit trail size + /// (e.g. `5.0` for a 5% trail). + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `percent` is not strictly + /// positive and finite. + pub fn new(percent: f64) -> Result { + if !percent.is_finite() || percent <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + percent, + prev_stop: None, + long: true, + }) + } + + /// A common configuration: a `5.0%` trail. + pub fn classic() -> Self { + Self::new(5.0).expect("classic percent is valid") + } + + /// Configured trail percent. + pub const fn percent(&self) -> f64 { + self.percent + } +} + +impl Indicator for PercentageTrailingStop { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + if !close.is_finite() { + return None; + } + let step = close.abs() * self.percent / 100.0; + let stop = match self.prev_stop { + Some(prev) => { + if self.long { + if close < prev { + // Close-through long stop — flip to short. + self.long = false; + close + step + } else { + prev.max(close - step) + } + } else if close > prev { + // Close-through short stop — flip to long. + self.long = true; + close - step + } else { + prev.min(close + step) + } + } + None => close - step, + }; + self.prev_stop = Some(stop); + Some(stop) + } + + fn reset(&mut self) { + self.prev_stop = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev_stop.is_some() + } + + fn name(&self) -> &'static str { + "PercentageTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_percent() { + assert!(PercentageTrailingStop::new(0.0).is_err()); + assert!(PercentageTrailingStop::new(-1.0).is_err()); + assert!(PercentageTrailingStop::new(f64::NAN).is_err()); + assert!(PercentageTrailingStop::new(f64::INFINITY).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = PercentageTrailingStop::classic(); + assert_relative_eq!(s.percent(), 5.0, epsilon = 1e-12); + assert_eq!(s.name(), "PercentageTrailingStop"); + assert_eq!(s.warmup_period(), 1); + } + + #[test] + fn first_value_is_step_below_price() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + // 100 · 0.10 = 10, so seed stop = 90. + assert_relative_eq!(s.update(100.0).unwrap(), 90.0, epsilon = 1e-12); + assert!(s.is_ready()); + } + + #[test] + fn long_stop_ratchets_up_with_price() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + // Rising series: each new high pulls the stop up. + let prices = [100.0, 110.0, 120.0, 130.0]; + let out: Vec = prices.iter().map(|&p| s.update(p).unwrap()).collect(); + assert_relative_eq!(out[0], 90.0, epsilon = 1e-9); + assert_relative_eq!(out[1], 99.0, epsilon = 1e-9); + assert_relative_eq!(out[2], 108.0, epsilon = 1e-9); + assert_relative_eq!(out[3], 117.0, epsilon = 1e-9); + } + + #[test] + fn flips_to_short_on_close_through() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + // Seed long at 100 -> stop 90. + s.update(100.0); + // Climb to 130 -> stop ratchets to 117. + s.update(130.0); + // Crash to 50 -> closes through 117 -> flip to short stop at 55. + let flipped = s.update(50.0).unwrap(); + assert_relative_eq!(flipped, 55.0, epsilon = 1e-9); + } + + #[test] + fn short_stop_ratchets_down_and_flips_back() { + let mut s = PercentageTrailingStop::new(10.0).unwrap(); + s.update(100.0); // long stop 90 + s.update(50.0); // flip short, stop 55 + let v = s.update(40.0).unwrap(); + // Short side ratchets down: min(55, 44) = 44. + assert_relative_eq!(v, 44.0, epsilon = 1e-9); + // Rally back through 44 -> flip long, stop = 100 · 0.9 = 90. + let v = s.update(100.0).unwrap(); + assert_relative_eq!(v, 90.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_holds_stop() { + let mut s = PercentageTrailingStop::new(5.0).unwrap(); + let out = s.batch(&[100.0; 30]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 95.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut s = PercentageTrailingStop::new(5.0).unwrap(); + s.update(100.0); + s.update(50.0); // flip short + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + // After reset the next update seeds a fresh long stop. + let v = s.update(200.0).unwrap(); + assert_relative_eq!(v, 190.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let mut a = PercentageTrailingStop::classic(); + let mut b = PercentageTrailingStop::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/perpetual_premium_index.rs b/crates/wickra-core/src/indicators/perpetual_premium_index.rs new file mode 100644 index 0000000..fd38435 --- /dev/null +++ b/crates/wickra-core/src/indicators/perpetual_premium_index.rs @@ -0,0 +1,139 @@ +//! Perpetual Premium Index — the perp mark price relative to spot. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Perpetual Premium Index — the perpetual's mark price relative to the spot index +/// it tracks, as a fraction. +/// +/// ```text +/// premium = (mark_price − index_price) / index_price +/// ``` +/// +/// A perpetual swap is pegged to spot by the funding mechanism, but it can still +/// trade at a premium (above spot) or discount (below). A positive premium signals +/// net long demand willing to pay up to hold the perp — bullish positioning, and +/// the proximate driver of positive funding; a negative premium signals the +/// reverse. Sustained extremes flag crowded positioning ripe for a funding-driven +/// mean reversion. +/// +/// The output is centred on zero and dimensionless (a fraction; multiply by `100` +/// for percent). `index_price` is validated strictly positive on the tick, so the +/// division is always defined. It is stateless — each tick yields one value (no +/// warmup). Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, PerpetualPremiumIndex}; +/// +/// let mut indicator = PerpetualPremiumIndex::new(); +/// // Mark 101 vs index 100 -> +1% premium. +/// let tick = DerivativesTick::new(0.0, 101.0, 100.0, 101.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap(); +/// let premium = indicator.update(tick).unwrap(); +/// assert!((premium - 0.01).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PerpetualPremiumIndex { + ready: bool, +} + +impl PerpetualPremiumIndex { + /// Construct a new Perpetual Premium Index. The indicator is parameter-free. + #[must_use] + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for PerpetualPremiumIndex { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + let premium = (tick.mark_price - tick.index_price) / tick.index_price; + self.ready = true; + Some(premium) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "PerpetualPremiumIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn tick(mark: f64, index: f64) -> DerivativesTick { + DerivativesTick::new_unchecked(0.0, mark, index, mark, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let p = PerpetualPremiumIndex::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "PerpetualPremiumIndex"); + assert!(!p.is_ready()); + } + + #[test] + fn premium_reference_value() { + let mut p = PerpetualPremiumIndex::new(); + assert_relative_eq!(p.update(tick(101.0, 100.0)).unwrap(), 0.01, epsilon = 1e-12); + } + + #[test] + fn discount_is_negative() { + let mut p = PerpetualPremiumIndex::new(); + assert!(p.update(tick(99.0, 100.0)).unwrap() < 0.0); + } + + #[test] + fn at_par_is_zero() { + let mut p = PerpetualPremiumIndex::new(); + assert_relative_eq!(p.update(tick(100.0, 100.0)).unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn ready_after_first_update() { + let mut p = PerpetualPremiumIndex::new(); + assert!(!p.is_ready()); + p.update(tick(100.0, 100.0)); + assert!(p.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut p = PerpetualPremiumIndex::new(); + p.update(tick(101.0, 100.0)); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..40) + .map(|i| tick(100.0 + (f64::from(i) * 0.3).sin(), 100.0)) + .collect(); + let batch = PerpetualPremiumIndex::new().batch(&ticks); + let mut b = PerpetualPremiumIndex::new(); + let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/pgo.rs b/crates/wickra-core/src/indicators/pgo.rs new file mode 100644 index 0000000..c2ba9b6 --- /dev/null +++ b/crates/wickra-core/src/indicators/pgo.rs @@ -0,0 +1,219 @@ +//! Pretty Good Oscillator (PGO). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::sma::Sma; +use crate::indicators::true_range::TrueRange; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Mark Johnson's Pretty Good Oscillator — displacement of the close from its +/// `period`-bar `SMA`, normalised by the `period`-bar `EMA` of the True Range. +/// +/// ```text +/// PGO_t = (close_t − SMA(close, period)_t) / EMA(TR_t, period) +/// ``` +/// +/// The numerator is positive when the close is above its mean of the last +/// `period` bars and negative when below. The denominator is the EMA-smoothed +/// volatility scale, so PGO is roughly "how many ATR-equivalents is the close +/// away from its mean?". Johnson's heuristic: cross above `+3` is a long entry, +/// below `−3` a short entry. +/// +/// The first output lands once both inner indicators have warmed up — for the +/// shared `period` parameter, that is exactly `period` candles in. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Pgo}; +/// +/// let mut pgo = Pgo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap(); +/// last = pgo.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Pgo { + period: usize, + sma: Sma, + tr: TrueRange, + ema_tr: Ema, + current: Option, +} + +impl Pgo { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + sma: Sma::new(period)?, + tr: TrueRange::new(), + ema_tr: Ema::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Pgo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let mean = self.sma.update(candle.close); + // TrueRange always emits (it falls back to high − low without a + // previous close), so we can unwrap the inner option safely. + let tr = self.tr.update(candle).expect("TrueRange always emits"); + let ema_tr = self.ema_tr.update(tr); + let mean = mean?; + let ema_tr = ema_tr?; + if ema_tr <= 0.0 { + // Pathological window of perfectly flat candles: divisor zero. + // Hold the previous value rather than blow up. + return self.current; + } + let value = (candle.close - mean) / ema_tr; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.sma.reset(); + self.tr.reset(); + self.ema_tr.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // Both inner state machines reach readiness at exactly `period` + // candles, so PGO emits at the same boundary. + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "PGO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, high: f64, low: f64, ts: i64) -> Candle { + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Pgo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut p = Pgo::new(14).unwrap(); + assert_eq!(p.period(), 14); + assert_eq!(p.warmup_period(), 14); + assert_eq!(p.name(), "PGO"); + assert!(!p.is_ready()); + for i in 0..14 { + p.update(candle(10.0, 11.0, 9.0, i)); + } + assert!(p.is_ready()); + } + + #[test] + fn flat_close_yields_zero_numerator() { + // Constant close -> SMA == close, so numerator is 0 regardless of the + // TR-EMA in the denominator (which is non-zero thanks to spread). + let mut p = Pgo::new(5).unwrap(); + let mut out = None; + for i in 0..20 { + out = p.update(candle(10.0, 11.0, 9.0, i)); + } + let v = out.unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut p = Pgo::new(3).unwrap(); + for i in 0..2 { + assert_eq!(p.update(candle(10.0, 11.0, 9.0, i)), None); + } + assert!(p.update(candle(10.0, 11.0, 9.0, 2)).is_some()); + } + + #[test] + fn close_above_mean_is_positive() { + // Rising series: latest close sits above its SMA, so PGO > 0. + let mut p = Pgo::new(5).unwrap(); + for i in 0..20 { + let c = 10.0 + f64::from(i); + p.update(candle(c, c + 0.5, c - 0.5, i64::from(i))); + } + // Use the last value implicitly. + let last = p.update(candle(40.0, 40.5, 39.5, 20)).expect("PGO is warm"); + assert!( + last > 0.0, + "PGO on rising series should be positive: {last}" + ); + } + + #[test] + fn zero_tr_holds_value() { + // Every candle is a single point (high == low == close): TR is zero, + // EMA(TR) collapses to zero -> PGO holds its previous value. + let mut p = Pgo::new(3).unwrap(); + p.update(candle(10.0, 10.0, 10.0, 0)); + p.update(candle(10.0, 10.0, 10.0, 1)); + let v = p.update(candle(10.0, 10.0, 10.0, 2)); + // With zero denominator on the first ready step we have no previous + // value, so the indicator stays unset. + assert!(v.is_none(), "expected hold, got {v:?}"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c, c + 1.0, c - 1.0, i) + }) + .collect(); + let batch = Pgo::new(14).unwrap().batch(&candles); + let mut b = Pgo::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut p = Pgo::new(5).unwrap(); + for i in 0..20 { + p.update(candle(10.0, 11.0, 9.0, i)); + } + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(candle(10.0, 11.0, 9.0, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/piercing_dark_cloud.rs b/crates/wickra-core/src/indicators/piercing_dark_cloud.rs new file mode 100644 index 0000000..70c685c --- /dev/null +++ b/crates/wickra-core/src/indicators/piercing_dark_cloud.rs @@ -0,0 +1,204 @@ +//! Piercing Line / Dark Cloud Cover candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Piercing Line / Dark Cloud Cover — a 2-bar reversal pattern. +/// +/// **Piercing Line** (bullish, `+1.0`): +/// ```text +/// prev_red & curr_green +/// & curr.open < prev.low +/// & curr.close > (prev.open + prev.close) / 2 +/// & curr.close < prev.open +/// ``` +/// +/// **Dark Cloud Cover** (bearish, `−1.0`): +/// ```text +/// prev_green & curr_red +/// & curr.open > prev.high +/// & curr.close < (prev.open + prev.close) / 2 +/// & curr.close > prev.open +/// ``` +/// +/// Output is `+1.0` for a Piercing Line, `−1.0` for a Dark Cloud Cover, and +/// `0.0` otherwise. The first bar always returns `0.0`. Pattern-shape check +/// only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, PiercingDarkCloud}; +/// +/// let mut indicator = PiercingDarkCloud::new(); +/// indicator.update(Candle::new(12.0, 12.5, 10.0, 10.0, 1.0, 0).unwrap()); +/// // Open below prev low, close above midpoint (11) but below prev open (12). +/// let out = indicator +/// .update(Candle::new(9.8, 11.8, 9.5, 11.5, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PiercingDarkCloud { + prev: Option, + has_emitted: bool, +} + +impl PiercingDarkCloud { + /// Construct a new Piercing Line / Dark Cloud Cover detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for PiercingDarkCloud { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let prev_red = p.close < p.open; + let prev_green = p.close > p.open; + let curr_green = candle.close > candle.open; + let curr_red = candle.close < candle.open; + let mid = f64::midpoint(p.open, p.close); + if prev_red + && curr_green + && candle.open < p.low + && candle.close > mid + && candle.close < p.open + { + Some(1.0) + } else if prev_green + && curr_red + && candle.open > p.high + && candle.close < mid + && candle.close > p.open + { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "PiercingDarkCloud" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let p = PiercingDarkCloud::new(); + assert_eq!(p.name(), "PiercingDarkCloud"); + assert_eq!(p.warmup_period(), 2); + assert!(!p.is_ready()); + } + + #[test] + fn piercing_line_is_plus_one() { + let mut p = PiercingDarkCloud::new(); + // Prev red: open 12, close 10. Curr green: opens at 9.8 (< prev low 10), + // closes at 11.5 (> midpoint 11, < prev open 12). + assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0)); + assert_eq!(p.update(c(9.8, 11.8, 9.5, 11.5, 1)), Some(1.0)); + } + + #[test] + fn dark_cloud_cover_is_minus_one() { + let mut p = PiercingDarkCloud::new(); + // Prev green: open 10, close 12. Curr red: opens 12.3 (> prev high 12.2), + // closes 10.5 (< midpoint 11, > prev open 10). + assert_eq!(p.update(c(10.0, 12.2, 9.5, 12.0, 0)), Some(0.0)); + assert_eq!(p.update(c(12.3, 12.4, 10.4, 10.5, 1)), Some(-1.0)); + } + + #[test] + fn close_below_midpoint_is_not_piercing() { + let mut p = PiercingDarkCloud::new(); + p.update(c(12.0, 12.5, 10.0, 10.0, 0)); + // Closes only at 10.8 (below midpoint 11) -> not piercing. + assert_eq!(p.update(c(9.8, 11.0, 9.5, 10.8, 1)), Some(0.0)); + } + + #[test] + fn full_engulf_is_not_piercing() { + let mut p = PiercingDarkCloud::new(); + p.update(c(12.0, 12.5, 10.0, 10.0, 0)); + // Closes above prev.open (12) -> engulfs, not piercing. + assert_eq!(p.update(c(9.8, 13.0, 9.5, 12.5, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut p = PiercingDarkCloud::new(); + assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 2 == 0 { + c(base + 2.0, base + 2.5, base, base, i) + } else { + c(base - 0.2, base + 1.8, base - 0.5, base + 1.5, i) + } + }) + .collect(); + let mut a = PiercingDarkCloud::new(); + let mut b = PiercingDarkCloud::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut p = PiercingDarkCloud::new(); + p.update(c(12.0, 12.5, 10.0, 10.0, 0)); + p.update(c(9.8, 11.8, 9.5, 11.5, 1)); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/pin.rs b/crates/wickra-core/src/indicators/pin.rs new file mode 100644 index 0000000..83d2f12 --- /dev/null +++ b/crates/wickra-core/src/indicators/pin.rs @@ -0,0 +1,223 @@ +//! PIN — Probability of Informed Trading (single-window EKOP estimate). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// PIN — the **Probability of Informed Trading**, estimated from the buy/sell order +/// imbalance over a rolling window of trades. +/// +/// ```text +/// over the last `window` trades: B = buys, S = sells (B + S = window) +/// PIN ≈ |B − S| / (B + S) ∈ [0, 1] +/// ``` +/// +/// The Easley-Kiefer-O'Hara-Paperman (EKOP) model splits order flow into an +/// uninformed component (balanced buys and sells, rate `ε` per side) and an +/// informed component that trades one-directionally when private information +/// arrives (rate `μ`, probability `α`). The probability that any given trade is +/// information-motivated is `PIN = αμ / (αμ + 2ε)`. Estimated over a single window, +/// the informed flow shows up as the **net imbalance** `|B − S|` and the uninformed +/// flow as the balanced remainder, giving the moment estimator above. A high PIN +/// flags a one-sided, likely-informed market; a low PIN flags balanced, uninformed +/// flow. +/// +/// This is distinct from [`Vpin`](crate::Vpin), the volume-synchronised variant +/// that buckets by volume and uses bulk-volume classification; here trades are +/// counted in event time and classified by their tagged aggressor side. The full +/// PIN is fit by maximum likelihood over many periods — this single-window +/// estimator is the streaming moment approximation. The output is in `[0, 1]`; the +/// first value lands after `window` trades. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Pin, Side, Trade}; +/// +/// let mut indicator = Pin::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// // All buys -> maximally one-sided -> PIN 1. +/// last = indicator.update(Trade::new(100.0, 1.0, Side::Buy, i).unwrap()); +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct Pin { + window: usize, + sides: VecDeque, + buy_count: usize, + last: Option, +} + +impl Pin { + /// Construct a PIN estimator over `window` trades. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window == 0`. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + sides: VecDeque::with_capacity(window), + buy_count: 0, + last: None, + }) + } + + /// Configured window of trades. + pub const fn window(&self) -> usize { + self.window + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Pin { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + let is_buy = trade.side.sign() > 0.0; + if self.sides.len() == self.window { + let old = self.sides.pop_front().expect("non-empty"); + if old > 0.0 { + self.buy_count -= 1; + } + } + self.sides.push_back(if is_buy { 1.0 } else { 0.0 }); + if is_buy { + self.buy_count += 1; + } + if self.sides.len() < self.window { + return None; + } + // The window is full and `window >= 1` (zero is rejected at + // construction), so the trade count is always positive — `|B - S| / N` + // needs no zero guard. + let buys = self.buy_count as f64; + let sells = self.window as f64 - buys; + let total = self.window as f64; + let pin = (buys - sells).abs() / total; + self.last = Some(pin); + Some(pin) + } + + fn reset(&mut self) { + self.sides.clear(); + self.buy_count = 0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "PIN" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn buy() -> Trade { + Trade::new_unchecked(100.0, 1.0, Side::Buy, 0) + } + + fn sell() -> Trade { + Trade::new_unchecked(100.0, 1.0, Side::Sell, 0) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(Pin::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let p = Pin::new(20).unwrap(); + assert_eq!(p.window(), 20); + assert_eq!(p.warmup_period(), 20); + assert_eq!(p.name(), "PIN"); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut p = Pin::new(4).unwrap(); + let out = p.batch(&[buy(), buy(), buy(), buy(), buy()]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn one_sided_flow_is_one() { + let mut p = Pin::new(10).unwrap(); + let trades: Vec = (0..20).map(|_| buy()).collect(); + let last = p.batch(&trades).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-12); + } + + #[test] + fn balanced_flow_is_zero() { + let mut p = Pin::new(10).unwrap(); + let trades: Vec = (0..20) + .map(|i| if i % 2 == 0 { buy() } else { sell() }) + .collect(); + let last = p.batch(&trades).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut p = Pin::new(16).unwrap(); + let trades: Vec = (0..200) + .map(|i| if (i * 5 % 13) < 8 { buy() } else { sell() }) + .collect(); + for v in p.batch(&trades).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut p = Pin::new(4).unwrap(); + p.batch(&[buy(), buy(), sell(), buy()]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + assert_eq!(p.update(buy()), None); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..120) + .map(|i| if i % 3 == 0 { sell() } else { buy() }) + .collect(); + let batch = Pin::new(16).unwrap().batch(&trades); + let mut b = Pin::new(16).unwrap(); + let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/pivot_reversal.rs b/crates/wickra-core/src/indicators/pivot_reversal.rs new file mode 100644 index 0000000..4ff9e46 --- /dev/null +++ b/crates/wickra-core/src/indicators/pivot_reversal.rs @@ -0,0 +1,293 @@ +//! Pivot Reversal — a breakout signal off the most recent confirmed swing pivots. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Pivot Reversal — emits a reversal **breakout signal** when price closes through +/// the most recently confirmed swing pivot. +/// +/// ```text +/// pivot high: a bar whose high is strictly above the `left` bars before and the +/// `right` bars after it (confirmed `right` bars late) +/// pivot low : the mirror on lows +/// signal = +1 when close crosses above the last confirmed pivot high +/// signal = −1 when close crosses below the last confirmed pivot low +/// signal = 0 otherwise +/// ``` +/// +/// Unlike [`WilliamsFractals`](crate::WilliamsFractals), which merely *marks* the +/// swing points, Pivot Reversal turns them into an actionable entry: once a swing +/// high is confirmed it becomes a breakout trigger — a close back above it signals +/// a bullish reversal — and likewise a close below a confirmed swing low signals a +/// bearish reversal. This is the logic of the classic "Pivot Reversal" strategy. +/// Signals fire only on the **crossing** bar, not while price sits beyond the +/// level. +/// +/// The first signal can appear once `left + right + 1` bars exist (a pivot needs +/// neighbours on both sides). The output is `+1` / `0` / `−1`. Each `update` is +/// O(`left + right`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, PivotReversal}; +/// +/// let mut indicator = PivotReversal::new(2, 2).unwrap(); +/// let mut fired = false; +/// for i in 0..60 { +/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// match indicator.update(c) { +/// Some(s) if s != 0.0 => fired = true, +/// _ => {} +/// } +/// } +/// let _ = fired; +/// ``` +#[derive(Debug, Clone)] +pub struct PivotReversal { + left: usize, + right: usize, + window: VecDeque, + pivot_high: Option, + pivot_low: Option, + prev_close: Option, + last: Option, +} + +impl PivotReversal { + /// Construct a Pivot Reversal with `left` bars before and `right` bars after + /// the pivot. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `left` or `right` is `0`. + pub fn new(left: usize, right: usize) -> Result { + if left == 0 || right == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + left, + right, + window: VecDeque::with_capacity(left + right + 1), + pivot_high: None, + pivot_low: None, + prev_close: None, + last: None, + }) + } + + /// Configured `(left, right)` strengths. + pub const fn params(&self) -> (usize, usize) { + (self.left, self.right) + } + + /// Most recent confirmed pivot-high level, if any. + pub const fn pivot_high(&self) -> Option { + self.pivot_high + } + + /// Most recent confirmed pivot-low level, if any. + pub const fn pivot_low(&self) -> Option { + self.pivot_low + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for PivotReversal { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + if self.window.len() == self.left + self.right + 1 { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.left + self.right + 1 { + self.prev_close = Some(close); + return None; + } + + // Confirm the pivot candidate sitting `right` bars back. + let cand = self.window[self.left]; + let is_high = self + .window + .iter() + .enumerate() + .all(|(i, c)| i == self.left || c.high < cand.high); + let is_low = self + .window + .iter() + .enumerate() + .all(|(i, c)| i == self.left || c.low > cand.low); + if is_high { + self.pivot_high = Some(cand.high); + } + if is_low { + self.pivot_low = Some(cand.low); + } + + // Breakout crossing of the latest confirmed pivots by the current close. + let mut signal = 0.0; + if let (Some(ph), Some(prev)) = (self.pivot_high, self.prev_close) { + if close > ph && prev <= ph { + signal = 1.0; + } + } + if let (Some(pl), Some(prev)) = (self.pivot_low, self.prev_close) { + if close < pl && prev >= pl { + signal = -1.0; + } + } + self.prev_close = Some(close); + self.last = Some(signal); + Some(signal) + } + + fn reset(&mut self) { + self.window.clear(); + self.pivot_high = None; + self.pivot_low = None; + self.prev_close = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.left + self.right + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "PivotReversal" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(close, high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_params() { + assert!(matches!(PivotReversal::new(0, 2), Err(Error::PeriodZero))); + assert!(matches!(PivotReversal::new(2, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let p = PivotReversal::new(2, 2).unwrap(); + assert_eq!(p.params(), (2, 2)); + assert_eq!(p.warmup_period(), 5); + assert_eq!(p.name(), "PivotReversal"); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + assert_eq!(p.pivot_high(), None); + assert_eq!(p.pivot_low(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut p = PivotReversal::new(1, 1).unwrap(); + let out = p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_some()); + } + + #[test] + fn confirms_pivot_high() { + // bar1 is a local high; once bar2 arrives it is confirmed. + let mut p = PivotReversal::new(1, 1).unwrap(); + p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]); + assert_eq!(p.pivot_high(), Some(12.0)); + } + + #[test] + fn confirms_pivot_low() { + let mut p = PivotReversal::new(1, 1).unwrap(); + p.batch(&[c(12.0, 11.0, 11.5), c(10.0, 8.0, 8.5), c(12.0, 11.0, 11.5)]); + assert_eq!(p.pivot_low(), Some(8.0)); + } + + #[test] + fn breakout_above_pivot_high_signals_plus_one() { + let mut p = PivotReversal::new(1, 1).unwrap(); + // Form a pivot high at 12, then a close above 12 crosses it. + let candles = [ + c(10.0, 9.0, 9.5), // index 0 + c(12.0, 11.0, 11.5), // pivot-high candidate + c(10.0, 9.0, 9.5), // confirms pivot high = 12 + c(11.0, 9.0, 9.0), // close 9.0 (below 12) + c(14.0, 12.5, 13.0), // close 13.0 > 12 and prev 9.0 <= 12 -> +1 + ]; + let out = p.batch(&candles); + assert_eq!(out.last().unwrap(), &Some(1.0)); + } + + #[test] + fn breakdown_below_pivot_low_signals_minus_one() { + let mut p = PivotReversal::new(1, 1).unwrap(); + let candles = [ + c(12.0, 11.0, 11.5), + c(10.0, 8.0, 8.5), // pivot-low candidate + c(12.0, 11.0, 11.5), // confirms pivot low = 8 + c(12.0, 9.0, 11.0), // close 11 (above 8) + c(9.0, 6.0, 7.0), // close 7 < 8 and prev 11 >= 8 -> -1 + ]; + let out = p.batch(&candles); + assert_eq!(out.last().unwrap(), &Some(-1.0)); + } + + #[test] + fn no_break_is_zero() { + let mut p = PivotReversal::new(1, 1).unwrap(); + let candles = [ + c(10.0, 9.0, 9.5), + c(12.0, 11.0, 11.5), + c(10.0, 9.0, 9.5), + c(10.5, 9.0, 9.8), + ]; + let out = p.batch(&candles); + assert_eq!(out.last().unwrap(), &Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut p = PivotReversal::new(1, 1).unwrap(); + p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + assert_eq!(p.pivot_high(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.4).sin() * 6.0; + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let batch = PivotReversal::new(2, 2).unwrap().batch(&candles); + let mut b = PivotReversal::new(2, 2).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/plus_di.rs b/crates/wickra-core/src/indicators/plus_di.rs new file mode 100644 index 0000000..5574ecd --- /dev/null +++ b/crates/wickra-core/src/indicators/plus_di.rs @@ -0,0 +1,201 @@ +//! Plus Directional Indicator (+DI), Wilder-smoothed. + +use crate::error::{Error, Result}; +use crate::indicators::adx::directional_movement; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wilder's Plus Directional Indicator (`PLUS_DI`). +/// +/// `+DI = 100 · smoothed(+DM) / smoothed(TR)`, where both the plus directional +/// movement and the true range are Wilder-smoothed over `period` bars. It is the +/// bullish half of the directional system that drives [`Adx`](crate::Adx); +/// readings above [`MinusDi`](crate::MinusDi) mark an up-trending regime. +/// +/// The first `period` raw values seed the two running sums; from then on each +/// applies the Wilder recursion `smoothed − smoothed / period + raw`. Because a +/// bar's directional movement and true range both need the previous bar, the +/// first value is emitted after `period + 1` candles. When the smoothed true +/// range is zero (a perfectly flat market) the indicator returns `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, PlusDi}; +/// +/// let mut indicator = PlusDi::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PlusDi { + period: usize, + prev: Option, + dm_seed: f64, + tr_seed: f64, + seed_count: usize, + dm_smooth: Option, + tr_smooth: Option, +} + +impl PlusDi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + dm_seed: 0.0, + tr_seed: 0.0, + seed_count: 0, + dm_smooth: None, + tr_smooth: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for PlusDi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + self.prev = Some(candle); + + let (plus_dm, _) = directional_movement(&prev, &candle); + let tr = candle.true_range(Some(prev.close)); + let n = self.period as f64; + + let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) { + let d_new = d - d / n + plus_dm; + let t_new = t - t / n + tr; + self.dm_smooth = Some(d_new); + self.tr_smooth = Some(t_new); + (d_new, t_new) + } else { + self.dm_seed += plus_dm; + self.tr_seed += tr; + self.seed_count += 1; + if self.seed_count < self.period { + return None; + } + self.dm_smooth = Some(self.dm_seed); + self.tr_smooth = Some(self.tr_seed); + (self.dm_seed, self.tr_seed) + }; + + let di = if tr_v == 0.0 { + 0.0 + } else { + 100.0 * dm_v / tr_v + }; + Some(di) + } + + fn reset(&mut self) { + self.prev = None; + self.dm_seed = 0.0; + self.tr_seed = 0.0; + self.seed_count = 0; + self.dm_smooth = None; + self.tr_smooth = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.dm_smooth.is_some() + } + + fn name(&self) -> &'static str { + "PLUS_DI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(PlusDi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let di = PlusDi::new(7).unwrap(); + assert_eq!(di.period(), 7); + assert_eq!(di.name(), "PLUS_DI"); + assert_eq!(di.warmup_period(), 7); + assert!(!di.is_ready()); + } + + #[test] + fn uptrend_drives_plus_di_high() { + // Strict uptrend: +DM dominates, so +DI is large and bounded by 100. + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i) * 2.0; + c(base + 1.0, base - 0.5, base + 0.5) + }) + .collect(); + let mut di = PlusDi::new(3).unwrap(); + let out: Vec> = di.batch(&candles); + assert_eq!(out[0], None); + // Seeds after `period` directional moves (candle index `period`). + assert!(out[3].is_some()); + let last = out.into_iter().flatten().last().unwrap(); + assert!(last > 0.0 && last <= 100.0); + assert!(di.is_ready()); + } + + #[test] + fn flat_market_returns_zero() { + // No range and no movement: smoothed true range is zero -> +DI is zero. + let candles: Vec = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect(); + let mut di = PlusDi::new(3).unwrap(); + let last = di.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_restores_initial_state() { + let candles: Vec = (0..6) + .map(|i| { + let base = 100.0 + f64::from(i) * 2.0; + c(base + 1.0, base - 0.5, base + 0.5) + }) + .collect(); + let mut di = PlusDi::new(3).unwrap(); + let _ = di.batch(&candles); + assert!(di.is_ready()); + di.reset(); + assert!(!di.is_ready()); + assert_eq!(di.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/plus_dm.rs b/crates/wickra-core/src/indicators/plus_dm.rs new file mode 100644 index 0000000..23646e2 --- /dev/null +++ b/crates/wickra-core/src/indicators/plus_dm.rs @@ -0,0 +1,197 @@ +//! Plus Directional Movement (+DM), Wilder-smoothed. + +use crate::error::{Error, Result}; +use crate::indicators::adx::directional_movement; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wilder's Plus Directional Movement (`PLUS_DM`). +/// +/// The raw plus directional movement of a bar is `max(high − high_prev, 0)` when +/// the up-move exceeds the down-move `low_prev − low`, and `0` otherwise. This +/// indicator returns the Wilder-smoothed running total of that raw `+DM` over +/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and +/// [`PlusDi`](crate::PlusDi). +/// +/// The first `period` raw values seed the sum; from then on each update applies +/// the Wilder recursion `smoothed − smoothed / period + raw`. Because a bar's +/// directional movement needs the previous bar, the first value is emitted after +/// `period + 1` candles. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, PlusDm}; +/// +/// let mut indicator = PlusDm::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PlusDm { + period: usize, + prev: Option, + seed: f64, + seed_count: usize, + smooth: Option, +} + +impl PlusDm { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + seed: 0.0, + seed_count: 0, + smooth: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for PlusDm { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + self.prev = Some(candle); + + let (plus_dm, _) = directional_movement(&prev, &candle); + let n = self.period as f64; + + if let Some(s) = self.smooth { + let s_new = s - s / n + plus_dm; + self.smooth = Some(s_new); + return Some(s_new); + } + + self.seed += plus_dm; + self.seed_count += 1; + if self.seed_count < self.period { + return None; + } + self.smooth = Some(self.seed); + Some(self.seed) + } + + fn reset(&mut self) { + self.prev = None; + self.seed = 0.0; + self.seed_count = 0; + self.smooth = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.smooth.is_some() + } + + fn name(&self) -> &'static str { + "PLUS_DM" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Candle with explicit high/low; open and close are pinned to `cl`. + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(PlusDm::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let dm = PlusDm::new(7).unwrap(); + assert_eq!(dm.period(), 7); + assert_eq!(dm.name(), "PLUS_DM"); + assert_eq!(dm.warmup_period(), 7); + assert!(!dm.is_ready()); + } + + #[test] + fn seeds_then_smooths_a_constant_plus_dm() { + // High rises by 1 each bar (up = +1); low rises by 0.5 each bar, so the + // down-move is negative and +DM equals the up-move (1.0) on every bar. + let candles: Vec = (0..5) + .map(|i| { + c( + 11.0 + f64::from(i), + 9.0 + 0.5 * f64::from(i), + 10.0 + f64::from(i), + ) + }) + .collect(); + let mut dm = PlusDm::new(3).unwrap(); + let out: Vec> = dm.batch(&candles); + // First candle only sets the previous bar; bars 2-3 seed the sum. + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_eq!(out[2], None); + // Seed = sum of three unit +DM values. + assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12); + // Wilder step: 3 - 3/3 + 1 = 3. + assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12); + assert!(dm.is_ready()); + } + + #[test] + fn down_moves_contribute_zero() { + // Strict downtrend: highs fall, so every raw +DM is zero and the smoothed + // total stays at zero. + let candles: Vec = (0..6) + .map(|i| c(20.0 - f64::from(i), 5.0 - f64::from(i), 12.0 - f64::from(i))) + .collect(); + let mut dm = PlusDm::new(3).unwrap(); + let last = dm.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_restores_initial_state() { + let candles: Vec = (0..5) + .map(|i| { + c( + 11.0 + f64::from(i), + 9.0 + 0.5 * f64::from(i), + 10.0 + f64::from(i), + ) + }) + .collect(); + let mut dm = PlusDm::new(3).unwrap(); + let _ = dm.batch(&candles); + assert!(dm.is_ready()); + dm.reset(); + assert!(!dm.is_ready()); + assert_eq!(dm.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/pmo.rs b/crates/wickra-core/src/indicators/pmo.rs new file mode 100644 index 0000000..10d9f72 --- /dev/null +++ b/crates/wickra-core/src/indicators/pmo.rs @@ -0,0 +1,244 @@ +//! Price Momentum Oscillator (`DecisionPoint`). + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Price Momentum Oscillator — Carl Swenlin's `DecisionPoint` PMO line. +/// +/// PMO is a doubly-smoothed rate of change. The 1-bar percentage change is +/// smoothed once, scaled by `10`, then smoothed again: +/// +/// ```text +/// roc_t = (price_t / price_{t−1} − 1) · 100 +/// smoothed_t = customEMA(roc, smoothing1)_t +/// PMO_t = customEMA(10 · smoothed, smoothing2)_t +/// ``` +/// +/// `customEMA` is the `DecisionPoint` smoothing: an exponential average whose +/// smoothing constant is `2 / period` (not the textbook `2 / (period + 1)`), +/// seeded from the very first value. The conventional periods are `35` and +/// `20`. The classic PMO **signal line** is simply a 10-period EMA of this +/// PMO line — compose it with [`Chain`](crate::Chain) and an [`Ema`] if you +/// need it. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Pmo}; +/// +/// let mut indicator = Pmo::new(35, 20).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Pmo { + smoothing1: usize, + smoothing2: usize, + prev_price: Option, + ema1: Ema, + ema2: Ema, + current: Option, +} + +impl Pmo { + /// Construct a new PMO with the two smoothing periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`, or + /// [`Error::InvalidPeriod`] if either is `1` (the smoothing constant + /// `2 / period` must not exceed `1`). + pub fn new(smoothing1: usize, smoothing2: usize) -> Result { + if smoothing1 == 0 || smoothing2 == 0 { + return Err(Error::PeriodZero); + } + if smoothing1 < 2 || smoothing2 < 2 { + return Err(Error::InvalidPeriod { + message: "PMO smoothing periods must be >= 2", + }); + } + Ok(Self { + smoothing1, + smoothing2, + prev_price: None, + ema1: Ema::with_alpha(2.0 / smoothing1 as f64)?, + ema2: Ema::with_alpha(2.0 / smoothing2 as f64)?, + current: None, + }) + } + + /// The `(smoothing1, smoothing2)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.smoothing1, self.smoothing2) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Pmo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.current; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + + let roc = if prev == 0.0 { + // Undefined ratio against a zero price: treat momentum as flat. + 0.0 + } else { + (input / prev - 1.0) * 100.0 + }; + let smoothed = self.ema1.update(roc)?; + let pmo = self.ema2.update(10.0 * smoothed)?; + self.current = Some(pmo); + Some(pmo) + } + + fn reset(&mut self) { + self.prev_price = None; + self.ema1.reset(); + self.ema2.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The first ROC needs a previous price; both customEMAs seed from + // their first input, so the first PMO lands on the second update. + 2 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "PMO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Pmo::new(0, 20), Err(Error::PeriodZero))); + assert!(matches!(Pmo::new(35, 0), Err(Error::PeriodZero))); + } + + #[test] + fn new_rejects_period_one() { + assert!(matches!(Pmo::new(1, 20), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Pmo::new(35, 1), Err(Error::InvalidPeriod { .. }))); + } + + /// Cover the const accessors `periods` / `value` (lines 76-83) and the + /// Indicator-impl `name` body (130-132). `warmup_period` is already + /// covered by `first_emission_at_second_update`. + #[test] + fn accessors_and_metadata() { + let mut pmo = Pmo::new(35, 20).unwrap(); + assert_eq!(pmo.periods(), (35, 20)); + assert_eq!(pmo.name(), "PMO"); + assert_eq!(pmo.value(), None); + pmo.update(100.0); + pmo.update(101.0); + assert!(pmo.value().is_some()); + } + + /// Cover the `prev == 0.0` defensive branch (line 103). The PMO ROC + /// divides by the previous price; existing tests use prices ≈ 100, so + /// the divide-by-zero guard never fired. Feed a single zero price + /// followed by a positive price and assert the first emitted PMO is + /// the flat-momentum value (the wrapping `customEMA` of `0.0` is 0.0 + /// regardless of smoothing factor on its first input). + #[test] + fn zero_previous_price_treats_roc_as_flat() { + let mut pmo = Pmo::new(2, 2).unwrap(); + // Seed prev_price = 0. + assert_eq!(pmo.update(0.0), None); + // Next bar: prev == 0 hits the fallback returning roc = 0.0; the + // doubly-smoothed PMO seeds at 0.0 (10 * 0 = 0 through both EMAs). + let out = pmo.update(50.0).expect("emits"); + assert_eq!(out, 0.0); + } + + #[test] + fn first_emission_at_second_update() { + let mut pmo = Pmo::new(35, 20).unwrap(); + assert_eq!(pmo.warmup_period(), 2); + assert_eq!(pmo.update(100.0), None); + assert!(pmo.update(101.0).is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Flat prices -> ROC is always 0 -> both smoothings stay at 0. + let mut pmo = Pmo::new(35, 20).unwrap(); + let out = pmo.batch(&[100.0; 60]); + for v in out.iter().skip(2).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn steady_uptrend_is_positive() { + let mut pmo = Pmo::new(35, 20).unwrap(); + let prices: Vec = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = pmo.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!( + *last > 0.0, + "steady uptrend PMO should be positive, got {last}" + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut pmo = Pmo::new(35, 20).unwrap(); + let out = pmo.batch(&(1..=60).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(pmo.update(f64::NAN), last); + assert_eq!(pmo.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut pmo = Pmo::new(35, 20).unwrap(); + pmo.batch(&(1..=60).map(f64::from).collect::>()); + assert!(pmo.is_ready()); + pmo.reset(); + assert!(!pmo.is_ready()); + assert_eq!(pmo.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0) + .collect(); + let batch = Pmo::new(35, 20).unwrap().batch(&prices); + let mut b = Pmo::new(35, 20).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/point_and_figure_bars.rs b/crates/wickra-core/src/indicators/point_and_figure_bars.rs new file mode 100644 index 0000000..b2ebe60 --- /dev/null +++ b/crates/wickra-core/src/indicators/point_and_figure_bars.rs @@ -0,0 +1,296 @@ +//! Point-and-Figure bar builder — box-size columns with an N-box reversal. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed Point-and-Figure column. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PnfColumn { + /// `+1` for a rising (X) column, `-1` for a falling (O) column. + pub direction: i8, + /// Upper box edge of the column. + pub high: f64, + /// Lower box edge of the column. + pub low: f64, +} + +/// Point-and-Figure bar builder using the fixed box-size, N-box reversal method. +/// +/// Price is quantised to a `box_size` grid (each close maps to the box that +/// contains it). An X column extends upward while price makes new box highs; an +/// O column extends downward while price makes new box lows. A reversal needs +/// price to move `reversal` boxes against the column, at which point the current +/// column is closed (returned from [`BarBuilder::update`]) and a new column +/// starts one box offset from the prior extreme. +/// +/// - The first candle seeds the grid box and prints no column. +/// - The first one-box move sets the initial column direction. +/// - At most one column completes per candle, so `update` returns an empty +/// vector or a single [`PnfColumn`]. +/// +/// Closes are mapped to their containing box via `floor(close / box_size)` for +/// both directions, so the construction is fully deterministic. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, PointAndFigureBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); +/// pnf.update(flat(10.0)); // seed +/// pnf.update(flat(15.0)); // X column up to 15 +/// let cols = pnf.update(flat(12.0)); // 3-box reversal closes the X column +/// assert_eq!(cols.len(), 1); +/// assert_eq!(cols[0].direction, 1); +/// ``` +#[derive(Debug, Clone)] +pub struct PointAndFigureBars { + box_size: f64, + reversal: usize, + dir: i8, + col_top: f64, + col_bottom: f64, + seeded: bool, +} + +impl PointAndFigureBars { + /// Construct a Point-and-Figure builder with the given box size and reversal + /// (in boxes). + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `box_size` is not finite and positive, + /// and [`Error::PeriodZero`] if `reversal` is zero. + pub fn new(box_size: f64, reversal: usize) -> Result { + if !box_size.is_finite() || box_size <= 0.0 { + return Err(Error::InvalidPeriod { + message: "box_size must be finite and positive", + }); + } + if reversal == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + box_size, + reversal, + dir: 0, + col_top: 0.0, + col_bottom: 0.0, + seeded: false, + }) + } + + /// Configured box size. + pub const fn box_size(&self) -> f64 { + self.box_size + } + + /// Configured reversal, in boxes. + pub const fn reversal(&self) -> usize { + self.reversal + } + + fn floor_box(&self, price: f64) -> f64 { + (price / self.box_size).floor() * self.box_size + } +} + +impl BarBuilder for PointAndFigureBars { + type Bar = PnfColumn; + + fn update(&mut self, candle: Candle) -> Vec { + let box_price = self.floor_box(candle.close); + if !self.seeded { + self.seeded = true; + self.col_top = box_price; + self.col_bottom = box_price; + return Vec::new(); + } + let box_size = self.box_size; + let reversal = self.reversal as f64 * box_size; + let mut cols = Vec::new(); + match self.dir { + 0 => { + if box_price >= self.col_top + box_size { + self.dir = 1; + self.col_top = box_price; + } else if box_price <= self.col_bottom - box_size { + self.dir = -1; + self.col_bottom = box_price; + } + } + 1 => { + if box_price > self.col_top { + self.col_top = box_price; + } else if box_price <= self.col_top - reversal { + cols.push(PnfColumn { + direction: 1, + high: self.col_top, + low: self.col_bottom, + }); + self.dir = -1; + self.col_top -= box_size; + self.col_bottom = box_price; + } + } + _ => { + if box_price < self.col_bottom { + self.col_bottom = box_price; + } else if box_price >= self.col_bottom + reversal { + cols.push(PnfColumn { + direction: -1, + high: self.col_top, + low: self.col_bottom, + }); + self.dir = 1; + self.col_bottom += box_size; + self.col_top = box_price; + } + } + } + cols + } + + fn reset(&mut self) { + self.dir = 0; + self.col_top = 0.0; + self.col_bottom = 0.0; + self.seeded = false; + } + + fn name(&self) -> &'static str { + "PointAndFigureBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_box_size() { + assert!(matches!( + PointAndFigureBars::new(0.0, 3), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + PointAndFigureBars::new(f64::NAN, 3), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn rejects_zero_reversal() { + assert!(matches!( + PointAndFigureBars::new(1.0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let pnf = PointAndFigureBars::new(0.5, 3).unwrap(); + assert_eq!(pnf.name(), "PointAndFigureBars"); + assert_relative_eq!(pnf.box_size(), 0.5, epsilon = 1e-12); + assert_eq!(pnf.reversal(), 3); + } + + #[test] + fn first_candle_seeds_without_column() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + assert!(pnf.update(flat(10.0)).is_empty()); + } + + #[test] + fn establishes_up_then_extends() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + assert!(pnf.update(flat(13.0)).is_empty()); // start X column + assert!(pnf.update(flat(15.0)).is_empty()); // extend up, no completed column + } + + #[test] + fn establishes_down_direction() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + assert!(pnf.update(flat(7.0)).is_empty()); // start O column + } + + #[test] + fn reversal_closes_x_column() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + pnf.update(flat(13.0)); + pnf.update(flat(15.0)); + let cols = pnf.update(flat(12.0)); // 3-box drop from 15 + assert_eq!(cols.len(), 1); + assert_eq!(cols[0].direction, 1); + assert_relative_eq!(cols[0].high, 15.0, epsilon = 1e-12); + assert_relative_eq!(cols[0].low, 10.0, epsilon = 1e-12); + } + + #[test] + fn reversal_closes_o_column() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + pnf.update(flat(13.0)); + pnf.update(flat(15.0)); + pnf.update(flat(12.0)); // now O column from 14 down + pnf.update(flat(10.0)); // extend O down to 10 + let cols = pnf.update(flat(15.0)); // 3-box rise -> closes O column + assert_eq!(cols.len(), 1); + assert_eq!(cols[0].direction, -1); + assert_relative_eq!(cols[0].low, 10.0, epsilon = 1e-12); + } + + #[test] + fn small_move_prints_nothing() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + pnf.update(flat(13.0)); + pnf.update(flat(15.0)); + assert!(pnf.update(flat(14.0)).is_empty()); // 1-box pullback < 3 + } + + #[test] + fn down_column_small_bounce_prints_nothing() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + pnf.update(flat(7.0)); // O column + pnf.update(flat(5.0)); // extend down + assert!(pnf.update(flat(6.0)).is_empty()); // 1-box bounce < 3 + } + + #[test] + fn reset_clears_state() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + pnf.update(flat(10.0)); + pnf.update(flat(15.0)); + pnf.reset(); + assert!(pnf.update(flat(99.0)).is_empty()); // re-seeds + assert!(pnf.update(flat(100.0)).is_empty()); // first move after reseed + } + + #[test] + fn batch_collects_completed_columns() { + let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap(); + let candles = [ + flat(10.0), + flat(15.0), // X column + flat(12.0), // reversal -> closes X + flat(9.0), // extend O + flat(15.0), // reversal -> closes O + ]; + let cols = pnf.batch(&candles); + assert_eq!(cols.len(), 2); + assert_eq!(cols[0].direction, 1); + assert_eq!(cols[1].direction, -1); + } +} diff --git a/crates/wickra-core/src/indicators/polarized_fractal_efficiency.rs b/crates/wickra-core/src/indicators/polarized_fractal_efficiency.rs new file mode 100644 index 0000000..f865b57 --- /dev/null +++ b/crates/wickra-core/src/indicators/polarized_fractal_efficiency.rs @@ -0,0 +1,246 @@ +//! Polarized Fractal Efficiency (PFE). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Polarized Fractal Efficiency: how efficiently price travelled over the last +/// `period` bars, signed by direction and smoothed by an EMA. +/// +/// ```text +/// straight = sqrt((C_t - C_{t-n})^2 + n^2) (direct distance over n bars) +/// path = Σ_{i=1..n} sqrt((C_{t-i+1} - C_{t-i})^2 + 1) (sum of single-bar steps) +/// raw = 100 * sign(C_t - C_{t-n}) * straight / path +/// PFE = EMA(raw, smoothing) +/// ``` +/// +/// The ratio `straight / path` is the fractal efficiency: it is `1` when price +/// moved in a perfectly straight line and falls toward `0` as the path becomes +/// jagged. Polarizing it by the sign of the net move pushes the reading to +/// `+100` for an efficient up-move and `-100` for an efficient down-move, with +/// choppy markets oscillating near zero. Because each single-bar step and the +/// `n`-bar diagonal both carry the bar count on the x-axis (`+1` and `+n^2`), +/// the path length is always `>= n`, so the denominator can never be zero. +/// +/// Reference: Hans Hannula, *Stocks & Commodities*, 1994. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PolarizedFractalEfficiency}; +/// +/// let mut indicator = PolarizedFractalEfficiency::new(10, 5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PolarizedFractalEfficiency { + period: usize, + smoothing: usize, + closes: VecDeque, + prev_close: Option, + segments: VecDeque, + segment_sum: f64, + ema: Ema, +} + +impl PolarizedFractalEfficiency { + /// Construct a PFE with the fractal lookback `period` and the EMA + /// `smoothing` period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` or `smoothing == 0`. + pub fn new(period: usize, smoothing: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + smoothing, + closes: VecDeque::with_capacity(period + 1), + prev_close: None, + segments: VecDeque::with_capacity(period), + segment_sum: 0.0, + ema: Ema::new(smoothing)?, + }) + } + + /// Configured `(period, smoothing)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.smoothing) + } +} + +impl Indicator for PolarizedFractalEfficiency { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + if !close.is_finite() { + return None; + } + if let Some(prev) = self.prev_close { + let diff = close - prev; + let segment = diff.mul_add(diff, 1.0).sqrt(); + self.segment_sum += segment; + self.segments.push_back(segment); + if self.segments.len() > self.period { + self.segment_sum -= self.segments.pop_front().unwrap_or(0.0); + } + } + self.prev_close = Some(close); + + self.closes.push_back(close); + if self.closes.len() > self.period + 1 { + self.closes.pop_front(); + } + if self.closes.len() <= self.period { + return None; + } + + let oldest = *self.closes.front().unwrap_or(&close); + let net = close - oldest; + let direction = if net > 0.0 { + 1.0 + } else if net < 0.0 { + -1.0 + } else { + 0.0 + }; + let span = self.period as f64; + let straight = net.mul_add(net, span * span).sqrt(); + let raw = 100.0 * direction * straight / self.segment_sum; + self.ema.update(raw) + } + + fn reset(&mut self) { + self.closes.clear(); + self.prev_close = None; + self.segments.clear(); + self.segment_sum = 0.0; + self.ema.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + self.smoothing + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "PolarizedFractalEfficiency" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + PolarizedFractalEfficiency::new(0, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + PolarizedFractalEfficiency::new(10, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let pfe = PolarizedFractalEfficiency::new(10, 5).unwrap(); + assert_eq!(pfe.periods(), (10, 5)); + assert_eq!(pfe.warmup_period(), 15); + assert_eq!(pfe.name(), "PolarizedFractalEfficiency"); + assert!(!pfe.is_ready()); + } + + #[test] + fn warmup_emits_after_period_plus_smoothing() { + let mut pfe = PolarizedFractalEfficiency::new(4, 2).unwrap(); + // raw needs period+1 = 5 closes; EMA(2) needs 2 raws -> first value at + // input 6 (index 5). + let inputs: Vec = (0..10).map(f64::from).collect(); + let out = pfe.batch(&inputs); + assert!(out[4].is_none()); + assert!(out[5].is_some()); + } + + #[test] + fn perfect_uptrend_is_strongly_positive() { + // A straight ramp: every step is +1, the diagonal is maximally + // efficient, so PFE saturates near +100. + let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap(); + let inputs: Vec = (0..30).map(f64::from).collect(); + let last = pfe.batch(&inputs).last().unwrap().unwrap(); + assert!(last > 99.0, "pfe {last} should be near +100"); + } + + #[test] + fn perfect_downtrend_is_strongly_negative() { + let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap(); + let inputs: Vec = (0..30).map(|i| -f64::from(i)).collect(); + let last = pfe.batch(&inputs).last().unwrap().unwrap(); + assert!(last < -99.0, "pfe {last} should be near -100"); + } + + #[test] + fn flat_market_returns_zero() { + // No net move over the window -> direction 0 -> raw 0 -> PFE 0. + let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap(); + let inputs = [10.0; 20]; + let last = pfe.batch(&inputs).last().unwrap().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn choppy_market_is_inefficient() { + // A sawtooth whip: the net move is tiny relative to the jagged path, so + // efficiency stays well below the +-100 saturation of a clean trend. + let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap(); + let inputs: Vec = (0..40) + .map(|i| if i % 2 == 0 { 100.0 } else { 102.0 }) + .collect(); + let last = pfe.batch(&inputs).last().unwrap().unwrap(); + assert!( + last.abs() < 60.0, + "choppy pfe {last} should be far from +-100" + ); + } + + #[test] + fn reset_clears_state() { + let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap(); + let inputs: Vec = (0..30).map(f64::from).collect(); + pfe.batch(&inputs); + assert!(pfe.is_ready()); + pfe.reset(); + assert!(!pfe.is_ready()); + assert_eq!(pfe.periods(), (5, 3)); + } + + #[test] + fn batch_equals_streaming() { + let inputs: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = PolarizedFractalEfficiency::new(10, 5).unwrap(); + let mut b = PolarizedFractalEfficiency::new(10, 5).unwrap(); + assert_eq!( + a.batch(&inputs), + inputs.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/ppo.rs b/crates/wickra-core/src/indicators/ppo.rs new file mode 100644 index 0000000..89eca47 --- /dev/null +++ b/crates/wickra-core/src/indicators/ppo.rs @@ -0,0 +1,233 @@ +//! Percentage Price Oscillator. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Percentage Price Oscillator — MACD expressed as a percentage. +/// +/// PPO is the gap between a fast and a slow EMA, divided by the slow EMA and +/// scaled to a percentage: +/// +/// ```text +/// PPO = 100 · (EMA_fast − EMA_slow) / EMA_slow +/// ``` +/// +/// Dividing by the slow EMA makes PPO **scale-free**: a `PPO` of `1.5` means +/// "the fast EMA is 1.5 % above the slow EMA" on any instrument, so PPO +/// readings *are* comparable across assets — unlike the raw price-unit +/// [`MacdIndicator`](crate::MacdIndicator). The classic PPO **signal line** is +/// a 9-period EMA of this PPO line; compose it with [`Chain`](crate::Chain) +/// and an [`Ema`] if you need it. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Ppo}; +/// +/// let mut indicator = Ppo::new(12, 26).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ppo { + fast: usize, + slow: usize, + ema_fast: Ema, + ema_slow: Ema, + current: Option, +} + +impl Ppo { + /// Construct a new PPO with the `fast` and `slow` EMA periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "PPO fast period must be < slow period", + }); + } + Ok(Self { + fast, + slow, + ema_fast: Ema::new(fast)?, + ema_slow: Ema::new(slow)?, + current: None, + }) + } + + /// The `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast, self.slow) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Ppo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the EMAs are not advanced. + return self.current; + } + let fast = self.ema_fast.update(input); + let slow = self.ema_slow.update(input); + match (fast, slow) { + (Some(f), Some(s)) => { + let ppo = if s == 0.0 { + // Undefined ratio against a zero slow EMA: report flat. + 0.0 + } else { + 100.0 * (f - s) / s + }; + self.current = Some(ppo); + Some(ppo) + } + _ => None, + } + } + + fn reset(&mut self) { + self.ema_fast.reset(); + self.ema_slow.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The slow EMA is the last to seed. + self.slow + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "PPO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Ppo::new(0, 26), Err(Error::PeriodZero))); + assert!(matches!(Ppo::new(12, 0), Err(Error::PeriodZero))); + } + + #[test] + fn new_rejects_fast_not_less_than_slow() { + assert!(matches!(Ppo::new(26, 12), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. }))); + } + + /// Cover the const accessors `periods` / `value` (lines 71-78) and the + /// Indicator-impl `name` body (122-124). `warmup_period` is already + /// covered by `first_emission_at_warmup_period`. + #[test] + fn accessors_and_metadata() { + let mut ppo = Ppo::new(12, 26).unwrap(); + assert_eq!(ppo.periods(), (12, 26)); + assert_eq!(ppo.name(), "PPO"); + assert_eq!(ppo.value(), None); + for i in 1..=26 { + ppo.update(f64::from(i)); + } + assert!(ppo.value().is_some()); + } + + /// Cover the `s == 0.0` defensive branch (line 96). PPO divides by + /// the slow EMA; existing tests use prices ≈ 100, so the slow EMA + /// is never 0. Feed a stream of zeros — both EMAs converge to 0.0 + /// and the indicator must emit exactly 0.0 (flat-momentum fallback) + /// rather than NaN. + #[test] + fn zero_slow_ema_yields_zero_ppo() { + let mut ppo = Ppo::new(3, 6).unwrap(); + let out = ppo.batch(&[0.0_f64; 20]); + let last = out.into_iter().flatten().last().expect("emits"); + assert_eq!(last, 0.0); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut ppo = Ppo::new(3, 6).unwrap(); + assert_eq!(ppo.warmup_period(), 6); + let out = ppo.batch(&(1..=30).map(f64::from).collect::>()); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Both EMAs converge to the constant, so their gap is zero. + let mut ppo = Ppo::new(3, 6).unwrap(); + let out = ppo.batch(&[100.0; 60]); + for v in out.iter().skip(5).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn uptrend_is_positive() { + // In a rising series the fast EMA leads the slow EMA, so PPO > 0. + let mut ppo = Ppo::new(5, 12).unwrap(); + let out = ppo.batch(&(1..=80).map(f64::from).collect::>()); + let last = out.iter().rev().flatten().next().unwrap(); + assert!(*last > 0.0, "uptrend PPO should be positive, got {last}"); + } + + #[test] + fn ignores_non_finite_input() { + let mut ppo = Ppo::new(3, 6).unwrap(); + let out = ppo.batch(&(1..=30).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(ppo.update(f64::NAN), last); + assert_eq!(ppo.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut ppo = Ppo::new(3, 6).unwrap(); + ppo.batch(&(1..=30).map(f64::from).collect::>()); + assert!(ppo.is_ready()); + ppo.reset(); + assert!(!ppo.is_ready()); + assert_eq!(ppo.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = Ppo::new(12, 26).unwrap().batch(&prices); + let mut b = Ppo::new(12, 26).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/ppo_histogram.rs b/crates/wickra-core/src/indicators/ppo_histogram.rs new file mode 100644 index 0000000..b3cedf8 --- /dev/null +++ b/crates/wickra-core/src/indicators/ppo_histogram.rs @@ -0,0 +1,230 @@ +//! Percentage Price Oscillator Histogram. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::ppo::Ppo; +use crate::traits::Indicator; + +/// PPO Histogram — the `ppo − signal` bar of the Percentage Price Oscillator. +/// +/// ```text +/// ppo = 100 · (EMA_fast − EMA_slow) / EMA_slow +/// signal = EMA(ppo, signal_period) +/// histogram = ppo − signal +/// ``` +/// +/// [`Ppo`](crate::Ppo) itself only emits the percentage line; this indicator +/// adds the classic 9-period signal EMA on top and reports the resulting +/// zero-centered histogram. Because PPO is scale-free (the EMA gap is divided +/// by the slow EMA), the histogram is **comparable across instruments** — a +/// PPO histogram of `0.4` means the same relative momentum on any asset, unlike +/// the price-unit [`MacdHistogram`](crate::MacdHistogram). +/// +/// With Appel's defaults `fast = 12`, `slow = 26`, `signal = 9`, the first +/// value lands after `slow + signal − 1` inputs — the point at which the slow +/// EMA and then the signal EMA are both seeded. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, PpoHistogram}; +/// +/// let mut indicator = PpoHistogram::new(12, 26, 9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PpoHistogram { + ppo: Ppo, + signal_ema: Ema, + signal_period: usize, + current: Option, +} + +impl PpoHistogram { + /// Construct a PPO histogram with the `fast`/`slow` EMA periods and the + /// `signal` EMA period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is `0`, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + if signal == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + ppo: Ppo::new(fast, slow)?, + signal_ema: Ema::new(signal)?, + signal_period: signal, + current: None, + }) + } + + /// Default `(12, 26, 9)` configuration. + pub fn classic() -> Self { + Self::new(12, 26, 9).expect("classic PPO periods are valid") + } + + /// Configured periods as `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + let (fast, slow) = self.ppo.periods(); + (fast, slow, self.signal_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for PpoHistogram { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Guard before touching either stage so a non-finite input never + // advances the signal EMA on a stale, re-fed PPO value. + if !input.is_finite() { + return self.current; + } + let ppo = self.ppo.update(input)?; + let signal = self.signal_ema.update(ppo)?; + let histogram = ppo - signal; + self.current = Some(histogram); + Some(histogram) + } + + fn reset(&mut self) { + self.ppo.reset(); + self.signal_ema.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // Slow EMA seeds the PPO, then the signal EMA needs `signal − 1` more. + self.ppo.warmup_period() + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "PpoHistogram" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + PpoHistogram::new(0, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + PpoHistogram::new(12, 0, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + PpoHistogram::new(12, 26, 0), + Err(Error::PeriodZero) + )); + assert!(matches!( + PpoHistogram::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let osc = PpoHistogram::classic(); + assert_eq!(osc.periods(), (12, 26, 9)); + assert_eq!(osc.name(), "PpoHistogram"); + assert_eq!(osc.warmup_period(), 26 + 9 - 1); + assert_eq!(osc.value(), None); + assert!(!osc.is_ready()); + } + + #[test] + fn equals_ppo_minus_signal_ema() { + // The histogram must equal PPO minus an EMA(signal) composed by hand. + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0) + .collect(); + let got = PpoHistogram::new(12, 26, 9).unwrap().batch(&prices); + + let mut ppo = Ppo::new(12, 26).unwrap(); + let mut sig = Ema::new(9).unwrap(); + let mut expected = Vec::with_capacity(prices.len()); + for p in &prices { + let out = ppo + .update(*p) + .and_then(|line| sig.update(line).map(|signal| line - signal)); + expected.push(out); + } + assert_eq!(got, expected); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut osc = PpoHistogram::new(3, 6, 3).unwrap(); + let warmup = osc.warmup_period(); + assert_eq!(warmup, 6 + 3 - 1); + for i in 1..warmup { + assert!(osc.update(100.0 + i as f64).is_none()); + } + assert!(osc.update(100.0 + warmup as f64).is_some()); + assert!(osc.is_ready()); + } + + #[test] + fn constant_series_converges_to_zero() { + let mut osc = PpoHistogram::classic(); + let out = osc.batch(&[100.0_f64; 200]); + let last = out.iter().rev().flatten().next().expect("emits a value"); + assert_relative_eq!(*last, 0.0, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut osc = PpoHistogram::new(3, 6, 3).unwrap(); + let out = osc.batch(&(1..=40).map(f64::from).collect::>()); + let before = *out.last().unwrap(); + assert!(before.is_some()); + assert_eq!(osc.update(f64::NAN), before); + assert_eq!(osc.update(f64::INFINITY), before); + assert_eq!(osc.value(), before); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=100) + .map(|i| 100.0 + (f64::from(i) * 0.4).cos() * 10.0) + .collect(); + let mut a = PpoHistogram::classic(); + let mut b = PpoHistogram::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut osc = PpoHistogram::classic(); + osc.batch(&(1..=80).map(f64::from).collect::>()); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + assert_eq!(osc.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/profile_shape.rs b/crates/wickra-core/src/indicators/profile_shape.rs new file mode 100644 index 0000000..cb38f0a --- /dev/null +++ b/crates/wickra-core/src/indicators/profile_shape.rs @@ -0,0 +1,285 @@ +//! Profile Shape — classifies the volume profile as b-shape, P-shape, or D/normal. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Profile Shape — classifies a rolling volume profile by where its point of +/// control (POC) sits within the range: `b`, `P`, or `D` (normal). +/// +/// ```text +/// build a `bins`-bucket volume profile over the last `period` candles +/// poc_idx = bin with the most volume +/// +1 P-shape : POC in the upper third (heavy top, thin tail down) — short-covering / accumulation +/// −1 b-shape : POC in the lower third (heavy bottom, thin tail up) — long-liquidation / distribution +/// 0 D/normal: POC in the middle third (balanced bell) +/// ``` +/// +/// Market Profile readers classify the day's shape by the location of the heaviest +/// trading. A **P-shape** (control high, a thin tail beneath) typically marks +/// short-covering or the start of accumulation; a **b-shape** (control low, thin +/// tail above) marks long liquidation or distribution; a **D-shape** is a balanced, +/// two-sided day. Reducing the profile to this three-way code gives a compact, +/// streaming read of market posture. +/// +/// The output is `+1` / `0` / `−1`. The first value lands after `period` candles; +/// each `update` rebuilds the profile in O(`period · bins`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ProfileShape}; +/// +/// let mut indicator = ProfileShape::new(20, 24).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ProfileShape { + period: usize, + bins: usize, + window: VecDeque, + last: Option, +} + +impl ProfileShape { + /// Construct a Profile Shape classifier. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` is zero, or + /// [`Error::InvalidPeriod`] if `bins < 3` (the three-way split needs three + /// zones). + pub fn new(period: usize, bins: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if bins < 3 { + return Err(Error::InvalidPeriod { + message: "profile shape needs bins >= 3", + }); + } + Ok(Self { + period, + bins, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, bins)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bins) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn poc_index(&self) -> usize { + let mut low = f64::INFINITY; + let mut high = f64::NEG_INFINITY; + for c in &self.window { + low = low.min(c.low); + high = high.max(c.high); + } + let mut hist = vec![0.0; self.bins]; + let span = high - low; + if span > 0.0 { + let width = span / self.bins as f64; + for c in &self.window { + if c.volume == 0.0 { + continue; + } + let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1); + let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1); + let share = c.volume / (hi_idx - lo_idx + 1) as f64; + for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *bin += share; + } + } + } + let mut poc_idx = 0; + let mut poc_vol = f64::NEG_INFINITY; + for (idx, &vol) in hist.iter().enumerate() { + if vol > poc_vol { + poc_vol = vol; + poc_idx = idx; + } + } + poc_idx + } +} + +impl Indicator for ProfileShape { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let poc = self.poc_index(); + let lower = self.bins / 3; + let upper = self.bins - self.bins / 3; + let shape = if poc >= upper { + 1.0 + } else if poc < lower { + -1.0 + } else { + 0.0 + }; + self.last = Some(shape); + Some(shape) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ProfileShape" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked( + f64::midpoint(high, low), + high, + low, + f64::midpoint(high, low), + volume, + 0, + ) + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(ProfileShape::new(0, 24), Err(Error::PeriodZero))); + assert!(matches!( + ProfileShape::new(20, 2), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let p = ProfileShape::new(20, 24).unwrap(); + assert_eq!(p.params(), (20, 24)); + assert_eq!(p.warmup_period(), 20); + assert_eq!(p.name(), "ProfileShape"); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut p = ProfileShape::new(4, 9).unwrap(); + let candles: Vec = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect(); + let out = p.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn heavy_top_is_p_shape() { + // Volume concentrated near the top of the range -> P-shape -> +1. + let mut p = ProfileShape::new(6, 9).unwrap(); + let mut candles: Vec = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect(); + candles.push(c(119.0, 80.0, 50.0)); // a thin tail down to 80 + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 1.0); + } + + #[test] + fn heavy_bottom_is_b_shape() { + let mut p = ProfileShape::new(6, 9).unwrap(); + let mut candles: Vec = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect(); + candles.push(c(120.0, 81.0, 50.0)); // a thin tail up to 120 + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, -1.0); + } + + #[test] + fn balanced_is_d_shape() { + // Volume concentrated in the middle -> D/normal -> 0. + let mut p = ProfileShape::new(6, 9).unwrap(); + let mut candles: Vec = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect(); + candles.push(c(120.0, 80.0, 50.0)); // thin tails both ways, POC in the middle + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut p = ProfileShape::new(4, 9).unwrap(); + p.batch(&[c(110.0, 90.0, 1_000.0); 6]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.25).sin() * 9.0, + 90.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = ProfileShape::new(20, 24).unwrap().batch(&candles); + let mut b = ProfileShape::new(20, 24).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn flat_window_is_handled() { + // Zero high-low span skips the histogram pass entirely. + let mut p = ProfileShape::new(2, 4).unwrap(); + p.update(c(50.0, 50.0, 10.0)); + assert!(p.update(c(50.0, 50.0, 10.0)).is_some()); + } + + #[test] + fn zero_volume_window_is_handled() { + // Non-flat window of zero-volume candles hits the skip path. + let mut p = ProfileShape::new(2, 4).unwrap(); + p.update(c(60.0, 40.0, 0.0)); + assert!(p.update(c(60.0, 40.0, 0.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/profit_factor.rs b/crates/wickra-core/src/indicators/profit_factor.rs new file mode 100644 index 0000000..1dd097d --- /dev/null +++ b/crates/wickra-core/src/indicators/profit_factor.rs @@ -0,0 +1,179 @@ +//! Rolling Profit Factor. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Profit Factor. +/// +/// Input is treated as a per-period return (or a per-trade P&L). Over the +/// trailing window: +/// +/// ```text +/// gross_profit = Σ max(0, r) over window +/// gross_loss = Σ max(0, −r) over window +/// PF = gross_profit / gross_loss +/// ``` +/// +/// `PF > 1` means the strategy made more than it lost in the window. If +/// there were no losing returns the gross loss is zero and the indicator +/// returns `f64::INFINITY` (or `0.0` when there were also no gains — +/// a flat window). +/// +/// Each `update` is O(period). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ProfitFactor}; +/// +/// let mut pf = ProfitFactor::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = pf.update((f64::from(i) * 0.2).sin() * 0.01); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ProfitFactor { + period: usize, + window: VecDeque, +} + +impl ProfitFactor { + /// Construct a new rolling Profit Factor. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ProfitFactor { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut gains = 0.0_f64; + let mut losses = 0.0_f64; + for &r in &self.window { + if r > 0.0 { + gains += r; + } else if r < 0.0 { + losses += -r; + } + } + if losses == 0.0 { + return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY }); + } + Some(gains / losses) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "ProfitFactor" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(ProfitFactor::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let p = ProfitFactor::new(10).unwrap(); + assert_eq!(p.period(), 10); + assert_eq!(p.name(), "ProfitFactor"); + assert_eq!(p.warmup_period(), 10); + } + + #[test] + fn reference_value() { + // returns = [0.02, -0.01, 0.03, -0.02] + // gains = 0.05, losses = 0.03, PF = 5/3. + let mut p = ProfitFactor::new(4).unwrap(); + let out = p.batch(&[0.02, -0.01, 0.03, -0.02]); + assert_relative_eq!(out[3].unwrap(), 5.0 / 3.0, epsilon = 1e-9); + } + + #[test] + fn no_losses_yields_infinity() { + let mut p = ProfitFactor::new(3).unwrap(); + let out = p.batch(&[0.01, 0.02, 0.03]); + assert!(out[2].unwrap().is_infinite()); + } + + #[test] + fn flat_window_yields_zero() { + let mut p = ProfitFactor::new(3).unwrap(); + let out = p.batch(&[0.0_f64; 3]); + assert_eq!(out[2], Some(0.0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut p = ProfitFactor::new(3).unwrap(); + assert_eq!(p.update(f64::NAN), None); + assert_eq!(p.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut p = ProfitFactor::new(3).unwrap(); + p.batch(&[0.01, -0.02, 0.03]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect(); + let batch = ProfitFactor::new(10).unwrap().batch(&returns); + let mut s = ProfitFactor::new(10).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/projection_bands.rs b/crates/wickra-core/src/indicators/projection_bands.rs new file mode 100644 index 0000000..87afad2 --- /dev/null +++ b/crates/wickra-core/src/indicators/projection_bands.rs @@ -0,0 +1,253 @@ +//! Projection Bands (Mel Widner) — a high/low linear-regression projection +//! envelope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Projection Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ProjectionBandsOutput { + /// Upper band: the maximum forward-projected high in the window. + pub upper: f64, + /// Middle line: the midpoint of the upper and lower bands. + pub middle: f64, + /// Lower band: the minimum forward-projected low in the window. + pub lower: f64, +} + +/// Projection Bands: forward-projected high/low envelope. +/// +/// Mel Widner ("Projection Bands and the Projection Oscillator", *Technical +/// Analysis of Stocks & Commodities*, May 1995) fits a separate linear +/// regression to the highs and to the lows over the last `period` bars, then +/// slides every bar's high and low forward to the current bar along its own +/// slope. The upper band is the maximum of the projected highs, the lower band +/// the minimum of the projected lows: +/// +/// ```text +/// slope_h = OLS slope of (x, high) over the window +/// slope_l = OLS slope of (x, low) over the window +/// // bar i (0 = oldest, period-1 = newest) is (period-1-i) bars in the past +/// upper = max over i of [ high_i + slope_h · (period-1-i) ] +/// lower = min over i of [ low_i + slope_l · (period-1-i) ] +/// middle = (upper + lower) / 2 +/// ``` +/// +/// Unlike [`LinRegChannel`](crate::LinRegChannel) and +/// [`StandardErrorBands`](crate::StandardErrorBands) — which wrap a single +/// close-regression endpoint by a dispersion statistic — Projection Bands are +/// built from the *extremes*: the envelope adapts to the trend's slope yet +/// always contains every projected high and low, so by construction price never +/// pierces the bands within the window. A flat slope reduces the bands to the +/// rolling highest-high / lowest-low (a Donchian channel); a steep slope tilts +/// the whole envelope with the trend. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ProjectionBands}; +/// +/// let mut indicator = ProjectionBands::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ProjectionBands { + period: usize, + highs: VecDeque, + lows: VecDeque, + sum_x: f64, + sum_xx: f64, +} + +impl ProjectionBands { + /// Construct new Projection Bands. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` (a regression slope + /// needs at least two points). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "projection bands need period >= 2", + }); + } + let n = period as f64; + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + sum_x: n * (n - 1.0) / 2.0, + sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// OLS slope of `(0..period, values)` over the live window. + fn slope(&self, values: &VecDeque) -> f64 { + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &y) in values.iter().enumerate() { + sum_y += y; + sum_xy += (i as f64) * y; + } + let denom = n * self.sum_xx - self.sum_x * self.sum_x; + (n * sum_xy - self.sum_x * sum_y) / denom + } +} + +impl Indicator for ProjectionBands { + type Input = Candle; + type Output = ProjectionBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.period { + return None; + } + + let slope_h = self.slope(&self.highs); + let slope_l = self.slope(&self.lows); + let last = (self.period - 1) as f64; + + let mut upper = f64::NEG_INFINITY; + let mut lower = f64::INFINITY; + for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() { + let forward = last - (i as f64); + let projected_high = high + slope_h * forward; + let projected_low = low + slope_l * forward; + if projected_high > upper { + upper = projected_high; + } + if projected_low < lower { + lower = projected_low; + } + } + + Some(ProjectionBandsOutput { + upper, + middle: f64::midpoint(upper, lower), + lower, + }) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.highs.len() == self.period + } + + fn name(&self) -> &'static str { + "ProjectionBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(low, high, low, close, 10.0, ts).unwrap() + } + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + ProjectionBands::new(0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ProjectionBands::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(ProjectionBands::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let pb = ProjectionBands::new(14).unwrap(); + assert_eq!(pb.period(), 14); + assert_eq!(pb.warmup_period(), 14); + assert_eq!(pb.name(), "ProjectionBands"); + assert!(!pb.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut pb = ProjectionBands::new(3).unwrap(); + assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none()); + assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none()); + assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some()); + assert!(pb.is_ready()); + } + + #[test] + fn known_projection() { + // highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5 + // lows 8, 9,10 -> slope_l = 1.0; projected = 10, 10, 10 -> lower 10 + let mut pb = ProjectionBands::new(3).unwrap(); + pb.update(candle(10.0, 8.0, 9.0, 0)); + pb.update(candle(12.0, 9.0, 11.0, 1)); + let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap(); + assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9); + assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9); + assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9); + } + + #[test] + fn perfect_trend_pins_bands_to_current_extremes() { + // High_i and Low_i both rise by exactly 1 per bar: every projected high + // collapses onto the current high, every projected low onto the current + // low. + let mut pb = ProjectionBands::new(5).unwrap(); + let mut last = None; + for i in 0..10 { + let high = 100.0 + f64::from(i); + let low = 95.0 + f64::from(i); + last = pb.update(candle(high, low, high, i64::from(i))); + } + let out = last.unwrap(); + assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9); + assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9); + assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut pb = ProjectionBands::new(3).unwrap(); + pb.update(candle(10.0, 8.0, 9.0, 0)); + pb.update(candle(12.0, 9.0, 11.0, 1)); + pb.update(candle(11.0, 10.0, 11.0, 2)); + assert!(pb.is_ready()); + pb.reset(); + assert!(!pb.is_ready()); + assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/projection_oscillator.rs b/crates/wickra-core/src/indicators/projection_oscillator.rs new file mode 100644 index 0000000..e4740cc --- /dev/null +++ b/crates/wickra-core/src/indicators/projection_oscillator.rs @@ -0,0 +1,168 @@ +//! Projection Oscillator (Mel Widner) — the close's position inside the +//! [`ProjectionBands`](crate::ProjectionBands). + +use crate::error::Result; +use crate::indicators::projection_bands::ProjectionBands; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Projection Oscillator: where the close sits inside the projection bands, +/// scaled to `0..100`. +/// +/// The companion to [`ProjectionBands`](crate::ProjectionBands) from Mel +/// Widner's May 1995 *Stocks & Commodities* article. It maps the close onto the +/// `[lower, upper]` projection envelope: +/// +/// ```text +/// PO = 100 · (close − lower) / (upper − lower) +/// ``` +/// +/// `PO = 0` means the close is sitting on the lower band, `PO = 100` on the +/// upper band, and `PO = 50` at the midline. Because the bands by construction +/// bracket every projected high and low, the close almost always falls inside +/// them and the oscillator stays in `0..100` — readings near the extremes flag +/// an overbought/oversold position *relative to the trend-tilted channel* +/// rather than to a horizontal level. When the bands collapse (a zero-range +/// window, `upper == lower`) the position is undefined and the oscillator +/// returns the neutral `50.0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ProjectionOscillator}; +/// +/// let mut indicator = ProjectionOscillator::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..30 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ProjectionOscillator { + bands: ProjectionBands, +} + +impl ProjectionOscillator { + /// Construct a new Projection Oscillator. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if + /// `period < 2`. + pub fn new(period: usize) -> Result { + Ok(Self { + bands: ProjectionBands::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.bands.period() + } +} + +impl Indicator for ProjectionOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let bands = self.bands.update(candle)?; + let width = bands.upper - bands.lower; + if width == 0.0 { + return Some(50.0); + } + Some(100.0 * (candle.close - bands.lower) / width) + } + + fn reset(&mut self) { + self.bands.reset(); + } + + fn warmup_period(&self) -> usize { + self.bands.warmup_period() + } + + fn is_ready(&self) -> bool { + self.bands.is_ready() + } + + fn name(&self) -> &'static str { + "ProjectionOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(low, high, low, close, 10.0, ts).unwrap() + } + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + ProjectionOscillator::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(ProjectionOscillator::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let po = ProjectionOscillator::new(14).unwrap(); + assert_eq!(po.period(), 14); + assert_eq!(po.warmup_period(), 14); + assert_eq!(po.name(), "ProjectionOscillator"); + assert!(!po.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut po = ProjectionOscillator::new(3).unwrap(); + assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none()); + assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none()); + assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some()); + assert!(po.is_ready()); + } + + #[test] + fn known_position() { + // Same window as ProjectionBands::known_projection: upper 12.5, lower 10. + // close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40. + let mut po = ProjectionOscillator::new(3).unwrap(); + po.update(candle(10.0, 8.0, 9.0, 0)); + po.update(candle(12.0, 9.0, 11.0, 1)); + let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap(); + assert_relative_eq!(out, 40.0, epsilon = 1e-9); + } + + #[test] + fn collapsed_bands_return_neutral() { + // Zero-range, perfectly trending candles: upper == lower every bar. + let mut po = ProjectionOscillator::new(3).unwrap(); + let mut last = None; + for i in 0..6 { + let v = 100.0 + f64::from(i); + last = po.update(candle(v, v, v, i64::from(i))); + } + assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut po = ProjectionOscillator::new(3).unwrap(); + po.update(candle(10.0, 8.0, 9.0, 0)); + po.update(candle(12.0, 9.0, 11.0, 1)); + po.update(candle(11.0, 10.0, 11.0, 2)); + assert!(po.is_ready()); + po.reset(); + assert!(!po.is_ready()); + assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/psar.rs b/crates/wickra-core/src/indicators/psar.rs new file mode 100644 index 0000000..46a2b8c --- /dev/null +++ b/crates/wickra-core/src/indicators/psar.rs @@ -0,0 +1,356 @@ +//! Parabolic SAR (Wilder). + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Trade direction in the SAR state machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Trend { + Up, + Down, +} + +/// Parabolic Stop And Reverse. +/// +/// Implementation follows Wilder's original recursion: each step computes a new +/// SAR from the previous SAR, extreme point (EP) and acceleration factor (AF); +/// the trend flips when price crosses the SAR. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Psar}; +/// +/// let mut indicator = Psar::new(0.02, 0.02, 0.2).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Psar { + af_start: f64, + af_step: f64, + af_max: f64, + + /// `true` once the first candle has been observed and the seed values + /// (`prev_high`, `prev_low`, `sar`, `ep`) are valid. `false` is the + /// constructor / `reset()` state in which the compute-fields hold + /// `f64::NAN` sentinels. + initialised: bool, + /// `true` once `update` has returned the first `Some(sar)`. Drives + /// [`Indicator::is_ready`] so it matches the convention of every other + /// indicator: `is_ready() == true` ↔ the most recent `update` produced + /// (or could produce) a real value. PSAR's seed candle returns `None` + /// while `initialised` flips to `true`, which is why `is_ready` cannot + /// just mirror `initialised`. + has_emitted: bool, + prev_high: f64, + prev_low: f64, + trend: Trend, + sar: f64, + ep: f64, + af: f64, +} + +impl Psar { + /// Construct PSAR with explicit acceleration parameters. + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] / [`Error::InvalidPeriod`] for invalid params. + pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Result { + if !af_start.is_finite() || !af_step.is_finite() || !af_max.is_finite() { + return Err(Error::NonPositiveMultiplier); + } + if af_start <= 0.0 || af_step <= 0.0 || af_max <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + if af_start > af_max { + return Err(Error::InvalidPeriod { + message: "af_start must be <= af_max", + }); + } + Ok(Self { + af_start, + af_step, + af_max, + initialised: false, + has_emitted: false, + // NaN sentinels: any read of these fields before the seed candle + // overwrites them is a logic bug. The `initialised` flag gates + // every read, and the `debug_assert!` in `update` makes the + // invariant explicit so a future refactor cannot silently treat a + // sentinel as a real price. + prev_high: f64::NAN, + prev_low: f64::NAN, + trend: Trend::Up, + sar: f64::NAN, + ep: f64::NAN, + af: af_start, + }) + } + + /// Wilder's defaults: `(0.02, 0.02, 0.20)`. + pub fn classic() -> Self { + Self::new(0.02, 0.02, 0.20).expect("classic PSAR params are valid") + } +} + +impl Indicator for Psar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if !self.initialised { + // Seed on the first candle; the first SAR is emitted on the second. + // The initial trend is assumed Up — PSAR's reversal logic flips it + // within the first few bars if the market is actually falling. + self.prev_high = candle.high; + self.prev_low = candle.low; + self.sar = candle.low; + self.ep = candle.high; + self.trend = Trend::Up; + self.af = self.af_start; + self.initialised = true; + // `has_emitted` stays false — this is the seed bar; the first + // `Some` lands on the next call. + return None; + } + + // After `initialised` flips to `true`, every compute field is guaranteed + // finite. This guards against a future refactor that changes the seed + // gate but leaves a NaN sentinel reachable. + debug_assert!( + self.prev_high.is_finite() + && self.prev_low.is_finite() + && self.sar.is_finite() + && self.ep.is_finite(), + "PSAR seed state must be finite once initialised" + ); + + // Predicted SAR for this period (before clamping to prior two extremes). + let mut new_sar = self.sar + self.af * (self.ep - self.sar); + + // Wilder rule: SAR cannot penetrate today's or yesterday's range. + let prev_h = self.prev_high; + let prev_l = self.prev_low; + new_sar = match self.trend { + Trend::Up => new_sar.min(prev_l).min(candle.low), + Trend::Down => new_sar.max(prev_h).max(candle.high), + }; + + let mut output_sar = new_sar; + + // Check for trend reversal. + let reversed = match self.trend { + Trend::Up => candle.low <= new_sar, + Trend::Down => candle.high >= new_sar, + }; + + if reversed { + // Flip trend, reset AF and EP, place SAR at prior EP. + output_sar = self.ep; + self.trend = match self.trend { + Trend::Up => Trend::Down, + Trend::Down => Trend::Up, + }; + self.ep = match self.trend { + Trend::Up => candle.high, + Trend::Down => candle.low, + }; + self.af = self.af_start; + } else { + // Update EP and AF if a new extreme has been reached. + match self.trend { + Trend::Up => { + if candle.high > self.ep { + self.ep = candle.high; + self.af = (self.af + self.af_step).min(self.af_max); + } + } + Trend::Down => { + if candle.low < self.ep { + self.ep = candle.low; + self.af = (self.af + self.af_step).min(self.af_max); + } + } + } + } + + self.sar = output_sar; + self.prev_high = candle.high; + self.prev_low = candle.low; + self.has_emitted = true; + Some(output_sar) + } + + fn reset(&mut self) { + // Restore every field to its constructor state. The compute fields + // return to `f64::NAN` sentinels so a future refactor that reads them + // before re-seeding cannot silently treat `0.0` as a real price. + self.initialised = false; + self.has_emitted = false; + self.prev_high = f64::NAN; + self.prev_low = f64::NAN; + self.trend = Trend::Up; + self.sar = f64::NAN; + self.ep = f64::NAN; + self.af = self.af_start; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + // Match the convention of every other indicator: `is_ready` flips to + // `true` only once a real value has been returned. The previous + // implementation returned `self.initialised`, which is `true` *after* + // the seed candle (which itself returns `None`) — so a streaming + // consumer that wrote `if ind.is_ready() { use(ind.update(c)?) }` + // would hit a `None` it didn't expect. (Audit finding R6.) + self.has_emitted + } + + fn name(&self) -> &'static str { + "PSAR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn first_candle_returns_none() { + let mut psar = Psar::classic(); + assert_eq!(psar.update(c(11.0, 9.0, 10.0)), None); + } + + #[test] + fn pure_uptrend_sar_below_lows() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 0.5, base - 0.5, base) + }) + .collect(); + let mut psar = Psar::classic(); + // `all()` with `is_none_or` keeps every reachable arm on the hot path — + // the previous filter_map / violation-Vec construction had a cold + // "violation found" tuple branch that was unreachable on a clean + // uptrend, leaving its line uncovered by Codecov. + let ok = psar + .batch(&candles) + .iter() + .enumerate() + .all(|(i, sar)| sar.is_none_or(|s| s <= candles[i].low + 1e-9)); + assert!(ok, "SAR sat above a candle's low on a pure uptrend"); + } + + #[test] + fn pure_downtrend_sar_above_highs() { + let candles: Vec = (0..40) + .rev() + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 0.5, base - 0.5, base) + }) + .collect(); + let mut psar = Psar::classic(); + // After the trend establishes downward, SAR should sit above highs. + // Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows` + // so the violation-tuple branch never appears as a cold path. + let ok = psar + .batch(&candles) + .iter() + .enumerate() + .skip(5) + .all(|(i, sar)| sar.is_none_or(|s| s >= candles[i].high - 1e-9)); + assert!(ok, "SAR sat below a candle's high on a pure downtrend"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = Psar::classic(); + let mut b = Psar::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + /// Cover the Indicator-impl `warmup_period` (206-208) and `name` + /// (220-222). PSAR's warmup is the constant 2 (seed candle + first + /// emitting candle); the name is the literal "PSAR". + #[test] + fn accessors_and_metadata() { + let psar = Psar::classic(); + assert_eq!(psar.warmup_period(), 2); + assert_eq!(psar.name(), "PSAR"); + } + + #[test] + fn rejects_invalid_params() { + assert!(Psar::new(0.0, 0.02, 0.20).is_err()); + assert!(Psar::new(0.02, 0.0, 0.20).is_err()); + assert!(Psar::new(0.30, 0.02, 0.20).is_err()); + assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err()); + } + + #[test] + fn is_ready_only_after_first_some_value() { + // Audit R6: the previous implementation flipped `is_ready` to true on + // the seed candle (which returns `None`), making the convention + // `is_ready == last_value.is_some()` a lie. The new gate is + // `has_emitted`, set when `update` returns its first `Some`. + let mut psar = Psar::classic(); + assert!(!psar.is_ready(), "fresh PSAR must not be ready"); + let first = psar.update(c(11.0, 9.0, 10.0)); + assert!(first.is_none(), "seed candle returns None by design"); + assert!( + !psar.is_ready(), + "is_ready must stay false until a Some value is produced" + ); + let second = psar.update(c(12.0, 10.0, 11.0)); + assert!(second.is_some(), "second candle must emit"); + assert!( + psar.is_ready(), + "is_ready must flip to true once a real value has been returned" + ); + } + + #[test] + fn reset_allows_clean_reuse() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 0.5, base - 0.5, base) + }) + .collect(); + let mut psar = Psar::classic(); + let first = psar.batch(&candles); + assert!(psar.is_ready()); + psar.reset(); + assert!(!psar.is_ready()); + // A reset instance must reproduce a pristine run bit for bit. + let second = psar.batch(&candles); + assert_eq!(first, second); + } +} diff --git a/crates/wickra-core/src/indicators/pvi.rs b/crates/wickra-core/src/indicators/pvi.rs new file mode 100644 index 0000000..2bf1934 --- /dev/null +++ b/crates/wickra-core/src/indicators/pvi.rs @@ -0,0 +1,228 @@ +//! Positive Volume Index. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Default starting value; matches Norman Fosback's textbook convention. +const STARTING_INDEX: f64 = 1000.0; + +/// Positive Volume Index (Paul Dysart, popularised by Norman Fosback). +/// +/// The PVI only updates when **volume expands** — Fosback's interpretation is +/// that the crowd ("uninformed money") trades on volume spikes, so the PVI +/// tracks the crowd-driven leg of price action. When today's volume is at or +/// below yesterday's, the PVI is left unchanged. +/// +/// ```text +/// PVI_t = PVI_{t−1} · (1 + (close_t − close_{t−1}) / close_{t−1}) if volume_t > volume_{t−1} +/// PVI_t = PVI_{t−1} otherwise +/// ``` +/// +/// The first bar establishes the baseline at `1000.0`. A bar whose previous +/// close is zero contributes no return. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Pvi}; +/// +/// let mut indicator = Pvi::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Pvi { + prev_close: Option, + prev_volume: Option, + index: f64, + has_emitted: bool, +} + +impl Pvi { + /// Construct a new PVI starting at `1000.0`. + pub const fn new() -> Self { + Self { + prev_close: None, + prev_volume: None, + index: STARTING_INDEX, + has_emitted: false, + } + } + + /// Construct a new PVI with a custom starting baseline. + pub const fn with_baseline(baseline: f64) -> Self { + Self { + prev_close: None, + prev_volume: None, + index: baseline, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.index) + } else { + None + } + } +} + +impl Default for Pvi { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Pvi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) { + if candle.volume > pv && pc != 0.0 { + let ret = (candle.close - pc) / pc; + self.index += self.index * ret; + } + } + self.prev_close = Some(candle.close); + self.prev_volume = Some(candle.volume); + self.has_emitted = true; + Some(self.index) + } + + fn reset(&mut self) { + self.prev_close = None; + self.prev_volume = None; + self.index = STARTING_INDEX; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "PVI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let mut p = Pvi::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "PVI"); + assert_eq!(p.value(), None); + p.update(c(10.0, 100.0, 0)); + assert_eq!(p.value(), Some(1000.0)); + } + + #[test] + fn default_matches_new() { + let a = Pvi::default(); + let b = Pvi::new(); + assert_eq!(a.warmup_period(), b.warmup_period()); + assert_eq!(a.value(), b.value()); + assert_eq!(a.is_ready(), b.is_ready()); + } + + #[test] + fn first_bar_seeds_baseline() { + let mut p = Pvi::new(); + assert_relative_eq!( + p.update(c(10.0, 100.0, 0)).unwrap(), + 1000.0, + epsilon = 1e-12 + ); + } + + #[test] + fn volume_rise_applies_percent_change() { + // 1000 * (1 + (11 - 10)/10) = 1100. + let mut p = Pvi::new(); + p.update(c(10.0, 100.0, 0)); + let v = p.update(c(11.0, 200.0, 1)).unwrap(); + assert_relative_eq!(v, 1100.0, epsilon = 1e-12); + } + + #[test] + fn volume_fall_leaves_index_unchanged() { + let mut p = Pvi::new(); + p.update(c(10.0, 200.0, 0)); + let v = p.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn equal_volume_leaves_index_unchanged() { + let mut p = Pvi::new(); + p.update(c(10.0, 100.0, 0)); + let v = p.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn zero_previous_close_contributes_no_return() { + let mut p = Pvi::new(); + p.update(c(0.0, 100.0, 0)); + let v = p.update(c(5.0, 200.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn custom_baseline() { + let mut p = Pvi::with_baseline(100.0); + assert_relative_eq!(p.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + ((i % 7) as f64) * 10.0, + i, + ) + }) + .collect(); + let mut a = Pvi::new(); + let mut b = Pvi::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut p = Pvi::new(); + p.batch(&[c(10.0, 100.0, 0), c(11.0, 200.0, 1)]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/qqe.rs b/crates/wickra-core/src/indicators/qqe.rs new file mode 100644 index 0000000..9699fb4 --- /dev/null +++ b/crates/wickra-core/src/indicators/qqe.rs @@ -0,0 +1,358 @@ +//! QQE — Quantitative Qualitative Estimation. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::rsi::Rsi; +use crate::traits::Indicator; + +/// One QQE reading: the smoothed RSI and its volatility-trailing line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct QqeOutput { + /// The EMA-smoothed RSI (the fast QQE line). + pub rsi_ma: f64, + /// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that + /// the smoothed RSI rides above in an uptrend and below in a downtrend. + pub trailing_line: f64, +} + +/// QQE — Quantitative Qualitative Estimation (Igor Livshin). +/// +/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it. +/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum +/// signals than the raw RSI: +/// +/// ```text +/// rsi_ma = EMA(RSI(price, rsi_period), smoothing) +/// atr_rsi = |rsi_ma − rsi_ma_prev| +/// ma_atr = EMA(atr_rsi, 2·rsi_period − 1) // Wilder length +/// dar = EMA(ma_atr, 2·rsi_period − 1) · factor // smoothed band width +/// +/// long_band = (rsi_ma_prev > long_band_prev && rsi_ma > long_band_prev) +/// ? max(long_band_prev, rsi_ma − dar) : rsi_ma − dar +/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev) +/// ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar +/// trend = cross-up of short_band → +1, cross-down of long_band → −1, else hold +/// trailing = trend == +1 ? long_band : short_band +/// ``` +/// +/// The trailing line ratchets in the trend direction (only ever tightening until +/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend) +/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`, +/// `factor = 4.236`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Qqe}; +/// +/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap(); +/// let mut last = None; +/// for i in 0..200 { +/// last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Qqe { + rsi: Rsi, + rsi_ma: Ema, + ma_atr: Ema, + dar_ema: Ema, + factor: f64, + prev_rsi_ma: Option, + bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend) + last_value: Option, +} + +impl Qqe { + /// Construct a QQE with the RSI period, RSI smoothing, and band `factor`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or + /// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive. + pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result { + if rsi_period == 0 || smoothing == 0 { + return Err(Error::PeriodZero); + } + if !factor.is_finite() || factor <= 0.0 { + return Err(Error::InvalidPeriod { + message: "QQE factor must be a finite positive value", + }); + } + let wilders = 2 * rsi_period - 1; + Ok(Self { + rsi: Rsi::new(rsi_period)?, + rsi_ma: Ema::new(smoothing)?, + ma_atr: Ema::new(wilders)?, + dar_ema: Ema::new(wilders)?, + factor, + prev_rsi_ma: None, + bands: None, + last_value: None, + }) + } + + /// Configured band factor. + pub const fn factor(&self) -> f64 { + self.factor + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for Qqe { + type Input = f64; + type Output = QqeOutput; + + fn update(&mut self, price: f64) -> Option { + let rsi = self.rsi.update(price)?; + let rsi_ma = self.rsi_ma.update(rsi)?; + + let Some(prev_ma) = self.prev_rsi_ma else { + self.prev_rsi_ma = Some(rsi_ma); + return None; + }; + let atr_rsi = (rsi_ma - prev_ma).abs(); + self.prev_rsi_ma = Some(rsi_ma); + + let ma_atr = self.ma_atr.update(atr_rsi)?; + let dar = self.dar_ema.update(ma_atr)? * self.factor; + + let new_long = rsi_ma - dar; + let new_short = rsi_ma + dar; + + let (long_band, short_band, trend) = match self.bands { + Some((lb_prev, sb_prev, tr_prev)) => { + let lb = if prev_ma > lb_prev && rsi_ma > lb_prev { + lb_prev.max(new_long) + } else { + new_long + }; + let sb = if prev_ma < sb_prev && rsi_ma < sb_prev { + sb_prev.min(new_short) + } else { + new_short + }; + let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev { + 1 + } else if prev_ma >= lb_prev && rsi_ma < lb_prev { + -1 + } else { + tr_prev + }; + (lb, sb, tr) + } + None => (new_long, new_short, 1), + }; + self.bands = Some((long_band, short_band, trend)); + + let trailing_line = if trend == 1 { long_band } else { short_band }; + let out = QqeOutput { + rsi_ma, + trailing_line, + }; + self.last_value = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.rsi.reset(); + self.rsi_ma.reset(); + self.ma_atr.reset(); + self.dar_ema.reset(); + self.prev_rsi_ma = None; + self.bands = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi -> + // ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays + // correct if those change. + self.rsi.warmup_period() + + self.rsi_ma.warmup_period() + + self.ma_atr.warmup_period() + + self.dar_ema.warmup_period() + - 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "QQE" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference replaying the full QQE recurrence. + fn naive( + prices: &[f64], + rsi_period: usize, + smoothing: usize, + factor: f64, + ) -> Vec> { + let mut rsi = Rsi::new(rsi_period).unwrap(); + let mut rsi_ma = Ema::new(smoothing).unwrap(); + let wilders = 2 * rsi_period - 1; + let mut ma_atr = Ema::new(wilders).unwrap(); + let mut dar_ema = Ema::new(wilders).unwrap(); + let mut prev_ma: Option = None; + let mut bands: Option<(f64, f64, i8)> = None; + let mut out = Vec::with_capacity(prices.len()); + for &p in prices { + let v = (|| { + let r = rsi.update(p)?; + let m = rsi_ma.update(r)?; + let Some(pm) = prev_ma else { + prev_ma = Some(m); + return None; + }; + let atr = (m - pm).abs(); + prev_ma = Some(m); + let ma = ma_atr.update(atr)?; + let dar = dar_ema.update(ma)? * factor; + let nl = m - dar; + let ns = m + dar; + let (lb, sb, tr) = match bands { + Some((lbp, sbp, trp)) => { + let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl }; + let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns }; + let tr = if pm <= sbp && m > sbp { + 1 + } else if pm >= lbp && m < lbp { + -1 + } else { + trp + }; + (lb, sb, tr) + } + None => (nl, ns, 1), + }; + bands = Some((lb, sb, tr)); + Some(QqeOutput { + rsi_ma: m, + trailing_line: if tr == 1 { lb } else { sb }, + }) + })(); + out.push(v); + } + out + } + + #[test] + fn rejects_bad_params() { + assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero))); + assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero))); + assert!(matches!( + Qqe::new(14, 5, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Qqe::new(14, 5, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + /// Cover the const accessors `factor` + `value` and the Indicator-impl + /// `name`. `warmup_period` is covered by `first_emission_matches_warmup`. + #[test] + fn accessors_and_metadata() { + let qqe = Qqe::new(14, 5, 4.236).unwrap(); + assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12); + assert_eq!(qqe.value(), None); + assert_eq!(qqe.name(), "QQE"); + } + + #[test] + fn first_emission_matches_warmup() { + // A long trend-up-then-down series exercises both trend flips and the + // band tighten/reset branches. + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0) + .collect(); + let mut qqe = Qqe::new(14, 5, 4.236).unwrap(); + let out = qqe.batch(&prices); + let warmup = qqe.warmup_period(); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!( + out[warmup - 1].is_some(), + "first value at warmup_period - 1" + ); + } + + #[test] + fn matches_naive_over_full_cycle() { + // Up, range, and down phases so every band/trend branch is traversed. + let prices: Vec = (0..220) + .map(|i| { + let t = f64::from(i); + 100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0 + }) + .collect(); + let mut qqe = Qqe::new(14, 5, 4.236).unwrap(); + let got = qqe.batch(&prices); + let want = naive(&prices, 14, 5, 4.236); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9); + assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9); + } + } + } + + #[test] + fn trailing_line_below_rsi_ma_in_uptrend() { + // Sustained rise: trend resolves to +1 and the trailing (long) band sits + // below the smoothed RSI. + let prices: Vec = (1..=120).map(f64::from).collect(); + let mut qqe = Qqe::new(14, 5, 4.236).unwrap(); + let last = qqe.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last.trailing_line <= last.rsi_ma, + "uptrend trailing {} should sit at/below rsi_ma {}", + last.trailing_line, + last.rsi_ma + ); + } + + #[test] + fn reset_clears_state() { + let mut qqe = Qqe::new(14, 5, 4.236).unwrap(); + qqe.batch( + &(0..120) + .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0) + .collect::>(), + ); + assert!(qqe.is_ready()); + qqe.reset(); + assert!(!qqe.is_ready()); + assert_eq!(qqe.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..150) + .map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0) + .collect(); + let mut a = Qqe::new(14, 5, 4.236).unwrap(); + let mut b = Qqe::new(14, 5, 4.236).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/qstick.rs b/crates/wickra-core/src/indicators/qstick.rs new file mode 100644 index 0000000..3ab7272 --- /dev/null +++ b/crates/wickra-core/src/indicators/qstick.rs @@ -0,0 +1,168 @@ +//! Qstick — Tushar Chande's measure of buying vs. selling pressure. + +use crate::error::Result; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Qstick: the simple moving average of the body `close - open` over `period` +/// bars. +/// +/// Positive values indicate a run of bars that closed above their open (net +/// buying pressure); negative values indicate net selling pressure. A zero +/// crossing is read as a shift in short-term sentiment. +/// +/// ```text +/// Qstick = SMA(close - open, period) +/// ``` +/// +/// Reference: Tushar Chande, *The New Technical Trader*, 1994. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Qstick}; +/// +/// let mut indicator = Qstick::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 1.0, base + 1.0, 1.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Qstick { + period: usize, + sma: Sma, +} + +impl Qstick { + /// Construct a Qstick with the given averaging period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + sma: Sma::new(period)?, + }) + } + + /// Configured averaging period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Qstick { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.sma.update(candle.close - candle.open) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "Qstick" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, close: f64, ts: i64) -> Candle { + let high = open.max(close) + 1.0; + let low = open.min(close) - 1.0; + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Qstick::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let q = Qstick::new(5).unwrap(); + assert_eq!(q.period(), 5); + assert_eq!(q.warmup_period(), 5); + assert_eq!(q.name(), "Qstick"); + assert!(!q.is_ready()); + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut q = Qstick::new(3).unwrap(); + let candles: Vec = (0..3).map(|i| candle(10.0, 11.0, i)).collect(); + let out = q.batch(&candles); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_some()); + } + + #[test] + fn constant_bodies_yield_the_body() { + // Every bar closes 1.5 above its open -> Qstick converges to 1.5. + let mut q = Qstick::new(4).unwrap(); + let candles: Vec = (0..10).map(|i| candle(10.0, 11.5, i)).collect(); + let out = q.batch(&candles); + assert_relative_eq!(out.last().unwrap().unwrap(), 1.5, epsilon = 1e-12); + } + + #[test] + fn selling_pressure_is_negative() { + let mut q = Qstick::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| candle(11.0, 10.0, i)).collect(); + let last = q.batch(&candles).last().unwrap().unwrap(); + assert!(last < 0.0, "qstick {last} should be negative"); + } + + #[test] + fn reset_clears_state() { + let mut q = Qstick::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| candle(10.0, 11.0, i)).collect(); + q.batch(&candles); + assert!(q.is_ready()); + q.reset(); + assert!(!q.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40_i64) + .map(|i| { + candle( + 100.0 + (i as f64 * 0.3).sin(), + 100.0 + (i as f64 * 0.4).cos(), + i, + ) + }) + .collect(); + let mut a = Qstick::new(7).unwrap(); + let mut b = Qstick::new(7).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/quartile_bands.rs b/crates/wickra-core/src/indicators/quartile_bands.rs new file mode 100644 index 0000000..3ba28d2 --- /dev/null +++ b/crates/wickra-core/src/indicators/quartile_bands.rs @@ -0,0 +1,197 @@ +//! Quartile Bands — rolling 25th / 50th / 75th percentile envelope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rolling_quantile::quantile_sorted; +use crate::traits::Indicator; + +/// Quartile Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct QuartileBandsOutput { + /// Upper band: the rolling third quartile (75th percentile, `Q3`). + pub upper: f64, + /// Middle line: the rolling median (50th percentile, `Q2`). + pub middle: f64, + /// Lower band: the rolling first quartile (25th percentile, `Q1`). + pub lower: f64, +} + +/// Quartile Bands: a distribution-based envelope drawn at the rolling quartiles. +/// +/// ```text +/// lower = Q1 = 25th percentile of the last `period` values +/// middle = Q2 = 50th percentile (median) +/// upper = Q3 = 75th percentile +/// ``` +/// +/// Quantiles use the type-7 (`NumPy`/`R-7`) linear interpolation shared with +/// [`RollingQuantile`](crate::RollingQuantile). Where Bollinger Bands assume an +/// approximately normal distribution and size the envelope by the mean and +/// standard deviation, Quartile Bands are fully **non-parametric**: the band +/// edges are order statistics, so a single outlier shifts at most one rank +/// rather than inflating the whole width, and the inter-quartile span between +/// the bands is exactly the [`RollingIqr`](crate::RollingIqr). The middle line +/// is the robust median rather than the mean, so it is unmoved by spikes. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, QuartileBands}; +/// +/// let mut indicator = QuartileBands::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct QuartileBands { + period: usize, + window: VecDeque, + scratch: Vec, +} + +impl QuartileBands { + /// Construct new Quartile Bands. + /// + /// # 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), + scratch: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for QuartileBands { + type Input = f64; + type Output = QuartileBandsOutput; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + self.scratch.clear(); + self.scratch.extend(self.window.iter().copied()); + self.scratch.sort_by(f64::total_cmp); + Some(QuartileBandsOutput { + upper: quantile_sorted(&self.scratch, 0.75), + middle: quantile_sorted(&self.scratch, 0.5), + lower: quantile_sorted(&self.scratch, 0.25), + }) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "QuartileBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero))); + assert!(QuartileBands::new(1).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let qb = QuartileBands::new(20).unwrap(); + assert_eq!(qb.period(), 20); + assert_eq!(qb.warmup_period(), 20); + assert_eq!(qb.name(), "QuartileBands"); + assert!(!qb.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut qb = QuartileBands::new(4).unwrap(); + assert!(qb.update(10.0).is_none()); + assert!(qb.update(20.0).is_none()); + assert!(qb.update(30.0).is_none()); + assert!(qb.update(40.0).is_some()); + assert!(qb.is_ready()); + } + + #[test] + fn known_quartiles() { + // sorted [10,20,30,40]: + // Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5 + // Q2 h=1.5 -> 20 + 0.5*10 = 25.0 + // Q3 h=2.25 -> 30 + 0.25*10 = 32.5 + let mut qb = QuartileBands::new(4).unwrap(); + let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]); + let last = out[3].unwrap(); + assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9); + assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9); + } + + #[test] + fn median_robust_to_outlier() { + // A single spike shifts the mean a lot but the median by at most one rank. + let mut qb = QuartileBands::new(5).unwrap(); + let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]); + assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12); + } + + #[test] + fn rolling_window_evicts_oldest() { + // Eight values through a period-4 window: only the last four survive, + // reproducing the `known_quartiles` window. + let mut qb = QuartileBands::new(4).unwrap(); + let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]); + let last = out[7].unwrap(); + assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9); + assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut qb = QuartileBands::new(4).unwrap(); + for v in [10.0, 20.0, 30.0, 40.0] { + qb.update(v); + } + assert!(qb.is_ready()); + qb.reset(); + assert!(!qb.is_ready()); + assert!(qb.update(10.0).is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/quoted_spread.rs b/crates/wickra-core/src/indicators/quoted_spread.rs new file mode 100644 index 0000000..cbb54ee --- /dev/null +++ b/crates/wickra-core/src/indicators/quoted_spread.rs @@ -0,0 +1,153 @@ +//! Quoted Spread — top-of-book spread in basis points. + +use crate::microstructure::OrderBook; +use crate::traits::Indicator; + +/// Quoted Spread — the top-of-book bid-ask spread expressed in basis points of +/// the mid price. +/// +/// ```text +/// mid = (bidPrice₁ + askPrice₁) / 2 +/// quotedSpread = (askPrice₁ − bidPrice₁) / mid · 10_000 (bps) +/// ``` +/// +/// This is the round-trip cost of crossing the spread at the touch, normalised +/// by price so it is comparable across instruments. For a valid (uncrossed) +/// book the result is non-negative. An empty book yields `0`. +/// +/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first +/// snapshot. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Level, OrderBook, QuotedSpread}; +/// +/// let book = OrderBook::new( +/// vec![Level::new(100.0, 1.0).unwrap()], +/// vec![Level::new(100.5, 1.0).unwrap()], +/// ) +/// .unwrap(); +/// let mut qs = QuotedSpread::new(); +/// // spread 0.5, mid 100.25 -> 0.5 / 100.25 * 10_000 ≈ 49.875 bps. +/// let bps = qs.update(book).unwrap(); +/// assert!((bps - 49.875_311_72).abs() < 1e-6); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct QuotedSpread { + has_emitted: bool, +} + +impl QuotedSpread { + /// Construct a new quoted-spread indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for QuotedSpread { + type Input = OrderBook; + type Output = f64; + + fn update(&mut self, book: OrderBook) -> Option { + self.has_emitted = true; + let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else { + return Some(0.0); + }; + let mid = f64::midpoint(bid.price, ask.price); + Some((ask.price - bid.price) / mid * 10_000.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "QuotedSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Level; + use crate::traits::BatchExt; + + fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook { + let to_levels = |xs: &[(f64, f64)]| { + xs.iter() + .map(|&(p, s)| Level::new(p, s).unwrap()) + .collect::>() + }; + OrderBook::new(to_levels(bids), to_levels(asks)).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let qs = QuotedSpread::new(); + assert_eq!(qs.name(), "QuotedSpread"); + assert_eq!(qs.warmup_period(), 1); + assert!(!qs.is_ready()); + } + + #[test] + fn known_value_in_bps() { + let mut qs = QuotedSpread::new(); + // spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps. + let bps = qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])).unwrap(); + assert!((bps - 99.502_487_56).abs() < 1e-6); + assert!(qs.is_ready()); + } + + #[test] + fn tight_book_is_small() { + let mut qs = QuotedSpread::new(); + let bps = qs.update(book(&[(100.0, 1.0)], &[(100.01, 1.0)])).unwrap(); + assert!(bps > 0.0 && bps < 2.0); + } + + #[test] + fn empty_book_is_zero() { + let mut qs = QuotedSpread::new(); + assert_eq!( + qs.update(OrderBook::new_unchecked(vec![], vec![])), + Some(0.0) + ); + } + + #[test] + fn batch_equals_streaming() { + let books: Vec = (0..20) + .map(|i| { + let ask = 100.5 + f64::from(i % 4) * 0.1; + book(&[(100.0, 1.0)], &[(ask, 1.0)]) + }) + .collect(); + let mut a = QuotedSpread::new(); + let mut b = QuotedSpread::new(); + assert_eq!( + a.batch(&books), + books + .iter() + .map(|x| b.update(x.clone())) + .collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut qs = QuotedSpread::new(); + qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])); + assert!(qs.is_ready()); + qs.reset(); + assert!(!qs.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/r_squared.rs b/crates/wickra-core/src/indicators/r_squared.rs new file mode 100644 index 0000000..8886379 --- /dev/null +++ b/crates/wickra-core/src/indicators/r_squared.rs @@ -0,0 +1,219 @@ +//! Coefficient of determination R² for the rolling OLS fit. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// R² (coefficient of determination) of the rolling least-squares fit. +/// +/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line +/// `y = a + b·x` is fitted and the ratio of variance explained by the line +/// to total variance is reported: +/// +/// ```text +/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// SS_total = Σy² − n·ȳ² +/// SS_explained = slope² · ( denom / n ) +/// R² = SS_explained / SS_total if SS_total > 0 +/// = 1 otherwise (flat window) +/// ``` +/// +/// A reading of `1.0` means the window lies on a straight line — perfect +/// linear fit. `0.0` means the slope is irrelevant; the trend explains none +/// of the variance. Mid-range values quantify how trending the recent price +/// action is, independent of the slope's sign or magnitude. Use it as a +/// trend-quality filter: a strategy that needs a clear trend can require +/// `R² > 0.7`, while a mean-reversion strategy can prefer `R² < 0.3`. +/// +/// A flat window has `SS_total = 0`; the line is also flat and the fit is +/// trivially perfect, so the indicator returns `1.0` rather than dividing +/// by zero. +/// +/// Each `update` is O(1) via the same rolling sums as +/// [`crate::LinearRegression`], plus a running `Σy²`. The output is +/// clamped to `[0, 1]` to absorb tiny floating-point cancellation. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RSquared}; +/// +/// let mut indicator = RSquared::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RSquared { + period: usize, + window: VecDeque, + sum_x: f64, + /// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`. + denom: f64, + sum_y: f64, + sum_xy: f64, + sum_y_sq: f64, +} + +impl RSquared { + /// Construct a new rolling R² over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line + /// is undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "R² needs period >= 2", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + sum_y_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RSquared { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + self.sum_y_sq -= y0 * y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + self.sum_y_sq += value * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let mean_y = self.sum_y / n; + let ss_total = (self.sum_y_sq - n * mean_y * mean_y).max(0.0); + let s_xx = self.denom / n; + let ss_explained = slope * slope * s_xx; + if ss_total <= 0.0 { + // Flat window: the fit is trivially perfect. + return Some(1.0); + } + Some((ss_explained / ss_total).clamp(0.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + self.sum_y_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 { + "RSquared" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(RSquared::new(0).is_err()); + assert!(RSquared::new(1).is_err()); + assert!(RSquared::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let r = RSquared::new(14).unwrap(); + assert_eq!(r.period(), 14); + assert_eq!(r.warmup_period(), 14); + assert_eq!(r.name(), "RSquared"); + } + + #[test] + fn perfect_line_is_one() { + let prices: Vec = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut r = RSquared::new(10).unwrap(); + for v in r.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_is_one() { + // SS_total is zero; the indicator must return 1 instead of NaN. + let mut r = RSquared::new(5).unwrap(); + for v in r.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_in_zero_one_range() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0 + (f64::from(i) * 0.07).cos() * 12.0) + .collect(); + let mut r = RSquared::new(20).unwrap(); + for v in r.batch(&prices).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "R² out of range: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut r = RSquared::new(5).unwrap(); + r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let batch = RSquared::new(14).unwrap().batch(&prices); + let mut b = RSquared::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/range_bars.rs b/crates/wickra-core/src/indicators/range_bars.rs new file mode 100644 index 0000000..05f63d7 --- /dev/null +++ b/crates/wickra-core/src/indicators/range_bars.rs @@ -0,0 +1,227 @@ +//! Range bar builder — fixed price-range bars with no reversal penalty. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed range bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RangeBar { + /// Price at the bar's origin edge. + pub open: f64, + /// Price at the bar's far edge (`open ± range`). + pub close: f64, + /// `+1` for an up bar, `-1` for a down bar. + pub direction: i8, +} + +/// Range bar builder using a fixed price increment on close prices. +/// +/// A range bar completes every time price travels a fixed `range` from the current +/// anchor, in *either* direction. This is the key difference from +/// [`RenkoBars`](crate::RenkoBars): Renko imposes a `2 * box_size` penalty to +/// reverse direction, so it filters out small oscillations; range bars have **no +/// reversal penalty** — a move of exactly `range` against the trend prints a bar +/// immediately. Range bars therefore track every leg of price movement, while Renko +/// smooths them. +/// +/// Construction rules: +/// +/// - The first candle seeds the anchor and prints no bar. +/// - Each subsequent candle prints one bar for every `range` of close movement away +/// from the anchor; a candle that gaps several ranges prints them all in one +/// [`BarBuilder::update`] call. +/// - Bars are aligned to the `range` grid relative to the seed price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, RangeBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut bars = RangeBars::new(1.0).unwrap(); +/// assert!(bars.update(flat(10.0)).is_empty()); // seed +/// let up = bars.update(flat(12.0)); // +2 ranges +/// assert_eq!(up.len(), 2); +/// let down = bars.update(flat(11.0)); // -1 range, no penalty +/// assert_eq!(down.len(), 1); +/// ``` +#[derive(Debug, Clone)] +pub struct RangeBars { + range: f64, + anchor: Option, +} + +impl RangeBars { + /// Construct a range-bar builder with the given price increment. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `range` is not finite and positive. + pub fn new(range: f64) -> Result { + if !range.is_finite() || range <= 0.0 { + return Err(Error::InvalidPeriod { + message: "range must be finite and positive", + }); + } + Ok(Self { + range, + anchor: None, + }) + } + + /// Configured price range. + pub const fn range(&self) -> f64 { + self.range + } + + /// Current anchor level (the close of the last completed bar, or the seed + /// price before any bar has formed). + pub const fn anchor(&self) -> Option { + self.anchor + } +} + +impl BarBuilder for RangeBars { + type Bar = RangeBar; + + fn update(&mut self, candle: Candle) -> Vec { + let close = candle.close; + let Some(mut anchor) = self.anchor else { + self.anchor = Some(close); + return Vec::new(); + }; + let range = self.range; + let mut bars = Vec::new(); + while close >= anchor + range { + bars.push(RangeBar { + open: anchor, + close: anchor + range, + direction: 1, + }); + anchor += range; + } + while close <= anchor - range { + bars.push(RangeBar { + open: anchor, + close: anchor - range, + direction: -1, + }); + anchor -= range; + } + self.anchor = Some(anchor); + bars + } + + fn reset(&mut self) { + self.anchor = None; + } + + fn name(&self) -> &'static str { + "RangeBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_range() { + assert!(matches!( + RangeBars::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RangeBars::new(-1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RangeBars::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bars = RangeBars::new(2.5).unwrap(); + assert_eq!(bars.name(), "RangeBars"); + assert_relative_eq!(bars.range(), 2.5, epsilon = 1e-12); + assert_eq!(bars.anchor(), None); + } + + #[test] + fn first_candle_seeds_without_bar() { + let mut bars = RangeBars::new(1.0).unwrap(); + assert!(bars.update(flat(10.0)).is_empty()); + assert_eq!(bars.anchor(), Some(10.0)); + } + + #[test] + fn up_move_prints_aligned_bars() { + let mut bars = RangeBars::new(1.0).unwrap(); + bars.update(flat(10.0)); + let up = bars.update(flat(13.0)); + assert_eq!(up.len(), 3); + assert_relative_eq!(up[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(up[2].close, 13.0, epsilon = 1e-12); + assert!(up.iter().all(|b| b.direction == 1)); + assert_eq!(bars.anchor(), Some(13.0)); + } + + #[test] + fn down_move_prints_aligned_bars() { + let mut bars = RangeBars::new(1.0).unwrap(); + bars.update(flat(10.0)); + let down = bars.update(flat(7.0)); + assert_eq!(down.len(), 3); + assert!(down.iter().all(|b| b.direction == -1)); + assert_relative_eq!(down[2].close, 7.0, epsilon = 1e-12); + } + + #[test] + fn reversal_needs_only_one_range() { + // Unlike Renko, a single-range move against the trend prints immediately. + let mut bars = RangeBars::new(1.0).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(12.0)); // anchor 12, up + let down = bars.update(flat(11.0)); // drop of exactly one range + assert_eq!(down.len(), 1); + assert_eq!(down[0].direction, -1); + assert_relative_eq!(down[0].close, 11.0, epsilon = 1e-12); + assert_eq!(bars.anchor(), Some(11.0)); + } + + #[test] + fn small_move_prints_nothing() { + let mut bars = RangeBars::new(1.0).unwrap(); + bars.update(flat(10.0)); + assert!(bars.update(flat(10.5)).is_empty()); + assert_eq!(bars.anchor(), Some(10.0)); + } + + #[test] + fn reset_clears_state() { + let mut bars = RangeBars::new(1.0).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(13.0)); + bars.reset(); + assert_eq!(bars.anchor(), None); + assert!(bars.update(flat(50.0)).is_empty()); + assert_eq!(bars.anchor(), Some(50.0)); + } + + #[test] + fn batch_concatenates_completed_bars() { + let mut bars = RangeBars::new(1.0).unwrap(); + let candles = [flat(10.0), flat(12.0), flat(13.0)]; + let out = bars.batch(&candles); + assert_eq!(out.len(), 3); + assert!(out.iter().all(|b| b.direction == 1)); + } +} diff --git a/crates/wickra-core/src/indicators/realized_spread.rs b/crates/wickra-core/src/indicators/realized_spread.rs new file mode 100644 index 0000000..c3c500e --- /dev/null +++ b/crates/wickra-core/src/indicators/realized_spread.rs @@ -0,0 +1,204 @@ +//! Realized Spread — the post-trade liquidity revenue of a trade in basis +//! points. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::TradeQuote; +use crate::traits::Indicator; + +/// Realized Spread — twice the signed deviation of a trade price from the mid +/// that prevails `horizon` trades *later*, expressed in basis points of the +/// trade's contemporaneous mid. +/// +/// ```text +/// realizedSpread = 2 · D · (tradePrice − mid_{t+horizon}) / mid_t · 10_000 (bps) +/// ``` +/// +/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell), `mid_t` +/// is the mid at the time of the trade, and `mid_{t+horizon}` is the mid +/// `horizon` trade-quotes later. Where the [effective spread] measures the full +/// cost paid by the aggressor against the contemporaneous mid, the realized +/// spread measures the share of that cost a liquidity provider *keeps* after +/// the mid has moved: it is the effective spread net of the price impact +/// (`effective = realized + 2 · priceImpact`). A high realized spread means +/// the quote was not picked off; a low or negative one is the signature of +/// adverse selection, the trade preceding a move in its own direction. +/// +/// The indicator buffers each incoming trade-quote and emits the realized +/// spread for the trade made `horizon` updates ago, once that future mid is +/// known. It warms up for `horizon + 1` trade-quotes — `update` returns `None` +/// until the first trade can be resolved — and then emits one value per update +/// in O(1). +/// +/// `Input = TradeQuote`, `Output = f64`. +/// +/// [effective spread]: crate::EffectiveSpread +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RealizedSpread, Side, Trade, TradeQuote}; +/// +/// let mut rs = RealizedSpread::new(1).unwrap(); +/// let tq = |price: f64, side, mid| TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap(); +/// // First trade buffered; nothing to resolve yet. +/// assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None); +/// // One trade later the mid is 100.20, resolving the first buy: +/// // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps (adverse selection). +/// let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap(); +/// assert!((out - (-20.0)).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct RealizedSpread { + horizon: usize, + // Each pending entry is (aggressor sign, trade price, contemporaneous mid). + pending: VecDeque<(f64, f64, f64)>, + has_emitted: bool, +} + +impl RealizedSpread { + /// Construct a realized-spread indicator that resolves each trade against + /// the mid `horizon` trade-quotes later. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `horizon` is zero (the realized spread + /// is defined against a strictly future mid). + pub fn new(horizon: usize) -> Result { + if horizon == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + horizon, + pending: VecDeque::with_capacity(horizon + 1), + has_emitted: false, + }) + } + + /// The configured horizon, in trade-quotes. + pub const fn horizon(&self) -> usize { + self.horizon + } +} + +impl Indicator for RealizedSpread { + type Input = TradeQuote; + type Output = f64; + + fn update(&mut self, quote: TradeQuote) -> Option { + let sign = quote.trade.side.sign(); + self.pending.push_back((sign, quote.trade.price, quote.mid)); + if self.pending.len() <= self.horizon { + return None; + } + let (old_sign, old_price, old_mid) = self.pending.pop_front().expect("len > horizon >= 1"); + self.has_emitted = true; + // `quote.mid` is the mid prevailing `horizon` trades after the resolved + // trade; normalise by that trade's own contemporaneous mid. + Some(2.0 * old_sign * (old_price - quote.mid) / old_mid * 10_000.0) + } + + fn reset(&mut self) { + self.pending.clear(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + self.horizon + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "RealizedSpread" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::{Side, Trade}; + use crate::traits::BatchExt; + + fn tq(price: f64, side: Side, mid: f64) -> TradeQuote { + TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap() + } + + #[test] + fn rejects_zero_horizon() { + assert!(matches!(RealizedSpread::new(0), Err(Error::PeriodZero))); + assert!(RealizedSpread::new(1).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let rs = RealizedSpread::new(3).unwrap(); + assert_eq!(rs.name(), "RealizedSpread"); + assert_eq!(rs.horizon(), 3); + assert_eq!(rs.warmup_period(), 4); + assert!(!rs.is_ready()); + } + + #[test] + fn resolves_against_future_mid() { + let mut rs = RealizedSpread::new(1).unwrap(); + assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None); + assert!(!rs.is_ready()); + // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps. + let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap(); + assert!((out - (-20.0)).abs() < 1e-9); + assert!(rs.is_ready()); + } + + #[test] + fn no_adverse_move_equals_effective_spread() { + // If the mid does not move over the horizon, realized == effective. + let mut rs = RealizedSpread::new(1).unwrap(); + rs.update(tq(100.05, Side::Buy, 100.0)); + // mid stays at 100.0 -> 2 · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps. + let out = rs.update(tq(100.0, Side::Buy, 100.0)).unwrap(); + assert!((out - 10.0).abs() < 1e-9); + } + + #[test] + fn longer_horizon_warms_up() { + let mut rs = RealizedSpread::new(3).unwrap(); + for _ in 0..3 { + assert_eq!(rs.update(tq(100.0, Side::Buy, 100.0)), None); + } + assert!(!rs.is_ready()); + assert!(rs.update(tq(100.0, Side::Buy, 100.0)).is_some()); + assert!(rs.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let quotes: Vec = (0..30) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + let mid = 100.0 + f64::from(i % 5) * 0.05; + tq(mid + 0.02, side, mid) + }) + .collect(); + let mut a = RealizedSpread::new(4).unwrap(); + let mut b = RealizedSpread::new(4).unwrap(); + assert_eq!( + a.batch("es), + quotes.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut rs = RealizedSpread::new(1).unwrap(); + rs.update(tq(100.05, Side::Buy, 100.0)); + rs.update(tq(100.0, Side::Buy, 100.0)); + assert!(rs.is_ready()); + rs.reset(); + assert!(!rs.is_ready()); + assert_eq!(rs.update(tq(100.05, Side::Buy, 100.0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/realized_volatility.rs b/crates/wickra-core/src/indicators/realized_volatility.rs new file mode 100644 index 0000000..a1b64e7 --- /dev/null +++ b/crates/wickra-core/src/indicators/realized_volatility.rs @@ -0,0 +1,240 @@ +//! Realized Volatility from the sum of squared log returns. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Realized Volatility — the square root of the sum of squared log returns over +/// the trailing `period` bars. +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// RV = √( Σ r_t² over the last `period` returns ) +/// ``` +/// +/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — which reports +/// the *annualised sample standard deviation* of log returns (mean-centred, +/// divided by `n − 1`, scaled by `√trading_periods` and ×100) — realized +/// volatility is the **raw, un-centred, un-annualised** quadratic variation +/// estimator used in high-frequency econometrics. It makes no Gaussian +/// assumption and no mean subtraction: it simply accumulates squared returns, +/// which converges to the integrated variance of the price path as the +/// sampling frequency rises. Multiply by `√trading_periods` yourself if an +/// annual figure is wanted. +/// +/// Non-finite and non-positive prices are ignored (the log return would be +/// undefined): the tick is dropped, state is left untouched, and the last +/// value is returned. +/// +/// Each `update` is O(1): a running sum of squared returns is maintained over +/// the rolling window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RealizedVolatility}; +/// +/// let mut indicator = RealizedVolatility::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RealizedVolatility { + period: usize, + prev_price: Option, + /// Rolling window of the last `period` log returns. + window: VecDeque, + sum_sq: f64, + last: Option, +} + +impl RealizedVolatility { + /// Construct a new realized-volatility indicator. + /// + /// `period` is the number of squared log returns accumulated in the window. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_price: None, + window: VecDeque::with_capacity(period), + sum_sq: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RealizedVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite / non-positive prices are skipped: `ln(input / prev)` is + // undefined, so the tick must not enter the return window. + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + // `prev` came from `self.prev_price`, gated by the guard above, so it is + // finite and positive — the log return is always well-defined. + let r = (input / prev).ln(); + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum_sq -= old * old; + } + self.window.push_back(r); + self.sum_sq += r * r; + if self.window.len() < self.period { + return None; + } + // Floating-point subtraction in the rolling sum can leave a tiny + // negative residual when every return is ~0; clamp before the sqrt. + let rv = self.sum_sq.max(0.0).sqrt(); + self.last = Some(rv); + Some(rv) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first log return needs a previous price, then the window fills. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "RealizedVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(RealizedVolatility::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let rv = RealizedVolatility::new(20).unwrap(); + assert_eq!(rv.period(), 20); + assert_eq!(rv.warmup_period(), 21); + assert_eq!(rv.name(), "RealizedVolatility"); + assert!(!rv.is_ready()); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut rv = RealizedVolatility::new(5).unwrap(); + let out = rv.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn known_value() { + // Two equal +10% steps: r = ln(1.1) each. RV = √(2·ln(1.1)²). + let mut rv = RealizedVolatility::new(2).unwrap(); + let out = rv.batch(&[100.0, 110.0, 121.0]); + let expected = (2.0 * (1.1_f64).ln().powi(2)).sqrt(); + assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut rv = RealizedVolatility::new(10).unwrap(); + for v in rv.batch(&[100.0; 40]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut rv = RealizedVolatility::new(20).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in rv.batch(&prices).into_iter().flatten() { + assert!( + v >= 0.0, + "realized volatility must be non-negative, got {v}" + ); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut rv = RealizedVolatility::new(5).unwrap(); + let out = rv.batch(&(1..=20).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(rv.update(f64::NAN), last); + assert_eq!(rv.update(f64::INFINITY), last); + } + + #[test] + fn skips_non_positive_prices() { + let mut rv = RealizedVolatility::new(5).unwrap(); + let warmup = rv.batch(&(1..=20).map(f64::from).collect::>()); + let baseline = warmup.last().copied().flatten().expect("warmed up"); + assert_eq!(rv.update(-5.0), Some(baseline)); + assert_eq!(rv.update(0.0), Some(baseline)); + // State untouched: a clone advanced by the same real tick agrees. + let mut control = rv.clone(); + let after = rv.update(21.0).expect("ready"); + assert_eq!(control.update(21.0).expect("ready"), after); + } + + #[test] + fn reset_clears_state() { + let mut rv = RealizedVolatility::new(5).unwrap(); + rv.batch(&(1..=20).map(f64::from).collect::>()); + assert!(rv.is_ready()); + rv.reset(); + assert!(!rv.is_ready()); + assert_eq!(rv.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = RealizedVolatility::new(20).unwrap().batch(&prices); + let mut b = RealizedVolatility::new(20).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/recovery_factor.rs b/crates/wickra-core/src/indicators/recovery_factor.rs new file mode 100644 index 0000000..9a0ef7c --- /dev/null +++ b/crates/wickra-core/src/indicators/recovery_factor.rs @@ -0,0 +1,212 @@ +//! Recovery Factor — cumulative net return over max drawdown. + +use crate::traits::Indicator; + +/// Recovery Factor. +/// +/// Input is treated as an equity-curve sample (e.g. total account equity). +/// The indicator tracks the running all-time peak and the deepest drawdown +/// seen so far, plus the cumulative net return relative to the *first* +/// observation: +/// +/// ```text +/// peak = max(equity since start) +/// trough_dd = max((peak − equity) / peak) +/// net_return = (equity_last / equity_first) − 1 +/// Recovery = net_return / trough_dd +/// ``` +/// +/// `Recovery > 1` means the strategy has earned more than it ever lost on +/// the way. A pure up-trend has no drawdown and the indicator reports `0.0` +/// (the ratio is undefined; zero by convention). +/// +/// Cumulative-from-start rather than rolling-windowed: the user resets to +/// re-start the count. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RecoveryFactor}; +/// +/// let mut r = RecoveryFactor::new(); +/// // Equity climbs, drops 20%, recovers and exceeds original peak. +/// for v in [100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0] { +/// r.update(v); +/// } +/// assert!(r.value().unwrap() > 0.0); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RecoveryFactor { + first: f64, + last: f64, + peak: f64, + max_dd: f64, + seen: bool, +} + +impl RecoveryFactor { + /// Construct a new Recovery Factor tracker. + pub const fn new() -> Self { + Self { + first: 0.0, + last: 0.0, + peak: f64::NEG_INFINITY, + max_dd: 0.0, + seen: false, + } + } + + /// Current value if available. + pub fn value(&self) -> Option { + if !self.seen || self.first == 0.0 { + return None; + } + if self.max_dd == 0.0 { + return Some(0.0); + } + let net_return = (self.last / self.first) - 1.0; + Some(net_return / self.max_dd) + } +} + +impl Indicator for RecoveryFactor { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.seen { + if input > self.peak { + self.peak = input; + } + if self.peak > 0.0 { + let dd = (self.peak - input) / self.peak; + if dd > self.max_dd { + self.max_dd = dd; + } + } + } else { + self.first = input; + self.peak = input; + self.seen = true; + } + self.last = input; + self.value() + } + + fn reset(&mut self) { + self.first = 0.0; + self.last = 0.0; + self.peak = f64::NEG_INFINITY; + self.max_dd = 0.0; + self.seen = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.seen && self.first != 0.0 + } + + fn name(&self) -> &'static str { + "RecoveryFactor" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn accessors_and_metadata() { + let r = RecoveryFactor::new(); + assert_eq!(r.name(), "RecoveryFactor"); + assert_eq!(r.warmup_period(), 1); + assert_eq!(r.value(), None); + } + + #[test] + fn pure_uptrend_yields_zero() { + let mut r = RecoveryFactor::new(); + for v in 1..=10 { + r.update(f64::from(v)); + } + // max_dd == 0 -> 0 by convention. + assert_eq!(r.value(), Some(0.0)); + } + + #[test] + fn reference_value() { + // Start 100, peak 110, trough 88 -> max_dd = 0.2. + // End 130 -> net_return = 0.3 -> Recovery = 1.5. + let mut r = RecoveryFactor::new(); + let out = r.batch(&[100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0]); + let last = out.last().copied().unwrap().unwrap(); + assert_relative_eq!(last, 0.30 / 0.20, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut r = RecoveryFactor::new(); + r.update(100.0); + r.update(90.0); + let v = r.value(); + assert_eq!(r.update(f64::NAN), v); + assert_eq!(r.update(f64::INFINITY), v); + } + + #[test] + fn first_value_alone_yields_zero() { + // First update: max_dd is still 0 -> 0 by convention; value defined. + let mut r = RecoveryFactor::new(); + assert_eq!(r.update(100.0), Some(0.0)); + } + + #[test] + fn first_zero_equity_keeps_value_none() { + // first == 0 means net-return division would be 0/0; indicator stays + // not-ready until a non-zero baseline is reset in. + let mut r = RecoveryFactor::new(); + assert_eq!(r.update(0.0), None); + assert!(!r.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut r = RecoveryFactor::new(); + r.batch(&[100.0, 90.0, 80.0]); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(100.0), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let batch = RecoveryFactor::new().batch(&prices); + let mut s = RecoveryFactor::new(); + let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_positive_peak_skips_drawdown_calc() { + // All inputs <= 0 keep `peak` non-positive, so the guarded drawdown + // computation is skipped on every step. Exercises the `else` branch + // of `if self.peak > 0.0`. + let mut r = RecoveryFactor::new(); + assert_eq!(r.update(-1.0), Some(0.0)); + assert_eq!(r.update(-2.0), Some(0.0)); + assert_eq!(r.update(-0.5), Some(0.0)); + assert!(r.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/rectangle_range.rs b/crates/wickra-core/src/indicators/rectangle_range.rs new file mode 100644 index 0000000..2d300ed --- /dev/null +++ b/crates/wickra-core/src/indicators/rectangle_range.rs @@ -0,0 +1,155 @@ +//! Rectangle / Range chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Rectangle / Range — price oscillating between a roughly horizontal support +/// and resistance, a mean-reversion (range-trading) structure. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised when the +/// last two highs and the last two lows are each flat within [`LEVEL_TOLERANCE`] +/// (3%): +/// +/// ```text +/// flat highs (resistance) AND flat lows (support): +/// last pivot a low → +1 (a bounce off support — buy the range) +/// last pivot a high → -1 (a rejection at resistance — sell the range) +/// ``` +/// +/// Unlike the breakout patterns the rectangle is range-bound, so the sign +/// encodes the actionable mean-reversion direction of the just-confirmed touch. +/// Output is `+1.0` / `-1.0` / `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct RectangleRange { + swing: SwingTracker, + has_emitted: bool, +} + +impl RectangleRange { + /// Construct a new Rectangle / Range detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for RectangleRange { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for RectangleRange { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let (high_old, high_new, low_old, low_new) = recent_legs(pivots); + let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE); + let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE); + if flat_highs && flat_lows { + let last_is_high = pivots[pivots.len() - 1].direction > 0.0; + return Some(if last_is_high { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "RectangleRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = RectangleRange::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = RectangleRange::new(); + assert_eq!(indicator.name(), "RectangleRange"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!RectangleRange::default().is_ready()); + } + + #[test] + fn range_bounce_off_support_is_plus_one() { + // Flat highs (120, 121), flat lows (100, 99); last pivot a low → +1. + let out = run(&[120.0, 100.0, 121.0, 99.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn range_rejection_at_resistance_is_minus_one() { + // Same range but ending on a high pivot → -1. + let out = run(&[130.0, 100.0, 120.0, 99.0, 121.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn trending_highs_are_not_a_rectangle() { + // Rising highs break the flat-resistance requirement → no rectangle. + let out = run(&[120.0, 100.0, 140.0, 99.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = RectangleRange::new(); + for c in candles_for_pivots(&[120.0, 100.0, 121.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0]); + let mut a = RectangleRange::new(); + let mut b = RectangleRange::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/reflex.rs b/crates/wickra-core/src/indicators/reflex.rs new file mode 100644 index 0000000..728c500 --- /dev/null +++ b/crates/wickra-core/src/indicators/reflex.rs @@ -0,0 +1,233 @@ +//! Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter. +#![allow(clippy::doc_markdown)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' **Reflex** — a near-zero-lag oscillator that measures how far the +/// smoothed price has deviated from the straight line connecting its endpoints +/// over the lookback. +/// +/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*, +/// Feb 2020): +/// +/// ```text +/// Filt = SuperSmoother(price, period) +/// slope = (Filt[period] − Filt[0]) / period (line over the window) +/// sum = mean over i=1..period of ( Filt[0] + i·slope − Filt[i] ) +/// ms = 0.04·sum² + 0.96·ms[−1] (adaptive normaliser) +/// Reflex = sum / sqrt(ms) (0 if ms == 0) +/// ``` +/// +/// Reflex fits a straight line across the SuperSmoothed price over `period` bars +/// and averages the deviation of the curve from that line. Because the line uses +/// both endpoints, the measure has almost no lag — it crosses zero essentially at +/// the cycle turns. The adaptive mean-square normaliser rescales the output to a +/// roughly `±3` range regardless of price, so the same thresholds work on any +/// instrument. Its sibling [`Trendflex`](crate::Trendflex) uses the deviation from +/// the *current* value instead of the line, making it trend- rather than +/// cycle-sensitive. +/// +/// The first value lands after `period + 1` SuperSmoothed samples. Each `update` +/// is O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Reflex}; +/// +/// let mut indicator = Reflex::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Reflex { + period: usize, + smoother: SuperSmoother, + filt: VecDeque, + ms: f64, + last: Option, +} + +impl Reflex { + /// Construct a Reflex with the given lookback `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + smoother: SuperSmoother::new(period)?, + filt: VecDeque::with_capacity(period + 1), + ms: 0.0, + last: None, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Reflex { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let filt = self.smoother.update(price)?; + if self.filt.len() == self.period + 1 { + self.filt.pop_front(); + } + self.filt.push_back(filt); + if self.filt.len() < self.period + 1 { + return None; + } + // Newest at index `period`, oldest (period bars ago) at index 0. + let newest = self.filt[self.period]; + let oldest = self.filt[0]; + let slope = (oldest - newest) / self.period as f64; + let mut sum = 0.0; + for i in 1..=self.period { + sum += (newest + i as f64 * slope) - self.filt[self.period - i]; + } + sum /= self.period as f64; + self.ms = 0.04 * sum * sum + 0.96 * self.ms; + let reflex = if self.ms > 0.0 { + sum / self.ms.sqrt() + } else { + 0.0 + }; + self.last = Some(reflex); + Some(reflex) + } + + fn reset(&mut self) { + self.smoother.reset(); + self.filt.clear(); + self.ms = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Reflex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Reflex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let r = Reflex::new(20).unwrap(); + assert_eq!(r.period(), 20); + assert_eq!(r.warmup_period(), 21); + assert_eq!(r.name(), "Reflex"); + assert!(!r.is_ready()); + assert_eq!(r.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut r = Reflex::new(5).unwrap(); + let xs: Vec = (0..12) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0) + .collect(); + let out = r.batch(&xs); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_input_is_zero() { + // A flat price is exactly its own straight line -> zero deviation -> 0. + let mut r = Reflex::new(10).unwrap(); + for v in r.batch(&[50.0; 100]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn cyclic_input_oscillates_around_zero() { + let mut r = Reflex::new(20).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + let out: Vec = r.batch(&xs).into_iter().flatten().skip(100).collect(); + assert!(out.iter().any(|&v| v > 0.5)); + assert!(out.iter().any(|&v| v < -0.5)); + } + + #[test] + fn ignores_non_finite() { + let mut r = Reflex::new(10).unwrap(); + r.batch( + &(0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin()) + .collect::>(), + ); + let before = r.value(); + assert_eq!(r.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut r = Reflex::new(10).unwrap(); + r.batch( + &(0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin()) + .collect::>(), + ); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = Reflex::new(20).unwrap().batch(&xs); + let mut b = Reflex::new(20).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/regime_label.rs b/crates/wickra-core/src/indicators/regime_label.rs new file mode 100644 index 0000000..27d5e96 --- /dev/null +++ b/crates/wickra-core/src/indicators/regime_label.rs @@ -0,0 +1,307 @@ +//! Regime Label — volatility-quantile classification of the current bar. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rolling_quantile::quantile_sorted; +use crate::traits::Indicator; + +/// Regime Label — a discrete `{−1, 0, +1}` classification of the current +/// volatility regime by where the latest rolling volatility falls within its +/// own recent distribution. +/// +/// ```text +/// σₜ = sample stddev of the last `vol_period` log returns +/// q1,q3 = 25th / 75th percentile of the last `lookback` σ readings +/// label = −1 if σₜ < q1 (calm regime) +/// +1 if σₜ > q3 (stressed regime) +/// 0 otherwise (normal regime) +/// ``` +/// +/// This is the canonical rolling-volatility-quantile regime split: rather than +/// thresholding absolute volatility (which is not comparable across instruments +/// or epochs), it asks whether *today's* volatility is unusually low or high +/// **relative to its own recent history**. `−1` is a calm regime, `+1` a +/// stressed / high-volatility regime, `0` the normal middle. Because the latest +/// reading is included in its own reference window, a freshly elevated +/// volatility prints `+1` until the window catches up to the new level — it +/// flags the *transition*, not just the absolute level. When the recent +/// volatilities are all equal (`q1 == q3`, e.g. a constant drift) there is no +/// spread to classify against and the label is `0`. +/// +/// Each `update` is `O(vol_period + lookback log lookback)`. Non-finite and +/// non-positive prices are ignored. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RegimeLabel}; +/// +/// let mut indicator = RegimeLabel::new(5, 20).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RegimeLabel { + vol_period: usize, + lookback: usize, + prev_price: Option, + /// Trailing window of the last `vol_period` log returns. + ret_window: VecDeque, + ret_sum: f64, + ret_sum_sq: f64, + /// Trailing window of the last `lookback` volatility readings. + vol_window: VecDeque, + /// Reusable scratch buffer for the quantile sort. + scratch: Vec, + last: Option, +} + +impl RegimeLabel { + /// Construct a new Regime Label classifier. + /// + /// `vol_period` is the window for the rolling volatility; `lookback` is the + /// window of volatility readings whose quartiles set the regime bands. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `vol_period < 2` (the sample standard + /// deviation needs at least two returns) or if `lookback < 2` (the quartile + /// split needs at least two readings). + pub fn new(vol_period: usize, lookback: usize) -> Result { + if vol_period < 2 { + return Err(Error::InvalidPeriod { + message: "regime label needs vol_period >= 2", + }); + } + if lookback < 2 { + return Err(Error::InvalidPeriod { + message: "regime label needs lookback >= 2", + }); + } + Ok(Self { + vol_period, + lookback, + prev_price: None, + ret_window: VecDeque::with_capacity(vol_period), + ret_sum: 0.0, + ret_sum_sq: 0.0, + vol_window: VecDeque::with_capacity(lookback), + scratch: Vec::with_capacity(lookback), + last: None, + }) + } + + /// Configured `(vol_period, lookback)`. + pub const fn params(&self) -> (usize, usize) { + (self.vol_period, self.lookback) + } +} + +impl Indicator for RegimeLabel { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + let r = (input / prev).ln(); + // Roll the return window and its running moments. + if self.ret_window.len() == self.vol_period { + let old = self.ret_window.pop_front().expect("non-empty"); + self.ret_sum -= old; + self.ret_sum_sq -= old * old; + } + self.ret_window.push_back(r); + self.ret_sum += r; + self.ret_sum_sq += r * r; + if self.ret_window.len() < self.vol_period { + return None; + } + let n = self.vol_period as f64; + let mean = self.ret_sum / n; + let var = ((self.ret_sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + let vol = var.sqrt(); + // Roll the volatility window. + if self.vol_window.len() == self.lookback { + self.vol_window.pop_front(); + } + self.vol_window.push_back(vol); + if self.vol_window.len() < self.lookback { + return None; + } + // Classify the latest volatility against the quartiles of the window. + self.scratch.clear(); + self.scratch.extend(self.vol_window.iter().copied()); + self.scratch.sort_by(f64::total_cmp); + let q1 = quantile_sorted(&self.scratch, 0.25); + let q3 = quantile_sorted(&self.scratch, 0.75); + let label = if vol < q1 { + -1.0 + } else if vol > q3 { + 1.0 + } else { + 0.0 + }; + self.last = Some(label); + Some(label) + } + + fn reset(&mut self) { + self.prev_price = None; + self.ret_window.clear(); + self.ret_sum = 0.0; + self.ret_sum_sq = 0.0; + self.vol_window.clear(); + self.scratch.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One price seeds `prev`, `vol_period` returns yield the first vol, then + // `lookback` vols fill the regime window. + self.vol_period + self.lookback + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "RegimeLabel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_bad_periods() { + assert!(matches!( + RegimeLabel::new(1, 20), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RegimeLabel::new(5, 1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let rl = RegimeLabel::new(5, 20).unwrap(); + assert_eq!(rl.params(), (5, 20)); + assert_eq!(rl.warmup_period(), 25); + assert_eq!(rl.name(), "RegimeLabel"); + assert!(!rl.is_ready()); + } + + #[test] + fn detects_stressed_regime_on_volatility_spike() { + // Calm warmup, then a burst of large moves: the elevated volatility + // prints +1 while the lookback window still holds the calm readings. + let mut rl = RegimeLabel::new(4, 8).unwrap(); + let mut prices: Vec = (0..24) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2) + .collect(); + let mut base = *prices.last().unwrap(); + for i in 0..8 { + base *= if i % 2 == 0 { 1.08 } else { 0.93 }; + prices.push(base); + } + let out = rl.batch(&prices); + assert!( + out.iter().flatten().any(|&v| v == 1.0), + "expected a stressed (+1) regime label" + ); + } + + #[test] + fn detects_calm_regime_after_volatility_drop() { + // Volatile warmup, then a calm tail: the depressed volatility prints -1. + let mut rl = RegimeLabel::new(4, 8).unwrap(); + let mut prices: Vec = Vec::new(); + let mut base = 100.0; + for i in 0..24 { + base *= if i % 2 == 0 { 1.05 } else { 0.96 }; + prices.push(base); + } + for i in 0..12 { + prices.push(base + (f64::from(i) * 0.7).sin() * 0.05); + } + let out = rl.batch(&prices); + assert!( + out.iter().flatten().any(|&v| v == -1.0), + "expected a calm (-1) regime label" + ); + } + + #[test] + fn zero_volatility_is_neutral() { + // A constant price has exactly-zero returns => zero volatility on every + // window => q1 == q3 == 0 => neutral 0 throughout. (A geometric drift is + // *conceptually* constant-vol too, but floating-point rounding of the + // log returns leaves ~1e-16 dispersion, so the exactly-flat series is + // the clean way to pin the q1 == q3 branch.) + let mut rl = RegimeLabel::new(4, 8).unwrap(); + for v in rl.batch(&[100.0; 40]).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn output_is_ternary() { + let mut rl = RegimeLabel::new(5, 20).unwrap(); + let prices: Vec = (0..300) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * (1.0 + (f64::from(i) * 0.05).sin() * 5.0)) + .collect(); + for v in rl.batch(&prices).into_iter().flatten() { + assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}"); + } + } + + #[test] + fn ignores_non_finite_and_non_positive() { + let mut rl = RegimeLabel::new(4, 6).unwrap(); + let prices: Vec = (0..40) + .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 2.0) + .collect(); + let out = rl.batch(&prices); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(rl.update(f64::NAN), last); + assert_eq!(rl.update(-1.0), last); + assert_eq!(rl.update(0.0), last); + } + + #[test] + fn reset_clears_state() { + let mut rl = RegimeLabel::new(4, 6).unwrap(); + rl.batch(&(1..=40).map(f64::from).collect::>()); + assert!(rl.is_ready()); + rl.reset(); + assert!(!rl.is_ready()); + assert_eq!(rl.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=160) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 4.0) + .collect(); + let batch = RegimeLabel::new(5, 20).unwrap().batch(&prices); + let mut b = RegimeLabel::new(5, 20).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/relative_strength_ab.rs b/crates/wickra-core/src/indicators/relative_strength_ab.rs new file mode 100644 index 0000000..27dbd88 --- /dev/null +++ b/crates/wickra-core/src/indicators/relative_strength_ab.rs @@ -0,0 +1,233 @@ +//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI. + +use crate::error::Result; +use crate::indicators::{Rsi, Sma}; +use crate::traits::Indicator; + +/// Output of [`RelativeStrengthAB`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RelativeStrengthOutput { + /// The raw relative-strength ratio `a / b`. + pub ratio: f64, + /// Simple moving average of the ratio over `ma_period`. + pub ratio_ma: f64, + /// Relative Strength Index of the ratio over `rsi_period`. + pub ratio_rsi: f64, +} + +/// Comparative relative strength of asset `a` against asset `b`. +/// +/// Each `update` receives one `(a, b)` price pair and forms the **ratio line** +/// `a / b`. The ratio is then smoothed with a simple moving average and run +/// through an RSI, so a single indicator gives you the relative-strength level, +/// its trend, and whether that trend is overbought or oversold: +/// +/// ```text +/// ratio = a / b +/// ratio_ma = SMA(ratio, ma_period) +/// ratio_rsi = RSI(ratio, rsi_period) +/// ``` +/// +/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of +/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a +/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or +/// "asset-vs-index" rotation screen. +/// +/// The first output appears once both the moving average and the RSI have +/// warmed up; the ratio itself is computed from the first valid pair. A +/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined +/// and is skipped, leaving the internal averages untouched. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RelativeStrengthAB}; +/// +/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap(); +/// let mut last = None; +/// for _ in 0..20 { +/// last = rs.update((200.0, 100.0)); // ratio is a constant 2.0 +/// } +/// let out = last.unwrap(); +/// assert!((out.ratio - 2.0).abs() < 1e-12); +/// assert!((out.ratio_ma - 2.0).abs() < 1e-12); +/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50. +/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct RelativeStrengthAB { + ma_period: usize, + rsi_period: usize, + ma: Sma, + rsi: Rsi, +} + +impl RelativeStrengthAB { + /// Construct a new comparative relative-strength indicator. + /// + /// `ma_period` is the moving-average look-back of the ratio; `rsi_period` + /// is the RSI look-back of the ratio. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period + /// is zero. + pub fn new(ma_period: usize, rsi_period: usize) -> Result { + Ok(Self { + ma_period, + rsi_period, + ma: Sma::new(ma_period)?, + rsi: Rsi::new(rsi_period)?, + }) + } + + /// Moving-average look-back of the ratio. + pub const fn ma_period(&self) -> usize { + self.ma_period + } + + /// RSI look-back of the ratio. + pub const fn rsi_period(&self) -> usize { + self.rsi_period + } +} + +impl Indicator for RelativeStrengthAB { + /// `(a, b)` price pair. + type Input = (f64, f64); + type Output = RelativeStrengthOutput; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if b == 0.0 || !a.is_finite() || !b.is_finite() { + // Undefined ratio: skip without disturbing the internal averages. + return None; + } + let ratio = a / b; + let ma = self.ma.update(ratio); + let rsi = self.rsi.update(ratio); + match (ma, rsi) { + (Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput { + ratio, + ratio_ma, + ratio_rsi, + }), + _ => None, + } + } + + fn reset(&mut self) { + self.ma.reset(); + self.rsi.reset(); + } + + fn warmup_period(&self) -> usize { + self.ma.warmup_period().max(self.rsi.warmup_period()) + } + + fn is_ready(&self) -> bool { + self.ma.is_ready() && self.rsi.is_ready() + } + + fn name(&self) -> &'static str { + "RelativeStrengthAB" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_periods() { + assert!(RelativeStrengthAB::new(0, 5).is_err()); + assert!(RelativeStrengthAB::new(5, 0).is_err()); + assert!(RelativeStrengthAB::new(5, 5).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let rs = RelativeStrengthAB::new(10, 14).unwrap(); + assert_eq!(rs.ma_period(), 10); + assert_eq!(rs.rsi_period(), 14); + // SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15. + assert_eq!(rs.warmup_period(), 15); + assert_eq!(rs.name(), "RelativeStrengthAB"); + } + + #[test] + fn constant_ratio_is_flat() { + // a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50. + let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect(); + let out = RelativeStrengthAB::new(5, 5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12); + assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12); + assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9); + } + + #[test] + fn rising_ratio_is_overbought() { + // a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100. + let pairs: Vec<(f64, f64)> = (0..20) + .map(|t| (100.0 + 2.0 * f64::from(t), 100.0)) + .collect(); + let out = RelativeStrengthAB::new(5, 5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(out.ratio > 1.0); + assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9); + } + + #[test] + fn zero_denominator_is_skipped() { + let mut rs = RelativeStrengthAB::new(3, 3).unwrap(); + // b == 0 and non-finite inputs never reach the internal averages. + assert_eq!(rs.update((100.0, 0.0)), None); + assert_eq!(rs.update((f64::NAN, 100.0)), None); + assert!(!rs.is_ready()); + for _ in 0..8 { + rs.update((150.0, 100.0)); + } + assert!(rs.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut rs = RelativeStrengthAB::new(3, 3).unwrap(); + for t in 0..10 { + rs.update((100.0 + f64::from(t), 100.0)); + } + assert!(rs.is_ready()); + rs.reset(); + assert!(!rs.is_ready()); + assert_eq!(rs.update((100.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|t| { + let tt = f64::from(t); + ( + 100.0 + 5.0 * (tt * 0.3).sin(), + 100.0 + 2.0 * (tt * 0.2).cos(), + ) + }) + .collect(); + let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs); + let mut rs = RelativeStrengthAB::new(10, 14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/renko_bars.rs b/crates/wickra-core/src/indicators/renko_bars.rs new file mode 100644 index 0000000..eb6897e --- /dev/null +++ b/crates/wickra-core/src/indicators/renko_bars.rs @@ -0,0 +1,267 @@ +//! Renko bar builder — fixed box-size bricks with the classic reversal rule. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed Renko brick. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RenkoBrick { + /// Price at the brick's origin edge. + pub open: f64, + /// Price at the brick's far edge (`open ± box_size`). + pub close: f64, + /// `+1` for an up brick, `-1` for a down brick. + pub direction: i8, +} + +/// Renko bar builder using the fixed box-size method on close prices. +/// +/// Construction follows the classic Renko rules: +/// +/// - The first candle seeds the reference level and prints no brick. +/// - While the trend continues, every additional `box_size` of close movement +/// prints one more brick in the trend direction. +/// - A reversal requires `2 * box_size` against the trend (one box to unwind the +/// last brick's body, one to print the first opposite brick); thereafter each +/// further `box_size` prints another brick. +/// +/// A single candle whose close gaps several boxes prints all the bricks it +/// completes in one [`BarBuilder::update`] call. Bricks are perfectly aligned to +/// the `box_size` grid relative to the seed price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, RenkoBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut renko = RenkoBars::new(1.0).unwrap(); +/// assert!(renko.update(flat(10.0)).is_empty()); // seed +/// let bricks = renko.update(flat(13.0)); // +3 boxes +/// assert_eq!(bricks.len(), 3); +/// assert!(bricks.iter().all(|b| b.direction == 1)); +/// ``` +#[derive(Debug, Clone)] +pub struct RenkoBars { + box_size: f64, + level: Option, + dir: i8, +} + +impl RenkoBars { + /// Construct a Renko builder with the given brick size. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `box_size` is not finite and positive. + pub fn new(box_size: f64) -> Result { + if !box_size.is_finite() || box_size <= 0.0 { + return Err(Error::InvalidPeriod { + message: "box_size must be finite and positive", + }); + } + Ok(Self { + box_size, + level: None, + dir: 0, + }) + } + + /// Configured brick size. + pub const fn box_size(&self) -> f64 { + self.box_size + } + + /// Current reference level (the close of the last completed brick, or the + /// seed price before any brick has formed). + pub const fn level(&self) -> Option { + self.level + } +} + +impl BarBuilder for RenkoBars { + type Bar = RenkoBrick; + + fn update(&mut self, candle: Candle) -> Vec { + let close = candle.close; + let Some(mut level) = self.level else { + self.level = Some(close); + return Vec::new(); + }; + let box_size = self.box_size; + let two = 2.0 * box_size; + let mut bricks = Vec::new(); + loop { + if self.dir >= 0 && close >= level + box_size { + bricks.push(RenkoBrick { + open: level, + close: level + box_size, + direction: 1, + }); + level += box_size; + self.dir = 1; + } else if self.dir <= 0 && close <= level - box_size { + bricks.push(RenkoBrick { + open: level, + close: level - box_size, + direction: -1, + }); + level -= box_size; + self.dir = -1; + } else if self.dir > 0 && close <= level - two { + bricks.push(RenkoBrick { + open: level - box_size, + close: level - two, + direction: -1, + }); + level -= two; + self.dir = -1; + } else if self.dir < 0 && close >= level + two { + bricks.push(RenkoBrick { + open: level + box_size, + close: level + two, + direction: 1, + }); + level += two; + self.dir = 1; + } else { + break; + } + } + self.level = Some(level); + bricks + } + + fn reset(&mut self) { + self.level = None; + self.dir = 0; + } + + fn name(&self) -> &'static str { + "RenkoBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_box_size() { + assert!(matches!( + RenkoBars::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RenkoBars::new(-1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RenkoBars::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let renko = RenkoBars::new(2.5).unwrap(); + assert_eq!(renko.name(), "RenkoBars"); + assert_relative_eq!(renko.box_size(), 2.5, epsilon = 1e-12); + assert_eq!(renko.level(), None); + } + + #[test] + fn first_candle_seeds_without_brick() { + let mut renko = RenkoBars::new(1.0).unwrap(); + assert!(renko.update(flat(10.0)).is_empty()); + assert_eq!(renko.level(), Some(10.0)); + } + + #[test] + fn up_trend_prints_aligned_bricks() { + let mut renko = RenkoBars::new(1.0).unwrap(); + renko.update(flat(10.0)); + let bricks = renko.update(flat(13.0)); + assert_eq!(bricks.len(), 3); + assert_relative_eq!(bricks[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(bricks[0].close, 11.0, epsilon = 1e-12); + assert_relative_eq!(bricks[2].close, 13.0, epsilon = 1e-12); + assert!(bricks.iter().all(|b| b.direction == 1)); + assert_eq!(renko.level(), Some(13.0)); + } + + #[test] + fn down_trend_prints_aligned_bricks() { + let mut renko = RenkoBars::new(1.0).unwrap(); + renko.update(flat(10.0)); + let bricks = renko.update(flat(7.0)); + assert_eq!(bricks.len(), 3); + assert!(bricks.iter().all(|b| b.direction == -1)); + assert_relative_eq!(bricks[2].close, 7.0, epsilon = 1e-12); + assert_eq!(renko.level(), Some(7.0)); + } + + #[test] + fn reversal_down_needs_two_boxes() { + let mut renko = RenkoBars::new(1.0).unwrap(); + renko.update(flat(10.0)); + renko.update(flat(13.0)); // level 13, dir up + let bricks = renko.update(flat(10.0)); // drop of 3 -> reversal eats one box + assert_eq!(bricks.len(), 2); + assert!(bricks.iter().all(|b| b.direction == -1)); + assert_relative_eq!(bricks[0].open, 12.0, epsilon = 1e-12); + assert_relative_eq!(bricks[0].close, 11.0, epsilon = 1e-12); + assert_relative_eq!(bricks[1].close, 10.0, epsilon = 1e-12); + assert_eq!(renko.level(), Some(10.0)); + } + + #[test] + fn reversal_up_needs_two_boxes() { + let mut renko = RenkoBars::new(1.0).unwrap(); + renko.update(flat(10.0)); + renko.update(flat(7.0)); // level 7, dir down + let bricks = renko.update(flat(10.0)); // rise of 3 -> reversal + assert_eq!(bricks.len(), 2); + assert!(bricks.iter().all(|b| b.direction == 1)); + assert_relative_eq!(bricks[0].open, 8.0, epsilon = 1e-12); + assert_relative_eq!(bricks[0].close, 9.0, epsilon = 1e-12); + assert_relative_eq!(bricks[1].close, 10.0, epsilon = 1e-12); + } + + #[test] + fn small_move_prints_nothing() { + let mut renko = RenkoBars::new(1.0).unwrap(); + renko.update(flat(10.0)); + renko.update(flat(13.0)); + assert!(renko.update(flat(12.5)).is_empty()); // less than a reversal + assert_eq!(renko.level(), Some(13.0)); + } + + #[test] + fn reset_clears_state() { + let mut renko = RenkoBars::new(1.0).unwrap(); + renko.update(flat(10.0)); + renko.update(flat(13.0)); + renko.reset(); + assert_eq!(renko.level(), None); + // After reset the next candle seeds again. + assert!(renko.update(flat(50.0)).is_empty()); + assert_eq!(renko.level(), Some(50.0)); + } + + #[test] + fn batch_concatenates_completed_bricks() { + let mut renko = RenkoBars::new(1.0).unwrap(); + let candles = [flat(10.0), flat(12.0), flat(13.0)]; + let bricks = renko.batch(&candles); + // seed at 10, then +2 then +1 => 3 up bricks total. + assert_eq!(bricks.len(), 3); + assert!(bricks.iter().all(|b| b.direction == 1)); + } +} diff --git a/crates/wickra-core/src/indicators/renko_trailing_stop.rs b/crates/wickra-core/src/indicators/renko_trailing_stop.rs new file mode 100644 index 0000000..7152618 --- /dev/null +++ b/crates/wickra-core/src/indicators/renko_trailing_stop.rs @@ -0,0 +1,236 @@ +//! Renko Trailing Stop. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Renko Trailing Stop — a trailing stop that follows a Renko-style brick +/// anchor: the stop only moves when price has advanced (or fallen) by at least +/// one full `block_size`, and then jumps the same fixed distance. +/// +/// ```text +/// long: advance = floor((close − anchor) / block_size) +/// if advance ≥ 1 -> anchor += advance · block_size +/// stop = anchor − block_size +/// flip-to-short on close < stop -> anchor = close, stop = anchor + block_size +/// short: advance = floor((anchor − close) / block_size) +/// if advance ≥ 1 -> anchor −= advance · block_size +/// stop = anchor + block_size +/// flip-to-long on close > stop -> anchor = close, stop = anchor − block_size +/// ``` +/// +/// Like a Renko chart the stop ignores intra-block noise — it sits one full +/// block behind the last "printed" brick and only ratchets in whole-block +/// increments. The first input seeds a long anchor at the close. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RenkoTrailingStop}; +/// +/// let mut indicator = RenkoTrailingStop::new(1.0).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RenkoTrailingStop { + block_size: f64, + anchor: Option, + long: bool, +} + +impl RenkoTrailingStop { + /// Construct a Renko Trailing Stop with an explicit block size. + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `block_size` is not strictly + /// positive and finite. + pub fn new(block_size: f64) -> Result { + if !block_size.is_finite() || block_size <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + block_size, + anchor: None, + long: true, + }) + } + + /// A common configuration: a `1.0` block size. + pub fn classic() -> Self { + Self::new(1.0).expect("classic block size is valid") + } + + /// Configured block size. + pub const fn block_size(&self) -> f64 { + self.block_size + } +} + +impl Indicator for RenkoTrailingStop { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + if !close.is_finite() { + return None; + } + let anchor = match self.anchor { + Some(prev) => { + if self.long { + let stop = prev - self.block_size; + if close < stop { + // Close-through long stop -> flip to short. + self.long = false; + close + } else { + let blocks = ((close - prev) / self.block_size).floor(); + if blocks >= 1.0 { + prev + blocks * self.block_size + } else { + prev + } + } + } else { + let stop = prev + self.block_size; + if close > stop { + self.long = true; + close + } else { + let blocks = ((prev - close) / self.block_size).floor(); + if blocks >= 1.0 { + prev - blocks * self.block_size + } else { + prev + } + } + } + } + None => close, + }; + self.anchor = Some(anchor); + let stop = if self.long { + anchor - self.block_size + } else { + anchor + self.block_size + }; + Some(stop) + } + + fn reset(&mut self) { + self.anchor = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.anchor.is_some() + } + + fn name(&self) -> &'static str { + "RenkoTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_block() { + assert!(RenkoTrailingStop::new(0.0).is_err()); + assert!(RenkoTrailingStop::new(-1.0).is_err()); + assert!(RenkoTrailingStop::new(f64::NAN).is_err()); + assert!(RenkoTrailingStop::new(f64::INFINITY).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = RenkoTrailingStop::classic(); + assert_relative_eq!(s.block_size(), 1.0, epsilon = 1e-12); + assert_eq!(s.name(), "RenkoTrailingStop"); + assert_eq!(s.warmup_period(), 1); + } + + #[test] + fn first_value_is_block_below_close() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + // Seed anchor = 100, long stop = 99. + assert_relative_eq!(s.update(100.0).unwrap(), 99.0, epsilon = 1e-12); + } + + #[test] + fn stop_only_moves_after_full_block_advance() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + // Intra-block move -> stop unchanged at 99. + assert_relative_eq!(s.update(100.5).unwrap(), 99.0, epsilon = 1e-12); + // Full block: 101 -> anchor 101, stop 100. + assert_relative_eq!(s.update(101.0).unwrap(), 100.0, epsilon = 1e-12); + // Two blocks at once: 103.5 -> anchor 103, stop 102. + assert_relative_eq!(s.update(103.5).unwrap(), 102.0, epsilon = 1e-12); + } + + #[test] + fn flips_to_short_on_close_through_stop() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(105.0); // anchor 105, stop 104 + // Drop to 50 closes through 104 -> flip short, anchor=50, stop=51. + let flipped = s.update(50.0).unwrap(); + assert_relative_eq!(flipped, 51.0, epsilon = 1e-12); + } + + #[test] + fn short_anchor_ratchets_down_and_flips_back() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); // short, anchor 50, stop 51 + let v = s.update(48.0).unwrap(); + // Two blocks down: anchor 48, stop 49. + assert_relative_eq!(v, 49.0, epsilon = 1e-12); + // Rally to 60 closes through 49 -> flip long, anchor=60, stop=59. + let back = s.update(60.0).unwrap(); + assert_relative_eq!(back, 59.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_holds_stop() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + let out = s.batch(&[100.0; 30]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 99.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut s = RenkoTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); // flip short + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let mut a = RenkoTrailingStop::classic(); + let mut b = RenkoTrailingStop::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/rickshaw_man.rs b/crates/wickra-core/src/indicators/rickshaw_man.rs new file mode 100644 index 0000000..dbfb36b --- /dev/null +++ b/crates/wickra-core/src/indicators/rickshaw_man.rs @@ -0,0 +1,167 @@ +//! Rickshaw Man candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Rickshaw Man — a single-bar indecision signal. A long-legged doji whose tiny +/// body sits near the *middle* of a wide range, the most balanced form of +/// indecision: neither side controlled the close and the midpoint pins it. +/// +/// ```text +/// range = high − low +/// doji = |close − open| <= 0.1 * range +/// long upper = high − max(open, close) >= 0.3 * range +/// long lower = min(open, close) − low >= 0.3 * range +/// centred body = body midpoint within the central 40–60 % of the range +/// ``` +/// +/// Output is `+1.0` when the rickshaw man prints and `0.0` otherwise. This is a +/// non-directional indecision flag — it never emits `−1.0`. A rickshaw man is a +/// special case of a long-legged doji (the body additionally sits at the centre), +/// so both detectors may flag the same bar. Body and shadow thresholds follow the +/// geometric house style (fixed fractions of the bar range) rather than TA-Lib's +/// rolling averages. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` detected, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, RickshawMan}; +/// +/// let mut indicator = RickshawMan::new(); +/// // Tiny body centred in a wide range, long shadows both sides. +/// let candle = Candle::new(10.0, 12.0, 8.0, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RickshawMan { + has_emitted: bool, +} + +impl RickshawMan { + /// Construct a new Rickshaw Man detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for RickshawMan { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + if (candle.close - candle.open).abs() > 0.1 * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + let body_mid = 0.5 * (candle.open + candle.close); + let pos = (body_mid - candle.low) / range; + if upper >= 0.3 * range && lower >= 0.3 * range && (0.4..=0.6).contains(&pos) { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "RickshawMan" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = RickshawMan::new(); + assert_eq!(t.name(), "RickshawMan"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn rickshaw_is_plus_one() { + let mut t = RickshawMan::new(); + assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.0, 0)), Some(1.0)); + } + + #[test] + fn off_centre_body_yields_zero() { + let mut t = RickshawMan::new(); + // Long-legged but the body sits near the top, not the middle. + assert_eq!(t.update(c(11.4, 12.0, 8.0, 11.45, 0)), Some(0.0)); + } + + #[test] + fn one_sided_shadow_yields_zero() { + let mut t = RickshawMan::new(); + // Dragonfly shape: no upper shadow -> not a rickshaw man. + assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn non_doji_yields_zero() { + let mut t = RickshawMan::new(); + assert_eq!(t.update(c(9.0, 12.0, 8.0, 11.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = RickshawMan::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base - 2.0, base + 0.05, i) + }) + .collect(); + let mut a = RickshawMan::new(); + let mut b = RickshawMan::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = RickshawMan::new(); + t.update(c(10.0, 12.0, 8.0, 10.0, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/rising_three_methods.rs b/crates/wickra-core/src/indicators/rising_three_methods.rs new file mode 100644 index 0000000..c1df878 --- /dev/null +++ b/crates/wickra-core/src/indicators/rising_three_methods.rs @@ -0,0 +1,235 @@ +//! Rising Three Methods candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Rising Three Methods — a 5-bar bullish continuation. A long white candle is +/// followed by three small bars that drift back but stay inside its range (a brief +/// rest), then a second long white candle closes above the first, resuming the +/// advance. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1 white & long +/// bar2, bar3, bar4 small bodies, each contained within bar1's high/low range +/// bar5 white, closing above bar1's close +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Rising Three +/// Methods is a single-direction (bullish-only) continuation, so it never emits +/// `−1.0`. The first four bars always return `0.0` because the five-bar window is +/// not yet filled. Body thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, RisingThreeMethods}; +/// +/// let mut indicator = RisingThreeMethods::new(); +/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(14.0, 14.1, 12.9, 13.0, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(13.5, 13.6, 12.4, 12.5, 1.0, 2).unwrap()); +/// indicator.update(Candle::new(13.0, 13.1, 11.9, 12.0, 1.0, 3).unwrap()); +/// let out = indicator +/// .update(Candle::new(12.5, 16.1, 12.4, 16.0, 1.0, 4).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RisingThreeMethods { + c1: Option, + c2: Option, + c3: Option, + c4: Option, + has_emitted: bool, +} + +impl RisingThreeMethods { + /// Construct a new Rising Three Methods detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + c3: None, + c4: None, + has_emitted: false, + } + } +} + +impl Indicator for RisingThreeMethods { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + let bar4 = self.c4; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = self.c4; + self.c4 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + if range1 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.close - bar1.open; + if body1 < 0.5 * range1 { + return Some(0.0); // bar1 must be a long white body + } + // The three middle bars stay within bar1's range with smaller bodies. + for mid in [bar2, bar3, bar4] { + if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low { + return Some(0.0); + } + } + // bar5 is a white candle closing above bar1's close. + if candle.close > candle.open && candle.close > bar1.close { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.c4 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "RisingThreeMethods" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = RisingThreeMethods::new(); + assert_eq!(t.name(), "RisingThreeMethods"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + } + + #[test] + fn rising_three_methods_is_plus_one() { + let mut t = RisingThreeMethods::new(); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 1)), Some(0.0)); + assert_eq!(t.update(c(13.5, 13.6, 12.4, 12.5, 2)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 3)), Some(0.0)); + assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(1.0)); + } + + #[test] + fn middle_bar_breaks_range_yields_zero() { + let mut t = RisingThreeMethods::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(14.0, 14.1, 12.9, 13.0, 1)); + // bar3 pokes above bar1's high. + t.update(c(13.5, 16.0, 12.4, 12.5, 2)); + t.update(c(13.0, 13.1, 11.9, 12.0, 3)); + assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0)); + } + + #[test] + fn bar5_not_new_high_yields_zero() { + let mut t = RisingThreeMethods::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(14.0, 14.1, 12.9, 13.0, 1)); + t.update(c(13.5, 13.6, 12.4, 12.5, 2)); + t.update(c(13.0, 13.1, 11.9, 12.0, 3)); + // bar5 white but closes below bar1's close. + assert_eq!(t.update(c(12.5, 14.6, 12.4, 14.5, 4)), Some(0.0)); + } + + #[test] + fn first_four_bars_return_zero() { + let mut t = RisingThreeMethods::new(); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 1)), Some(0.0)); + assert_eq!(t.update(c(13.5, 13.6, 12.4, 12.5, 2)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 5.2, base - 0.1, base + 5.0, i) + }) + .collect(); + let mut a = RisingThreeMethods::new(); + let mut b = RisingThreeMethods::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = RisingThreeMethods::new(); + t.update(c(10.0, 15.1, 9.9, 15.0, 0)); + t.update(c(14.0, 14.1, 12.9, 13.0, 1)); + t.update(c(13.5, 13.6, 12.4, 12.5, 2)); + t.update(c(13.0, 13.1, 11.9, 12.0, 3)); + t.update(c(12.5, 16.1, 12.4, 16.0, 4)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_first_bar_yields_zero() { + let mut t = RisingThreeMethods::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + t.update(c(14.0, 14.1, 12.9, 13.0, 1)); + t.update(c(13.5, 13.6, 12.4, 12.5, 2)); + t.update(c(13.0, 13.1, 11.9, 12.0, 3)); + assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0)); + } + + #[test] + fn short_first_body_yields_zero() { + let mut t = RisingThreeMethods::new(); + // bar1 has a wide range but a tiny body -> not a long white body. + t.update(c(10.0, 16.0, 9.0, 10.2, 0)); + t.update(c(14.0, 14.1, 12.9, 13.0, 1)); + t.update(c(13.5, 13.6, 12.4, 12.5, 2)); + t.update(c(13.0, 13.1, 11.9, 12.0, 3)); + assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/rmi.rs b/crates/wickra-core/src/indicators/rmi.rs new file mode 100644 index 0000000..2dc1e1f --- /dev/null +++ b/crates/wickra-core/src/indicators/rmi.rs @@ -0,0 +1,276 @@ +//! Relative Momentum Index (RMI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback. +/// +/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close. +/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago, +/// then applies the same Wilder-smoothed up/down accumulator over `period`: +/// +/// ```text +/// change_t = close_t - close_{t-momentum} +/// gain = max(change, 0), loss = max(-change, 0) +/// avg_gain, avg_loss = Wilder-smoothed over `period` +/// RMI = 100 * avg_gain / (avg_gain + avg_loss) +/// ``` +/// +/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes +/// the oscillator smoother and slower to flip, holding overbought/oversold +/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market +/// (no gains and no losses) returns the neutral `50`. +/// +/// The first value lands after `momentum + period` inputs: `momentum` to fill +/// the lookback, then `period` changes to seed Wilder's averages. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Rmi}; +/// +/// let mut indicator = Rmi::new(14, 5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rmi { + period: usize, + momentum: usize, + /// The last `momentum` prices, oldest at the front. + window: VecDeque, + seed_gains: Vec, + seed_losses: Vec, + avg_gain: Option, + avg_loss: Option, + last_value: Option, +} + +impl Rmi { + /// Construct an RMI with the given smoothing `period` and `momentum` + /// lookback. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`. + pub fn new(period: usize, momentum: usize) -> Result { + if period == 0 || momentum == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + momentum, + window: VecDeque::with_capacity(momentum), + seed_gains: Vec::with_capacity(period), + seed_losses: Vec::with_capacity(period), + avg_gain: None, + avg_loss: None, + last_value: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured momentum lookback. + pub const fn momentum(&self) -> usize { + self.momentum + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { + let denom = avg_gain + avg_loss; + if denom == 0.0 { + 50.0 + } else { + // Ratio first, then scale, so `100 * g / g` cannot round above 100. + 100.0 * (avg_gain / denom) + } + } +} + +impl Indicator for Rmi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + if self.window.len() < self.momentum { + // Still filling the momentum lookback; no change to measure yet. + self.window.push_back(input); + return None; + } + let past = self.window.pop_front().expect("window full"); + self.window.push_back(input); + + let change = input - past; + let gain = if change > 0.0 { change } else { 0.0 }; + let loss = if change < 0.0 { -change } else { 0.0 }; + + if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) { + let n = self.period as f64; + let new_ag = (ag * (n - 1.0) + gain) / n; + let new_al = (al * (n - 1.0) + loss) / n; + self.avg_gain = Some(new_ag); + self.avg_loss = Some(new_al); + let v = Self::rmi_from_avgs(new_ag, new_al); + self.last_value = Some(v); + return Some(v); + } + + self.seed_gains.push(gain); + self.seed_losses.push(loss); + if self.seed_gains.len() == self.period { + let ag = self.seed_gains.iter().sum::() / self.period as f64; + let al = self.seed_losses.iter().sum::() / self.period as f64; + self.avg_gain = Some(ag); + self.avg_loss = Some(al); + let v = Self::rmi_from_avgs(ag, al); + self.last_value = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.window.clear(); + self.seed_gains.clear(); + self.seed_losses.clear(); + self.avg_gain = None; + self.avg_loss = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.momentum + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "RMI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::Rsi; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_params() { + assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero))); + assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` + `momentum` + `value` and the + /// Indicator-impl `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let rmi = Rmi::new(14, 5).unwrap(); + assert_eq!(rmi.period(), 14); + assert_eq!(rmi.momentum(), 5); + assert_eq!(rmi.value(), None); + assert_eq!(rmi.warmup_period(), 19); + assert_eq!(rmi.name(), "RMI"); + } + + #[test] + fn momentum_one_equals_rsi() { + // With momentum = 1 the RMI is exactly Wilder's RSI. + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0) + .collect(); + let mut rmi = Rmi::new(14, 1).unwrap(); + let mut rsi = Rsi::new(14).unwrap(); + for (i, &p) in prices.iter().enumerate() { + let got = rmi.update(p); + let want = rsi.update(p); + assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}"); + if let (Some(a), Some(b)) = (got, want) { + assert_relative_eq!(a, b, epsilon = 1e-9); + } + } + } + + #[test] + fn warmup_then_emits() { + // momentum + period = 3 + 2 = 5 inputs before the first value. + let mut rmi = Rmi::new(2, 3).unwrap(); + let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first value at warmup_period - 1"); + } + + #[test] + fn pure_uptrend_is_one_hundred() { + // Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100. + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut rmi = Rmi::new(5, 3).unwrap(); + let last = rmi.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn flat_market_is_neutral() { + // No change -> no gains and no losses -> neutral 50. + let mut rmi = Rmi::new(3, 2).unwrap(); + let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-12); + } + + #[test] + fn ignores_non_finite_input() { + let mut rmi = Rmi::new(2, 2).unwrap(); + let ready = rmi + .batch(&[1.0, 2.0, 3.0, 4.0, 5.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(rmi.update(f64::NAN), Some(ready)); + assert_eq!(rmi.update(f64::INFINITY), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut rmi = Rmi::new(3, 2).unwrap(); + rmi.batch(&(1..=20).map(f64::from).collect::>()); + assert!(rmi.is_ready()); + rmi.reset(); + assert!(!rmi.is_ready()); + assert_eq!(rmi.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40) + .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0) + .collect(); + let mut a = Rmi::new(14, 5).unwrap(); + let mut b = Rmi::new(14, 5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/roc.rs b/crates/wickra-core/src/indicators/roc.rs new file mode 100644 index 0000000..934f1dd --- /dev/null +++ b/crates/wickra-core/src/indicators/roc.rs @@ -0,0 +1,183 @@ +//! Rate of Change (ROC). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rate of Change as a percentage: `(close - close[period]) / close[period] * 100`. +/// +/// Non-finite inputs are ignored and leave the window untouched; the last +/// computed value is returned instead, matching the SMA / EMA convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Roc}; +/// +/// let mut indicator = Roc::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Roc { + period: usize, + window: VecDeque, + last: Option, +} + +impl Roc { + /// # 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 + 1), + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Roc { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite inputs are ignored: return the last value, leave state as is. + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period + 1 { + return None; + } + let prev = *self.window.front().expect("non-empty"); + let roc = if prev == 0.0 { + 0.0 + } else { + (input - prev) / prev * 100.0 + }; + self.last = Some(roc); + Some(roc) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "ROC" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_zero() { + let mut roc = Roc::new(5).unwrap(); + let out = roc.batch(&[10.0_f64; 20]); + for v in out.iter().skip(5).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn known_value() { + // ROC(3) where prev = 100, now = 110 -> 10% + let mut roc = Roc::new(3).unwrap(); + let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]); + assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 2.0).collect(); + let mut a = Roc::new(5).unwrap(); + let mut b = Roc::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut roc = Roc::new(5).unwrap(); + roc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert!(roc.is_ready()); + roc.reset(); + assert!(!roc.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Roc::new(0).is_err()); + } + + /// Cover the const accessor `period` (47-49) and the Indicator-impl + /// `warmup_period` (83-85) + `name` (91-93). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let roc = Roc::new(5).unwrap(); + assert_eq!(roc.period(), 5); + assert_eq!(roc.warmup_period(), 6); + assert_eq!(roc.name(), "ROC"); + } + + /// Cover the `prev == 0.0` defensive branch (line 70). All existing + /// tests use prices ≥ 1.0, so the divide-by-zero guard was never + /// triggered. Feed a leading zero followed by `period` more values + /// so the front of the window is exactly 0.0, then assert the next + /// emission is the flat-momentum fallback 0.0 (not NaN). + #[test] + fn zero_previous_price_yields_zero_roc() { + let mut roc = Roc::new(3).unwrap(); + let out = roc.batch(&[0.0, 5.0, 7.0, 9.0]); + let v = out[3].expect("ready after period + 1 inputs"); + assert_eq!(v, 0.0); + } + + #[test] + fn ignores_non_finite_input() { + let mut roc = Roc::new(3).unwrap(); + let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]); + let ready = out[3].expect("ROC(3) ready after four inputs"); + // Non-finite inputs return the last value without sliding the window. + assert_eq!(roc.update(f64::NAN), Some(ready)); + assert_eq!(roc.update(f64::INFINITY), Some(ready)); + // Window untouched: the next finite input still references prev = 105. + assert_relative_eq!( + roc.update(115.0).unwrap(), + (115.0 - 105.0) / 105.0 * 100.0, + epsilon = 1e-12 + ); + } +} diff --git a/crates/wickra-core/src/indicators/rocp.rs b/crates/wickra-core/src/indicators/rocp.rs new file mode 100644 index 0000000..b74ba51 --- /dev/null +++ b/crates/wickra-core/src/indicators/rocp.rs @@ -0,0 +1,162 @@ +//! Rate of Change Percentage (ROCP). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rate of Change Percentage (`ROCP`): `(close - close[period]) / close[period]`. +/// +/// The same momentum measure as [`Roc`](crate::Roc) but expressed as a raw +/// fraction rather than a percentage — `Roc` is exactly `100 · ROCP`. Where the +/// reference price is zero the result is reported as `0`. +/// +/// Non-finite inputs are ignored and leave the window untouched; the last +/// computed value is returned instead, matching the SMA / EMA convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Rocp}; +/// +/// let mut indicator = Rocp::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rocp { + period: usize, + window: VecDeque, + last: Option, +} + +impl Rocp { + /// # 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 + 1), + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Rocp { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period + 1 { + return None; + } + let prev = *self.window.front().expect("non-empty"); + let rocp = if prev == 0.0 { + 0.0 + } else { + (input - prev) / prev + }; + self.last = Some(rocp); + Some(rocp) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "ROCP" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Rocp::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let r = Rocp::new(3).unwrap(); + assert_eq!(r.period(), 3); + assert_eq!(r.name(), "ROCP"); + assert_eq!(r.warmup_period(), 4); + assert!(!r.is_ready()); + } + + #[test] + fn known_value_is_a_fraction() { + // period 1 over [10, 11]: (11 - 10) / 10 = 0.1. + let mut r = Rocp::new(1).unwrap(); + let out: Vec> = r.batch(&[10.0, 11.0]); + assert_eq!(out[0], None); + assert_relative_eq!(out[1].unwrap(), 0.1, epsilon = 1e-12); + assert!(r.is_ready()); + } + + #[test] + fn constant_series_yields_zero() { + let mut r = Rocp::new(3).unwrap(); + for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_reference_price_reports_zero() { + // period 1 over [0, 5]: reference price is zero -> guarded to 0. + let mut r = Rocp::new(1).unwrap(); + let out: Vec> = r.batch(&[0.0, 5.0]); + assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn non_finite_input_holds_last() { + let mut r = Rocp::new(1).unwrap(); + assert_eq!(r.update(10.0), None); + let v = r.update(11.0).unwrap(); + assert_eq!(r.update(f64::NAN), Some(v)); + } + + #[test] + fn reset_clears_state() { + let mut r = Rocp::new(1).unwrap(); + let _ = r.batch(&[10.0, 11.0]); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(10.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/rocr.rs b/crates/wickra-core/src/indicators/rocr.rs new file mode 100644 index 0000000..1675b1f --- /dev/null +++ b/crates/wickra-core/src/indicators/rocr.rs @@ -0,0 +1,157 @@ +//! Rate of Change Ratio (ROCR). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rate of Change Ratio (`ROCR`): `close / close[period]`. +/// +/// The momentum ratio relative to the price `period` bars ago: `1.0` means no +/// change, `> 1` an advance, `< 1` a decline. It is [`Rocp`](crate::Rocp) plus +/// one. Where the reference price is zero the result is reported as `0`. +/// +/// Non-finite inputs are ignored and leave the window untouched; the last +/// computed value is returned instead, matching the SMA / EMA convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Rocr}; +/// +/// let mut indicator = Rocr::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rocr { + period: usize, + window: VecDeque, + last: Option, +} + +impl Rocr { + /// # 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 + 1), + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Rocr { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period + 1 { + return None; + } + let prev = *self.window.front().expect("non-empty"); + let rocr = if prev == 0.0 { 0.0 } else { input / prev }; + self.last = Some(rocr); + Some(rocr) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "ROCR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Rocr::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let r = Rocr::new(3).unwrap(); + assert_eq!(r.period(), 3); + assert_eq!(r.name(), "ROCR"); + assert_eq!(r.warmup_period(), 4); + assert!(!r.is_ready()); + } + + #[test] + fn known_value_is_a_ratio() { + // period 1 over [10, 11]: 11 / 10 = 1.1. + let mut r = Rocr::new(1).unwrap(); + let out: Vec> = r.batch(&[10.0, 11.0]); + assert_eq!(out[0], None); + assert_relative_eq!(out[1].unwrap(), 1.1, epsilon = 1e-12); + assert!(r.is_ready()); + } + + #[test] + fn constant_series_yields_one() { + let mut r = Rocr::new(3).unwrap(); + for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() { + assert_relative_eq!(*v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_reference_price_reports_zero() { + let mut r = Rocr::new(1).unwrap(); + let out: Vec> = r.batch(&[0.0, 5.0]); + assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn non_finite_input_holds_last() { + let mut r = Rocr::new(1).unwrap(); + assert_eq!(r.update(10.0), None); + let v = r.update(11.0).unwrap(); + assert_eq!(r.update(f64::INFINITY), Some(v)); + } + + #[test] + fn reset_clears_state() { + let mut r = Rocr::new(1).unwrap(); + let _ = r.batch(&[10.0, 11.0]); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(10.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/rocr100.rs b/crates/wickra-core/src/indicators/rocr100.rs new file mode 100644 index 0000000..3a0c9f9 --- /dev/null +++ b/crates/wickra-core/src/indicators/rocr100.rs @@ -0,0 +1,161 @@ +//! Rate of Change Ratio scaled by 100 (ROCR100). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rate of Change Ratio × 100 (`ROCR100`): `close / close[period] · 100`. +/// +/// The same ratio as [`Rocr`](crate::Rocr) rescaled so that an unchanged price +/// reads `100` rather than `1`: `> 100` is an advance, `< 100` a decline. Where +/// the reference price is zero the result is reported as `0`. +/// +/// Non-finite inputs are ignored and leave the window untouched; the last +/// computed value is returned instead, matching the SMA / EMA convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Rocr100}; +/// +/// let mut indicator = Rocr100::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rocr100 { + period: usize, + window: VecDeque, + last: Option, +} + +impl Rocr100 { + /// # 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 + 1), + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Rocr100 { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period + 1 { + return None; + } + let prev = *self.window.front().expect("non-empty"); + let rocr = if prev == 0.0 { + 0.0 + } else { + input / prev * 100.0 + }; + self.last = Some(rocr); + Some(rocr) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + 1 + } + + fn name(&self) -> &'static str { + "ROCR100" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Rocr100::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_report_config() { + let r = Rocr100::new(3).unwrap(); + assert_eq!(r.period(), 3); + assert_eq!(r.name(), "ROCR100"); + assert_eq!(r.warmup_period(), 4); + assert!(!r.is_ready()); + } + + #[test] + fn known_value_is_a_scaled_ratio() { + // period 1 over [10, 11]: 11 / 10 * 100 = 110. + let mut r = Rocr100::new(1).unwrap(); + let out: Vec> = r.batch(&[10.0, 11.0]); + assert_eq!(out[0], None); + assert_relative_eq!(out[1].unwrap(), 110.0, epsilon = 1e-12); + assert!(r.is_ready()); + } + + #[test] + fn constant_series_yields_hundred() { + let mut r = Rocr100::new(3).unwrap(); + for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() { + assert_relative_eq!(*v, 100.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_reference_price_reports_zero() { + let mut r = Rocr100::new(1).unwrap(); + let out: Vec> = r.batch(&[0.0, 5.0]); + assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn non_finite_input_holds_last() { + let mut r = Rocr100::new(1).unwrap(); + assert_eq!(r.update(10.0), None); + let v = r.update(11.0).unwrap(); + assert_eq!(r.update(f64::NEG_INFINITY), Some(v)); + } + + #[test] + fn reset_clears_state() { + let mut r = Rocr100::new(1).unwrap(); + let _ = r.batch(&[10.0, 11.0]); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(10.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/rogers_satchell.rs b/crates/wickra-core/src/indicators/rogers_satchell.rs new file mode 100644 index 0000000..0ea5e96 --- /dev/null +++ b/crates/wickra-core/src/indicators/rogers_satchell.rs @@ -0,0 +1,288 @@ +//! Rogers-Satchell Volatility (drift-free OHLC estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Rogers-Satchell Volatility — a drift-free OHLC realised-volatility +/// estimator. +/// +/// Rogers, Satchell & Yoon (1994) extended the Garman-Klass framework to +/// handle non-zero drift between bars without introducing the bias the +/// Garman-Klass estimator picks up in trending markets. The per-bar sample +/// is +/// +/// ```text +/// s_t = ln(H_t / C_t) · ln(H_t / O_t) + ln(L_t / C_t) · ln(L_t / O_t) +/// ``` +/// +/// and the indicator returns the annualised square root of the rolling +/// mean of `s_t`: +/// +/// ```text +/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100 +/// ``` +/// +/// The estimator is exact under a Brownian Motion with arbitrary drift — +/// the drift component cancels out algebraically. Each per-bar sample is +/// also guaranteed non-negative (both products contribute non-negative +/// terms by construction: `H >= O,C` and `L <= O,C`), so the rolling mean +/// cannot drift below zero except through FP cancellation. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, RogersSatchellVolatility}; +/// +/// let mut indicator = RogersSatchellVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RogersSatchellVolatility { + period: usize, + trading_periods: usize, + window: VecDeque, + sum: f64, + last: Option, +} + +impl RogersSatchellVolatility { + /// Construct a Rogers-Satchell Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`. + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + trading_periods, + window: VecDeque::with_capacity(period), + sum: 0.0, + last: None, + }) + } + + /// Configured `(period, trading_periods)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.trading_periods) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for RogersSatchellVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // `Candle::new` guarantees finite, positive OHLC with `high >= + // max(open, low, close)` and `low <= min(open, high, close)`. The + // factors below thus have predictable signs: + // ln(H/C) >= 0, ln(H/O) >= 0, ln(L/C) <= 0, ln(L/O) <= 0 + // so both products are non-negative and the per-bar sample is + // guaranteed `>= 0` by construction. + let log_hc = (candle.high / candle.close).ln(); + let log_ho = (candle.high / candle.open).ln(); + let log_lc = (candle.low / candle.close).ln(); + let log_lo = (candle.low / candle.open).ln(); + let sample = log_hc.mul_add(log_ho, log_lc * log_lo); + + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + } + self.window.push_back(sample); + self.sum += sample; + + if self.window.len() < self.period { + return None; + } + + let n = self.period as f64; + // The clamp absorbs FP cancellation; the mathematical value is + // already `>= 0` by the sign argument above. + let variance = (self.sum / n).max(0.0); + let sigma = variance.sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "RogersSatchellVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(o, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + RogersSatchellVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + RogersSatchellVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let rs = RogersSatchellVolatility::new(20, 252).unwrap(); + assert_eq!(rs.periods(), (20, 252)); + assert_eq!(rs.value(), None); + assert_eq!(rs.warmup_period(), 20); + assert_eq!(rs.name(), "RogersSatchellVolatility"); + assert!(!rs.is_ready()); + } + + #[test] + fn zero_movement_yields_zero() { + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + let mut rs = RogersSatchellVolatility::new(14, 1).unwrap(); + for v in rs.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_bar_shape_yields_constant_sigma() { + // Each bar has identical OHLC -> per-bar sample is a constant `k`. + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let log_hc = (11.0_f64 / 10.5_f64).ln(); + let log_ho = (11.0_f64 / 10.0_f64).ln(); + let log_lc = (9.0_f64 / 10.5_f64).ln(); + let log_lo = (9.0_f64 / 10.0_f64).ln(); + let k = log_hc * log_ho + log_lc * log_lo; + let expected = k.max(0.0).sqrt() * 100.0; + + let mut rs = RogersSatchellVolatility::new(10, 1).unwrap(); + let out = rs.batch(&candles); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut rs = RogersSatchellVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + let open = base - 0.1; + let close = base + 0.2; + candle(open, base + half, base - half, close, i64::from(i)) + }) + .collect(); + for v in rs.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Rogers-Satchell must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle(base, base + half, base - half, base + 0.3, i64::from(i)) + }) + .collect(); + let raw = RogersSatchellVolatility::new(10, 1) + .unwrap() + .batch(&candles); + let annual = RogersSatchellVolatility::new(10, 252) + .unwrap() + .batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let mut rs = RogersSatchellVolatility::new(5, 1).unwrap(); + let out = rs.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle(base, base + half, base - half, base + 0.5, i64::from(i)) + }) + .collect(); + let batch = RogersSatchellVolatility::new(14, 252) + .unwrap() + .batch(&candles); + let mut streamer = RogersSatchellVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let mut rs = RogersSatchellVolatility::new(14, 252).unwrap(); + rs.batch(&candles); + assert!(rs.is_ready()); + rs.reset(); + assert!(!rs.is_ready()); + assert_eq!(rs.value(), None); + assert_eq!(rs.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/roll_measure.rs b/crates/wickra-core/src/indicators/roll_measure.rs new file mode 100644 index 0000000..55a32eb --- /dev/null +++ b/crates/wickra-core/src/indicators/roll_measure.rs @@ -0,0 +1,210 @@ +//! Roll Measure — effective spread implied by serial covariance of price changes. + +use std::collections::VecDeque; + +use crate::microstructure::Trade; +use crate::traits::Indicator; +use crate::{Error, Result}; + +/// Roll Measure — the effective bid-ask spread implied by the negative +/// first-order serial covariance of trade-price changes (Roll, 1984). +/// +/// ```text +/// Δpₜ = priceₜ − priceₜ₋₁ +/// γ = sample lag-1 autocovariance of Δp over the last `period` changes +/// spread = 2 · √(−γ) if γ < 0, else 0 +/// ``` +/// +/// Roll's insight: in a frictionless market price changes are serially +/// uncorrelated, but the *bid-ask bounce* — trades alternating between buying at +/// the ask and selling at the bid — induces a **negative** autocovariance whose +/// magnitude pins the spread. The measure recovers an effective spread from +/// trade prices alone, with no quote data. When the serial covariance is +/// non-negative (a trending or frictionless tape) the model implies no spread +/// and the indicator returns `0`. +/// +/// `Input = Trade` (only the price is used). Each `update` is `O(period)`: the +/// autocovariance is recomputed from the window of price changes. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Side, Trade, RollMeasure}; +/// +/// let mut roll = RollMeasure::new(20).unwrap(); +/// let mut last = None; +/// // A clean bid-ask bounce of ±0.5 around 100 implies a spread near 1.0. +/// for i in 0..40 { +/// let price = if i % 2 == 0 { 100.0 } else { 101.0 }; +/// last = roll.update(Trade::new(price, 1.0, Side::Buy, 0).unwrap()); +/// } +/// assert!(last.unwrap() > 0.0); +/// ``` +#[derive(Debug, Clone)] +pub struct RollMeasure { + period: usize, + prev_price: Option, + window: VecDeque, +} + +impl RollMeasure { + /// Construct a new Roll Measure over the given window of price changes. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` — the lag-1 + /// autocovariance needs at least two consecutive change pairs. + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "Roll measure needs period >= 3", + }); + } + Ok(Self { + period, + prev_price: None, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RollMeasure { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + let Some(prev) = self.prev_price else { + self.prev_price = Some(trade.price); + return None; + }; + let change = trade.price - prev; + self.prev_price = Some(trade.price); + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(change); + if self.window.len() < self.period { + return None; + } + // Sample lag-1 autocovariance of the price changes over the window. + let changes: Vec = self.window.iter().copied().collect(); + let count = changes.len() as f64; + let mean = changes.iter().sum::() / count; + let pairs = (changes.len() - 1) as f64; + let mut cov = 0.0; + for pair in changes.windows(2) { + cov += (pair[0] - mean) * (pair[1] - mean); + } + cov /= pairs; + let spread = if cov < 0.0 { 2.0 * (-cov).sqrt() } else { 0.0 }; + Some(spread) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "RollMeasure" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn trade(price: f64) -> Trade { + Trade::new(price, 1.0, Side::Buy, 0).unwrap() + } + + #[test] + fn rejects_period_below_three() { + assert!(matches!( + RollMeasure::new(2), + Err(Error::InvalidPeriod { .. }) + )); + assert!(RollMeasure::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let roll = RollMeasure::new(20).unwrap(); + assert_eq!(roll.period(), 20); + assert_eq!(roll.warmup_period(), 21); + assert_eq!(roll.name(), "RollMeasure"); + assert!(!roll.is_ready()); + } + + #[test] + fn bid_ask_bounce_implies_spread() { + // Prices bounce 100/101 => Δp alternates +1/-1 => mean 0, lag-1 + // autocov = -5/(6-1) = -1 over a 6-change window => spread = 2. + let mut roll = RollMeasure::new(6).unwrap(); + let prices: Vec = (0..20) + .map(|i| trade(if i % 2 == 0 { 100.0 } else { 101.0 })) + .collect(); + let last = roll.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 2.0, epsilon = 1e-12); + } + + #[test] + fn trending_prices_imply_no_spread() { + // Monotone prices => constant Δp => zero-centred deviations => cov 0 + // => spread 0. + let mut roll = RollMeasure::new(6).unwrap(); + let prices: Vec = (0..20).map(|i| trade(100.0 + f64::from(i))).collect(); + for v in roll.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut roll = RollMeasure::new(20).unwrap(); + let prices: Vec = (0..200) + .map(|i| trade(100.0 + (f64::from(i) * 0.7).sin() * 2.0)) + .collect(); + for v in roll.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "spread must be non-negative, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut roll = RollMeasure::new(5).unwrap(); + for i in 0..20 { + roll.update(trade(100.0 + f64::from(i % 2))); + } + assert!(roll.is_ready()); + roll.reset(); + assert!(!roll.is_ready()); + assert_eq!(roll.update(trade(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| trade(100.0 + (f64::from(i) * 0.6).sin() * 3.0)) + .collect(); + let batch = RollMeasure::new(14).unwrap().batch(&prices); + let mut b = RollMeasure::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|t| b.update(*t)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_correlation.rs b/crates/wickra-core/src/indicators/rolling_correlation.rs new file mode 100644 index 0000000..50e173f --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_correlation.rs @@ -0,0 +1,289 @@ +//! Rolling Pearson correlation of the period-over-period *returns* of two series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling correlation of the **returns** of two synchronised series. +/// +/// Where [`crate::PearsonCorrelation`] correlates the raw *levels* `(x, y)`, +/// this indicator first differences each channel into a one-step return and +/// correlates those returns over the trailing window: +/// +/// ```text +/// rxₜ = xₜ − xₜ₋₁ ryₜ = yₜ − yₜ₋₁ +/// corr = cov(rx, ry) / √(var(rx) · var(ry)) +/// ``` +/// +/// Return correlation is the quantity that matters for hedging and portfolio +/// risk: two assets can trend together (high level correlation) while their +/// day-to-day moves are nearly independent (low return correlation). The output +/// is in `[−1, +1]`; a flat return channel makes the ratio undefined and the +/// indicator reports `0` rather than `NaN`. The value is clamped to `[−1, +1]` +/// to absorb tiny floating-point overshoots near the boundaries. +/// +/// Each `update` is O(1): the five running sums (`Σrx`, `Σry`, `Σrx²`, `Σry²`, +/// `Σrxry`) are maintained as the window of returns slides. The first level in +/// each channel produces no return, so a `period`-pair correlation needs +/// `period + 1` updates of warmup. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingCorrelation}; +/// +/// let mut rc = RollingCorrelation::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// // A varying path where y always moves with x ⇒ return correlation +1. +/// let x = (f64::from(i) * 0.5).sin() * 10.0; +/// last = rc.update((x, 2.0 * x)); +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingCorrelation { + period: usize, + prev: Option<(f64, f64)>, + window: VecDeque<(f64, f64)>, + sum_x: f64, + sum_y: f64, + sum_xx: f64, + sum_yy: f64, + sum_xy: f64, +} + +impl RollingCorrelation { + /// Construct a new rolling return-correlation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is + /// undefined for fewer than two return pairs. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "rolling correlation needs period >= 2", + }); + } + Ok(Self { + period, + prev: None, + window: VecDeque::with_capacity(period), + sum_x: 0.0, + sum_y: 0.0, + sum_xx: 0.0, + sum_yy: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured window of returns. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RollingCorrelation { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } + let Some((px, py)) = self.prev else { + // First level in each channel: store it, no return yet. + self.prev = Some((x, y)); + return None; + }; + self.prev = Some((x, y)); + let (rx, ry) = (x - px, y - py); + if self.window.len() == self.period { + let (ox, oy) = self.window.pop_front().expect("non-empty"); + self.sum_x -= ox; + self.sum_y -= oy; + self.sum_xx -= ox * ox; + self.sum_yy -= oy * oy; + self.sum_xy -= ox * oy; + } + self.window.push_back((rx, ry)); + self.sum_x += rx; + self.sum_y += ry; + self.sum_xx += rx * rx; + self.sum_yy += ry * ry; + self.sum_xy += rx * ry; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_x = self.sum_x / n; + let mean_y = self.sum_y / n; + let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0); + let var_y = (self.sum_yy / n - mean_y * mean_y).max(0.0); + let cov = self.sum_xy / n - mean_x * mean_y; + let denom = (var_x * var_y).sqrt(); + if denom == 0.0 { + // At least one return channel is flat: correlation is undefined. + return Some(0.0); + } + Some((cov / denom).clamp(-1.0, 1.0)) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum_x = 0.0; + self.sum_y = 0.0; + self.sum_xx = 0.0; + self.sum_yy = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "RollingCorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(RollingCorrelation::new(0).is_err()); + assert!(RollingCorrelation::new(1).is_err()); + assert!(RollingCorrelation::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let rc = RollingCorrelation::new(14).unwrap(); + assert_eq!(rc.period(), 14); + assert_eq!(rc.warmup_period(), 15); + assert_eq!(rc.name(), "RollingCorrelation"); + assert!(!rc.is_ready()); + } + + #[test] + fn warmup_needs_period_plus_one() { + let mut rc = RollingCorrelation::new(3).unwrap(); + // First update only seeds the previous level ⇒ None. + assert_eq!(rc.update((1.0, 1.0)), None); + assert_eq!(rc.update((2.0, 3.0)), None); // 1 return + assert_eq!(rc.update((3.0, 5.0)), None); // 2 returns + assert!(rc.update((4.0, 7.0)).is_some()); // 3 returns ⇒ ready + assert!(rc.is_ready()); + } + + #[test] + fn comoving_returns_are_plus_one() { + // y always moves by 2x x's move ⇒ perfectly correlated returns. + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| { + let x = (f64::from(i) * 0.5).sin() * 10.0; + (x, 2.0 * x + 100.0) + }) + .collect(); + let last = RollingCorrelation::new(8) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn opposing_returns_are_minus_one() { + let pairs: Vec<(f64, f64)> = (0..20) + .map(|i| { + let x = (f64::from(i) * 0.5).sin() * 10.0; + (x, -1.5 * x + 50.0) + }) + .collect(); + let last = RollingCorrelation::new(8) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn flat_return_channel_yields_zero() { + // y is constant ⇒ its returns are all zero ⇒ undefined ⇒ 0. + let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect(); + let last = RollingCorrelation::new(6) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0) + }) + .collect(); + let mut rc = RollingCorrelation::new(20).unwrap(); + for v in rc.batch(&pairs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut rc = RollingCorrelation::new(4).unwrap(); + rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(rc.is_ready()); + rc.reset(); + assert!(!rc.is_ready()); + assert_eq!(rc.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin(), (t * 0.5).cos()) + }) + .collect(); + let batch = RollingCorrelation::new(14).unwrap().batch(&pairs); + let mut rc = RollingCorrelation::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut rc = RollingCorrelation::new(2).unwrap(); + assert_eq!(rc.update((f64::NAN, 1.0)), None); + assert_eq!(rc.update((1.0, f64::INFINITY)), None); + // First finite tick seeds prev; two more returns fill the window. + assert_eq!(rc.update((1.0, 1.0)), None); + assert_eq!(rc.update((2.0, 3.0)), None); + assert!(rc.update((3.0, 5.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_covariance.rs b/crates/wickra-core/src/indicators/rolling_covariance.rs new file mode 100644 index 0000000..80b8b5c --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_covariance.rs @@ -0,0 +1,257 @@ +//! Rolling covariance of the period-over-period *returns* of two series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling covariance of the **returns** of two synchronised series. +/// +/// Each `update` takes one `(x, y)` level pair, differences each channel into a +/// one-step return, and reports the population covariance of those returns over +/// the trailing window of `period` return pairs: +/// +/// ```text +/// rxₜ = xₜ − xₜ₋₁ ryₜ = yₜ − yₜ₋₁ +/// cov = (1/n) · Σ rx·ry − r̄x · r̄y +/// ``` +/// +/// Unlike [`crate::RollingCorrelation`] the result is **not** normalised to +/// `[−1, 1]`: it carries the units of the two return streams multiplied +/// together, so it scales with volatility. It is the raw building block behind +/// correlation, beta and portfolio variance — positive when the two return +/// streams tend to move the same way, negative when they offset. +/// +/// Each `update` is O(1): three running sums (`Σrx`, `Σry`, `Σrxry`) are +/// maintained as the window slides. The first level in each channel produces no +/// return, so a `period`-pair covariance needs `period + 1` updates of warmup. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingCovariance}; +/// +/// let mut rc = RollingCovariance::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let x = f64::from(i); +/// last = rc.update((x, 3.0 * x)); // y's return is 3× x's return +/// } +/// // cov(rx, ry) = cov(1, 3) over constant unit returns = 3 · var(rx) = 0 +/// // for a constant return; use a varying path in practice. Here returns are +/// // constant (1 and 3) ⇒ covariance 0. +/// assert!(last.unwrap().abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingCovariance { + period: usize, + prev: Option<(f64, f64)>, + window: VecDeque<(f64, f64)>, + sum_x: f64, + sum_y: f64, + sum_xy: f64, +} + +impl RollingCovariance { + /// Construct a new rolling return-covariance. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — covariance is + /// undefined for fewer than two return pairs. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "rolling covariance needs period >= 2", + }); + } + Ok(Self { + period, + prev: None, + window: VecDeque::with_capacity(period), + sum_x: 0.0, + sum_y: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured window of returns. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RollingCovariance { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } + let Some((px, py)) = self.prev else { + self.prev = Some((x, y)); + return None; + }; + self.prev = Some((x, y)); + let (rx, ry) = (x - px, y - py); + if self.window.len() == self.period { + let (ox, oy) = self.window.pop_front().expect("non-empty"); + self.sum_x -= ox; + self.sum_y -= oy; + self.sum_xy -= ox * oy; + } + self.window.push_back((rx, ry)); + self.sum_x += rx; + self.sum_y += ry; + self.sum_xy += rx * ry; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_x = self.sum_x / n; + let mean_y = self.sum_y / n; + Some(self.sum_xy / n - mean_x * mean_y) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum_x = 0.0; + self.sum_y = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "RollingCovariance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(RollingCovariance::new(0).is_err()); + assert!(RollingCovariance::new(1).is_err()); + assert!(RollingCovariance::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let rc = RollingCovariance::new(14).unwrap(); + assert_eq!(rc.period(), 14); + assert_eq!(rc.warmup_period(), 15); + assert_eq!(rc.name(), "RollingCovariance"); + assert!(!rc.is_ready()); + } + + #[test] + fn warmup_needs_period_plus_one() { + let mut rc = RollingCovariance::new(3).unwrap(); + assert_eq!(rc.update((1.0, 1.0)), None); + assert_eq!(rc.update((2.0, 3.0)), None); + assert_eq!(rc.update((3.0, 5.0)), None); + assert!(rc.update((4.0, 7.0)).is_some()); + assert!(rc.is_ready()); + } + + #[test] + fn hand_computed_value() { + // Levels x = 0,1,3,6,10 ⇒ returns 1,2,3,4; y = 2x ⇒ returns 2,4,6,8. + // With period = 3 the final window is rx = [2,3,4], ry = [4,6,8]: + // Σrx·ry/3 = 58/3, r̄x·r̄y = 3·6 = 18 ⇒ cov = 58/3 − 18 = 4/3. + let pairs = [ + (0.0, 0.0), + (1.0, 2.0), + (3.0, 6.0), + (6.0, 12.0), + (10.0, 20.0), + ]; + let last = RollingCovariance::new(3) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 4.0 / 3.0, epsilon = 1e-9); + } + + #[test] + fn opposing_returns_give_negative_covariance() { + let pairs: Vec<(f64, f64)> = (0..30) + .map(|i| { + let x = (f64::from(i) * 0.4).sin() * 10.0; + (x, -x) + }) + .collect(); + let last = RollingCovariance::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last < 0.0, "cov {last}"); + } + + #[test] + fn flat_channel_gives_zero() { + let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect(); + let last = RollingCovariance::new(6) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut rc = RollingCovariance::new(4).unwrap(); + rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 1.0), (4.0, 9.0), (5.0, 2.0)]); + assert!(rc.is_ready()); + rc.reset(); + assert!(!rc.is_ready()); + assert_eq!(rc.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin() * 4.0, (t * 0.5).cos() * 2.0) + }) + .collect(); + let batch = RollingCovariance::new(12).unwrap().batch(&pairs); + let mut rc = RollingCovariance::new(12).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut rc = RollingCovariance::new(2).unwrap(); + assert_eq!(rc.update((f64::NAN, 1.0)), None); + assert_eq!(rc.update((1.0, f64::INFINITY)), None); + // First finite tick seeds prev; two more returns fill the window. + assert_eq!(rc.update((1.0, 1.0)), None); + assert_eq!(rc.update((2.0, 3.0)), None); + assert!(rc.update((3.0, 5.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_iqr.rs b/crates/wickra-core/src/indicators/rolling_iqr.rs new file mode 100644 index 0000000..eddcd67 --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_iqr.rs @@ -0,0 +1,189 @@ +//! Rolling Interquartile Range (IQR) over a trailing window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rolling_quantile::quantile_sorted; +use crate::traits::Indicator; + +/// Interquartile Range of the last `period` values: `Q3 − Q1`. +/// +/// ```text +/// IQR = quantile(0.75) − quantile(0.25) +/// ``` +/// +/// The IQR is the width of the central 50% of the window — the spread between +/// the third and first quartiles. It is a robust dispersion measure: unlike the +/// standard deviation it ignores the extreme tails entirely, so a single spike +/// barely moves it. That makes it the natural scale for outlier rules (the +/// classic *Tukey fence* flags points more than `1.5 · IQR` beyond a quartile) +/// and for volatility-regime splits that must not be dominated by one shock. +/// +/// Both quartiles use the type-7 / NumPy-default linearly-interpolated +/// definition, identical to [`RollingQuantile`](crate::RollingQuantile). Each +/// `update` is O(period log period): the window is copied into a scratch buffer +/// and sorted once. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingIqr}; +/// +/// let mut indicator = RollingIqr::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingIqr { + period: usize, + window: VecDeque, + /// Reusable scratch buffer to avoid allocating per `update`. + scratch: Vec, +} + +impl RollingIqr { + /// Construct a new rolling IQR with the given period. + /// + /// # 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), + scratch: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RollingIqr { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + self.scratch.clear(); + self.scratch.extend(self.window.iter().copied()); + self.scratch.sort_by(f64::total_cmp); + let q1 = quantile_sorted(&self.scratch, 0.25); + let q3 = quantile_sorted(&self.scratch, 0.75); + Some(q3 - q1) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "RollingIqr" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(RollingIqr::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let iqr = RollingIqr::new(14).unwrap(); + assert_eq!(iqr.period(), 14); + assert_eq!(iqr.warmup_period(), 14); + assert_eq!(iqr.name(), "RollingIqr"); + assert!(!iqr.is_ready()); + } + + #[test] + fn reference_value() { + // sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20, + // Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20. + let mut iqr = RollingIqr::new(5).unwrap(); + let out = iqr.batch(&[50.0, 40.0, 30.0, 20.0, 10.0]); + assert_relative_eq!(out[4].unwrap(), 20.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut iqr = RollingIqr::new(8).unwrap(); + for v in iqr.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut iqr = RollingIqr::new(20).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in iqr.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "IQR must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_single_extreme_outlier() { + // 19 tightly-clustered values plus one huge spike: the central 50% + // is unaffected, so the IQR stays small (well below the spike scale). + let mut iqr = RollingIqr::new(20).unwrap(); + let mut prices = vec![5.0; 19]; + prices.push(10_000.0); + let last = iqr.batch(&prices).into_iter().flatten().last().unwrap(); + assert!(last < 1.0, "spike leaked into IQR: {last}"); + } + + #[test] + fn reset_clears_state() { + let mut iqr = RollingIqr::new(5).unwrap(); + iqr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(iqr.is_ready()); + iqr.reset(); + assert!(!iqr.is_ready()); + assert_eq!(iqr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = RollingIqr::new(14).unwrap().batch(&prices); + let mut b = RollingIqr::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_min_max_scaler.rs b/crates/wickra-core/src/indicators/rolling_min_max_scaler.rs new file mode 100644 index 0000000..5a365f0 --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_min_max_scaler.rs @@ -0,0 +1,261 @@ +//! Rolling Min-Max Scaler — normalises the latest value to `[0, 1]` over a window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Min-Max Scaler — maps the current value onto `[0, 1]` relative to the +/// minimum and maximum of the trailing window. +/// +/// ```text +/// scaled = (x − min(window)) / (max(window) − min(window)) +/// ``` +/// +/// This is the streaming form of scikit-learn's `MinMaxScaler` applied over a +/// sliding window: `0` means the value is the lowest in the window, `1` the +/// highest, `0.5` the midpoint of the range. It is the engine behind oscillators +/// like the Stochastic %K and a handy normaliser for feeding any indicator into a +/// bounded model input. Because it rescales to the window's own range it is +/// scale-free across instruments. +/// +/// The output is in `[0, 1]`. A flat window (`max == min`) has no range to scale +/// against and returns the neutral `0.5`. The first value lands after `period` +/// inputs; each `update` scans the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingMinMaxScaler}; +/// +/// let mut indicator = RollingMinMaxScaler::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingMinMaxScaler { + period: usize, + window: VecDeque, + last: Option, +} + +impl RollingMinMaxScaler { + /// Construct a rolling min-max scaler over `period` values. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::InvalidPeriod`] if `period < 2` (a range needs two points). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "min-max scaler needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for RollingMinMaxScaler { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &v in &self.window { + min = min.min(v); + max = max.max(v); + } + let range = max - min; + let scaled = if range > 0.0 { + (input - min) / range + } else { + 0.5 + }; + self.last = Some(scaled); + Some(scaled) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "RollingMinMaxScaler" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_period() { + assert!(matches!( + RollingMinMaxScaler::new(0), + Err(Error::PeriodZero) + )); + assert!(matches!( + RollingMinMaxScaler::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(RollingMinMaxScaler::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = RollingMinMaxScaler::new(14).unwrap(); + assert_eq!(s.period(), 14); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "RollingMinMaxScaler"); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let out = s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn highest_in_window_is_one() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + // last value is the highest -> 1.0. + let last = s + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-12); + } + + #[test] + fn lowest_in_window_is_zero() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let last = s + .batch(&[4.0, 3.0, 2.0, 1.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn midpoint_is_half() { + let mut s = RollingMinMaxScaler::new(3).unwrap(); + // window [0, 2, 1]: min 0, max 2, current 1 -> 0.5. + let last = s + .batch(&[0.0, 2.0, 1.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-12); + } + + #[test] + fn flat_window_is_half() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let last = s.batch(&[7.0; 8]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut s = RollingMinMaxScaler::new(14).unwrap(); + for v in s + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!((0.0..=1.0).contains(&v)); + } + } + + #[test] + fn ignores_non_finite() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + let ready = s + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(s.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut s = RollingMinMaxScaler::new(4).unwrap(); + s.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + assert_eq!(s.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = RollingMinMaxScaler::new(14).unwrap().batch(&xs); + let mut b = RollingMinMaxScaler::new(14).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_percentile_rank.rs b/crates/wickra-core/src/indicators/rolling_percentile_rank.rs new file mode 100644 index 0000000..512c7c9 --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_percentile_rank.rs @@ -0,0 +1,194 @@ +//! Rolling Percentile Rank of the latest value within its trailing window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Percentile rank of the most-recent value within the last `period` values, +/// in `[0, 100]`. +/// +/// ```text +/// rank = 100 · (#below + 0.5 · #equal) / period +/// ``` +/// +/// where `#below` counts window values strictly less than the current value and +/// `#equal` counts those equal to it (including the current value itself). This +/// is the "mean" method of `percentileofscore`: ties are split symmetrically, +/// so a flat window scores exactly `50`, the strict window maximum scores just +/// under `100`, and the strict minimum just over `0`. +/// +/// Percentile rank turns any series into a bounded, self-normalising oscillator: +/// "where does today sit relative to its own recent history" — high readings +/// mark stretched extremes, mid readings mark the typical range. It is the +/// scale-free cousin of the z-score that makes no distributional assumption. +/// +/// Each `update` is O(period): one linear pass tallies the comparisons. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingPercentileRank}; +/// +/// let mut indicator = RollingPercentileRank::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// // A strictly rising series puts the newest value near the top. +/// assert!(last.unwrap() > 90.0); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingPercentileRank { + period: usize, + window: VecDeque, +} + +impl RollingPercentileRank { + /// Construct a new rolling percentile rank with the given period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RollingPercentileRank { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let mut below = 0_usize; + let mut equal = 0_usize; + for &x in &self.window { + if x < value { + below += 1; + } else if x == value { + equal += 1; + } + } + let score = (below as f64 + 0.5 * equal as f64) / self.period as f64 * 100.0; + Some(score) + } + + 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 { + "RollingPercentileRank" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + RollingPercentileRank::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let pr = RollingPercentileRank::new(14).unwrap(); + assert_eq!(pr.period(), 14); + assert_eq!(pr.warmup_period(), 14); + assert_eq!(pr.name(), "RollingPercentileRank"); + assert!(!pr.is_ready()); + } + + #[test] + fn flat_window_scores_fifty() { + // All values equal: #below = 0, #equal = period → 0.5 → 50. + let mut pr = RollingPercentileRank::new(10).unwrap(); + for v in pr.batch(&[7.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn current_is_strict_maximum() { + // Window [1,2,3,4,5], current = 5: #below = 4, #equal = 1. + // (4 + 0.5) / 5 * 100 = 90. + let mut pr = RollingPercentileRank::new(5).unwrap(); + let out = pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_relative_eq!(out[4].unwrap(), 90.0, epsilon = 1e-12); + } + + #[test] + fn current_is_strict_minimum() { + // Window [5,4,3,2,1], current = 1: #below = 0, #equal = 1. + // (0 + 0.5) / 5 * 100 = 10. + let mut pr = RollingPercentileRank::new(5).unwrap(); + let out = pr.batch(&[5.0, 4.0, 3.0, 2.0, 1.0]); + assert_relative_eq!(out[4].unwrap(), 10.0, epsilon = 1e-12); + } + + #[test] + fn output_within_bounds() { + let mut pr = RollingPercentileRank::new(20).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in pr.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "out of bounds: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut pr = RollingPercentileRank::new(5).unwrap(); + pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(pr.is_ready()); + pr.reset(); + assert!(!pr.is_ready()); + assert_eq!(pr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = RollingPercentileRank::new(14).unwrap().batch(&prices); + let mut b = RollingPercentileRank::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/rolling_quantile.rs b/crates/wickra-core/src/indicators/rolling_quantile.rs new file mode 100644 index 0000000..b091efd --- /dev/null +++ b/crates/wickra-core/src/indicators/rolling_quantile.rs @@ -0,0 +1,233 @@ +//! Rolling Quantile over a trailing window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// The `quantile`-th quantile of the last `period` values, with linear +/// interpolation between order statistics. +/// +/// ```text +/// h = (period − 1) · quantile +/// lower = ⌊h⌋ +/// result = sorted[lower] + (h − lower) · (sorted[lower + 1] − sorted[lower]) +/// ``` +/// +/// This is the type-7 / NumPy-default `quantile` definition: `quantile = 0.0` +/// returns the window minimum, `0.5` the median, `1.0` the maximum, and +/// fractional values interpolate linearly between the bracketing order +/// statistics. Rolling quantiles are the building block for distribution-aware +/// thresholds — a price sitting above its rolling 90th-percentile, a volatility +/// regime split at the 25th/75th percentiles, robust band edges that ignore the +/// tails. +/// +/// Each `update` is O(period log period): the window is copied into a scratch +/// buffer and sorted with total ordering (NaN-safe). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RollingQuantile}; +/// +/// // Rolling median of the last 5 values. +/// let mut indicator = RollingQuantile::new(5, 0.5).unwrap(); +/// let out = indicator.update(1.0); +/// assert!(out.is_none()); // warming up +/// ``` +#[derive(Debug, Clone)] +pub struct RollingQuantile { + period: usize, + quantile: f64, + window: VecDeque, + /// Reusable scratch buffer to avoid allocating per `update`. + scratch: Vec, +} + +impl RollingQuantile { + /// Construct a new rolling quantile. + /// + /// `quantile` selects the order statistic in `[0.0, 1.0]`. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidParameter`] if `quantile` is not a finite value in + /// `[0.0, 1.0]`. + pub fn new(period: usize, quantile: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !quantile.is_finite() || !(0.0..=1.0).contains(&quantile) { + return Err(Error::InvalidParameter { + message: "rolling quantile must be a finite value in [0.0, 1.0]", + }); + } + Ok(Self { + period, + quantile, + window: VecDeque::with_capacity(period), + scratch: Vec::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured quantile in `[0.0, 1.0]`. + pub const fn quantile(&self) -> f64 { + self.quantile + } +} + +/// Linearly-interpolated quantile of a sorted, non-empty slice (type-7). +pub(crate) fn quantile_sorted(sorted: &[f64], quantile: f64) -> f64 { + let n = sorted.len(); + if n == 1 { + return sorted[0]; + } + let h = (n - 1) as f64 * quantile; + let lower = h.floor(); + let idx = lower as usize; + // `idx <= n - 1`: when `quantile == 1.0`, `h == n - 1` and `idx == n - 1`, + // so the interpolation neighbour would be out of bounds — return the top. + if idx >= n - 1 { + return sorted[n - 1]; + } + let frac = h - lower; + sorted[idx] + frac * (sorted[idx + 1] - sorted[idx]) +} + +impl Indicator for RollingQuantile { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + self.scratch.clear(); + self.scratch.extend(self.window.iter().copied()); + self.scratch.sort_by(f64::total_cmp); + Some(quantile_sorted(&self.scratch, self.quantile)) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "RollingQuantile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + RollingQuantile::new(0, 0.5), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_out_of_range_quantile() { + assert!(matches!( + RollingQuantile::new(5, -0.1), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + RollingQuantile::new(5, 1.1), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + RollingQuantile::new(5, f64::NAN), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let q = RollingQuantile::new(14, 0.25).unwrap(); + assert_eq!(q.period(), 14); + assert_relative_eq!(q.quantile(), 0.25, epsilon = 1e-12); + assert_eq!(q.warmup_period(), 14); + assert_eq!(q.name(), "RollingQuantile"); + assert!(!q.is_ready()); + } + + #[test] + fn median_of_window() { + // Window [5, 1, 3, 2, 4] sorted [1,2,3,4,5] → median 3. + let mut q = RollingQuantile::new(5, 0.5).unwrap(); + let out = q.batch(&[5.0, 1.0, 3.0, 2.0, 4.0]); + assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12); + } + + #[test] + fn min_and_max_quantiles() { + let prices = [5.0, 1.0, 3.0, 2.0, 4.0]; + let lo = RollingQuantile::new(5, 0.0).unwrap().batch(&prices)[4].unwrap(); + let hi = RollingQuantile::new(5, 1.0).unwrap().batch(&prices)[4].unwrap(); + assert_relative_eq!(lo, 1.0, epsilon = 1e-12); + assert_relative_eq!(hi, 5.0, epsilon = 1e-12); + } + + #[test] + fn interpolated_quantile() { + // sorted [10,20,30,40]: q=0.25 → h=(4-1)*0.25=0.75 → 10 + 0.75*(20-10)=17.5. + let mut q = RollingQuantile::new(4, 0.25).unwrap(); + let out = q.batch(&[40.0, 30.0, 20.0, 10.0]); + assert_relative_eq!(out[3].unwrap(), 17.5, epsilon = 1e-12); + } + + #[test] + fn single_period_returns_value() { + // period 1: window holds one value; quantile of a singleton is itself. + let mut q = RollingQuantile::new(1, 0.3).unwrap(); + assert_relative_eq!(q.update(7.0).unwrap(), 7.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut q = RollingQuantile::new(5, 0.5).unwrap(); + q.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(q.is_ready()); + q.reset(); + assert!(!q.is_ready()); + assert_eq!(q.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = RollingQuantile::new(14, 0.75).unwrap().batch(&prices); + let mut b = RollingQuantile::new(14, 0.75).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/roofing_filter.rs b/crates/wickra-core/src/indicators/roofing_filter.rs new file mode 100644 index 0000000..01190b0 --- /dev/null +++ b/crates/wickra-core/src/indicators/roofing_filter.rs @@ -0,0 +1,202 @@ +//! Ehlers Roofing Filter (high-pass followed by SuperSmoother). +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' Roofing Filter — a bandpass formed by feeding a 2-pole high-pass +/// into a [`SuperSmoother`]. +/// +/// Defined in *Cycle Analytics for Traders* (Ehlers 2013, ch. 7) as the +/// canonical pre-filter for cycle-aware oscillators: the high-pass strips out +/// the trend (periods longer than `hp_period`), and the SuperSmoother removes +/// noise (periods shorter than `lp_period`). The result is essentially the +/// 10–48 bar cycle band by default. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RoofingFilter}; +/// +/// let mut rf = RoofingFilter::new(10, 48).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = rf.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RoofingFilter { + lp_period: usize, + hp_period: usize, + alpha: f64, + prev_in_1: Option, + prev_hp_1: f64, + prev_hp_2: f64, + smoother: SuperSmoother, + last_value: Option, +} + +impl RoofingFilter { + /// Construct with `lp_period` (SuperSmoother critical period) and + /// `hp_period` (high-pass cutoff). Defaults in Ehlers are `(10, 48)`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is zero, and + /// [`Error::InvalidPeriod`] if `lp_period >= hp_period`. + pub fn new(lp_period: usize, hp_period: usize) -> Result { + if lp_period == 0 || hp_period == 0 { + return Err(Error::PeriodZero); + } + if lp_period >= hp_period { + return Err(Error::InvalidPeriod { + message: "lp_period must be strictly less than hp_period", + }); + } + // Single-pole high-pass alpha from Ehlers ch. 7. + let arg = 2.0 * PI / hp_period as f64; + let alpha = (arg.cos() + arg.sin() - 1.0) / arg.cos(); + Ok(Self { + lp_period, + hp_period, + alpha, + prev_in_1: None, + prev_hp_1: 0.0, + prev_hp_2: 0.0, + smoother: SuperSmoother::new(lp_period)?, + last_value: None, + }) + } + + /// Configured `(lp_period, hp_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.lp_period, self.hp_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for RoofingFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + let hp = if let Some(x1) = self.prev_in_1 { + let one_minus_half_alpha = 1.0 - self.alpha / 2.0; + one_minus_half_alpha * (input - x1) + (1.0 - self.alpha) * self.prev_hp_1 + } else { + 0.0 + }; + self.prev_hp_2 = self.prev_hp_1; + self.prev_hp_1 = hp; + self.prev_in_1 = Some(input); + let v = self.smoother.update(hp)?; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev_in_1 = None; + self.prev_hp_1 = 0.0; + self.prev_hp_2 = 0.0; + self.smoother.reset(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // SuperSmoother is ready after one input; we need two to compute HP. + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "RoofingFilter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_invalid_periods() { + assert!(matches!(RoofingFilter::new(0, 48), Err(Error::PeriodZero))); + assert!(matches!(RoofingFilter::new(10, 0), Err(Error::PeriodZero))); + assert!(matches!( + RoofingFilter::new(48, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + RoofingFilter::new(10, 10), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let mut rf = RoofingFilter::new(10, 48).unwrap(); + assert_eq!(rf.periods(), (10, 48)); + assert_eq!(rf.warmup_period(), 2); + assert_eq!(rf.name(), "RoofingFilter"); + assert!(!rf.is_ready()); + rf.update(100.0); + rf.update(101.0); + assert!(rf.is_ready()); + assert!(rf.value().is_some()); + } + + #[test] + fn constant_series_converges_to_zero() { + // High-pass on a flat input is zero, and the smoother of zero is zero. + let mut rf = RoofingFilter::new(10, 48).unwrap(); + let out = rf.batch(&[42.0_f64; 300]); + for x in out.iter().skip(100).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-6); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0) + .collect(); + let mut a = RoofingFilter::new(10, 48).unwrap(); + let mut b = RoofingFilter::new(10, 48).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut rf = RoofingFilter::new(10, 48).unwrap(); + rf.batch(&(1..=100).map(f64::from).collect::>()); + let before = rf.value(); + assert!(before.is_some()); + assert_eq!(rf.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut rf = RoofingFilter::new(10, 48).unwrap(); + rf.batch(&(1..=100).map(f64::from).collect::>()); + assert!(rf.is_ready()); + rf.reset(); + assert!(!rf.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/rsi.rs b/crates/wickra-core/src/indicators/rsi.rs new file mode 100644 index 0000000..8f3233c --- /dev/null +++ b/crates/wickra-core/src/indicators/rsi.rs @@ -0,0 +1,531 @@ +//! Relative Strength Index using Wilder's smoothing. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Relative Strength Index (Wilder, 1978). +/// +/// Uses Wilder's smoothing (an EMA with `alpha = 1 / period`). The first output +/// is produced after `period + 1` inputs: the seed averages the first `period` +/// gains and losses, and the first emitted RSI corresponds to the input at +/// index `period`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Rsi}; +/// +/// let mut indicator = Rsi::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rsi { + period: usize, + /// `period - 1` as `f64`, precomputed for the Wilder smoothing step. + n_minus_1: f64, + /// `1 / period`, precomputed so the per-tick smoothing multiplies instead of + /// divides (a reciprocal is hoisted out of the hot path). + inv_period: f64, + /// Previous close, valid once `has_prev` is set. Bare `f64` + flag instead of + /// `Option` to avoid an enum-tag read on every tick. + prev_close: f64, + has_prev: bool, + // Wilder seeds with the simple average of the first `period` gains/losses, + // then transitions to recursive smoothing. + seed_buf_gains: Vec, + seed_buf_losses: Vec, + /// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s + /// + flag so the hot recurrence avoids reading two `Option` tags per tick. + avg_gain: f64, + avg_loss: f64, + avgs_seeded: bool, + last_value: Option, +} + +impl Rsi { + /// Construct an RSI with the given Wilder period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + n_minus_1: (period - 1) as f64, + inv_period: 1.0 / period as f64, + prev_close: 0.0, + has_prev: false, + seed_buf_gains: Vec::with_capacity(period), + seed_buf_losses: Vec::with_capacity(period), + avg_gain: 0.0, + avg_loss: 0.0, + avgs_seeded: false, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// Vectorized batch returning one `f64` per input (`NaN` during warmup). + /// + /// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket + /// default. RSI is a recursive (IIR) filter — Wilder smoothing — so it cannot + /// be SIMD-vectorized any more than the C peers manage; the win is purely in + /// stripping per-tick overhead. For a fresh indicator over an all-finite slice + /// long enough to seed (`n > period`) it runs the seed once and then the bare + /// smoothing recurrence in a tight loop with no per-tick `is_finite`/`has_prev`/ + /// `avgs_seeded` branch and no `Option`, using the identical division at the + /// seed and `mul_add`/`rsi_from_avgs` afterwards — so it is *bit-for-bit* equal + /// to replaying `update`. Shorter or non-fresh/non-finite inputs defer to the + /// exact `update` replay. + pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec { + let p = self.period; + let n = inputs.len(); + if self.has_prev + || self.avgs_seeded + || !self.seed_buf_gains.is_empty() + || n <= p + || !inputs.iter().all(|x| x.is_finite()) + { + return inputs + .iter() + .map(|&x| self.update(x).unwrap_or(f64::NAN)) + .collect(); + } + + // Warmup `[0, p)` is `NaN`; outputs from index `p` on are pushed once each. + let mut out = vec![f64::NAN; p]; + out.reserve(n - p); + // Seed from the first `period` diffs (inputs[1..=p]); index 0 only sets the + // baseline. Retain the seed gains/losses exactly as `update` leaves them. + let mut prev = inputs[0]; + let (mut sum_gain, mut sum_loss) = (0.0_f64, 0.0_f64); + for &x in &inputs[1..=p] { + let diff = x - prev; + prev = x; + let gain = if diff > 0.0 { diff } else { 0.0 }; + let loss = if diff < 0.0 { -diff } else { 0.0 }; + self.seed_buf_gains.push(gain); + self.seed_buf_losses.push(loss); + sum_gain += gain; + sum_loss += loss; + } + let p_f64 = p as f64; + let mut ag = sum_gain / p_f64; + let mut al = sum_loss / p_f64; + out.push(Self::rsi_from_avgs(ag, al)); + + // Steady state: Wilder smoothing, reciprocal hoisted, one `rsi_from_avgs`. + for &x in &inputs[p + 1..] { + let diff = x - prev; + prev = x; + let gain = if diff > 0.0 { diff } else { 0.0 }; + let loss = if diff < 0.0 { -diff } else { 0.0 }; + ag = ag.mul_add(self.n_minus_1, gain) * self.inv_period; + al = al.mul_add(self.n_minus_1, loss) * self.inv_period; + out.push(Self::rsi_from_avgs(ag, al)); + } + + // Leave state where a full `update` replay would. + self.prev_close = prev; + self.has_prev = true; + self.avg_gain = ag; + self.avg_loss = al; + self.avgs_seeded = true; + self.last_value = Some(out[n - 1]); + out + } + + fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { + // Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`, + // which needs a single division instead of two and removes the separate + // `rs` step. Edge cases stay exact: `al == 0, ag > 0` gives `100·ag/ag = + // 100`; `ag == 0, al > 0` gives `0`; both zero (no movement) is the + // undefined case and returns the neutral 50. + let denom = avg_gain + avg_loss; + if denom == 0.0 { + 50.0 + } else { + 100.0 * avg_gain / denom + } + } +} + +impl Indicator for Rsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_value; + } + + if !self.has_prev { + self.prev_close = input; + self.has_prev = true; + return None; + } + let prev = self.prev_close; + self.prev_close = input; + + let diff = input - prev; + let gain = if diff > 0.0 { diff } else { 0.0 }; + let loss = if diff < 0.0 { -diff } else { 0.0 }; + + if self.avgs_seeded { + // Wilder smoothing `(prev·(n-1) + x) / n` with the reciprocal hoisted: + // a fused multiply-add then a multiply by `1/n`, no per-tick division. + let new_ag = self.avg_gain.mul_add(self.n_minus_1, gain) * self.inv_period; + let new_al = self.avg_loss.mul_add(self.n_minus_1, loss) * self.inv_period; + self.avg_gain = new_ag; + self.avg_loss = new_al; + let v = Self::rsi_from_avgs(new_ag, new_al); + self.last_value = Some(v); + return Some(v); + } + + self.seed_buf_gains.push(gain); + self.seed_buf_losses.push(loss); + if self.seed_buf_gains.len() == self.period { + let ag = self.seed_buf_gains.iter().sum::() / self.period as f64; + let al = self.seed_buf_losses.iter().sum::() / self.period as f64; + self.avg_gain = ag; + self.avg_loss = al; + self.avgs_seeded = true; + let v = Self::rsi_from_avgs(ag, al); + self.last_value = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.prev_close = 0.0; + self.has_prev = false; + self.seed_buf_gains.clear(); + self.seed_buf_losses.clear(); + self.avg_gain = 0.0; + self.avg_loss = 0.0; + self.avgs_seeded = false; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "RSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Independent reference: Wilder RSI computed straight from the definition. + fn rsi_naive(prices: &[f64], period: usize) -> Vec> { + let n = period as f64; + let mut out = vec![None; prices.len()]; + let mut gains: Vec = Vec::new(); + let mut losses: Vec = Vec::new(); + let mut avg_gain: Option = None; + let mut avg_loss: Option = None; + let rsi_val = |ag: f64, al: f64| -> f64 { + if al == 0.0 { + if ag == 0.0 { + 50.0 + } else { + 100.0 + } + } else { + 100.0 - 100.0 / (1.0 + ag / al) + } + }; + for i in 1..prices.len() { + let diff = prices[i] - prices[i - 1]; + let gain = if diff > 0.0 { diff } else { 0.0 }; + let loss = if diff < 0.0 { -diff } else { 0.0 }; + if let (Some(ag), Some(al)) = (avg_gain, avg_loss) { + let nag = (ag * (n - 1.0) + gain) / n; + let nal = (al * (n - 1.0) + loss) / n; + avg_gain = Some(nag); + avg_loss = Some(nal); + out[i] = Some(rsi_val(nag, nal)); + } else { + gains.push(gain); + losses.push(loss); + if gains.len() == period { + let ag = gains.iter().sum::() / n; + let al = losses.iter().sum::() / n; + avg_gain = Some(ag); + avg_loss = Some(al); + out[i] = Some(rsi_val(ag, al)); + } + } + } + out + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Rsi::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (60-67) and the + /// Indicator-impl `name` body (145-147). `warmup_period` is covered + /// already by `warmup_period_is_period_plus_one`. + #[test] + fn accessors_and_metadata() { + let mut rsi = Rsi::new(14).unwrap(); + assert_eq!(rsi.period(), 14); + assert_eq!(rsi.name(), "RSI"); + assert_eq!(rsi.value(), None); + for i in 1..=15 { + rsi.update(100.0 + f64::from(i)); + } + assert!(rsi.value().is_some()); + } + + /// Cover the `ag == 0` branch (line 167) of the test-helper `rsi_naive`: + /// when both `avg_gain` and `avg_loss` are 0 (a perfectly flat series), + /// the helper must return the neutral 50.0. The proptest reference uses + /// random inputs that essentially never hit zero gains AND zero losses + /// simultaneously, leaving this branch dead in the helper. + #[test] + fn naive_helper_flat_series_yields_50() { + let ks = rsi_naive(&[42.0; 20], 5); + for r in ks.into_iter().skip(5) { + assert_eq!(r.expect("ready after period+1 inputs"), 50.0); + } + } + + /// Cover the `100.0` branch (line 169) of the test-helper `rsi_naive`: + /// strictly increasing prices give `avg_loss == 0` while `avg_gain > 0`, + /// the textbook overbought saturation case. Random proptest inputs + /// virtually never satisfy `al == 0 && ag != 0`, so this needs an + /// explicit monotone series. + #[test] + fn naive_helper_monotone_up_yields_100() { + let prices: Vec = (1..=20).map(f64::from).collect(); + let ks = rsi_naive(&prices, 5); + for r in ks.into_iter().skip(5) { + assert_eq!(r.expect("ready after period+1 inputs"), 100.0); + } + } + + #[test] + fn warmup_period_is_period_plus_one() { + let rsi = Rsi::new(14).unwrap(); + assert_eq!(rsi.warmup_period(), 15); + } + + #[test] + fn first_emission_at_index_period() { + // RSI(14) needs 14 diffs => 15 inputs before first value. + let prices: Vec = (1..=20).map(f64::from).collect(); + let mut rsi = Rsi::new(14).unwrap(); + let out = rsi.batch(&prices); + // indices 0..14 -> None, index 14 -> first Some + for x in &out[..14] { + assert!(x.is_none()); + } + assert!(out[14].is_some()); + } + + #[test] + fn pure_uptrend_yields_rsi_100() { + let prices: Vec = (1..=20).map(f64::from).collect(); + let mut rsi = Rsi::new(14).unwrap(); + let out = rsi.batch(&prices); + // All diffs are positive => avg_loss == 0 => RSI == 100 + for v in out.iter().filter_map(|x| x.as_ref()) { + assert_relative_eq!(*v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn pure_downtrend_yields_rsi_0() { + let prices: Vec = (1..=20).rev().map(f64::from).collect(); + let mut rsi = Rsi::new(14).unwrap(); + let out = rsi.batch(&prices); + for v in out.iter().filter_map(|x| x.as_ref()) { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn flat_series_yields_rsi_50() { + let prices = [10.0_f64; 30]; + let mut rsi = Rsi::new(14).unwrap(); + let out = rsi.batch(&prices); + for v in out.iter().filter_map(|x| x.as_ref()) { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn classic_wilder_textbook_values() { + // Wilder's original example from "New Concepts in Technical Trading Systems", + // 14-period RSI. We compute the first value at index 14 and compare to the + // value Wilder publishes (~70.46). + // Source: classic textbook table, reproduced in many references (e.g. Investopedia). + let prices = [ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, + 45.61, 46.28, 46.28, + ]; + let mut rsi = Rsi::new(14).unwrap(); + let out = rsi.batch(&prices); + let first = out[14].expect("first RSI emitted at index period"); + assert_relative_eq!(first, 70.464, epsilon = 0.05); + } + + #[test] + fn rsi_stays_in_0_100_range() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0) + .collect(); + let mut rsi = Rsi::new(14).unwrap(); + for x in rsi.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&x), "RSI out of range: {x}"); + } + } + + #[test] + fn reset_clears_state() { + let mut rsi = Rsi::new(5).unwrap(); + rsi.batch(&[1.0, 2.0, 3.0, 2.0, 4.0, 5.0, 6.0]); + assert!(rsi.is_ready()); + rsi.reset(); + assert!(!rsi.is_ready()); + assert_eq!(rsi.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i)) + .collect(); + let mut a = Rsi::new(7).unwrap(); + let mut b = Rsi::new(7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut rsi = Rsi::new(3).unwrap(); + rsi.batch(&[1.0, 2.0, 3.0, 4.0]); + let before = rsi.value(); + assert!(before.is_some()); + assert_eq!(rsi.update(f64::NAN), before); + assert_eq!(rsi.update(f64::INFINITY), before); + assert_eq!(rsi.value(), before); + } + + fn bits_eq(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x == y || (x.is_nan() && y.is_nan())) + } + + fn rsi_replay(period: usize, series: &[f64]) -> Vec { + let mut r = Rsi::new(period).unwrap(); + series + .iter() + .map(|&x| r.update(x).unwrap_or(f64::NAN)) + .collect() + } + + #[test] + fn batch_nan_fast_path_is_bit_identical() { + let series: Vec = (0..300) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i) * 0.1 + 100.0) + .collect(); + let mut rsi = Rsi::new(14).unwrap(); + let got = rsi.batch_nan(&series); + assert!(bits_eq(&got, &rsi_replay(14, &series))); + let mut ref_rsi = Rsi::new(14).unwrap(); + for &x in &series { + ref_rsi.update(x); + } + assert_eq!(rsi.update(123.0), ref_rsi.update(123.0)); + } + + #[test] + fn batch_nan_falls_back_on_non_finite() { + let series = [10.0, 11.0, 9.0, f64::NAN, 12.0, 13.0, 8.0]; + let mut rsi = Rsi::new(3).unwrap(); + assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series))); + } + + #[test] + fn batch_nan_falls_back_when_not_fresh() { + let mut rsi = Rsi::new(3).unwrap(); + rsi.update(50.0); + let series = [51.0, 49.0, 52.0, 53.0, 50.0]; + let mut ref_rsi = Rsi::new(3).unwrap(); + ref_rsi.update(50.0); + let want: Vec = series + .iter() + .map(|&x| ref_rsi.update(x).unwrap_or(f64::NAN)) + .collect(); + assert!(bits_eq(&rsi.batch_nan(&series), &want)); + } + + #[test] + fn batch_nan_too_short_to_seed_falls_back() { + // n <= period: routed to the exact replay (cannot seed yet). + let series = [10.0, 11.0, 12.0]; + let mut rsi = Rsi::new(3).unwrap(); + assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series))); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn rsi_matches_naive( + period in 1usize..20, + prices in proptest::collection::vec(1.0_f64..1000.0, 0..150), + ) { + let mut rsi = Rsi::new(period).unwrap(); + let got = rsi.batch(&prices); + let want = rsi_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() < 1e-7, + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/rsx.rs b/crates/wickra-core/src/indicators/rsx.rs new file mode 100644 index 0000000..3adef58 --- /dev/null +++ b/crates/wickra-core/src/indicators/rsx.rs @@ -0,0 +1,291 @@ +//! RSX — Jurik-style smoothed RSI. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade. +/// +/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single +/// EMA, the RSX runs the signed price change *and* its absolute value through +/// three cascaded "double-EMA with overshoot" stages (each stage is +/// `x = 1.5·a − 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the +/// RSI-style ratio from the two smoothed streams: +/// +/// ```text +/// f18 = 3 / (length + 2), f20 = 1 - f18 +/// each stage: a = f20·a + f18·in; b = f18·a + f20·b; out = 1.5·a − 0.5·b +/// v14 = stage3(signed change), v1C = stage3(|change|) +/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100) (50 when v1C == 0) +/// ``` +/// +/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far +/// smoother for the same responsiveness — it has very little of the RSI's +/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat +/// market returns the neutral `50`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Rsx}; +/// +/// let mut indicator = Rsx::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rsx { + length: usize, + f18: f64, + f20: f64, + prev: Option, + count: usize, + // Signed-change cascade (three stages: a/b pairs). + s_a0: f64, + s_b0: f64, + s_a1: f64, + s_b1: f64, + s_a2: f64, + s_b2: f64, + // Absolute-change cascade. + a_a0: f64, + a_b0: f64, + a_a1: f64, + a_b1: f64, + a_a2: f64, + a_b2: f64, + last_value: Option, +} + +impl Rsx { + /// Construct an RSX with the given smoothing length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `length == 0`. + pub fn new(length: usize) -> Result { + if length == 0 { + return Err(Error::PeriodZero); + } + let f18 = 3.0 / (length as f64 + 2.0); + Ok(Self { + length, + f18, + f20: 1.0 - f18, + prev: None, + count: 0, + s_a0: 0.0, + s_b0: 0.0, + s_a1: 0.0, + s_b1: 0.0, + s_a2: 0.0, + s_b2: 0.0, + a_a0: 0.0, + a_b0: 0.0, + a_a1: 0.0, + a_b1: 0.0, + a_a2: 0.0, + a_b2: 0.0, + last_value: None, + }) + } + + /// Configured length. + pub const fn length(&self) -> usize { + self.length + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + /// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place + /// and returns `1.5·a − 0.5·b`. + fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 { + *a = self.f20 * *a + self.f18 * input; + *b = self.f18 * *a + self.f20 * *b; + 1.5 * *a - 0.5 * *b + } +} + +impl Indicator for Rsx { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last_value; + } + let Some(prev) = self.prev else { + self.prev = Some(price); + return None; + }; + self.prev = Some(price); + + let change = price - prev; + + // Signed-change cascade. + let (mut sa0, mut sb0) = (self.s_a0, self.s_b0); + let v_c = self.stage(&mut sa0, &mut sb0, change); + self.s_a0 = sa0; + self.s_b0 = sb0; + let (mut sa1, mut sb1) = (self.s_a1, self.s_b1); + let v_10 = self.stage(&mut sa1, &mut sb1, v_c); + self.s_a1 = sa1; + self.s_b1 = sb1; + let (mut sa2, mut sb2) = (self.s_a2, self.s_b2); + let v_14 = self.stage(&mut sa2, &mut sb2, v_10); + self.s_a2 = sa2; + self.s_b2 = sb2; + + // Absolute-change cascade. + let abs = change.abs(); + let (mut aa0, mut ab0) = (self.a_a0, self.a_b0); + let v_c1 = self.stage(&mut aa0, &mut ab0, abs); + self.a_a0 = aa0; + self.a_b0 = ab0; + let (mut aa1, mut ab1) = (self.a_a1, self.a_b1); + let v_18 = self.stage(&mut aa1, &mut ab1, v_c1); + self.a_a1 = aa1; + self.a_b1 = ab1; + let (mut aa2, mut ab2) = (self.a_a2, self.a_b2); + let v_1c = self.stage(&mut aa2, &mut ab2, v_18); + self.a_a2 = aa2; + self.a_b2 = ab2; + + let v4 = if v_1c > 0.0 { + (v_14 / v_1c + 1.0) * 50.0 + } else { + 50.0 + }; + let rsx = v4.clamp(0.0, 100.0); + + self.count += 1; + self.last_value = Some(rsx); + if self.count >= self.length { + Some(rsx) + } else { + None + } + } + + fn reset(&mut self) { + *self = Self::new(self.length).expect("length already validated"); + } + + fn warmup_period(&self) -> usize { + // One input to seed `prev`, then `length` changes to settle the cascade. + self.length + 1 + } + + fn is_ready(&self) -> bool { + self.count >= self.length + } + + fn name(&self) -> &'static str { + "RSX" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_length() { + assert!(matches!(Rsx::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `length` + `value` and the Indicator-impl + /// `warmup_period` + `name`. + #[test] + fn accessors_and_metadata() { + let rsx = Rsx::new(14).unwrap(); + assert_eq!(rsx.length(), 14); + assert_eq!(rsx.value(), None); + assert_eq!(rsx.warmup_period(), 15); + assert_eq!(rsx.name(), "RSX"); + } + + #[test] + fn warmup_then_emits() { + let mut rsx = Rsx::new(3).unwrap(); + // 1 input seeds prev; then 3 changes settle -> first Some on input 4. + assert_eq!(rsx.update(10.0), None); + assert_eq!(rsx.update(11.0), None); + assert_eq!(rsx.update(12.0), None); + assert!(rsx.update(13.0).is_some()); + } + + #[test] + fn flat_market_is_neutral() { + // No movement -> absolute cascade is zero -> neutral 50. + let mut rsx = Rsx::new(5).unwrap(); + let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-12); + } + + #[test] + fn output_stays_in_range() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0) + .collect(); + let mut rsx = Rsx::new(14).unwrap(); + for v in rsx.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]"); + } + } + + #[test] + fn strong_uptrend_is_high() { + // A sustained rise drives RSX well above the neutral 50. + let prices: Vec = (1..=60).map(f64::from).collect(); + let mut rsx = Rsx::new(14).unwrap(); + let last = rsx.batch(&prices).into_iter().flatten().last().unwrap(); + assert!( + last > 80.0, + "strong uptrend should push RSX high, got {last}" + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut rsx = Rsx::new(3).unwrap(); + let ready = rsx + .batch(&[1.0, 2.0, 3.0, 4.0, 5.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(rsx.update(f64::NAN), Some(ready)); + assert_eq!(rsx.update(f64::INFINITY), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut rsx = Rsx::new(5).unwrap(); + rsx.batch(&(1..=40).map(f64::from).collect::>()); + assert!(rsx.is_ready()); + rsx.reset(); + assert!(!rsx.is_ready()); + assert_eq!(rsx.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0) + .collect(); + let mut a = Rsx::new(14).unwrap(); + let mut b = Rsx::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/run_bars.rs b/crates/wickra-core/src/indicators/run_bars.rs new file mode 100644 index 0000000..d4587a4 --- /dev/null +++ b/crates/wickra-core/src/indicators/run_bars.rs @@ -0,0 +1,257 @@ +//! Run bar builder (simplified López de Prado) — sample on runs of same-signed ticks. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed run bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RunBar { + /// Open of the first candle in the bar. + pub open: f64, + /// Highest high across the bar. + pub high: f64, + /// Lowest low across the bar. + pub low: f64, + /// Close of the candle that closed the bar. + pub close: f64, + /// Length of the run that closed the bar (`== run_length`). + pub length: usize, + /// `+1` if a buy run closed the bar, `-1` if a sell run. + pub direction: i8, +} + +/// Run bar builder — a **simplified** form of López de Prado's run bars. +/// +/// A *run* is an uninterrupted sequence of same-signed ticks: a streak of up-ticks +/// (a buy run) or down-ticks (a sell run), with unchanged closes extending the +/// current run. This builder counts the current run's length and closes a bar when +/// it reaches `run_length`; a tick in the opposite direction restarts the run from +/// one. Where [`ImbalanceBars`](crate::ImbalanceBars) sample on the *net* signed +/// imbalance (which oscillating flow can cancel back to zero), run bars sample on +/// *persistence*: they fire only when the market pushes the same way without +/// interruption, making them a cleaner sequential-trend detector. +/// +/// **Simplification.** The full method estimates a *dynamic* expected run length +/// from an EWMA and can weight runs by volume or traded value. This builder uses a +/// **fixed** run-length threshold on unweighted ticks. See López de Prado (2018), +/// ch. 2, for the adaptive estimator and weighted variants. +/// +/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an +/// empty vector or a single [`RunBar`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, RunBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut bars = RunBars::new(3).unwrap(); +/// bars.update(flat(10.0)); // seed +/// bars.update(flat(11.0)); // run 1 +/// bars.update(flat(12.0)); // run 2 +/// let out = bars.update(flat(13.0)); // run 3 -> close +/// assert_eq!(out.len(), 1); +/// assert_eq!(out[0].direction, 1); +/// ``` +#[derive(Debug, Clone)] +pub struct RunBars { + run_length: usize, + count: usize, + open: f64, + high: f64, + low: f64, + close: f64, + prev_close: Option, + run_sign: i8, + run_len: usize, +} + +impl RunBars { + /// Construct a run-bar builder that closes a bar on a run of `run_length` ticks. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `run_length == 0`. + pub fn new(run_length: usize) -> Result { + if run_length == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + run_length, + count: 0, + open: 0.0, + high: 0.0, + low: 0.0, + close: 0.0, + prev_close: None, + run_sign: 0, + run_len: 0, + }) + } + + /// Configured run length that closes a bar. + pub const fn run_length(&self) -> usize { + self.run_length + } + + /// Length of the in-progress run. + pub const fn run(&self) -> usize { + self.run_len + } +} + +impl BarBuilder for RunBars { + type Bar = RunBar; + + fn update(&mut self, candle: Candle) -> Vec { + if self.count == 0 { + self.open = candle.open; + self.high = candle.high; + self.low = candle.low; + } else { + self.high = self.high.max(candle.high); + self.low = self.low.min(candle.low); + } + self.close = candle.close; + self.count += 1; + if let Some(prev) = self.prev_close { + let directional = if candle.close > prev { + 1 + } else if candle.close < prev { + -1 + } else { + 0 + }; + if directional == 0 { + // A flat tick extends the current run (if one is under way). + if self.run_sign != 0 { + self.run_len += 1; + } + } else if directional == self.run_sign { + self.run_len += 1; + } else { + self.run_sign = directional; + self.run_len = 1; + } + } + self.prev_close = Some(candle.close); + if self.run_sign == 0 || self.run_len < self.run_length { + return Vec::new(); + } + let bar = RunBar { + open: self.open, + high: self.high, + low: self.low, + close: self.close, + length: self.run_len, + direction: self.run_sign, + }; + self.count = 0; + self.run_sign = 0; + self.run_len = 0; + vec![bar] + } + + fn reset(&mut self) { + self.count = 0; + self.prev_close = None; + self.run_sign = 0; + self.run_len = 0; + } + + fn name(&self) -> &'static str { + "RunBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_run_length() { + assert!(matches!(RunBars::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let bars = RunBars::new(5).unwrap(); + assert_eq!(bars.run_length(), 5); + assert_eq!(bars.run(), 0); + assert_eq!(bars.name(), "RunBars"); + } + + #[test] + fn buy_run_closes_up_bar() { + let mut bars = RunBars::new(3).unwrap(); + bars.update(flat(10.0)); // seed + bars.update(flat(11.0)); // run 1 + bars.update(flat(12.0)); // run 2 + let out = bars.update(flat(13.0)); // run 3 + assert_eq!(out.len(), 1); + assert_eq!(out[0].direction, 1); + assert_eq!(out[0].length, 3); + } + + #[test] + fn sell_run_closes_down_bar() { + let mut bars = RunBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(9.0)); // run 1 + bars.update(flat(8.0)); // run 2 + let out = bars.update(flat(7.0)); // run 3 + assert_eq!(out.len(), 1); + assert_eq!(out[0].direction, -1); + } + + #[test] + fn opposite_tick_restarts_run() { + let mut bars = RunBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // up run 1 + bars.update(flat(12.0)); // up run 2 + bars.update(flat(11.0)); // down -> run restarts at 1 + assert_eq!(bars.run(), 1); + } + + #[test] + fn flat_tick_extends_run() { + let mut bars = RunBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // run 1 + bars.update(flat(11.0)); // flat -> run 2 + let out = bars.update(flat(12.0)); // run 3 + assert_eq!(out.len(), 1); + assert_eq!(out[0].direction, 1); + } + + #[test] + fn reset_clears_state() { + let mut bars = RunBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); + bars.reset(); + assert_eq!(bars.run(), 0); + assert!(bars.update(flat(50.0)).is_empty()); + } + + #[test] + fn batch_concatenates_completed_bars() { + let mut bars = RunBars::new(2).unwrap(); + let candles = [ + flat(10.0), + flat(11.0), // run 1 + flat(12.0), // run 2 -> close + flat(13.0), // run 1 + flat(14.0), // run 2 -> close + ]; + let out = bars.batch(&candles); + assert_eq!(out.len(), 2); + assert!(out.iter().all(|b| b.direction == 1)); + } +} diff --git a/crates/wickra-core/src/indicators/rvi.rs b/crates/wickra-core/src/indicators/rvi.rs new file mode 100644 index 0000000..a1c609b --- /dev/null +++ b/crates/wickra-core/src/indicators/rvi.rs @@ -0,0 +1,217 @@ +//! Relative Vigor Index (RVI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Relative Vigor Index — Donald Dorsey's ratio of intra-bar drive (close − open) +/// to intra-bar range (high − low), averaged over a `period`-bar window. +/// +/// The reading is `SMA(close − open, period) / SMA(high − low, period)`. A +/// positive value means the average bar in the window closed above where it +/// opened (bullish "vigor"); a negative value means the average closed below. +/// The denominator's rolling-window SMA can fall to zero on a perfectly flat +/// stretch, in which case the recurrence is undefined and the indicator holds +/// its previous value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Rvi}; +/// +/// let mut rvi = Rvi::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let o = 100.0 + f64::from(i); +/// let c = o + 0.5; +/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap(); +/// last = rvi.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rvi { + period: usize, + window: VecDeque<(f64, f64)>, + sum_num: f64, + sum_den: f64, + current: Option, +} + +impl Rvi { + /// # 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_num: 0.0, + sum_den: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Rvi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let num = candle.close - candle.open; + let den = candle.high - candle.low; + if self.window.len() == self.period { + let (old_n, old_d) = self.window.pop_front().expect("window is non-empty"); + self.sum_num -= old_n; + self.sum_den -= old_d; + } + self.window.push_back((num, den)); + self.sum_num += num; + self.sum_den += den; + if self.window.len() < self.period { + return None; + } + if self.sum_den <= 0.0 { + // Window of perfectly flat (zero-range) bars: ratio undefined. + // Hold the previous value rather than emitting NaN / inf. + return self.current; + } + let value = self.sum_num / self.sum_den; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_num = 0.0; + self.sum_den = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "RVI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Rvi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut r = Rvi::new(10).unwrap(); + assert_eq!(r.period(), 10); + assert_eq!(r.warmup_period(), 10); + assert_eq!(r.name(), "RVI"); + assert_eq!(r.value(), None); + for i in 0..10 { + r.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + assert!(r.value().is_some()); + } + + #[test] + fn reference_value_period_2() { + // Two bars with (open, high, low, close) = (10, 11, 9, 10.5) and + // (10.5, 11.5, 10, 11). Per bar: + // num1 = 0.5, num2 = 0.5; sum = 1.0 + // den1 = 2.0, den2 = 1.5; sum = 3.5 + // RVI = 1.0 / 3.5 ≈ 0.2857142857 + let mut r = Rvi::new(2).unwrap(); + assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None); + let v = r.update(candle(10.5, 11.5, 10.0, 11.0, 1)).unwrap(); + assert_relative_eq!(v, 1.0 / 3.5, epsilon = 1e-12); + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut r = Rvi::new(3).unwrap(); + for i in 0..2 { + assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, i)), None); + } + assert!(r.update(candle(10.5, 11.5, 10.0, 11.0, 2)).is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Every bar closes above its open and has a non-zero range: RVI > 0. + let mut r = Rvi::new(5).unwrap(); + for i in 0..10 { + let o = 10.0 + f64::from(i); + let c = o + 0.5; + r.update(candle(o, c + 0.2, o - 0.2, c, i64::from(i))); + } + let v = r.value().unwrap(); + assert!(v > 0.0, "uptrend RVI should be positive: {v}"); + } + + #[test] + fn zero_range_window_holds_value() { + // Window of perfectly flat bars (high == low): ratio undefined, + // indicator holds. + let mut r = Rvi::new(3).unwrap(); + r.update(candle(10.0, 10.0, 10.0, 10.0, 0)); + r.update(candle(10.0, 10.0, 10.0, 10.0, 1)); + assert_eq!(r.update(candle(10.0, 10.0, 10.0, 10.0, 2)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40_i64) + .map(|i| { + let o = 100.0 + (i as f64 * 0.3).sin() * 5.0; + let c = o + (i as f64 * 0.1).cos(); + candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i) + }) + .collect(); + let batch = Rvi::new(10).unwrap().batch(&candles); + let mut b = Rvi::new(10).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut r = Rvi::new(5).unwrap(); + for i in 0..10 { + r.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/rvi_volatility.rs b/crates/wickra-core/src/indicators/rvi_volatility.rs new file mode 100644 index 0000000..0a471df --- /dev/null +++ b/crates/wickra-core/src/indicators/rvi_volatility.rs @@ -0,0 +1,338 @@ +//! Relative Volatility Index (Donald Dorsey). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Relative Volatility Index — Donald Dorsey's RSI-shaped volatility gauge. +/// +/// Where RSI partitions price changes into gains and losses and Wilder-smooths +/// each side, RVI partitions the rolling standard deviation of price into "up +/// volatility" (when price rose since the previous bar) and "down volatility" +/// (when price fell), then applies the same Wilder smoothing and ratio: +/// +/// ```text +/// sd_t = stddev_pop(close over `period`) // single scalar each bar +/// up_t = sd_t if close_t > close_{t-1}, else 0 +/// down_t = sd_t if close_t < close_{t-1}, else 0 +/// AvgUp_t = Wilder(up, period) +/// AvgDown_t = Wilder(down, period) +/// RVI_t = 100 · AvgUp_t / (AvgUp_t + AvgDown_t) +/// ``` +/// +/// The output is bounded on `[0, 100]`. A series with no down-bars saturates +/// at `100`; a series with no up-bars saturates at `0`. A completely flat +/// series (no movement, both averages zero) returns `50` by the same +/// undefined-RS convention as `RSI` (`crates/wickra-core/src/indicators/rsi.rs`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RviVolatility}; +/// +/// let mut indicator = RviVolatility::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RviVolatility { + period: usize, + // Rolling-stddev state. + window: VecDeque, + sum: f64, + sum_sq: f64, + // Direction tracking. + prev_close: Option, + // Wilder-smoothed up/down volatility. + seed_up: Vec, + seed_down: Vec, + avg_up: Option, + avg_down: Option, + last_value: Option, +} + +impl RviVolatility { + /// Construct an RVI with the given period. + /// + /// `period` is used both as the standard-deviation window length and as + /// the Wilder smoothing constant for the up/down averages. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `period == 1` (a 1-bar rolling standard + /// deviation is always zero and the indicator would never produce a + /// meaningful reading). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "RVI period must be >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + prev_close: None, + seed_up: Vec::with_capacity(period), + seed_down: Vec::with_capacity(period), + avg_up: None, + avg_down: None, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn ratio(avg_up: f64, avg_down: f64) -> f64 { + let denom = avg_up + avg_down; + if denom == 0.0 { + // No volatility on either side. Match RSI's undefined-RS convention. + 50.0 + } else { + 100.0 * avg_up / denom + } + } +} + +impl Indicator for RviVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input leaves state untouched, mirrors `StdDev` / `Rsi`. + return self.last_value; + } + + // 1. Roll the standard-deviation window. + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(input); + self.sum += input; + self.sum_sq += input * input; + + if self.window.len() < self.period { + // Track previous close from the very first input so that the first + // ready stddev sample is paired with a valid direction. + self.prev_close = Some(input); + return None; + } + + let n = self.period as f64; + let mean = self.sum / n; + // Population variance with a non-negativity clamp for FP cancellation. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let sd = variance.sqrt(); + + // 2. Classify the stddev sample as up- or down-volatility. + let prev = self + .prev_close + .expect("prev_close is set on every input before this point"); + let (up, down) = if input > prev { + (sd, 0.0) + } else if input < prev { + (0.0, sd) + } else { + (0.0, 0.0) + }; + self.prev_close = Some(input); + + // 3. Wilder-smooth the up/down series. + if let (Some(au), Some(ad)) = (self.avg_up, self.avg_down) { + let new_au = au.mul_add(n - 1.0, up) / n; + let new_ad = ad.mul_add(n - 1.0, down) / n; + self.avg_up = Some(new_au); + self.avg_down = Some(new_ad); + let v = Self::ratio(new_au, new_ad); + self.last_value = Some(v); + return Some(v); + } + + self.seed_up.push(up); + self.seed_down.push(down); + if self.seed_up.len() == self.period { + let au = self.seed_up.iter().sum::() / n; + let ad = self.seed_down.iter().sum::() / n; + self.avg_up = Some(au); + self.avg_down = Some(ad); + let v = Self::ratio(au, ad); + self.last_value = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.prev_close = None; + self.seed_up.clear(); + self.seed_down.clear(); + self.avg_up = None; + self.avg_down = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // `period` bars to fill the stddev window plus another `period − 1` + // bars to seed the Wilder averages with up/down samples. The two + // phases overlap by one bar (the `period`-th input produces both the + // first stddev sample and the first up/down classification), so the + // first ready RVI lands at index `2 · period − 2`, i.e. the + // `(2·period − 1)`-th input. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "RVIVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(RviVolatility::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_period_one() { + assert!(matches!( + RviVolatility::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let rvi = RviVolatility::new(14).unwrap(); + assert_eq!(rvi.period(), 14); + assert_eq!(rvi.name(), "RVIVolatility"); + assert_eq!(rvi.value(), None); + assert_eq!(rvi.warmup_period(), 27); + assert!(!rvi.is_ready()); + } + + #[test] + fn constant_series_yields_fifty() { + // Flat input -> stddev is zero every bar and direction is "unchanged", + // so both avg_up and avg_down stay at zero -> the undefined-RS + // convention returns 50. + let mut rvi = RviVolatility::new(5).unwrap(); + let out = rvi.batch(&[42.0; 40]); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_saturates_to_one_hundred() { + // Every bar's close is above the previous -> every stddev sample is + // classified as up, every down sample is zero -> RVI = 100. + let mut rvi = RviVolatility::new(5).unwrap(); + let prices: Vec = (1..=40).map(f64::from).collect(); + let out = rvi.batch(&prices); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn pure_downtrend_saturates_to_zero() { + let mut rvi = RviVolatility::new(5).unwrap(); + let prices: Vec = (1..=40).rev().map(f64::from).collect(); + let out = rvi.batch(&prices); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn output_is_bounded() { + let mut rvi = RviVolatility::new(10).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in rvi.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "RVI out of range: {v}"); + } + } + + #[test] + fn first_emission_at_warmup_period() { + let mut rvi = RviVolatility::new(5).unwrap(); + assert_eq!(rvi.warmup_period(), 9); + let prices: Vec = (0..30) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0) + .collect(); + let out = rvi.batch(&prices); + for v in out.iter().take(8) { + assert!(v.is_none(), "indicator must still be warming up"); + } + assert!( + out[8].is_some(), + "first value lands at warmup_period - 1 = 8" + ); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = RviVolatility::new(10).unwrap().batch(&prices); + let mut streamer = RviVolatility::new(10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| streamer.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut rvi = RviVolatility::new(5).unwrap(); + rvi.batch(&(1..=40).map(f64::from).collect::>()); + assert!(rvi.is_ready()); + rvi.reset(); + assert!(!rvi.is_ready()); + assert_eq!(rvi.value(), None); + assert_eq!(rvi.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut rvi = RviVolatility::new(5).unwrap(); + let out = rvi.batch(&(1..=40).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(rvi.update(f64::NAN), last); + assert_eq!(rvi.update(f64::INFINITY), last); + } +} diff --git a/crates/wickra-core/src/indicators/rwi.rs b/crates/wickra-core/src/indicators/rwi.rs new file mode 100644 index 0000000..897c0ad --- /dev/null +++ b/crates/wickra-core/src/indicators/rwi.rs @@ -0,0 +1,347 @@ +//! Random Walk Index (RWI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Random Walk Index output: the bullish (high) and bearish (low) lines. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RwiOutput { + /// `RWI_High` — strength of the trend up vs. a random walk. + pub high: f64, + /// `RWI_Low` — strength of the trend down vs. a random walk. + pub low: f64, +} + +/// Mike Poulos' Random Walk Index — a trend-vs.-random-walk indicator that +/// asks "how many standard deviations away from a random walk is the current +/// move?". +/// +/// For each lookback `i ∈ [2, period]`, RWI computes the ratio of the actual +/// price displacement over `i` bars to the expected displacement of a random +/// walk of the same length: +/// +/// ```text +/// RWI_High_t(i) = (high_t − low_{t-i+1}) / (ATR_i(t) * sqrt(i)) +/// RWI_Low_t(i) = (high_{t-i+1} − low_t) / (ATR_i(t) * sqrt(i)) +/// ``` +/// +/// where `ATR_i(t)` is the simple average of true-range over the most recent +/// `i` bars. The reported `RWI_High_t` / `RWI_Low_t` are the maxima of these +/// ratios across all lookbacks `i ∈ [2, period]`. +/// +/// `RWI_High` crossing above `RWI_Low` and exceeding 1 (`> 2` is the typical +/// strong-trend threshold) signals an uptrend dominating random-walk; the +/// mirror situation flags a downtrend. When both lines are below 1, neither +/// direction beats a random walk and the market is read as ranging. +/// +/// The first output is emitted after `period` candles (the second one provides +/// the first `period = 2` lookback, so the indicator emits at index +/// `period - 1`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Rwi}; +/// +/// let mut indicator = Rwi::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rwi { + period: usize, + /// Rolling window of the most recent `period` candles (oldest at the front). + candles: VecDeque, + /// Rolling window of `period` true-range values aligned with `candles` + /// after the first bar (so `tr[0]` corresponds to `candles[1]`). + trs: VecDeque, + last: Option, +} + +impl Rwi { + /// Construct a new RWI with the given lookback period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + /// Returns [`Error::InvalidPeriod`] if `period < 2` — RWI's shortest + /// lookback is `i = 2`, so a one-bar window would emit nothing. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "RWI requires period >= 2", + }); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(period), + trs: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Rwi { + type Input = Candle; + type Output = RwiOutput; + + fn update(&mut self, candle: Candle) -> Option { + // Compute the true range of this candle vs. the previous close (if any), + // then slide the windows. + let tr = if let Some(prev) = self.candles.back() { + candle.true_range(Some(prev.close)) + } else { + candle.high - candle.low + }; + + if self.candles.len() == self.period { + self.candles.pop_front(); + } + self.candles.push_back(candle); + + // `trs` aligns with `candles` from index 1 onward; only push once we + // have at least one previous candle (the bar's TR-vs-prev is what we + // store). With the first bar in `candles`, no TR is recorded yet. + if self.candles.len() >= 2 { + if self.trs.len() == self.period - 1 { + self.trs.pop_front(); + } + self.trs.push_back(tr); + } + + // Need a full `period` candles before we can scan lookbacks i ∈ [2,period]. + if self.candles.len() < self.period { + return None; + } + + // Slice access for indexed maths. + let candles: Vec<&Candle> = self.candles.iter().collect(); + let trs: Vec = self.trs.iter().copied().collect(); + let n = candles.len(); // == self.period + let last_high = candles[n - 1].high; + let last_low = candles[n - 1].low; + + let mut rwi_high = 0.0_f64; + let mut rwi_low = 0.0_f64; + // For lookback i in [2, period]: compare bar `n - 1` to bar `n - i`. + // The TRs covered are those at trs indices [n - i .. n - 1], which is + // `i - 1` TR values (TR at index n - i is the TR of candle n - i + 1 + // vs. candle n - i, the first TR contributing to the i-bar ATR... or + // strictly the ATR over the i-bar window is the mean of the i-1 TRs + // _between_ those bars). We use the i-1-TR mean to keep the indicator + // strictly causal. + for i in 2..=self.period { + // Trs slice indices (within trs Vec): start = n - i, end = n - 1 (excl.). + // trs has length n - 1; trs[k] = TR of candle k+1 vs candle k. + // count = i - 1, which is >= 1 for i >= 2. + let tr_start = n - i; + let tr_end = n - 1; + let count = tr_end - tr_start; + let atr_i: f64 = trs[tr_start..tr_end].iter().sum::() / (count as f64); + let denom = atr_i * (i as f64).sqrt(); + if denom == 0.0 { + continue; + } + let old_low = candles[n - i].low; + let old_high = candles[n - i].high; + let h = (last_high - old_low) / denom; + let l = (old_high - last_low) / denom; + if h > rwi_high { + rwi_high = h; + } + if l > rwi_low { + rwi_low = l; + } + } + + let out = RwiOutput { + high: rwi_high, + low: rwi_low, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.candles.clear(); + self.trs.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // First emission once the rolling window holds `period` candles. + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "RWI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(c, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Rwi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_period_one() { + assert!(matches!(Rwi::new(1), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let mut r = Rwi::new(14).unwrap(); + assert_eq!(r.period(), 14); + assert_eq!(r.warmup_period(), 14); + assert_eq!(r.name(), "RWI"); + assert!(r.value().is_none()); + for i in 0..30_i64 { + let p = 100.0 + (i as f64); + r.update(candle(p + 1.0, p - 1.0, p, i)); + } + assert!(r.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..40_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + let mut r = Rwi::new(5).unwrap(); + let out = r.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn constant_series_yields_zero_outputs() { + // Flat market: ATR is zero, so all lookbacks short-circuit on the + // denom-zero guard and both lines stay at 0. + let candles: Vec = (0..30_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect(); + let mut r = Rwi::new(5).unwrap(); + let last = r.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last.high, 0.0); + assert_eq!(last.low, 0.0); + } + + #[test] + fn pure_uptrend_high_dominates_low() { + // A monotone uptrend should produce RWI_High >> RWI_Low. + let candles: Vec = (0..40_i64) + .map(|i| { + let base = 100.0 + (i as f64) * 2.0; + candle(base + 1.0, base - 0.5, base + 0.5, i) + }) + .collect(); + let mut r = Rwi::new(14).unwrap(); + let last = r.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last.high > last.low, + "RWI_High {} should exceed RWI_Low {}", + last.high, + last.low + ); + assert!( + last.high > 1.0, + "strong uptrend should exceed 1, got {}", + last.high + ); + } + + #[test] + fn pure_downtrend_low_dominates_high() { + let candles: Vec = (0..40_i64) + .rev() + .map(|i| { + let base = 100.0 + (i as f64) * 2.0; + candle(base + 0.5, base - 1.0, base - 0.5, 40 - i) + }) + .collect(); + let mut r = Rwi::new(14).unwrap(); + let last = r.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last.low > last.high); + assert!(last.low > 1.0); + } + + #[test] + fn outputs_non_negative() { + let candles: Vec = (0..120_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0; + candle(p + 1.5, p - 1.5, p, i) + }) + .collect(); + let mut r = Rwi::new(10).unwrap(); + for v in r.batch(&candles).into_iter().flatten() { + assert!(v.high >= 0.0 && v.low >= 0.0); + assert!(v.high.is_finite() && v.low.is_finite()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + let mut a = Rwi::new(7).unwrap(); + let mut b = Rwi::new(7).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut r = Rwi::new(5).unwrap(); + r.batch(&candles); + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/sample_entropy.rs b/crates/wickra-core/src/indicators/sample_entropy.rs new file mode 100644 index 0000000..b5367af --- /dev/null +++ b/crates/wickra-core/src/indicators/sample_entropy.rs @@ -0,0 +1,337 @@ +//! Sample Entropy (`SampEn`) — the regularity / predictability of a window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Population standard deviation of a slice (used for the matching tolerance). +fn population_stddev(window: &[f64]) -> f64 { + let n = window.len() as f64; + let mean = window.iter().sum::() / n; + let var = window.iter().map(|&v| (v - mean) * (v - mean)).sum::() / n; + var.max(0.0).sqrt() +} + +/// Whether two length-`len` templates starting at `i` and `j` match within the +/// Chebyshev tolerance `tol`. +fn templates_match(window: &[f64], i: usize, j: usize, len: usize, tol: f64) -> bool { + for k in 0..len { + if (window[i + k] - window[j + k]).abs() > tol { + return false; + } + } + true +} + +/// Sample Entropy (`SampEn`) — Richman & Moorman's measure of how *regular* (i.e. +/// predictable) a series is: the negative log conditional probability that two +/// sub-sequences similar for `m` points stay similar at the next point. +/// +/// ```text +/// tol = r_factor · stddev(window) +/// B = # template pairs of length m within tol (i < j) +/// A = # template pairs of length m+1 within tol (i < j) +/// `SampEn` = − ln(A / B) +/// ``` +/// +/// Low `SampEn` means the window is **regular** — patterns of length `m` reliably +/// extend to length `m + 1`, the fingerprint of a trending or cyclic market. High +/// `SampEn` means the series is **irregular** — knowing the last `m` points tells +/// you little about the next, the fingerprint of noise. Unlike the older +/// approximate entropy (`ApEn`), `SampEn` excludes self-matches, so it is far less +/// biased on short windows. +/// +/// The tolerance is `r_factor` times the window's standard deviation, so the +/// measure self-scales. A perfectly flat window (`stddev == 0`) is maximally +/// regular and returns `0`. If no length-`m` pairs match, the entropy is +/// undefined and `0` is returned; if length-`m` pairs match but none extend, the +/// estimator falls back to treating the unseen count as one (`−ln(1/B) = ln(B)`). +/// The first value lands after `period` inputs; each `update` is O(`period²`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SampleEntropy}; +/// +/// let mut indicator = SampleEntropy::new(50, 2, 0.2).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update((f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SampleEntropy { + period: usize, + emb_dim: usize, + r_factor: f64, + window: VecDeque, + last: Option, +} + +impl SampleEntropy { + /// Construct a Sample Entropy over `period` values with embedding dimension + /// `m` and tolerance factor `r_factor`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `m` is `0`, + /// [`Error::InvalidPeriod`] if `period < m + 2` (no length-`m+1` template + /// pairs otherwise), and [`Error::InvalidParameter`] if `r_factor` is not + /// finite and positive. + pub fn new(period: usize, m: usize, r_factor: f64) -> Result { + if period == 0 || m == 0 { + return Err(Error::PeriodZero); + } + if period < m + 2 { + return Err(Error::InvalidPeriod { + message: "sample entropy needs period >= m + 2", + }); + } + if !r_factor.is_finite() || r_factor <= 0.0 { + return Err(Error::InvalidParameter { + message: "sample entropy r_factor must be finite and positive", + }); + } + Ok(Self { + period, + emb_dim: m, + r_factor, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, m, r_factor)`. + pub const fn params(&self) -> (usize, usize, f64) { + (self.period, self.emb_dim, self.r_factor) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> f64 { + let window: Vec = self.window.iter().copied().collect(); + let std = population_stddev(&window); + if std == 0.0 { + return 0.0; + } + let tol = self.r_factor * std; + let m = self.emb_dim; + // Restrict both template lengths to the same index range so A and B share + // their candidate pairs: there are `period − m` length-(m+1) templates. + let count = self.period - m; + let mut matches_m = 0u64; + let mut matches_m1 = 0u64; + for i in 0..count { + for j in (i + 1)..count { + if templates_match(&window, i, j, m, tol) { + matches_m += 1; + if templates_match(&window, i, j, m + 1, tol) { + matches_m1 += 1; + } + } + } + } + if matches_m == 0 { + return 0.0; + } + if matches_m1 == 0 { + // No length-(m+1) matches: fall back to one unseen count. + return (matches_m as f64).ln(); + } + -((matches_m1 as f64) / (matches_m as f64)).ln() + } +} + +impl Indicator for SampleEntropy { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SampleEntropy" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + SampleEntropy::new(0, 2, 0.2), + Err(Error::PeriodZero) + )); + assert!(matches!( + SampleEntropy::new(50, 0, 0.2), + Err(Error::PeriodZero) + )); + assert!(matches!( + SampleEntropy::new(3, 2, 0.2), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + SampleEntropy::new(50, 2, 0.0), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let s = SampleEntropy::new(50, 2, 0.2).unwrap(); + assert_eq!(s.params(), (50, 2, 0.2)); + assert_eq!(s.warmup_period(), 50); + assert_eq!(s.name(), "SampleEntropy"); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut s = SampleEntropy::new(10, 2, 0.2).unwrap(); + let xs: Vec = (0..14).map(|i| (f64::from(i) * 0.5).sin()).collect(); + let out = s.batch(&xs); + for v in out.iter().take(9) { + assert!(v.is_none()); + } + assert!(out[9].is_some()); + } + + #[test] + fn constant_window_is_zero() { + let mut s = SampleEntropy::new(20, 2, 0.2).unwrap(); + let last = s.batch(&[5.0; 30]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_is_non_negative() { + let mut s = SampleEntropy::new(40, 2, 0.2).unwrap(); + for v in s + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 5.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!(v >= 0.0, "sample entropy must be non-negative, got {v}"); + } + } + + #[test] + fn regular_below_irregular() { + // A smooth sine is far more regular (lower `SampEn`) than a chaotic + // logistic-map series. (An *alternating* series would be periodic, hence + // regular too -- chaos is what makes the window genuinely unpredictable.) + let smooth: Vec = (0..60).map(|i| (f64::from(i) * 0.2).sin() * 5.0).collect(); + let mut x = 0.37_f64; + let chaotic: Vec = (0..60) + .map(|_| { + x = 3.99 * x * (1.0 - x); + x * 5.0 + }) + .collect(); + let s_smooth = SampleEntropy::new(50, 2, 0.2) + .unwrap() + .batch(&smooth) + .into_iter() + .flatten() + .last() + .unwrap(); + let s_chaotic = SampleEntropy::new(50, 2, 0.2) + .unwrap() + .batch(&chaotic) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!( + s_smooth <= s_chaotic, + "smooth ({s_smooth}) should be <= chaotic ({s_chaotic})" + ); + } + + #[test] + fn ignores_non_finite() { + let mut s = SampleEntropy::new(10, 2, 0.2).unwrap(); + let xs: Vec = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect(); + let ready = s.batch(&xs).into_iter().flatten().last().unwrap(); + assert_eq!(s.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut s = SampleEntropy::new(10, 2, 0.2).unwrap(); + let xs: Vec = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect(); + s.batch(&xs); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + assert_eq!(s.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = SampleEntropy::new(40, 2, 0.2).unwrap().batch(&xs); + let mut b = SampleEntropy::new(40, 2, 0.2).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn falls_back_when_no_m_plus_one_matches() { + // `[1, 1, 1, 5]` with m = 2: the length-2 template `(1, 1)` repeats + // (matches_m > 0) but no length-3 template repeats (matches_m1 == 0), + // so SampEn takes the `ln(matches_m)` fallback branch. + let xs = [1.0, 1.0, 1.0, 5.0]; + let v = SampleEntropy::new(4, 2, 0.2) + .unwrap() + .batch(&xs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(v.is_finite() && v >= 0.0, "got {v}"); + } +} diff --git a/crates/wickra-core/src/indicators/sar_ext.rs b/crates/wickra-core/src/indicators/sar_ext.rs new file mode 100644 index 0000000..bdf6338 --- /dev/null +++ b/crates/wickra-core/src/indicators/sar_ext.rs @@ -0,0 +1,418 @@ +//! Parabolic SAR Extended (SAREXT). + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Trend { + Up, + Down, +} + +/// One direction's acceleration-factor schedule (initial, step, maximum). +#[derive(Debug, Clone, Copy)] +struct Accel { + init: f64, + step: f64, + max: f64, +} + +impl Accel { + fn validate(self) -> Result { + if !(self.init.is_finite() && self.step.is_finite() && self.max.is_finite()) { + return Err(Error::NonPositiveMultiplier); + } + if self.init <= 0.0 || self.step <= 0.0 || self.max <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + if self.init > self.max { + return Err(Error::InvalidPeriod { + message: "acceleration init must be <= max", + }); + } + Ok(self) + } +} + +/// Parabolic SAR Extended (`SAREXT`): Wilder's Parabolic SAR with TA-Lib's +/// extended controls. +/// +/// Beyond [`Psar`](crate::Psar) it adds: +/// - **`start_value`** — the initial SAR. `0` auto-seeds (long, like `Psar`); +/// a positive value starts a long phase at that SAR, a negative value starts a +/// short phase at its absolute value. +/// - **`offset_on_reverse`** — a fractional offset applied to the new SAR on each +/// reversal, pushing it further from price (`0` disables it). +/// - **separate long / short acceleration** — independent `(init, step, max)` +/// schedules for rising and falling phases. +/// +/// The output is **signed**: a positive value during a long phase (SAR below +/// price) and a negative value during a short phase (SAR above price), so the +/// sign alone encodes the current trade direction. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SarExt}; +/// +/// let mut indicator = +/// SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SarExt { + start_value: f64, + offset_on_reverse: f64, + long: Accel, + short: Accel, + + initialised: bool, + has_emitted: bool, + prev_high: f64, + prev_low: f64, + trend: Trend, + sar: f64, + ep: f64, + af: f64, +} + +impl SarExt { + /// Construct an extended Parabolic SAR. + /// + /// Parameters mirror TA-Lib's `SAREXT`: `start_value`, `offset_on_reverse`, + /// then the long `(init, step, max)` and short `(init, step, max)` + /// acceleration schedules. + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if any acceleration term is + /// non-positive or non-finite, [`Error::InvalidPeriod`] if an `init` exceeds + /// its `max`, and [`Error::NonPositiveMultiplier`] if `start_value` or + /// `offset_on_reverse` is non-finite or `offset_on_reverse` is negative. + #[allow(clippy::too_many_arguments)] + pub fn new( + start_value: f64, + offset_on_reverse: f64, + accel_init_long: f64, + accel_long: f64, + accel_max_long: f64, + accel_init_short: f64, + accel_short: f64, + accel_max_short: f64, + ) -> Result { + if !start_value.is_finite() || !offset_on_reverse.is_finite() || offset_on_reverse < 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let long = Accel { + init: accel_init_long, + step: accel_long, + max: accel_max_long, + } + .validate()?; + let short = Accel { + init: accel_init_short, + step: accel_short, + max: accel_max_short, + } + .validate()?; + Ok(Self { + start_value, + offset_on_reverse, + long, + short, + initialised: false, + has_emitted: false, + prev_high: f64::NAN, + prev_low: f64::NAN, + trend: Trend::Up, + sar: f64::NAN, + ep: f64::NAN, + af: long.init, + }) + } + + /// Wilder's defaults with no start value or reversal offset and symmetric + /// `(0.02, 0.02, 0.20)` acceleration in both directions. + pub fn classic() -> Self { + Self::new(0.0, 0.0, 0.02, 0.02, 0.20, 0.02, 0.02, 0.20) + .expect("classic SAREXT params are valid") + } + + fn signed(&self, sar: f64) -> f64 { + match self.trend { + Trend::Up => sar, + Trend::Down => -sar, + } + } +} + +impl Indicator for SarExt { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if !self.initialised { + self.prev_high = candle.high; + self.prev_low = candle.low; + if self.start_value > 0.0 { + self.trend = Trend::Up; + self.sar = self.start_value; + self.ep = candle.high; + self.af = self.long.init; + } else if self.start_value < 0.0 { + self.trend = Trend::Down; + self.sar = -self.start_value; + self.ep = candle.low; + self.af = self.short.init; + } else { + self.trend = Trend::Up; + self.sar = candle.low; + self.ep = candle.high; + self.af = self.long.init; + } + self.initialised = true; + return None; + } + + let mut new_sar = self.sar + self.af * (self.ep - self.sar); + let prev_h = self.prev_high; + let prev_l = self.prev_low; + new_sar = match self.trend { + Trend::Up => new_sar.min(prev_l).min(candle.low), + Trend::Down => new_sar.max(prev_h).max(candle.high), + }; + + let mut output_sar = new_sar; + let reversed = match self.trend { + Trend::Up => candle.low <= new_sar, + Trend::Down => candle.high >= new_sar, + }; + + if reversed { + output_sar = self.ep; + self.trend = match self.trend { + Trend::Up => Trend::Down, + Trend::Down => Trend::Up, + }; + match self.trend { + Trend::Up => { + output_sar -= output_sar.abs() * self.offset_on_reverse; + self.ep = candle.high; + self.af = self.long.init; + } + Trend::Down => { + output_sar += output_sar.abs() * self.offset_on_reverse; + self.ep = candle.low; + self.af = self.short.init; + } + } + } else { + match self.trend { + Trend::Up => { + if candle.high > self.ep { + self.ep = candle.high; + self.af = (self.af + self.long.step).min(self.long.max); + } + } + Trend::Down => { + if candle.low < self.ep { + self.ep = candle.low; + self.af = (self.af + self.short.step).min(self.short.max); + } + } + } + } + + self.sar = output_sar; + self.prev_high = candle.high; + self.prev_low = candle.low; + self.has_emitted = true; + Some(self.signed(output_sar)) + } + + fn reset(&mut self) { + self.initialised = false; + self.has_emitted = false; + self.prev_high = f64::NAN; + self.prev_low = f64::NAN; + self.trend = Trend::Up; + self.sar = f64::NAN; + self.ep = f64::NAN; + self.af = self.long.init; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "SAREXT" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + fn classic() -> SarExt { + SarExt::classic() + } + + #[test] + fn rejects_invalid_params() { + // Non-positive acceleration terms. + assert!(SarExt::new(0.0, 0.0, 0.0, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.0, 0.02, 0.2).is_err()); + assert!(SarExt::new(0.0, 0.0, 0.30, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + // Non-finite acceleration terms hit the finite guard in `Accel::validate`, + // on both the long and the short schedule. + assert!(SarExt::new(0.0, 0.0, f64::NAN, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, f64::INFINITY, 0.2).is_err()); + // Bad start value / offset. + assert!(SarExt::new(f64::NAN, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + assert!(SarExt::new(0.0, -1.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = classic(); + assert_eq!(s.warmup_period(), 2); + assert_eq!(s.name(), "SAREXT"); + assert!(!s.is_ready()); + } + + #[test] + fn seed_returns_none_then_emits() { + let mut s = classic(); + assert_eq!(s.update(c(11.0, 9.0, 10.0)), None); + assert!(!s.is_ready()); + assert!(s.update(c(12.0, 10.0, 11.0)).is_some()); + assert!(s.is_ready()); + } + + #[test] + fn uptrend_is_positive_and_below_lows() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 0.5, base - 0.5, base) + }) + .collect(); + let mut s = classic(); + let ok = s + .batch(&candles) + .iter() + .enumerate() + .all(|(i, v)| v.is_none_or(|x| x > 0.0 && x <= candles[i].low + 1e-9)); + assert!(ok, "long-phase SAREXT must be positive and below the low"); + } + + #[test] + fn downtrend_is_negative_and_above_highs() { + let candles: Vec = (0..40) + .rev() + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 0.5, base - 0.5, base) + }) + .collect(); + let mut s = classic(); + let ok = s + .batch(&candles) + .iter() + .enumerate() + .skip(5) + .all(|(i, v)| v.is_none_or(|x| x < 0.0 && -x >= candles[i].high - 1e-9)); + assert!(ok, "short-phase SAREXT must be negative and above the high"); + } + + #[test] + fn positive_start_value_begins_long() { + // start_value > 0 seeds a long phase: first emitted value is positive. + let mut s = SarExt::new(95.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap(); + assert_eq!(s.update(c(101.0, 99.0, 100.0)), None); + let v = s.update(c(102.0, 100.0, 101.0)).unwrap(); + assert!(v > 0.0); + } + + #[test] + fn negative_start_value_begins_short() { + // start_value < 0 seeds a short phase: first emitted value is negative. + let mut s = SarExt::new(-105.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap(); + assert_eq!(s.update(c(101.0, 99.0, 100.0)), None); + let v = s.update(c(100.0, 98.0, 99.0)).unwrap(); + assert!(v < 0.0); + } + + #[test] + fn offset_on_reverse_pushes_sar_further() { + // A V-shaped path forces a reversal; with an offset the reversal SAR is + // pushed further from price than without one. + let candles: Vec = (0..12) + .map(|i| { + let base = if i < 6 { + 100.0 - f64::from(i) * 2.0 + } else { + 88.0 + f64::from(i - 6) * 2.0 + }; + c(base + 1.0, base - 1.0, base) + }) + .collect(); + let plain = SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2) + .unwrap() + .batch(&candles); + let offset = SarExt::new(0.0, 0.1, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2) + .unwrap() + .batch(&candles); + // The two configurations must diverge once a reversal with offset fires. + assert_ne!(plain, offset); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut a = classic(); + let mut b = classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_allows_clean_reuse() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + c(base + 0.5, base - 0.5, base) + }) + .collect(); + let mut s = classic(); + let first = s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(first, s.batch(&candles)); + } +} diff --git a/crates/wickra-core/src/indicators/seasonal_z_score.rs b/crates/wickra-core/src/indicators/seasonal_z_score.rs new file mode 100644 index 0000000..207955d --- /dev/null +++ b/crates/wickra-core/src/indicators/seasonal_z_score.rs @@ -0,0 +1,232 @@ +//! Seasonal Z-Score — how far the current bar's return sits from the historical +//! mean return of bars in the *same hour of day*, in standard deviations. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +const HOURS: usize = 24; + +/// Seasonal Z-Score keyed on hour of day. +/// +/// For every bar the indicator forms the simple return `close / previous_close - 1` +/// and compares it to the running mean and standard deviation of all prior +/// returns that fell in the *same* local hour (the wall-clock hour of +/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`). The +/// output is `(return - hour_mean) / hour_std`. A bucket needs at least two prior +/// samples before it can emit; a bucket with zero historical variance reports +/// `0.0`. The per-hour statistics use Welford's online algorithm. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SeasonalZScore}; +/// +/// let day = 24 * 3_600_000; +/// let mut z = SeasonalZScore::new(0); +/// // Same hour each day so they share a bucket; close grows then jumps. +/// for (i, close) in [100.0, 101.0, 103.0].iter().enumerate() { +/// z.update(Candle::new(*close, *close, *close, *close, 1.0, i as i64 * day).unwrap()); +/// } +/// // Fourth same-hour sample has two priors in the bucket -> emits a z-score. +/// let out = z.update(Candle::new(110.0, 110.0, 110.0, 110.0, 1.0, 3 * day).unwrap()); +/// assert!(out.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SeasonalZScore { + utc_offset_minutes: i32, + prev_close: Option, + count: [u64; HOURS], + mean: [f64; HOURS], + m2: [f64; HOURS], + last: Option, +} + +impl SeasonalZScore { + /// Construct a Seasonal Z-Score indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + prev_close: None, + count: [0; HOURS], + mean: [0.0; HOURS], + m2: [0.0; HOURS], + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent z-score if a populated bucket has produced one. + pub const fn value(&self) -> Option { + self.last + } + + fn z_for(&self, hour: usize, ret: f64) -> Option { + if self.count[hour] < 2 { + return None; + } + let variance = self.m2[hour] / (self.count[hour] - 1) as f64; + if variance > 0.0 { + Some((ret - self.mean[hour]) / variance.sqrt()) + } else { + Some(0.0) + } + } + + fn accumulate(&mut self, hour: usize, ret: f64) { + self.count[hour] += 1; + let delta = ret - self.mean[hour]; + self.mean[hour] += delta / self.count[hour] as f64; + let delta2 = ret - self.mean[hour]; + self.m2[hour] += delta * delta2; + } +} + +impl Indicator for SeasonalZScore { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let hour = civil.hour as usize; + let result = if let Some(prev) = self.prev_close { + let ret = if prev == 0.0 { + 0.0 + } else { + candle.close / prev - 1.0 + }; + let z = self.z_for(hour, ret); + self.accumulate(hour, ret); + z + } else { + None + }; + self.prev_close = Some(candle.close); + if result.is_some() { + self.last = result; + } + result + } + + fn reset(&mut self) { + self.prev_close = None; + self.count = [0; HOURS]; + self.mean = [0.0; HOURS]; + self.m2 = [0.0; HOURS]; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SeasonalZScore" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const DAY: i64 = 24 * 3_600_000; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let z = SeasonalZScore::new(120); + assert_eq!(z.utc_offset_minutes(), 120); + assert_eq!(z.name(), "SeasonalZScore"); + assert_eq!(z.warmup_period(), 2); + assert!(!z.is_ready()); + assert!(z.value().is_none()); + } + + #[test] + fn no_output_until_bucket_has_two_priors() { + let mut z = SeasonalZScore::new(0); + // Each bar shares the same hour bucket (same time-of-day, daily spacing). + assert!(z.update(c(100.0, 0)).is_none()); // first: no return + assert!(z.update(c(101.0, DAY)).is_none()); // return #1 -> bucket has 0 priors + assert!(z.update(c(102.0, 2 * DAY)).is_none()); // return #2 -> bucket has 1 prior + // return #3 -> bucket has 2 priors -> emits. + assert!(z.update(c(104.0, 3 * DAY)).is_some()); + assert!(z.is_ready()); + } + + #[test] + fn z_score_matches_manual_welford() { + let mut z = SeasonalZScore::new(0); + // Returns into one hourly bucket: r1 = 0.01, r2 = 0.02, r3 = 0.03. + z.update(c(100.0, 0)); + z.update(c(101.0, DAY)); // r1 = 0.01 + z.update(c(103.02, 2 * DAY)); // r2 = 0.02 + // Priors {0.01, 0.02}: mean 0.015, sample std = sqrt(((.005)^2*2)/1). + let mean = 0.015; + let std = (((0.01_f64 - mean).powi(2) + (0.02 - mean).powi(2)) / 1.0).sqrt(); + let r3 = 0.03; + let expected = (r3 - mean) / std; + let close = 103.02 * (1.0 + r3); + let out = z.update(c(close, 3 * DAY)).unwrap(); + assert_relative_eq!(out, expected, epsilon = 1e-9); + } + + #[test] + fn zero_variance_bucket_reports_zero() { + let mut z = SeasonalZScore::new(0); + // Constant return into the bucket -> variance 0 -> z = 0. + z.update(c(100.0, 0)); + z.update(c(110.0, DAY)); // r1 = 0.10 + z.update(c(121.0, 2 * DAY)); // r2 = 0.10 + let out = z.update(c(133.1, 3 * DAY)).unwrap(); // r3 = 0.10 + assert_relative_eq!(out, 0.0); + } + + #[test] + fn zero_prev_close_uses_zero_return() { + let mut z = SeasonalZScore::new(0); + z.update(c(0.0, 0)); // prev close 0 + z.update(c(0.0, DAY)); // ret = 0 (guarded), bucket sample + z.update(c(0.0, 2 * DAY)); // ret = 0, bucket now 2 priors + let out = z.update(c(0.0, 3 * DAY)).unwrap(); + assert_relative_eq!(out, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut z = SeasonalZScore::new(0); + for i in 0..4 { + z.update(c(100.0 + f64::from(i), i64::from(i) * DAY)); + } + z.reset(); + assert!(!z.is_ready()); + assert!(z.value().is_none()); + assert!(z.update(c(100.0, 4 * DAY)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(100.0 + f64::from(i % 9), i64::from(i) * 3 * 3_600_000)) + .collect(); + let mut a = SeasonalZScore::new(0); + let mut b = SeasonalZScore::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/separating_lines.rs b/crates/wickra-core/src/indicators/separating_lines.rs new file mode 100644 index 0000000..1989723 --- /dev/null +++ b/crates/wickra-core/src/indicators/separating_lines.rs @@ -0,0 +1,218 @@ +//! Separating Lines candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Separating Lines — a 2-bar continuation. After a counter-trend candle, the next +/// candle of the *opposite* colour opens right back at the prior open and runs as +/// an opening marubozu in the trend direction, so the trend "separates" from the +/// pullback and resumes. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1, bar2 opposite colours +/// bar2 opens at bar1's open (|open2 − open1| <= 0.05 · range1) +/// bar2 is a long opening marubozu in its direction +/// white bar2: open2 == low2 (no lower shadow) -> +1.0 +/// black bar2: open2 == high2 (no upper shadow) -> −1.0 +/// ``` +/// +/// Output is `+1.0` (bullish continuation) or `−1.0` (bearish continuation) when +/// the pattern completes and `0.0` otherwise. The first bar always returns `0.0` +/// because the two-bar window is not yet filled. Open-equality and marubozu +/// thresholds follow the geometric house style rather than TA-Lib's rolling +/// averages. Pattern-shape check only — no trend filter is applied; combine with +/// a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the two directions +/// occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SeparatingLines}; +/// +/// let mut indicator = SeparatingLines::new(); +/// indicator.update(Candle::new(12.0, 12.1, 9.9, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(12.0, 14.1, 12.0, 14.0, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct SeparatingLines { + prev: Option, + has_emitted: bool, +} + +impl SeparatingLines { + /// Construct a new Separating Lines detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for SeparatingLines { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + let range2 = candle.high - candle.low; + if range1 <= 0.0 || range2 <= 0.0 { + return Some(0.0); + } + // Opens must coincide. + if (candle.open - bar1.open).abs() > 0.05 * range1 { + return Some(0.0); + } + let body2 = candle.close - candle.open; + if body2.abs() < 0.5 * range2 { + return Some(0.0); // bar2 must be a long body + } + let tol = 0.05 * range2; + // Bullish: bar1 black, bar2 a long white opening marubozu (no lower wick). + if bar1.close < bar1.open && body2 > 0.0 && candle.open - candle.low <= tol { + return Some(1.0); + } + // Bearish: bar1 white, bar2 a long black opening marubozu (no upper wick). + if bar1.close > bar1.open && body2 < 0.0 && candle.high - candle.open <= tol { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "SeparatingLines" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = SeparatingLines::new(); + assert_eq!(t.name(), "SeparatingLines"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_separating_lines_is_plus_one() { + let mut t = SeparatingLines::new(); + assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 14.1, 12.0, 14.0, 1)), Some(1.0)); + } + + #[test] + fn bearish_separating_lines_is_minus_one() { + let mut t = SeparatingLines::new(); + assert_eq!(t.update(c(10.0, 12.1, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.0, 10.0, 7.9, 8.0, 1)), Some(-1.0)); + } + + #[test] + fn same_color_yields_zero() { + let mut t = SeparatingLines::new(); + // Both white -> not separating (need opposite colours). + t.update(c(12.0, 14.1, 11.9, 14.0, 0)); + assert_eq!(t.update(c(12.0, 14.1, 12.0, 14.0, 1)), Some(0.0)); + } + + #[test] + fn different_open_yields_zero() { + let mut t = SeparatingLines::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + // bar2 opens far from bar1's open. + assert_eq!(t.update(c(13.0, 15.1, 13.0, 15.0, 1)), Some(0.0)); + } + + #[test] + fn opening_shadow_yields_zero() { + let mut t = SeparatingLines::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + // White bar2 but it has a lower shadow -> not an opening marubozu. + assert_eq!(t.update(c(12.0, 14.1, 11.0, 14.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = SeparatingLines::new(); + assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.0, base, base + 1.9, i) + }) + .collect(); + let mut a = SeparatingLines::new(); + let mut b = SeparatingLines::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = SeparatingLines::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + t.update(c(12.0, 14.1, 12.0, 14.0, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = SeparatingLines::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + assert_eq!(t.update(c(10.0, 12.0, 9.0, 11.0, 1)), Some(0.0)); + } + + #[test] + fn short_second_body_yields_zero() { + let mut t = SeparatingLines::new(); + t.update(c(10.0, 12.0, 8.0, 9.0, 0)); + // Opens coincide but bar2's body is too short to be a separating line. + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.1, 1)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/session_high_low.rs b/crates/wickra-core/src/indicators/session_high_low.rs new file mode 100644 index 0000000..0d32a64 --- /dev/null +++ b/crates/wickra-core/src/indicators/session_high_low.rs @@ -0,0 +1,226 @@ +//! Session High/Low — the running high and low of the current calendar-day +//! session, re-anchored automatically at each day boundary. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Session High/Low output: the high and low established so far in the current +/// session. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SessionHighLowOutput { + /// Highest high seen since the current session opened. + pub high: f64, + /// Lowest low seen since the current session opened. + pub low: f64, +} + +/// Running high / low of the current session, keyed off the wall-clock day of +/// [`Candle::timestamp`](crate::Candle). +/// +/// Unlike [`crate::OpeningRange`] or [`crate::InitialBalance`], which require the +/// caller to invoke `reset()` at every session boundary, this indicator detects +/// the boundary itself: whenever a candle falls on a different local calendar +/// day (after shifting by `utc_offset_minutes`) the high / low are re-anchored to +/// that candle. `utc_offset_minutes` lets callers align the day boundary to an +/// exchange session — `0` for UTC, `-300` for U.S. Eastern standard time. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SessionHighLow}; +/// +/// // One bar per hour; the day rolls over after 24 bars at UTC. +/// let mut shl = SessionHighLow::new(0); +/// let hour = 3_600_000; +/// shl.update(Candle::new(100.0, 105.0, 99.0, 101.0, 1.0, 0).unwrap()); +/// let v = shl.update(Candle::new(101.0, 108.0, 100.0, 107.0, 1.0, hour).unwrap()).unwrap(); +/// assert_eq!(v.high, 108.0); +/// assert_eq!(v.low, 99.0); +/// // A bar on the next day re-anchors to that bar alone. +/// let v = shl.update(Candle::new(50.0, 51.0, 49.0, 50.0, 1.0, 24 * hour).unwrap()).unwrap(); +/// assert_eq!(v.high, 51.0); +/// assert_eq!(v.low, 49.0); +/// ``` +#[derive(Debug, Clone)] +pub struct SessionHighLow { + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + high: f64, + low: f64, + last: Option, +} + +impl SessionHighLow { + /// Construct a Session High/Low indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + day_key: None, + high: f64::NEG_INFINITY, + low: f64::INFINITY, + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent output if at least one bar has been seen. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for SessionHighLow { + type Input = Candle; + type Output = SessionHighLowOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + if self.day_key == Some(key) { + if candle.high > self.high { + self.high = candle.high; + } + if candle.low < self.low { + self.low = candle.low; + } + } else { + self.day_key = Some(key); + self.high = candle.high; + self.low = candle.low; + } + let out = SessionHighLowOutput { + high: self.high, + low: self.low, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.day_key = None; + self.high = f64::NEG_INFINITY; + self.low = f64::INFINITY; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SessionHighLow" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + let mid = f64::midpoint(high, low); + Candle::new(mid, high, low, mid, 1.0, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let shl = SessionHighLow::new(-300); + assert_eq!(shl.utc_offset_minutes(), -300); + assert_eq!(shl.name(), "SessionHighLow"); + assert_eq!(shl.warmup_period(), 1); + assert!(!shl.is_ready()); + assert!(shl.value().is_none()); + } + + #[test] + fn tracks_high_low_within_day() { + let mut shl = SessionHighLow::new(0); + let first = shl.update(c(105.0, 99.0, 0)).unwrap(); + assert_relative_eq!(first.high, 105.0); + assert_relative_eq!(first.low, 99.0); + assert!(shl.is_ready()); + let second = shl.update(c(108.0, 100.0, HOUR)).unwrap(); + assert_relative_eq!(second.high, 108.0); + assert_relative_eq!(second.low, 99.0); + // A narrower bar does not shrink the range. + let third = shl.update(c(106.0, 101.0, 2 * HOUR)).unwrap(); + assert_relative_eq!(third.high, 108.0); + assert_relative_eq!(third.low, 99.0); + // A bar with a lower low extends the range downward (same day). + let fourth = shl.update(c(107.0, 95.0, 3 * HOUR)).unwrap(); + assert_relative_eq!(fourth.high, 108.0); + assert_relative_eq!(fourth.low, 95.0); + } + + #[test] + fn re_anchors_on_new_day() { + let mut shl = SessionHighLow::new(0); + shl.update(c(105.0, 99.0, 0)); + shl.update(c(108.0, 100.0, HOUR)); + let next = shl.update(c(51.0, 49.0, 24 * HOUR)).unwrap(); + assert_relative_eq!(next.high, 51.0); + assert_relative_eq!(next.low, 49.0); + } + + #[test] + fn utc_offset_shifts_day_boundary() { + // Two bars 1h apart straddling UTC midnight. At UTC they are different + // days; at +120 min they fall on the same local day. + let pre = 23 * HOUR; // 1970-01-01 23:00 UTC + let post = 24 * HOUR; // 1970-01-02 00:00 UTC + let mut utc = SessionHighLow::new(0); + utc.update(c(105.0, 99.0, pre)); + let rolled = utc.update(c(108.0, 100.0, post)).unwrap(); + assert_relative_eq!(rolled.high, 108.0); + assert_relative_eq!(rolled.low, 100.0); // re-anchored + + let mut shifted = SessionHighLow::new(120); + shifted.update(c(105.0, 99.0, pre)); + let same = shifted.update(c(108.0, 100.0, post)).unwrap(); + assert_relative_eq!(same.high, 108.0); + assert_relative_eq!(same.low, 99.0); // same local day, range kept + } + + #[test] + fn reset_clears_state() { + let mut shl = SessionHighLow::new(0); + shl.update(c(105.0, 99.0, 0)); + shl.reset(); + assert!(!shl.is_ready()); + assert!(shl.value().is_none()); + let after = shl.update(c(60.0, 50.0, HOUR)).unwrap(); + assert_relative_eq!(after.high, 60.0); + assert_relative_eq!(after.low, 50.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + c( + 100.0 + f64::from(i), + 90.0 + f64::from(i) * 0.5, + i64::from(i) * HOUR, + ) + }) + .collect(); + let mut a = SessionHighLow::new(0); + let mut b = SessionHighLow::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/session_range.rs b/crates/wickra-core/src/indicators/session_range.rs new file mode 100644 index 0000000..06fa49d --- /dev/null +++ b/crates/wickra-core/src/indicators/session_range.rs @@ -0,0 +1,248 @@ +//! Session Range — the high-minus-low range accumulated within each of the +//! three canonical trading sessions (Asia / EU / US) of the current day. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Session Range output: the current day's range within each session. +/// +/// A session with no bars yet reports `0.0`. All three reset at the local day +/// boundary. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SessionRangeOutput { + /// High − low within the Asia session (local hours `00:00..08:00`). + pub asia: f64, + /// High − low within the EU session (local hours `08:00..16:00`). + pub eu: f64, + /// High − low within the US session (local hours `16:00..24:00`). + pub us: f64, +} + +#[derive(Debug, Clone, Copy)] +struct Extent { + high: f64, + low: f64, +} + +impl Extent { + const EMPTY: Self = Self { + high: f64::NEG_INFINITY, + low: f64::INFINITY, + }; + + fn add(&mut self, candle: Candle) { + if candle.high > self.high { + self.high = candle.high; + } + if candle.low < self.low { + self.low = candle.low; + } + } + + fn range(self) -> f64 { + if self.high >= self.low { + self.high - self.low + } else { + 0.0 + } + } +} + +/// Per-session high-low range, keyed off the wall-clock hour of +/// [`Candle::timestamp`](crate::Candle). +/// +/// The local day (after shifting by `utc_offset_minutes`) is split into three +/// eight-hour sessions: **Asia** `00:00..08:00`, **EU** `08:00..16:00`, **US** +/// `16:00..24:00`. Each session accumulates its own high / low; the reported +/// range is `high - low`, or `0.0` before that session has seen a bar. All three +/// re-anchor automatically at the day boundary. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SessionRange}; +/// +/// let hour = 3_600_000; +/// let mut sr = SessionRange::new(0); +/// // 02:00 UTC — Asia session. +/// sr.update(Candle::new(100.0, 104.0, 98.0, 101.0, 1.0, 2 * hour).unwrap()); +/// // 10:00 UTC — EU session. +/// let v = sr.update(Candle::new(101.0, 110.0, 100.0, 109.0, 1.0, 10 * hour).unwrap()).unwrap(); +/// assert_eq!(v.asia, 6.0); +/// assert_eq!(v.eu, 10.0); +/// assert_eq!(v.us, 0.0); +/// ``` +#[derive(Debug, Clone)] +pub struct SessionRange { + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + sessions: [Extent; 3], + last: Option, +} + +impl SessionRange { + /// Construct a Session Range indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + day_key: None, + sessions: [Extent::EMPTY; 3], + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent output if at least one bar has been seen. + pub const fn value(&self) -> Option { + self.last + } + + fn snapshot(&self) -> SessionRangeOutput { + SessionRangeOutput { + asia: self.sessions[0].range(), + eu: self.sessions[1].range(), + us: self.sessions[2].range(), + } + } +} + +impl Indicator for SessionRange { + type Input = Candle; + type Output = SessionRangeOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + if self.day_key != Some(key) { + self.day_key = Some(key); + self.sessions = [Extent::EMPTY; 3]; + } + let session = (civil.hour / 8) as usize; // 0 Asia, 1 EU, 2 US + self.sessions[session].add(candle); + let out = self.snapshot(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.day_key = None; + self.sessions = [Extent::EMPTY; 3]; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SessionRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(high: f64, low: f64, ts: i64) -> Candle { + let mid = f64::midpoint(high, low); + Candle::new(mid, high, low, mid, 1.0, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let sr = SessionRange::new(60); + assert_eq!(sr.utc_offset_minutes(), 60); + assert_eq!(sr.name(), "SessionRange"); + assert_eq!(sr.warmup_period(), 1); + assert!(!sr.is_ready()); + assert!(sr.value().is_none()); + } + + #[test] + fn assigns_bars_to_sessions() { + let mut sr = SessionRange::new(0); + let asia = sr.update(c(104.0, 98.0, 2 * HOUR)).unwrap(); + assert_relative_eq!(asia.asia, 6.0); + assert_relative_eq!(asia.eu, 0.0); + assert_relative_eq!(asia.us, 0.0); + assert!(sr.is_ready()); + let eu = sr.update(c(110.0, 100.0, 10 * HOUR)).unwrap(); + assert_relative_eq!(eu.eu, 10.0); + let us = sr.update(c(120.0, 118.0, 20 * HOUR)).unwrap(); + assert_relative_eq!(us.us, 2.0); + assert_relative_eq!(us.asia, 6.0); + } + + #[test] + fn widens_within_one_session() { + let mut sr = SessionRange::new(0); + sr.update(c(104.0, 98.0, HOUR)); + let wider = sr.update(c(106.0, 95.0, 3 * HOUR)).unwrap(); + assert_relative_eq!(wider.asia, 11.0); + } + + #[test] + fn resets_sessions_on_new_day() { + let mut sr = SessionRange::new(0); + sr.update(c(104.0, 98.0, 2 * HOUR)); + sr.update(c(110.0, 100.0, 10 * HOUR)); + let next = sr.update(c(101.0, 99.0, (24 + 2) * HOUR)).unwrap(); + assert_relative_eq!(next.asia, 2.0); + assert_relative_eq!(next.eu, 0.0); + } + + #[test] + fn utc_offset_moves_bar_between_sessions() { + // 07:00 UTC is Asia; shifted +120 min it becomes 09:00 -> EU. + let mut utc = SessionRange::new(0); + let a = utc.update(c(104.0, 98.0, 7 * HOUR)).unwrap(); + assert_relative_eq!(a.asia, 6.0); + assert_relative_eq!(a.eu, 0.0); + + let mut shifted = SessionRange::new(120); + let e = shifted.update(c(104.0, 98.0, 7 * HOUR)).unwrap(); + assert_relative_eq!(e.asia, 0.0); + assert_relative_eq!(e.eu, 6.0); + } + + #[test] + fn reset_clears_state() { + let mut sr = SessionRange::new(0); + sr.update(c(104.0, 98.0, 2 * HOUR)); + sr.reset(); + assert!(!sr.is_ready()); + assert!(sr.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + c( + 100.0 + f64::from(i % 5), + 95.0 - f64::from(i % 3), + i64::from(i) * HOUR, + ) + }) + .collect(); + let mut a = SessionRange::new(0); + let mut b = SessionRange::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/session_vwap.rs b/crates/wickra-core/src/indicators/session_vwap.rs new file mode 100644 index 0000000..c14f74f --- /dev/null +++ b/crates/wickra-core/src/indicators/session_vwap.rs @@ -0,0 +1,199 @@ +//! Session VWAP — the volume-weighted average price accumulated since the start +//! of the current calendar-day session, re-anchored automatically each day. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-weighted average price reset at each local day boundary. +/// +/// Each bar contributes its typical price `(high + low + close) / 3` weighted by +/// volume. The running VWAP is `Σ(typical · volume) / Σ volume` over the current +/// session; if the session's volume is still zero the indicator falls back to the +/// latest typical price so the output is always finite. The session boundary is +/// the wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by +/// `utc_offset_minutes`. +/// +/// Where [`crate::RollingVwap`] averages over a fixed bar window and +/// [`crate::AnchoredVwap`] anchors at a caller-chosen bar, Session VWAP anchors +/// at the automatically detected day open. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SessionVwap}; +/// +/// let hour = 3_600_000; +/// let mut vwap = SessionVwap::new(0); +/// // typical = 100, volume 10. +/// vwap.update(Candle::new(100.0, 100.0, 100.0, 100.0, 10.0, 0).unwrap()); +/// // typical = 110, volume 30 -> VWAP = (100*10 + 110*30) / 40 = 107.5. +/// let v = vwap.update(Candle::new(110.0, 110.0, 110.0, 110.0, 30.0, hour).unwrap()).unwrap(); +/// assert!((v - 107.5).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct SessionVwap { + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + cum_pv: f64, + cum_volume: f64, + last: Option, +} + +impl SessionVwap { + /// Construct a Session VWAP indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + day_key: None, + cum_pv: 0.0, + cum_volume: 0.0, + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent VWAP if at least one bar has been seen. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for SessionVwap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + if self.day_key != Some(key) { + self.day_key = Some(key); + self.cum_pv = 0.0; + self.cum_volume = 0.0; + } + let typical = (candle.high + candle.low + candle.close) / 3.0; + self.cum_pv += typical * candle.volume; + self.cum_volume += candle.volume; + let vwap = if self.cum_volume > 0.0 { + self.cum_pv / self.cum_volume + } else { + typical + }; + self.last = Some(vwap); + Some(vwap) + } + + fn reset(&mut self) { + self.day_key = None; + self.cum_pv = 0.0; + self.cum_volume = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SessionVwap" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(price: f64, volume: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, volume, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let vwap = SessionVwap::new(-480); + assert_eq!(vwap.utc_offset_minutes(), -480); + assert_eq!(vwap.name(), "SessionVwap"); + assert_eq!(vwap.warmup_period(), 1); + assert!(!vwap.is_ready()); + assert!(vwap.value().is_none()); + } + + #[test] + fn volume_weights_the_average() { + let mut vwap = SessionVwap::new(0); + let first = vwap.update(c(100.0, 10.0, 0)).unwrap(); + assert_relative_eq!(first, 100.0); + assert!(vwap.is_ready()); + let second = vwap.update(c(110.0, 30.0, HOUR)).unwrap(); + assert_relative_eq!(second, 107.5); + } + + #[test] + fn zero_volume_session_falls_back_to_typical() { + let mut vwap = SessionVwap::new(0); + let v = vwap.update(c(100.0, 0.0, 0)).unwrap(); + assert_relative_eq!(v, 100.0); + let v2 = vwap.update(c(120.0, 0.0, HOUR)).unwrap(); + assert_relative_eq!(v2, 120.0); + } + + #[test] + fn re_anchors_on_new_day() { + let mut vwap = SessionVwap::new(0); + vwap.update(c(100.0, 10.0, 0)); + vwap.update(c(110.0, 30.0, HOUR)); + // New day: VWAP restarts from the first bar of day 2. + let next = vwap.update(c(200.0, 5.0, 24 * HOUR)).unwrap(); + assert_relative_eq!(next, 200.0); + } + + #[test] + fn typical_price_uses_high_low_close() { + let mut vwap = SessionVwap::new(0); + // typical = (120 + 90 + 102) / 3 = 104. + let candle = Candle::new(100.0, 120.0, 90.0, 102.0, 10.0, 0).unwrap(); + let v = vwap.update(candle).unwrap(); + assert_relative_eq!(v, 104.0); + } + + #[test] + fn reset_clears_state() { + let mut vwap = SessionVwap::new(0); + vwap.update(c(100.0, 10.0, 0)); + vwap.reset(); + assert!(!vwap.is_ready()); + assert!(vwap.value().is_none()); + let after = vwap.update(c(50.0, 1.0, HOUR)).unwrap(); + assert_relative_eq!(after, 50.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + c( + 100.0 + f64::from(i), + 1.0 + f64::from(i % 4), + i64::from(i) * HOUR, + ) + }) + .collect(); + let mut a = SessionVwap::new(0); + let mut b = SessionVwap::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/shannon_entropy.rs b/crates/wickra-core/src/indicators/shannon_entropy.rs new file mode 100644 index 0000000..143cd15 --- /dev/null +++ b/crates/wickra-core/src/indicators/shannon_entropy.rs @@ -0,0 +1,261 @@ +//! Shannon Entropy — the information content of a price window's distribution. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Shannon Entropy — the Shannon information entropy (in **bits**) of the +/// distribution of values in a rolling window, after binning them into a fixed +/// number of equal-width buckets. +/// +/// ```text +/// bucket each of the last `period` values into `bins` equal-width bins over +/// [min, max] of the window +/// p_i = count_i / period +/// H = − Σ p_i · log2(p_i) (over non-empty bins) +/// ``` +/// +/// Entropy measures how *spread out* and unpredictable the recent values are. A +/// window concentrated in one bin (a flat or tightly-ranging market) has low +/// entropy near `0`; a window whose values are spread evenly across all bins (a +/// noisy, directionless market) approaches the maximum `log2(bins)`. Traders use +/// it as a **regime filter**: low entropy favours trend/breakout strategies, high +/// entropy favours mean-reversion or standing aside. +/// +/// The output lies in `[0, log2(bins)]`. A degenerate window where every value is +/// identical (`max == min`) returns `0`. The first value lands after `period` +/// inputs; each `update` rebins the window in O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ShannonEntropy}; +/// +/// let mut indicator = ShannonEntropy::new(32, 8).unwrap(); +/// let mut last = None; +/// for i in 0..64 { +/// last = indicator.update((f64::from(i) * 0.7).sin() * 10.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ShannonEntropy { + period: usize, + bins: usize, + window: VecDeque, + last: Option, +} + +impl ShannonEntropy { + /// Construct a Shannon entropy over `period` values binned into `bins` + /// buckets. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is `0`, or + /// [`Error::InvalidPeriod`] if `bins < 2` (entropy needs at least two bins). + pub fn new(period: usize, bins: usize) -> Result { + if period == 0 || bins == 0 { + return Err(Error::PeriodZero); + } + if bins < 2 { + return Err(Error::InvalidPeriod { + message: "Shannon entropy needs bins >= 2", + }); + } + Ok(Self { + period, + bins, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, bins)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bins) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for ShannonEntropy { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &v in &self.window { + min = min.min(v); + max = max.max(v); + } + if max <= min { + // Degenerate window: all values identical -> zero entropy. + self.last = Some(0.0); + return Some(0.0); + } + let width = (max - min) / self.bins as f64; + let mut counts = vec![0usize; self.bins]; + for &v in &self.window { + // `(v - min) / width` is in [0, bins]; the cast truncates toward zero + // (intended) and the value is non-negative, then clamped to the last + // bin so the index is always valid. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let raw = ((v - min) / width) as usize; + let idx = raw.min(self.bins - 1); + counts[idx] += 1; + } + let n = self.period as f64; + let mut h = 0.0; + for &count in &counts { + if count > 0 { + let p = count as f64 / n; + h -= p * p.log2(); + } + } + self.last = Some(h); + Some(h) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ShannonEntropy" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!(ShannonEntropy::new(0, 8), Err(Error::PeriodZero))); + assert!(matches!(ShannonEntropy::new(32, 0), Err(Error::PeriodZero))); + assert!(matches!( + ShannonEntropy::new(32, 1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let e = ShannonEntropy::new(32, 8).unwrap(); + assert_eq!(e.params(), (32, 8)); + assert_eq!(e.warmup_period(), 32); + assert_eq!(e.name(), "ShannonEntropy"); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut e = ShannonEntropy::new(4, 4).unwrap(); + let out = e.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn constant_window_is_zero() { + let mut e = ShannonEntropy::new(8, 4).unwrap(); + let last = e.batch(&[5.0; 12]).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn uniform_window_is_max_entropy() { + // One value per bin -> uniform distribution -> H = log2(bins). + let mut e = ShannonEntropy::new(4, 4).unwrap(); + // Values 0,1,2,3 with min=0,max=3,width=0.75 -> bins 0,1,2,3. + let last = e + .batch(&[0.0, 1.0, 2.0, 3.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 2.0, epsilon = 1e-9); // log2(4) = 2 + } + + #[test] + fn output_in_range() { + let mut e = ShannonEntropy::new(32, 8).unwrap(); + let max_h = 8f64.log2(); + for v in e + .batch( + &(0..200) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!((0.0..=max_h + 1e-9).contains(&v)); + } + } + + #[test] + fn ignores_non_finite() { + let mut e = ShannonEntropy::new(4, 4).unwrap(); + let ready = e + .batch(&[1.0, 2.0, 3.0, 4.0]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(e.update(f64::NAN), Some(ready)); + } + + #[test] + fn reset_clears_state() { + let mut e = ShannonEntropy::new(4, 4).unwrap(); + e.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(e.is_ready()); + e.reset(); + assert!(!e.is_ready()); + assert_eq!(e.value(), None); + assert_eq!(e.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = ShannonEntropy::new(32, 8).unwrap().batch(&xs); + let mut b = ShannonEntropy::new(32, 8).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/shark.rs b/crates/wickra-core/src/indicators/shark.rs new file mode 100644 index 0000000..59814a8 --- /dev/null +++ b/crates/wickra-core/src/indicators/shark.rs @@ -0,0 +1,155 @@ +//! Shark harmonic pattern. + +use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Shark — a 5-point (X-A-B-C-D) harmonic pattern characterised by an +/// **expansion** leg (AB longer than XA) and a `0.886`–`1.13` D completion: +/// +/// ```text +/// AB / XA ∈ [1.13, 1.618] (expansion — B overshoots X) +/// BC / AB ∈ [1.618, 2.24] +/// CD / BC ∈ [0.382, 0.886] +/// AD / XA ∈ [0.886, 1.13] (the defining D completion near A) +/// ``` +/// +/// This is the 5-point reading of the Shark; output is `+1.0` (bullish, D a +/// swing low), `-1.0` (bearish, D a swing high), or `0.0`; never `None`. See +/// `crates/wickra-core/src/indicators/shark.rs`. +#[derive(Debug, Clone)] +pub struct Shark { + swing: SwingTracker, + has_emitted: bool, +} + +impl Shark { + /// Construct a new Shark detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for Shark { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Shark { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let cd = (p.d - p.c).abs(); + let ad = (p.d - p.a).abs(); + let matched = ratios_in(&[ + (ab / xa, 1.13, 1.618), + (bc / ab, 1.618, 2.24), + (cd / bc, 0.382, 0.886), + (ad / xa, 0.886, 1.13), + ]); + if matched { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Shark" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Shark::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Shark::new(); + assert_eq!(indicator.name(), "Shark"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!Shark::default().is_ready()); + } + + #[test] + fn bullish_shark_is_plus_one() { + let out = run(&[150.0, 100.0, 140.0, 88.0, 186.8, 100.0]); + assert_eq!(*out.last().unwrap(), 1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bearish_shark_is_minus_one() { + let out = run(&[150.0, 110.0, 162.0, 60.2, 150.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn out_of_ratio_does_not_trigger() { + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Shark::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 88.0, 186.8, 100.0]); + let mut a = Shark::new(); + let mut b = Shark::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/sharpe_ratio.rs b/crates/wickra-core/src/indicators/sharpe_ratio.rs new file mode 100644 index 0000000..251f954 --- /dev/null +++ b/crates/wickra-core/src/indicators/sharpe_ratio.rs @@ -0,0 +1,220 @@ +//! Rolling Sharpe Ratio. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Sharpe Ratio over `period` period-returns. +/// +/// The input is treated as a single period-return (e.g. one day's percentage +/// return). Over the trailing window of `period` returns the indicator +/// computes: +/// +/// ```text +/// Sharpe = (mean(returns) − risk_free_per_period) / stddev(returns) +/// ``` +/// +/// `stddev` is the sample standard deviation with `n − 1` in the denominator. +/// `risk_free_per_period` is the per-period risk-free rate the caller supplies +/// (e.g. `0.0` for excess-of-zero or a daily-equivalent rate to match the +/// return frequency). Wickra does not annualise: feed already-annualised +/// returns and supply an annual risk-free rate if you want an annualised +/// Sharpe. +/// +/// A flat window has zero standard deviation and Sharpe is undefined; the +/// indicator returns `0.0` in that case rather than producing `NaN`. +/// +/// Each `update` is O(1) — Welford-style running sums maintain `Σr`, `Σr²` +/// as the window slides. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SharpeRatio}; +/// +/// let mut sr = SharpeRatio::new(20, 0.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = sr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SharpeRatio { + period: usize, + risk_free: f64, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl SharpeRatio { + /// Construct a new rolling Sharpe Ratio with the given window and + /// per-period risk-free rate. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` (sample standard + /// deviation needs at least two observations). + pub fn new(period: usize, risk_free: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "sharpe ratio needs period >= 2", + }); + } + Ok(Self { + period, + risk_free, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured per-period risk-free rate. + pub const fn risk_free(&self) -> f64 { + self.risk_free + } +} + +impl Indicator for SharpeRatio { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(input); + self.sum += input; + self.sum_sq += input * input; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Sample variance with Bessel's correction. + let var = (self.sum_sq - n * mean * mean).max(0.0) / (n - 1.0); + let sd = var.sqrt(); + if sd == 0.0 { + return Some(0.0); + } + Some((mean - self.risk_free) / sd) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "SharpeRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_less_than_two() { + assert!(matches!( + SharpeRatio::new(1, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + SharpeRatio::new(0, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let sr = SharpeRatio::new(20, 0.001).unwrap(); + assert_eq!(sr.period(), 20); + assert_relative_eq!(sr.risk_free(), 0.001, epsilon = 1e-12); + assert_eq!(sr.name(), "SharpeRatio"); + assert_eq!(sr.warmup_period(), 20); + } + + #[test] + fn constant_returns_yield_zero() { + let mut sr = SharpeRatio::new(5, 0.0).unwrap(); + let out = sr.batch(&[0.01; 10]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_value() { + // returns = [0.01, 0.02, 0.03, 0.04], rf = 0. + // mean = 0.025, var = ((0.01-.025)^2 + (.02-.025)^2 + (.03-.025)^2 + // + (.04-.025)^2) / 3 = 0.00016666..., sd = sqrt(0.000166..) = + // 0.01290994..., Sharpe = 0.025 / 0.01290994 ≈ 1.936491673. + let mut sr = SharpeRatio::new(4, 0.0).unwrap(); + let out = sr.batch(&[0.01, 0.02, 0.03, 0.04]); + let expected = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt(); + assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut sr = SharpeRatio::new(3, 0.0).unwrap(); + assert_eq!(sr.update(0.01), None); + assert_eq!(sr.update(f64::NAN), None); + assert_eq!(sr.update(0.02), None); + assert!(sr.update(0.03).is_some()); + } + + #[test] + fn warmup_returns_none() { + let mut sr = SharpeRatio::new(5, 0.0).unwrap(); + for i in 0..4 { + assert_eq!(sr.update(f64::from(i) * 0.01), None); + } + assert!(sr.update(0.05).is_some()); + } + + #[test] + fn reset_clears_state() { + let mut sr = SharpeRatio::new(3, 0.0).unwrap(); + sr.batch(&[0.01, 0.02, 0.03]); + assert!(sr.is_ready()); + sr.reset(); + assert!(!sr.is_ready()); + assert_eq!(sr.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..50) + .map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01) + .collect(); + let batch = SharpeRatio::new(10, 0.0).unwrap().batch(&returns); + let mut s = SharpeRatio::new(10, 0.0).unwrap(); + let streamed: Vec<_> = returns.iter().map(|p| s.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/shooting_star.rs b/crates/wickra-core/src/indicators/shooting_star.rs new file mode 100644 index 0000000..5dacec4 --- /dev/null +++ b/crates/wickra-core/src/indicators/shooting_star.rs @@ -0,0 +1,159 @@ +//! Shooting Star candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Shooting Star — a single-bar bearish reversal candidate. +/// +/// A Shooting Star has the same geometry as an Inverted Hammer (small body +/// near the bottom, long upper shadow ≥ 2× body, short lower shadow) but is +/// read bearishly because it appears at the top of an uptrend. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// star = upper_shadow >= 2 * body +/// && lower_shadow <= body +/// && body > 0 +/// ``` +/// +/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape +/// check only — no trend filter is applied; combine with a trend indicator +/// for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// A Shooting Star is bearish by definition, so under the uniform candlestick +/// sign convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it emits +/// `−1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`. +/// The same geometry read at the bottom of a downtrend is the bullish +/// `InvertedHammer`, which carries the opposite sign. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ShootingStar}; +/// +/// let mut indicator = ShootingStar::new(); +/// let candle = Candle::new(10.0, 15.0, 9.9, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ShootingStar { + has_emitted: bool, +} + +impl ShootingStar { + /// Construct a new Shooting Star detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for ShootingStar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body = (candle.close - candle.open).abs(); + if body <= 0.0 { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + Some(if upper >= 2.0 * body && lower <= body { + -1.0 + } else { + 0.0 + }) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ShootingStar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let s = ShootingStar::new(); + assert_eq!(s.name(), "ShootingStar"); + assert_eq!(s.warmup_period(), 1); + assert!(!s.is_ready()); + } + + #[test] + fn clean_shooting_star_is_minus_one() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 15.0, 9.9, 10.5, 0)), Some(-1.0)); + } + + #[test] + fn hammer_shape_is_not_shooting_star() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_shooting_star() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut s = ShootingStar::new(); + assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 4.0, base - 0.1, base + 0.5, i) + }) + .collect(); + let mut a = ShootingStar::new(); + let mut b = ShootingStar::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut s = ShootingStar::new(); + s.update(c(10.0, 15.0, 9.9, 10.5, 0)); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/short_line.rs b/crates/wickra-core/src/indicators/short_line.rs new file mode 100644 index 0000000..eabec8b --- /dev/null +++ b/crates/wickra-core/src/indicators/short_line.rs @@ -0,0 +1,233 @@ +//! Short Line candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; +use std::collections::VecDeque; + +/// Short Line — a single candle whose range is *shorter* than the recent average +/// while its body still dominates that (small) range: a compact directional bar. +/// As with [`LongLine`](crate::LongLine), "short" only has meaning relative to +/// recent activity, so the detector compares each candle's range against a rolling +/// average of the previous `period` ranges. +/// +/// ```text +/// avg = mean range of the previous `period` candles +/// short line = range < avg AND |close − open| >= 0.5 * range +/// white -> +1.0, black -> −1.0 +/// ``` +/// +/// Output is `+1.0` (short white line), `−1.0` (short black line), or `0.0` +/// otherwise. The first `period` candles return `0.0` while the rolling average +/// fills. `period` defaults to `5` and must be at least `1`. Pattern-shape check +/// only — no trend filter is applied; combine with a trend indicator for +/// actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ShortLine}; +/// +/// let mut indicator = ShortLine::new(); +/// // Five wide bars fill the rolling average. +/// for ts in 0..5 { +/// indicator.update(Candle::new(10.0, 13.0, 9.5, 12.9, 1.0, ts).unwrap()); +/// } +/// // A compact solid white bar is a short white line. +/// let out = indicator +/// .update(Candle::new(10.0, 11.0, 9.9, 10.9, 1.0, 5).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct ShortLine { + period: usize, + ranges: VecDeque, +} + +impl Default for ShortLine { + fn default() -> Self { + Self::new() + } +} + +impl ShortLine { + /// Construct a Short Line detector with the default 5-candle rolling average. + pub const fn new() -> Self { + Self { + period: 5, + ranges: VecDeque::new(), + } + } + + /// Construct a Short Line detector with a custom averaging period. + /// + /// `period` must be at least `1`. + pub fn with_period(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + ranges: VecDeque::new(), + }) + } + + /// Configured averaging period. + pub fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ShortLine { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let body = candle.close - candle.open; + if self.ranges.len() < self.period { + self.ranges.push_back(range); + return Some(0.0); + } + let avg = self.ranges.iter().sum::() / self.period as f64; + self.ranges.push_back(range); + self.ranges.pop_front(); + if range < avg && body.abs() >= 0.5 * range { + return Some(if body > 0.0 { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.ranges.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.ranges.len() >= self.period + } + + fn name(&self) -> &'static str { + "ShortLine" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + fn warm(t: &mut ShortLine) { + for ts in 0..5 { + assert_eq!(t.update(c(10.0, 13.0, 9.5, 12.9, ts)), Some(0.0)); + } + } + + #[test] + fn rejects_zero_period() { + assert!(ShortLine::with_period(0).is_err()); + } + + #[test] + fn accepts_valid_period() { + let t = ShortLine::with_period(10).unwrap(); + assert_eq!(t.period(), 10); + } + + #[test] + fn accessors_and_metadata() { + let t = ShortLine::new(); + assert_eq!(t.name(), "ShortLine"); + assert_eq!(t.warmup_period(), 5); + assert!(!t.is_ready()); + assert_eq!(t.period(), 5); + } + + #[test] + fn short_white_line_is_plus_one() { + let mut t = ShortLine::new(); + warm(&mut t); + assert!(t.is_ready()); + assert_eq!(t.update(c(10.0, 11.0, 9.9, 10.9, 5)), Some(1.0)); + } + + #[test] + fn short_black_line_is_minus_one() { + let mut t = ShortLine::new(); + warm(&mut t); + assert_eq!(t.update(c(10.9, 11.0, 9.9, 10.0, 5)), Some(-1.0)); + } + + #[test] + fn wide_range_yields_zero() { + let mut t = ShortLine::new(); + warm(&mut t); + // Range as wide as the average -> not a short line. + assert_eq!(t.update(c(10.0, 13.0, 9.5, 12.9, 5)), Some(0.0)); + } + + #[test] + fn short_range_small_body_yields_zero() { + let mut t = ShortLine::new(); + warm(&mut t); + // Compact range but a tiny body -> not a solid short line. + assert_eq!(t.update(c(10.4, 11.0, 9.9, 10.5, 5)), Some(0.0)); + } + + #[test] + fn warmup_returns_zero() { + let mut t = ShortLine::new(); + for ts in 0..5 { + assert_eq!(t.update(c(10.0, 11.0, 9.9, 10.9, ts)), Some(0.0)); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 7 == 0 { + c(base, base + 0.6, base - 0.1, base + 0.5, i) + } else { + c(base, base + 3.0, base - 1.0, base + 2.8, i) + } + }) + .collect(); + let mut a = ShortLine::new(); + let mut b = ShortLine::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ShortLine::new(); + warm(&mut t); + t.update(c(10.0, 11.0, 9.9, 10.9, 5)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.0, 9.9, 10.9, 0)), Some(0.0)); + } + + #[test] + fn default_matches_new() { + assert_eq!(ShortLine::default().period(), ShortLine::new().period()); + } +} diff --git a/crates/wickra-core/src/indicators/signed_volume.rs b/crates/wickra-core/src/indicators/signed_volume.rs new file mode 100644 index 0000000..e10d8b9 --- /dev/null +++ b/crates/wickra-core/src/indicators/signed_volume.rs @@ -0,0 +1,128 @@ +//! Signed Volume — per-trade volume signed by aggressor side. + +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Signed Volume — the size of each trade signed by its aggressor side. +/// +/// ```text +/// signedVolume = size · (+1 if buy, −1 if sell) +/// ``` +/// +/// A positive value is buyer-initiated flow, a negative value seller-initiated. +/// It is the per-trade building block of [`crate::CumulativeVolumeDelta`] and +/// trade-flow imbalance. +/// +/// `Input = Trade`, `Output = f64`. Stateless; ready after the first trade. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SignedVolume, Side, Trade}; +/// +/// let mut sv = SignedVolume::new(); +/// let buy = Trade::new(100.0, 2.0, Side::Buy, 0).unwrap(); +/// assert_eq!(sv.update(buy), Some(2.0)); +/// let sell = Trade::new(100.0, 3.0, Side::Sell, 1).unwrap(); +/// assert_eq!(sv.update(sell), Some(-3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct SignedVolume { + has_emitted: bool, +} + +impl SignedVolume { + /// Construct a new signed-volume indicator. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for SignedVolume { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + self.has_emitted = true; + Some(trade.size * trade.side.sign()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "SignedVolume" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(size: f64, side: Side, ts: i64) -> Trade { + Trade::new(100.0, size, side, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let sv = SignedVolume::new(); + assert_eq!(sv.name(), "SignedVolume"); + assert_eq!(sv.warmup_period(), 1); + assert!(!sv.is_ready()); + } + + #[test] + fn buy_is_positive() { + let mut sv = SignedVolume::new(); + assert_eq!(sv.update(trade(2.0, Side::Buy, 0)), Some(2.0)); + assert!(sv.is_ready()); + } + + #[test] + fn sell_is_negative() { + let mut sv = SignedVolume::new(); + assert_eq!(sv.update(trade(3.0, Side::Sell, 0)), Some(-3.0)); + } + + #[test] + fn zero_size_is_zero() { + let mut sv = SignedVolume::new(); + assert_eq!(sv.update(trade(0.0, Side::Buy, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..20) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + trade(1.0 + (i % 4) as f64, side, i) + }) + .collect(); + let mut a = SignedVolume::new(); + let mut b = SignedVolume::new(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut sv = SignedVolume::new(); + sv.update(trade(1.0, Side::Buy, 0)); + assert!(sv.is_ready()); + sv.reset(); + assert!(!sv.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/sine_wave.rs b/crates/wickra-core/src/indicators/sine_wave.rs new file mode 100644 index 0000000..e06b8e4 --- /dev/null +++ b/crates/wickra-core/src/indicators/sine_wave.rs @@ -0,0 +1,230 @@ +//! Ehlers Sine Wave indicator. +#![allow(clippy::manual_clamp)] + +use std::f64::consts::PI; + +use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle; +use crate::traits::Indicator; + +/// Ehlers' Sine Wave indicator (sine + leadsine). +/// +/// Implementation from *Rocket Science for Traders* (Ehlers 2001, ch. 9). Uses +/// the same Hilbert-transform machinery as [`HilbertDominantCycle`] to derive +/// the instantaneous phase, then returns `sin(phase)` and the 45° lead +/// `sin(phase + 45°)`. The two lines cross deep in trends but oscillate +/// rapidly during cycles, providing a visual lead/lag signal. +/// +/// Only the primary `sine` line is exposed as the scalar output to match the +/// crate's standard scalar-indicator surface; the lead is accessible via the +/// [`SineWave::lead`] accessor after each update. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SineWave}; +/// +/// let mut sw = SineWave::new(); +/// let mut last = None; +/// for i in 0..200 { +/// last = sw.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct SineWave { + cycle: HilbertDominantCycle, + smooth_buf: Vec, + detrender_buf: Vec, + last_phase: f64, + last_sine: Option, + last_lead: f64, + count: usize, +} + +impl SineWave { + /// Construct a new Sine Wave indicator. + pub fn new() -> Self { + Self::default() + } + + /// Most recent lead (45°-ahead) value. `0.0` until the indicator is ready. + pub const fn lead(&self) -> f64 { + self.last_lead + } + + /// Current sine value if available. + pub const fn value(&self) -> Option { + self.last_sine + } + + fn push_front(buf: &mut Vec, v: f64, cap: usize) { + buf.insert(0, v); + if buf.len() > cap { + buf.truncate(cap); + } + } +} + +impl Indicator for SineWave { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.last_sine; + } + self.count += 1; + // Drive the dominant-cycle estimator first; its smoothing state is + // independent from ours so the two share input but not buffers. + let _ = self.cycle.update(input); + + Self::push_front(&mut self.smooth_buf, input, 7); + if self.smooth_buf.len() < 4 { + return None; + } + let smooth = (4.0 * self.smooth_buf[0] + + 3.0 * self.smooth_buf[1] + + 2.0 * self.smooth_buf[2] + + self.smooth_buf[3]) + / 10.0; + if self.smooth_buf.len() < 7 { + return None; + } + let period = self.cycle.value().unwrap_or(15.0).max(6.0).min(50.0); + let adj = 0.075 * period + 0.54; + let s0 = smooth; + let s2 = self.smooth_buf[2]; + let s4 = self.smooth_buf[4]; + let s6 = self.smooth_buf[6]; + let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj; + Self::push_front(&mut self.detrender_buf, detrender, 7); + if self.detrender_buf.len() < 7 { + return None; + } + let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2] + - 0.5769 * self.detrender_buf[4] + - 0.0962 * self.detrender_buf[6]) + * adj; + let i1 = self.detrender_buf[3]; + let phase = if i1.abs() > f64::EPSILON { + (q1 / i1).atan() + } else { + self.last_phase + }; + self.last_phase = phase; + let sine = phase.sin(); + let lead = (phase + PI / 4.0).sin(); + + if self.count < 50 { + return None; + } + self.last_sine = Some(sine); + self.last_lead = lead; + Some(sine) + } + + fn reset(&mut self) { + self.cycle.reset(); + self.smooth_buf.clear(); + self.detrender_buf.clear(); + self.last_phase = 0.0; + self.last_sine = None; + self.last_lead = 0.0; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 50 + } + + fn is_ready(&self) -> bool { + self.last_sine.is_some() + } + + fn name(&self) -> &'static str { + "SineWave" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn accessors_and_metadata() { + let mut sw = SineWave::new(); + assert_eq!(sw.warmup_period(), 50); + assert_eq!(sw.name(), "SineWave"); + assert!(!sw.is_ready()); + assert!(sw.value().is_none()); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + sw.batch(&prices); + assert!(sw.is_ready()); + assert!(sw.value().is_some()); + } + + #[test] + fn output_bounded() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 5.0) + .collect(); + let mut sw = SineWave::new(); + for v in sw.batch(&prices).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "sine out of bounds: {v}"); + } + // Lead value also bounded after warmup. + assert!(sw.lead() >= -1.0 && sw.lead() <= 1.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = SineWave::new(); + let mut b = SineWave::new(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut sw = SineWave::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + sw.batch(&prices); + let before = sw.value(); + assert!(before.is_some()); + assert_eq!(sw.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut sw = SineWave::new(); + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + sw.batch(&prices); + assert!(sw.is_ready()); + sw.reset(); + assert!(!sw.is_ready()); + assert!(sw.value().is_none()); + } + + #[test] + fn flat_input_uses_phase_fallback() { + // Zero inputs make every smooth/detrender term arithmetically exact + // zero (no IEEE-754 cancellation residue), so `i1 == 0.0` and the + // phase calculation deterministically takes the `self.last_phase` + // fallback rather than `atan(q1/i1)`. A non-zero constant like + // `100.0` leaves a sub-EPSILON residue that flips the branch back. + let mut sw = SineWave::new(); + let _ = sw.batch(&[0.0_f64; 120]); + assert!(sw.value().is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/sine_weighted_ma.rs b/crates/wickra-core/src/indicators/sine_weighted_ma.rs new file mode 100644 index 0000000..eb88dee --- /dev/null +++ b/crates/wickra-core/src/indicators/sine_weighted_ma.rs @@ -0,0 +1,273 @@ +//! Sine-Weighted Moving Average (SWMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Sine-Weighted Moving Average — a windowed average whose weights follow one +/// half-cycle of a sine wave. +/// +/// Over the last `period` inputs the weight of the value at position +/// `i = 0, 1, …, period − 1` (oldest to newest) is +/// +/// ```text +/// w_i = sin(π · (i + 1) / (period + 1)) +/// SWMA = Σ (w_i · value_i) / Σ w_i +/// ``` +/// +/// The window is symmetric: weights rise to a peak in the middle of the window +/// and fall off at both ends, so the central observations dominate while the +/// extremes are de-emphasised. Every weight is strictly positive because the +/// argument `(i + 1) / (period + 1)` lies in the open interval `(0, 1)`, so the +/// normaliser is always non-zero. +/// +/// Each `update` is O(`period`): the fixed weight vector is dotted with the +/// trailing window, mirroring the way [`Alma`](crate::Alma) recomputes its +/// Gaussian weights. `period == 1` collapses to a pass-through +/// (`w_0 = sin(π/2) = 1`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SineWeightedMa}; +/// +/// let mut indicator = SineWeightedMa::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SineWeightedMa { + period: usize, + window: VecDeque, + /// Sine weights for positions `0..period` (oldest to newest), constant in + /// `period`. + weights: Vec, + weights_total: f64, +} + +impl SineWeightedMa { + /// Construct a new sine-weighted moving average over `period` inputs. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let denom = period as f64 + 1.0; + let weights: Vec = (0..period) + .map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin()) + .collect(); + let weights_total = weights.iter().sum(); + Ok(Self { + period, + window: VecDeque::with_capacity(period), + weights, + weights_total, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if the window is full. + pub fn value(&self) -> Option { + if self.window.len() == self.period { + let dot: f64 = self + .window + .iter() + .zip(&self.weights) + .map(|(v, w)| v * w) + .sum(); + Some(dot / self.weights_total) + } else { + None + } + } +} + +impl Indicator for SineWeightedMa { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + self.value() + } + + 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 { + "SWMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Reference implementation: explicit sine-weighted average over a window. + fn swma_naive(prices: &[f64], period: usize) -> Vec> { + let denom = period as f64 + 1.0; + let weights: Vec = (0..period) + .map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin()) + .collect(); + let total: f64 = weights.iter().sum(); + prices + .iter() + .enumerate() + .map(|(i, _)| { + if i + 1 < period { + None + } else { + let window = &prices[i + 1 - period..=i]; + let dot: f64 = window.iter().zip(&weights).map(|(v, w)| v * w).sum(); + Some(dot / total) + } + }) + .collect() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(SineWeightedMa::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let swma = SineWeightedMa::new(7).unwrap(); + assert_eq!(swma.period(), 7); + assert_eq!(swma.warmup_period(), 7); + assert_eq!(swma.name(), "SWMA"); + } + + #[test] + fn warmup_returns_none() { + let mut swma = SineWeightedMa::new(3).unwrap(); + assert_eq!(swma.update(1.0), None); + assert_eq!(swma.update(2.0), None); + // SWMA(3): weights sin(pi/4), sin(pi/2), sin(3pi/4) = [√½, 1, √½]. + // Over [1,2,3]: (√½·1 + 1·2 + √½·3) / (√½ + 1 + √½). + let s = std::f64::consts::FRAC_1_SQRT_2; + let total = s + 1.0 + s; + let want = (s * 1.0 + 1.0 * 2.0 + s * 3.0) / total; + assert_relative_eq!(swma.update(3.0).unwrap(), want, epsilon = 1e-12); + } + + #[test] + fn symmetric_weights_give_midpoint_on_linear_window() { + // For a perfectly linear window the symmetric weighting reproduces the + // arithmetic centre of the window. + let mut swma = SineWeightedMa::new(5).unwrap(); + let v = swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_relative_eq!(v[4].unwrap(), 3.0, epsilon = 1e-12); + } + + #[test] + fn period_one_is_pass_through() { + let mut swma = SineWeightedMa::new(1).unwrap(); + assert_relative_eq!(swma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(swma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn matches_naive_over_inputs() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect(); + let mut swma = SineWeightedMa::new(7).unwrap(); + let got = swma.batch(&prices); + let want = swma_naive(&prices, 7); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut swma = SineWeightedMa::new(4).unwrap(); + swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(swma.is_ready()); + swma.reset(); + assert!(!swma.is_ready()); + assert_eq!(swma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| f64::from(i) * 0.5).collect(); + let mut a = SineWeightedMa::new(5).unwrap(); + let mut b = SineWeightedMa::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut swma = SineWeightedMa::new(3).unwrap(); + swma.update(1.0); + swma.update(2.0); + let ready = swma.update(3.0).expect("SWMA(3) ready after three inputs"); + assert_eq!(swma.update(f64::NAN), Some(ready)); + assert_eq!(swma.update(f64::INFINITY), Some(ready)); + // The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4. + let s = std::f64::consts::FRAC_1_SQRT_2; + let total = s + 1.0 + s; + let want = (s * 2.0 + 1.0 * 3.0 + s * 4.0) / total; + assert_relative_eq!(swma.update(4.0).unwrap(), want, epsilon = 1e-12); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn proptest_matches_naive( + period in 1usize..15, + prices in proptest::collection::vec(-500.0_f64..500.0, 0..120), + ) { + let mut swma = SineWeightedMa::new(period).unwrap(); + let got = swma.batch(&prices); + let want = swma_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() < 1e-7, + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/single_prints.rs b/crates/wickra-core/src/indicators/single_prints.rs new file mode 100644 index 0000000..96510c9 --- /dev/null +++ b/crates/wickra-core/src/indicators/single_prints.rs @@ -0,0 +1,253 @@ +//! Single Prints — count of price levels touched by exactly one bar (low acceptance). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Single Prints — the number of price levels (bins) in the rolling profile that +/// were touched by **exactly one** bar, marking zones of low acceptance / fast +/// movement. +/// +/// ```text +/// for each of `bins` price levels over the last `period` candles: +/// touches = number of bars whose high-low range covers that level +/// SinglePrints = count of levels with touches == 1 +/// ``` +/// +/// In Market Profile a "single print" is a price the market traded through so +/// quickly that only one time-period printed there — a footprint of an aggressive, +/// one-sided move with little two-way trade. Single prints often act as support or +/// resistance on a retest (the imbalance gets "repaired") and mark the edges of +/// rapid moves. Counting them per profile gives a streaming gauge of how much of +/// the recent range was traversed without acceptance: a high count means a fast, +/// trending, low-rotation market; a low count means a balanced, well-traded range. +/// +/// The output is a non-negative count. The first value lands after `period` +/// candles; each `update` rebuilds the touch histogram in O(`period · bins`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SinglePrints}; +/// +/// let mut indicator = SinglePrints::new(20, 24).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); // a one-directional ramp -> many single prints +/// let c = Candle::new(base, base + 0.5, base - 0.5, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SinglePrints { + period: usize, + bins: usize, + window: VecDeque, + last: Option, +} + +impl SinglePrints { + /// Construct a Single Prints counter. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero. + pub fn new(period: usize, bins: usize) -> Result { + if period == 0 || bins == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bins, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured `(period, bins)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bins) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn count_single_prints(&self) -> usize { + let mut low = f64::INFINITY; + let mut high = f64::NEG_INFINITY; + for c in &self.window { + low = low.min(c.low); + high = high.max(c.high); + } + let span = high - low; + if span <= 0.0 { + return 0; + } + let width = span / self.bins as f64; + let mut touches = vec![0u32; self.bins]; + for c in &self.window { + let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1); + let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1); + for t in touches.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *t += 1; + } + } + touches.iter().filter(|&&t| t == 1).count() + } +} + +impl Indicator for SinglePrints { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let count = self.count_single_prints() as f64; + self.last = Some(count); + Some(count) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SinglePrints" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64) -> Candle { + Candle::new_unchecked( + f64::midpoint(high, low), + high, + low, + f64::midpoint(high, low), + 1_000.0, + 0, + ) + } + + #[test] + fn rejects_zero_params() { + assert!(matches!(SinglePrints::new(0, 24), Err(Error::PeriodZero))); + assert!(matches!(SinglePrints::new(20, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let s = SinglePrints::new(20, 24).unwrap(); + assert_eq!(s.params(), (20, 24)); + assert_eq!(s.warmup_period(), 20); + assert_eq!(s.name(), "SinglePrints"); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut s = SinglePrints::new(4, 8).unwrap(); + let candles: Vec = (0..6) + .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i))) + .collect(); + let out = s.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn flat_range_has_no_single_prints() { + // Every bar covers the same single price -> zero span -> 0. + let mut s = SinglePrints::new(4, 8).unwrap(); + let last = s + .batch(&[c(100.0, 100.0); 6]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn ramp_has_many_single_prints() { + // A one-directional ramp visits most levels exactly once. + let mut s = SinglePrints::new(10, 24).unwrap(); + let candles: Vec = (0..10) + .map(|i| c(100.5 + f64::from(i), 99.5 + f64::from(i))) + .collect(); + let last = s.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last > 0.0, + "a ramp should produce single prints, got {last}" + ); + } + + #[test] + fn output_non_negative() { + let mut s = SinglePrints::new(14, 24).unwrap(); + for v in s + .batch( + &(0..60) + .map(|i| c(110.0 + (f64::from(i) * 0.3).sin() * 8.0, 90.0)) + .collect::>(), + ) + .into_iter() + .flatten() + { + assert!(v >= 0.0); + } + } + + #[test] + fn reset_clears_state() { + let mut s = SinglePrints::new(4, 8).unwrap(); + s.batch( + &(0..6) + .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i))) + .collect::>(), + ); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + assert_eq!(s.update(c(101.0, 99.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| c(110.0 + (f64::from(i) * 0.25).sin() * 9.0, 90.0)) + .collect(); + let batch = SinglePrints::new(20, 24).unwrap().batch(&candles); + let mut b = SinglePrints::new(20, 24).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/skewness.rs b/crates/wickra-core/src/indicators/skewness.rs new file mode 100644 index 0000000..79f51f1 --- /dev/null +++ b/crates/wickra-core/src/indicators/skewness.rs @@ -0,0 +1,205 @@ +//! Rolling Pearson skewness (third standardised central moment). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Pearson skewness of the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ x +/// m2 = (1/n) · Σ (x − mean)² // population variance +/// m3 = (1/n) · Σ (x − mean)³ // third central moment +/// Skew = m3 / m2^(3/2) +/// ``` +/// +/// Positive skewness means the right tail (large positive deviations from +/// the mean) is heavier than the left; negative skewness flags the +/// opposite. A symmetric distribution has skewness `0`. This is the +/// population (Pearson) definition with divisor `n`; many statistics +/// packages report the bias-corrected sample skewness instead. The window +/// is required to have at least three points so the moments are +/// well-defined. A window with zero dispersion yields `0`. +/// +/// Each `update` is O(1): three running sums (`Σ x`, `Σ x²`, `Σ x³`) are +/// maintained as the window slides; the central moments are then derived +/// from them via the binomial-expansion identities, so no inner loop runs +/// per bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Skewness}; +/// +/// let mut indicator = Skewness::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Skewness { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, + sum_cu: f64, +} + +impl Skewness { + /// Construct a new rolling skewness with the given period. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3`. + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "skewness needs period >= 3", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + sum_cu: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Skewness { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.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.sum_cu -= old * old * old; + } + self.window.push_back(value); + self.sum += value; + self.sum_sq += value * value; + self.sum_cu += value * value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // m2 = E[x²] − E[x]² + let m2 = (self.sum_sq / n - mean * mean).max(0.0); + // m3 = E[x³] − 3·mean·E[x²] + 2·mean³ (binomial expansion). + let m3 = self.sum_cu / n - 3.0 * mean * (self.sum_sq / n) + 2.0 * mean * mean * mean; + if m2 == 0.0 { + // A window with no dispersion has no defined shape; return 0. + return Some(0.0); + } + Some(m3 / m2.powf(1.5)) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.sum_cu = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "Skewness" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(Skewness::new(0).is_err()); + assert!(Skewness::new(1).is_err()); + assert!(Skewness::new(2).is_err()); + assert!(Skewness::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = Skewness::new(14).unwrap(); + assert_eq!(s.period(), 14); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "Skewness"); + } + + #[test] + fn symmetric_window_is_zero() { + // Symmetric around its mean — skewness must be (numerically) zero. + let mut s = Skewness::new(5).unwrap(); + let out = s.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]); + assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_yields_zero() { + let mut s = Skewness::new(5).unwrap(); + for v in s.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn right_tail_is_positive() { + // One large positive outlier creates a right-skewed window. + let mut s = Skewness::new(5).unwrap(); + let out = s.batch(&[0.0, 0.0, 0.0, 0.0, 10.0]); + assert!(out[4].unwrap() > 0.0); + } + + #[test] + fn left_tail_is_negative() { + // Mirror image — one large negative outlier gives left skew. + let mut s = Skewness::new(5).unwrap(); + let out = s.batch(&[10.0, 10.0, 10.0, 10.0, 0.0]); + assert!(out[4].unwrap() < 0.0); + } + + #[test] + fn reset_clears_state() { + let mut s = Skewness::new(5).unwrap(); + s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 7.0) + .collect(); + let batch = Skewness::new(14).unwrap().batch(&prices); + let mut b = Skewness::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/sma.rs b/crates/wickra-core/src/indicators/sma.rs new file mode 100644 index 0000000..88b7c4a --- /dev/null +++ b/crates/wickra-core/src/indicators/sma.rs @@ -0,0 +1,421 @@ +//! Simple Moving Average. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Simple Moving Average over a fixed window. +/// +/// Maintains a rolling sum so each update is O(1). Output equals +/// `sum(last `period` prices) / period` once the window is full; `None` before. +/// +/// On long-running streams a single-subtract incremental sum can accumulate +/// rounding error (catastrophic cancellation when values of very different +/// magnitudes are alternately added and removed). To keep drift bounded, the +/// running sum is reseeded from the live window every `16 · period` updates — +/// O(1) amortised cost (`O(period)` work amortised over `O(period)` updates), +/// zero observable behaviour change on inputs that did not drift to begin +/// with, and a strict cap on accumulated rounding for streams that did. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Sma}; +/// +/// let mut indicator = Sma::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Sma { + period: usize, + /// Fixed-capacity ring buffer of the last `period` finite inputs. A flat + /// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path: + /// sequential storage, branchless wraparound, no per-call bookkeeping. + buf: Box<[f64]>, + /// Index of the next slot to write — also the oldest element once full. + head: usize, + /// Number of slots filled, saturating at `period`. + count: usize, + sum: f64, + /// Number of finite updates since the running `sum` was last reseeded from + /// the live window. Caps accumulated floating-point drift on long streams. + /// See [`RECOMPUTE_EVERY`] below. + updates_since_recompute: usize, +} + +/// How often (in finite updates) the incremental sum is reseeded from the live +/// window. The multiplier `16` is the smallest power of two that keeps the +/// amortised cost flat under any `period` while still bounding any drift to +/// roughly `16 · period · ULP · max(|x|)` — sub-picodollar on real-world price +/// scales. +const RECOMPUTE_EVERY: usize = 16; + +impl Sma { + /// Construct a new SMA with the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + buf: vec![0.0; period].into_boxed_slice(), + head: 0, + count: 0, + sum: 0.0, + updates_since_recompute: 0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub fn value(&self) -> Option { + if self.count == self.period { + Some(self.sum / self.period as f64) + } else { + None + } + } + + /// Vectorized batch returning one `f64` per input (`NaN` during warmup). + /// + /// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket + /// default via inherent-method resolution. For a fresh, all-finite slice it + /// inlines `update`'s rolling sum and drift-reseed, writing the mean as a bare + /// `f64` (warmup → `NaN`) instead of allocating an `Option` per element + /// and walking the result a second time. Same add/subtract order, same reseed + /// cadence, same `sum / period` division — so it is *bit-for-bit* equal to + /// replaying `update`, including the long-stream drift bound. Any other state, + /// or a non-finite element, defers to the exact `update` replay. + pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec { + let p = self.period; + if self.count != 0 + || self.updates_since_recompute != 0 + || !inputs.iter().all(|x| x.is_finite()) + { + return inputs + .iter() + .map(|&x| self.update(x).unwrap_or(f64::NAN)) + .collect(); + } + + let p_f64 = p as f64; + let mut out = Vec::with_capacity(inputs.len()); + for &x in inputs { + if self.count == p { + self.sum -= self.buf[self.head]; + self.buf[self.head] = x; + self.sum += x; + } else { + self.buf[self.head] = x; + self.sum += x; + self.count += 1; + } + self.head += 1; + if self.head == p { + self.head = 0; + } + self.updates_since_recompute += 1; + if self.updates_since_recompute >= RECOMPUTE_EVERY * p { + self.sum = self.buf[self.head..] + .iter() + .chain(&self.buf[..self.head]) + .copied() + .sum(); + self.updates_since_recompute = 0; + } + out.push(if self.count == p { + self.sum / p_f64 + } else { + f64::NAN + }); + } + out + } +} + +impl Indicator for Sma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.count == self.period { + // Window full: overwrite the oldest slot (at `head`). Each step is a + // single f64 add/subtract — O(1) but introduces ~1 ULP of rounding + // noise. The periodic reseed below caps the accumulated drift. + self.sum -= self.buf[self.head]; + self.buf[self.head] = input; + self.sum += input; + } else { + self.buf[self.head] = input; + self.sum += input; + self.count += 1; + } + // Branchless-ish wraparound, cheaper than `% period`. + self.head += 1; + if self.head == self.period { + self.head = 0; + } + self.updates_since_recompute += 1; + if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { + // Reseed in chronological order (oldest at `head`) so the running sum + // tracks a fresh from-scratch mean to the bit on stable inputs. + self.sum = self.buf[self.head..] + .iter() + .chain(&self.buf[..self.head]) + .copied() + .sum(); + self.updates_since_recompute = 0; + } + self.value() + } + + fn reset(&mut self) { + self.head = 0; + self.count = 0; + self.sum = 0.0; + self.updates_since_recompute = 0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.count == self.period + } + + fn name(&self) -> &'static str { + "SMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + use std::collections::VecDeque; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Sma::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` (70-72) and the Indicator-impl + /// `warmup_period` (115-117) + `name` (123-125). Existing tests + /// inspect SMA output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let sma = Sma::new(20).unwrap(); + assert_eq!(sma.period(), 20); + assert_eq!(sma.warmup_period(), 20); + assert_eq!(sma.name(), "SMA"); + } + + #[test] + fn warmup_returns_none() { + let mut sma = Sma::new(3).unwrap(); + assert_eq!(sma.update(1.0), None); + assert_eq!(sma.update(2.0), None); + assert_eq!(sma.update(3.0), Some(2.0)); + } + + #[test] + fn rolls_window_after_full() { + let mut sma = Sma::new(3).unwrap(); + let out: Vec<_> = [1.0, 2.0, 3.0, 4.0, 5.0] + .iter() + .map(|p| sma.update(*p)) + .collect(); + assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]); + } + + #[test] + fn period_one_is_pass_through() { + let mut sma = Sma::new(1).unwrap(); + assert_eq!(sma.update(5.0), Some(5.0)); + assert_eq!(sma.update(10.0), Some(10.0)); + } + + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut sma = Sma::new(3).unwrap(); + sma.update(1.0); + sma.update(2.0); + sma.update(3.0); + assert_eq!(sma.update(f64::NAN), Some(2.0)); + assert_eq!(sma.update(f64::INFINITY), Some(2.0)); + // Non-finite inputs were not pushed; window still holds 1,2,3. + assert_eq!(sma.update(6.0), Some((2.0 + 3.0 + 6.0) / 3.0)); + } + + #[test] + fn reset_clears_state() { + let mut sma = Sma::new(3).unwrap(); + sma.batch(&[1.0, 2.0, 3.0]); + assert!(sma.is_ready()); + sma.reset(); + assert!(!sma.is_ready()); + assert_eq!(sma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(f64::from).collect(); + let mut a = Sma::new(5).unwrap(); + let batch = a.batch(&prices); + let mut b = Sma::new(5).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn known_reference_values() { + // SMA(3) of [2, 4, 6, 8, 10] -> [_, _, 4, 6, 8] + let mut sma = Sma::new(3).unwrap(); + let out = sma.batch(&[2.0, 4.0, 6.0, 8.0, 10.0]); + assert_eq!(out[2], Some(4.0)); + assert_eq!(out[3], Some(6.0)); + assert_eq!(out[4], Some(8.0)); + } + + #[test] + fn constant_series_yields_constant_sma() { + let mut sma = Sma::new(5).unwrap(); + let v = sma.batch(&[7.0; 10]); + for x in v.iter().skip(4) { + assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12); + } + } + + /// NaN-aware bit-equality for the `f64`-with-NaN-warmup batch outputs. + fn bits_eq(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x == y || (x.is_nan() && y.is_nan())) + } + + fn sma_replay(period: usize, series: &[f64]) -> Vec { + let mut s = Sma::new(period).unwrap(); + series + .iter() + .map(|&x| s.update(x).unwrap_or(f64::NAN)) + .collect() + } + + #[test] + fn batch_nan_fast_path_is_bit_identical_with_reseed() { + // > 16*period inputs so the drift-reseed branch fires inside batch_nan. + let series: Vec = (0..500) + .map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0) + .collect(); + let mut sma = Sma::new(14).unwrap(); + let got = sma.batch_nan(&series); + assert!(bits_eq(&got, &sma_replay(14, &series))); + // State left where the replay would: continued updates agree. + let mut ref_sma = Sma::new(14).unwrap(); + for &x in &series { + ref_sma.update(x); + } + assert_eq!(sma.update(42.0), ref_sma.update(42.0)); + } + + #[test] + fn batch_nan_falls_back_on_non_finite() { + let series = [1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0]; + let mut sma = Sma::new(3).unwrap(); + assert!(bits_eq(&sma.batch_nan(&series), &sma_replay(3, &series))); + } + + #[test] + fn batch_nan_falls_back_when_not_fresh() { + let mut sma = Sma::new(3).unwrap(); + sma.update(99.0); + let series = [1.0, 2.0, 3.0, 4.0]; + let mut ref_sma = Sma::new(3).unwrap(); + ref_sma.update(99.0); + let want: Vec = series + .iter() + .map(|&x| ref_sma.update(x).unwrap_or(f64::NAN)) + .collect(); + assert!(bits_eq(&sma.batch_nan(&series), &want)); + } + + #[test] + fn batch_nan_sub_period_slice_is_all_nan() { + let series = [1.0, 2.0, 3.0]; + let mut sma = Sma::new(10).unwrap(); + let got = sma.batch_nan(&series); + assert!(bits_eq(&got, &sma_replay(10, &series))); + assert!(got.iter().all(|x| x.is_nan())); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(64))] + #[test] + fn sma_matches_naive_definition( + period in 1usize..20, + prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..200), + ) { + let mut sma = Sma::new(period).unwrap(); + let stream: Vec<_> = prices.iter().map(|p| sma.update(*p)).collect(); + for (i, got) in stream.iter().enumerate() { + if i + 1 < period { + proptest::prop_assert!(got.is_none()); + } else { + let window = &prices[i + 1 - period..=i]; + let expected = window.iter().sum::() / period as f64; + let actual = got.expect("ready"); + proptest::prop_assert!( + (actual - expected).abs() < 1e-9, + "i={i} actual={actual} expected={expected}" + ); + } + } + } + } + + /// Long-running stability check. Runs more updates than `RECOMPUTE_EVERY * + /// period` so the periodic reseed must fire several times, then asserts + /// that the reported SMA still equals a fresh from-scratch mean over the + /// live window to within tight floating-point tolerance. Inputs swing + /// between two magnitudes (`1e9` and `1.0`) — a pattern designed to + /// expose catastrophic cancellation in a naive single-subtract sum. + #[test] + fn long_stream_drift_stays_bounded() { + let period = 20; + let mut sma = Sma::new(period).unwrap(); + let mut window: VecDeque = VecDeque::with_capacity(period); + // `RECOMPUTE_EVERY * period * 5` updates → recompute fires 5+ times. + let n_updates = 16 * period * 5; + for i in 0..n_updates { + let v = if i % 2 == 0 { 1e9 } else { 1.0 }; + sma.update(v); + if window.len() == period { + window.pop_front(); + } + window.push_back(v); + } + let from_scratch: f64 = window.iter().sum::() / period as f64; + let got = sma.value().expect("warmed up"); + assert!( + (got - from_scratch).abs() < 1e-6, + "SMA drift exceeds 1e-6 over {n_updates} updates: got={got}, scratch={from_scratch}" + ); + } +} diff --git a/crates/wickra-core/src/indicators/smi.rs b/crates/wickra-core/src/indicators/smi.rs new file mode 100644 index 0000000..584d17e --- /dev/null +++ b/crates/wickra-core/src/indicators/smi.rs @@ -0,0 +1,285 @@ +//! Stochastic Momentum Index (SMI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// William Blau's Stochastic Momentum Index — a doubly-smoothed, +/// `±100`-bounded oscillator built from the close's distance to the centre +/// of the recent high-low range. +/// +/// Over the lookback `period`, let `HH = max(high)`, `LL = min(low)`, +/// `C = (HH + LL) / 2` and `R = HH - LL`. The raw displacement is +/// `d_t = close_t - C_t`. Both `d` and `R` are smoothed twice with `EMA`s, +/// then combined into the bounded reading: +/// +/// ```text +/// D_smoothed = EMA(EMA(d, d_period), d2_period) +/// HL_smoothed = EMA(EMA(R, d_period), d2_period) +/// SMI = 100 · D_smoothed / (HL_smoothed / 2) +/// ``` +/// +/// Blau's recommended defaults are `(period = 5, d = 3, d2 = 3)`. Wickra +/// publishes the SMI value only; the optional signal `EMA(SMI, k)` is left +/// to the consumer via `Chain` / their own `Ema`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Smi}; +/// +/// let mut smi = Smi::new(5, 3, 3).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap(); +/// last = smi.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Smi { + period: usize, + d_period: usize, + d2_period: usize, + highs: VecDeque, + lows: VecDeque, + ema_d1: Ema, + ema_d2: Ema, + ema_r1: Ema, + ema_r2: Ema, + current: Option, +} + +impl Smi { + /// # Errors + /// Returns [`Error::PeriodZero`] if any period is zero. + pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result { + if period == 0 || d_period == 0 || d2_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + d_period, + d2_period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + ema_d1: Ema::new(d_period)?, + ema_d2: Ema::new(d2_period)?, + ema_r1: Ema::new(d_period)?, + ema_r2: Ema::new(d2_period)?, + current: None, + }) + } + + /// Blau's recommended defaults `(period = 5, d = 3, d2 = 3)`. + pub fn classic() -> Self { + Self::new(5, 3, 3).expect("classic SMI parameters are valid") + } + + /// Configured `(period, d_period, d2_period)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.period, self.d_period, self.d2_period) + } +} + +impl Indicator for Smi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.period { + return None; + } + let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let center = f64::midpoint(hh, ll); + let displacement = candle.close - center; + let range = hh - ll; + + // Feed every EMA on every candle so both stacks warm in parallel — + // gating the range stack behind the displacement stack would starve + // it by one input. + let d1 = self.ema_d1.update(displacement); + let r1 = self.ema_r1.update(range); + let d2 = d1.and_then(|x| self.ema_d2.update(x)); + let r2 = r1.and_then(|x| self.ema_r2.update(x)); + let (d2, r2) = (d2?, r2?); + + if r2 <= 0.0 { + // Window where the smoothed range collapses to zero: the formula + // is undefined. Hold the previous reading rather than emit inf. + return self.current; + } + let value = 100.0 * d2 / (r2 / 2.0); + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.ema_d1.reset(); + self.ema_d2.reset(); + self.ema_r1.reset(); + self.ema_r2.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The high-low window needs `period` candles; then both EMA stacks + // need `d_period + d2_period - 1` more values to fully warm up. + self.period + self.d_period + self.d2_period - 2 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "SMI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Smi::new(0, 3, 3), Err(Error::PeriodZero))); + assert!(matches!(Smi::new(5, 0, 3), Err(Error::PeriodZero))); + assert!(matches!(Smi::new(5, 3, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let smi = Smi::new(5, 3, 3).unwrap(); + assert_eq!(smi.periods(), (5, 3, 3)); + assert_eq!(smi.warmup_period(), 9); + assert_eq!(smi.name(), "SMI"); + } + + #[test] + fn classic_factory() { + let smi = Smi::classic(); + assert_eq!(smi.periods(), (5, 3, 3)); + } + + #[test] + fn close_at_high_pushes_toward_plus_100() { + // Every candle's close equals its high in a rising series: the + // displacement is at the top of the range every bar, so SMI sits in + // the strongly positive region. After enough double-smoothing it + // approaches the upper bound. + let mut smi = Smi::classic(); + let mut last = None; + for i in 0..80 { + let h = 100.0 + f64::from(i); + let l = h - 2.0; + last = smi.update(candle(h, l, h, i64::from(i))); + } + let v = last.expect("SMI is warm"); + assert!( + v > 50.0, + "close-at-high series should drive SMI well above 0: {v}" + ); + } + + #[test] + fn close_at_low_pushes_toward_minus_100() { + let mut smi = Smi::classic(); + let mut last = None; + for i in 0..80 { + let h = 100.0 - f64::from(i); + let l = h - 2.0; + last = smi.update(candle(h, l, l, i64::from(i))); + } + let v = last.expect("SMI is warm"); + assert!( + v < -50.0, + "close-at-low series should drive SMI well below 0: {v}" + ); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut smi = Smi::new(3, 2, 2).unwrap(); + // period 3 + d 2 + d2 2 - 2 = 5. + assert_eq!(smi.warmup_period(), 5); + let mut got = None; + for i in 0..5 { + got = smi.update(candle(11.0, 9.0, 10.0, i)); + } + assert!(got.is_some()); + } + + #[test] + fn flat_close_yields_zero_displacement() { + // Every close is exactly at the centre of the range -> displacement + // is 0 every bar -> SMI converges to 0. + let mut smi = Smi::classic(); + let mut last = None; + for i in 0..60 { + // High and low straddle a constant close. + last = smi.update(candle(11.0, 9.0, 10.0, i)); + } + let v = last.unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c + 1.0, c - 1.0, c, i) + }) + .collect(); + let batch = Smi::classic().batch(&candles); + let mut b = Smi::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut smi = Smi::classic(); + for i in 0..40 { + smi.update(candle(11.0, 9.0, 10.0, i)); + } + assert!(smi.is_ready()); + smi.reset(); + assert!(!smi.is_ready()); + } + + #[test] + fn zero_range_holds_previous_value() { + // High == low on every bar -> instantaneous range is zero, the + // EMA of (range / 2) settles to zero, so `r2 <= 0.0` after warmup + // and the indicator must hold its previous value (None here, since + // r2 was zero from the very first warm bar) rather than divide by + // zero. + let mut smi = Smi::new(3, 2, 2).unwrap(); + // warmup_period = 3 + 2 + 2 - 2 = 5; feed warmup + 2 extra bars. + for i in 0..7 { + let v = smi.update(candle(10.0, 10.0, 10.0, i)); + assert_eq!(v, None, "zero-range SMI must hold None, got {v:?}"); + } + } +} diff --git a/crates/wickra-core/src/indicators/smma.rs b/crates/wickra-core/src/indicators/smma.rs new file mode 100644 index 0000000..a5561bd --- /dev/null +++ b/crates/wickra-core/src/indicators/smma.rs @@ -0,0 +1,198 @@ +//! Smoothed Moving Average (Wilder's RMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Smoothed Moving Average — Wilder's running moving average, also known as +/// RMA. +/// +/// Seeded with the simple average of the first `period` inputs, then advanced +/// by `SMMA_t = (SMMA_{t-1} * (period - 1) + price_t) / period`. This is an +/// exponential average with a slow `1 / period` smoothing factor and is the +/// average underlying Wilder's RSI and ATR. The first output lands after +/// exactly `period` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Smma}; +/// +/// let mut indicator = Smma::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Smma { + period: usize, + /// Inputs collected while seeding (before the first value is produced). + seed: VecDeque, + seed_sum: f64, + current: Option, +} + +impl Smma { + /// Construct a new SMMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + seed: VecDeque::with_capacity(period), + seed_sum: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Smma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored, leaving state untouched. + return self.current; + } + if let Some(prev) = self.current { + let period = self.period as f64; + self.current = Some((prev * (period - 1.0) + input) / period); + } else { + self.seed.push_back(input); + self.seed_sum += input; + if self.seed.len() == self.period { + self.current = Some(self.seed_sum / self.period as f64); + } + } + self.current + } + + fn reset(&mut self) { + self.seed.clear(); + self.seed_sum = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "SMMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Smma::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` and the Indicator-impl + /// `warmup_period` / `name` methods. Existing tests only exercise the + /// numeric output of `update` / `batch` / `reset`, never query the + /// metadata surface. + #[test] + fn accessors_and_metadata() { + let mut smma = Smma::new(7).unwrap(); + assert_eq!(smma.period(), 7); + assert_eq!(smma.warmup_period(), 7); + assert_eq!(smma.name(), "SMMA"); + // value() must report both the pre-warmup None and post-warmup Some branches. + assert_eq!(smma.value(), None); + for i in 1..=7 { + smma.update(f64::from(i)); + } + assert!(smma.value().is_some()); + } + + #[test] + fn warmup_then_recurrence() { + // SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3. + let mut smma = Smma::new(3).unwrap(); + assert_eq!(smma.update(1.0), None); + assert_eq!(smma.update(2.0), None); + assert_eq!(smma.update(3.0), Some(2.0)); + assert_relative_eq!( + smma.update(4.0).unwrap(), + (2.0 * 2.0 + 4.0) / 3.0, + epsilon = 1e-12 + ); + assert_relative_eq!( + smma.update(5.0).unwrap(), + ((2.0 * 2.0 + 4.0) / 3.0 * 2.0 + 5.0) / 3.0, + epsilon = 1e-12 + ); + } + + #[test] + fn period_one_is_pass_through() { + let mut smma = Smma::new(1).unwrap(); + assert_eq!(smma.update(5.0), Some(5.0)); + assert_eq!(smma.update(10.0), Some(10.0)); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut smma = Smma::new(5).unwrap(); + let out = smma.batch(&[7.0; 20]); + for x in out.iter().skip(4) { + assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut smma = Smma::new(3).unwrap(); + smma.batch(&[1.0, 2.0, 3.0]); + assert_eq!(smma.update(f64::NAN), Some(2.0)); + assert_eq!(smma.update(f64::INFINITY), Some(2.0)); + } + + #[test] + fn reset_clears_state() { + let mut smma = Smma::new(3).unwrap(); + smma.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(smma.is_ready()); + smma.reset(); + assert!(!smma.is_ready()); + assert_eq!(smma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30).map(f64::from).collect(); + let batch = Smma::new(7).unwrap().batch(&prices); + let mut b = Smma::new(7).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/smoothed_heikin_ashi.rs b/crates/wickra-core/src/indicators/smoothed_heikin_ashi.rs new file mode 100644 index 0000000..00d27d1 --- /dev/null +++ b/crates/wickra-core/src/indicators/smoothed_heikin_ashi.rs @@ -0,0 +1,252 @@ +//! Smoothed Heikin-Ashi — Heikin-Ashi computed on EMA-smoothed OHLC. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// One smoothed Heikin-Ashi candle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SmoothedHeikinAshiOutput { + /// Smoothed Heikin-Ashi open. + pub open: f64, + /// Smoothed Heikin-Ashi high. + pub high: f64, + /// Smoothed Heikin-Ashi low. + pub low: f64, + /// Smoothed Heikin-Ashi close. + pub close: f64, +} + +/// Smoothed Heikin-Ashi — the [`HeikinAshi`](crate::HeikinAshi) transform applied +/// to **EMA-smoothed** OHLC, for an even cleaner trend view. +/// +/// ```text +/// eo, eh, el, ec = EMA(open|high|low|close, period) +/// ha_close = (eo + eh + el + ec) / 4 +/// ha_open = (prev_ha_open + prev_ha_close) / 2 (seeded with (eo + ec)/2) +/// ha_high = max(eh, ha_open, ha_close) +/// ha_low = min(el, ha_open, ha_close) +/// ``` +/// +/// Standard Heikin-Ashi already averages the OHLC; smoothing each input series +/// with an EMA *before* the transform removes still more noise, producing long, +/// uninterrupted runs of same-colour candles in a trend and crisp colour flips at +/// turns. The trade-off is added lag proportional to `period`. The output uses the +/// same OHLC field layout as a candle so it can be charted directly. +/// +/// The first value lands once the EMAs are seeded (`period` inputs). Each `update` +/// is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SmoothedHeikinAshi}; +/// +/// let mut indicator = SmoothedHeikinAshi::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SmoothedHeikinAshi { + period: usize, + ema_open: Ema, + ema_high: Ema, + ema_low: Ema, + ema_close: Ema, + prev: Option, + last: Option, +} + +impl SmoothedHeikinAshi { + /// Construct a smoothed Heikin-Ashi with the given EMA `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + ema_open: Ema::new(period)?, + ema_high: Ema::new(period)?, + ema_low: Ema::new(period)?, + ema_close: Ema::new(period)?, + prev: None, + last: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for SmoothedHeikinAshi { + type Input = Candle; + type Output = SmoothedHeikinAshiOutput; + + fn update(&mut self, candle: Candle) -> Option { + let eo = self.ema_open.update(candle.open); + let eh = self.ema_high.update(candle.high); + let el = self.ema_low.update(candle.low); + let ec = self.ema_close.update(candle.close); + let (Some(eo), Some(eh), Some(el), Some(ec)) = (eo, eh, el, ec) else { + return None; + }; + let ha_close = (eo + eh + el + ec) / 4.0; + let ha_open = match self.prev { + Some(p) => f64::midpoint(p.open, p.close), + None => f64::midpoint(eo, ec), + }; + let ha_high = eh.max(ha_open).max(ha_close); + let ha_low = el.min(ha_open).min(ha_close); + let out = SmoothedHeikinAshiOutput { + open: ha_open, + high: ha_high, + low: ha_low, + close: ha_close, + }; + self.prev = Some(out); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.ema_open.reset(); + self.ema_high.reset(); + self.ema_low.reset(); + self.ema_close.reset(); + self.prev = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SmoothedHeikinAshi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(open, high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(SmoothedHeikinAshi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let s = SmoothedHeikinAshi::new(10).unwrap(); + assert_eq!(s.period(), 10); + assert_eq!(s.warmup_period(), 10); + assert_eq!(s.name(), "SmoothedHeikinAshi"); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut s = SmoothedHeikinAshi::new(3).unwrap(); + let candles: Vec = (0..6) + .map(|i| { + let b = 100.0 + f64::from(i); + c(b, b + 1.0, b - 1.0, b + 0.5) + }) + .collect(); + let out = s.batch(&candles); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn high_brackets_open_close() { + let mut s = SmoothedHeikinAshi::new(3).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let b = 100.0 + f64::from(i); + c(b, b + 2.0, b - 2.0, b + 0.5) + }) + .collect(); + for o in s.batch(&candles).into_iter().flatten() { + assert!(o.high >= o.open && o.high >= o.close); + assert!(o.low <= o.open && o.low <= o.close); + } + } + + #[test] + fn uptrend_close_above_open() { + let mut s = SmoothedHeikinAshi::new(3).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let b = 100.0 + 2.0 * f64::from(i); + c(b, b + 1.0, b - 1.0, b + 0.5) + }) + .collect(); + let o = s.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + o.close > o.open, + "an uptrend should print a bullish smoothed HA candle" + ); + } + + #[test] + fn reset_clears_state() { + let mut s = SmoothedHeikinAshi::new(3).unwrap(); + s.batch( + &(0..10) + .map(|i| { + let b = 100.0 + f64::from(i); + c(b, b + 1.0, b - 1.0, b) + }) + .collect::>(), + ); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.value(), None); + assert_eq!(s.update(c(100.0, 101.0, 99.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + c(b, b + 1.0, b - 1.0, b + 0.3) + }) + .collect(); + let batch = SmoothedHeikinAshi::new(10).unwrap().batch(&candles); + let mut b = SmoothedHeikinAshi::new(10).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/sortino_ratio.rs b/crates/wickra-core/src/indicators/sortino_ratio.rs new file mode 100644 index 0000000..e0e2543 --- /dev/null +++ b/crates/wickra-core/src/indicators/sortino_ratio.rs @@ -0,0 +1,197 @@ +//! Rolling Sortino Ratio — Sharpe with downside-only volatility. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Sortino Ratio. +/// +/// Like the Sharpe Ratio but only penalises **downside** volatility — returns +/// below the minimum acceptable return (`mar`). The numerator is excess return +/// over `mar`; the denominator is the downside deviation: +/// +/// ```text +/// downside_dev = sqrt( mean( min(0, r − mar)² over period ) ) +/// Sortino = (mean(r) − mar) / downside_dev +/// ``` +/// +/// Downside variance uses the population formula (`n` in the denominator) +/// since the negative-shortfall samples are treated as the full population. +/// If every return in the window is ≥ `mar` the downside deviation is `0` +/// and the indicator returns `0.0` rather than `NaN`. +/// +/// Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SortinoRatio}; +/// +/// let mut sr = SortinoRatio::new(20, 0.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = sr.update((f64::from(i) * 0.1).sin() * 0.01); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SortinoRatio { + period: usize, + mar: f64, + window: VecDeque, + sum: f64, +} + +impl SortinoRatio { + /// Construct a new rolling Sortino Ratio. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize, mar: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "sortino ratio needs period >= 2", + }); + } + Ok(Self { + period, + mar, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured minimum-acceptable return. + pub const fn mar(&self) -> f64 { + self.mar + } +} + +impl Indicator for SortinoRatio { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + } + self.window.push_back(input); + self.sum += input; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + let mut downside_sq = 0.0; + for &r in &self.window { + let d = r - self.mar; + if d < 0.0 { + downside_sq += d * d; + } + } + let dd = (downside_sq / n).sqrt(); + if dd == 0.0 { + return Some(0.0); + } + Some((mean - self.mar) / dd) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "SortinoRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_less_than_two() { + assert!(matches!( + SortinoRatio::new(1, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let s = SortinoRatio::new(10, 0.001).unwrap(); + assert_eq!(s.period(), 10); + assert_relative_eq!(s.mar(), 0.001, epsilon = 1e-12); + assert_eq!(s.name(), "SortinoRatio"); + assert_eq!(s.warmup_period(), 10); + } + + #[test] + fn all_returns_above_mar_yields_zero_downside() { + let mut s = SortinoRatio::new(5, 0.0).unwrap(); + let out = s.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]); + // Downside deviation is 0 -> indicator returns 0.0. + assert_eq!(out[4], Some(0.0)); + } + + #[test] + fn reference_value() { + // returns = [-0.02, 0.01, -0.01, 0.03], mar = 0. + // mean = 0.0025, downside_sq = (0.02)^2 + (0.01)^2 = 0.0005; + // downside_dev = sqrt(0.0005 / 4) = sqrt(0.000125) ≈ 0.01118033... + // Sortino = 0.0025 / 0.011180339887 ≈ 0.2236068. + let mut s = SortinoRatio::new(4, 0.0).unwrap(); + let out = s.batch(&[-0.02, 0.01, -0.01, 0.03]); + let expected = 0.0025 / (0.000_125_f64).sqrt(); + assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9); + } + + #[test] + fn ignores_non_finite_input() { + let mut s = SortinoRatio::new(3, 0.0).unwrap(); + assert_eq!(s.update(f64::NAN), None); + assert_eq!(s.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut s = SortinoRatio::new(3, 0.0).unwrap(); + s.batch(&[-0.01, -0.02, -0.005]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..50) + .map(|i| 0.001 + (f64::from(i) * 0.3).sin() * 0.02) + .collect(); + let batch = SortinoRatio::new(10, 0.0).unwrap().batch(&returns); + let mut s = SortinoRatio::new(10, 0.0).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/spearman_correlation.rs b/crates/wickra-core/src/indicators/spearman_correlation.rs new file mode 100644 index 0000000..688c391 --- /dev/null +++ b/crates/wickra-core/src/indicators/spearman_correlation.rs @@ -0,0 +1,327 @@ +//! Rolling Spearman rank correlation between two synchronised series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Spearman rank correlation between two synchronised series. +/// +/// Each `update` receives one `(x, y)` pair. Over the trailing window of +/// `period` pairs, the values in each channel are replaced by their ranks +/// (mid-ranks for ties), and the Pearson correlation of those ranks is +/// reported: +/// +/// ```text +/// rx = rank(x_i) with mid-rank tie handling +/// ry = rank(y_i) with mid-rank tie handling +/// Spearman = Pearson( rx, ry ) +/// ``` +/// +/// Spearman is the non-linear, **monotone** analogue of +/// [`crate::PearsonCorrelation`]: `+1` means the two series move in the +/// same direction (any monotone relationship, not just linear); `−1` +/// means they move in opposite directions; `0` means no monotone +/// relationship. Because ranks throw away magnitude, Spearman is robust +/// to outliers and to non-linear (but monotone) transformations — the +/// canonical example is two assets that move together but with very +/// different volatility profiles. +/// +/// Each `update` is O(period²) in the naïve implementation; Wickra uses +/// an O(period log period) sort-and-pair approach: the window is copied +/// into a scratch buffer, sorted twice (once per channel) to derive the +/// ranks, then Pearson is computed on the rank arrays via the same O(n) +/// rolling sums as [`crate::PearsonCorrelation`]. +/// +/// A window in which one channel is constant has no rank dispersion and +/// the correlation is undefined; the indicator returns `0` rather than +/// `NaN`. The output is clamped to `[−1, +1]` to absorb tiny +/// floating-point overshoots. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SpearmanCorrelation}; +/// +/// let mut indicator = SpearmanCorrelation::new(10).unwrap(); +/// let mut last = None; +/// for i in 1..20 { +/// // Strictly monotone — Spearman should be +1. +/// last = indicator.update((f64::from(i), (f64::from(i)).powi(3))); +/// } +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct SpearmanCorrelation { + period: usize, + window: VecDeque<(f64, f64)>, + /// Reusable scratch buffer for ranking; pairs of `(value, original_index)`. + scratch: Vec<(f64, usize)>, + /// Reusable rank buffers, indexed by original position in the window. + rx: Vec, + ry: Vec, +} + +impl SpearmanCorrelation { + /// Construct a new rolling Spearman correlation. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "spearman correlation needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + scratch: Vec::with_capacity(period), + rx: vec![0.0; period], + ry: vec![0.0; period], + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +/// Fill `ranks_out[original_index] = rank` for the supplied `values`, +/// using mid-ranks for ties. `scratch` is reused so no allocation +/// happens per call after the first. +fn rank_into( + values: impl Iterator, + ranks_out: &mut [f64], + scratch: &mut Vec<(f64, usize)>, +) { + scratch.clear(); + for (i, v) in values.enumerate() { + scratch.push((v, i)); + } + scratch.sort_by(|a, b| a.0.total_cmp(&b.0)); + let n = scratch.len(); + let mut i = 0; + while i < n { + let mut j = i + 1; + while j < n && scratch[j].0 == scratch[i].0 { + j += 1; + } + // Mid-rank of positions [i, j-1] in 1-indexed terms: + // (i + 1 + j) / 2. + let mid = (i as f64 + 1.0 + j as f64) / 2.0; + for k in i..j { + ranks_out[scratch[k].1] = mid; + } + i = j; + } +} + +impl Indicator for SpearmanCorrelation { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + // Rank each channel. + rank_into( + self.window.iter().map(|p| p.0), + &mut self.rx, + &mut self.scratch, + ); + rank_into( + self.window.iter().map(|p| p.1), + &mut self.ry, + &mut self.scratch, + ); + // Pearson over the rank arrays. Closed forms are not used here + // because tie handling produces mid-ranks; the generic Pearson keeps + // the code uniform. + let n = self.period as f64; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xx = 0.0; + let mut sum_yy = 0.0; + let mut sum_xy = 0.0; + for i in 0..self.period { + let x = self.rx[i]; + let y = self.ry[i]; + sum_x += x; + sum_y += y; + sum_xx += x * x; + sum_yy += y * y; + sum_xy += x * y; + } + let mean_x = sum_x / n; + let mean_y = sum_y / n; + let var_x = (sum_xx / n - mean_x * mean_x).max(0.0); + let var_y = (sum_yy / n - mean_y * mean_y).max(0.0); + let cov = sum_xy / n - mean_x * mean_y; + let denom = (var_x * var_y).sqrt(); + if denom == 0.0 { + return Some(0.0); + } + Some((cov / denom).clamp(-1.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + self.scratch.clear(); + self.rx.iter_mut().for_each(|r| *r = 0.0); + self.ry.iter_mut().for_each(|r| *r = 0.0); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "SpearmanCorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_two() { + assert!(SpearmanCorrelation::new(0).is_err()); + assert!(SpearmanCorrelation::new(1).is_err()); + assert!(SpearmanCorrelation::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let s = SpearmanCorrelation::new(14).unwrap(); + assert_eq!(s.period(), 14); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "SpearmanCorrelation"); + } + + #[test] + fn perfect_monotone_relationship_is_one() { + // y = x³ is strictly monotone but very non-linear; Pearson would + // not return exactly 1 but Spearman must. + let pairs: Vec<(f64, f64)> = (1..=10) + .map(|i| (f64::from(i), (f64::from(i)).powi(3))) + .collect(); + let last = SpearmanCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn perfect_inverse_is_minus_one() { + let pairs: Vec<(f64, f64)> = (1..=10) + .map(|i| (f64::from(i), 1.0 / (f64::from(i)))) + .collect(); + let last = SpearmanCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn constant_channel_yields_zero() { + let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect(); + let last = SpearmanCorrelation::new(5) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn output_in_minus_one_to_one_range() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (100.0 + t.sin() * 5.0, 50.0 + (t * 0.7).cos() * 3.0) + }) + .collect(); + let mut s = SpearmanCorrelation::new(20).unwrap(); + for v in s.batch(&pairs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn handles_ties_via_mid_ranks() { + // x has a tie at the top; Spearman must still produce a sensible + // value (it equals Pearson of the rank arrays). + let pairs = [(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (3.0, 4.0)]; + let last = SpearmanCorrelation::new(4) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + // Ranks: rx = [1, 2, 3.5, 3.5]; ry = [1, 2, 3, 4]. Pearson of those + // is a positive number less than 1 because of the tie in rx. + assert!(last > 0.0 && last < 1.0); + } + + #[test] + fn reset_clears_state() { + let mut s = SpearmanCorrelation::new(5).unwrap(); + s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update((1.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|i| { + let t = f64::from(i); + (t.sin() + (t * 0.1).cos(), (t * 0.3).cos()) + }) + .collect(); + let batch = SpearmanCorrelation::new(14).unwrap().batch(&pairs); + let mut b = SpearmanCorrelation::new(14).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut s = SpearmanCorrelation::new(2).unwrap(); + assert_eq!(s.update((f64::NAN, 1.0)), None); + assert_eq!(s.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(s.update((1.0, 2.0)), None); + assert!(s.update((2.0, 5.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/spinning_top.rs b/crates/wickra-core/src/indicators/spinning_top.rs new file mode 100644 index 0000000..227498d --- /dev/null +++ b/crates/wickra-core/src/indicators/spinning_top.rs @@ -0,0 +1,221 @@ +//! Spinning Top candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Spinning Top — a single-bar indecision candle with a small body and two +/// long shadows. +/// +/// ```text +/// body = |close − open| +/// upper_shadow = high − max(open, close) +/// lower_shadow = min(open, close) − low +/// range = high − low +/// spinning = body <= body_threshold * range +/// && upper_shadow >= 2 * body +/// && lower_shadow >= 2 * body +/// && body > 0 +/// ``` +/// +/// While direction is ambiguous by intent, the output is direction-signed so +/// downstream filters can distinguish a green spinning top (`+1.0`) from a red +/// one (`−1.0`). A clean Doji (body == 0) is *not* a Spinning Top. +/// +/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SpinningTop}; +/// +/// let mut indicator = SpinningTop::new(); +/// // Body 0.5, both shadows 3.0 -> spinning. +/// let candle = Candle::new(10.0, 13.5, 7.0, 10.5, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct SpinningTop { + body_threshold: f64, + has_emitted: bool, +} + +impl Default for SpinningTop { + fn default() -> Self { + Self::new() + } +} + +impl SpinningTop { + /// Construct a Spinning Top detector with the default body threshold. + pub const fn new() -> Self { + Self { + body_threshold: 0.3, + has_emitted: false, + } + } + + /// Construct a Spinning Top detector with a custom body / range threshold. + pub fn with_threshold(body_threshold: f64) -> Result { + if !(body_threshold > 0.0 && body_threshold <= 1.0) { + return Err(Error::InvalidPeriod { + message: "spinning top body threshold must lie in (0, 1]", + }); + } + Ok(Self { + body_threshold, + has_emitted: false, + }) + } + + /// Configured body / range threshold. + pub fn body_threshold(&self) -> f64 { + self.body_threshold + } +} + +impl Indicator for SpinningTop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + let body_signed = candle.close - candle.open; + let body = body_signed.abs(); + if body <= 0.0 { + return Some(0.0); + } + if body > self.body_threshold * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if upper >= 2.0 * body && lower >= 2.0 * body { + Some(if body_signed > 0.0 { 1.0 } else { -1.0 }) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "SpinningTop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(SpinningTop::with_threshold(0.0).is_err()); + assert!(SpinningTop::with_threshold(1.5).is_err()); + } + + #[test] + fn accepts_valid_threshold() { + let s = SpinningTop::with_threshold(0.25).unwrap(); + assert!((s.body_threshold() - 0.25).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let s = SpinningTop::default(); + assert_eq!(s.name(), "SpinningTop"); + assert_eq!(s.warmup_period(), 1); + assert!(!s.is_ready()); + assert!((s.body_threshold() - 0.3).abs() < 1e-12); + } + + #[test] + fn green_spinning_top_is_plus_one() { + let mut s = SpinningTop::new(); + // body 0.5 (10 -> 10.5), upper 3.0, lower 3.0, range 6.5 -> 0.5/6.5 < 0.3. + assert_eq!(s.update(c(10.0, 13.5, 7.0, 10.5, 0)), Some(1.0)); + } + + #[test] + fn red_spinning_top_is_minus_one() { + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.5, 13.5, 7.0, 10.0, 0)), Some(-1.0)); + } + + #[test] + fn marubozu_is_not_spinning() { + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0)); + } + + #[test] + fn doji_is_not_spinning() { + // body == 0 fails the body > 0 guard. + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn hammer_shape_is_not_spinning_top() { + // Lower shadow is long but upper is tiny -> only one long shadow. + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut s = SpinningTop::new(); + assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 3.0, base - 3.0, base + 0.5, i) + }) + .collect(); + let mut a = SpinningTop::new(); + let mut b = SpinningTop::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut s = SpinningTop::new(); + s.update(c(10.0, 13.5, 7.0, 10.5, 0)); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs b/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs new file mode 100644 index 0000000..fbfbfca --- /dev/null +++ b/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs @@ -0,0 +1,266 @@ +//! AR(1) autoregression coefficient of the spread of two series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// First-order autoregression coefficient `ρ` of the spread `a − b`. +/// +/// Each `update` takes one `(a, b)` price pair and forms the spread +/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator +/// fits the discrete AR(1) model by ordinary least squares of the level on its +/// own lag: +/// +/// ```text +/// sₜ = ρ · sₜ₋₁ + c + εₜ +/// ρ = cov(sₜ₋₁, sₜ) / var(sₜ₋₁) +/// ``` +/// +/// `ρ` is the direct measure of cointegration / mean-reversion strength of the +/// pair: +/// +/// - `ρ` near `0` — the spread snaps back to its mean almost instantly (very +/// strong mean reversion). +/// - `ρ` near `1` — the spread behaves like a random walk (a unit root: no +/// reliable reversion, the pair is *not* cointegrated). +/// - `ρ > 1` — the spread is explosive (diverging). +/// +/// This is the complement of [`OuHalfLife`](crate::OuHalfLife): the OU half-life +/// is `−ln(2) / ln(ρ)` for `0 < ρ < 1`, but `ρ` itself is the raw, unbounded +/// stationarity statistic many pairs-trading screens threshold on directly +/// (e.g. "trade only pairs with `ρ < 0.9`"). When the spread is flat over the +/// window (`var(sₜ₋₁) = 0`) the regression slope is undefined and the indicator +/// returns `0`. +/// +/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's +/// running geometry. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SpreadAr1Coefficient}; +/// +/// let mut ar1 = SpreadAr1Coefficient::new(40).unwrap(); +/// let mut last = None; +/// for t in 0..120 { +/// let b = 100.0 + f64::from(t); +/// // `a` hugs `b` with a fast mean-reverting wobble ⇒ ρ well below 1. +/// let a = b + 2.0 * (f64::from(t) * 0.9).sin(); +/// last = ar1.update((a, b)); +/// } +/// let rho = last.unwrap(); +/// assert!(rho > 0.0 && rho < 1.0); +/// ``` +#[derive(Debug, Clone)] +pub struct SpreadAr1Coefficient { + period: usize, + window: VecDeque, +} + +impl SpreadAr1Coefficient { + /// Construct a new AR(1) spread-coefficient estimator. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression + /// needs at least two `(level, next)` observations (a slope and an + /// intercept). + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "AR(1) spread coefficient needs period >= 3", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured look-back window of spreads. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for SpreadAr1Coefficient { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(a - b); + if self.window.len() < self.period { + return None; + } + // OLS slope ρ of the level on its own lag over the window. + let spreads: Vec = self.window.iter().copied().collect(); + let count = (spreads.len() - 1) as f64; + let mut sum_level = 0.0; + let mut sum_next = 0.0; + let mut sum_ll = 0.0; + let mut sum_ln = 0.0; + for pair in spreads.windows(2) { + let level = pair[0]; + let next = pair[1]; + sum_level += level; + sum_next += next; + sum_ll += level * level; + sum_ln += level * next; + } + let mean_level = sum_level / count; + let mean_next = sum_next / count; + let var_level = sum_ll / count - mean_level * mean_level; + if var_level <= 0.0 { + // Flat spread: the regression has no defined slope. + return Some(0.0); + } + let cov = sum_ln / count - mean_level * mean_next; + Some(cov / var_level) + } + + 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 { + "SpreadAr1Coefficient" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(SpreadAr1Coefficient::new(2).is_err()); + assert!(SpreadAr1Coefficient::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let ar1 = SpreadAr1Coefficient::new(30).unwrap(); + assert_eq!(ar1.period(), 30); + assert_eq!(ar1.warmup_period(), 30); + assert_eq!(ar1.name(), "SpreadAr1Coefficient"); + assert!(!ar1.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut ar1 = SpreadAr1Coefficient::new(4).unwrap(); + assert_eq!(ar1.update((1.0, 0.0)), None); + assert_eq!(ar1.update((2.0, 0.0)), None); + assert_eq!(ar1.update((3.0, 0.0)), None); + assert!(ar1.update((4.0, 0.0)).is_some()); + assert!(ar1.is_ready()); + } + + #[test] + fn mean_reverting_spread_has_rho_below_one() { + // Fast sinusoidal spread around zero ⇒ stationary ⇒ 0 < ρ < 1. + let pairs: Vec<(f64, f64)> = (0..120) + .map(|t| { + let b = 100.0 + f64::from(t); + let a = b + 2.0 * (f64::from(t) * 0.9).sin(); + (a, b) + }) + .collect(); + let last = SpreadAr1Coefficient::new(40) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last > 0.0 && last < 1.0, "rho {last}"); + } + + #[test] + fn random_walk_spread_has_rho_near_one() { + // Spread = a − b grows by exactly 1 each bar ⇒ next = level + 1 ⇒ + // the OLS slope is exactly 1 (unit root). + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| (2.0 * f64::from(t), f64::from(t))) + .collect(); + let last = SpreadAr1Coefficient::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn flat_spread_returns_zero() { + // a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0. + let pairs: Vec<(f64, f64)> = (0..30) + .map(|t| (5.0 + f64::from(t), f64::from(t))) + .collect(); + let last = SpreadAr1Coefficient::new(10) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 0.0); + } + + #[test] + fn reset_clears_state() { + let mut ar1 = SpreadAr1Coefficient::new(5).unwrap(); + for t in 0..10 { + ar1.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t))); + } + assert!(ar1.is_ready()); + ar1.reset(); + assert!(!ar1.is_ready()); + assert_eq!(ar1.update((1.0, 0.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|t| { + let b = 50.0 + 0.5 * f64::from(t); + (b + (f64::from(t) * 0.6).sin(), b) + }) + .collect(); + let batch = SpreadAr1Coefficient::new(25).unwrap().batch(&pairs); + let mut ar1 = SpreadAr1Coefficient::new(25).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut ar1 = SpreadAr1Coefficient::new(4).unwrap(); + assert_eq!(ar1.update((f64::NAN, 1.0)), None); + assert_eq!(ar1.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(ar1.update((1.0, 0.0)), None); + assert_eq!(ar1.update((2.0, 0.0)), None); + assert_eq!(ar1.update((3.0, 0.0)), None); + assert!(ar1.update((4.0, 0.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/spread_bollinger_bands.rs b/crates/wickra-core/src/indicators/spread_bollinger_bands.rs new file mode 100644 index 0000000..3cdaea0 --- /dev/null +++ b/crates/wickra-core/src/indicators/spread_bollinger_bands.rs @@ -0,0 +1,281 @@ +//! Bollinger bands on the spread of two series, for pairs mean-reversion trading. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Output of [`SpreadBollingerBands`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpreadBollingerBandsOutput { + /// Middle band: the rolling mean of the spread. + pub middle: f64, + /// Upper band: `middle + num_std · σ`. + pub upper: f64, + /// Lower band: `middle − num_std · σ`. + pub lower: f64, + /// `%b`: where the current spread sits across the band, `(s − lower) / + /// (upper − lower)`. `0` is the lower band, `1` the upper, `0.5` the middle. + /// Reported as `0.5` when the band has zero width (a flat spread). + pub percent_b: f64, +} + +/// Bollinger bands on the spread `a − b` of two series. +/// +/// Each `update` takes one `(a, b)` price pair and forms the spread +/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads it builds a +/// classic Bollinger envelope: +/// +/// ```text +/// middle = mean(s) σ = stddev(s) +/// upper = middle + num_std · σ +/// lower = middle − num_std · σ +/// %b = (s_now − lower) / (upper − lower) +/// ``` +/// +/// Applied to a spread rather than a price, the bands are a ready-made pairs +/// mean-reversion signal: the spread riding the **upper** band is stretched +/// rich (a short-the-spread setup), the **lower** band stretched cheap, and a +/// return to the **middle** is the exit. `%b` compresses the location into one +/// number for thresholding. The spread is the raw difference `a − b`, so feed +/// already-comparable legs (e.g. a hedged pair, two yields, or log prices); pair +/// this with [`crate::BetaNeutralSpread`] when the legs need a hedge ratio first. +/// +/// A flat spread yields a zero-width band; `%b` is then reported as the neutral +/// `0.5`. Each `update` is `O(1)`: the mean and variance come from two running +/// sums maintained as the window slides. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SpreadBollingerBands}; +/// +/// let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap(); +/// let mut last = None; +/// for t in 0..60 { +/// let b = 100.0 + f64::from(t); +/// let a = b + 2.0 * (f64::from(t) * 0.5).sin(); +/// last = bb.update((a, b)); +/// } +/// let out = last.unwrap(); +/// assert!(out.lower <= out.middle && out.middle <= out.upper); +/// ``` +#[derive(Debug, Clone)] +pub struct SpreadBollingerBands { + period: usize, + num_std: f64, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl SpreadBollingerBands { + /// Construct new spread Bollinger bands. + /// + /// `period` is the look-back window; `num_std` is the band width in standard + /// deviations. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`, or + /// [`Error::InvalidParameter`] if `num_std` is not strictly positive (and + /// finite). + pub fn new(period: usize, num_std: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "spread bollinger bands needs period >= 2", + }); + } + if !num_std.is_finite() || num_std <= 0.0 { + return Err(Error::InvalidParameter { + message: "spread bollinger bands needs num_std > 0", + }); + } + Ok(Self { + period, + num_std, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured look-back window. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured band width in standard deviations. + pub const fn num_std(&self) -> f64 { + self.num_std + } +} + +impl Indicator for SpreadBollingerBands { + type Input = (f64, f64); + type Output = SpreadBollingerBandsOutput; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + let spread = a - b; + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(spread); + self.sum += spread; + self.sum_sq += spread * spread; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let middle = self.sum / n; + let variance = (self.sum_sq / n - middle * middle).max(0.0); + let sigma = variance.sqrt(); + let half_width = self.num_std * sigma; + let upper = middle + half_width; + let lower = middle - half_width; + let percent_b = if half_width == 0.0 { + 0.5 + } else { + (spread - lower) / (upper - lower) + }; + Some(SpreadBollingerBandsOutput { + middle, + upper, + lower, + percent_b, + }) + } + + 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 { + "SpreadBollingerBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_bad_parameters() { + assert!(SpreadBollingerBands::new(1, 2.0).is_err()); + assert!(SpreadBollingerBands::new(20, 0.0).is_err()); + assert!(SpreadBollingerBands::new(20, -1.0).is_err()); + assert!(SpreadBollingerBands::new(20, f64::NAN).is_err()); + assert!(SpreadBollingerBands::new(2, 2.0).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let bb = SpreadBollingerBands::new(20, 2.5).unwrap(); + assert_eq!(bb.period(), 20); + assert_eq!(bb.num_std(), 2.5); + assert_eq!(bb.warmup_period(), 20); + assert_eq!(bb.name(), "SpreadBollingerBands"); + assert!(!bb.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut bb = SpreadBollingerBands::new(3, 2.0).unwrap(); + assert_eq!(bb.update((1.0, 0.0)), None); + assert_eq!(bb.update((2.0, 0.0)), None); + assert!(bb.update((3.0, 0.0)).is_some()); + assert!(bb.is_ready()); + } + + #[test] + fn hand_computed_value() { + // Spreads 1,2,3,4 (b = 0), period 4, num_std 2: + // mean = 2.5, σ = √1.25, upper = 2.5 + 2√1.25, lower = 2.5 − 2√1.25, + // %b at s = 4 ⇒ 0.8354102. + let pairs = [(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)]; + let out = SpreadBollingerBands::new(4, 2.0) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.middle, 2.5, epsilon = 1e-9); + assert_relative_eq!(out.upper, 4.736_067_977_499_79, epsilon = 1e-9); + assert_relative_eq!(out.lower, 0.263_932_022_500_21, epsilon = 1e-9); + assert_relative_eq!(out.percent_b, 0.835_410_196_624_97, epsilon = 1e-9); + } + + #[test] + fn flat_spread_collapses_band() { + // a − b constant ⇒ σ = 0 ⇒ upper = middle = lower, %b = 0.5. + let pairs: Vec<(f64, f64)> = (0..10) + .map(|t| (5.0 + f64::from(t), f64::from(t))) + .collect(); + let out = SpreadBollingerBands::new(5, 2.0) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.upper, out.middle, epsilon = 1e-12); + assert_relative_eq!(out.lower, out.middle, epsilon = 1e-12); + assert_relative_eq!(out.percent_b, 0.5, epsilon = 1e-12); + } + + #[test] + fn bands_are_ordered() { + let pairs: Vec<(f64, f64)> = (0..80) + .map(|t| { + let b = 100.0 + f64::from(t); + (b + 3.0 * (f64::from(t) * 0.4).sin(), b) + }) + .collect(); + let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap(); + for out in bb.batch(&pairs).into_iter().flatten() { + assert!(out.lower <= out.middle && out.middle <= out.upper); + } + } + + #[test] + fn reset_clears_state() { + let mut bb = SpreadBollingerBands::new(4, 2.0).unwrap(); + bb.batch(&[(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0)]); + assert!(bb.is_ready()); + bb.reset(); + assert!(!bb.is_ready()); + assert_eq!(bb.update((1.0, 0.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..60) + .map(|t| { + let b = 30.0 + 0.7 * f64::from(t); + (b + (f64::from(t) * 0.4).sin() * 1.5, b) + }) + .collect(); + let batch = SpreadBollingerBands::new(15, 2.0).unwrap().batch(&pairs); + let mut bb = SpreadBollingerBands::new(15, 2.0).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| bb.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/spread_hurst.rs b/crates/wickra-core/src/indicators/spread_hurst.rs new file mode 100644 index 0000000..ef5784a --- /dev/null +++ b/crates/wickra-core/src/indicators/spread_hurst.rs @@ -0,0 +1,286 @@ +//! Hurst exponent of the spread of two series, for pairs-trading regime detection. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Hurst exponent of the spread `a − b` over a rolling window. +/// +/// Each `update` takes one `(a, b)` price pair and forms the spread +/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator +/// estimates the Hurst exponent `H` from how the variance of `τ`-lagged +/// differences grows with the lag `τ`: +/// +/// ```text +/// V(τ) = mean_t (s_{t+τ} − s_t)² ∝ τ^(2H) +/// H = slope of log V(τ) on log τ, divided by two +/// ``` +/// +/// `H` classifies the spread's regime: +/// +/// * `H < 0.5` — **mean-reverting** (anti-persistent): the spread snaps back, +/// the regime pairs traders want. +/// * `H ≈ 0.5` — a **random walk**: no exploitable structure. +/// * `H > 0.5` — **trending** (persistent): the spread keeps diverging. +/// +/// The fit uses lags `1..=period/4` (at least two). When the spread is flat — +/// every lagged difference is zero, so the log-regression has fewer than two +/// usable points — the indicator returns the neutral `0.5`. The output is +/// clamped to `[0, 1]`. +/// +/// Each `update` is `O(period · period/4)`, bounded by the fixed window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SpreadHurst}; +/// +/// let mut h = SpreadHurst::new(60).unwrap(); +/// let mut last = None; +/// for t in 0..200 { +/// let b = 100.0 + f64::from(t); +/// // A tight oscillating spread is anti-persistent ⇒ H < 0.5. +/// let a = b + 3.0 * (f64::from(t) * 0.8).sin(); +/// last = h.update((a, b)); +/// } +/// assert!(last.unwrap() < 0.5); +/// ``` +#[derive(Debug, Clone)] +pub struct SpreadHurst { + period: usize, + max_lag: usize, + window: VecDeque, +} + +impl SpreadHurst { + /// Construct a new spread Hurst estimator. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 8` — fewer than eight + /// spreads cannot support a two-lag log–log regression. + pub fn new(period: usize) -> Result { + if period < 8 { + return Err(Error::InvalidPeriod { + message: "spread Hurst needs period >= 8", + }); + } + Ok(Self { + period, + max_lag: (period / 4).max(2), + window: VecDeque::with_capacity(period), + }) + } + + /// Configured look-back window of spreads. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for SpreadHurst { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(a - b); + if self.window.len() < self.period { + return None; + } + let spreads: Vec = self.window.iter().copied().collect(); + // Collect (log τ, log V(τ)) for every lag whose variance is positive. + let mut log_lag = Vec::with_capacity(self.max_lag); + let mut log_var = Vec::with_capacity(self.max_lag); + for lag in 1..=self.max_lag { + let mut sum_sq = 0.0; + let mut count = 0.0; + for pair in spreads.windows(lag + 1) { + let diff = pair[lag] - pair[0]; + sum_sq += diff * diff; + count += 1.0; + } + let var = sum_sq / count; + if var > 0.0 { + log_lag.push((lag as f64).ln()); + log_var.push(var.ln()); + } + } + if log_lag.len() < 2 { + // Degenerate (flat) spread: report the random-walk midpoint. + return Some(0.5); + } + let n = log_lag.len() as f64; + let mean_lag = log_lag.iter().sum::() / n; + let mean_var = log_var.iter().sum::() / n; + let mut cov = 0.0; + let mut var_lag = 0.0; + for (lx, lv) in log_lag.iter().zip(&log_var) { + cov += (lx - mean_lag) * (lv - mean_var); + var_lag += (lx - mean_lag) * (lx - mean_lag); + } + // `log_lag` holds at least two *distinct* lag logarithms, so the lag + // variance is strictly positive — no degenerate-slope guard is needed. + let slope = cov / var_lag; + Some((slope / 2.0).clamp(0.0, 1.0)) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "SpreadHurst" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_eight() { + assert!(SpreadHurst::new(7).is_err()); + assert!(SpreadHurst::new(8).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let h = SpreadHurst::new(40).unwrap(); + assert_eq!(h.period(), 40); + assert_eq!(h.warmup_period(), 40); + assert_eq!(h.name(), "SpreadHurst"); + assert!(!h.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut h = SpreadHurst::new(8).unwrap(); + for t in 0..7 { + assert_eq!(h.update((f64::from(t), 0.0)), None); + } + assert!(h.update((7.0, 0.0)).is_some()); + assert!(h.is_ready()); + } + + #[test] + fn oscillating_spread_is_anti_persistent() { + let pairs: Vec<(f64, f64)> = (0..200) + .map(|t| { + let b = 100.0 + f64::from(t); + (b + 3.0 * (f64::from(t) * 0.8).sin(), b) + }) + .collect(); + let last = SpreadHurst::new(60) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last < 0.5, "H {last}"); + } + + #[test] + fn linear_trend_spread_is_persistent() { + // Spread = a − b = t ⇒ τ-lagged differences are all τ ⇒ V(τ) = τ² ⇒ H = 1. + let pairs: Vec<(f64, f64)> = (0..40) + .map(|t| (2.0 * f64::from(t), f64::from(t))) + .collect(); + let last = SpreadHurst::new(20) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn flat_spread_returns_midpoint() { + // a − b is constant ⇒ all lagged differences zero ⇒ neutral 0.5. + let pairs: Vec<(f64, f64)> = (0..30) + .map(|t| (5.0 + f64::from(t), f64::from(t))) + .collect(); + let last = SpreadHurst::new(16) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.5, epsilon = 1e-12); + } + + #[test] + fn output_in_unit_range() { + let pairs: Vec<(f64, f64)> = (0..150) + .map(|t| { + let b = 50.0 + 0.3 * f64::from(t); + ( + b + (f64::from(t) * 0.5).sin() * 2.0 + (f64::from(t) * 0.13).cos(), + b, + ) + }) + .collect(); + let mut h = SpreadHurst::new(48).unwrap(); + for v in h.batch(&pairs).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut h = SpreadHurst::new(8).unwrap(); + for t in 0..12 { + h.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t))); + } + assert!(h.is_ready()); + h.reset(); + assert!(!h.is_ready()); + assert_eq!(h.update((1.0, 0.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..100) + .map(|t| { + let b = 30.0 + 0.7 * f64::from(t); + (b + (f64::from(t) * 0.4).sin() * 1.5, b) + }) + .collect(); + let batch = SpreadHurst::new(32).unwrap().batch(&pairs); + let mut h = SpreadHurst::new(32).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut h = SpreadHurst::new(8).unwrap(); + assert_eq!(h.update((f64::NAN, 1.0)), None); + assert_eq!(h.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + for t in 0..7 { + assert_eq!(h.update((f64::from(t), 0.0)), None); + } + assert!(h.update((7.0, 0.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/stalled_pattern.rs b/crates/wickra-core/src/indicators/stalled_pattern.rs new file mode 100644 index 0000000..6ce1394 --- /dev/null +++ b/crates/wickra-core/src/indicators/stalled_pattern.rs @@ -0,0 +1,237 @@ +//! Stalled Pattern (Deliberation) candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Stalled Pattern (also called Deliberation) — a 3-bar bearish reversal warning. +/// Two long white candles push higher, then a small-bodied white candle opens at +/// or near the top of the second body and barely advances — the rally is running +/// out of breath, hinting that buyers are losing control. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// small body = |close − open| <= 0.3 * (high − low) +/// bar1, bar2 long white; bar3 small white +/// rising closes: close3 > close2 > close1 +/// bar3 rides the shoulder: open3 >= close2 − 0.1 * (high2 − low2) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Stalled Pattern +/// is a single-direction (bearish-only) warning, so it never emits `+1.0`. The +/// first two bars always return `0.0` because the three-bar window is not yet +/// filled. Body thresholds follow the geometric house style rather than TA-Lib's +/// rolling averages. Pattern-shape check only — no trend filter is applied; combine +/// with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, StalledPattern}; +/// +/// let mut indicator = StalledPattern::new(); +/// indicator.update(Candle::new(10.0, 12.05, 9.9, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(11.0, 14.05, 10.9, 14.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(14.0, 14.6, 13.95, 14.15, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct StalledPattern { + c1: Option, + c2: Option, + has_emitted: bool, +} + +impl StalledPattern { + /// Construct a new Stalled Pattern detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + has_emitted: false, + } + } +} + +impl Indicator for StalledPattern { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + self.c1 = self.c2; + self.c2 = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + let range2 = bar2.high - bar2.low; + let range3 = candle.high - candle.low; + if range1 <= 0.0 || range2 <= 0.0 || range3 <= 0.0 { + return Some(0.0); + } + // All three candles are white. + if bar1.close <= bar1.open || bar2.close <= bar2.open || candle.close <= candle.open { + return Some(0.0); + } + // Rising closes. + if candle.close <= bar2.close || bar2.close <= bar1.close { + return Some(0.0); + } + // bar1 and bar2 are long bodies. + if bar1.close - bar1.open < 0.5 * range1 || bar2.close - bar2.open < 0.5 * range2 { + return Some(0.0); + } + // bar3 is a small body. + if candle.close - candle.open > 0.3 * range3 { + return Some(0.0); + } + // bar3 opens at or near the top of bar2's body (rides the shoulder). + if candle.open >= bar2.close - 0.1 * range2 { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "StalledPattern" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = StalledPattern::new(); + assert_eq!(t.name(), "StalledPattern"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn stalled_pattern_is_minus_one() { + let mut t = StalledPattern::new(); + assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 14.05, 10.9, 14.0, 1)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.6, 13.95, 14.15, 2)), Some(-1.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = StalledPattern::new(); + assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 14.05, 10.9, 14.0, 1)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = StalledPattern::new(); + t.update(c(10.0, 12.05, 9.9, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + // bar3 has zero range. + assert_eq!(t.update(c(14.0, 14.0, 14.0, 14.0, 2)), Some(0.0)); + } + + #[test] + fn non_white_yields_zero() { + let mut t = StalledPattern::new(); + t.update(c(10.0, 12.05, 9.9, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + // bar3 is black. + assert_eq!(t.update(c(14.2, 14.6, 13.95, 14.05, 2)), Some(0.0)); + } + + #[test] + fn non_rising_closes_yield_zero() { + let mut t = StalledPattern::new(); + t.update(c(10.0, 12.05, 9.9, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + // bar3 closes below bar2's close (white but not advancing). + assert_eq!(t.update(c(13.5, 14.0, 13.45, 13.6, 2)), Some(0.0)); + } + + #[test] + fn short_first_bodies_yield_zero() { + let mut t = StalledPattern::new(); + // bar1 is white but its body is short relative to range. + t.update(c(11.5, 14.0, 10.0, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + assert_eq!(t.update(c(14.0, 14.6, 13.95, 14.15, 2)), Some(0.0)); + } + + #[test] + fn large_third_body_yields_zero() { + let mut t = StalledPattern::new(); + t.update(c(10.0, 12.05, 9.9, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + // bar3 has a large body (not a small stalling candle). + assert_eq!(t.update(c(14.0, 16.05, 13.95, 16.0, 2)), Some(0.0)); + } + + #[test] + fn third_bar_off_shoulder_yields_zero() { + let mut t = StalledPattern::new(); + t.update(c(10.0, 12.05, 9.9, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + // bar3 is a small white candle but opens well below bar2's close, + // so it is not riding the shoulder. + assert_eq!(t.update(c(13.6, 14.1, 12.55, 14.05, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 2.05, base - 0.1, base + 2.0, i) + }) + .collect(); + let mut a = StalledPattern::new(); + let mut b = StalledPattern::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = StalledPattern::new(); + t.update(c(10.0, 12.05, 9.9, 12.0, 0)); + t.update(c(11.0, 14.05, 10.9, 14.0, 1)); + t.update(c(14.0, 14.6, 13.95, 14.15, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/standard_error.rs b/crates/wickra-core/src/indicators/standard_error.rs new file mode 100644 index 0000000..a3c5de9 --- /dev/null +++ b/crates/wickra-core/src/indicators/standard_error.rs @@ -0,0 +1,247 @@ +//! Standard Error of the rolling least-squares regression. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Standard Error of the regression line fit over the last `period` inputs. +/// +/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line +/// `y = a + b·x` is fitted, then: +/// +/// ```text +/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// SS_total = Σy² − n·ȳ² // total sum of squares +/// RSS = SS_total − slope² · S_xx // residual sum of squares +/// StdErr = √( RSS / (n − 2) ) // n − 2 residual d.o.f. +/// ``` +/// +/// where `S_xx = (n·Σxx − (Σx)²) / n` is the centred sum of squares of the +/// design. +/// +/// This is the textbook **standard error of estimate** of OLS: it measures +/// the typical distance between the observed prices and the fitted line, +/// using the residual degrees of freedom `n − 2`. It is the spread that +/// drives [`crate::BollingerBands`]-style bands around a regression instead of +/// around an SMA — when the price hugs its trend, `StdErr` is small. +/// +/// Each `update` is O(1): the `Σx` and `Σxx` terms depend only on `period` +/// and are precomputed once, while `Σy`, `Σxy`, and `Σy²` are maintained +/// incrementally as the window slides. Tiny floating-point cancellation +/// noise that could drive the residual sum of squares slightly negative is +/// clamped to zero before the square root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StandardError}; +/// +/// let mut indicator = StandardError::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StandardError { + period: usize, + window: VecDeque, + sum_x: f64, + /// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`. + denom: f64, + sum_y: f64, + sum_xy: f64, + sum_y_sq: f64, +} + +impl StandardError { + /// Construct a new rolling standard error of regression. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` — the residual + /// degrees of freedom `n − 2` would be non-positive. + pub fn new(period: usize) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "standard error needs period >= 3", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + sum_y_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for StandardError { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + // Slide: pop oldest, shift indices, then push the new value at index n − 1. + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + self.sum_y_sq -= y0 * y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + self.sum_y_sq += value * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let mean_y = self.sum_y / n; + let ss_total = self.sum_y_sq - n * mean_y * mean_y; + // S_xx = denom / n + let s_xx = self.denom / n; + let rss = (ss_total - slope * slope * s_xx).max(0.0); + Some((rss / (n - 2.0)).sqrt()) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + self.sum_y_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 { + "StandardError" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(StandardError::new(0).is_err()); + assert!(StandardError::new(2).is_err()); + assert!(StandardError::new(3).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let se = StandardError::new(14).unwrap(); + assert_eq!(se.period(), 14); + assert_eq!(se.warmup_period(), 14); + assert_eq!(se.name(), "StandardError"); + } + + #[test] + fn perfect_line_has_zero_error() { + // Residuals from a perfectly linear fit are zero, so SE = 0. + let prices: Vec = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut se = StandardError::new(10).unwrap(); + for v in se.batch(&prices).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut se = StandardError::new(5).unwrap(); + for v in se.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn matches_naive_definition() { + // Compare the O(1) update against a fresh-from-scratch OLS refit each bar. + fn naive(window: &[f64]) -> f64 { + let n = window.len() as f64; + let mean_y = window.iter().sum::() / n; + let mut sum_xy = 0.0; + let mut sum_x = 0.0; + let mut sum_xx = 0.0; + for (i, &y) in window.iter().enumerate() { + let x = i as f64; + sum_xy += x * y; + sum_x += x; + sum_xx += x * x; + } + let mean_x = sum_x / n; + let s_xx = sum_xx - n * mean_x * mean_x; + let slope = (sum_xy - n * mean_x * mean_y) / s_xx; + let intercept = mean_y - slope * mean_x; + let rss: f64 = window + .iter() + .enumerate() + .map(|(i, &y)| { + let r = y - (intercept + slope * i as f64); + r * r + }) + .sum(); + (rss / (n - 2.0)).sqrt() + } + + let prices: Vec = (0..60) + .map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0) + .collect(); + let period = 14; + let got = StandardError::new(period).unwrap().batch(&prices); + for (i, g) in got.iter().enumerate() { + if let Some(v) = g { + let expected = naive(&prices[i + 1 - period..=i]); + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + } + + #[test] + fn reset_clears_state() { + let mut se = StandardError::new(5).unwrap(); + se.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(se.is_ready()); + se.reset(); + assert!(!se.is_ready()); + assert_eq!(se.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0) + .collect(); + let batch = StandardError::new(14).unwrap().batch(&prices); + let mut b = StandardError::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/standard_error_bands.rs b/crates/wickra-core/src/indicators/standard_error_bands.rs new file mode 100644 index 0000000..51a66af --- /dev/null +++ b/crates/wickra-core/src/indicators/standard_error_bands.rs @@ -0,0 +1,281 @@ +//! Standard Error Bands. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Standard Error Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StandardErrorBandsOutput { + /// Upper band: regression endpoint plus `multiplier · standard_error`. + pub upper: f64, + /// Middle line: OLS endpoint over the window. + pub middle: f64, + /// Lower band: regression endpoint minus `multiplier · standard_error`. + pub lower: f64, +} + +/// Standard Error Bands: linear-regression line wrapped by the standard error +/// of the fit. +/// +/// ```text +/// fit y = a + b·x by OLS over the last `period` closes +/// residual_i = y_i − (a + b · x_i) +/// stderr = sqrt( Σ residual_i² / (period − 2) ) // OLS standard error +/// middle = a + b · (period − 1) +/// upper = middle + multiplier · stderr +/// lower = middle − multiplier · stderr +/// ``` +/// +/// Standard Error Bands and [`LinRegChannel`](crate::LinRegChannel) both wrap +/// an OLS endpoint, but use *different denominators* for the dispersion +/// statistic: +/// +/// - The `LinReg` Channel uses the population standard deviation of the +/// residuals (denominator `n`). +/// - Standard Error Bands use the OLS standard error (denominator `n − 2`, +/// one degree of freedom for the slope and one for the intercept). +/// +/// The `n − 2` divisor produces a slightly wider channel and is the +/// statistically-correct band-width when the regression is interpreted as a +/// prediction interval. Jon Andersen's original publication pairs the bands +/// with a default `multiplier = 2.0` and a 3-bar SMA smoothing of all three +/// outputs; this implementation reports the *raw* bands so callers can pipe +/// them through their own smoother (e.g. [`Sma::new(3)`](crate::Sma)). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StandardErrorBands}; +/// +/// let mut indicator = StandardErrorBands::new(21, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StandardErrorBands { + period: usize, + multiplier: f64, + window: VecDeque, + sum_x: f64, + sum_xx: f64, +} + +impl StandardErrorBands { + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 3` (the `n − 2` + /// denominator requires at least 3 points) and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(period: usize, multiplier: f64) -> Result { + if period < 3 { + return Err(Error::InvalidPeriod { + message: "standard error bands need period >= 3", + }); + } + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let n = period as f64; + Ok(Self { + period, + multiplier, + window: VecDeque::with_capacity(period), + sum_x: n * (n - 1.0) / 2.0, + sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for StandardErrorBands { + type Input = f64; + type Output = StandardErrorBandsOutput; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let x = i as f64; + sum_y += y; + sum_xy += x * y; + } + let denom = n * self.sum_xx - self.sum_x * self.sum_x; + let slope = (n * sum_xy - self.sum_x * sum_y) / denom; + let intercept = (sum_y - slope * self.sum_x) / n; + + let mut sse = 0.0; + for (i, &y) in self.window.iter().enumerate() { + let fitted = intercept + slope * (i as f64); + let r = y - fitted; + sse += r * r; + } + // OLS standard error with `n − 2` degrees of freedom. `n − 2` is at + // least 1 because the constructor enforces `period >= 3`. + let stderr = (sse / (n - 2.0)).sqrt(); + let middle = intercept + slope * (n - 1.0); + Some(StandardErrorBandsOutput { + upper: middle + self.multiplier * stderr, + middle, + lower: middle - self.multiplier * stderr, + }) + } + + 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 { + "StandardErrorBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_below_three() { + assert!(StandardErrorBands::new(0, 2.0).is_err()); + assert!(StandardErrorBands::new(1, 2.0).is_err()); + assert!(StandardErrorBands::new(2, 2.0).is_err()); + assert!(StandardErrorBands::new(3, 2.0).is_ok()); + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + StandardErrorBands::new(20, 0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + StandardErrorBands::new(20, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + StandardErrorBands::new(20, f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let seb = StandardErrorBands::new(21, 2.0).unwrap(); + assert_eq!(seb.period(), 21); + assert_relative_eq!(seb.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(seb.warmup_period(), 21); + assert_eq!(seb.name(), "StandardErrorBands"); + } + + #[test] + fn perfect_line_collapses_bands() { + let prices: Vec = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect(); + let mut seb = StandardErrorBands::new(10, 2.0).unwrap(); + for o in seb.batch(&prices).into_iter().flatten() { + assert_relative_eq!(o.upper, o.middle, epsilon = 1e-9); + assert_relative_eq!(o.middle, o.lower, epsilon = 1e-9); + } + } + + #[test] + fn upper_above_middle_above_lower() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut seb = StandardErrorBands::new(21, 2.0).unwrap(); + for o in seb.batch(&prices).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = StandardErrorBands::new(21, 2.0).unwrap(); + let mut b = StandardErrorBands::new(21, 2.0).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut seb = StandardErrorBands::new(5, 2.0).unwrap(); + seb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(seb.is_ready()); + seb.reset(); + assert!(!seb.is_ready()); + assert_eq!(seb.update(1.0), None); + } + + /// Reference: period 3 over `[1, 2, 9]`. Fitted line `y = 0 + 4·x`, + /// endpoint at `x = 2` is `8`. Residuals: 1, −2, 1. SSE = 6. + /// `n − 2 = 1`, so stderr = sqrt(6 / 1) = sqrt(6). With multiplier 2.0: + /// upper = 8 + 2·sqrt(6), lower = 8 − 2·sqrt(6). + #[test] + fn reference_values() { + let mut seb = StandardErrorBands::new(3, 2.0).unwrap(); + let v = seb.batch(&[1.0, 2.0, 9.0])[2].unwrap(); + let s = f64::sqrt(6.0); + assert_relative_eq!(v.middle, 8.0, epsilon = 1e-9); + assert_relative_eq!(v.upper, 8.0 + 2.0 * s, epsilon = 1e-9); + assert_relative_eq!(v.lower, 8.0 - 2.0 * s, epsilon = 1e-9); + } + + /// The n−2 standard error must be strictly larger than the population + /// stddev (n divisor) on the same residuals — by the factor sqrt(n / (n−2)). + #[test] + fn standard_error_exceeds_population_stddev() { + // Use n = 5 (factor = sqrt(5/3)) with non-trivial residuals. + let prices: Vec = vec![1.0, 5.0, 2.0, 8.0, 3.0]; + let mut seb = StandardErrorBands::new(5, 1.0).unwrap(); + let v = seb.batch(&prices)[4].unwrap(); + // The half-width of the band is `multiplier · stderr`, so: + let half = v.upper - v.middle; + assert!(half > 0.0); + // sigma² = SSE / 5, stderr² = SSE / 3, ratio of stderr to sigma = sqrt(5/3). + // Reproduce stderr from the half-width (multiplier = 1.0) and check + // it is sqrt(5/3) ≈ 1.291 times larger than sigma. + let factor = (5.0_f64 / 3.0).sqrt(); + // half / factor would equal the population stddev — we expect factor > 1. + assert!(half / factor < half, "n−2 stderr must exceed n stddev"); + } +} diff --git a/crates/wickra-core/src/indicators/starc_bands.rs b/crates/wickra-core/src/indicators/starc_bands.rs new file mode 100644 index 0000000..e985f01 --- /dev/null +++ b/crates/wickra-core/src/indicators/starc_bands.rs @@ -0,0 +1,229 @@ +//! STARC Bands (Stoller Average Range Channel). + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// STARC Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StarcBandsOutput { + /// Upper band: `middle + multiplier · ATR`. + pub upper: f64, + /// Middle band: SMA of close. + pub middle: f64, + /// Lower band: `middle − multiplier · ATR`. + pub lower: f64, +} + +/// STARC Bands (Stoller Average Range Channel): a close-SMA centerline with +/// bands sized by ATR. +/// +/// ```text +/// middle = SMA(close, sma_period) +/// upper = middle + multiplier · ATR(atr_period) +/// lower = middle − multiplier · ATR(atr_period) +/// ``` +/// +/// STARC and [`Keltner`](crate::Keltner) share the same skeleton — moving +/// average plus an ATR offset — but Keltner's centerline is an `EMA` of the +/// typical price while STARC uses an `SMA` of the close. The SMA gives a +/// flatter, less reactive midline that traders use to pick the larger swing +/// targets; Stoller's reference parameters are `SMA(6)` over the close with +/// `ATR(15)` and a multiplier of `2.0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, StarcBands}; +/// +/// let mut indicator = StarcBands::new(6, 15, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StarcBands { + sma: Sma, + atr: Atr, + multiplier: f64, + sma_period: usize, + atr_period: usize, +} + +impl StarcBands { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on + /// invalid inputs. + pub fn new(sma_period: usize, atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + sma: Sma::new(sma_period)?, + atr: Atr::new(atr_period)?, + multiplier, + sma_period, + atr_period, + }) + } + + /// Stoller's classic configuration: SMA(6), ATR(15), multiplier 2.0. + pub fn classic() -> Self { + Self::new(6, 15, 2.0).expect("classic STARC parameters are valid") + } + + /// Configured `(sma_period, atr_period, multiplier)`. + pub const fn parameters(&self) -> (usize, usize, f64) { + (self.sma_period, self.atr_period, self.multiplier) + } +} + +impl Indicator for StarcBands { + type Input = Candle; + type Output = StarcBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + // Feed both unconditionally so SMA and ATR warm up in parallel. + let mid = self.sma.update(candle.close); + let atr = self.atr.update(candle); + let (mid, atr) = (mid?, atr?); + Some(StarcBandsOutput { + upper: mid + self.multiplier * atr, + middle: mid, + lower: mid - self.multiplier * atr, + }) + } + + fn reset(&mut self) { + self.sma.reset(); + self.atr.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma_period.max(self.atr_period) + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() && self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "StarcBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_input() { + assert!(StarcBands::new(0, 14, 2.0).is_err()); + assert!(StarcBands::new(6, 0, 2.0).is_err()); + assert!(StarcBands::new(6, 14, 0.0).is_err()); + assert!(StarcBands::new(6, 14, -1.0).is_err()); + assert!(StarcBands::new(6, 14, f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = StarcBands::new(6, 15, 2.0).unwrap(); + let (sp, ap, m) = s.parameters(); + assert_eq!(sp, 6); + assert_eq!(ap, 15); + assert_relative_eq!(m, 2.0, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 15); + assert_eq!(s.name(), "StarcBands"); + } + + #[test] + fn flat_market_collapses_bands() { + let candles: Vec = (0..50).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut s = StarcBands::new(6, 15, 2.0).unwrap(); + let last = s.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.upper, last.middle, epsilon = 1e-9); + assert_relative_eq!(last.lower, last.middle, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut s = StarcBands::classic(); + for o in s.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut a = StarcBands::classic(); + let mut b = StarcBands::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut s = StarcBands::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + /// STARC must equal feeding independent SMA(close) and ATR siblings and + /// combining them. + #[test] + fn matches_independent_sma_and_atr() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.5, m - 1.5, m) + }) + .collect(); + let mut s = StarcBands::new(6, 15, 2.0).unwrap(); + let mut sma = Sma::new(6).unwrap(); + let mut atr = Atr::new(15).unwrap(); + for candle in &candles { + let got = s.update(*candle); + let mid = sma.update(candle.close); + let a = atr.update(*candle); + if let (Some(m), Some(av)) = (mid, a) { + let o = got.expect("STARC emits once both ready"); + assert_relative_eq!(o.middle, m, epsilon = 1e-9); + assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9); + assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9); + } else { + assert!(got.is_none()); + } + } + } +} diff --git a/crates/wickra-core/src/indicators/stc.rs b/crates/wickra-core/src/indicators/stc.rs new file mode 100644 index 0000000..8ad8657 --- /dev/null +++ b/crates/wickra-core/src/indicators/stc.rs @@ -0,0 +1,329 @@ +//! Schaff Trend Cycle (STC). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Doug Schaff's Trend Cycle — a doubly-`Stochastic`-smoothed MACD that +/// produces a bounded `[0, 100]` reading reacting faster than `MACD` itself. +/// +/// ```text +/// macd_t = EMA(close, fast)_t − EMA(close, slow)_t +/// %K_t = 100 · (macd − LL(macd, period)) / (HH(macd, period) − LL(macd, period)) +/// %D_t = %D_{t-1} + factor · (%K_t − %D_{t-1}) // half-EMA when factor = 0.5 +/// %K2_t = 100 · (%D − LL(%D, period)) / (HH(%D, period) − LL(%D, period)) +/// STC_t = STC_{t-1} + factor · (%K2_t − STC_{t-1}) +/// ``` +/// +/// Wickra uses `factor = 0.5` and Schaff's recommended defaults +/// `(fast = 23, slow = 50, period = 10)`. The stochastic stages clamp to `0` +/// when the window range collapses (perfectly flat input), and the smoothing +/// stages hold their previous value if the upstream stage is not yet ready — +/// so a flat input series settles deterministically at `0` after warmup. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Stc}; +/// +/// let mut stc = Stc::classic(); +/// let mut last = None; +/// for i in 0..200 { +/// last = stc.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Stc { + fast_period: usize, + slow_period: usize, + schaff_period: usize, + factor: f64, + fast_ema: Ema, + slow_ema: Ema, + macd_window: VecDeque, + d_window: VecDeque, + last_d: Option, + last_value: Option, +} + +impl Stc { + /// # Errors + /// - [`Error::PeriodZero`] if any period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow` or `factor` is not in `(0, 1]`. + pub fn new(fast: usize, slow: usize, schaff_period: usize, factor: f64) -> Result { + if fast == 0 || slow == 0 || schaff_period == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "STC fast period must be strictly less than slow", + }); + } + if !factor.is_finite() || factor <= 0.0 || factor > 1.0 { + return Err(Error::InvalidPeriod { + message: "STC factor must be a finite value in (0, 1]", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + schaff_period, + factor, + fast_ema: Ema::new(fast)?, + slow_ema: Ema::new(slow)?, + macd_window: VecDeque::with_capacity(schaff_period), + d_window: VecDeque::with_capacity(schaff_period), + last_d: None, + last_value: None, + }) + } + + /// Schaff's recommended defaults `(fast = 23, slow = 50, period = 10, factor = 0.5)`. + pub fn classic() -> Self { + Self::new(23, 50, 10, 0.5).expect("classic STC parameters are valid") + } + + /// Configured `(fast, slow, schaff_period, factor)`. + pub const fn params(&self) -> (usize, usize, usize, f64) { + ( + self.fast_period, + self.slow_period, + self.schaff_period, + self.factor, + ) + } +} + +fn rolling_minmax(window: &VecDeque) -> (f64, f64) { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for &v in window { + if v < lo { + lo = v; + } + if v > hi { + hi = v; + } + } + (lo, hi) +} + +impl Indicator for Stc { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let f = self.fast_ema.update(input); + let s = self.slow_ema.update(input); + let (f, s) = (f?, s?); + let macd = f - s; + + if self.macd_window.len() == self.schaff_period { + self.macd_window.pop_front(); + } + self.macd_window.push_back(macd); + if self.macd_window.len() < self.schaff_period { + return None; + } + + let (lo, hi) = rolling_minmax(&self.macd_window); + let k = if hi > lo { + 100.0 * (macd - lo) / (hi - lo) + } else { + 0.0 + }; + + let d = match self.last_d { + Some(prev) => prev + self.factor * (k - prev), + None => k, + }; + self.last_d = Some(d); + + if self.d_window.len() == self.schaff_period { + self.d_window.pop_front(); + } + self.d_window.push_back(d); + if self.d_window.len() < self.schaff_period { + return None; + } + + let (lo_d, hi_d) = rolling_minmax(&self.d_window); + let k2 = if hi_d > lo_d { + 100.0 * (d - lo_d) / (hi_d - lo_d) + } else { + 0.0 + }; + + let stc = match self.last_value { + Some(prev) => prev + self.factor * (k2 - prev), + None => k2, + }; + self.last_value = Some(stc); + Some(stc.clamp(0.0, 100.0)) + } + + fn reset(&mut self) { + self.fast_ema.reset(); + self.slow_ema.reset(); + self.macd_window.clear(); + self.d_window.clear(); + self.last_d = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // Slow EMA emits at `slow` inputs. Then the macd-window needs + // `schaff_period − 1` more inputs to fill, and the d-window another + // `schaff_period − 1` after that. + self.slow_period + 2 * (self.schaff_period - 1) + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() && self.d_window.len() == self.schaff_period + } + + fn name(&self) -> &'static str { + "STC" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Stc::new(0, 50, 10, 0.5), Err(Error::PeriodZero))); + assert!(matches!(Stc::new(23, 0, 10, 0.5), Err(Error::PeriodZero))); + assert!(matches!(Stc::new(23, 50, 0, 0.5), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + Stc::new(50, 23, 10, 0.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Stc::new(23, 50, 10, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Stc::new(23, 50, 10, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Stc::new(23, 50, 10, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let stc = Stc::classic(); + let (f, s, p, k) = stc.params(); + assert_eq!((f, s, p), (23, 50, 10)); + assert!((k - 0.5).abs() < 1e-12); + assert_eq!(stc.warmup_period(), 50 + 18); + assert_eq!(stc.name(), "STC"); + } + + #[test] + fn classic_factory() { + let (f, s, p, k) = Stc::classic().params(); + assert_eq!((f, s, p), (23, 50, 10)); + assert!((k - 0.5).abs() < 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + // Flat input -> macd is 0 every bar -> stochastic-on-flat-window + // returns 0 -> d stays at 0 -> %K2 returns 0 -> STC stays at 0. + let mut stc = Stc::new(3, 5, 4, 0.5).unwrap(); + let out = stc.batch(&[42.0_f64; 80]); + for v in out.iter().rev().take(5).flatten() { + assert_eq!(*v, 0.0); + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut stc = Stc::new(2, 4, 3, 0.5).unwrap(); + // slow(4) + 2*(3-1) = 8. + assert_eq!(stc.warmup_period(), 8); + let prices: Vec = (1..=10).map(f64::from).collect(); + let out = stc.batch(&prices); + for v in out.iter().take(7) { + assert!(v.is_none()); + } + assert!(out[7].is_some()); + } + + #[test] + fn output_is_bounded() { + let mut stc = Stc::classic(); + let prices: Vec = (0..400) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 25.0) + .collect(); + for v in stc.batch(&prices).iter().flatten() { + assert!((0.0..=100.0).contains(v), "STC out of [0, 100]: {v}"); + } + } + + #[test] + fn oscillating_series_visits_full_range() { + // STC needs a non-degenerate MACD range to exercise the two + // stochastic stages. A purely monotone series collapses the rolling + // window (constant MACD) and a purely flat one collapses both + // stages — in either case both inner ranges become zero and STC + // sticks at 0. A sinusoidal trend with enough amplitude makes the + // stages cycle through the full [0, 100] band. + let mut stc = Stc::classic(); + let prices: Vec = (0..400) + .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 30.0) + .collect(); + let out = stc.batch(&prices); + let mut saw_high = false; + let mut saw_low = false; + for v in out.iter().flatten() { + if *v > 80.0 { + saw_high = true; + } + if *v < 20.0 { + saw_low = true; + } + } + assert!( + saw_high, + "STC should reach above 80 on a strong oscillation" + ); + assert!(saw_low, "STC should reach below 20 on a strong oscillation"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Stc::classic(); + let mut b = Stc::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut stc = Stc::classic(); + stc.batch(&(1..=200).map(f64::from).collect::>()); + assert!(stc.is_ready()); + stc.reset(); + assert!(!stc.is_ready()); + assert!(stc.last_value.is_none()); + } +} diff --git a/crates/wickra-core/src/indicators/std_dev.rs b/crates/wickra-core/src/indicators/std_dev.rs new file mode 100644 index 0000000..ba1fdd5 --- /dev/null +++ b/crates/wickra-core/src/indicators/std_dev.rs @@ -0,0 +1,216 @@ +//! Rolling population standard deviation. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling population standard deviation over the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ price +/// variance = (1/n) · Σ price² − mean² +/// StdDev = √variance +/// ``` +/// +/// This is the **population** standard deviation (divisor `n`, not `n − 1`) — +/// the same dispersion measure that drives [`BollingerBands`](crate::BollingerBands). +/// It is maintained as an O(1) rolling state machine: a running sum and a +/// running sum-of-squares, updated by one add and one subtract per bar. Tiny +/// negative variances from floating-point cancellation are clamped to zero +/// before the square root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StdDev}; +/// +/// let mut indicator = StdDev::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StdDev { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, + last: Option, +} + +impl StdDev { + /// Construct a new rolling standard deviation with the given period. + /// + /// # 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: 0.0, + sum_sq: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for StdDev { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the window is left untouched. + return self.last; + } + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(input); + self.sum += input; + self.sum_sq += input * input; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Clamp floating-point cancellation noise: variance is never negative. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let sd = variance.sqrt(); + self.last = Some(sd); + Some(sd) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "StdDev" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(StdDev::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` and the Indicator-impl + /// `warmup_period` / `name` methods (lines 64-71, 110-112, 118-120). + /// Existing tests only inspect numeric outputs of `update` / `batch`. + #[test] + fn accessors_and_metadata() { + let mut sd = StdDev::new(14).unwrap(); + assert_eq!(sd.period(), 14); + assert_eq!(sd.warmup_period(), 14); + assert_eq!(sd.name(), "StdDev"); + assert_eq!(sd.value(), None); + for i in 1..=14 { + sd.update(f64::from(i)); + } + assert!(sd.value().is_some()); + } + + #[test] + fn reference_value() { + // StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3. + let mut sd = StdDev::new(3).unwrap(); + let out = sd.batch(&[2.0, 4.0, 6.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), (8.0_f64 / 3.0).sqrt(), epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut sd = StdDev::new(5).unwrap(); + let out = sd.batch(&[42.0; 20]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn matches_naive_definition() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0) + .collect(); + let period = 10; + let got = StdDev::new(period).unwrap().batch(&prices); + for (i, g) in got.iter().enumerate() { + if let Some(value) = g { + let window = &prices[i + 1 - period..=i]; + let mean = window.iter().sum::() / period as f64; + let var = window.iter().map(|x| (x - mean).powi(2)).sum::() / period as f64; + assert_relative_eq!(*value, var.sqrt(), epsilon = 1e-9); + } + } + } + + #[test] + fn ignores_non_finite_input() { + let mut sd = StdDev::new(3).unwrap(); + let out = sd.batch(&[2.0, 4.0, 6.0]); + let last = out[2]; + assert!(last.is_some()); + assert_eq!(sd.update(f64::NAN), last); + assert_eq!(sd.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut sd = StdDev::new(3).unwrap(); + sd.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(sd.is_ready()); + sd.reset(); + assert!(!sd.is_ready()); + assert_eq!(sd.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0) + .collect(); + let batch = StdDev::new(14).unwrap().batch(&prices); + let mut b = StdDev::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/step_trailing_stop.rs b/crates/wickra-core/src/indicators/step_trailing_stop.rs new file mode 100644 index 0000000..a0759e5 --- /dev/null +++ b/crates/wickra-core/src/indicators/step_trailing_stop.rs @@ -0,0 +1,228 @@ +//! Step Trailing Stop. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Step Trailing Stop — a stop that ratchets in fixed-size discrete steps and +/// flips to the opposite side on a close-through. +/// +/// ```text +/// long: target = close − step_size +/// stop_t = max(stop_{t−1}, floor(target / step_size) · step_size) +/// while close ≥ stop_{t−1} +/// short: target = close + step_size +/// stop_t = min(stop_{t−1}, ceil(target / step_size) · step_size) +/// while close ≤ stop_{t−1} +/// flip-to-long on close > prev short-stop -> stop = floor((close − step) / step) · step +/// flip-to-short on close < prev long-stop -> stop = ceil((close + step) / step) · step +/// ``` +/// +/// Quantising the stop to a multiple of `step_size` keeps the level on a +/// round-number grid, which mirrors how many discretionary traders move stops +/// by hand (in $0.50, $1, or 10-pip increments). The first input seeds a long +/// stop one step below the snapped close. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StepTrailingStop}; +/// +/// let mut indicator = StepTrailingStop::new(1.0).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StepTrailingStop { + step_size: f64, + prev_stop: Option, + long: bool, +} + +impl StepTrailingStop { + /// Construct a Step Trailing Stop with an explicit step size. + /// + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `step_size` is not strictly + /// positive and finite. + pub fn new(step_size: f64) -> Result { + if !step_size.is_finite() || step_size <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + step_size, + prev_stop: None, + long: true, + }) + } + + /// A common configuration: a `1.0` step size. + pub fn classic() -> Self { + Self::new(1.0).expect("classic step is valid") + } + + /// Configured step size. + pub const fn step_size(&self) -> f64 { + self.step_size + } + + /// Snap `value` down to the nearest `step_size`-grid line below it. + fn snap_long(&self, close: f64) -> f64 { + ((close - self.step_size) / self.step_size).floor() * self.step_size + } + + /// Snap `value` up to the nearest `step_size`-grid line above it. + fn snap_short(&self, close: f64) -> f64 { + ((close + self.step_size) / self.step_size).ceil() * self.step_size + } +} + +impl Indicator for StepTrailingStop { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + if !close.is_finite() { + return None; + } + let stop = match self.prev_stop { + Some(prev) => { + if self.long { + if close < prev { + self.long = false; + self.snap_short(close) + } else { + prev.max(self.snap_long(close)) + } + } else if close > prev { + self.long = true; + self.snap_long(close) + } else { + prev.min(self.snap_short(close)) + } + } + None => self.snap_long(close), + }; + self.prev_stop = Some(stop); + Some(stop) + } + + fn reset(&mut self) { + self.prev_stop = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev_stop.is_some() + } + + fn name(&self) -> &'static str { + "StepTrailingStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_step() { + assert!(StepTrailingStop::new(0.0).is_err()); + assert!(StepTrailingStop::new(-1.0).is_err()); + assert!(StepTrailingStop::new(f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = StepTrailingStop::classic(); + assert_relative_eq!(s.step_size(), 1.0, epsilon = 1e-12); + assert_eq!(s.name(), "StepTrailingStop"); + assert_eq!(s.warmup_period(), 1); + } + + #[test] + fn first_value_snaps_below_price() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + // floor((100.4 - 1) / 1) · 1 = 99. + assert_relative_eq!(s.update(100.4).unwrap(), 99.0, epsilon = 1e-12); + } + + #[test] + fn long_stop_ratchets_in_discrete_steps() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + let out: Vec = [100.0, 100.5, 101.0, 102.0, 103.5] + .iter() + .map(|&p| s.update(p).unwrap()) + .collect(); + // 100 -> 99, 100.5 -> 99 (no advance), 101 -> 100, 102 -> 101, 103.5 -> 102. + assert_relative_eq!(out[0], 99.0, epsilon = 1e-9); + assert_relative_eq!(out[1], 99.0, epsilon = 1e-9); + assert_relative_eq!(out[2], 100.0, epsilon = 1e-9); + assert_relative_eq!(out[3], 101.0, epsilon = 1e-9); + assert_relative_eq!(out[4], 102.0, epsilon = 1e-9); + } + + #[test] + fn flips_to_short_on_close_through_and_back() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + s.update(100.0); // 99 + s.update(105.0); // 104 + let flipped = s.update(50.0).unwrap(); + // ceil((50+1)/1)·1 = 51. + assert_relative_eq!(flipped, 51.0, epsilon = 1e-9); + // Rally back through 51 -> flip long at 99. + let back = s.update(100.0).unwrap(); + assert_relative_eq!(back, 99.0, epsilon = 1e-9); + } + + #[test] + fn short_stop_ratchets_down() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); // short at 51 + let v = s.update(40.0).unwrap(); + // ceil((40+1)/1)·1 = 41 -> min(51, 41) = 41. + assert_relative_eq!(v, 41.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_holds_stop() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + let out = s.batch(&[100.0; 30]); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 99.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut s = StepTrailingStop::new(1.0).unwrap(); + s.update(100.0); + s.update(50.0); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_relative_eq!(s.update(200.0).unwrap(), 199.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let mut a = StepTrailingStop::classic(); + let mut b = StepTrailingStop::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} 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 0000000..b074e39 --- /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/stick_sandwich.rs b/crates/wickra-core/src/indicators/stick_sandwich.rs new file mode 100644 index 0000000..9fdd0a0 --- /dev/null +++ b/crates/wickra-core/src/indicators/stick_sandwich.rs @@ -0,0 +1,213 @@ +//! Stick Sandwich candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Stick Sandwich — a 3-bar bullish reversal. A black candle is followed by a +/// white candle that trades entirely above the first close, then a second black +/// candle drives price back down to close at the same level as the first. The +/// matching closes "sandwich" the white candle and mark a support floor. +/// +/// ```text +/// bar1 black, bar2 white, bar3 black +/// bar2 trades above bar1's close: low2 > close1 +/// matching closes: |close3 − close1| <= 0.1 * (high1 − low1) +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Stick Sandwich +/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The +/// first two bars always return `0.0` because the three-bar window is not yet +/// filled. The matching-close tolerance follows the geometric house style (a fixed +/// fraction of the first bar's range) rather than TA-Lib's rolling averages. +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, StickSandwich}; +/// +/// let mut indicator = StickSandwich::new(); +/// indicator.update(Candle::new(12.0, 12.1, 9.9, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(10.5, 11.6, 10.4, 11.5, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.5, 11.6, 9.9, 10.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct StickSandwich { + c1: Option, + c2: Option, + has_emitted: bool, +} + +impl StickSandwich { + /// Construct a new Stick Sandwich detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + has_emitted: false, + } + } +} + +impl Indicator for StickSandwich { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + self.c1 = self.c2; + self.c2 = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + // bar1 black, bar2 white, bar3 black. + if bar1.close >= bar1.open || bar2.close <= bar2.open || candle.close >= candle.open { + return Some(0.0); + } + // The white candle trades entirely above the first close. + if bar2.low <= bar1.close { + return Some(0.0); + } + // The two black candles close at the same level (the sandwich). + let range1 = bar1.high - bar1.low; + if (candle.close - bar1.close).abs() <= 0.1 * range1 { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "StickSandwich" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = StickSandwich::new(); + assert_eq!(t.name(), "StickSandwich"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn stick_sandwich_is_plus_one() { + let mut t = StickSandwich::new(); + assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.6, 10.4, 11.5, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(1.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = StickSandwich::new(); + assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.6, 10.4, 11.5, 1)), Some(0.0)); + } + + #[test] + fn first_candle_not_black_yields_zero() { + let mut t = StickSandwich::new(); + // bar1 white. + t.update(c(9.9, 12.1, 9.8, 10.0, 0)); + t.update(c(10.5, 11.6, 10.4, 11.5, 1)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0)); + } + + #[test] + fn middle_candle_not_white_yields_zero() { + let mut t = StickSandwich::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + // bar2 black. + t.update(c(11.5, 11.6, 10.4, 10.5, 1)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0)); + } + + #[test] + fn third_candle_not_black_yields_zero() { + let mut t = StickSandwich::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + t.update(c(10.5, 11.6, 10.4, 11.5, 1)); + // bar3 white. + assert_eq!(t.update(c(9.9, 11.6, 9.8, 10.0, 2)), Some(0.0)); + } + + #[test] + fn middle_low_not_above_first_close_yields_zero() { + let mut t = StickSandwich::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + // bar2 white but dips below bar1's close. + t.update(c(10.5, 11.6, 9.0, 11.5, 1)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0)); + } + + #[test] + fn mismatched_closes_yield_zero() { + let mut t = StickSandwich::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + t.update(c(10.5, 11.6, 10.4, 11.5, 1)); + // bar3 black but closes well away from bar1's close. + assert_eq!(t.update(c(11.5, 11.6, 7.9, 8.0, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 2.0, base + 2.1, base - 0.1, base, i) + }) + .collect(); + let mut a = StickSandwich::new(); + let mut b = StickSandwich::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = StickSandwich::new(); + t.update(c(12.0, 12.1, 9.9, 10.0, 0)); + t.update(c(10.5, 11.6, 10.4, 11.5, 1)); + t.update(c(11.5, 11.6, 9.9, 10.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/stoch_rsi.rs b/crates/wickra-core/src/indicators/stoch_rsi.rs new file mode 100644 index 0000000..43e1d0e --- /dev/null +++ b/crates/wickra-core/src/indicators/stoch_rsi.rs @@ -0,0 +1,242 @@ +//! Stochastic RSI. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Rsi; + +/// Stochastic RSI — the Stochastic Oscillator formula applied to the RSI series +/// instead of to price. +/// +/// RSI itself rarely reaches its `[0, 100]` extremes, so it spends most of its +/// life bunched in the middle of the range. `StochRSI` re-scales it: it reports +/// where the *current* RSI sits within its own high/low range over the last +/// `stoch_period` bars, which makes overbought/oversold turns far easier to +/// see. +/// +/// ```text +/// StochRSI = 100 · (RSI − min(RSI, stoch_period)) / (max(RSI, …) − min(RSI, …)) +/// ``` +/// +/// The output is bounded in `[0, 100]`. A flat RSI window (zero range) is +/// reported as the neutral `50.0`, matching the [`Stochastic`](crate::Stochastic) +/// convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StochRsi}; +/// +/// let mut indicator = StochRsi::new(14, 14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin() * 10.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StochRsi { + rsi_period: usize, + stoch_period: usize, + rsi: Rsi, + /// Rolling window of the last `stoch_period` RSI values. + window: VecDeque, + last: Option, +} + +impl StochRsi { + /// Construct a new `StochRSI` with the RSI period and the stochastic lookback. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`. + pub fn new(rsi_period: usize, stoch_period: usize) -> Result { + if rsi_period == 0 || stoch_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + rsi_period, + stoch_period, + rsi: Rsi::new(rsi_period)?, + window: VecDeque::with_capacity(stoch_period), + last: None, + }) + } + + /// The `(rsi_period, stoch_period)` pair. + pub const fn periods(&self) -> (usize, usize) { + (self.rsi_period, self.stoch_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for StochRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.last; + } + let rsi_value = self.rsi.update(input)?; + + if self.window.len() == self.stoch_period { + self.window.pop_front(); + } + self.window.push_back(rsi_value); + if self.window.len() < self.stoch_period { + return None; + } + + let max = self + .window + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let min = self.window.iter().copied().fold(f64::INFINITY, f64::min); + let range = max - min; + let stoch = if range == 0.0 { + // Flat RSI window: report the neutral midpoint. + 50.0 + } else { + 100.0 * (rsi_value - min) / range + }; + self.last = Some(stoch); + Some(stoch) + } + + fn reset(&mut self) { + self.rsi.reset(); + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // RSI emits its first value at input `rsi_period + 1`; the stochastic + // window then needs `stoch_period` RSI values. + self.rsi_period + self.stoch_period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "StochRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(StochRsi::new(0, 14), Err(Error::PeriodZero))); + assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `periods` / `value` (69-76) and the + /// Indicator-impl `name` body (131-133). `warmup_period` is already + /// covered by `first_emission_at_warmup_period`. + #[test] + fn accessors_and_metadata() { + let mut sr = StochRsi::new(14, 14).unwrap(); + assert_eq!(sr.periods(), (14, 14)); + assert_eq!(sr.name(), "StochRSI"); + assert_eq!(sr.value(), None); + for i in 1..=sr.warmup_period() { + sr.update(100.0 + f64::from(u32::try_from(i).unwrap())); + } + assert!(sr.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut sr = StochRsi::new(5, 4).unwrap(); + assert_eq!(sr.warmup_period(), 9); + let prices: Vec = (1..=40) + .map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0) + .collect(); + let out = sr.batch(&prices); + for v in out.iter().take(8) { + assert!(v.is_none()); + } + assert!(out[8].is_some()); + } + + #[test] + fn flat_rsi_window_yields_50() { + // A constant price series gives a constant RSI (50.0), so the StochRSI + // window has zero range and reports the neutral midpoint. + let mut sr = StochRsi::new(5, 4).unwrap(); + let out = sr.batch(&[100.0; 40]); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_yields_50() { + // A pure uptrend pins RSI at 100, so its window is again flat. + let mut sr = StochRsi::new(5, 4).unwrap(); + let out = sr.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_within_0_100() { + let mut sr = StochRsi::new(14, 14).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 15.0 + (f64::from(i) * 0.07).cos() * 6.0) + .collect(); + for v in sr.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "StochRSI out of range: {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut sr = StochRsi::new(5, 4).unwrap(); + let prices: Vec = (1..=40) + .map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0) + .collect(); + let out = sr.batch(&prices); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(sr.update(f64::NAN), last); + assert_eq!(sr.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut sr = StochRsi::new(5, 4).unwrap(); + sr.batch(&(1..=40).map(f64::from).collect::>()); + assert!(sr.is_ready()); + sr.reset(); + assert!(!sr.is_ready()); + assert_eq!(sr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0) + .collect(); + let batch = StochRsi::new(14, 14).unwrap().batch(&prices); + let mut b = StochRsi::new(14, 14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/stochastic.rs b/crates/wickra-core/src/indicators/stochastic.rs new file mode 100644 index 0000000..7986595 --- /dev/null +++ b/crates/wickra-core/src/indicators/stochastic.rs @@ -0,0 +1,352 @@ +//! Stochastic Oscillator (%K and %D). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Stochastic Oscillator output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StochasticOutput { + /// Raw %K: `100 * (close - LL) / (HH - LL)` over the lookback. + pub k: f64, + /// %D: SMA of %K over the smoothing period. + pub d: f64, +} + +/// Fast Stochastic Oscillator. +/// +/// Maintains rolling highest-high and lowest-low over the lookback period via a +/// monotonic deque, giving O(1) amortized updates. %D is an SMA of the %K series. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Stochastic}; +/// +/// let mut indicator = Stochastic::new(5, 3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Stochastic { + k_period: usize, + d_period: usize, + candles: VecDeque, + // Monotonic deques over candle indices in the rolling window. + hh_idx: VecDeque, // indices of candidates for highest high (front = current max) + ll_idx: VecDeque, // indices of candidates for lowest low (front = current min) + // Absolute count of candles ever ingested. Used so monotonic-deque indices stay unique. + count: usize, + d_sma: Sma, + last_k: Option, +} + +impl Stochastic { + /// Construct a stochastic with %K lookback and %D smoothing periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is zero. + pub fn new(k_period: usize, d_period: usize) -> Result { + if k_period == 0 || d_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + k_period, + d_period, + candles: VecDeque::with_capacity(k_period), + hh_idx: VecDeque::with_capacity(k_period), + ll_idx: VecDeque::with_capacity(k_period), + count: 0, + d_sma: Sma::new(d_period)?, + last_k: None, + }) + } + + /// Classic fast stochastic: `%K = 14`, `%D = 3`. + pub fn classic() -> Self { + Self::new(14, 3).expect("classic stochastic periods are valid") + } + + /// Configured `(k_period, d_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.k_period, self.d_period) + } + + fn push_window(&mut self, candle: Candle) { + let idx = self.count; + self.count += 1; + // Drop deque entries that are outside the window. + let oldest_keep_idx = idx.saturating_sub(self.k_period - 1); + while let Some(&front) = self.hh_idx.front() { + if front < oldest_keep_idx { + self.hh_idx.pop_front(); + } else { + break; + } + } + while let Some(&front) = self.ll_idx.front() { + if front < oldest_keep_idx { + self.ll_idx.pop_front(); + } else { + break; + } + } + // Maintain monotonic-decreasing deque for highs. + while let Some(&back) = self.hh_idx.back() { + let back_off = back - idx.saturating_sub(self.candles.len()); + if self.candles[back_off].high <= candle.high { + self.hh_idx.pop_back(); + } else { + break; + } + } + self.hh_idx.push_back(idx); + // Maintain monotonic-increasing deque for lows. + while let Some(&back) = self.ll_idx.back() { + let back_off = back - idx.saturating_sub(self.candles.len()); + if self.candles[back_off].low >= candle.low { + self.ll_idx.pop_back(); + } else { + break; + } + } + self.ll_idx.push_back(idx); + + if self.candles.len() == self.k_period { + self.candles.pop_front(); + } + self.candles.push_back(candle); + } + + fn current_extremes(&self) -> (f64, f64) { + let base = self.count - self.candles.len(); + let hi = self.candles[self.hh_idx[0] - base].high; + let lo = self.candles[self.ll_idx[0] - base].low; + (hi, lo) + } +} + +impl Indicator for Stochastic { + type Input = Candle; + type Output = StochasticOutput; + + fn update(&mut self, candle: Candle) -> Option { + self.push_window(candle); + if self.candles.len() < self.k_period { + return None; + } + let (hh, ll) = self.current_extremes(); + let range = hh - ll; + let k = if range == 0.0 { + // Flat range; convention: 50 (neutral, like RSI on flat input). + 50.0 + } else { + 100.0 * (candle.close - ll) / range + }; + self.last_k = Some(k); + let d = self.d_sma.update(k)?; + Some(StochasticOutput { k, d }) + } + + fn reset(&mut self) { + self.candles.clear(); + self.hh_idx.clear(); + self.ll_idx.clear(); + self.count = 0; + self.d_sma.reset(); + self.last_k = None; + } + + fn warmup_period(&self) -> usize { + self.k_period + self.d_period - 1 + } + + fn is_ready(&self) -> bool { + self.d_sma.is_ready() + } + + fn name(&self) -> &'static str { + "Stochastic" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + /// Naive %K computation for cross-checks. + fn naive_k(candles: &[Candle], k_period: usize) -> Vec> { + candles + .iter() + .enumerate() + .map(|(i, _)| { + if i + 1 < k_period { + None + } else { + let w = &candles[i + 1 - k_period..=i]; + let hh = w.iter().map(|x| x.high).fold(f64::NEG_INFINITY, f64::max); + let ll = w.iter().map(|x| x.low).fold(f64::INFINITY, f64::min); + let range = hh - ll; + let cl = candles[i].close; + Some(if range == 0.0 { + 50.0 + } else { + 100.0 * (cl - ll) / range + }) + } + }) + .collect() + } + + #[test] + fn rejects_zero_periods() { + assert!(matches!(Stochastic::new(0, 3), Err(Error::PeriodZero))); + assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero))); + } + + /// Cover the `Stochastic::classic()` convenience constructor plus the + /// `periods()` const accessor and the Indicator-impl `warmup_period` + /// / `name` methods. Existing tests called `Stochastic::new(_, _)` + /// directly and never asked for the configured periods, warmup + /// length, or name. + #[test] + fn classic_periods_and_metadata() { + let s = Stochastic::classic(); + assert_eq!(s.periods(), (14, 3)); + // Warmup for the classic config: k_period + d_period - 1 = 14 + 3 - 1 = 16. + assert_eq!(s.warmup_period(), 16); + assert_eq!(s.name(), "Stochastic"); + } + + #[test] + fn close_at_high_yields_k_100() { + let candles = vec![ + c(10.0, 8.0, 9.0), + c(11.0, 9.0, 10.0), + c(12.0, 10.0, 12.0), // close == high == HH + ]; + let mut s = Stochastic::new(3, 1).unwrap(); + let out = s.batch(&candles); + assert_relative_eq!(out[2].unwrap().k, 100.0, epsilon = 1e-12); + } + + #[test] + fn close_at_low_yields_k_0() { + let candles = vec![ + c(10.0, 8.0, 9.0), + c(11.0, 9.0, 10.0), + c(12.0, 8.0, 8.0), // close == LL + ]; + let mut s = Stochastic::new(3, 1).unwrap(); + let out = s.batch(&candles); + assert_relative_eq!(out[2].unwrap().k, 0.0, epsilon = 1e-12); + } + + #[test] + fn flat_range_yields_k_50() { + let candles: Vec = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut s = Stochastic::new(14, 3).unwrap(); + for o in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(o.k, 50.0, epsilon = 1e-12); + assert_relative_eq!(o.d, 50.0, epsilon = 1e-12); + } + // Cross-check: the naive_k test helper must agree on the flat-range + // convention. The k_matches_naive test only feeds oscillating prices, + // so the helper's flat-range branch was never exercised. + let ks = naive_k(&candles, 14); + for k in ks.into_iter().skip(13) { + assert_relative_eq!(k.expect("ready after 14 inputs"), 50.0, epsilon = 1e-12); + } + } + + #[test] + fn k_matches_naive() { + let candles: Vec = (0..60) + .map(|i| { + let mid = 50.0 + (f64::from(i) * 0.4).sin() * 10.0; + c(mid + 2.0, mid - 2.0, mid + (f64::from(i) * 0.7).cos()) + }) + .collect(); + let mut s = Stochastic::new(14, 3).unwrap(); + let out = s.batch(&candles); + let naive = naive_k(&candles, 14); + for (i, got) in out.iter().enumerate() { + if let Some(o) = got { + let n = naive[i].expect("naive ready"); + assert_relative_eq!(o.k, n, epsilon = 1e-9); + } + } + } + + #[test] + fn d_is_sma_of_k() { + let candles: Vec = (0..60) + .map(|i| { + let mid = 50.0 + f64::from(i).sin() * 5.0; + c(mid + 1.5, mid - 1.5, mid) + }) + .collect(); + let mut s = Stochastic::new(14, 3).unwrap(); + let out = s.batch(&candles); + // The naive %K series gives us the ground-truth values that %D should average. + let naive_ks = naive_k(&candles, 14); + // The first emitted %D corresponds to the SMA of the first three valid %K values + // (i.e. those at indices 13, 14, 15). At that point %D becomes ready, and the + // first `Some(_)` output appears at index 15. + let first_emit_idx = out + .iter() + .position(Option::is_some) + .expect("d eventually emits"); + let first_d = out[first_emit_idx].unwrap().d; + let k_window = &naive_ks[first_emit_idx - 2..=first_emit_idx]; + let want = k_window + .iter() + .map(|v| v.expect("naive K ready inside window")) + .sum::() + / 3.0; + assert_relative_eq!(first_d, want, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| { + let mid = 100.0 + f64::from(i) * 0.5; + c(mid + 2.0, mid - 2.0, mid) + }) + .collect(); + let mut a = Stochastic::new(14, 3).unwrap(); + let mut b = Stochastic::new(14, 3).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut s = Stochastic::new(5, 3).unwrap(); + let candles: Vec = (0..10).map(|i| c(10.0 + f64::from(i), 5.0, 7.0)).collect(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/stochastic_cci.rs b/crates/wickra-core/src/indicators/stochastic_cci.rs new file mode 100644 index 0000000..d574f9b --- /dev/null +++ b/crates/wickra-core/src/indicators/stochastic_cci.rs @@ -0,0 +1,231 @@ +//! Stochastic CCI — a stochastic oscillator applied to the CCI. + +use std::collections::VecDeque; + +use crate::error::Result; +use crate::indicators::cci::Cci; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Stochastic CCI — the stochastic oscillator computed over the +/// [`Cci`](crate::Cci) instead of price. +/// +/// The CCI is unbounded and spends most of its time inside `±100`, which makes +/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI +/// re-scales it to `[0, 100]` relative to its own recent range, turning it into +/// a bounded, self-normalising momentum oscillator: +/// +/// ```text +/// cci = CCI(typical price, period) +/// %K = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period)) +/// ``` +/// +/// The same `period` is used for the CCI and the stochastic lookback. When the +/// CCI range over the window is zero (a flat market, where the CCI is pinned at +/// `0`) the oscillator returns the neutral `50`. The first value lands after +/// `2·period − 1` bars: `period` to seed the CCI, then `period` CCI values to +/// fill the stochastic window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, StochasticCci, Indicator}; +/// +/// let mut sc = StochasticCci::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap(); +/// last = sc.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StochasticCci { + period: usize, + cci: Cci, + /// The last `period` CCI values. + window: VecDeque, +} + +impl StochasticCci { + /// Construct a Stochastic CCI with the given period (shared by the CCI and + /// the stochastic lookback). + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + cci: Cci::new(period)?, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for StochasticCci { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let cci = self.cci.update(candle)?; + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(cci); + if self.window.len() < self.period { + return None; + } + let mut lo = f64::MAX; + let mut hi = f64::MIN; + for &v in &self.window { + if v < lo { + lo = v; + } + if v > hi { + hi = v; + } + } + let range = hi - lo; + if range == 0.0 { + return Some(50.0); + } + // Ratio first, then scale: `100 * x / x` can round to 100.0000…1. + Some(100.0 * ((cci - lo) / range)) + } + + fn reset(&mut self) { + self.cci.reset(); + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + // CCI seeds at `period`, then `period` CCI values fill the stochastic window. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "StochasticCCI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64) -> Candle { + Candle::new(close, high, low, close, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(StochasticCci::new(0).is_err()); + } + + /// Cover the const accessor `period` and the Indicator-impl `warmup_period` + /// + `name`. + #[test] + fn accessors_and_metadata() { + let sc = StochasticCci::new(14).unwrap(); + assert_eq!(sc.period(), 14); + assert_eq!(sc.warmup_period(), 27); + assert_eq!(sc.name(), "StochasticCCI"); + } + + #[test] + fn first_emission_matches_warmup_period() { + let bars: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0; + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut sc = StochasticCci::new(5).unwrap(); + let out = sc.batch(&bars); + let warmup = sc.warmup_period(); + assert_eq!(warmup, 9); + for (i, v) in out.iter().enumerate().take(warmup - 1) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn bounded_zero_to_hundred() { + let bars: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0; + candle(base + 2.0, base - 2.0, base) + }) + .collect(); + let mut sc = StochasticCci::new(9).unwrap(); + for v in sc.batch(&bars).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]"); + } + } + + #[test] + fn flat_market_is_neutral() { + // Constant candles -> CCI pinned at 0 -> zero range -> neutral 50. + let mut sc = StochasticCci::new(4).unwrap(); + let bars = vec![candle(10.0, 10.0, 10.0); 20]; + let last = sc.batch(&bars).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-12); + } + + #[test] + fn highest_cci_in_window_is_hundred() { + // When the latest CCI is the window maximum, %K must be 100. + // A long rise then makes the final CCI the highest in its window. + let mut bars: Vec = (0..20) + .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + // Strong final push so the last CCI tops its window. + bars.push(candle(100.0, 98.0, 100.0)); + let mut sc = StochasticCci::new(5).unwrap(); + let last = sc.batch(&bars).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut sc = StochasticCci::new(5).unwrap(); + sc.batch( + &(0..30) + .map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect::>(), + ); + assert!(sc.is_ready()); + sc.reset(); + assert!(!sc.is_ready()); + assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let bars: Vec = (0..60) + .map(|i| { + let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0; + candle(base + 1.5, base - 1.5, base) + }) + .collect(); + let mut a = StochasticCci::new(9).unwrap(); + let mut b = StochasticCci::new(9).unwrap(); + assert_eq!( + a.batch(&bars), + bars.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/super_smoother.rs b/crates/wickra-core/src/indicators/super_smoother.rs new file mode 100644 index 0000000..9e4071c --- /dev/null +++ b/crates/wickra-core/src/indicators/super_smoother.rs @@ -0,0 +1,217 @@ +//! Ehlers SuperSmoother filter. +#![allow(clippy::doc_markdown)] + +use std::f64::consts::PI; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ehlers' 2-pole Butterworth-style "SuperSmoother" lowpass filter. +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 3). For a given +/// critical period `period`, the filter coefficients are: +/// +/// ```text +/// a1 = exp(-sqrt(2) * pi / period) +/// b1 = 2 * a1 * cos(sqrt(2) * pi / period) +/// c2 = b1 +/// c3 = -a1 * a1 +/// c1 = 1 - c2 - c3 +/// y[t] = c1 * (x[t] + x[t-1]) / 2 + c2 * y[t-1] + c3 * y[t-2] +/// ``` +/// +/// The implementation needs two prior inputs and two prior outputs to begin +/// running; until then it returns the input itself (a common Ehlers initial +/// condition), which lets downstream filters warm up without long delays. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, SuperSmoother}; +/// +/// let mut ss = SuperSmoother::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = ss.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SuperSmoother { + period: usize, + c1: f64, + c2: f64, + c3: f64, + prev_input: Option, + prev_output_1: Option, + prev_output_2: Option, + count: usize, +} + +impl SuperSmoother { + /// Construct a new SuperSmoother with the given critical period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let arg = std::f64::consts::SQRT_2 * PI / period as f64; + let a1 = (-arg).exp(); + let b1 = 2.0 * a1 * arg.cos(); + let c2 = b1; + let c3 = -a1 * a1; + let c1 = 1.0 - c2 - c3; + Ok(Self { + period, + c1, + c2, + c3, + prev_input: None, + prev_output_1: None, + prev_output_2: None, + count: 0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Filter coefficients `(c1, c2, c3)`. + pub const fn coefficients(&self) -> (f64, f64, f64) { + (self.c1, self.c2, self.c3) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.prev_output_1 + } +} + +impl Indicator for SuperSmoother { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.prev_output_1; + } + self.count += 1; + let output = match (self.prev_input, self.prev_output_1, self.prev_output_2) { + (Some(p_in), Some(y1), Some(y2)) => { + let avg = 0.5 * (input + p_in); + self.c1 * avg + self.c2 * y1 + self.c3 * y2 + } + _ => input, + }; + self.prev_output_2 = self.prev_output_1; + self.prev_output_1 = Some(output); + self.prev_input = Some(input); + Some(output) + } + + fn reset(&mut self) { + self.prev_input = None; + self.prev_output_1 = None; + self.prev_output_2 = None; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev_output_1.is_some() + } + + fn name(&self) -> &'static str { + "SuperSmoother" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(SuperSmoother::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut ss = SuperSmoother::new(10).unwrap(); + assert_eq!(ss.period(), 10); + assert_eq!(ss.name(), "SuperSmoother"); + assert_eq!(ss.warmup_period(), 1); + let (c1, c2, c3) = ss.coefficients(); + // Coefficients sum to 1 by construction (steady-state gain == 1). + assert_relative_eq!(c1 + c2 + c3, 1.0, epsilon = 1e-12); + assert!(ss.value().is_none()); + ss.update(42.0); + assert!(ss.value().is_some()); + assert!(ss.is_ready()); + } + + #[test] + fn first_output_equals_input_then_filters() { + let mut ss = SuperSmoother::new(10).unwrap(); + // Initial condition: first two outputs equal their inputs. + assert_eq!(ss.update(100.0), Some(100.0)); + assert_eq!(ss.update(101.0), Some(101.0)); + let third = ss.update(102.0).unwrap(); + // From step 3 onward, the recursive filter activates and the result + // is no longer the raw input. + assert!((third - 102.0).abs() < 5.0); + } + + #[test] + fn constant_series_converges_to_constant() { + // Steady-state gain is 1 (c1 + c2 + c3 = 1), so a flat input yields a + // flat output after warmup. + let mut ss = SuperSmoother::new(20).unwrap(); + let out = ss.batch(&[50.0_f64; 200]); + for x in out.iter().skip(50).flatten() { + assert_relative_eq!(*x, 50.0, epsilon = 1e-9); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = SuperSmoother::new(15).unwrap(); + let mut b = SuperSmoother::new(15).unwrap(); + let batch = a.batch(&prices); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn ignores_non_finite_input() { + let mut ss = SuperSmoother::new(10).unwrap(); + ss.batch(&(1..=20).map(f64::from).collect::>()); + let before = ss.value(); + assert!(before.is_some()); + assert_eq!(ss.update(f64::NAN), before); + assert_eq!(ss.update(f64::INFINITY), before); + } + + #[test] + fn reset_clears_state() { + let mut ss = SuperSmoother::new(10).unwrap(); + ss.batch(&(1..=40).map(f64::from).collect::>()); + assert!(ss.is_ready()); + ss.reset(); + assert!(!ss.is_ready()); + assert_eq!(ss.update(50.0), Some(50.0)); + } +} diff --git a/crates/wickra-core/src/indicators/super_trend.rs b/crates/wickra-core/src/indicators/super_trend.rs new file mode 100644 index 0000000..5037424 --- /dev/null +++ b/crates/wickra-core/src/indicators/super_trend.rs @@ -0,0 +1,326 @@ +//! `SuperTrend`. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `SuperTrend` output: the trailing-stop level and the trend direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SuperTrendOutput { + /// The `SuperTrend` line — the active trailing-stop level for this bar. + pub value: f64, + /// Trend direction: `+1.0` in an uptrend (the line sits below price), + /// `-1.0` in a downtrend (the line sits above price). + pub direction: f64, +} + +/// Previous-bar state carried forward by the `SuperTrend` recurrence. +#[derive(Debug, Clone, Copy)] +struct PrevState { + final_upper: f64, + final_lower: f64, + close: f64, + direction: f64, +} + +/// `SuperTrend` — an ATR-banded trailing stop that flips sides on a close +/// through the band. +/// +/// ```text +/// hl2 = (high + low) / 2 +/// basic_upper = hl2 + multiplier · ATR +/// basic_lower = hl2 − multiplier · ATR +/// +/// final_upper = basic_upper if basic_upper < prev_final_upper or prev_close > prev_final_upper +/// else prev_final_upper +/// final_lower = basic_lower if basic_lower > prev_final_lower or prev_close < prev_final_lower +/// else prev_final_lower +/// +/// in a downtrend: stay down while close <= final_upper, else flip up +/// in an uptrend: stay up while close >= final_lower, else flip down +/// SuperTrend = final_lower in an uptrend, final_upper in a downtrend +/// ``` +/// +/// The final bands ratchet — the upper band only moves down (and the lower +/// band only moves up) until price closes through it, which flips the trend +/// and hands the role of trailing stop to the opposite band. The first +/// ATR-ready bar seeds the trend as up. Wilder's classic configuration is +/// `ATR(10)` with a `3.0` multiplier. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SuperTrend}; +/// +/// let mut indicator = SuperTrend::classic(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct SuperTrend { + atr: Atr, + multiplier: f64, + atr_period: usize, + prev: Option, +} + +impl SuperTrend { + /// Construct a `SuperTrend` with an explicit ATR period and band multiplier. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + multiplier, + atr_period, + prev: None, + }) + } + + /// Wilder's classic configuration: `ATR(10)` with a `3.0` multiplier. + pub fn classic() -> Self { + Self::new(10, 3.0).expect("classic SuperTrend params are valid") + } + + /// Configured `(atr_period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.atr_period, self.multiplier) + } +} + +impl Indicator for SuperTrend { + type Input = Candle; + type Output = SuperTrendOutput; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let hl2 = f64::midpoint(candle.high, candle.low); + let basic_upper = hl2 + self.multiplier * atr; + let basic_lower = hl2 - self.multiplier * atr; + + let (final_upper, final_lower, direction) = match self.prev { + None => { + // First ATR-ready bar: no prior bands, seed the trend as up. + (basic_upper, basic_lower, 1.0) + } + Some(p) => { + let final_upper = if basic_upper < p.final_upper || p.close > p.final_upper { + basic_upper + } else { + p.final_upper + }; + let final_lower = if basic_lower > p.final_lower || p.close < p.final_lower { + basic_lower + } else { + p.final_lower + }; + let direction = if p.direction < 0.0 { + // Previous downtrend — the line was the upper band. + if candle.close <= final_upper { + -1.0 + } else { + 1.0 + } + } else { + // Previous uptrend — the line was the lower band. + if candle.close >= final_lower { + 1.0 + } else { + -1.0 + } + }; + (final_upper, final_lower, direction) + } + }; + + let value = if direction > 0.0 { + final_lower + } else { + final_upper + }; + self.prev = Some(PrevState { + final_upper, + final_lower, + close: candle.close, + direction, + }); + Some(SuperTrendOutput { value, direction }) + } + + fn reset(&mut self) { + self.atr.reset(); + self.prev = None; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.prev.is_some() + } + + fn name(&self) -> &'static str { + "SuperTrend" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn uptrend_keeps_line_below_price_and_direction_up() { + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + 2.0 * i as f64; + c(base + 1.0, base - 1.0, base + 0.5, i) + }) + .collect(); + let mut st = SuperTrend::classic(); + for (o, candle) in st.batch(&candles).into_iter().zip(candles.iter()) { + if let Some(o) = o { + assert_eq!(o.direction, 1.0, "a pure uptrend stays in direction +1"); + assert!(o.value < candle.close, "the stop line sits below price"); + } + } + } + + #[test] + fn downtrend_keeps_line_above_price_and_direction_down() { + let candles: Vec = (0..60) + .map(|i| { + let base = 220.0 - 2.0 * i as f64; + c(base + 1.0, base - 1.0, base - 0.5, i) + }) + .collect(); + let mut st = SuperTrend::classic(); + let emitted: Vec<(SuperTrendOutput, f64)> = st + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + // The seed bar starts the trend up; a steep decline flips it within a + // few bars. The settled tail must be a clean downtrend. + for &(o, close) in emitted.iter().skip(10) { + assert_eq!( + o.direction, -1.0, + "a steep downtrend settles to direction -1" + ); + assert!(o.value > close, "the stop line sits above price"); + } + } + + #[test] + fn trend_flips_when_price_reverses() { + let mut candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base + 0.5, i) + }) + .collect(); + candles.extend((0..40).map(|i| { + let base = 140.0 - i as f64; + c(base + 1.0, base - 1.0, base - 0.5, 40 + i) + })); + let mut st = SuperTrend::classic(); + let dirs: Vec = st + .batch(&candles) + .into_iter() + .flatten() + .map(|o| o.direction) + .collect(); + assert!(dirs.iter().any(|&d| d > 0.0), "expected an uptrend stretch"); + assert!( + dirs.iter().any(|&d| d < 0.0), + "expected a downtrend stretch" + ); + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut st = SuperTrend::classic(); + let out = st.batch(&candles); + assert_eq!(st.warmup_period(), 10); + for (i, v) in out.iter().enumerate().take(9) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[9].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(SuperTrend::new(0, 3.0).is_err()); + assert!(SuperTrend::new(10, 0.0).is_err()); + assert!(SuperTrend::new(10, -1.0).is_err()); + assert!(SuperTrend::new(10, f64::NAN).is_err()); + } + + /// Cover the const accessor `params` (99-101) and the Indicator-impl + /// `name` body (176-178). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let st = SuperTrend::new(10, 3.0).unwrap(); + let (p, m) = st.params(); + assert_eq!(p, 10); + assert!((m - 3.0).abs() < 1e-12); + assert_eq!(st.name(), "SuperTrend"); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut st = SuperTrend::classic(); + st.batch(&candles); + assert!(st.is_ready()); + st.reset(); + assert!(!st.is_ready()); + assert_eq!(st.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = SuperTrend::classic(); + let mut b = SuperTrend::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/t3.rs b/crates/wickra-core/src/indicators/t3.rs new file mode 100644 index 0000000..f73a428 --- /dev/null +++ b/crates/wickra-core/src/indicators/t3.rs @@ -0,0 +1,278 @@ +//! Tillson T3 Moving Average. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Tillson's T3 — a six-fold cascaded EMA recombined with a *volume factor* `v`. +/// +/// T3 is the generalised DEMA applied three times. Tim Tillson's expansion of +/// that triple application over six chained EMAs (`e1 … e6`, each of the same +/// `period`) gives the closed form used here: +/// +/// ```text +/// c1 = −v³ +/// c2 = 3v² + 3v³ +/// c3 = −6v² − 3v − 3v³ +/// c4 = 1 + 3v + v³ + 3v² +/// T3 = c1·e6 + c2·e5 + c3·e4 + c4·e3 +/// ``` +/// +/// The volume factor `v ∈ [0, 1]` controls the lag/smoothness trade-off: +/// `v = 0` collapses T3 to the plain triple-cascaded EMA `e3`, while the +/// conventional `v = 0.7` adds a hump that sharpens the response to turns. +/// The coefficients always sum to `1`, so a constant series maps to itself. +/// +/// The first output lands after `6·period − 5` inputs — the index at which the +/// sixth cascaded EMA seeds. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, T3}; +/// +/// let mut indicator = T3::new(5, 0.7).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct T3 { + period: usize, + v: f64, + c1: f64, + c2: f64, + c3: f64, + c4: f64, + e1: Ema, + e2: Ema, + e3: Ema, + e4: Ema, + e5: Ema, + e6: Ema, + current: Option, +} + +impl T3 { + /// Construct a new T3 with the given `period` and volume factor `v`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`. + pub fn new(period: usize, v: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(Error::InvalidPeriod { + message: "T3 volume factor must be a finite value in [0.0, 1.0]", + }); + } + let v2 = v * v; + let v3 = v2 * v; + Ok(Self { + period, + v, + c1: -v3, + c2: 3.0 * v2 + 3.0 * v3, + c3: -6.0 * v2 - 3.0 * v - 3.0 * v3, + c4: 1.0 + 3.0 * v + v3 + 3.0 * v2, + e1: Ema::new(period)?, + e2: Ema::new(period)?, + e3: Ema::new(period)?, + e4: Ema::new(period)?, + e5: Ema::new(period)?, + e6: Ema::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured volume factor `v`. + pub const fn volume_factor(&self) -> f64 { + self.v + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for T3 { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the cascade is not advanced. + return self.current; + } + let e1 = self.e1.update(input)?; + let e2 = self.e2.update(e1)?; + let e3 = self.e3.update(e2)?; + let e4 = self.e4.update(e3)?; + let e5 = self.e5.update(e4)?; + let e6 = self.e6.update(e5)?; + let out = self.c1 * e6 + self.c2 * e5 + self.c3 * e4 + self.c4 * e3; + self.current = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.e1.reset(); + self.e2.reset(); + self.e3.reset(); + self.e4.reset(); + self.e5.reset(); + self.e6.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + 6 * self.period - 5 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "T3" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `volume_factor` / `value` and + /// the Indicator-impl `name` (lines 95-107, 148-150). Existing tests + /// query `warmup_period` (covered by `first_emission_at_warmup_period`) + /// but never inspect period, v, value, or name. + #[test] + fn accessors_and_metadata() { + let mut t3 = T3::new(5, 0.7).unwrap(); + assert_eq!(t3.period(), 5); + assert_relative_eq!(t3.volume_factor(), 0.7, epsilon = 1e-12); + assert_eq!(t3.name(), "T3"); + assert_eq!(t3.value(), None); + for _ in 0..t3.warmup_period() { + t3.update(50.0); + } + assert!(t3.value().is_some()); + } + + #[test] + fn new_rejects_out_of_range_volume_factor() { + assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(T3::new(5, 1.5), Err(Error::InvalidPeriod { .. }))); + assert!(matches!( + T3::new(5, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + assert!(T3::new(5, 0.0).is_ok()); + assert!(T3::new(5, 1.0).is_ok()); + } + + #[test] + fn coefficients_sum_to_one() { + // c1 + c2 + c3 + c4 == 1 for any v, so a constant series is preserved. + for &v in &[0.0, 0.3, 0.7, 1.0] { + let t3 = T3::new(5, v).unwrap(); + assert_relative_eq!(t3.c1 + t3.c2 + t3.c3 + t3.c4, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn first_emission_at_warmup_period() { + let mut t3 = T3::new(4, 0.7).unwrap(); + assert_eq!(t3.warmup_period(), 6 * 4 - 5); + let out = t3.batch(&(1..=60).map(f64::from).collect::>()); + for v in out.iter().take(t3.warmup_period() - 1) { + assert!(v.is_none()); + } + assert!(out[t3.warmup_period() - 1].is_some()); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut t3 = T3::new(6, 0.7).unwrap(); + let out = t3.batch(&[50.0; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 50.0, epsilon = 1e-9); + } + + #[test] + fn zero_volume_factor_collapses_to_triple_cascaded_ema() { + // With v = 0 the coefficients are c1=c2=c3=0, c4=1, so T3 == e3, + // the third stage of the EMA cascade. + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 9.0) + .collect(); + let mut t3 = T3::new(5, 0.0).unwrap(); + let got = t3.batch(&prices); + + let mut e1 = Ema::new(5).unwrap(); + let mut e2 = Ema::new(5).unwrap(); + let mut e3 = Ema::new(5).unwrap(); + let want: Vec> = prices + .iter() + .map(|p| { + e1.update(*p) + .and_then(|a| e2.update(a)) + .and_then(|b| e3.update(b)) + }) + .collect(); + + for i in (t3.warmup_period() - 1)..prices.len() { + assert_relative_eq!(got[i].unwrap(), want[i].unwrap(), epsilon = 1e-9); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut t3 = T3::new(4, 0.7).unwrap(); + let out = t3.batch(&(1..=60).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(t3.update(f64::NAN), last); + assert_eq!(t3.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut t3 = T3::new(4, 0.7).unwrap(); + t3.batch(&(1..=60).map(f64::from).collect::>()); + assert!(t3.is_ready()); + t3.reset(); + assert!(!t3.is_ready()); + assert_eq!(t3.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 7.0) + .collect(); + let batch = T3::new(7, 0.7).unwrap().batch(&prices); + let mut b = T3::new(7, 0.7).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).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 0000000..499d6d4 --- /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/taker_buy_sell_ratio.rs b/crates/wickra-core/src/indicators/taker_buy_sell_ratio.rs new file mode 100644 index 0000000..5df8010 --- /dev/null +++ b/crates/wickra-core/src/indicators/taker_buy_sell_ratio.rs @@ -0,0 +1,137 @@ +//! Taker Buy/Sell Ratio — aggressive buy volume relative to aggressive sell +//! volume. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Taker Buy/Sell Ratio — the taker (market-order) buy volume divided by the +/// taker sell volume carried by each tick. +/// +/// ```text +/// takerBuySellRatio = takerBuyVolume / takerSellVolume +/// ``` +/// +/// Taker volume is the volume that crossed the spread — the aggressive flow that +/// moves price. A ratio above `1` means buyers are lifting offers faster than +/// sellers are hitting bids (net aggressive buying); below `1` the reverse. It +/// is the perpetual-feed analogue of [trade imbalance], read straight off the +/// venue's taker-volume fields. When taker sell volume is zero the ratio is +/// undefined and the indicator reports `0.0`. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [trade imbalance]: crate::TradeImbalance +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, TakerBuySellRatio}; +/// +/// fn tick(buy: f64, sell: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, buy, sell, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut tbs = TakerBuySellRatio::new(); +/// // 60 taker buys vs 40 taker sells -> 1.5. +/// assert_eq!(tbs.update(tick(60.0, 40.0)), Some(1.5)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TakerBuySellRatio { + has_emitted: bool, +} + +impl TakerBuySellRatio { + /// Construct a new taker buy/sell ratio indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TakerBuySellRatio { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + if tick.taker_sell_volume == 0.0 { + // No taker sell volume to divide by: the ratio is undefined. + return Some(0.0); + } + Some(tick.taker_buy_volume / tick.taker_sell_volume) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TakerBuySellRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(buy: f64, sell: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, buy, sell, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let tbs = TakerBuySellRatio::new(); + assert_eq!(tbs.name(), "TakerBuySellRatio"); + assert_eq!(tbs.warmup_period(), 1); + assert!(!tbs.is_ready()); + } + + #[test] + fn divides_buy_by_sell() { + let mut tbs = TakerBuySellRatio::new(); + assert_eq!(tbs.update(tick(60.0, 40.0)), Some(1.5)); + assert_eq!(tbs.update(tick(20.0, 80.0)), Some(0.25)); + assert!(tbs.is_ready()); + } + + #[test] + fn zero_sell_is_zero() { + let mut tbs = TakerBuySellRatio::new(); + assert_eq!(tbs.update(tick(60.0, 0.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(50.0 + f64::from(i % 5) * 5.0, 40.0 + f64::from(i % 3) * 5.0)) + .collect(); + let mut a = TakerBuySellRatio::new(); + let mut b = TakerBuySellRatio::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut tbs = TakerBuySellRatio::new(); + tbs.update(tick(60.0, 40.0)); + assert!(tbs.is_ready()); + tbs.reset(); + assert!(!tbs.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/takuri.rs b/crates/wickra-core/src/indicators/takuri.rs new file mode 100644 index 0000000..a417179 --- /dev/null +++ b/crates/wickra-core/src/indicators/takuri.rs @@ -0,0 +1,167 @@ +//! Takuri candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Takuri — a single-bar bullish reversal, a stricter Dragonfly Doji. Open, close, +/// and high sit at the very top of the bar with a negligible upper shadow, while an +/// exceptionally long lower shadow shows price was driven sharply down and then bid +/// all the way back — an emphatic rejection of the lows. +/// +/// ```text +/// range = high − low +/// doji = |close − open| <= 0.1 * range +/// negligible upper = high − max(open, close) <= 0.05 * range +/// very long lower = min(open, close) − low >= 0.7 * range +/// ``` +/// +/// Output is `+1.0` when the Takuri prints and `0.0` otherwise. Takuri is a +/// single-direction (bullish-only) shape, so it never emits `−1.0`. Its tighter +/// upper-shadow and longer lower-shadow thresholds make it a strict subset of +/// [`crate::DragonflyDoji`]. Body and shadow thresholds follow the geometric house +/// style (fixed fractions of the bar range) rather than TA-Lib's rolling averages. +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Takuri}; +/// +/// let mut indicator = Takuri::new(); +/// // Body at the top, very long lower shadow. +/// let candle = Candle::new(10.0, 10.05, 7.0, 10.0, 1.0, 0).unwrap(); +/// assert_eq!(indicator.update(candle), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Takuri { + has_emitted: bool, +} + +impl Takuri { + /// Construct a new Takuri detector. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Takuri { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + if range <= 0.0 { + return Some(0.0); + } + if (candle.close - candle.open).abs() > 0.1 * range { + return Some(0.0); + } + let upper = candle.high - candle.open.max(candle.close); + let lower = candle.open.min(candle.close) - candle.low; + if upper <= 0.05 * range && lower >= 0.7 * range { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Takuri" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = Takuri::new(); + assert_eq!(t.name(), "Takuri"); + assert_eq!(t.warmup_period(), 1); + assert!(!t.is_ready()); + } + + #[test] + fn takuri_is_plus_one() { + let mut t = Takuri::new(); + assert_eq!(t.update(c(10.0, 10.05, 7.0, 10.0, 0)), Some(1.0)); + } + + #[test] + fn non_doji_body_yields_zero() { + let mut t = Takuri::new(); + // Large body -> not a doji. + assert_eq!(t.update(c(10.0, 12.0, 7.0, 11.5, 0)), Some(0.0)); + } + + #[test] + fn upper_shadow_yields_zero() { + let mut t = Takuri::new(); + // Long upper shadow -> not a Takuri. + assert_eq!(t.update(c(10.0, 14.0, 7.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn dragonfly_but_not_takuri_yields_zero() { + let mut t = Takuri::new(); + // Upper shadow ~0.07 of range: a Dragonfly Doji, but exceeds Takuri's + // tighter 0.05 ceiling. + assert_eq!(t.update(c(10.0, 10.24, 7.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_yields_zero() { + let mut t = Takuri::new(); + assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 0.02, base - 4.0, base, i) + }) + .collect(); + let mut a = Takuri::new(); + let mut b = Takuri::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Takuri::new(); + t.update(c(10.0, 10.05, 7.0, 10.0, 0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/tasuki_gap.rs b/crates/wickra-core/src/indicators/tasuki_gap.rs new file mode 100644 index 0000000..47baea4 --- /dev/null +++ b/crates/wickra-core/src/indicators/tasuki_gap.rs @@ -0,0 +1,278 @@ +//! Tasuki Gap candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Tasuki Gap — a 3-bar continuation. Two same-coloured candles open a body gap in +/// the trend direction, then an opposite-coloured candle opens inside the second +/// body and closes back *into* the gap without filling it — the gap holds, so the +/// trend is expected to continue. +/// +/// ```text +/// Upside (bullish, +1): +/// bar1 white, bar2 white with an upside body gap (open2 > close1) +/// bar3 black, opens within bar2's body, closes inside the gap +/// (close1 < close3 < open2) +/// Downside (bearish, −1): the mirror image with black candles and a downside gap +/// ``` +/// +/// Output is `+1.0` for an upside Tasuki gap, `−1.0` for a downside one, and `0.0` +/// otherwise. The first two bars always return `0.0` because the three-bar window +/// is not yet filled. Thresholds follow the geometric house style rather than +/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is +/// applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it drops +/// straight into a machine-learning feature matrix where the bullish and bearish +/// variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TasukiGap}; +/// +/// let mut indicator = TasukiGap::new(); +/// indicator.update(Candle::new(10.0, 11.2, 9.8, 11.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(12.0, 14.0, 11.9, 13.5, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.0, 13.1, 11.4, 11.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TasukiGap { + c1: Option, + c2: Option, + has_emitted: bool, +} + +impl TasukiGap { + /// Construct a new Tasuki Gap detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + has_emitted: false, + } + } +} + +impl Indicator for TasukiGap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + self.c1 = self.c2; + self.c2 = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + + let up = bar1.close > bar1.open && bar2.close > bar2.open; + let down = bar1.close < bar1.open && bar2.close < bar2.open; + if up { + if bar2.open <= bar1.close { + return Some(0.0); // no upside body gap + } + if candle.close >= candle.open { + return Some(0.0); // bar3 must be black + } + if candle.open <= bar2.open || candle.open >= bar2.close { + return Some(0.0); // bar3 must open within bar2's body + } + if candle.close < bar2.open && candle.close > bar1.close { + return Some(1.0); // bar3 closes inside the gap + } + return Some(0.0); + } + if down { + if bar2.open >= bar1.close { + return Some(0.0); // no downside body gap + } + if candle.close <= candle.open { + return Some(0.0); // bar3 must be white + } + if candle.open >= bar2.open || candle.open <= bar2.close { + return Some(0.0); // bar3 must open within bar2's body + } + if candle.close > bar2.open && candle.close < bar1.close { + return Some(-1.0); // bar3 closes inside the gap + } + return Some(0.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TasukiGap" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = TasukiGap::new(); + assert_eq!(t.name(), "TasukiGap"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn upside_tasuki_gap_is_plus_one() { + let mut t = TasukiGap::new(); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 14.0, 11.9, 13.5, 1)), Some(0.0)); + assert_eq!(t.update(c(13.0, 13.1, 11.4, 11.5, 2)), Some(1.0)); + } + + #[test] + fn downside_tasuki_gap_is_minus_one() { + let mut t = TasukiGap::new(); + assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.0, 11.1, 9.5, 10.0, 1)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.6, 10.4, 11.5, 2)), Some(-1.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = TasukiGap::new(); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 14.0, 11.9, 13.5, 1)), Some(0.0)); + } + + #[test] + fn up_no_gap_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + // bar2 white but opens below bar1's close -> no upside gap. + t.update(c(10.5, 13.1, 10.4, 13.0, 1)); + assert_eq!(t.update(c(12.5, 12.6, 10.9, 11.0, 2)), Some(0.0)); + } + + #[test] + fn up_third_not_black_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 14.0, 11.9, 13.5, 1)); + // bar3 white. + assert_eq!(t.update(c(12.5, 13.1, 12.4, 13.0, 2)), Some(0.0)); + } + + #[test] + fn up_third_open_outside_body_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 14.0, 11.9, 13.5, 1)); + // bar3 black but opens above bar2's body. + assert_eq!(t.update(c(14.0, 14.1, 11.4, 11.5, 2)), Some(0.0)); + } + + #[test] + fn up_third_close_not_in_gap_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 14.0, 11.9, 13.5, 1)); + // bar3 black, opens in body, but closes below the gap (under bar1's close). + assert_eq!(t.update(c(13.0, 13.1, 10.4, 10.5, 2)), Some(0.0)); + } + + #[test] + fn down_no_gap_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + // bar2 black but opens above bar1's close -> no downside gap. + t.update(c(12.5, 12.6, 10.4, 10.5, 1)); + assert_eq!(t.update(c(11.0, 12.6, 10.9, 12.0, 2)), Some(0.0)); + } + + #[test] + fn down_third_not_white_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + t.update(c(11.0, 11.1, 9.5, 10.0, 1)); + // bar3 black. + assert_eq!(t.update(c(11.5, 11.6, 10.4, 10.5, 2)), Some(0.0)); + } + + #[test] + fn down_third_open_outside_body_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + t.update(c(11.0, 11.1, 9.5, 10.0, 1)); + // bar3 white but opens below bar2's body. + assert_eq!(t.update(c(9.5, 11.6, 9.4, 11.5, 2)), Some(0.0)); + } + + #[test] + fn down_third_close_not_in_gap_yields_zero() { + let mut t = TasukiGap::new(); + t.update(c(13.0, 13.2, 11.8, 12.0, 0)); + t.update(c(11.0, 11.1, 9.5, 10.0, 1)); + // bar3 white, opens in body, but closes above the gap (over bar1's close). + assert_eq!(t.update(c(10.5, 13.0, 10.4, 12.5, 2)), Some(0.0)); + } + + #[test] + fn mixed_colours_yield_zero() { + let mut t = TasukiGap::new(); + // bar1 white, bar2 black -> neither an upside nor downside setup. + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(13.0, 13.2, 11.0, 11.5, 1)); + assert_eq!(t.update(c(12.0, 12.6, 10.9, 11.0, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 5.2, base - 0.1, base + 5.0, i) + }) + .collect(); + let mut a = TasukiGap::new(); + let mut b = TasukiGap::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = TasukiGap::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 14.0, 11.9, 13.5, 1)); + t.update(c(13.0, 13.1, 11.4, 11.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/td_camouflage.rs b/crates/wickra-core/src/indicators/td_camouflage.rs new file mode 100644 index 0000000..2b5be36 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_camouflage.rs @@ -0,0 +1,171 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Camouflage — a hidden-strength/weakness 1-bar reversal pattern. +//! +//! TD Camouflage spots a bar that *looks* weak (or strong) on its close-to-close +//! comparison but reveals the opposite intrabar, "camouflaging" a reversal. +//! +//! - **Buy signal** (`+1.0`): `close < close[-1]` (a lower close, looks bearish), +//! yet `close > open` (it actually closed up on the bar) and `low < low[-1]` +//! (it dipped to a new low and was bought back) — hidden accumulation. +//! - **Sell signal** (`-1.0`): `close > close[-1]`, `close < open`, and +//! `high > high[-1]` — hidden distribution. +//! - Otherwise the output is `0.0`. +//! +//! The one-bar lookback means the first value lands on the second candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Camouflage — 1-bar hidden-strength/weakness reversal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdCamouflage, Candle, Indicator}; +/// +/// let mut indicator = TdCamouflage::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdCamouflage { + prev: Option, + last_value: Option, +} + +impl TdCamouflage { + /// Construct a new `TdCamouflage`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdCamouflage { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let v = if candle.close < prev.close && candle.close > candle.open && candle.low < prev.low + { + 1.0 + } else if candle.close > prev.close && candle.close < candle.open && candle.high > prev.high + { + -1.0 + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDCamouflage" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(open, high, low, close, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let td = TdCamouflage::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDCamouflage"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn first_bar_seeds_without_signal() { + let mut td = TdCamouflage::new(); + assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0)); + assert!(td.update(c(10.0, 11.0, 8.0, 9.5)).is_some()); + } + + #[test] + fn bullish_camouflage_buy() { + // prev close 10. Current: close 9.5 < 10 (lower close), close 9.5 > open 9.0, + // low 7.0 < prev low 8.0 -> buy. + let mut td = TdCamouflage::new(); + td.update(c(10.0, 11.0, 8.0, 10.0)); + assert_eq!(td.update(c(9.0, 10.0, 7.0, 9.5)), Some(1.0)); + } + + #[test] + fn bearish_camouflage_sell() { + // prev close 10. Current: close 10.5 > 10, close 10.5 < open 11.0, + // high 12.0 > prev high 11.0 -> sell. + let mut td = TdCamouflage::new(); + td.update(c(10.0, 11.0, 8.0, 10.0)); + assert_eq!(td.update(c(11.0, 12.0, 10.0, 10.5)), Some(-1.0)); + } + + #[test] + fn no_pattern_is_zero() { + let mut td = TdCamouflage::new(); + td.update(c(10.0, 11.0, 9.0, 10.0)); + assert_eq!(td.update(c(10.0, 11.5, 9.5, 11.0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut td = TdCamouflage::new(); + td.update(c(10.0, 11.0, 9.0, 10.0)); + td.update(c(9.0, 10.0, 7.0, 9.5)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; + c(b, b + 1.0, b - 1.0, b + 0.2) + }) + .collect(); + let batch = TdCamouflage::new().batch(&candles); + let mut b = TdCamouflage::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/td_clop.rs b/crates/wickra-core/src/indicators/td_clop.rs new file mode 100644 index 0000000..2ecf8f0 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_clop.rs @@ -0,0 +1,174 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Clop — a 2-bar open/close engulfing reversal. +//! +//! TD Clop ("CLose/OPen") fires when the current bar's open opens beyond **both** +//! the prior bar's open and close, and its close finishes back beyond both — an +//! open-gap that fully reverses, signalling a turn. +//! +//! - **Buy signal** (`+1.0`): `open < open[-1]` AND `open < close[-1]` +//! (opens below the whole prior body) AND `close > open[-1]` AND +//! `close > close[-1]` (closes above it). +//! - **Sell signal** (`-1.0`): `open > open[-1]` AND `open > close[-1]` AND +//! `close < open[-1]` AND `close < close[-1]`. +//! - Otherwise the output is `0.0`. +//! +//! The one-bar lookback means the first value lands on the second candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Clop — 2-bar open/close engulfing reversal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdClop, Candle, Indicator}; +/// +/// let mut indicator = TdClop::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdClop { + prev: Option, + last_value: Option, +} + +impl TdClop { + /// Construct a new `TdClop`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdClop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let below_body = candle.open < prev.open && candle.open < prev.close; + let above_body = candle.close > prev.open && candle.close > prev.close; + let over_body = candle.open > prev.open && candle.open > prev.close; + let under_body = candle.close < prev.open && candle.close < prev.close; + let v = if below_body && above_body { + 1.0 + } else if over_body && under_body { + -1.0 + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDClop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, close: f64) -> Candle { + let high = open.max(close) + 1.0; + let low = open.min(close) - 1.0; + Candle::new_unchecked(open, high, low, close, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let td = TdClop::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDClop"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn first_bar_seeds_without_signal() { + let mut td = TdClop::new(); + assert_eq!(td.update(c(10.0, 11.0)), Some(0.0)); + assert!(td.update(c(9.0, 12.0)).is_some()); + } + + #[test] + fn bullish_clop_buy() { + // prev body [10, 11]. Current open 9 < both, close 12 > both -> buy. + let mut td = TdClop::new(); + td.update(c(10.0, 11.0)); + assert_eq!(td.update(c(9.0, 12.0)), Some(1.0)); + } + + #[test] + fn bearish_clop_sell() { + // prev body [10, 11]. Current open 12 > both, close 9 < both -> sell. + let mut td = TdClop::new(); + td.update(c(10.0, 11.0)); + assert_eq!(td.update(c(12.0, 9.0)), Some(-1.0)); + } + + #[test] + fn no_pattern_is_zero() { + let mut td = TdClop::new(); + td.update(c(10.0, 11.0)); + assert_eq!(td.update(c(10.5, 11.5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut td = TdClop::new(); + td.update(c(10.0, 11.0)); + td.update(c(9.0, 12.0)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 11.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; + c(b, b + 0.5) + }) + .collect(); + let batch = TdClop::new().batch(&candles); + let mut b = TdClop::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/td_clopwin.rs b/crates/wickra-core/src/indicators/td_clopwin.rs new file mode 100644 index 0000000..98015fc --- /dev/null +++ b/crates/wickra-core/src/indicators/td_clopwin.rs @@ -0,0 +1,177 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Clopwin — a 2-bar "close/open within" inside-body pattern. +//! +//! TD Clopwin ("CLose/OPen WInthIN") is the inside-body cousin of TD Clop: the +//! current bar's open **and** close both sit within the prior bar's real body, +//! marking a compression bar whose direction hints at the next move. +//! +//! - **Buy signal** (`+1.0`): current `open` and `close` are both inside the prior +//! bar's body `[min(open,close)[-1], max(open,close)[-1]]` AND `close >= open` +//! (a bullish inside bar). +//! - **Sell signal** (`-1.0`): both inside the prior body AND `close < open` +//! (a bearish inside bar). +//! - Otherwise the output is `0.0`. +//! +//! The one-bar lookback means the first value lands on the second candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Clopwin — 2-bar inside-body compression pattern detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdClopwin, Candle, Indicator}; +/// +/// let mut indicator = TdClopwin::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdClopwin { + prev: Option, + last_value: Option, +} + +impl TdClopwin { + /// Construct a new `TdClopwin`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdClopwin { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let body_low = prev.open.min(prev.close); + let body_high = prev.open.max(prev.close); + let open_in = candle.open >= body_low && candle.open <= body_high; + let close_in = candle.close >= body_low && candle.close <= body_high; + let v = if open_in && close_in { + if candle.close >= candle.open { + 1.0 + } else { + -1.0 + } + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDClopwin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, close: f64) -> Candle { + let high = open.max(close) + 1.0; + let low = open.min(close) - 1.0; + Candle::new_unchecked(open, high, low, close, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let td = TdClopwin::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDClopwin"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn first_bar_seeds_without_signal() { + let mut td = TdClopwin::new(); + assert_eq!(td.update(c(10.0, 14.0)), Some(0.0)); + assert!(td.update(c(11.0, 13.0)).is_some()); + } + + #[test] + fn bullish_inside_body_buy() { + // prev body [10, 14]. Current open 11, close 13 both inside, close>open -> +1. + let mut td = TdClopwin::new(); + td.update(c(10.0, 14.0)); + assert_eq!(td.update(c(11.0, 13.0)), Some(1.0)); + } + + #[test] + fn bearish_inside_body_sell() { + // prev body [10, 14]. Current open 13, close 11 inside, close -1. + let mut td = TdClopwin::new(); + td.update(c(10.0, 14.0)); + assert_eq!(td.update(c(13.0, 11.0)), Some(-1.0)); + } + + #[test] + fn outside_body_is_zero() { + let mut td = TdClopwin::new(); + td.update(c(10.0, 14.0)); + // close 16 outside the prior body -> 0. + assert_eq!(td.update(c(11.0, 16.0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut td = TdClopwin::new(); + td.update(c(10.0, 14.0)); + td.update(c(11.0, 13.0)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 14.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; + c(b, b + 0.3) + }) + .collect(); + let batch = TdClopwin::new().batch(&candles); + let mut b = TdClopwin::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/td_combo.rs b/crates/wickra-core/src/indicators/td_combo.rs new file mode 100644 index 0000000..585b9d5 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_combo.rs @@ -0,0 +1,373 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Combo — an aggressive variant of TD Countdown. +//! +//! TD Combo is DeMark's stricter countdown variant. Unlike vanilla TD +//! Sequential (which only requires `close <= low[i - 2]` for a buy +//! countdown), Combo adds two strictness conditions that prevent the +//! countdown from advancing on weak bars: +//! +//! - **Buy combo** bars must satisfy: +//! 1. `close[i] <= low[i - 2]` (the classic countdown rule) +//! 2. `low[i] <= low[i - 1]` (monotone strictly-non-rising lows) +//! 3. `close[i] < close[i - 1]` (each combo bar must close strictly lower) +//! - **Sell combo** bars must satisfy the mirror set: +//! 1. `close[i] >= high[i - 2]` +//! 2. `high[i] >= high[i - 1]` +//! 3. `close[i] > close[i - 1]` +//! +//! Like vanilla countdown, the combo is *armed* by a completed 9-bar setup +//! (same definition as [`crate::TdSetup`]) in the same direction. The combo +//! count saturates at `target` (DeMark's classic value is `13`). +//! +//! Output is a signed counter: positive for an active buy-combo run, +//! negative for a sell-combo run, `0.0` when no combo is currently armed. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Direction of an active TD Combo run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + None, + Buy, + Sell, +} + +/// TD Combo — aggressive countdown variant. +/// # Example +/// +/// ``` +/// use wickra_core::{TdCombo, Candle, Indicator}; +/// +/// let mut indicator = TdCombo::new(4, 9, 2, 13).unwrap(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct TdCombo { + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + candles: VecDeque, + buy_setup: usize, + sell_setup: usize, + buy_combo: usize, + sell_combo: usize, + direction: Direction, + ready: bool, +} + +impl TdCombo { + /// Construct a TD Combo with explicit lookbacks and targets. The + /// canonical DeMark configuration is `setup_lookback = 4`, + /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any argument is zero. + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + if setup_lookback == 0 + || setup_target == 0 + || countdown_lookback == 0 + || countdown_target == 0 + { + return Err(Error::PeriodZero); + } + let cap = setup_lookback.max(countdown_lookback) + 1; + Ok(Self { + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + candles: VecDeque::with_capacity(cap), + buy_setup: 0, + sell_setup: 0, + buy_combo: 0, + sell_combo: 0, + direction: Direction::None, + ready: false, + }) + } + + /// DeMark's classic configuration: setup `lookback = 4, target = 9`, + /// combo `lookback = 2, target = 13`. + pub fn classic() -> Self { + Self::new(4, 9, 2, 13).expect("classic TD Combo parameters are valid") + } + + /// Configured `(setup_lookback, setup_target, countdown_lookback, + /// countdown_target)`. + pub const fn params(&self) -> (usize, usize, usize, usize) { + ( + self.setup_lookback, + self.setup_target, + self.countdown_lookback, + self.countdown_target, + ) + } +} + +impl Indicator for TdCombo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let need = self.setup_lookback.max(self.countdown_lookback); + let cap = need + 1; + if self.candles.len() == cap { + self.candles.pop_front(); + } + if self.candles.len() < need { + self.candles.push_back(candle); + return None; + } + + // Setup rule: compare to close[setup_lookback bars ago]. + let setup_ref_idx = need - self.setup_lookback; + let setup_ref_close = self.candles[setup_ref_idx].close; + if candle.close < setup_ref_close { + self.buy_setup = (self.buy_setup + 1).min(self.setup_target); + self.sell_setup = 0; + } else if candle.close > setup_ref_close { + self.sell_setup = (self.sell_setup + 1).min(self.setup_target); + self.buy_setup = 0; + } else { + self.buy_setup = 0; + self.sell_setup = 0; + } + + // Combo arming: a completed setup in either direction arms the + // combo in the same direction (resetting any opposite-direction + // combo count first). + if self.buy_setup == self.setup_target { + if self.direction != Direction::Buy { + self.buy_combo = 0; + self.sell_combo = 0; + } + self.direction = Direction::Buy; + } else if self.sell_setup == self.setup_target { + if self.direction != Direction::Sell { + self.buy_combo = 0; + self.sell_combo = 0; + } + self.direction = Direction::Sell; + } + + // Combo rule references the candle `countdown_lookback` bars ago + // (high / low) and the immediately-prior candle (low / high / + // close monotone strictness). + let combo_ref = self.candles[need - self.countdown_lookback]; + let prev = self.candles[need - 1]; + match self.direction { + Direction::Buy => { + let cond_classic = candle.close <= combo_ref.low; + let cond_low = candle.low <= prev.low; + let cond_close = candle.close < prev.close; + if cond_classic && cond_low && cond_close && self.buy_combo < self.countdown_target + { + self.buy_combo += 1; + } + } + Direction::Sell => { + let cond_classic = candle.close >= combo_ref.high; + let cond_high = candle.high >= prev.high; + let cond_close = candle.close > prev.close; + if cond_classic + && cond_high + && cond_close + && self.sell_combo < self.countdown_target + { + self.sell_combo += 1; + } + } + Direction::None => {} + } + + self.candles.push_back(candle); + self.ready = true; + + let v = match self.direction { + Direction::Buy => self.buy_combo as f64, + Direction::Sell => -(self.sell_combo as f64), + Direction::None => 0.0, + }; + Some(v) + } + + fn reset(&mut self) { + self.candles.clear(); + self.buy_setup = 0; + self.sell_setup = 0; + self.buy_combo = 0; + self.sell_combo = 0; + self.direction = Direction::None; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.setup_lookback.max(self.countdown_lookback) + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDCombo" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_arms_sell_combo_and_advances() { + // Strictly increasing closes -> sell setup completes at idx 12, + // then every subsequent bar satisfies the three sell-combo + // strictness conditions, so combo advances by one per bar and + // saturates at -13. + let candles: Vec = (1..=40) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut combo = TdCombo::classic(); + let out = combo.batch(&candles); + // First emit is at index 4 (warmup is 5). + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the setup completes and combo direction is sell; on + // the same bar the combo rule fires once because the + // monotone-strictness conditions hold for a strictly-rising + // series, so combo == -1. + let at_12 = out[12].expect("ready"); + assert_eq!(at_12, -1.0); + // By idx 30 the combo has saturated at -13. + let later = out[30].expect("ready"); + assert_eq!(later, -13.0); + } + + #[test] + fn pure_downtrend_arms_buy_combo_and_advances() { + // Strictly decreasing closes -> buy setup completes at idx 12, + // then every subsequent bar satisfies the three buy-combo + // strictness conditions, so combo advances by one per bar and + // saturates at +13. + let candles: Vec = (1..=40) + .rev() + .enumerate() + .map(|(k, i)| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::try_from(k).unwrap(), + ) + }) + .collect(); + let mut combo = TdCombo::classic(); + let out = combo.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the setup completes and combo direction is buy; on + // the same bar the combo rule fires once because the + // monotone-strictness conditions hold for a strictly-falling + // series, so combo == +1. + let at_12 = out[12].expect("ready"); + assert_eq!(at_12, 1.0); + // By idx 30 the combo has saturated at +13. + let later = out[30].expect("ready"); + assert_eq!(later, 13.0); + } + + #[test] + fn flat_series_never_arms_combo() { + // All closes equal -> setup never completes -> combo never arms. + let candles: Vec = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut combo = TdCombo::classic(); + for v in combo.batch(&candles).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdCombo::classic(); + let mut b = TdCombo::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero))); + assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero))); + assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero))); + assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=30) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut combo = TdCombo::classic(); + combo.batch(&candles); + assert!(combo.is_ready()); + combo.reset(); + assert!(!combo.is_ready()); + assert_eq!(combo.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let combo = TdCombo::classic(); + assert_eq!(combo.params(), (4, 9, 2, 13)); + assert_eq!(combo.warmup_period(), 5); + assert_eq!(combo.name(), "TDCombo"); + } +} diff --git a/crates/wickra-core/src/indicators/td_countdown.rs b/crates/wickra-core/src/indicators/td_countdown.rs new file mode 100644 index 0000000..7e01f16 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_countdown.rs @@ -0,0 +1,355 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Countdown (standalone 13-bar countdown). +//! +//! The Countdown is the second half of DeMark's TD Sequential, packaged +//! here as a standalone indicator that runs the setup-detection phase +//! internally and then exposes only the countdown count (and direction) +//! to callers who don't need the running setup state. +//! +//! - **Setup detection** (internal): 9 consecutive bars whose close is +//! less-than (buy setup) or greater-than (sell setup) the close +//! `setup_lookback` bars earlier. +//! - **Buy countdown** advances on bars where `close[i] <= low[i - +//! countdown_lookback]` (need not be consecutive). Saturates at +//! `countdown_target` (13 in DeMark's classic configuration). +//! - **Sell countdown** advances on bars where `close[i] >= high[i - +//! countdown_lookback]`. +//! - An opposite-direction setup completion invalidates the active +//! countdown (count resets to zero in the new direction). +//! +//! Output is a signed counter: positive for an active buy countdown, +//! negative for an active sell countdown, and `0.0` when no countdown is +//! currently armed. +//! +//! This indicator differs from [`crate::TdSequential`] only in its +//! output shape: callers who only need the countdown value (and not the +//! running setup count) can use this for a smaller streaming payload. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Direction of an active TD Countdown phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + None, + Buy, + Sell, +} + +/// TD Countdown — standalone 13-bar countdown. +/// # Example +/// +/// ``` +/// use wickra_core::{TdCountdown, Candle, Indicator}; +/// +/// let mut indicator = TdCountdown::new(4, 9, 2, 13).unwrap(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct TdCountdown { + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + candles: VecDeque, + buy_setup: usize, + sell_setup: usize, + buy_countdown: usize, + sell_countdown: usize, + direction: Direction, + ready: bool, +} + +impl TdCountdown { + /// Construct a TD Countdown with explicit lookbacks and targets. The + /// canonical DeMark configuration is `setup_lookback = 4`, + /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any argument is zero. + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + if setup_lookback == 0 + || setup_target == 0 + || countdown_lookback == 0 + || countdown_target == 0 + { + return Err(Error::PeriodZero); + } + let cap = setup_lookback.max(countdown_lookback) + 1; + Ok(Self { + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + candles: VecDeque::with_capacity(cap), + buy_setup: 0, + sell_setup: 0, + buy_countdown: 0, + sell_countdown: 0, + direction: Direction::None, + ready: false, + }) + } + + /// DeMark's classic configuration: setup `lookback = 4, target = 9`, + /// countdown `lookback = 2, target = 13`. + pub fn classic() -> Self { + Self::new(4, 9, 2, 13).expect("classic TD Countdown parameters are valid") + } + + /// Configured `(setup_lookback, setup_target, countdown_lookback, + /// countdown_target)`. + pub const fn params(&self) -> (usize, usize, usize, usize) { + ( + self.setup_lookback, + self.setup_target, + self.countdown_lookback, + self.countdown_target, + ) + } +} + +impl Indicator for TdCountdown { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let need = self.setup_lookback.max(self.countdown_lookback); + let cap = need + 1; + if self.candles.len() == cap { + self.candles.pop_front(); + } + if self.candles.len() < need { + self.candles.push_back(candle); + return None; + } + + // Setup rule: compare to close[setup_lookback bars ago]. + let setup_ref_idx = need - self.setup_lookback; + let setup_ref_close = self.candles[setup_ref_idx].close; + if candle.close < setup_ref_close { + self.buy_setup = (self.buy_setup + 1).min(self.setup_target); + self.sell_setup = 0; + } else if candle.close > setup_ref_close { + self.sell_setup = (self.sell_setup + 1).min(self.setup_target); + self.buy_setup = 0; + } else { + self.buy_setup = 0; + self.sell_setup = 0; + } + + if self.buy_setup == self.setup_target { + if self.direction != Direction::Buy { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.direction = Direction::Buy; + } else if self.sell_setup == self.setup_target { + if self.direction != Direction::Sell { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.direction = Direction::Sell; + } + + let cd_ref = self.candles[need - self.countdown_lookback]; + match self.direction { + Direction::Buy => { + if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target { + self.buy_countdown += 1; + } + } + Direction::Sell => { + if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target { + self.sell_countdown += 1; + } + } + Direction::None => {} + } + + self.candles.push_back(candle); + self.ready = true; + + let v = match self.direction { + Direction::Buy => self.buy_countdown as f64, + Direction::Sell => -(self.sell_countdown as f64), + Direction::None => 0.0, + }; + Some(v) + } + + fn reset(&mut self) { + self.candles.clear(); + self.buy_setup = 0; + self.sell_setup = 0; + self.buy_countdown = 0; + self.sell_countdown = 0; + self.direction = Direction::None; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.setup_lookback.max(self.countdown_lookback) + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDCountdown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_completes_setup_then_runs_sell_countdown_to_minus_13() { + let candles: Vec = (1..=40) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdCountdown::classic(); + let out = td.batch(&candles); + // Warmup: 4 None values. + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the sell setup completes; on the same bar the + // countdown rule fires once because close > high[i-2] for a + // strictly-rising series, so countdown == -1. + assert_eq!(out[12].expect("ready"), -1.0); + // After enough bars the countdown saturates at -13. + assert_eq!(out[30].expect("ready"), -13.0); + } + + #[test] + fn pure_downtrend_completes_setup_then_runs_buy_countdown_to_plus_13() { + let candles: Vec = (1..=40) + .rev() + .enumerate() + .map(|(k, i)| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::try_from(k).unwrap(), + ) + }) + .collect(); + let mut td = TdCountdown::classic(); + let out = td.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // At idx 12 the buy setup completes; on the same bar the + // countdown rule fires once because close < low[i-2] for a + // strictly-falling series, so countdown == +1. + assert_eq!(out[12].expect("ready"), 1.0); + // After enough bars the countdown saturates at +13. + assert_eq!(out[30].expect("ready"), 13.0); + } + + #[test] + fn flat_series_never_arms_countdown() { + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut td = TdCountdown::classic(); + for v in td.batch(&candles).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdCountdown::classic(); + let mut b = TdCountdown::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + TdCountdown::new(0, 9, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdCountdown::new(4, 0, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdCountdown::new(4, 9, 0, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdCountdown::new(4, 9, 2, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=30) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdCountdown::classic(); + td.batch(&candles); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdCountdown::classic(); + assert_eq!(td.params(), (4, 9, 2, 13)); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDCountdown"); + } +} diff --git a/crates/wickra-core/src/indicators/td_demarker.rs b/crates/wickra-core/src/indicators/td_demarker.rs new file mode 100644 index 0000000..6860c5a --- /dev/null +++ b/crates/wickra-core/src/indicators/td_demarker.rs @@ -0,0 +1,246 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark DeMarker (TD DeMarker) — bounded [0, 1] oscillator built from +//! highs and lows. +//! +//! For each bar `i`: +//! +//! ```text +//! DeMax(i) = max(high[i] - high[i-1], 0) +//! DeMin(i) = max(low[i-1] - low[i], 0) +//! ``` +//! +//! Then the indicator is the simple moving average of `DeMax` over `period` +//! bars divided by the sum of the simple moving averages of `DeMax` and +//! `DeMin` over the same window: +//! +//! ```text +//! DeMarker = SMA(DeMax, period) / (SMA(DeMax, period) + SMA(DeMin, period)) +//! ``` +//! +//! When both averages are zero (a perfectly flat market) the indicator emits +//! the neutral midpoint `0.5`. Values above `0.7` mark overbought conditions, +//! values below `0.3` mark oversold. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD DeMarker bounded oscillator. +#[derive(Debug, Clone)] +pub struct TdDeMarker { + period: usize, + prev: Option, + demax: VecDeque, + demin: VecDeque, + last_value: Option, +} + +impl TdDeMarker { + /// Construct a TD DeMarker with the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + demax: VecDeque::with_capacity(period), + demin: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// Configured window. + pub const fn period(&self) -> usize { + self.period + } + + /// Latest emitted value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdDeMarker { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let demax = (candle.high - prev.high).max(0.0); + let demin = (prev.low - candle.low).max(0.0); + self.prev = Some(candle); + if self.demax.len() == self.period { + self.demax.pop_front(); + self.demin.pop_front(); + } + self.demax.push_back(demax); + self.demin.push_back(demin); + if self.demax.len() < self.period { + return None; + } + let n = self.period as f64; + let sum_max: f64 = self.demax.iter().sum::() / n; + let sum_min: f64 = self.demin.iter().sum::() / n; + let denom = sum_max + sum_min; + let v = if denom == 0.0 { 0.5 } else { sum_max / denom }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.demax.clear(); + self.demin.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDDeMarker" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn flat_market_emits_neutral_05() { + // All highs and lows equal -> DeMax == DeMin == 0 every bar -> the + // denominator is zero and the indicator must fall back to 0.5. + let candles: Vec = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut dm = TdDeMarker::new(14).unwrap(); + let out = dm.batch(&candles); + for v in out.iter().skip(14).copied().flatten() { + assert_relative_eq!(v, 0.5, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_pegs_indicator_at_one() { + // Every bar makes a higher high and higher low. DeMax is always + // positive, DeMin is always zero -> indicator = 1. + let candles: Vec = (0..20) + .map(|i: i32| { + c( + 11.0 + f64::from(i), + 9.0 + f64::from(i), + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut dm = TdDeMarker::new(5).unwrap(); + let out = dm.batch(&candles); + for v in out.iter().skip(6).copied().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_downtrend_pegs_indicator_at_zero() { + let candles: Vec = (0..20) + .map(|i: i32| { + c( + 11.0 - f64::from(i), + 9.0 - f64::from(i), + 10.0 - f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut dm = TdDeMarker::new(5).unwrap(); + let out = dm.batch(&candles); + for v in out.iter().skip(6).copied().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn stays_in_unit_interval() { + let candles: Vec = (0..200) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut dm = TdDeMarker::new(14).unwrap(); + for v in dm.batch(&candles).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdDeMarker::new(14).unwrap(); + let mut b = TdDeMarker::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TdDeMarker::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i: i32| { + c( + 11.0 + f64::from(i), + 9.0 + f64::from(i), + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut dm = TdDeMarker::new(14).unwrap(); + dm.batch(&candles); + assert!(dm.is_ready()); + dm.reset(); + assert!(!dm.is_ready()); + assert_eq!(dm.update(candles[0]), None); + assert_eq!(dm.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let dm = TdDeMarker::new(14).unwrap(); + assert_eq!(dm.period(), 14); + assert_eq!(dm.warmup_period(), 15); + assert_eq!(dm.name(), "TDDeMarker"); + } +} diff --git a/crates/wickra-core/src/indicators/td_differential.rs b/crates/wickra-core/src/indicators/td_differential.rs new file mode 100644 index 0000000..ff74ce5 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_differential.rs @@ -0,0 +1,206 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Differential — 2-bar momentum-divergence reversal pattern. +//! +//! TD Differential flags an exhaustion-and-reversal candle whose buying or +//! selling pressure has shifted from the prior bar. The rules use the +//! current bar's close vs the prior bar's close (direction filter), the +//! buying pressure `close - low` and the selling pressure `high - close`. +//! +//! - **Buy signal** (`+1.0`) on bar `i` when: +//! 1. `close[i] < close[i - 1]` (down day) +//! 2. `close[i] - low[i] > close[i - 1] - low[i - 1]` (more buying pressure than the prior bar) +//! 3. `high[i] - close[i] < high[i - 1] - close[i - 1]` (less selling pressure than the prior bar) +//! - **Sell signal** (`-1.0`) on bar `i` when: +//! 1. `close[i] > close[i - 1]` +//! 2. `high[i] - close[i] > high[i - 1] - close[i - 1]` +//! 3. `close[i] - low[i] < close[i - 1] - low[i - 1]` +//! - Otherwise the output is `0.0`. +//! +//! The two-bar lookback means the indicator emits its first value on the +//! second input candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Differential — 2-bar reversal pattern detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdDifferential, Candle, Indicator}; +/// +/// let mut indicator = TdDifferential::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdDifferential { + prev: Option, + last_value: Option, +} + +impl TdDifferential { + /// Construct a new `TdDifferential`. + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdDifferential { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let buying_now = candle.close - candle.low; + let buying_prev = prev.close - prev.low; + let selling_now = candle.high - candle.close; + let selling_prev = prev.high - prev.close; + + let v = if candle.close < prev.close + && buying_now > buying_prev + && selling_now < selling_prev + { + 1.0 + } else if candle.close > prev.close + && selling_now > selling_prev + && buying_now < buying_prev + { + -1.0 + } else { + 0.0 + }; + + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDDifferential" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn buy_signal_on_strong_down_close_with_more_buying_pressure() { + // Prev bar: high=10, low=8, close=9 -> buying=1, selling=1. + // Curr bar: high=9, low=7, close=8.5 -> close 1, selling=0.5 < 1 -> buy signal +1. + let mut td = TdDifferential::new(); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None); + assert_eq!(td.update(c(9.0, 7.0, 8.5, 1)), Some(1.0)); + } + + #[test] + fn sell_signal_on_strong_up_close_with_more_selling_pressure() { + // Prev bar: high=10, low=8, close=9 -> buying=1, selling=1. + // Curr bar: high=12, low=9, close=10.5 -> close>prev.close (10.5>9), + // selling=1.5 > 1, buying=1.5 > 1 -> condition 3 fails -> no signal. + // Build a real sell case: + // Curr bar: high=12, low=9.5, close=10.5 -> + // close>prev.close: 10.5>9 ✓ + // selling = 12 - 10.5 = 1.5 > prev.selling 1 ✓ + // buying = 10.5 - 9.5 = 1.0 < prev.buying 1 → NO (need strict <). + // Curr bar: high=12, low=9.8, close=10.5 -> + // buying = 0.7 < 1 ✓; selling = 1.5 > 1 ✓; close>prev ✓ -> sell. + let mut td = TdDifferential::new(); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None); + assert_relative_eq!(td.update(c(12.0, 9.8, 10.5, 1)).unwrap(), -1.0); + } + + #[test] + fn no_signal_on_neutral_bar() { + // Identical bars -> equality everywhere -> zero. + let mut td = TdDifferential::new(); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdDifferential::new(); + let mut b = TdDifferential::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn output_only_in_canonical_set() { + // Every emitted value is in {-1, 0, +1}. + let candles: Vec = (0..120) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut td = TdDifferential::new(); + for v in td.batch(&candles).into_iter().flatten() { + assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut td = TdDifferential::new(); + td.update(c(10.0, 8.0, 9.0, 0)); + td.update(c(11.0, 9.0, 10.0, 1)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 8.0, 9.0, 2)), None); + assert_eq!(td.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdDifferential::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDDifferential"); + assert_eq!(td.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_dwave.rs b/crates/wickra-core/src/indicators/td_dwave.rs new file mode 100644 index 0000000..eee50ef --- /dev/null +++ b/crates/wickra-core/src/indicators/td_dwave.rs @@ -0,0 +1,295 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD D-Wave — a simplified Elliott-style swing-wave counter. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Tom DeMark **TD D-Wave** — a streaming wave counter that labels the market's +/// swing sequence with an Elliott-style `1–5` impulse / `A–C` correction count. +/// +/// TD D-Wave is DeMark's objective alternative to discretionary Elliott Wave +/// counting. This streaming implementation detects alternating swing pivots with a +/// symmetric fractal of half-width `strength`, and advances a counter through the +/// eight-leg cycle each time a new swing leg is confirmed: +/// +/// ```text +/// legs: 1 → 2 → 3 → 4 → 5 → A(6) → B(7) → C(8) → 1 … +/// output = current wave number, 1.0..8.0 (6/7/8 = corrective A/B/C) +/// ``` +/// +/// The number tells you which wave of the cycle price is currently working on — a +/// running map of impulse versus correction that updates as each swing confirms. +/// This is a **simplified** swing-leg count (it does not enforce Elliott's price +/// ratio and overlap rules); treat it as a structural guide, not a strict wave +/// label. +/// +/// Readiness is data-dependent: the first value appears once the first swing pivot +/// confirms (`strength` bars after it forms). `warmup_period` returns the minimum +/// bars to confirm one pivot. Each `update` is O(`strength`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TdDWave}; +/// +/// let mut indicator = TdDWave::new(2).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// let _ = last; +/// ``` +#[derive(Debug, Clone)] +pub struct TdDWave { + strength: usize, + window: VecDeque, + last_is_high: Option, + last_extreme: f64, + wave: usize, + last_value: Option, +} + +impl TdDWave { + /// Construct a TD D-Wave with the given fractal `strength`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `strength == 0`. + pub fn new(strength: usize) -> Result { + if strength == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + strength, + window: VecDeque::with_capacity(2 * strength + 1), + last_is_high: None, + last_extreme: 0.0, + wave: 0, + last_value: None, + }) + } + + /// Configured fractal strength. + pub const fn strength(&self) -> usize { + self.strength + } + + /// Current wave number if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn advance(&mut self, is_high: bool, price: f64) { + match self.last_is_high { + Some(prev) if prev == is_high => { + // Same-direction extreme: extend the current leg if more extreme. + let extends = if is_high { + price > self.last_extreme + } else { + price < self.last_extreme + }; + if extends { + self.last_extreme = price; + } + } + _ => { + // A new alternating leg: advance the wave counter (1..8 cycle). + self.wave = self.wave % 8 + 1; + self.last_is_high = Some(is_high); + self.last_extreme = price; + self.last_value = Some(self.wave as f64); + } + } + } +} + +impl Indicator for TdDWave { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let span = 2 * self.strength + 1; + if self.window.len() == span { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() == span { + let center = self.window[self.strength]; + let is_high = self + .window + .iter() + .enumerate() + .all(|(i, c)| i == self.strength || c.high < center.high); + let is_low = self + .window + .iter() + .enumerate() + .all(|(i, c)| i == self.strength || c.low > center.low); + if is_high && !is_low { + self.advance(true, center.high); + } else if is_low && !is_high { + self.advance(false, center.low); + } + } + self.last_value + } + + fn reset(&mut self) { + self.window.clear(); + self.last_is_high = None; + self.last_extreme = 0.0; + self.wave = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 * self.strength + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDDWave" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64) -> Candle { + Candle::new_unchecked( + f64::midpoint(high, low), + high, + low, + f64::midpoint(high, low), + 1_000.0, + 0, + ) + } + + fn zigzag() -> Vec { + (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0; + c(base + 1.0, base - 1.0) + }) + .collect() + } + + #[test] + fn rejects_zero_strength() { + assert!(matches!(TdDWave::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let td = TdDWave::new(2).unwrap(); + assert_eq!(td.strength(), 2); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDDWave"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn counts_waves_on_swings() { + let mut td = TdDWave::new(2).unwrap(); + let out = td.batch(&zigzag()); + assert!(out.iter().any(Option::is_some)); + assert!(td.is_ready()); + } + + #[test] + fn same_direction_pivots_extend_one_leg() { + // Strictly decreasing lows mean no bar is ever a low pivot, so the + // confirmed pivots are all highs. Consecutive same-direction highs + // exercise the `extends` branch (true at 30 > 20, false at 25 < 30) + // without ever advancing the wave past leg 1. + let mut td = TdDWave::new(1).unwrap(); + let bars = [ + (10.0, 100.0), + (20.0, 99.0), + (12.0, 98.0), + (30.0, 97.0), + (15.0, 96.0), + (25.0, 95.0), + (14.0, 94.0), + (14.0, 93.0), + ]; + let vals: Vec = bars + .iter() + .filter_map(|&(high, low)| td.update(c(high, low))) + .collect(); + assert!(!vals.is_empty()); + assert!(vals.iter().all(|&v| v == 1.0)); + } + + #[test] + fn same_direction_low_pivots_extend_one_leg() { + // Mirror of the high-pivot case: strictly increasing highs mean no bar + // is ever a high pivot, so the confirmed pivots are all lows. The + // `extends` else-branch fires (true at 2 < 5, false at 4 > 2). + let mut td = TdDWave::new(1).unwrap(); + let bars = [ + (100.0, 10.0), + (101.0, 5.0), + (102.0, 8.0), + (103.0, 2.0), + (104.0, 6.0), + (105.0, 4.0), + (106.0, 7.0), + (107.0, 7.0), + ]; + let vals: Vec = bars + .iter() + .filter_map(|&(high, low)| td.update(c(high, low))) + .collect(); + assert!(!vals.is_empty()); + assert!(vals.iter().all(|&v| v == 1.0)); + } + + #[test] + fn wave_stays_in_one_to_eight() { + let mut td = TdDWave::new(2).unwrap(); + for v in td.batch(&zigzag()).into_iter().flatten() { + assert!((1.0..=8.0).contains(&v), "wave out of range: {v}"); + } + } + + #[test] + fn flat_input_never_counts() { + // A perfectly flat series has no distinct swing highs/lows. + let mut td = TdDWave::new(2).unwrap(); + let candles: Vec = (0..40).map(|_| c(100.0, 100.0)).collect(); + assert!(td.batch(&candles).iter().all(Option::is_none)); + } + + #[test] + fn reset_clears_state() { + let mut td = TdDWave::new(2).unwrap(); + td.batch(&zigzag()); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles = zigzag(); + let batch = TdDWave::new(2).unwrap().batch(&candles); + let mut b = TdDWave::new(2).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/td_lines.rs b/crates/wickra-core/src/indicators/td_lines.rs new file mode 100644 index 0000000..d2192ec --- /dev/null +++ b/crates/wickra-core/src/indicators/td_lines.rs @@ -0,0 +1,340 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Lines (TDST — TD Setup Trend Support / Resistance levels). +//! +//! Once a TD Setup completes in either direction, DeMark defines two +//! horizontal trend levels derived from the nine bars of that setup: +//! +//! - **TDST resistance** is the highest high among the nine bars of the +//! most-recently-completed **buy** setup. A break above resistance +//! invalidates the setup's bullish reversal thesis. +//! - **TDST support** is the lowest low among the nine bars of the +//! most-recently-completed **sell** setup. A break below support +//! invalidates the setup's bearish reversal thesis. +//! +//! Until a setup completes in a given direction, the corresponding level +//! is `f64::NAN` (no level defined). Once a level is set it stays at its +//! value until the next completed setup in that direction updates it. +//! +//! This implementation tracks both the buy and sell setup state machines +//! in parallel (sharing the same `lookback` / `target` parameters as +//! [`crate::TdSetup`]) and records the bar extremes during the active +//! streak so the level can be emitted the moment the setup completes. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdLines`]: the latest TDST resistance / support pair. +/// +/// `resistance` is set after a completed buy setup (the highest high of +/// the nine setup bars); `support` is set after a completed sell setup +/// (the lowest low of the nine setup bars). Either field is `f64::NAN` +/// until the first setup in that direction completes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdLinesOutput { + /// Latest TDST resistance, or `NAN` if no buy setup has completed yet. + pub resistance: f64, + /// Latest TDST support, or `NAN` if no sell setup has completed yet. + pub support: f64, +} + +/// TD Lines (TDST) — setup-derived horizontal support / resistance. +/// # Example +/// +/// ``` +/// use wickra_core::{TdLines, Candle, Indicator}; +/// +/// let mut indicator = TdLines::new(4, 9).unwrap(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct TdLines { + lookback: usize, + target: usize, + closes: VecDeque, + buy_count: usize, + sell_count: usize, + /// Highest high observed during the *current* buy-setup run (running + /// extreme, resets when the buy run resets). + buy_run_max_high: f64, + /// Lowest low observed during the *current* sell-setup run. + sell_run_min_low: f64, + resistance: f64, + support: f64, + ready: bool, +} + +impl TdLines { + /// Construct a TD Lines with explicit lookback and target. The + /// canonical DeMark configuration is `lookback = 4`, `target = 9`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is zero. + pub fn new(lookback: usize, target: usize) -> Result { + if lookback == 0 || target == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lookback, + target, + closes: VecDeque::with_capacity(lookback + 1), + buy_count: 0, + sell_count: 0, + buy_run_max_high: f64::NEG_INFINITY, + sell_run_min_low: f64::INFINITY, + resistance: f64::NAN, + support: f64::NAN, + ready: false, + }) + } + + /// DeMark's classic configuration: `lookback = 4`, `target = 9`. + pub fn classic() -> Self { + Self::new(4, 9).expect("classic TD Lines parameters are valid") + } + + /// Configured `(lookback, target)`. + pub const fn params(&self) -> (usize, usize) { + (self.lookback, self.target) + } +} + +impl Indicator for TdLines { + type Input = Candle; + type Output = TdLinesOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.closes.len() > self.lookback { + self.closes.pop_front(); + } + if self.closes.len() < self.lookback { + self.closes.push_back(candle.close); + return None; + } + let reference = *self.closes.front().expect("non-empty after the guard"); + self.closes.push_back(candle.close); + + if candle.close < reference { + // Continue / start a buy-setup run; if the sell run breaks + // here, reset its running extreme. + if self.buy_count == 0 { + self.buy_run_max_high = candle.high; + } else { + self.buy_run_max_high = self.buy_run_max_high.max(candle.high); + } + self.buy_count = (self.buy_count + 1).min(self.target); + self.sell_count = 0; + self.sell_run_min_low = f64::INFINITY; + if self.buy_count == self.target { + self.resistance = self.buy_run_max_high; + } + } else if candle.close > reference { + if self.sell_count == 0 { + self.sell_run_min_low = candle.low; + } else { + self.sell_run_min_low = self.sell_run_min_low.min(candle.low); + } + self.sell_count = (self.sell_count + 1).min(self.target); + self.buy_count = 0; + self.buy_run_max_high = f64::NEG_INFINITY; + if self.sell_count == self.target { + self.support = self.sell_run_min_low; + } + } else { + // Equality breaks both runs. + self.buy_count = 0; + self.sell_count = 0; + self.buy_run_max_high = f64::NEG_INFINITY; + self.sell_run_min_low = f64::INFINITY; + } + + self.ready = true; + Some(TdLinesOutput { + resistance: self.resistance, + support: self.support, + }) + } + + fn reset(&mut self) { + self.closes.clear(); + self.buy_count = 0; + self.sell_count = 0; + self.buy_run_max_high = f64::NEG_INFINITY; + self.sell_run_min_low = f64::INFINITY; + self.resistance = f64::NAN; + self.support = f64::NAN; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.lookback + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDLines" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn uptrend_completes_sell_setup_and_sets_support() { + // Strictly rising series -> sell setup completes at bar index 12 + // (warmup 5 + 8 advances). The lowest low across bars 4..=12 is + // the low at idx 4 since the series is strictly rising. + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut lines = TdLines::classic(); + let out = lines.batch(&candles); + // Before completion, support is NaN; resistance is NaN throughout + // (no buy setup ever completes). + let early = out[5].expect("ready"); + assert!(early.support.is_nan()); + assert!(early.resistance.is_nan()); + // After completion at idx 12, support is the low of bar idx 4 = 4.5. + let after = out[12].expect("ready"); + assert!(after.resistance.is_nan()); + assert_relative_eq!(after.support, 4.5, epsilon = 1e-12); + // Subsequent bars (still increasing, sell setup saturating) keep + // the running extreme at the original low. + let final_out = out[19].expect("ready"); + assert_relative_eq!(final_out.support, 4.5, epsilon = 1e-12); + } + + #[test] + fn downtrend_completes_buy_setup_and_sets_resistance() { + let candles: Vec = (1..=20) + .rev() + .enumerate() + .map(|(i, v)| { + c( + f64::from(v) + 0.5, + f64::from(v) - 0.5, + f64::from(v), + i64::try_from(i).unwrap(), + ) + }) + .collect(); + let mut lines = TdLines::classic(); + let out = lines.batch(&candles); + // Buy setup completes at idx 12. The highest high during the + // buy run is the high of bar idx 4 (since the series is strictly + // decreasing): low/high of bar 4 are computed below. + let after = out[12].expect("ready"); + assert!(after.support.is_nan()); + // The high at idx 4 in the reversed series is value 16 + 0.5. + assert_relative_eq!(after.resistance, 16.5, epsilon = 1e-12); + } + + #[test] + fn flat_series_never_sets_levels() { + // All closes equal -> neither setup advances -> both levels stay NaN. + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut lines = TdLines::classic(); + for v in lines.batch(&candles).into_iter().flatten() { + assert!(v.support.is_nan()); + assert!(v.resistance.is_nan()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdLines::classic(); + let mut b = TdLines::classic(); + let av = a.batch(&candles); + let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(av.len(), bv.len()); + for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() { + assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch"); + if let (Some(a), Some(b)) = (x, y) { + assert_eq!( + a.support.is_nan(), + b.support.is_nan(), + "row {i} support nan flag" + ); + assert_eq!( + a.resistance.is_nan(), + b.resistance.is_nan(), + "row {i} resistance nan flag" + ); + if !a.support.is_nan() { + assert_relative_eq!(a.support, b.support, epsilon = 1e-12); + } + if !a.resistance.is_nan() { + assert_relative_eq!(a.resistance, b.resistance, epsilon = 1e-12); + } + } + } + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(TdLines::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(TdLines::new(4, 0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut lines = TdLines::classic(); + lines.batch(&candles); + assert!(lines.is_ready()); + lines.reset(); + assert!(!lines.is_ready()); + assert_eq!(lines.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let lines = TdLines::classic(); + assert_eq!(lines.params(), (4, 9)); + assert_eq!(lines.warmup_period(), 5); + assert_eq!(lines.name(), "TDLines"); + } +} diff --git a/crates/wickra-core/src/indicators/td_moving_average.rs b/crates/wickra-core/src/indicators/td_moving_average.rs new file mode 100644 index 0000000..81e969f --- /dev/null +++ b/crates/wickra-core/src/indicators/td_moving_average.rs @@ -0,0 +1,229 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Moving Averages — the ST1 (fast) and ST2 (slow) trend ribbon. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdMovingAverage`]: the fast (`st1`) and slow (`st2`) moving-average +/// lines. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdMovingAverageOutput { + /// ST1 — the fast (short) moving average. + pub st1: f64, + /// ST2 — the slow (long) moving average. + pub st2: f64, +} + +/// Tom DeMark **TD Moving Averages** — a two-line trend ribbon (ST1 fast, ST2 +/// slow) computed on the median price, whose relationship defines the trend. +/// +/// ```text +/// price = (high + low) / 2 (median price) +/// st1 = SMA(price, period_st1) (fast / "Sequential Trend 1") +/// st2 = SMA(price, period_st2) (slow / "Sequential Trend 2") +/// ``` +/// +/// DeMark's moving-average pair frames the trend objectively: when `st1` is above +/// `st2` the trend is up, below it down, and the cross marks the change. Using the +/// **median price** rather than the close de-emphasises closing noise. This is a +/// streaming dual-SMA implementation of the ST1/ST2 ribbon; read the lines and +/// their crossover exactly as a fast/slow moving-average system. +/// +/// `period_st1` must be strictly smaller than `period_st2`. The first value lands +/// once the slow average is seeded (`period_st2` inputs). Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TdMovingAverage}; +/// +/// let mut indicator = TdMovingAverage::new(5, 13).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TdMovingAverage { + st1: Sma, + st2: Sma, + period_st1: usize, + period_st2: usize, + last: Option, +} + +impl TdMovingAverage { + /// Construct TD Moving Averages with the given fast and slow periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`, and + /// [`Error::InvalidPeriod`] if `period_st1 >= period_st2`. + pub fn new(period_st1: usize, period_st2: usize) -> Result { + if period_st1 == 0 || period_st2 == 0 { + return Err(Error::PeriodZero); + } + if period_st1 >= period_st2 { + return Err(Error::InvalidPeriod { + message: "TD moving average ST1 period must be strictly less than ST2", + }); + } + Ok(Self { + st1: Sma::new(period_st1)?, + st2: Sma::new(period_st2)?, + period_st1, + period_st2, + last: None, + }) + } + + /// Configured `(period_st1, period_st2)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period_st1, self.period_st2) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for TdMovingAverage { + type Input = Candle; + type Output = TdMovingAverageOutput; + + fn update(&mut self, candle: Candle) -> Option { + let price = candle.median_price(); + let fast = self.st1.update(price); + let slow = self.st2.update(price); + if let (Some(st1), Some(st2)) = (fast, slow) { + let out = TdMovingAverageOutput { st1, st2 }; + self.last = Some(out); + return Some(out); + } + None + } + + fn reset(&mut self) { + self.st1.reset(); + self.st2.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period_st2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TDMovingAverage" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(median: f64) -> Candle { + Candle::new_unchecked(median, median + 1.0, median - 1.0, median, 1_000.0, 0) + } + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + TdMovingAverage::new(0, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdMovingAverage::new(13, 5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + TdMovingAverage::new(5, 5), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let td = TdMovingAverage::new(5, 13).unwrap(); + assert_eq!(td.periods(), (5, 13)); + assert_eq!(td.warmup_period(), 13); + assert_eq!(td.name(), "TDMovingAverage"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut td = TdMovingAverage::new(2, 4).unwrap(); + let candles: Vec = (0..8).map(|i| c(100.0 + f64::from(i))).collect(); + let out = td.batch(&candles); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn fast_leads_slow_in_uptrend() { + let mut td = TdMovingAverage::new(3, 7).unwrap(); + let candles: Vec = (0..40).map(|i| c(100.0 + f64::from(i))).collect(); + let out = td.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(out.st1 > out.st2, "fast MA should lead in an uptrend"); + } + + #[test] + fn fast_below_slow_in_downtrend() { + let mut td = TdMovingAverage::new(3, 7).unwrap(); + let candles: Vec = (0..40).map(|i| c(200.0 - f64::from(i))).collect(); + let out = td.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(out.st1 < out.st2, "fast MA should trail in a downtrend"); + } + + #[test] + fn flat_series_equal_lines() { + let mut td = TdMovingAverage::new(2, 4).unwrap(); + let out = td + .batch(&[c(50.0); 10]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(out.st1, 50.0, epsilon = 1e-9); + assert_relative_eq!(out.st2, 50.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut td = TdMovingAverage::new(2, 4).unwrap(); + td.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::>()); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + assert_eq!(td.update(c(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0)) + .collect(); + let batch = TdMovingAverage::new(5, 13).unwrap().batch(&candles); + let mut b = TdMovingAverage::new(5, 13).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/td_open.rs b/crates/wickra-core/src/indicators/td_open.rs new file mode 100644 index 0000000..a3f6a36 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_open.rs @@ -0,0 +1,187 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Open — open-vs-prior-range gap-reversal signal. +//! +//! TD Open flags bars whose open prints *outside* the prior bar's range +//! but whose subsequent action recovers back inside it — a classic +//! gap-and-fade reversal pattern. +//! +//! - **Buy signal** (`+1.0`) on bar `i` when: +//! 1. `open[i] < low[i - 1]` (gap-down open) +//! 2. `high[i] > low[i - 1]` (high recovers above the prior low) +//! - **Sell signal** (`-1.0`) on bar `i` when: +//! 1. `open[i] > high[i - 1]` (gap-up open) +//! 2. `low[i] < high[i - 1]` (low fades back under the prior high) +//! - Otherwise the output is `0.0`. +//! +//! The one-bar lookback means the indicator emits its first value on the +//! second input candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Open — gap-and-fade reversal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdOpen, Candle, Indicator}; +/// +/// let mut indicator = TdOpen::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdOpen { + prev: Option, + last_value: Option, +} + +impl TdOpen { + /// Construct a new `TdOpen`. + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdOpen { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + return None; + }; + let v = if candle.open < prev.low && candle.high > prev.low { + 1.0 + } else if candle.open > prev.high && candle.low < prev.high { + -1.0 + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDOpen" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(open, high, low, close, 0.0, ts) + } + + #[test] + fn buy_signal_on_gap_down_with_recovery() { + // Prev bar: low=10. Curr open=9 < 10, curr high=11 > 10 -> buy +1. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 11.0, 10.0, 10.5, 0)), None); + assert_eq!(td.update(c(9.0, 11.0, 8.5, 9.5, 1)), Some(1.0)); + } + + #[test] + fn sell_signal_on_gap_up_with_fade() { + // Prev bar: high=12. Curr open=13 > 12, curr low=11 < 12 -> sell -1. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None); + assert_eq!(td.update(c(13.0, 13.5, 11.0, 11.5, 1)), Some(-1.0)); + } + + #[test] + fn no_signal_on_normal_open_within_range() { + // Open within previous range -> neither gap condition fires. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None); + assert_eq!(td.update(c(10.5, 11.5, 9.5, 11.0, 1)), Some(0.0)); + } + + #[test] + fn gap_down_without_recovery_is_zero() { + // Open below prev.low, but high stays below prev.low too -> no signal. + let mut td = TdOpen::new(); + assert_eq!(td.update(c(10.0, 12.0, 10.0, 11.0, 0)), None); + // Curr open=9, curr high=9.5 -> high < prev.low (10) -> no buy. + assert_eq!(td.update(c(9.0, 9.5, 8.5, 9.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i)) + }) + .collect(); + let mut a = TdOpen::new(); + let mut b = TdOpen::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn output_only_in_canonical_set() { + let candles: Vec = (0..120) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i)) + }) + .collect(); + let mut td = TdOpen::new(); + for v in td.batch(&candles).into_iter().flatten() { + assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut td = TdOpen::new(); + td.update(c(10.0, 11.0, 9.0, 10.0, 0)); + td.update(c(10.5, 11.5, 9.5, 10.5, 1)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0, 2)), None); + assert_eq!(td.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdOpen::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDOpen"); + assert_eq!(td.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_pressure.rs b/crates/wickra-core/src/indicators/td_pressure.rs new file mode 100644 index 0000000..c833ce7 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_pressure.rs @@ -0,0 +1,240 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Pressure — volume-weighted buying / selling pressure +//! oscillator. +//! +//! For each bar `i` with strictly positive range: +//! +//! ```text +//! bar_pressure(i) = ((close[i] - open[i]) / (high[i] - low[i])) * volume[i] +//! ``` +//! +//! Bars whose range is zero (`high == low`) contribute zero pressure (the +//! ratio is undefined; DeMark's convention is to treat such bars as neutral). +//! The output is the SMA of bar pressure normalised by the SMA of volume over +//! a configurable `period`, scaled by 100: +//! +//! ```text +//! TD_Pressure = 100 * SMA(bar_pressure, period) / SMA(volume, period) +//! ``` +//! +//! When the windowed volume is zero (a flat zero-volume window) the +//! indicator emits `0`. Positive readings indicate net buying pressure; +//! negative readings indicate net selling pressure. The numerator is bounded +//! by `± volume_per_bar`, so the result is bounded by `±100`. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Pressure volume-weighted pressure oscillator. +#[derive(Debug, Clone)] +pub struct TdPressure { + period: usize, + pressures: VecDeque, + volumes: VecDeque, + last_value: Option, +} + +impl TdPressure { + /// Construct a TD Pressure with the given averaging window. A common + /// default in DeMark's literature is `period = 5`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + pressures: VecDeque::with_capacity(period), + volumes: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// Configured window. + pub const fn period(&self) -> usize { + self.period + } + + /// Latest emitted value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdPressure { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let bar_pressure = if range > 0.0 { + ((candle.close - candle.open) / range) * candle.volume + } else { + 0.0 + }; + + if self.pressures.len() == self.period { + self.pressures.pop_front(); + self.volumes.pop_front(); + } + self.pressures.push_back(bar_pressure); + self.volumes.push_back(candle.volume); + if self.pressures.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_p: f64 = self.pressures.iter().sum::() / n; + let mean_v: f64 = self.volumes.iter().sum::() / n; + let v = if mean_v == 0.0 { + 0.0 + } else { + 100.0 * mean_p / mean_v + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.pressures.clear(); + self.volumes.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDPressure" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new_unchecked(open, high, low, close, volume, ts) + } + + #[test] + fn pure_bullish_candles_yield_full_positive_pressure() { + // Every bar closes at its high (close == high, open == low), so the + // per-bar pressure ratio is +1. Volume cancels in the ratio and the + // indicator must read +100. + let candles: Vec = (0..20) + .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-12); + } + + #[test] + fn pure_bearish_candles_yield_full_negative_pressure() { + let candles: Vec = (0..20) + .map(|i| c(11.0, 11.0, 9.0, 9.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, -100.0, epsilon = 1e-12); + } + + #[test] + fn neutral_doji_close_eq_open_yields_zero() { + let candles: Vec = (0..20) + .map(|i| c(10.0, 11.0, 9.0, 10.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn zero_range_bars_contribute_zero() { + // Mix one zero-range bar with otherwise-bullish bars; the zero-range + // bar must be silently skipped (not produce NaN or inf). + let mut candles = Vec::new(); + for i in 0..5 { + candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))); + } + // Zero-range, zero-volume bar in the middle. + candles.push(c(10.0, 10.0, 10.0, 10.0, 0.0, 5)); + for i in 6..11 { + candles.push(c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))); + } + let mut p = TdPressure::new(5).unwrap(); + for v in p.batch(&candles).into_iter().flatten() { + assert!(v.is_finite(), "non-finite output: {v}"); + assert!((-100.0..=100.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn flat_zero_volume_window_emits_zero() { + let candles: Vec = (0..10) + .map(|i| c(10.0, 11.0, 9.0, 10.5, 0.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + // Every bar has zero volume -> per-bar pressure is zero AND the + // denominator is zero. The indicator must fall back to 0. + let last = p.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, 100.0, i64::from(i)) + }) + .collect(); + let mut a = TdPressure::new(5).unwrap(); + let mut b = TdPressure::new(5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TdPressure::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(9.0, 11.0, 9.0, 11.0, 100.0, i64::from(i))) + .collect(); + let mut p = TdPressure::new(5).unwrap(); + p.batch(&candles); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(candles[0]), None); + assert_eq!(p.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let p = TdPressure::new(5).unwrap(); + assert_eq!(p.period(), 5); + assert_eq!(p.warmup_period(), 5); + assert_eq!(p.name(), "TDPressure"); + } +} diff --git a/crates/wickra-core/src/indicators/td_propulsion.rs b/crates/wickra-core/src/indicators/td_propulsion.rs new file mode 100644 index 0000000..f941adf --- /dev/null +++ b/crates/wickra-core/src/indicators/td_propulsion.rs @@ -0,0 +1,167 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Propulsion — a 2-bar trend-continuation thrust signal. +//! +//! TD Propulsion qualifies a continuation thrust: the bar opens on the trend side +//! of the prior close and then closes beyond the prior bar's extreme, "propelling" +//! the move forward. +//! +//! - **Propulsion up** (`+1.0`): `open >= close[-1]` (opens at or above the prior +//! close) AND `close > high[-1]` (closes above the prior high). +//! - **Propulsion down** (`-1.0`): `open <= close[-1]` AND `close < low[-1]`. +//! - Otherwise the output is `0.0`. +//! +//! The one-bar lookback means the first value lands on the second candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Propulsion — 2-bar trend-continuation thrust detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdPropulsion, Candle, Indicator}; +/// +/// let mut indicator = TdPropulsion::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdPropulsion { + prev: Option, + last_value: Option, +} + +impl TdPropulsion { + /// Construct a new `TdPropulsion`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdPropulsion { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + self.prev = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let v = if candle.open >= prev.close && candle.close > prev.high { + 1.0 + } else if candle.open <= prev.close && candle.close < prev.low { + -1.0 + } else { + 0.0 + }; + self.prev = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDPropulsion" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(open, high, low, close, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let td = TdPropulsion::new(); + assert_eq!(td.warmup_period(), 2); + assert_eq!(td.name(), "TDPropulsion"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn first_bar_seeds_without_signal() { + let mut td = TdPropulsion::new(); + assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0)); + assert!(td.update(c(10.5, 12.0, 10.0, 11.5)).is_some()); + } + + #[test] + fn propulsion_up() { + // prev close 10, high 11. Current open 10.5 >= 10, close 11.5 > 11 -> +1. + let mut td = TdPropulsion::new(); + td.update(c(9.5, 11.0, 9.0, 10.0)); + assert_eq!(td.update(c(10.5, 12.0, 10.0, 11.5)), Some(1.0)); + } + + #[test] + fn propulsion_down() { + // prev close 10, low 9. Current open 9.5 <= 10, close 8.5 < 9 -> -1. + let mut td = TdPropulsion::new(); + td.update(c(10.5, 11.0, 9.0, 10.0)); + assert_eq!(td.update(c(9.5, 10.0, 8.0, 8.5)), Some(-1.0)); + } + + #[test] + fn no_thrust_is_zero() { + let mut td = TdPropulsion::new(); + td.update(c(9.5, 11.0, 9.0, 10.0)); + // close 10.5 not above prior high 11 -> 0. + assert_eq!(td.update(c(10.5, 10.8, 10.0, 10.5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut td = TdPropulsion::new(); + td.update(c(9.5, 11.0, 9.0, 10.0)); + td.update(c(10.5, 12.0, 10.0, 11.5)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(9.5, 11.0, 9.0, 10.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0; + c(b, b + 1.0, b - 1.0, b + 0.3) + }) + .collect(); + let batch = TdPropulsion::new().batch(&candles); + let mut b = TdPropulsion::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/td_range_projection.rs b/crates/wickra-core/src/indicators/td_range_projection.rs new file mode 100644 index 0000000..66fa2ef --- /dev/null +++ b/crates/wickra-core/src/indicators/td_range_projection.rs @@ -0,0 +1,169 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Range Projection — next-bar high/low projection from the +//! current bar's open/high/low/close (DeMark's "X-projection" pivot). +//! +//! After each bar closes, DeMark proposes a projected high and low for the +//! *next* bar derived from a pivot weighted by the relationship between +//! the close and the open: +//! +//! ```text +//! if close < open: pivot_sum = high + 2*low + close +//! if close > open: pivot_sum = 2*high + low + close +//! if close == open: pivot_sum = high + low + 2*close +//! +//! projected_high = pivot_sum / 2 - low +//! projected_low = pivot_sum / 2 - high +//! ``` +//! +//! The indicator is stateless beyond the current bar — every bar's input +//! deterministically produces a projection — but it is wrapped in the same +//! `Indicator` state-machine API as the rest of Wickra so it composes with +//! the streaming/batch infrastructure. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdRangeProjection`]: the projected high and low for the +/// next bar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdRangeProjectionOutput { + /// Projected high for the next bar. + pub high: f64, + /// Projected low for the next bar. + pub low: f64, +} + +/// TD Range Projection — next-bar high/low pivot. +#[derive(Debug, Clone, Default)] +pub struct TdRangeProjection { + last_value: Option, +} + +impl TdRangeProjection { + /// Construct a new `TdRangeProjection`. + pub fn new() -> Self { + Self::default() + } + + /// Latest projection if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdRangeProjection { + type Input = Candle; + type Output = TdRangeProjectionOutput; + + fn update(&mut self, candle: Candle) -> Option { + let pivot_sum = if candle.close < candle.open { + candle.high + 2.0 * candle.low + candle.close + } else if candle.close > candle.open { + 2.0 * candle.high + candle.low + candle.close + } else { + candle.high + candle.low + 2.0 * candle.close + }; + let half = pivot_sum / 2.0; + let out = TdRangeProjectionOutput { + high: half - candle.low, + low: half - candle.high, + }; + self.last_value = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDRangeProjection" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(open, high, low, close, 0.0, ts) + } + + #[test] + fn bullish_bar_close_above_open_uses_double_high_pivot() { + // open=10, high=12, low=9, close=11 -> close > open + // pivot_sum = 2*12 + 9 + 11 = 44; half = 22. + // projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10. + let mut p = TdRangeProjection::new(); + let v = p.update(c(10.0, 12.0, 9.0, 11.0, 0)).unwrap(); + assert_relative_eq!(v.high, 13.0, epsilon = 1e-12); + assert_relative_eq!(v.low, 10.0, epsilon = 1e-12); + } + + #[test] + fn bearish_bar_close_below_open_uses_double_low_pivot() { + // open=11, high=12, low=9, close=10 -> close < open + // pivot_sum = 12 + 2*9 + 10 = 40; half = 20. + // projHigh = 20 - 9 = 11; projLow = 20 - 12 = 8. + let mut p = TdRangeProjection::new(); + let v = p.update(c(11.0, 12.0, 9.0, 10.0, 0)).unwrap(); + assert_relative_eq!(v.high, 11.0, epsilon = 1e-12); + assert_relative_eq!(v.low, 8.0, epsilon = 1e-12); + } + + #[test] + fn doji_close_equals_open_uses_double_close_pivot() { + // open=close=10, high=12, low=9 -> doji branch. + // pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5. + // projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5. + let mut p = TdRangeProjection::new(); + let v = p.update(c(10.0, 12.0, 9.0, 10.0, 0)).unwrap(); + assert_relative_eq!(v.high, 11.5, epsilon = 1e-12); + assert_relative_eq!(v.low, 8.5, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i)) + }) + .collect(); + let mut a = TdRangeProjection::new(); + let mut b = TdRangeProjection::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut p = TdRangeProjection::new(); + p.update(c(10.0, 12.0, 9.0, 11.0, 0)); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let p = TdRangeProjection::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "TDRangeProjection"); + assert_eq!(p.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_rei.rs b/crates/wickra-core/src/indicators/td_rei.rs new file mode 100644 index 0000000..0a22440 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_rei.rs @@ -0,0 +1,286 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark Range Expansion Index (TD REI). +//! +//! The TD REI is a `period`-bar bounded oscillator in `[-100, 100]` that +//! detects exhaustion via comparisons of the current bar's range to the bars +//! two and five-or-six bars earlier. The canonical TD REI uses a `period` of +//! 5. +//! +//! Per bar `i` (requires history through `i - 7`): +//! +//! ```text +//! cond1 = (high[i] >= low[i-5]) OR (high[i] >= low[i-6]) +//! cond2 = (low[i] <= high[i-5]) OR (low[i] <= high[i-6]) +//! +//! if cond1 AND cond2: +//! numerator = (high[i] - high[i-2]) + (low[i] - low[i-2]) +//! else: +//! numerator = 0 +//! +//! denominator = |high[i] - high[i-2]| + |low[i] - low[i-2]| +//! +//! REI(i) = 100 * sum(numerator, period) / sum(denominator, period) +//! ``` +//! +//! When the windowed denominator is zero the indicator falls back to `0` (the +//! neutral midpoint). Readings above `+60` are typically considered +//! overbought; below `-60` oversold. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Range Expansion Index oscillator. +#[derive(Debug, Clone)] +pub struct TdRei { + period: usize, + // Need at least the last 7 candles for the lookback comparisons; we keep a + // rolling window long enough for the rule plus enough numerator/ + // denominator history. + candles: VecDeque, + numerators: VecDeque, + denominators: VecDeque, + last_value: Option, +} + +/// Minimum history required to evaluate the TD REI per-bar rule. The +/// numerator and denominator both reference `bar[i-2]` and the long +/// conditional references `bar[i-5]` and `bar[i-6]`, so we need the candle +/// six bars before the current one to be available. +const LOOKBACK: usize = 7; + +impl TdRei { + /// Construct a TD REI with the given averaging window. The classic + /// DeMark configuration is `period = 5`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(LOOKBACK), + numerators: VecDeque::with_capacity(period), + denominators: VecDeque::with_capacity(period), + last_value: None, + }) + } + + /// DeMark's classic configuration: `period = 5`. + pub fn classic() -> Self { + Self::new(5).expect("classic TD REI parameters are valid") + } + + /// Configured window. + pub const fn period(&self) -> usize { + self.period + } + + /// Latest emitted value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdRei { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // Maintain a rolling window of the last `LOOKBACK` candles (front = + // 6 bars ago when full). + if self.candles.len() == LOOKBACK { + self.candles.pop_front(); + } + if self.candles.len() < LOOKBACK - 1 { + // Need 6 previous candles before we can evaluate the rule on the + // current one. + self.candles.push_back(candle); + return None; + } + // candles currently holds the 6 most recent bars (in order); the new + // candle is the 7th. After the rule fires we push it onto the back. + // Indexing convention: index 0 is the oldest in the window (i.e. 6 + // bars ago); index 5 is the bar just before the current one. + // For the rule we need: + // bar[i-2] -> candles[len-2] (here len == 6) + // bar[i-5] -> candles[1] + // bar[i-6] -> candles[0] + let prev2 = self.candles[self.candles.len() - 2]; + let prev5 = self.candles[1]; + let prev6 = self.candles[0]; + + let cond1 = candle.high >= prev5.low || candle.high >= prev6.low; + let cond2 = candle.low <= prev5.high || candle.low <= prev6.high; + + let raw_num = (candle.high - prev2.high) + (candle.low - prev2.low); + let denominator = (candle.high - prev2.high).abs() + (candle.low - prev2.low).abs(); + let numerator = if cond1 && cond2 { raw_num } else { 0.0 }; + + if self.numerators.len() == self.period { + self.numerators.pop_front(); + self.denominators.pop_front(); + } + self.numerators.push_back(numerator); + self.denominators.push_back(denominator); + self.candles.push_back(candle); + + if self.numerators.len() < self.period { + return None; + } + let sum_num: f64 = self.numerators.iter().sum(); + let sum_den: f64 = self.denominators.iter().sum(); + let v = if sum_den == 0.0 { + 0.0 + } else { + 100.0 * sum_num / sum_den + }; + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.candles.clear(); + self.numerators.clear(); + self.denominators.clear(); + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // 6 bars to fill the lookback plus `period` updates to fill the + // numerator / denominator buffers. + (LOOKBACK - 1) + self.period + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDREI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn flat_market_yields_neutral_zero() { + // All highs and lows equal -> denominator is identically zero, so the + // indicator emits its neutral fallback of 0. + let candles: Vec = (0..40).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut rei = TdRei::classic(); + let out = rei.batch(&candles); + for v in out.iter().skip(rei.warmup_period()).copied().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_pegs_indicator_at_100() { + // Every bar makes strictly higher highs and lows. Both range-overlap + // conditions hold (current high > all previous lows; current low > all + // previous highs is false, but we need current low <= some prev + // high). For a slow steady uptrend cond2 still holds because + // current low < prev5/prev6 highs as long as the slope is moderate. + // With slope 1 and spread 2 (low to high), cond2 fails after ~3 bars. + // Use a smaller slope so cond2 holds throughout. + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + f64::from(i) * 0.1; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + let last = rei.batch(&candles).into_iter().flatten().last().unwrap(); + // Every numerator is positive (price moving up) and equals the + // denominator in magnitude (no sign flips), so REI saturates at 100. + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn pure_downtrend_pegs_indicator_at_minus_100() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 - f64::from(i) * 0.1; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + let last = rei.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, -100.0, epsilon = 1e-9); + } + + #[test] + fn stays_in_minus_100_to_100() { + let candles: Vec = (0..200) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + for v in rei.batch(&candles).into_iter().flatten() { + assert!((-100.0..=100.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdRei::classic(); + let mut b = TdRei::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TdRei::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let m = 100.0 + f64::from(i) * 0.1; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut rei = TdRei::classic(); + rei.batch(&candles); + assert!(rei.is_ready()); + rei.reset(); + assert!(!rei.is_ready()); + assert_eq!(rei.update(candles[0]), None); + assert_eq!(rei.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let rei = TdRei::classic(); + assert_eq!(rei.period(), 5); + assert_eq!(rei.warmup_period(), 6 + 5); + assert_eq!(rei.name(), "TDREI"); + } +} diff --git a/crates/wickra-core/src/indicators/td_risk_level.rs b/crates/wickra-core/src/indicators/td_risk_level.rs new file mode 100644 index 0000000..0403e04 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_risk_level.rs @@ -0,0 +1,331 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Risk Level — protective-stop levels derived from setup +//! extremes. +//! +//! DeMark proposes a quantitative stop level for trades taken on the back +//! of a completed setup. The risk level is computed from the bar that +//! made the most-extreme price during the setup run and that bar's true +//! range: +//! +//! - **Buy risk** (the protective stop for a long position taken on a +//! completed buy setup) is `low_extreme_bar.low - true_range_extreme_bar`. +//! `low_extreme_bar` is the bar with the lowest low among the setup's +//! bars; `true_range_extreme_bar` is its true range +//! (`max(high - low, |high - prev_close|, |low - prev_close|)`). +//! - **Sell risk** (the protective stop for a short position taken on a +//! completed sell setup) is `high_extreme_bar.high + +//! true_range_extreme_bar`. +//! +//! The level is set the moment a setup completes and stays at that value +//! until the next setup in that direction completes. Either field is +//! `f64::NAN` until the first setup in that direction completes. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`TdRiskLevel`]: the latest buy- and sell-side protective +/// stop levels derived from the most-recently-completed setup in each +/// direction. Either field is `f64::NAN` until the first setup in that +/// direction completes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdRiskLevelOutput { + /// Protective-stop level for a long position taken on a completed + /// buy setup. `NAN` until the first buy setup completes. + pub buy_risk: f64, + /// Protective-stop level for a short position taken on a completed + /// sell setup. `NAN` until the first sell setup completes. + pub sell_risk: f64, +} + +/// Track the bar making the running extreme of the current run, together +/// with its true range. +#[derive(Debug, Clone, Copy)] +struct ExtremeBar { + price: f64, + true_range: f64, +} + +/// TD Risk Level — setup-derived protective-stop levels. +/// # Example +/// +/// ``` +/// use wickra_core::{TdRiskLevel, Candle, Indicator}; +/// +/// let mut indicator = TdRiskLevel::new(4, 9).unwrap(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct TdRiskLevel { + lookback: usize, + target: usize, + closes: VecDeque, + prev: Option, + buy_count: usize, + sell_count: usize, + /// Extreme (lowest low) bar of the active buy-setup run. + buy_extreme: Option, + /// Extreme (highest high) bar of the active sell-setup run. + sell_extreme: Option, + buy_risk: f64, + sell_risk: f64, + ready: bool, +} + +fn true_range(candle: Candle, prev: Option) -> f64 { + let hl = candle.high - candle.low; + if let Some(p) = prev { + let hc = (candle.high - p.close).abs(); + let lc = (candle.low - p.close).abs(); + hl.max(hc).max(lc) + } else { + hl + } +} + +impl TdRiskLevel { + /// Construct a TD Risk Level with explicit lookback and target. The + /// canonical DeMark configuration is `lookback = 4`, `target = 9`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is zero. + pub fn new(lookback: usize, target: usize) -> Result { + if lookback == 0 || target == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lookback, + target, + closes: VecDeque::with_capacity(lookback + 1), + prev: None, + buy_count: 0, + sell_count: 0, + buy_extreme: None, + sell_extreme: None, + buy_risk: f64::NAN, + sell_risk: f64::NAN, + ready: false, + }) + } + + /// DeMark's classic configuration: `lookback = 4`, `target = 9`. + pub fn classic() -> Self { + Self::new(4, 9).expect("classic TD Risk Level parameters are valid") + } + + /// Configured `(lookback, target)`. + pub const fn params(&self) -> (usize, usize) { + (self.lookback, self.target) + } +} + +impl Indicator for TdRiskLevel { + type Input = Candle; + type Output = TdRiskLevelOutput; + + fn update(&mut self, candle: Candle) -> Option { + let tr = true_range(candle, self.prev); + if self.closes.len() > self.lookback { + self.closes.pop_front(); + } + if self.closes.len() < self.lookback { + self.closes.push_back(candle.close); + self.prev = Some(candle); + return None; + } + let reference = *self.closes.front().expect("non-empty after the guard"); + self.closes.push_back(candle.close); + + if candle.close < reference { + // Buy setup run. + let new_extreme = ExtremeBar { + price: candle.low, + true_range: tr, + }; + self.buy_extreme = Some(match self.buy_extreme { + Some(e) if e.price <= candle.low => e, + _ => new_extreme, + }); + self.buy_count = (self.buy_count + 1).min(self.target); + self.sell_count = 0; + self.sell_extreme = None; + if self.buy_count == self.target { + let e = self.buy_extreme.expect("set above when buy_count > 0"); + self.buy_risk = e.price - e.true_range; + } + } else if candle.close > reference { + // Sell setup run. + let new_extreme = ExtremeBar { + price: candle.high, + true_range: tr, + }; + self.sell_extreme = Some(match self.sell_extreme { + Some(e) if e.price >= candle.high => e, + _ => new_extreme, + }); + self.sell_count = (self.sell_count + 1).min(self.target); + self.buy_count = 0; + self.buy_extreme = None; + if self.sell_count == self.target { + let e = self.sell_extreme.expect("set above when sell_count > 0"); + self.sell_risk = e.price + e.true_range; + } + } else { + self.buy_count = 0; + self.sell_count = 0; + self.buy_extreme = None; + self.sell_extreme = None; + } + + self.prev = Some(candle); + self.ready = true; + Some(TdRiskLevelOutput { + buy_risk: self.buy_risk, + sell_risk: self.sell_risk, + }) + } + + fn reset(&mut self) { + self.closes.clear(); + self.prev = None; + self.buy_count = 0; + self.sell_count = 0; + self.buy_extreme = None; + self.sell_extreme = None; + self.buy_risk = f64::NAN; + self.sell_risk = f64::NAN; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.lookback + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDRiskLevel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn uptrend_sets_sell_risk_above_highest_high_of_setup() { + // Strictly rising closes -> sell setup completes at idx 12. + // The sell run starts at idx 4 (first bar that has close > + // close[i-4]). The highest high during the run is the bar at + // idx 12 (since the series is strictly increasing). + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdRiskLevel::classic(); + let out = td.batch(&candles); + let after = out[12].expect("ready"); + assert!(after.buy_risk.is_nan()); + // High at idx 12 is 13.5; the true range there is 1.0 (1.0 vs + // |13.5-12|=1.5 vs |12.5-12|=0.5 -> max=1.5). So sell_risk = + // 13.5 + 1.5 = 15.0. + assert_relative_eq!(after.sell_risk, 15.0, epsilon = 1e-12); + } + + #[test] + fn flat_series_never_sets_levels() { + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut td = TdRiskLevel::classic(); + for v in td.batch(&candles).into_iter().flatten() { + assert!(v.buy_risk.is_nan()); + assert!(v.sell_risk.is_nan()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdRiskLevel::classic(); + let mut b = TdRiskLevel::classic(); + let av = a.batch(&candles); + let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(av.len(), bv.len()); + for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() { + assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch"); + if let (Some(a), Some(b)) = (x, y) { + assert_eq!(a.buy_risk.is_nan(), b.buy_risk.is_nan()); + assert_eq!(a.sell_risk.is_nan(), b.sell_risk.is_nan()); + if !a.buy_risk.is_nan() { + assert_relative_eq!(a.buy_risk, b.buy_risk, epsilon = 1e-12); + } + if !a.sell_risk.is_nan() { + assert_relative_eq!(a.sell_risk, b.sell_risk, epsilon = 1e-12); + } + } + } + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!(TdRiskLevel::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(TdRiskLevel::new(4, 0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdRiskLevel::classic(); + td.batch(&candles); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdRiskLevel::classic(); + assert_eq!(td.params(), (4, 9)); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDRiskLevel"); + } +} diff --git a/crates/wickra-core/src/indicators/td_sequential.rs b/crates/wickra-core/src/indicators/td_sequential.rs new file mode 100644 index 0000000..9016190 --- /dev/null +++ b/crates/wickra-core/src/indicators/td_sequential.rs @@ -0,0 +1,430 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Sequential (Setup + Countdown). +//! +//! TD Sequential is DeMark's flagship two-phase exhaustion pattern: +//! +//! 1. **Setup phase** — 9 consecutive bars whose close is less-than (buy +//! setup) or greater-than (sell setup) the close 4 bars earlier. The +//! setup *completes* on the 9th bar. +//! 2. **Countdown phase** — after a completed setup, count up to 13 bars +//! that satisfy the countdown comparison (buy countdown: `close <= low` +//! two bars earlier; sell countdown: `close >= high` two bars earlier). +//! Countdown bars do not need to be consecutive. +//! +//! A completed countdown (13) signals exhaustion in the direction of the +//! original setup and is the canonical DeMark reversal signal. +//! +//! Output struct `TdSequentialOutput`: +//! +//! - `setup`: signed setup count (positive for buy setup, negative for sell +//! setup, 0 when no streak is active; capped at ±9). +//! - `countdown`: signed countdown count (positive for buy countdown, negative +//! for sell countdown, 0 when no countdown is active; capped at ±13). +//! - `direction`: `+1.0` if a buy countdown is currently active, `-1.0` if a +//! sell countdown is active, `0.0` otherwise. The countdown direction is +//! set when the originating setup completes and stays valid until the +//! countdown finishes or is invalidated by an opposite-direction setup. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Direction of an active TD Sequential countdown phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + None, + Buy, + Sell, +} + +/// Output of [`TdSequential`]: setup count, countdown count, and active +/// countdown direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TdSequentialOutput { + /// Signed setup count: +N for an active buy setup of length `N`, −N for + /// a sell setup of length `N`, 0 if neither streak is active. Capped at + /// ±9 (the canonical setup target). + pub setup: f64, + /// Signed countdown count: +N for an active buy countdown of length `N`, + /// −N for a sell countdown of length `N`, 0 if no countdown is active. + /// Capped at ±13. + pub countdown: f64, + /// Direction of the active countdown: `+1.0` for buy, `−1.0` for sell, + /// `0.0` if no countdown is currently active. + pub direction: f64, +} + +/// TD Sequential state machine: combined Setup (1-9) + Countdown (1-13). +/// # Example +/// +/// ``` +/// use wickra_core::{TdSequential, Candle, Indicator}; +/// +/// let mut indicator = TdSequential::new(4, 9, 2, 13).unwrap(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct TdSequential { + // Rolling window of recent candles. We need up to 5 closes back (for the + // setup rule which compares close[i] vs close[i-4]) and the high/low from + // 2 bars ago (for the countdown rule). + candles: VecDeque, + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + buy_setup: usize, + sell_setup: usize, + buy_countdown: usize, + sell_countdown: usize, + countdown_dir: Direction, + ready: bool, +} + +impl TdSequential { + /// Construct a TD Sequential with explicit lookbacks and targets. The + /// canonical DeMark configuration is `setup_lookback = 4`, `setup_target = + /// 9`, `countdown_lookback = 2`, `countdown_target = 13`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any argument is zero. + pub fn new( + setup_lookback: usize, + setup_target: usize, + countdown_lookback: usize, + countdown_target: usize, + ) -> Result { + if setup_lookback == 0 + || setup_target == 0 + || countdown_lookback == 0 + || countdown_target == 0 + { + return Err(Error::PeriodZero); + } + // Need to keep enough candles for both rules: setup uses close[-N]; + // countdown uses high/low[-M]. Reserve `max(N, M) + 1` slots. + let cap = setup_lookback.max(countdown_lookback) + 1; + Ok(Self { + candles: VecDeque::with_capacity(cap), + setup_lookback, + setup_target, + countdown_lookback, + countdown_target, + buy_setup: 0, + sell_setup: 0, + buy_countdown: 0, + sell_countdown: 0, + countdown_dir: Direction::None, + ready: false, + }) + } + + /// DeMark's classic configuration: setup `lookback = 4, target = 9`, + /// countdown `lookback = 2, target = 13`. + pub fn classic() -> Self { + Self::new(4, 9, 2, 13).expect("classic TD Sequential parameters are valid") + } + + /// Configured `(setup_lookback, setup_target, countdown_lookback, + /// countdown_target)`. + pub const fn params(&self) -> (usize, usize, usize, usize) { + ( + self.setup_lookback, + self.setup_target, + self.countdown_lookback, + self.countdown_target, + ) + } +} + +impl Indicator for TdSequential { + type Input = Candle; + type Output = TdSequentialOutput; + + fn update(&mut self, candle: Candle) -> Option { + let cap = self.setup_lookback.max(self.countdown_lookback) + 1; + if self.candles.len() == cap { + self.candles.pop_front(); + } + // The required minimum history is `max(setup_lookback, + // countdown_lookback)` previous bars. Once we have that many, we can + // evaluate both rules. + let need = self.setup_lookback.max(self.countdown_lookback); + if self.candles.len() < need { + self.candles.push_back(candle); + return None; + } + + // --- Setup rule: compare to close[setup_lookback bars ago] --- + // After `need` candles are buffered, the candle at offset `need - L` + // from the front is the one `L` bars before the new candle (0-based + // count: `front()` is `need` bars ago). + let setup_ref_idx = need - self.setup_lookback; + let setup_ref_close = self.candles[setup_ref_idx].close; + + if candle.close < setup_ref_close { + self.buy_setup = (self.buy_setup + 1).min(self.setup_target); + self.sell_setup = 0; + } else if candle.close > setup_ref_close { + self.sell_setup = (self.sell_setup + 1).min(self.setup_target); + self.buy_setup = 0; + } else { + self.buy_setup = 0; + self.sell_setup = 0; + } + + // --- Countdown activation: when a setup completes, arm the countdown + // in the same direction; an opposite-direction setup invalidates any + // active countdown. + if self.buy_setup == self.setup_target { + if self.countdown_dir != Direction::Buy { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.countdown_dir = Direction::Buy; + } else if self.sell_setup == self.setup_target { + if self.countdown_dir != Direction::Sell { + self.buy_countdown = 0; + self.sell_countdown = 0; + } + self.countdown_dir = Direction::Sell; + } + + // --- Countdown rule: compare close to high/low `countdown_lookback` + // bars ago. Only the active direction advances. Once a countdown + // reaches `countdown_target`, the strict `< countdown_target` guard + // keeps it pinned so the caller can detect the "13" signal on this + // bar and any subsequent bar until a new setup arms a fresh run. + let cd_ref_idx = need - self.countdown_lookback; + let cd_ref = &self.candles[cd_ref_idx]; + match self.countdown_dir { + Direction::Buy => { + if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target { + self.buy_countdown += 1; + } + } + Direction::Sell => { + if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target { + self.sell_countdown += 1; + } + } + Direction::None => {} + } + + self.candles.push_back(candle); + self.ready = true; + + let setup = if self.buy_setup > 0 { + self.buy_setup as f64 + } else if self.sell_setup > 0 { + -(self.sell_setup as f64) + } else { + 0.0 + }; + let (countdown, direction) = match self.countdown_dir { + Direction::Buy => (self.buy_countdown as f64, 1.0), + Direction::Sell => (-(self.sell_countdown as f64), -1.0), + Direction::None => (0.0, 0.0), + }; + + Some(TdSequentialOutput { + setup, + countdown, + direction, + }) + } + + fn reset(&mut self) { + self.candles.clear(); + self.buy_setup = 0; + self.sell_setup = 0; + self.buy_countdown = 0; + self.sell_countdown = 0; + self.countdown_dir = Direction::None; + self.ready = false; + } + + fn warmup_period(&self) -> usize { + self.setup_lookback.max(self.countdown_lookback) + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "TDSequential" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, high, low, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_completes_sell_setup_then_progresses_countdown() { + // Strictly increasing closes -> sell setup increments every bar past + // warmup, reaching -9 by index 12 (warmup is 4 + 1). After that, + // every bar continues to make a higher close, so each subsequent bar + // also makes a higher close than the high 2 bars ago — the sell + // countdown increments on each bar after activation. + let candles: Vec = (1..=40) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdSequential::classic(); + let out = td.batch(&candles); + + // Warmup: indices 0..3 yield None (need=4 prior closes). + for v in out.iter().take(4) { + assert!(v.is_none()); + } + // After index 12, setup reaches -9 (completed). From the next bar on, + // countdown begins to increment. + let at_12 = out[12].expect("setup ready"); + assert_eq!(at_12.setup, -9.0); + assert_eq!(at_12.direction, -1.0); // countdown direction armed + + // Each subsequent bar makes close > high[i-2], so the sell countdown + // advances by one per bar; by some later index it caps at -13. + let later = out[30].expect("ready"); + assert_eq!(later.direction, -1.0); + assert_eq!(later.countdown, -13.0); + } + + #[test] + fn pure_downtrend_completes_buy_setup_then_progresses_countdown() { + // Strictly decreasing closes -> buy setup increments every bar past + // warmup, reaching 9 by index 12. After activation, every subsequent + // bar satisfies close <= low[i-2], so the buy countdown advances by + // one per bar and pins at +13. + let candles: Vec = (1..=40) + .rev() + .enumerate() + .map(|(k, i)| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::try_from(k).unwrap(), + ) + }) + .collect(); + let mut td = TdSequential::classic(); + let out = td.batch(&candles); + + // Warmup: indices 0..3 yield None. + for v in out.iter().take(4) { + assert!(v.is_none()); + } + let at_12 = out[12].expect("setup ready"); + assert_eq!(at_12.setup, 9.0); + assert_eq!(at_12.direction, 1.0); // buy direction armed + + // By idx 30 the buy countdown has saturated at +13. + let later = out[30].expect("ready"); + assert_eq!(later.direction, 1.0); + assert_eq!(later.countdown, 13.0); + } + + #[test] + fn flat_series_emits_zero_setup_and_no_countdown() { + // All closes equal -> never completes any setup; countdown never + // activates; setup, countdown, direction all stay at 0. + let candles: Vec = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect(); + let mut td = TdSequential::classic(); + let out = td.batch(&candles); + for v in out.iter().skip(5) { + let o = v.expect("ready post-warmup"); + assert_eq!(o.setup, 0.0); + assert_eq!(o.countdown, 0.0); + assert_eq!(o.direction, 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m, i64::from(i)) + }) + .collect(); + let mut a = TdSequential::classic(); + let mut b = TdSequential::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + TdSequential::new(0, 9, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdSequential::new(4, 0, 2, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdSequential::new(4, 9, 0, 13), + Err(Error::PeriodZero) + )); + assert!(matches!( + TdSequential::new(4, 9, 2, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20) + .map(|i| { + c( + f64::from(i) + 0.5, + f64::from(i) - 0.5, + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut td = TdSequential::classic(); + td.batch(&candles); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(candles[0]), None); + } + + #[test] + fn accessors_and_metadata() { + let td = TdSequential::classic(); + assert_eq!(td.params(), (4, 9, 2, 13)); + assert_eq!(td.warmup_period(), 5); + assert_eq!(td.name(), "TDSequential"); + } +} diff --git a/crates/wickra-core/src/indicators/td_setup.rs b/crates/wickra-core/src/indicators/td_setup.rs new file mode 100644 index 0000000..40da33d --- /dev/null +++ b/crates/wickra-core/src/indicators/td_setup.rs @@ -0,0 +1,277 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Setup (9-bar buy / sell setup). +//! +//! The TD Setup is the first half of DeMark's TD Sequential. It counts how many +//! consecutive bars satisfy a fixed price-comparison rule relative to the close +//! `lookback` bars earlier (the canonical lookback is 4 — i.e. compare `close[i]` +//! to `close[i-4]`). +//! +//! - A **buy setup** advances by one for each bar whose close is *less than* the +//! close `lookback` bars earlier. The streak resets to zero as soon as the +//! condition fails. A "completed" buy setup is a streak of 9 (DeMark's +//! default `target`). +//! - A **sell setup** advances symmetrically when the close is *greater than* +//! the close `lookback` bars earlier. +//! +//! Only one direction can be active on a given bar: the same bar cannot satisfy +//! both `close < close[-4]` and `close > close[-4]`. If neither condition +//! holds (equality with the lookback close) both streaks reset. +//! +//! This indicator emits a signed count: positive values mean the buy-setup +//! streak is active, negative values mean the sell-setup streak is active, +//! and `0` means neither streak is active on the current bar. The magnitude is +//! the current run length, capped at `target` once the setup completes — the +//! caller can detect "perfected" setups by waiting for `value.abs() == +//! target`. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Setup state machine: counts consecutive bars meeting DeMark's setup +/// comparison rule against the close `lookback` bars earlier. +/// # Example +/// +/// ``` +/// use wickra_core::{TdSetup, Candle, Indicator}; +/// +/// let mut indicator = TdSetup::new(4, 9).unwrap(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone)] +pub struct TdSetup { + lookback: usize, + target: usize, + closes: VecDeque, + buy_count: usize, + sell_count: usize, + last_value: Option, +} + +impl TdSetup { + /// Construct a TD Setup with an explicit lookback and target count. + /// + /// The classic DeMark configuration is `lookback = 4` and `target = 9`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either argument is zero. + pub fn new(lookback: usize, target: usize) -> Result { + if lookback == 0 || target == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lookback, + target, + closes: VecDeque::with_capacity(lookback + 1), + buy_count: 0, + sell_count: 0, + last_value: None, + }) + } + + /// DeMark's classic configuration: `lookback = 4`, `target = 9`. + pub fn classic() -> Self { + Self::new(4, 9).expect("classic TD Setup parameters are valid") + } + + /// Configured `(lookback, target)`. + pub const fn params(&self) -> (usize, usize) { + (self.lookback, self.target) + } + + /// Current signed setup value if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdSetup { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // Maintain a rolling window of the last `lookback + 1` closes so the + // oldest entry (front) is exactly the close `lookback` bars ago. + if self.closes.len() > self.lookback { + self.closes.pop_front(); + } + if self.closes.len() < self.lookback { + self.closes.push_back(candle.close); + return None; + } + // We now have exactly `lookback` historical closes buffered; the oldest + // is the comparison reference. + let reference = *self.closes.front().expect("non-empty after the guard"); + self.closes.push_back(candle.close); + + if candle.close < reference { + self.buy_count = (self.buy_count + 1).min(self.target); + self.sell_count = 0; + let v = self.buy_count as f64; + self.last_value = Some(v); + Some(v) + } else if candle.close > reference { + self.sell_count = (self.sell_count + 1).min(self.target); + self.buy_count = 0; + let v = -(self.sell_count as f64); + self.last_value = Some(v); + Some(v) + } else { + // Equality breaks both streaks; the bar emits zero. + self.buy_count = 0; + self.sell_count = 0; + self.last_value = Some(0.0); + Some(0.0) + } + } + + fn reset(&mut self) { + self.closes.clear(); + self.buy_count = 0; + self.sell_count = 0; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + self.lookback + 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDSetup" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new_unchecked(close, close, close, close, 0.0, ts) + } + + #[test] + fn pure_uptrend_reaches_sell_setup_9() { + // Every close is strictly greater than four bars ago, so the sell + // streak advances by one per bar from the moment lookback is filled. + let candles: Vec = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect(); + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + // Indices 0..4 are warmup. Index 4 is the first bar with a reference. + // Sell-setup advances each bar: -1 at idx 4, -2 at idx 5, …, -9 at + // idx 12; from there it caps at -9 because target is 9. + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert_eq!(out[4], Some(-1.0)); + assert_eq!(out[5], Some(-2.0)); + assert_eq!(out[12], Some(-9.0)); + assert_eq!(out[13], Some(-9.0)); + assert_eq!(out[19], Some(-9.0)); + } + + #[test] + fn pure_downtrend_reaches_buy_setup_9() { + let candles: Vec = (1..=20) + .rev() + .enumerate() + .map(|(i, v)| c(f64::from(v), i64::try_from(i).unwrap())) + .collect(); + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + // Buy streak should mirror the sell case: +1 at idx 4, capping at +9. + assert_eq!(out[4], Some(1.0)); + assert_eq!(out[12], Some(9.0)); + assert_eq!(out[19], Some(9.0)); + } + + #[test] + fn flat_series_emits_zero_after_warmup() { + // Every close equals the reference close (lookback bars earlier), so + // neither streak ever advances; the indicator emits 0 every bar. + let candles: Vec = (0..20).map(|i| c(42.0, i)).collect(); + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + for v in out.iter().skip(4) { + assert_eq!(*v, Some(0.0)); + } + } + + #[test] + fn streak_resets_on_direction_flip() { + // First 4 closes are warmup. Then 4 strictly-lower closes -> buy + // streak 1..=4. The next close is higher than its reference -> the + // buy streak resets and the sell streak starts at 1. + let candles = [ + c(10.0, 0), + c(10.0, 1), + c(10.0, 2), + c(10.0, 3), + c(9.0, 4), + c(8.0, 5), + c(7.0, 6), + c(6.0, 7), + c(11.0, 8), + ]; + let mut setup = TdSetup::classic(); + let out = setup.batch(&candles); + assert_eq!(out[4], Some(1.0)); + assert_eq!(out[7], Some(4.0)); + assert_eq!(out[8], Some(-1.0)); + } + + #[test] + fn rejects_zero_arguments() { + assert!(matches!(TdSetup::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(TdSetup::new(4, 0), Err(Error::PeriodZero))); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0, i64::from(i))) + .collect(); + let mut a = TdSetup::classic(); + let mut b = TdSetup::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect(); + let mut setup = TdSetup::classic(); + setup.batch(&candles); + assert!(setup.is_ready()); + setup.reset(); + assert!(!setup.is_ready()); + assert_eq!(setup.update(candles[0]), None); + assert_eq!(setup.value(), None); + } + + #[test] + fn accessors_and_metadata() { + let setup = TdSetup::new(4, 9).unwrap(); + assert_eq!(setup.params(), (4, 9)); + assert_eq!(setup.warmup_period(), 5); + assert_eq!(setup.name(), "TDSetup"); + assert_eq!(setup.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/td_trap.rs b/crates/wickra-core/src/indicators/td_trap.rs new file mode 100644 index 0000000..68948ff --- /dev/null +++ b/crates/wickra-core/src/indicators/td_trap.rs @@ -0,0 +1,186 @@ +#![allow(clippy::doc_markdown)] + +//! Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout. +//! +//! A TD Trap forms when one bar is an **inside bar** (its high below and low above +//! the prior bar's), coiling the market; the next bar that closes beyond the trap +//! bar's high or low triggers the directional signal. +//! +//! - **Buy signal** (`+1.0`): the prior bar was an inside bar and the current +//! `close` is above that inside bar's `high`. +//! - **Sell signal** (`-1.0`): the prior bar was an inside bar and the current +//! `close` is below that inside bar's `low`. +//! - Otherwise the output is `0.0`. +//! +//! The two-bar lookback (one to set the inside bar, one before it) means the first +//! value lands on the third candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TD Trap — inside-bar breakout signal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TdTrap, Candle, Indicator}; +/// +/// let mut indicator = TdTrap::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TdTrap { + prev1: Option, + prev2: Option, + last_value: Option, +} + +impl TdTrap { + /// Construct a new `TdTrap`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TdTrap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let (Some(trap), Some(before)) = (self.prev1, self.prev2) else { + // Not enough history yet: emit a neutral 0.0 while seeding. + self.prev2 = self.prev1; + self.prev1 = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let is_inside = trap.high < before.high && trap.low > before.low; + let v = if is_inside && candle.close > trap.high { + 1.0 + } else if is_inside && candle.close < trap.low { + -1.0 + } else { + 0.0 + }; + self.prev2 = self.prev1; + self.prev1 = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.prev1 = None; + self.prev2 = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TDTrap" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let td = TdTrap::new(); + assert_eq!(td.warmup_period(), 3); + assert_eq!(td.name(), "TDTrap"); + assert!(!td.is_ready()); + assert_eq!(td.value(), None); + } + + #[test] + fn first_two_bars_seed_without_signal() { + let mut td = TdTrap::new(); + assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0)); + assert_eq!(td.update(c(108.0, 95.0, 102.0)), Some(0.0)); + assert!(td.update(c(112.0, 100.0, 110.0)).is_some()); + } + + #[test] + fn inside_then_breakout_up_buys() { + // bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1. + let mut td = TdTrap::new(); + td.update(c(110.0, 90.0, 100.0)); + td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90) + assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0)); + } + + #[test] + fn inside_then_breakdown_sells() { + let mut td = TdTrap::new(); + td.update(c(110.0, 90.0, 100.0)); + td.update(c(108.0, 95.0, 102.0)); // inside bar + assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95 + } + + #[test] + fn no_inside_bar_is_zero() { + let mut td = TdTrap::new(); + td.update(c(110.0, 90.0, 100.0)); + td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside + assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0)); + } + + #[test] + fn inside_but_no_breakout_is_zero() { + let mut td = TdTrap::new(); + td.update(c(110.0, 90.0, 100.0)); + td.update(c(108.0, 95.0, 102.0)); // inside bar + assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108] + } + + #[test] + fn reset_clears_state() { + let mut td = TdTrap::new(); + td.update(c(110.0, 90.0, 100.0)); + td.update(c(108.0, 95.0, 102.0)); + td.update(c(112.0, 100.0, 109.0)); + assert!(td.is_ready()); + td.reset(); + assert!(!td.is_ready()); + assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0; + c(b + 2.0, b - 2.0, b) + }) + .collect(); + let batch = TdTrap::new().batch(&candles); + let mut b = TdTrap::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/tema.rs b/crates/wickra-core/src/indicators/tema.rs new file mode 100644 index 0000000..c3b996c --- /dev/null +++ b/crates/wickra-core/src/indicators/tema.rs @@ -0,0 +1,132 @@ +//! Triple Exponential Moving Average (TEMA). + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Triple Exponential Moving Average: `3 * EMA1 - 3 * EMA2 + EMA3`, +/// where `EMA2 = EMA(EMA1)` and `EMA3 = EMA(EMA2)`. +/// +/// Reduces lag further than DEMA at the cost of more responsiveness to noise. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Tema}; +/// +/// let mut indicator = Tema::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Tema { + ema1: Ema, + ema2: Ema, + ema3: Ema, + period: usize, +} + +impl Tema { + /// # Errors + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + ema1: Ema::new(period)?, + ema2: Ema::new(period)?, + ema3: Ema::new(period)?, + period, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Tema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let e1 = self.ema1.update(input)?; + let e2 = self.ema2.update(e1)?; + let e3 = self.ema3.update(e2)?; + Some(3.0 * e1 - 3.0 * e2 + e3) + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + self.ema3.reset(); + } + + fn warmup_period(&self) -> usize { + 3 * self.period - 2 + } + + fn is_ready(&self) -> bool { + self.ema3.is_ready() + } + + fn name(&self) -> &'static str { + "TEMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_constant_tema() { + let mut tema = Tema::new(5).unwrap(); + let out = tema.batch(&[42.0_f64; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 42.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = Tema::new(5).unwrap(); + let mut b = Tema::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut tema = Tema::new(5).unwrap(); + tema.batch(&(1..=80).map(f64::from).collect::>()); + assert!(tema.is_ready()); + tema.reset(); + assert!(!tema.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Tema::new(0).is_err()); + } + + /// Cover the const accessor `period` (45-47) and the Indicator-impl + /// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect + /// TEMA output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let tema = Tema::new(5).unwrap(); + assert_eq!(tema.period(), 5); + // EMA1 seeds at period (5), each cascade stage needs another (period-1) inputs. + assert_eq!(tema.warmup_period(), 3 * 5 - 2); + assert_eq!(tema.name(), "TEMA"); + } +} diff --git a/crates/wickra-core/src/indicators/term_structure_basis.rs b/crates/wickra-core/src/indicators/term_structure_basis.rs new file mode 100644 index 0000000..5927339 --- /dev/null +++ b/crates/wickra-core/src/indicators/term_structure_basis.rs @@ -0,0 +1,139 @@ +//! Term-Structure Basis — the dated future's relative premium to spot. + +use crate::derivatives::DerivativesTick; +use crate::traits::Indicator; + +/// Term-Structure Basis — the relative basis between a dated (e.g. quarterly) +/// futures price and the spot index. +/// +/// ```text +/// basis = (futuresPrice − indexPrice) / indexPrice +/// ``` +/// +/// Where [`FundingBasis`] measures the *perpetual*'s premium to spot, this +/// measures a *dated future*'s — the term-structure carry that a calendar or +/// cash-and-carry trade harvests as the contract converges to spot at expiry. A +/// positive basis is contango (futures above spot), a negative one backwardation. +/// The output is a fraction (e.g. `0.02` = 2%); multiply by `10_000` for basis +/// points. +/// +/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first +/// tick. +/// +/// [`FundingBasis`]: crate::FundingBasis +/// +/// # Example +/// +/// ``` +/// use wickra_core::{DerivativesTick, Indicator, TermStructureBasis}; +/// +/// fn tick(futures: f64, index: f64) -> DerivativesTick { +/// DerivativesTick::new(0.0, index, index, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0) +/// .unwrap() +/// } +/// +/// let mut ts = TermStructureBasis::new(); +/// // futures 102 vs index 100 -> 0.02 (2% contango). +/// assert!((ts.update(tick(102.0, 100.0)).unwrap() - 0.02).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TermStructureBasis { + has_emitted: bool, +} + +impl TermStructureBasis { + /// Construct a new term-structure basis indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TermStructureBasis { + type Input = DerivativesTick; + type Output = f64; + + fn update(&mut self, tick: DerivativesTick) -> Option { + self.has_emitted = true; + Some((tick.futures_price - tick.index_price) / tick.index_price) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TermStructureBasis" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn tick(futures: f64, index: f64) -> DerivativesTick { + DerivativesTick::new_unchecked( + 0.0, index, index, futures, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let ts = TermStructureBasis::new(); + assert_eq!(ts.name(), "TermStructureBasis"); + assert_eq!(ts.warmup_period(), 1); + assert!(!ts.is_ready()); + } + + #[test] + fn contango_is_positive() { + let mut ts = TermStructureBasis::new(); + let out = ts.update(tick(102.0, 100.0)).unwrap(); + assert!((out - 0.02).abs() < 1e-12); + assert!(ts.is_ready()); + } + + #[test] + fn backwardation_is_negative() { + let mut ts = TermStructureBasis::new(); + let out = ts.update(tick(98.0, 100.0)).unwrap(); + assert!((out + 0.02).abs() < 1e-12); + } + + #[test] + fn at_par_is_zero() { + let mut ts = TermStructureBasis::new(); + assert_eq!(ts.update(tick(100.0, 100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let ticks: Vec = (0..20) + .map(|i| tick(100.0 + f64::from(i % 5), 100.0)) + .collect(); + let mut a = TermStructureBasis::new(); + let mut b = TermStructureBasis::new(); + assert_eq!( + a.batch(&ticks), + ticks.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ts = TermStructureBasis::new(); + ts.update(tick(102.0, 100.0)); + assert!(ts.is_ready()); + ts.reset(); + assert!(!ts.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/three_drives.rs b/crates/wickra-core/src/indicators/three_drives.rs new file mode 100644 index 0000000..e42d303 --- /dev/null +++ b/crates/wickra-core/src/indicators/three_drives.rs @@ -0,0 +1,155 @@ +//! Three Drives harmonic pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, ratios_in, xabcd, SwingTracker, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Drives — a symmetric harmonic pattern of two visible drives separated +/// by two retracements, read from the last five pivots `X-A-B-C-D` (the two +/// drive legs are `A→B` and `C→D`): +/// +/// ```text +/// AB / XA ∈ [1.13, 1.75] (drive 1 extends the prior retracement) +/// CD / BC ∈ [1.13, 1.75] (drive 2 extends symmetrically) +/// AB ≈ CD (within 20%) (the two drives are similar in size) +/// XA ≈ BC (within 30%) (the two retracements are similar) +/// ``` +/// +/// Output is `+1.0` (bullish, terminal D a swing low — drives down), `-1.0` +/// (bearish, drives up), or `0.0`; never `None`. See +/// `crates/wickra-core/src/indicators/three_drives.rs`. +#[derive(Debug, Clone)] +pub struct ThreeDrives { + swing: SwingTracker, + has_emitted: bool, +} + +impl ThreeDrives { + /// Construct a new Three Drives detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for ThreeDrives { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for ThreeDrives { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let p = xabcd(pivots); + let xa = (p.a - p.x).abs(); + let ab = (p.b - p.a).abs(); + let bc = (p.c - p.b).abs(); + let cd = (p.d - p.c).abs(); + let extensions = ratios_in(&[(ab / xa, 1.13, 1.75), (cd / bc, 1.13, 1.75)]); + let symmetric = approx_equal(ab, cd, 0.20) && approx_equal(xa, bc, 0.30); + if extensions && symmetric { + return Some(if p.bullish { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeDrives" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = ThreeDrives::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = ThreeDrives::new(); + assert_eq!(indicator.name(), "ThreeDrives"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!ThreeDrives::default().is_ready()); + } + + #[test] + fn bearish_three_drives_is_minus_one() { + // Three rising drives (120, 128, 136) → bearish exhaustion. + let out = run(&[120.0, 100.0, 128.0, 108.0, 136.0]); + assert_eq!(*out.last().unwrap(), -1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn bullish_three_drives_is_plus_one() { + // Three falling drives → bullish exhaustion. + let out = run(&[150.0, 120.0, 140.0, 112.0, 132.0, 104.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn asymmetric_drives_do_not_trigger() { + let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = ThreeDrives::new(); + for c in candles_for_pivots(&[120.0, 100.0, 128.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 128.0, 108.0, 136.0]); + let mut a = ThreeDrives::new(); + let mut b = ThreeDrives::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/three_inside.rs b/crates/wickra-core/src/indicators/three_inside.rs new file mode 100644 index 0000000..3ee471c --- /dev/null +++ b/crates/wickra-core/src/indicators/three_inside.rs @@ -0,0 +1,202 @@ +//! Three Inside Up / Down candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Inside Up / Down — a confirmed Harami: the first two bars form a +/// Harami and the third bar confirms direction by closing beyond the first +/// bar's body. +/// +/// **Three Inside Up** (`+1.0`): +/// 1. Bar 1 is a long red candle. +/// 2. Bar 2 is a small green candle whose body sits inside Bar 1's body. +/// 3. Bar 3 is a green candle whose close exceeds Bar 1's open. +/// +/// **Three Inside Down** (`−1.0`): the mirror — long green, small red inside, +/// red closing below Bar 1's open. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeInside}; +/// +/// let mut indicator = ThreeInside::new(); +/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.0, 13.0, 10.9, 12.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeInside { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl ThreeInside { + /// Construct a new Three Inside Up / Down detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeInside { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = (b1.close - b1.open).abs(); + let body2 = (b2.close - b2.open).abs(); + if body1 <= 0.0 || body2 <= 0.0 || body2 >= body1 { + return Some(0.0); + } + let b1_red = b1.close < b1.open; + let b1_green = b1.close > b1.open; + let b2_green = b2.close > b2.open; + let b2_red = b2.close < b2.open; + let b3_green = candle.close > candle.open; + let b3_red = candle.close < candle.open; + // Bullish: prior red, inside green harami, then green confirms above b1.open. + if b1_red + && b2_green + && b2.open >= b1.close + && b2.close <= b1.open + && b3_green + && candle.close > b1.open + { + return Some(1.0); + } + // Bearish: prior green, inside red harami, then red confirms below b1.open. + if b1_green + && b2_red + && b2.open <= b1.close + && b2.close >= b1.open + && b3_red + && candle.close < b1.open + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeInside" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeInside::new(); + assert_eq!(t.name(), "ThreeInside"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn three_inside_up_is_plus_one() { + let mut t = ThreeInside::new(); + assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(0.0)); + // Bar 3 closes 12.5 > Bar 1 open 12. + assert_eq!(t.update(c(11.0, 13.0, 10.9, 12.5, 2)), Some(1.0)); + } + + #[test] + fn three_inside_down_is_minus_one() { + let mut t = ThreeInside::new(); + assert_eq!(t.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(0.0)); + // Bar 3 closes 9.5 < Bar 1 open 10. + assert_eq!(t.update(c(11.0, 11.1, 9.3, 9.5, 2)), Some(-1.0)); + } + + #[test] + fn unconfirmed_third_bar_yields_zero() { + let mut t = ThreeInside::new(); + t.update(c(12.0, 12.5, 9.5, 10.0, 0)); + t.update(c(10.5, 11.5, 10.4, 11.0, 1)); + // Bar 3 green but closes below b1.open (12). + assert_eq!(t.update(c(11.0, 11.8, 10.9, 11.5, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeInside::new(); + assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.0, base - 0.5, base + 0.5, i) + }) + .collect(); + let mut a = ThreeInside::new(); + let mut b = ThreeInside::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeInside::new(); + t.update(c(12.0, 12.5, 9.5, 10.0, 0)); + t.update(c(10.5, 11.5, 10.4, 11.0, 1)); + t.update(c(11.0, 13.0, 10.9, 12.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/three_line_break.rs b/crates/wickra-core/src/indicators/three_line_break.rs new file mode 100644 index 0000000..7a6e9eb --- /dev/null +++ b/crates/wickra-core/src/indicators/three_line_break.rs @@ -0,0 +1,241 @@ +//! Three Line Break — the close-driven line-break chart trend, as a direction. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Line Break — the trend direction of a line-break ("kakushi") chart, where +/// a reversal requires the close to break the extreme of the last `lines` lines. +/// +/// ```text +/// continue the trend when close exceeds the prior line's end +/// reverse the trend when close breaks beyond the extreme of the last `lines` lines +/// output = current line direction: +1 (up), −1 (down) +/// ``` +/// +/// A line-break chart ignores time and small moves entirely: it draws a new line +/// only when the close makes a new extreme in the trend, and flips direction only +/// when the close reverses past the high (or low) of the last `lines` lines — +/// classically **three**. This filters out minor pullbacks, so the emitted +/// direction stays in a trend until a genuinely significant reversal. Distinct from +/// the candlestick [`ThreeLineStrike`](crate::ThreeLineStrike) (a fixed four-bar +/// pattern); this is the line-break *chart type* reduced to its trend state. See +/// also the alt-chart "Three-Line-Break Bars" builder. +/// +/// The output is `+1.0` / `−1.0`. The first bar seeds the reference price; the +/// direction is emitted once the first line is drawn (data-dependent; +/// `warmup_period` returns the minimum `2`). Each `update` is O(`lines`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeLineBreak}; +/// +/// let mut indicator = ThreeLineBreak::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let close = 100.0 + f64::from(i); +/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert_eq!(last, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct ThreeLineBreak { + lines: usize, + line_values: Vec, + dir: i8, + last: Option, +} + +impl ThreeLineBreak { + /// Construct a Three Line Break requiring `lines` lines to reverse (classic 3). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `lines == 0`. + pub fn new(lines: usize) -> Result { + if lines == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lines, + line_values: Vec::with_capacity(lines + 1), + dir: 0, + last: None, + }) + } + + /// Configured number of lines required to reverse. + pub const fn lines(&self) -> usize { + self.lines + } + + /// Current direction if available. + pub const fn value(&self) -> Option { + self.last + } + + fn push_line(&mut self, close: f64, dir: i8) { + self.dir = dir; + self.line_values.push(close); + if self.line_values.len() > self.lines { + self.line_values.remove(0); + } + } +} + +impl Indicator for ThreeLineBreak { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + let Some(&prior) = self.line_values.last() else { + // Seed the reference price; no line yet. + self.line_values.push(close); + return None; + }; + if self.dir >= 0 { + if close > prior { + self.push_line(close, 1); + } else { + let low = self + .line_values + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + if close < low { + self.push_line(close, -1); + } + } + } else if close < prior { + self.push_line(close, -1); + } else { + let high = self + .line_values + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + if close > high { + self.push_line(close, 1); + } + } + if self.dir == 0 { + return None; + } + let v = f64::from(self.dir); + self.last = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.line_values.clear(); + self.dir = 0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ThreeLineBreak" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(close: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_lines() { + assert!(matches!(ThreeLineBreak::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeLineBreak::new(3).unwrap(); + assert_eq!(t.lines(), 3); + assert_eq!(t.warmup_period(), 2); + assert_eq!(t.name(), "ThreeLineBreak"); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn uptrend_is_plus_one() { + let mut t = ThreeLineBreak::new(3).unwrap(); + let candles: Vec = (0..20).map(|i| c(100.0 + f64::from(i))).collect(); + let out = t.batch(&candles); + assert!(out[0].is_none()); + assert_eq!(out[1], Some(1.0)); + assert_eq!(out.last().unwrap(), &Some(1.0)); + } + + #[test] + fn downtrend_is_minus_one() { + let mut t = ThreeLineBreak::new(3).unwrap(); + let candles: Vec = (0..20).map(|i| c(100.0 - f64::from(i))).collect(); + let last = t.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last, -1.0); + } + + #[test] + fn small_pullback_does_not_reverse() { + // Rise to build 3 up-lines, then a small dip that does not break the + // 3-line low keeps the direction up. + let mut t = ThreeLineBreak::new(3).unwrap(); + t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // up-lines at 101,102,103 + // close 102.5 is below the prior line (103) but above the 3-line low (101) -> no reversal. + assert_eq!(t.update(c(102.5)), Some(1.0)); + } + + #[test] + fn break_of_three_line_extreme_reverses() { + let mut t = ThreeLineBreak::new(3).unwrap(); + t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // lines 101,102,103, dir up + // close 100.5 breaks below the 3-line low (101) -> reverse to down. + assert_eq!(t.update(c(100.5)), Some(-1.0)); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeLineBreak::new(3).unwrap(); + t.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::>()); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + assert_eq!(t.update(c(100.0)), None); + } + + #[test] + fn flat_close_emits_none_until_a_line_forms() { + let mut t = ThreeLineBreak::new(3).unwrap(); + assert_eq!(t.update(c(100.0)), None); + // An identical close draws no line, so the direction stays unset. + assert_eq!(t.update(c(100.0)), None); + assert!(!t.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0)) + .collect(); + let batch = ThreeLineBreak::new(3).unwrap().batch(&candles); + let mut b = ThreeLineBreak::new(3).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/three_line_break_bars.rs b/crates/wickra-core/src/indicators/three_line_break_bars.rs new file mode 100644 index 0000000..0a98058 --- /dev/null +++ b/crates/wickra-core/src/indicators/three_line_break_bars.rs @@ -0,0 +1,305 @@ +//! Three-Line-Break bar builder — line-break chart segments driven by close prices. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed line-break line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LineBreakBar { + /// Price where the line began (the previous line's far edge). + pub open: f64, + /// Price where the line ended (the new close that drew it). + pub close: f64, + /// `+1` for a rising line, `-1` for a falling line. + pub direction: i8, +} + +/// Three-Line-Break bar builder using the classic close-based reversal rule. +/// +/// A line-break chart draws a new line in the trend direction whenever the close +/// makes a new extreme, and only reverses when the close breaks the extreme of the +/// previous `lines` lines (three by default — hence "three-line break"). This filters +/// minor noise: a pullback that fails to exceed the last three lines is ignored +/// entirely, so the chart isolates meaningful reversals. +/// +/// This is the **bar-builder** counterpart of the +/// [`ThreeLineBreak`](crate::ThreeLineBreak) indicator: the indicator reports the +/// current line *state* as a streaming value, whereas this builder emits each +/// completed line as a [`LineBreakBar`] so you can reconstruct the full line-break +/// chart. At most one line forms per candle, so [`BarBuilder::update`] returns either +/// an empty vector or a single bar. +/// +/// Construction rules: +/// +/// - The first candle seeds a reference close and prints nothing. +/// - The first subsequent move (up or down) draws the first line. +/// - In an up-trend a close above the last line's top extends it (a new up line); a +/// close below the lowest low of the last `lines` lines reverses to a down line. +/// The down-trend is symmetric. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, ThreeLineBreakBars}; +/// +/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap(); +/// let mut bars = ThreeLineBreakBars::new(3).unwrap(); +/// bars.update(flat(10.0)); // seed +/// let first = bars.update(flat(11.0)); // first up line +/// assert_eq!(first.len(), 1); +/// assert_eq!(first[0].direction, 1); +/// ``` +#[derive(Debug, Clone)] +pub struct ThreeLineBreakBars { + lines: usize, + seed: Option, + recent: VecDeque, +} + +impl ThreeLineBreakBars { + /// Construct a line-break builder that reverses on a break of the last `lines` + /// lines (3 for the classic three-line break). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `lines == 0`. + pub fn new(lines: usize) -> Result { + if lines == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + lines, + seed: None, + recent: VecDeque::with_capacity(lines), + }) + } + + /// Configured number of lines a reversal must break. + pub const fn lines(&self) -> usize { + self.lines + } + + /// Number of recent lines currently tracked for the reversal test. + pub fn tracked(&self) -> usize { + self.recent.len() + } + + fn push_line(&mut self, bar: LineBreakBar) { + if self.recent.len() == self.lines { + self.recent.pop_front(); + } + self.recent.push_back(bar); + } + + fn lowest_low(&self) -> f64 { + self.recent + .iter() + .map(|bar| bar.open.min(bar.close)) + .fold(f64::INFINITY, f64::min) + } + + fn highest_high(&self) -> f64 { + self.recent + .iter() + .map(|bar| bar.open.max(bar.close)) + .fold(f64::NEG_INFINITY, f64::max) + } +} + +impl BarBuilder for ThreeLineBreakBars { + type Bar = LineBreakBar; + + fn update(&mut self, candle: Candle) -> Vec { + let close = candle.close; + let Some(last) = self.recent.back().copied() else { + // No line yet: seed, then draw the first line on the first move. + let Some(seed) = self.seed else { + self.seed = Some(close); + return Vec::new(); + }; + let bar = if close > seed { + LineBreakBar { + open: seed, + close, + direction: 1, + } + } else if close < seed { + LineBreakBar { + open: seed, + close, + direction: -1, + } + } else { + return Vec::new(); + }; + self.push_line(bar); + return vec![bar]; + }; + let new_bar = if last.direction > 0 { + if close > last.close { + Some(LineBreakBar { + open: last.close, + close, + direction: 1, + }) + } else if close < self.lowest_low() { + Some(LineBreakBar { + open: last.close, + close, + direction: -1, + }) + } else { + None + } + } else if close < last.close { + Some(LineBreakBar { + open: last.close, + close, + direction: -1, + }) + } else if close > self.highest_high() { + Some(LineBreakBar { + open: last.close, + close, + direction: 1, + }) + } else { + None + }; + if let Some(bar) = new_bar { + self.push_line(bar); + vec![bar] + } else { + Vec::new() + } + } + + fn reset(&mut self) { + self.seed = None; + self.recent.clear(); + } + + fn name(&self) -> &'static str { + "ThreeLineBreakBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn flat(price: f64) -> Candle { + Candle::new(price, price, price, price, 1.0, 0).unwrap() + } + + #[test] + fn rejects_zero_lines() { + assert!(matches!(ThreeLineBreakBars::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let bars = ThreeLineBreakBars::new(3).unwrap(); + assert_eq!(bars.lines(), 3); + assert_eq!(bars.tracked(), 0); + assert_eq!(bars.name(), "ThreeLineBreakBars"); + } + + #[test] + fn seed_then_first_line() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + assert!(bars.update(flat(10.0)).is_empty()); // seed + let first = bars.update(flat(11.0)); + assert_eq!(first.len(), 1); + assert_eq!(first[0].direction, 1); + assert_relative_eq!(first[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(first[0].close, 11.0, epsilon = 1e-12); + } + + #[test] + fn new_high_extends_up_line() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // line 1 up + let cont = bars.update(flat(12.0)); // new high -> extend + assert_eq!(cont.len(), 1); + assert_eq!(cont[0].direction, 1); + assert_relative_eq!(cont[0].open, 11.0, epsilon = 1e-12); + } + + #[test] + fn small_pullback_prints_nothing() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // line 1 + bars.update(flat(12.0)); // line 2 + bars.update(flat(13.0)); // line 3, lows are 10/11/12 + assert!(bars.update(flat(10.5)).is_empty()); // not > 13, not < 10 + } + + #[test] + fn reversal_breaks_three_lines() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); // line 1, low 10 + bars.update(flat(12.0)); // line 2, low 11 + bars.update(flat(13.0)); // line 3, low 12 + let rev = bars.update(flat(9.0)); // 9 < lowest low 10 -> reverse + assert_eq!(rev.len(), 1); + assert_eq!(rev[0].direction, -1); + assert_relative_eq!(rev[0].open, 13.0, epsilon = 1e-12); + assert_relative_eq!(rev[0].close, 9.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + bars.update(flat(10.0)); + bars.update(flat(11.0)); + bars.reset(); + assert_eq!(bars.tracked(), 0); + assert!(bars.update(flat(50.0)).is_empty()); // re-seeds + } + + #[test] + fn flat_first_move_prints_nothing() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + assert!(bars.update(flat(10.0)).is_empty()); // seed + assert!(bars.update(flat(10.0)).is_empty()); // equal to seed -> no line + } + + #[test] + fn first_line_down_then_down_trend_and_reversal() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + assert!(bars.update(flat(10.0)).is_empty()); // seed + let first = bars.update(flat(9.0)); // first line down + assert_eq!(first.len(), 1); + assert_eq!(first[0].direction, -1); + assert_relative_eq!(first[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(first[0].close, 9.0, epsilon = 1e-12); + let cont = bars.update(flat(8.0)); // new low extends the down line + assert_eq!(cont.len(), 1); + assert_eq!(cont[0].direction, -1); + assert_relative_eq!(cont[0].open, 9.0, epsilon = 1e-12); + bars.update(flat(7.0)); // third down line; highs are 10/9/8 + assert!(bars.update(flat(7.5)).is_empty()); // not < 7, not > highest high 10 + let rev = bars.update(flat(11.0)); // > highest high 10 -> reverse up + assert_eq!(rev.len(), 1); + assert_eq!(rev[0].direction, 1); + assert_relative_eq!(rev[0].open, 7.0, epsilon = 1e-12); + } + + #[test] + fn batch_concatenates_completed_lines() { + let mut bars = ThreeLineBreakBars::new(3).unwrap(); + let candles = [flat(10.0), flat(11.0), flat(12.0), flat(13.0)]; + let out = bars.batch(&candles); + // seed at 10, then three rising lines. + assert_eq!(out.len(), 3); + assert!(out.iter().all(|b| b.direction == 1)); + } +} diff --git a/crates/wickra-core/src/indicators/three_line_strike.rs b/crates/wickra-core/src/indicators/three_line_strike.rs new file mode 100644 index 0000000..bd28f0d --- /dev/null +++ b/crates/wickra-core/src/indicators/three_line_strike.rs @@ -0,0 +1,215 @@ +//! Three Line Strike candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Line Strike — a 4-bar pattern: three candles marching in one direction +/// (a three-soldiers / three-crows advance) followed by a fourth candle of the +/// opposite colour that opens beyond the third candle and closes back past the +/// first candle's open, "striking" through the whole run. +/// +/// **Bullish** (`+1.0`): +/// ```text +/// bar1..bar3 green, each opening inside the prior body and closing higher +/// bar4 red & opens above bar3's close & closes below bar1's open +/// ``` +/// +/// **Bearish** (`−1.0`): the mirror — three falling red candles struck by a +/// green bar4 that opens below bar3's close and closes above bar1's open. +/// +/// Output is `0.0` otherwise. The first three bars always return `0.0` because +/// the four-bar window is not yet filled. Pattern-shape check only — no trend +/// filter is applied; combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it +/// drops straight into a machine-learning feature matrix where the bullish and +/// bearish variants occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeLineStrike}; +/// +/// let mut indicator = ThreeLineStrike::new(); +/// indicator.update(Candle::new(10.0, 11.1, 9.9, 11.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(10.5, 12.1, 10.4, 12.0, 1.0, 1).unwrap()); +/// indicator.update(Candle::new(11.5, 13.1, 11.4, 13.0, 1.0, 2).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.5, 13.6, 9.4, 9.5, 1.0, 3).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeLineStrike { + c1: Option, + c2: Option, + c3: Option, + has_emitted: bool, +} + +impl ThreeLineStrike { + /// Construct a new Three Line Strike detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + c3: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeLineStrike { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + let bar3 = self.c3; + self.c1 = self.c2; + self.c2 = self.c3; + self.c3 = Some(candle); + let (Some(bar1), Some(bar2), Some(bar3)) = (bar1, bar2, bar3) else { + return Some(0.0); + }; + // Bullish: three rising green candles struck by a red bar4. + if bar1.close > bar1.open + && bar2.close > bar2.open + && bar3.close > bar3.open + && bar2.open >= bar1.open + && bar2.open <= bar1.close + && bar2.close > bar1.close + && bar3.open >= bar2.open + && bar3.open <= bar2.close + && bar3.close > bar2.close + && candle.close < candle.open + && candle.open > bar3.close + && candle.close < bar1.open + { + return Some(1.0); + } + // Bearish: three falling red candles struck by a green bar4. + if bar1.close < bar1.open + && bar2.close < bar2.open + && bar3.close < bar3.open + && bar2.open <= bar1.open + && bar2.open >= bar1.close + && bar2.close < bar1.close + && bar3.open <= bar2.open + && bar3.open >= bar2.close + && bar3.close < bar2.close + && candle.close > candle.open + && candle.open < bar3.close + && candle.close > bar1.open + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.c3 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 4 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeLineStrike" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeLineStrike::new(); + assert_eq!(t.name(), "ThreeLineStrike"); + assert_eq!(t.warmup_period(), 4); + assert!(!t.is_ready()); + } + + #[test] + fn bullish_three_line_strike_is_plus_one() { + let mut t = ThreeLineStrike::new(); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.1, 10.4, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 13.1, 11.4, 13.0, 2)), Some(0.0)); + assert_eq!(t.update(c(13.5, 13.6, 9.4, 9.5, 3)), Some(1.0)); + } + + #[test] + fn bearish_three_line_strike_is_minus_one() { + let mut t = ThreeLineStrike::new(); + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.5, 12.6, 10.9, 11.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0)); + assert_eq!(t.update(c(9.5, 13.6, 9.4, 13.5, 3)), Some(-1.0)); + } + + #[test] + fn strike_not_clearing_first_open_yields_zero() { + let mut t = ThreeLineStrike::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + t.update(c(10.5, 12.1, 10.4, 12.0, 1)); + t.update(c(11.5, 13.1, 11.4, 13.0, 2)); + // bar4 closes 10.5, above bar1's open (10.0) -> does not strike through. + assert_eq!(t.update(c(13.5, 13.6, 10.4, 10.5, 3)), Some(0.0)); + } + + #[test] + fn first_three_bars_return_zero() { + let mut t = ThreeLineStrike::new(); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.1, 10.4, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 13.1, 11.4, 13.0, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.5, base - 0.2, base + 1.0, i) + }) + .collect(); + let mut a = ThreeLineStrike::new(); + let mut b = ThreeLineStrike::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeLineStrike::new(); + t.update(c(10.0, 11.1, 9.9, 11.0, 0)); + t.update(c(10.5, 12.1, 10.4, 12.0, 1)); + t.update(c(11.5, 13.1, 11.4, 13.0, 2)); + t.update(c(13.5, 13.6, 9.4, 9.5, 3)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/three_outside.rs b/crates/wickra-core/src/indicators/three_outside.rs new file mode 100644 index 0000000..fe084ab --- /dev/null +++ b/crates/wickra-core/src/indicators/three_outside.rs @@ -0,0 +1,201 @@ +//! Three Outside Up / Down candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Outside Up / Down — a confirmed Engulfing: the first two bars form +/// an Engulfing pattern and the third bar confirms direction. +/// +/// **Three Outside Up** (`+1.0`): +/// 1. Bar 1 is a red candle. +/// 2. Bar 2 is a green candle that engulfs Bar 1's body. +/// 3. Bar 3 is a green candle with `close > b2.close`. +/// +/// **Three Outside Down** (`−1.0`): the mirror — green, bearish engulfing, +/// followed by a red bar closing below Bar 2's close. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeOutside}; +/// +/// let mut indicator = ThreeOutside::new(); +/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.5, 13.0, 11.4, 12.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeOutside { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl ThreeOutside { + /// Construct a new Three Outside Up / Down detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeOutside { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let body1 = (b1.close - b1.open).abs(); + let body2 = (b2.close - b2.open).abs(); + if body1 <= 0.0 || body2 <= body1 { + return Some(0.0); + } + let b1_red = b1.close < b1.open; + let b1_green = b1.close > b1.open; + let b2_green = b2.close > b2.open; + let b2_red = b2.close < b2.open; + let b3_green = candle.close > candle.open; + let b3_red = candle.close < candle.open; + // Bullish: prior red, engulfing green (b2 fully engulfs b1 body), then green confirms above b2.close. + if b1_red + && b2_green + && b2.open <= b1.close + && b2.close >= b1.open + && b3_green + && candle.close > b2.close + { + return Some(1.0); + } + // Bearish: prior green, engulfing red, then red confirms below b2.close. + if b1_green + && b2_red + && b2.open >= b1.close + && b2.close <= b1.open + && b3_red + && candle.close < b2.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeOutside" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeOutside::new(); + assert_eq!(t.name(), "ThreeOutside"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn three_outside_up_is_plus_one() { + let mut t = ThreeOutside::new(); + assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + // Bar 3 green close 12.5 > b2.close 11.5. + assert_eq!(t.update(c(11.5, 13.0, 11.4, 12.5, 2)), Some(1.0)); + } + + #[test] + fn three_outside_down_is_minus_one() { + let mut t = ThreeOutside::new(); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(0.0)); + // Bar 3 red close 8.0 < b2.close 9.0. + assert_eq!(t.update(c(9.0, 9.1, 7.9, 8.0, 2)), Some(-1.0)); + } + + #[test] + fn unconfirmed_third_bar_yields_zero() { + let mut t = ThreeOutside::new(); + t.update(c(11.0, 11.2, 9.8, 10.0, 0)); + t.update(c(9.5, 12.0, 9.5, 11.5, 1)); + // Bar 3 green but does not exceed b2.close 11.5. + assert_eq!(t.update(c(11.0, 11.4, 10.9, 11.3, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeOutside::new(); + assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.5, base - 0.5, base + 1.0, i) + }) + .collect(); + let mut a = ThreeOutside::new(); + let mut b = ThreeOutside::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeOutside::new(); + t.update(c(11.0, 11.2, 9.8, 10.0, 0)); + t.update(c(9.5, 12.0, 9.5, 11.5, 1)); + t.update(c(11.5, 13.0, 11.4, 12.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/three_soldiers_or_crows.rs b/crates/wickra-core/src/indicators/three_soldiers_or_crows.rs new file mode 100644 index 0000000..917da81 --- /dev/null +++ b/crates/wickra-core/src/indicators/three_soldiers_or_crows.rs @@ -0,0 +1,204 @@ +//! Three White Soldiers / Three Black Crows candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three White Soldiers / Three Black Crows — a 3-bar continuation pattern of +/// three consecutive long candles in the same direction, each opening inside +/// the previous body and closing beyond it. +/// +/// **Three White Soldiers** (`+1.0`): +/// ```text +/// all three green & monotonically rising closes +/// & each open in [prev.open, prev.close] +/// & each close > prev.close +/// ``` +/// +/// **Three Black Crows** (`−1.0`): the mirror — three red candles with +/// monotonically falling closes. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeSoldiersOrCrows}; +/// +/// let mut indicator = ThreeSoldiersOrCrows::new(); +/// indicator.update(Candle::new(10.0, 11.5, 9.9, 11.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(10.5, 12.5, 10.4, 12.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(11.5, 13.5, 11.4, 13.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ThreeSoldiersOrCrows { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl ThreeSoldiersOrCrows { + /// Construct a new Three White Soldiers / Black Crows detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for ThreeSoldiersOrCrows { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(b1), Some(b2)) = (pp, p) else { + return Some(0.0); + }; + let g1 = b1.close > b1.open; + let g2 = b2.close > b2.open; + let g3 = candle.close > candle.open; + let r1 = b1.close < b1.open; + let r2 = b2.close < b2.open; + let r3 = candle.close < candle.open; + // Three White Soldiers + if g1 + && g2 + && g3 + && b2.close > b1.close + && candle.close > b2.close + && b2.open >= b1.open + && b2.open <= b1.close + && candle.open >= b2.open + && candle.open <= b2.close + { + return Some(1.0); + } + // Three Black Crows + if r1 + && r2 + && r3 + && b2.close < b1.close + && candle.close < b2.close + && b2.open <= b1.open + && b2.open >= b1.close + && candle.open <= b2.open + && candle.open >= b2.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeSoldiersOrCrows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeSoldiersOrCrows::new(); + assert_eq!(t.name(), "ThreeSoldiersOrCrows"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn three_white_soldiers_is_plus_one() { + let mut t = ThreeSoldiersOrCrows::new(); + assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.5, 10.4, 12.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 13.5, 11.4, 13.0, 2)), Some(1.0)); + } + + #[test] + fn three_black_crows_is_minus_one() { + let mut t = ThreeSoldiersOrCrows::new(); + // Bar1 13->12 (red), Bar2 opens inside [12,13] at 12.5, closes 11. + // Bar3 opens inside [11,12.5] at 11.5, closes 10. + assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.5, 12.6, 10.9, 11.0, 1)), Some(0.0)); + assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(-1.0)); + } + + #[test] + fn mixed_directions_yield_zero() { + let mut t = ThreeSoldiersOrCrows::new(); + t.update(c(10.0, 11.5, 9.9, 11.0, 0)); + t.update(c(11.0, 11.2, 10.0, 10.5, 1)); + assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.4, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeSoldiersOrCrows::new(); + assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(10.5, 12.5, 10.4, 12.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 1.5, base - 0.1, base + 1.0, i) + }) + .collect(); + let mut a = ThreeSoldiersOrCrows::new(); + let mut b = ThreeSoldiersOrCrows::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeSoldiersOrCrows::new(); + t.update(c(10.0, 11.5, 9.9, 11.0, 0)); + t.update(c(10.5, 12.5, 10.4, 12.0, 1)); + t.update(c(11.5, 13.5, 11.4, 13.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/three_stars_in_south.rs b/crates/wickra-core/src/indicators/three_stars_in_south.rs new file mode 100644 index 0000000..a022c6c --- /dev/null +++ b/crates/wickra-core/src/indicators/three_stars_in_south.rs @@ -0,0 +1,239 @@ +//! Three Stars in the South candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Three Stars in the South — a rare 3-bar bullish reversal: three shrinking red +/// candles where each session carves out a higher low and contracts toward a +/// tiny black marubozu, signalling exhausted selling at the bottom of a decline. +/// +/// ```text +/// tol = tolerance * max(|bar3.high|, |bar3.low|) +/// all three red (close < open) +/// bar1 long lower shadow (bar1.close − bar1.low) >= (bar1.open − bar1.close) +/// bar2 opens inside bar1's body, higher low, smaller body, closes above bar1.close +/// bar3 small black marubozu (upper & lower shadow <= tol) inside bar2's range +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Three Stars +/// in the South is a single-direction (bullish-only) pattern, so it never emits +/// `−1.0`. The first two bars always return `0.0` because the three-bar window +/// is not yet filled. `tolerance` defaults to `0.001` (10 bps relative) and must +/// lie in `[0, 1)`. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ThreeStarsInSouth}; +/// +/// let mut indicator = ThreeStarsInSouth::new(); +/// indicator.update(Candle::new(20.0, 20.1, 8.0, 15.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(18.0, 18.1, 12.0, 16.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(15.0, 15.0, 14.0, 14.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct ThreeStarsInSouth { + tolerance: f64, + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl Default for ThreeStarsInSouth { + fn default() -> Self { + Self::new() + } +} + +impl ThreeStarsInSouth { + /// Construct a detector with the default relative tolerance (1e-3). + pub const fn new() -> Self { + Self { + tolerance: 0.001, + prev: None, + prev_prev: None, + has_emitted: false, + } + } + + /// Construct a detector with a custom relative tolerance. + /// + /// `tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(tolerance: f64) -> Result { + if !(0.0..1.0).contains(&tolerance) { + return Err(Error::InvalidPeriod { + message: "three stars in the south tolerance must lie in [0, 1)", + }); + } + Ok(Self { + tolerance, + prev: None, + prev_prev: None, + has_emitted: false, + }) + } + + /// Configured relative tolerance. + pub fn tolerance(&self) -> f64 { + self.tolerance + } +} + +impl Indicator for ThreeStarsInSouth { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (pp, p) else { + return Some(0.0); + }; + let tol = self.tolerance * candle.high.abs().max(candle.low.abs()); + let bar1_body = bar1.open - bar1.close; + let bar1_lower_shadow = bar1.close - bar1.low; + let bar2_body = bar2.open - bar2.close; + let bar3_upper_shadow = candle.high - candle.open; + let bar3_lower_shadow = candle.close - candle.low; + if bar1.close < bar1.open + && bar2.close < bar2.open + && candle.close < candle.open + && bar1_lower_shadow >= bar1_body + && bar2.open <= bar1.open + && bar2.open >= bar1.close + && bar2.low > bar1.low + && bar2.close > bar1.close + && bar2_body < bar1_body + && bar3_upper_shadow <= tol + && bar3_lower_shadow <= tol + && candle.high < bar2.high + && candle.low > bar2.low + { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ThreeStarsInSouth" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(ThreeStarsInSouth::with_tolerance(-0.01).is_err()); + assert!(ThreeStarsInSouth::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = ThreeStarsInSouth::with_tolerance(0.0).unwrap(); + assert!((t.tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = ThreeStarsInSouth::default(); + assert_eq!(t.name(), "ThreeStarsInSouth"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + assert!((t.tolerance() - 0.001).abs() < 1e-12); + } + + #[test] + fn three_stars_in_south_is_plus_one() { + let mut t = ThreeStarsInSouth::new(); + assert_eq!(t.update(c(20.0, 20.1, 8.0, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(18.0, 18.1, 12.0, 16.0, 1)), Some(0.0)); + assert_eq!(t.update(c(15.0, 15.0, 14.0, 14.0, 2)), Some(1.0)); + } + + #[test] + fn third_with_shadow_yields_zero() { + let mut t = ThreeStarsInSouth::new(); + t.update(c(20.0, 20.1, 8.0, 15.0, 0)); + t.update(c(18.0, 18.1, 12.0, 16.0, 1)); + // bar3 has a long lower shadow -> not a marubozu. + assert_eq!(t.update(c(15.0, 15.0, 12.5, 14.0, 2)), Some(0.0)); + } + + #[test] + fn lower_low_yields_zero() { + let mut t = ThreeStarsInSouth::new(); + t.update(c(20.0, 20.1, 8.0, 15.0, 0)); + // bar2 dips below bar1's low -> no higher low. + assert_eq!(t.update(c(18.0, 18.1, 7.0, 16.0, 1)), Some(0.0)); + assert_eq!(t.update(c(15.0, 15.0, 14.0, 14.0, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = ThreeStarsInSouth::new(); + assert_eq!(t.update(c(20.0, 20.1, 8.0, 15.0, 0)), Some(0.0)); + assert_eq!(t.update(c(18.0, 18.1, 12.0, 16.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 2.0, base + 2.1, base - 3.0, base, i) + }) + .collect(); + let mut a = ThreeStarsInSouth::new(); + let mut b = ThreeStarsInSouth::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = ThreeStarsInSouth::new(); + t.update(c(20.0, 20.1, 8.0, 15.0, 0)); + t.update(c(18.0, 18.1, 12.0, 16.0, 1)); + t.update(c(15.0, 15.0, 14.0, 14.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(20.0, 20.1, 8.0, 15.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/thrusting.rs b/crates/wickra-core/src/indicators/thrusting.rs new file mode 100644 index 0000000..aebab78 --- /dev/null +++ b/crates/wickra-core/src/indicators/thrusting.rs @@ -0,0 +1,194 @@ +//! Thrusting candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Thrusting — a 2-bar bearish continuation, deeper than In-Neck but short of a +/// piercing reversal. A long black candle in a decline is followed by a white +/// candle that opens below the black bar's low and closes well into the black +/// body — but still below its midpoint, so the bounce is not yet a reversal. +/// +/// ```text +/// long body = |close − open| >= 0.5 * (high − low) +/// bar1 black & long +/// bar2 white, opens below bar1's low (open2 < low1) +/// bar2 closes above the in-neck zone but below the body midpoint +/// (close1 + 0.1·body1 < close2 < midpoint(open1, close1)) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Thrusting is a +/// single-direction (bearish-only) continuation, so it never emits `+1.0`. A close +/// at or above the midpoint would be a piercing pattern instead. The first bar +/// always returns `0.0` because the two-bar window is not yet filled. Body and +/// neckline thresholds follow the geometric house style rather than TA-Lib's +/// rolling averages. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Thrusting}; +/// +/// let mut indicator = Thrusting::new(); +/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap()); +/// let out = indicator +/// .update(Candle::new(7.0, 11.6, 6.9, 11.5, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Thrusting { + prev: Option, + has_emitted: bool, +} + +impl Thrusting { + /// Construct a new Thrusting detector. + pub const fn new() -> Self { + Self { + prev: None, + has_emitted: false, + } + } +} + +impl Indicator for Thrusting { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(bar1) = prev else { + return Some(0.0); + }; + let range1 = bar1.high - bar1.low; + if range1 <= 0.0 { + return Some(0.0); + } + let body1 = bar1.open - bar1.close; + let mid1 = f64::midpoint(bar1.open, bar1.close); + if bar1.close < bar1.open + && body1 >= 0.5 * range1 + && candle.close > candle.open + && candle.open < bar1.low + && candle.close > bar1.close + 0.1 * body1 + && candle.close < mid1 + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Thrusting" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = Thrusting::new(); + assert_eq!(t.name(), "Thrusting"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + } + + #[test] + fn thrusting_is_minus_one() { + let mut t = Thrusting::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + assert_eq!(t.update(c(7.0, 11.6, 6.9, 11.5, 1)), Some(-1.0)); + } + + #[test] + fn shallow_close_yields_zero() { + let mut t = Thrusting::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + // Closes barely into the body -> in-neck, not thrusting. + assert_eq!(t.update(c(7.0, 10.3, 6.9, 10.2, 1)), Some(0.0)); + } + + #[test] + fn close_past_midpoint_yields_zero() { + let mut t = Thrusting::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + // Closes above the midpoint -> piercing, not thrusting. + assert_eq!(t.update(c(7.0, 13.1, 6.9, 13.0, 1)), Some(0.0)); + } + + #[test] + fn second_bar_black_yields_zero() { + let mut t = Thrusting::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + assert_eq!(t.update(c(12.0, 12.1, 6.9, 11.5, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = Thrusting::new(); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 5.0, base + 5.1, base - 1.0, base, i) + }) + .collect(); + let mut a = Thrusting::new(); + let mut b = Thrusting::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Thrusting::new(); + t.update(c(15.0, 15.1, 9.0, 10.0, 0)); + t.update(c(7.0, 11.6, 6.9, 11.5, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0)); + } + + #[test] + fn zero_range_first_bar_yields_zero() { + let mut t = Thrusting::new(); + // Flat first bar (range1 == 0) -> rejected. + t.update(c(10.0, 10.0, 10.0, 10.0, 0)); + assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/tick_bars.rs b/crates/wickra-core/src/indicators/tick_bars.rs new file mode 100644 index 0000000..6e97368 --- /dev/null +++ b/crates/wickra-core/src/indicators/tick_bars.rs @@ -0,0 +1,209 @@ +//! Tick bar builder — aggregate a fixed number of candles into one OHLCV bar. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed tick bar (an OHLCV aggregate of `ticks` input candles). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TickBar { + /// Open of the first candle in the group. + pub open: f64, + /// Highest high across the group. + pub high: f64, + /// Lowest low across the group. + pub low: f64, + /// Close of the last candle in the group. + pub close: f64, + /// Summed volume across the group. + pub volume: f64, +} + +/// Tick bar builder — emits one OHLCV bar for every `ticks` input candles. +/// +/// Classic time bars (1-minute, 1-hour) sample the market on a clock; tick bars +/// sample it on *activity* by grouping a fixed number of trades — here modelled as a +/// fixed number of input candles. In fast markets a tick bar closes quickly; in +/// quiet markets it takes longer, so each bar carries roughly equal information +/// content. This is the simplest of the information-driven bar types; the +/// [`VolumeBars`](crate::VolumeBars) and [`DollarBars`](crate::DollarBars) builders +/// extend the idea to equal traded volume and equal traded value respectively. +/// +/// The open is the first candle's open, the high and low are the extremes across the +/// group, the close is the last candle's close, and the volume is the group sum. +/// Exactly one bar completes every `ticks` candles, so [`BarBuilder::update`] +/// returns either an empty vector or a single [`TickBar`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, TickBars}; +/// +/// let c = |o, h, l, cl, v| Candle::new(o, h, l, cl, v, 0).unwrap(); +/// let mut bars = TickBars::new(3).unwrap(); +/// assert!(bars.update(c(10.0, 11.0, 9.0, 10.5, 100.0)).is_empty()); +/// assert!(bars.update(c(10.5, 12.0, 10.0, 11.0, 150.0)).is_empty()); +/// let out = bars.update(c(11.0, 11.5, 10.8, 11.2, 120.0)); +/// assert_eq!(out.len(), 1); +/// assert_eq!(out[0].volume, 370.0); +/// ``` +#[derive(Debug, Clone)] +pub struct TickBars { + ticks: usize, + count: usize, + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, +} + +impl TickBars { + /// Construct a tick-bar builder that groups `ticks` candles per bar. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `ticks == 0`. + pub fn new(ticks: usize) -> Result { + if ticks == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + ticks, + count: 0, + open: 0.0, + high: 0.0, + low: 0.0, + close: 0.0, + volume: 0.0, + }) + } + + /// Configured number of candles per bar. + pub const fn ticks(&self) -> usize { + self.ticks + } + + /// Number of candles accumulated into the in-progress bar. + pub const fn count(&self) -> usize { + self.count + } +} + +impl BarBuilder for TickBars { + type Bar = TickBar; + + fn update(&mut self, candle: Candle) -> Vec { + if self.count == 0 { + self.open = candle.open; + self.high = candle.high; + self.low = candle.low; + self.volume = 0.0; + } else { + self.high = self.high.max(candle.high); + self.low = self.low.min(candle.low); + } + self.close = candle.close; + self.volume += candle.volume; + self.count += 1; + if self.count < self.ticks { + return Vec::new(); + } + self.count = 0; + vec![TickBar { + open: self.open, + high: self.high, + low: self.low, + close: self.close, + volume: self.volume, + }] + } + + fn reset(&mut self) { + self.count = 0; + self.volume = 0.0; + } + + fn name(&self) -> &'static str { + "TickBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new(open, high, low, close, volume, 0).unwrap() + } + + #[test] + fn rejects_zero_ticks() { + assert!(matches!(TickBars::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let bars = TickBars::new(5).unwrap(); + assert_eq!(bars.ticks(), 5); + assert_eq!(bars.count(), 0); + assert_eq!(bars.name(), "TickBars"); + } + + #[test] + fn emits_every_n_candles() { + let mut bars = TickBars::new(2).unwrap(); + assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty()); + assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1); + assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty()); + assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1); + } + + #[test] + fn aggregates_ohlcv() { + let mut bars = TickBars::new(3).unwrap(); + bars.update(candle(10.0, 11.0, 9.0, 10.5, 100.0)); + bars.update(candle(10.5, 12.0, 10.0, 11.0, 150.0)); + let out = bars.update(candle(11.0, 11.5, 10.8, 11.2, 120.0)); + assert_eq!(out.len(), 1); + assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12); + assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12); + assert_relative_eq!(out[0].close, 11.2, epsilon = 1e-12); + assert_relative_eq!(out[0].volume, 370.0, epsilon = 1e-12); + } + + #[test] + fn partial_group_emits_nothing() { + let mut bars = TickBars::new(4).unwrap(); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)); + assert_eq!(bars.count(), 2); + } + + #[test] + fn reset_clears_state() { + let mut bars = TickBars::new(3).unwrap(); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)); + bars.reset(); + assert_eq!(bars.count(), 0); + // After reset the next candle starts a fresh group. + assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 5.0)).is_empty()); + assert_eq!(bars.count(), 1); + } + + #[test] + fn batch_concatenates_completed_bars() { + let mut bars = TickBars::new(2).unwrap(); + let candles = [ + candle(10.0, 10.0, 10.0, 10.0, 1.0), + candle(10.0, 10.0, 10.0, 10.0, 1.0), + candle(10.0, 10.0, 10.0, 10.0, 1.0), + candle(10.0, 10.0, 10.0, 10.0, 1.0), + ]; + let out = bars.batch(&candles); + assert_eq!(out.len(), 2); + } +} diff --git a/crates/wickra-core/src/indicators/tick_index.rs b/crates/wickra-core/src/indicators/tick_index.rs new file mode 100644 index 0000000..469adc5 --- /dev/null +++ b/crates/wickra-core/src/indicators/tick_index.rs @@ -0,0 +1,148 @@ +//! TICK Index — instantaneous net advancing-minus-declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// TICK Index — the instantaneous net of advancing minus declining issues across +/// a universe, `advancers - decliners`. +/// +/// Unlike the cumulative [`AdvanceDecline`](crate::AdvanceDecline) line, the TICK +/// is *not* accumulated: each tick reports the breadth of that snapshot alone. It +/// oscillates around zero — strongly positive readings mean a broad surge of +/// upticks (often an intraday overbought extreme), strongly negative readings a +/// broad flush. Traders fade extremes and watch the zero line for intraday bias. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, TickIndex}; +/// +/// let mut tick = TickIndex::new(); +/// // 2 advancers, 5 decliners -> net -3. +/// let snapshot = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(tick.update(snapshot), Some(-3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TickIndex { + has_emitted: bool, +} + +impl TickIndex { + /// Construct a new TICK Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TickIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancers() as f64 - section.decliners() as f64; + self.has_emitted = true; + Some(net) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TickIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let tick = TickIndex::new(); + assert_eq!(tick.name(), "TickIndex"); + assert_eq!(tick.warmup_period(), 1); + assert!(!tick.is_ready()); + } + + #[test] + fn positive_when_advancers_lead() { + let mut tick = TickIndex::new(); + assert_eq!(tick.update(section(5, 2)), Some(3.0)); + assert!(tick.is_ready()); + } + + #[test] + fn negative_when_decliners_lead() { + let mut tick = TickIndex::new(); + assert_eq!(tick.update(section(2, 5)), Some(-3.0)); + } + + #[test] + fn does_not_accumulate() { + let mut tick = TickIndex::new(); + // Each tick is independent — the second reading does not carry the first. + assert_eq!(tick.update(section(3, 0)), Some(3.0)); + assert_eq!(tick.update(section(0, 1)), Some(-1.0)); + } + + #[test] + fn reset_clears_state() { + let mut tick = TickIndex::new(); + tick.update(section(3, 0)); + assert!(tick.is_ready()); + tick.reset(); + assert!(!tick.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(5, 2), section(2, 5), section(3, 0), section(0, 1)]; + let mut a = TickIndex::new(); + let mut b = TickIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/tii.rs b/crates/wickra-core/src/indicators/tii.rs new file mode 100644 index 0000000..e4c50fa --- /dev/null +++ b/crates/wickra-core/src/indicators/tii.rs @@ -0,0 +1,269 @@ +//! Trend Intensity Index (TII). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// M.H. Pee's Trend Intensity Index — a `[0, 100]` oscillator that measures +/// what fraction of the recent SMA deviations are positive. +/// +/// First, compute an `SMA(close, sma_period)` (canonical `sma_period = 60`). +/// On each bar `t` that the SMA is defined, compute the deviation +/// `dev_t = close_t − SMA_t`. Then, over the most recent `dev_period` +/// deviations (canonical `dev_period = 30`, i.e. `sma_period / 2`), sum the +/// positive and negative magnitudes separately: +/// +/// ```text +/// SD_pos = Σ_{i ∈ window, dev_i > 0} dev_i +/// SD_neg = Σ_{i ∈ window, dev_i < 0} |dev_i| +/// TII = 100 · SD_pos / (SD_pos + SD_neg) +/// ``` +/// +/// `TII` is bounded in `[0, 100]`: high readings (`> 80`) signal a sustained +/// uptrend (most recent closes above the SMA), low readings (`< 20`) a +/// sustained downtrend. A perfectly flat window produces `50` (every deviation +/// is zero, so the indicator falls back to its neutral mid-point). +/// +/// The first output is emitted once both the SMA is ready (`sma_period` +/// inputs) and the deviation ring is full (`dev_period − 1` more inputs): +/// warmup = `sma_period + dev_period − 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Tii}; +/// +/// let mut indicator = Tii::new(20, 10).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Tii { + sma_period: usize, + dev_period: usize, + sma: Sma, + /// Rolling window of the most recent `dev_period` deviations. + window: VecDeque, + sum_pos: f64, + sum_neg: f64, + last: Option, +} + +impl Tii { + /// Construct a new TII with the SMA period and the deviation window length. + /// + /// The canonical Pee parameters are `(sma_period = 60, dev_period = 30)`; + /// expose them as the Python defaults. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`. + pub fn new(sma_period: usize, dev_period: usize) -> Result { + if sma_period == 0 || dev_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + sma_period, + dev_period, + sma: Sma::new(sma_period)?, + window: VecDeque::with_capacity(dev_period), + sum_pos: 0.0, + sum_neg: 0.0, + last: None, + }) + } + + /// Configured `(sma_period, dev_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.sma_period, self.dev_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Tii { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let sma_value = self.sma.update(input)?; + let dev = input - sma_value; + + if self.window.len() == self.dev_period { + let old = self.window.pop_front().expect("ring is non-empty"); + if old > 0.0 { + self.sum_pos -= old; + } else if old < 0.0 { + self.sum_neg -= -old; + } + } + self.window.push_back(dev); + if dev > 0.0 { + self.sum_pos += dev; + } else if dev < 0.0 { + self.sum_neg += -dev; + } + + if self.window.len() < self.dev_period { + return None; + } + + let denom = self.sum_pos + self.sum_neg; + let tii = if denom <= 0.0 { + // A perfectly flat window — every deviation is zero. By + // convention we return the neutral mid-point, matching + // pandas-ta's implementation. The `<=` also catches the rare + // case where rolling-subtraction rounding leaves the + // accumulator slightly negative; the indicator is then + // mathematically undefined and we again fall back to the + // neutral mid-point. + 50.0 + } else { + // Clamp to [0, 100]: by construction the ratio lives in this + // interval, but the rolling sum_pos / sum_neg subtractions + // accumulate floating-point error and can produce a result + // a few ULP outside the bound on long histories. + (100.0 * self.sum_pos / denom).clamp(0.0, 100.0) + }; + self.last = Some(tii); + Some(tii) + } + + fn reset(&mut self) { + self.sma.reset(); + self.window.clear(); + self.sum_pos = 0.0; + self.sum_neg = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // SMA emits its first value at input `sma_period`; the deviation ring + // then needs `dev_period − 1` more inputs to fill, so first TII lands + // at `sma_period + dev_period − 1`. + self.sma_period + self.dev_period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TII" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Tii::new(0, 10), Err(Error::PeriodZero))); + assert!(matches!(Tii::new(10, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut t = Tii::new(60, 30).unwrap(); + assert_eq!(t.periods(), (60, 30)); + assert_eq!(t.warmup_period(), 89); + assert_eq!(t.name(), "TII"); + assert!(t.value().is_none()); + let prices: Vec = (1..=100).map(|i| 100.0 + f64::from(i)).collect(); + for &p in &prices { + t.update(p); + } + assert!(t.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let prices: Vec = (1..=30) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut t = Tii::new(5, 4).unwrap(); + let out = t.batch(&prices); + let warmup = 5 + 4 - 1; // 8 + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn pure_uptrend_saturates_at_100() { + // Strictly increasing series: the SMA always lags, so every close + // sits above the SMA → every deviation positive → TII = 100. + let prices: Vec = (1..=80).map(|i| 100.0 + f64::from(i)).collect(); + let mut t = Tii::new(10, 5).unwrap(); + let last = t.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn pure_downtrend_falls_to_zero() { + let prices: Vec = (1..=80).rev().map(|i| 100.0 + f64::from(i)).collect(); + let mut t = Tii::new(10, 5).unwrap(); + let last = t.batch(&prices).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn constant_series_yields_neutral_50() { + // Every deviation is zero; the `denom == 0` guard returns the + // neutral mid-point. + let mut t = Tii::new(5, 4).unwrap(); + let last = t + .batch(&[10.0_f64; 30]) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 50.0); + } + + #[test] + fn output_bounded_in_unit_interval() { + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0 + (f64::from(i) * 0.07).cos() * 3.0) + .collect(); + let mut t = Tii::new(20, 10).unwrap(); + for v in t.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v)); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0) + .collect(); + let mut a = Tii::new(20, 10).unwrap(); + let mut b = Tii::new(20, 10).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Tii::new(5, 4).unwrap(); + t.batch(&(1..=30).map(f64::from).collect::>()); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/time_based_stop.rs b/crates/wickra-core/src/indicators/time_based_stop.rs new file mode 100644 index 0000000..646ab3d --- /dev/null +++ b/crates/wickra-core/src/indicators/time_based_stop.rs @@ -0,0 +1,192 @@ +//! Time-Based Stop — a holding-period timer that fires after a fixed bar count. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of +/// price. +/// +/// ```text +/// bars_held increments by 1 each bar (since the last reset) +/// progress = min(bars_held / max_bars, 1.0) in [0, 1] +/// stop fires when progress == 1.0 (bars_held >= max_bars) +/// ``` +/// +/// Some setups should not be given unlimited time to work: a mean-reversion entry +/// that has not reverted within `max_bars`, or an event trade whose catalyst has +/// passed, is best closed regardless of price. This indicator is a pure timer — +/// it ignores the candle's prices entirely and reports the fraction of the +/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars` +/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer +/// restarts from the position open. +/// +/// Each `update` is O(1) and the first bar already emits a value +/// (`1 / max_bars`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TimeBasedStop}; +/// +/// let mut indicator = TimeBasedStop::new(5).unwrap(); +/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap(); +/// // Five bars reach the stop. +/// let mut last = 0.0; +/// for _ in 0..5 { +/// last = indicator.update(c).unwrap(); +/// } +/// assert_eq!(last, 1.0); +/// ``` +#[derive(Debug, Clone)] +pub struct TimeBasedStop { + max_bars: usize, + bars_held: usize, + last: Option, +} + +impl TimeBasedStop { + /// Construct a time-based stop that fires after `max_bars` bars. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `max_bars == 0`. + pub fn new(max_bars: usize) -> Result { + if max_bars == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + max_bars, + bars_held: 0, + last: None, + }) + } + + /// Configured maximum holding period in bars. + pub const fn max_bars(&self) -> usize { + self.max_bars + } + + /// Number of bars held since the last reset. + pub const fn bars_held(&self) -> usize { + self.bars_held + } + + /// Whether the stop has fired (the holding period has fully elapsed). + pub const fn triggered(&self) -> bool { + self.bars_held >= self.max_bars + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for TimeBasedStop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, _candle: Candle) -> Option { + self.bars_held += 1; + let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0); + self.last = Some(progress); + Some(progress) + } + + fn reset(&mut self) { + self.bars_held = 0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TimeBasedStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c() -> Candle { + Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0) + } + + #[test] + fn rejects_zero_max_bars() { + assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let t = TimeBasedStop::new(5).unwrap(); + assert_eq!(t.max_bars(), 5); + assert_eq!(t.bars_held(), 0); + assert!(!t.triggered()); + assert_eq!(t.warmup_period(), 1); + assert_eq!(t.name(), "TimeBasedStop"); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn progress_climbs_to_one() { + let mut t = TimeBasedStop::new(4).unwrap(); + let out = t.batch(&[c(), c(), c(), c()]); + assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12); + assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12); + assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12); + } + + #[test] + fn triggers_after_max_bars() { + let mut t = TimeBasedStop::new(3).unwrap(); + t.update(c()); + assert!(!t.triggered()); + t.update(c()); + assert!(!t.triggered()); + t.update(c()); + assert!(t.triggered()); + } + + #[test] + fn progress_saturates_at_one() { + // Beyond max_bars the progress stays clamped at 1.0. + let mut t = TimeBasedStop::new(2).unwrap(); + let out = t.batch(&[c(), c(), c(), c()]); + assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12); + assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn reset_restarts_timer() { + let mut t = TimeBasedStop::new(3).unwrap(); + t.batch(&[c(), c(), c()]); + assert!(t.triggered()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.bars_held(), 0); + assert!(!t.triggered()); + assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles = [c(); 10]; + let batch = TimeBasedStop::new(4).unwrap().batch(&candles); + let mut b = TimeBasedStop::new(4).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/time_of_day_return_profile.rs b/crates/wickra-core/src/indicators/time_of_day_return_profile.rs new file mode 100644 index 0000000..20bd134 --- /dev/null +++ b/crates/wickra-core/src/indicators/time_of_day_return_profile.rs @@ -0,0 +1,226 @@ +//! Time-of-Day Return Profile — the mean bar return in each intraday time bucket. + +use crate::calendar::civil_from_timestamp; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Time-of-Day Return Profile output: the per-bucket mean return. +/// +/// `bins[i]` is the mean simple return of all bars whose local time-of-day fell +/// in bucket `i`, where bucket `i` spans the minutes +/// `[i * 1440 / bins.len(), (i + 1) * 1440 / bins.len())`. Empty buckets read +/// `0.0`. +#[derive(Debug, Clone, PartialEq)] +pub struct TimeOfDayReturnProfileOutput { + /// Per-bucket mean return, earliest bucket first. Length equals `buckets`. + pub bins: Vec, +} + +/// Mean bar return bucketed by local time of day. +/// +/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle) +/// shifted by `utc_offset_minutes`) is divided into `buckets` equal slices. Each +/// bar's simple return `close / previous_close - 1` is accumulated into the bucket +/// of its time-of-day, and the profile reports the running mean per bucket. The +/// first bar produces no output (no return yet). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TimeOfDayReturnProfile}; +/// +/// let hour = 3_600_000; +/// let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap(); +/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none()); +/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap(); +/// assert_eq!(out.bins.len(), 24); +/// ``` +#[derive(Debug, Clone)] +pub struct TimeOfDayReturnProfile { + buckets: usize, + utc_offset_minutes: i32, + prev_close: Option, + sum: Vec, + count: Vec, + last: Option, +} + +impl TimeOfDayReturnProfile { + /// Construct a Time-of-Day Return Profile with `buckets` intraday slices. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `buckets == 0`. + pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result { + if buckets == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + buckets, + utc_offset_minutes, + prev_close: None, + sum: vec![0.0; buckets], + count: vec![0; buckets], + last: None, + }) + } + + /// Configured `(buckets, utc_offset_minutes)`. + pub const fn params(&self) -> (usize, i32) { + (self.buckets, self.utc_offset_minutes) + } + + /// Most recent profile if at least one return has been recorded. + pub fn value(&self) -> Option<&TimeOfDayReturnProfileOutput> { + self.last.as_ref() + } + + fn bucket_of(&self, minute_of_day: u32) -> usize { + let raw = (minute_of_day as usize * self.buckets) / 1440; + raw.min(self.buckets - 1) + } + + fn snapshot(&self) -> TimeOfDayReturnProfileOutput { + let bins = self + .sum + .iter() + .zip(&self.count) + .map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 }) + .collect(); + TimeOfDayReturnProfileOutput { bins } + } +} + +impl Indicator for TimeOfDayReturnProfile { + type Input = Candle; + type Output = TimeOfDayReturnProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let result = if let Some(prev) = self.prev_close { + let ret = if prev == 0.0 { + 0.0 + } else { + candle.close / prev - 1.0 + }; + let bucket = self.bucket_of(civil.minute_of_day()); + self.sum[bucket] += ret; + self.count[bucket] += 1; + let out = self.snapshot(); + self.last = Some(out.clone()); + Some(out) + } else { + None + }; + self.prev_close = Some(candle.close); + result + } + + fn reset(&mut self) { + self.prev_close = None; + self.sum.iter_mut().for_each(|x| *x = 0.0); + self.count.iter_mut().for_each(|x| *x = 0); + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TimeOfDayReturnProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_buckets() { + assert!(matches!( + TimeOfDayReturnProfile::new(0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn metadata_and_accessors() { + let prof = TimeOfDayReturnProfile::new(24, -300).unwrap(); + assert_eq!(prof.params(), (24, -300)); + assert_eq!(prof.name(), "TimeOfDayReturnProfile"); + assert_eq!(prof.warmup_period(), 2); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + } + + #[test] + fn buckets_by_hour_and_means_returns() { + let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap(); + assert!(prof.update(c(100.0, 0)).is_none()); // 00:00, no return + // 01:00 return +0.01 -> bucket 1. + let out = prof.update(c(101.0, HOUR)).unwrap(); + assert_eq!(out.bins.len(), 24); + assert_relative_eq!(out.bins[1], 0.01); + assert_relative_eq!(out.bins[0], 0.0); + assert!(prof.is_ready()); + // 01:00 next day, return -> averages into bucket 1. + let out = prof.update(c(102.01, 25 * HOUR)).unwrap(); + // two returns in bucket 1: 0.01 and 0.01 -> mean 0.01. + assert_relative_eq!(out.bins[1], 0.01); + } + + #[test] + fn last_bucket_clamped_for_end_of_day() { + let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap(); + prof.update(c(100.0, 23 * HOUR)); + // 23:59 -> minute 1439 -> bucket min(23, 23) = 23. + let out = prof.update(c(110.0, 23 * HOUR + 59 * 60_000)).unwrap(); + assert_relative_eq!(out.bins[23], 0.10); + } + + #[test] + fn zero_prev_close_uses_zero_return() { + let mut prof = TimeOfDayReturnProfile::new(4, 0).unwrap(); + prof.update(c(0.0, 0)); + let out = prof.update(c(5.0, HOUR)).unwrap(); + assert_relative_eq!(out.bins[0], 0.0); + } + + #[test] + fn reset_clears_state() { + let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap(); + prof.update(c(100.0, 0)); + prof.update(c(101.0, HOUR)); + prof.reset(); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + assert!(prof.update(c(100.0, 2 * HOUR)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(100.0 + f64::from(i % 7), i64::from(i) * HOUR)) + .collect(); + let mut a = TimeOfDayReturnProfile::new(12, 0).unwrap(); + let mut b = TimeOfDayReturnProfile::new(12, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/tower_top_bottom.rs b/crates/wickra-core/src/indicators/tower_top_bottom.rs new file mode 100644 index 0000000..2e46887 --- /dev/null +++ b/crates/wickra-core/src/indicators/tower_top_bottom.rs @@ -0,0 +1,239 @@ +#![allow(clippy::doc_markdown)] + +//! Tower Top / Tower Bottom — a tall bar, a pause, then a tall opposite bar. +//! +//! A Tower is a reversal where a strong directional bar is followed by a small +//! "pause" bar and then a strong bar in the *opposite* direction, like two towers +//! flanking a low wall. This is the compact three-bar form of the classic +//! multi-bar Tower pattern. +//! +//! - **Tower Bottom** (`+1.0`): a tall **bearish** bar, a small-bodied bar, then a +//! tall **bullish** bar. +//! - **Tower Top** (`-1.0`): a tall **bullish** bar, a small-bodied bar, then a +//! tall **bearish** bar. +//! - Otherwise the output is `0.0`. +//! +//! "Tall" = body `>= 0.5 * range`; "small" = body `<= 0.3 * range`. The three-bar +//! lookback means the first value lands on the third candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +fn body_fraction(candle: Candle) -> f64 { + let range = candle.high - candle.low; + if range > 0.0 { + (candle.close - candle.open).abs() / range + } else { + 0.0 + } +} + +fn is_tall(candle: Candle) -> bool { + body_fraction(candle) >= 0.5 +} + +fn is_small(candle: Candle) -> bool { + body_fraction(candle) <= 0.3 +} + +/// Tower Top / Bottom — three-bar reversal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{TowerTopBottom, Candle, Indicator}; +/// +/// let mut indicator = TowerTopBottom::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TowerTopBottom { + c1: Option, + c2: Option, + last_value: Option, +} + +impl TowerTopBottom { + /// Construct a new `TowerTopBottom`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for TowerTopBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let (Some(first), Some(middle)) = (self.c1, self.c2) else { + self.c1 = self.c2; + self.c2 = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let pause = is_small(middle); + let first_tall = is_tall(first); + let last_tall = is_tall(candle); + let v = if pause && first_tall && last_tall { + let first_up = first.close > first.open; + let last_up = candle.close > candle.open; + if !first_up && last_up { + 1.0 + } else if first_up && !last_up { + -1.0 + } else { + 0.0 + } + } else { + 0.0 + }; + self.c1 = self.c2; + self.c2 = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "TowerTopBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + /// A tall candle from `open` to `close` (body fills most of the range). + fn tall(open: f64, close: f64) -> Candle { + Candle::new_unchecked( + open, + open.max(close) + 0.1, + open.min(close) - 0.1, + close, + 0.0, + 0, + ) + } + + /// A small-bodied candle (long shadows, tiny body). + fn small(mid: f64) -> Candle { + Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let t = TowerTopBottom::new(); + assert_eq!(t.warmup_period(), 3); + assert_eq!(t.name(), "TowerTopBottom"); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn first_two_bars_seed_without_signal() { + let mut t = TowerTopBottom::new(); + assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0)); + assert_eq!(t.update(small(105.0)), Some(0.0)); + assert!(t.update(tall(110.0, 100.0)).is_some()); + } + + #[test] + fn tower_top() { + // tall bullish, small pause, tall bearish -> top -> -1. + let mut t = TowerTopBottom::new(); + t.update(tall(100.0, 110.0)); + t.update(small(110.0)); + assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0)); + } + + #[test] + fn tower_bottom() { + let mut t = TowerTopBottom::new(); + t.update(tall(110.0, 100.0)); + t.update(small(100.0)); + assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0)); + } + + #[test] + fn same_direction_is_zero() { + let mut t = TowerTopBottom::new(); + t.update(tall(100.0, 110.0)); + t.update(small(110.0)); + // last bar also bullish -> not a tower -> 0. + assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0)); + } + + #[test] + fn no_pause_is_zero() { + let mut t = TowerTopBottom::new(); + t.update(tall(100.0, 110.0)); + t.update(tall(110.0, 120.0)); // middle is tall, not a pause + assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut t = TowerTopBottom::new(); + t.update(tall(100.0, 110.0)); + t.update(small(110.0)); + t.update(tall(110.0, 100.0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0)); + } + + #[test] + fn zero_range_bar_has_zero_body_fraction() { + // A flat bar (high == low) exercises the zero-range body-fraction branch; + // it counts as a small "pause" bar, so tall-flat-tall still reverses. + fn flat(mid: f64) -> Candle { + Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0) + } + let mut t = TowerTopBottom::new(); + t.update(tall(100.0, 110.0)); + t.update(flat(110.0)); + assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| match i % 3 { + 0 => tall(100.0, 110.0), + 1 => small(110.0), + _ => tall(110.0, 100.0), + }) + .collect(); + let batch = TowerTopBottom::new().batch(&candles); + let mut b = TowerTopBottom::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/tpo_profile.rs b/crates/wickra-core/src/indicators/tpo_profile.rs new file mode 100644 index 0000000..6bfd73d --- /dev/null +++ b/crates/wickra-core/src/indicators/tpo_profile.rs @@ -0,0 +1,314 @@ +//! TPO Profile — the Time-Price-Opportunity (market-profile letter) distribution. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TPO Profile output: the price domain plus the per-bin time-period counts. +/// +/// `counts[i]` is the number of periods in the rolling window whose `[low, high]` +/// range touched the price bucket `[price_low + i * w, price_low + (i + 1) * w)` +/// where `w = (price_high - price_low) / counts.len()`. This is the classic +/// market-profile "letter" count: one Time-Price-Opportunity per period per +/// price level it traded at, independent of volume. +#[derive(Debug, Clone, PartialEq)] +pub struct TpoProfileOutput { + /// Lowest price in the window — the lower edge of bin 0. + pub price_low: f64, + /// Highest price in the window — the upper edge of the last bin. + pub price_high: f64, + /// Per-bin TPO count, lowest price bucket first. Length equals `bin_count`. + pub counts: Vec, +} + +/// Rolling TPO (Time Price Opportunity) Profile over the last `period` candles. +/// +/// Where [`crate::VolumeProfile`] distributes each bar's *volume* across the +/// bins it touches, the TPO profile counts *time*: every period that trades at a +/// price level contributes exactly one TPO mark there, regardless of how much +/// volume it carried. The result highlights the prices the market spent the most +/// time at — the market-profile bell curve. Each touched bin receives a full +/// `+1` per period (no sharing), so a wide-range bar marks every level it spans. +/// +/// A window whose bars are all single-print at one price (`price_high == price_low`) +/// is degenerate: every period's mark lands in bin 0 and both edges collapse to +/// that price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TpoProfile}; +/// +/// let mut tpo = TpoProfile::new(5, 10).unwrap(); +/// let mut last = None; +/// for i in 0..10 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = tpo.update(candle); +/// } +/// let profile = last.unwrap(); +/// assert_eq!(profile.counts.len(), 10); +/// ``` +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone)] +pub struct TpoProfile { + period: usize, + bin_count: usize, + window: VecDeque, + last: Option, +} + +impl TpoProfile { + /// Construct a TPO Profile indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero. + pub fn new(period: usize, bin_count: usize) -> Result { + if period == 0 || bin_count == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bin_count, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Classic TPO Profile: 30-bar rolling window, 50 bins. + pub fn classic() -> Self { + Self::new(30, 50).expect("classic TpoProfile params are valid") + } + + /// Configured `(period, bin_count)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bin_count) + } + + /// Most recent profile if available. + pub fn value(&self) -> Option<&TpoProfileOutput> { + self.last.as_ref() + } + + fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize { + let raw = ((price - win_low) / bin_width).floor(); + let max = (self.bin_count - 1) as f64; + raw.clamp(0.0, max) as usize + } + + fn compute(&self) -> TpoProfileOutput { + let mut win_low = f64::INFINITY; + let mut win_high = f64::NEG_INFINITY; + for candle in &self.window { + if candle.low < win_low { + win_low = candle.low; + } + if candle.high > win_high { + win_high = candle.high; + } + } + let span = win_high - win_low; + let mut counts = vec![0.0_f64; self.bin_count]; + + if span <= 0.0 { + // All bars are single-print at the same price: every period marks bin 0. + counts[0] = self.window.len() as f64; + return TpoProfileOutput { + price_low: win_low, + price_high: win_low, + counts, + }; + } + + let bin_width = span / self.bin_count as f64; + for candle in &self.window { + if candle.high <= candle.low { + let idx = self.price_to_bin(candle.low, win_low, bin_width); + counts[idx] += 1.0; + continue; + } + let lo_idx = self.price_to_bin(candle.low, win_low, bin_width); + let hi_idx = self.price_to_bin(candle.high, win_low, bin_width); + for count in counts.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *count += 1.0; + } + } + + TpoProfileOutput { + price_low: win_low, + price_high: win_high, + counts, + } + } +} + +impl Indicator for TpoProfile { + type Input = Candle; + type Output = TpoProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out.clone()); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TpoProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TpoProfile::new(0, 50), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_zero_bin_count() { + assert!(matches!(TpoProfile::new(20, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let tpo = TpoProfile::new(30, 50).unwrap(); + assert_eq!(tpo.name(), "TpoProfile"); + assert_eq!(tpo.warmup_period(), 30); + assert_eq!(tpo.params(), (30, 50)); + assert!(tpo.value().is_none()); + assert!(!tpo.is_ready()); + } + + #[test] + fn classic_params() { + let tpo = TpoProfile::classic(); + assert_eq!(tpo.params(), (30, 50)); + } + + #[test] + fn warms_up_over_period() { + let mut tpo = TpoProfile::new(3, 4).unwrap(); + assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none()); + assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none()); + assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some()); + assert!(tpo.is_ready()); + } + + #[test] + fn reference_counts() { + // Window of 2 candles, 4 bins, domain 10..14, width 1. + // bar0: 10..14 touches bins 0,1,2,3 -> +1 each. + // bar1: 11..12 touches bins 1,2 -> +1 each. + // counts = [1, 2, 2, 1]. TPO is volume-agnostic. + let mut tpo = TpoProfile::new(2, 4).unwrap(); + assert!(tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)).is_none()); + let out = tpo.update(c(11.0, 12.0, 11.0, 11.5, 999.0, 1)).unwrap(); + assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12); + assert_eq!(out.counts.len(), 4); + assert_relative_eq!(out.counts[0], 1.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[1], 2.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[2], 2.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[3], 1.0, epsilon = 1e-12); + } + + #[test] + fn volume_independent() { + // Identical ranges with wildly different volumes give identical TPO counts. + let mut a = TpoProfile::new(2, 4).unwrap(); + let mut b = TpoProfile::new(2, 4).unwrap(); + a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 0)); + let out_a = a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 1)).unwrap(); + b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 0)); + let out_b = b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 1)).unwrap(); + assert_eq!(out_a.counts, out_b.counts); + } + + #[test] + fn degenerate_single_price_window() { + let mut tpo = TpoProfile::new(3, 4).unwrap(); + tpo.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0)); + tpo.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1)); + let out = tpo.update(c(50.0, 50.0, 50.0, 50.0, 30.0, 2)).unwrap(); + assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[0], 3.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[1], 0.0, epsilon = 1e-12); + } + + #[test] + fn single_print_bar_marks_one_bin() { + // A single-print bar inside a wider domain marks exactly its own bin. + let mut tpo = TpoProfile::new(2, 4).unwrap(); + tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)); // domain setter + let out = tpo.update(c(13.0, 13.0, 13.0, 13.0, 5.0, 1)).unwrap(); + // domain 10..14, width 1; price 13 -> bin 3. + assert_relative_eq!(out.counts[3], 2.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut tpo = TpoProfile::new(2, 4).unwrap(); + tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)); + tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)); + assert!(tpo.is_ready()); + tpo.reset(); + assert!(!tpo.is_ready()); + assert!(tpo.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i % 7); + c( + base, + base + 2.0, + base - 2.0, + base, + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut a = TpoProfile::new(10, 16).unwrap(); + let mut b = TpoProfile::new(10, 16).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/trade_imbalance.rs b/crates/wickra-core/src/indicators/trade_imbalance.rs new file mode 100644 index 0000000..284a915 --- /dev/null +++ b/crates/wickra-core/src/indicators/trade_imbalance.rs @@ -0,0 +1,193 @@ +//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing +/// window of `window` trades. +/// +/// ```text +/// buyVol = Σ size of buyer-initiated trades in the window +/// sellVol = Σ size of seller-initiated trades in the window +/// imbalance = (buyVol − sellVol) / (buyVol + sellVol) +/// ``` +/// +/// The output lies in `[−1, +1]`: `+1` means the window was all aggressive +/// buying, `−1` all aggressive selling, `0` balanced (or no volume). The +/// indicator warms up for `window` trades — `update` returns `None` until the +/// window is full — then emits the rolling imbalance, maintained in O(1) per +/// trade. +/// +/// `Input = Trade`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Side, Trade, TradeImbalance}; +/// +/// let mut ti = TradeImbalance::new(2).unwrap(); +/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None); +/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5. +/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap()); +/// assert_eq!(out, Some(0.5)); +/// ``` +#[derive(Debug, Clone)] +pub struct TradeImbalance { + window: usize, + history: VecDeque<(f64, f64)>, + buy_sum: f64, + sell_sum: f64, +} + +impl TradeImbalance { + /// Construct a trade-imbalance indicator over a window of `window` trades. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `window` is zero. + pub fn new(window: usize) -> Result { + if window == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + window, + history: VecDeque::with_capacity(window), + buy_sum: 0.0, + sell_sum: 0.0, + }) + } + + /// The configured window length, in trades. + pub fn window(&self) -> usize { + self.window + } +} + +impl Indicator for TradeImbalance { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + let (buy, sell) = if trade.side.sign() > 0.0 { + (trade.size, 0.0) + } else { + (0.0, trade.size) + }; + self.history.push_back((buy, sell)); + self.buy_sum += buy; + self.sell_sum += sell; + if self.history.len() > self.window { + let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window"); + self.buy_sum -= old_buy; + self.sell_sum -= old_sell; + } + if self.history.len() < self.window { + return None; + } + let total = self.buy_sum + self.sell_sum; + if total <= 0.0 { + return Some(0.0); + } + Some((self.buy_sum - self.sell_sum) / total) + } + + fn reset(&mut self) { + self.history.clear(); + self.buy_sum = 0.0; + self.sell_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.window + } + + fn is_ready(&self) -> bool { + self.history.len() >= self.window + } + + fn name(&self) -> &'static str { + "TradeImbalance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + + fn trade(size: f64, side: Side, ts: i64) -> Trade { + Trade::new(100.0, size, side, ts).unwrap() + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let ti = TradeImbalance::new(5).unwrap(); + assert_eq!(ti.name(), "TradeImbalance"); + assert_eq!(ti.warmup_period(), 5); + assert_eq!(ti.window(), 5); + assert!(!ti.is_ready()); + } + + #[test] + fn warms_up_then_emits() { + let mut ti = TradeImbalance::new(2).unwrap(); + assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None); + assert!(!ti.is_ready()); + // Window full: buyVol 3, sellVol 1 -> 0.5. + assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5)); + assert!(ti.is_ready()); + } + + #[test] + fn rolls_off_old_trades() { + let mut ti = TradeImbalance::new(2).unwrap(); + ti.update(trade(3.0, Side::Buy, 0)); + ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5 + // Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6. + let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap(); + assert!((out - (4.0 / 6.0)).abs() < 1e-12); + } + + #[test] + fn zero_volume_window_is_zero() { + let mut ti = TradeImbalance::new(2).unwrap(); + ti.update(trade(0.0, Side::Buy, 0)); + assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..30) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + trade(1.0 + (i % 5) as f64, side, i) + }) + .collect(); + let mut a = TradeImbalance::new(5).unwrap(); + let mut b = TradeImbalance::new(5).unwrap(); + assert_eq!( + a.batch(&trades), + trades.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ti = TradeImbalance::new(2).unwrap(); + ti.update(trade(3.0, Side::Buy, 0)); + ti.update(trade(1.0, Side::Sell, 1)); + assert!(ti.is_ready()); + ti.reset(); + assert!(!ti.is_ready()); + assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None); + } +} diff --git a/crates/wickra-core/src/indicators/trade_sign_autocorrelation.rs b/crates/wickra-core/src/indicators/trade_sign_autocorrelation.rs new file mode 100644 index 0000000..436c651 --- /dev/null +++ b/crates/wickra-core/src/indicators/trade_sign_autocorrelation.rs @@ -0,0 +1,217 @@ +//! Trade-Sign Autocorrelation — lag-1 persistence of the trade-aggressor side. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::microstructure::Trade; +use crate::traits::Indicator; + +/// Trade-Sign Autocorrelation — the lag-1 autocorrelation of the **trade sign** +/// (`+1` buy, `−1` sell), measuring how strongly signed order flow persists. +/// +/// ```text +/// s_t = +1 if the trade is a buy, −1 if a sell +/// ρ1 = mean over the window of ( s_t · s_{t−1} ) ∈ [−1, +1] +/// ``` +/// +/// In real markets trade signs are strongly **positively** autocorrelated: a buy +/// tends to be followed by another buy (and a sell by a sell), because large +/// parent orders are split into many child trades and because of order-splitting +/// and herding. A high reading therefore indicates persistent directional pressure +/// — a footprint of informed or algorithmic execution — while a reading near zero +/// signals balanced, uninformed flow and a negative reading signals alternating +/// (bid-ask bounce) flow. +/// +/// The output is the mean product of consecutive signs, bounded in `[−1, +1]`. The +/// first value lands after `period` trades. Each `update` is O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Side, Trade, TradeSignAutocorrelation}; +/// +/// let mut indicator = TradeSignAutocorrelation::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; +/// last = indicator.update(Trade::new(100.0, 1.0, side, i).unwrap()); +/// } +/// // Perfectly alternating signs -> autocorrelation -1. +/// assert!((last.unwrap() + 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct TradeSignAutocorrelation { + period: usize, + signs: VecDeque, + last: Option, +} + +impl TradeSignAutocorrelation { + /// Construct a trade-sign autocorrelation over `period` trades. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `period < 2` (a lag-1 product needs two + /// trades). + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "trade-sign autocorrelation needs period >= 2", + }); + } + Ok(Self { + period, + signs: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Configured window of trades. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for TradeSignAutocorrelation { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + if self.signs.len() == self.period { + self.signs.pop_front(); + } + self.signs.push_back(trade.side.sign()); + if self.signs.len() < self.period { + return None; + } + let mut product_sum = 0.0; + let mut prev: Option = None; + for &s in &self.signs { + if let Some(p) = prev { + product_sum += s * p; + } + prev = Some(s); + } + let rho = product_sum / (self.period as f64 - 1.0); + self.last = Some(rho); + Some(rho) + } + + fn reset(&mut self) { + self.signs.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TradeSignAutocorrelation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microstructure::Side; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn buy() -> Trade { + Trade::new_unchecked(100.0, 1.0, Side::Buy, 0) + } + + fn sell() -> Trade { + Trade::new_unchecked(100.0, 1.0, Side::Sell, 0) + } + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + TradeSignAutocorrelation::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(TradeSignAutocorrelation::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let t = TradeSignAutocorrelation::new(20).unwrap(); + assert_eq!(t.period(), 20); + assert_eq!(t.warmup_period(), 20); + assert_eq!(t.name(), "TradeSignAutocorrelation"); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut t = TradeSignAutocorrelation::new(4).unwrap(); + let out = t.batch(&[buy(), buy(), buy(), buy(), buy()]); + for v in out.iter().take(3) { + assert!(v.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn persistent_flow_is_one() { + let mut t = TradeSignAutocorrelation::new(10).unwrap(); + let trades: Vec = (0..20).map(|_| buy()).collect(); + let last = t.batch(&trades).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-12); + } + + #[test] + fn alternating_flow_is_minus_one() { + let mut t = TradeSignAutocorrelation::new(10).unwrap(); + let trades: Vec = (0..20) + .map(|i| if i % 2 == 0 { buy() } else { sell() }) + .collect(); + let last = t.batch(&trades).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut t = TradeSignAutocorrelation::new(16).unwrap(); + let trades: Vec = (0..200) + .map(|i| if (i * 7 % 13) < 6 { buy() } else { sell() }) + .collect(); + for v in t.batch(&trades).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[test] + fn reset_clears_state() { + let mut t = TradeSignAutocorrelation::new(4).unwrap(); + t.batch(&[buy(), buy(), buy(), buy()]); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + assert_eq!(t.update(buy()), None); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..120) + .map(|i| if i % 3 == 0 { sell() } else { buy() }) + .collect(); + let batch = TradeSignAutocorrelation::new(16).unwrap().batch(&trades); + let mut b = TradeSignAutocorrelation::new(16).unwrap(); + let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/trade_volume_index.rs b/crates/wickra-core/src/indicators/trade_volume_index.rs new file mode 100644 index 0000000..6ab6516 --- /dev/null +++ b/crates/wickra-core/src/indicators/trade_volume_index.rs @@ -0,0 +1,235 @@ +//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Trade Volume Index — a cumulative line that adds volume while price ticks up +/// and subtracts it while price ticks down, where "up" and "down" are decided by +/// a **minimum tick value** rather than any change. +/// +/// ```text +/// change = close − prev_close +/// if change > min_tick: direction = +1 +/// if change < −min_tick: direction = −1 +/// else: direction unchanged (price is "churning") +/// TVI_t = TVI_{t−1} + direction * volume +/// ``` +/// +/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick` +/// flip the accumulation direction, so a price drifting within the spread keeps +/// adding volume in the last established direction instead of whipsawing. This is +/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter +/// and applied to close-to-close moves. Like all cumulative lines, only its slope +/// and divergences against price carry meaning — the absolute level is arbitrary. +/// +/// The first candle seeds the reference close and emits nothing; thereafter each +/// bar emits the running total. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TradeVolumeIndex}; +/// +/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let close = 100.0 + f64::from(i); +/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TradeVolumeIndex { + min_tick: f64, + prev_close: Option, + direction: f64, + tvi: f64, + last: Option, +} + +impl TradeVolumeIndex { + /// Construct a new Trade Volume Index with the given minimum tick value. + /// + /// # Errors + /// + /// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is + /// negative. A `min_tick` of `0` is allowed and makes every non-zero move + /// flip the direction. + pub fn new(min_tick: f64) -> Result { + if !min_tick.is_finite() || min_tick < 0.0 { + return Err(Error::InvalidParameter { + message: "trade volume index min_tick must be finite and non-negative", + }); + } + Ok(Self { + min_tick, + prev_close: None, + direction: 0.0, + tvi: 0.0, + last: None, + }) + } + + /// Configured minimum tick value. + pub const fn min_tick(&self) -> f64 { + self.min_tick + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for TradeVolumeIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let change = candle.close - prev_close; + if change > self.min_tick { + self.direction = 1.0; + } else if change < -self.min_tick { + self.direction = -1.0; + } + // Otherwise the direction is held from the previous bar (or 0 before the + // first decisive move), so a churning price keeps its last lean. + self.tvi += self.direction * candle.volume; + self.prev_close = Some(candle.close); + self.last = Some(self.tvi); + Some(self.tvi) + } + + fn reset(&mut self) { + self.prev_close = None; + self.direction = 0.0; + self.tvi = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TradeVolumeIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, volume, 0) + } + + #[test] + fn rejects_invalid_min_tick() { + assert!(matches!( + TradeVolumeIndex::new(-1.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + TradeVolumeIndex::new(f64::NAN), + Err(Error::InvalidParameter { .. }) + )); + assert!(TradeVolumeIndex::new(0.0).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let tvi = TradeVolumeIndex::new(0.25).unwrap(); + assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12); + assert_eq!(tvi.warmup_period(), 2); + assert_eq!(tvi.name(), "TradeVolumeIndex"); + assert!(!tvi.is_ready()); + assert_eq!(tvi.value(), None); + } + + #[test] + fn first_bar_seeds_without_output() { + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + assert_eq!(tvi.update(candle(100.0, 1_000.0)), None); + assert!(tvi.update(candle(101.0, 1_000.0)).is_some()); + } + + #[test] + fn uptrend_accumulates_volume() { + // Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume. + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + let candles = [ + candle(100.0, 1_000.0), // seed + candle(101.0, 500.0), // +1 -> +500 + candle(102.0, 300.0), // +1 -> +300 + ]; + let out = tvi.batch(&candles); + assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9); + } + + #[test] + fn small_move_holds_last_direction() { + // After an up-move, a sub-tick wobble keeps adding in the up direction. + let mut tvi = TradeVolumeIndex::new(1.0).unwrap(); + let candles = [ + candle(100.0, 1_000.0), // seed + candle(102.0, 400.0), // +2 > tick -> dir +1, +400 + candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100 + ]; + let out = tvi.batch(&candles); + assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9); + } + + #[test] + fn downtrend_distributes_volume() { + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + let candles = [ + candle(100.0, 1_000.0), + candle(99.0, 200.0), // -1 -> -200 + candle(98.0, 300.0), // -1 -> -300 + ]; + let out = tvi.batch(&candles); + assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]); + assert!(tvi.is_ready()); + tvi.reset(); + assert!(!tvi.is_ready()); + assert_eq!(tvi.value(), None); + assert_eq!(tvi.update(candle(100.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + candle( + 100.0 + (f64::from(i) * 0.3).sin() * 5.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles); + let mut b = TradeVolumeIndex::new(0.5).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/trend_label.rs b/crates/wickra-core/src/indicators/trend_label.rs new file mode 100644 index 0000000..a189c8f --- /dev/null +++ b/crates/wickra-core/src/indicators/trend_label.rs @@ -0,0 +1,209 @@ +//! Trend Label — the sign of the rolling least-squares slope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Trend Label — a discrete `{−1, 0, +1}` classification of the local trend from +/// the sign of the ordinary-least-squares slope over the last `period` values. +/// +/// ```text +/// slope = Σ (tᵢ − t̄)(xᵢ − x̄) / Σ (tᵢ − t̄)² (regress price on bar index) +/// label = +1 if slope > 0, −1 if slope < 0, 0 if slope == 0 +/// ``` +/// +/// The sign of the regression slope is *scale-invariant* — it does not depend on +/// the nominal price level — which makes it a clean, comparable trend state +/// across instruments. `+1` marks a rising regression line, `−1` a falling one, +/// and `0` a perfectly flat window. It is the discrete companion to +/// [`LinRegSlope`](crate::LinRegSlope) (which returns the continuous slope): use +/// the label when a feature pipeline wants a categorical trend direction and +/// keys any magnitude / dead-band tuning on the raw slope itself. +/// +/// Each `update` is `O(period)`: the slope numerator is recomputed from the +/// window. The denominator `Σ(tᵢ − t̄)²` is strictly positive for `period ≥ 2`, +/// so the sign is always well-defined. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, TrendLabel}; +/// +/// let mut indicator = TrendLabel::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// last = indicator.update(100.0 + f64::from(i)); // strictly rising +/// } +/// assert_eq!(last, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct TrendLabel { + period: usize, + window: VecDeque, +} + +impl TrendLabel { + /// Construct a new Trend Label classifier. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a slope needs at least + /// two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "trend label needs period >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for TrendLabel { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(value); + if self.window.len() < self.period { + return None; + } + let count = self.period as f64; + let mean_t = (count - 1.0) / 2.0; + let mean_x = self.window.iter().sum::() / count; + // Slope numerator: Σ (t − t̄)(x − x̄). The denominator Σ(t − t̄)² > 0 for + // period >= 2, so the slope sign equals the numerator sign. + let mut numerator = 0.0; + for (t, &x) in self.window.iter().enumerate() { + numerator += (t as f64 - mean_t) * (x - mean_x); + } + let label = if numerator > 0.0 { + 1.0 + } else if numerator < 0.0 { + -1.0 + } else { + 0.0 + }; + Some(label) + } + + 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 { + "TrendLabel" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_period_below_two() { + assert!(matches!( + TrendLabel::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(TrendLabel::new(2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let tl = TrendLabel::new(10).unwrap(); + assert_eq!(tl.period(), 10); + assert_eq!(tl.warmup_period(), 10); + assert_eq!(tl.name(), "TrendLabel"); + assert!(!tl.is_ready()); + } + + #[test] + fn rising_series_is_plus_one() { + let mut tl = TrendLabel::new(10).unwrap(); + let prices: Vec = (0..20).map(f64::from).collect(); + assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(1.0)); + } + + #[test] + fn falling_series_is_minus_one() { + let mut tl = TrendLabel::new(10).unwrap(); + let prices: Vec = (0..20).map(|i| 100.0 - f64::from(i)).collect(); + assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(-1.0)); + } + + #[test] + fn flat_series_is_zero() { + let mut tl = TrendLabel::new(8).unwrap(); + for v in tl.batch(&[42.0; 16]).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } + + #[test] + fn scale_invariant_sign() { + // Multiplying the whole series by a constant cannot change the trend sign. + let prices: Vec = (0..30) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0) + .collect(); + let small = TrendLabel::new(12).unwrap().batch(&prices); + let scaled: Vec = prices.iter().map(|p| p * 1000.0).collect(); + let large = TrendLabel::new(12).unwrap().batch(&scaled); + assert_eq!(small, large); + } + + #[test] + fn output_is_ternary() { + let mut tl = TrendLabel::new(14).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + for v in tl.batch(&prices).into_iter().flatten() { + assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut tl = TrendLabel::new(5).unwrap(); + tl.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(tl.is_ready()); + tl.reset(); + assert!(!tl.is_ready()); + assert_eq!(tl.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let batch = TrendLabel::new(14).unwrap().batch(&prices); + let mut b = TrendLabel::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/trend_strength_index.rs b/crates/wickra-core/src/indicators/trend_strength_index.rs new file mode 100644 index 0000000..14088a2 --- /dev/null +++ b/crates/wickra-core/src/indicators/trend_strength_index.rs @@ -0,0 +1,221 @@ +//! Trend Strength Index — the signed coefficient of determination of a linear +//! regression of price against time. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Trend Strength Index: fits an ordinary-least-squares line to the last +/// `period` prices against their bar index and reports the coefficient of +/// determination `r^2`, signed by the slope of the fit. +/// +/// ```text +/// regress y = close on x = 0..period-1 +/// r^2 = (n·Σxy − Σx·Σy)^2 / [ (n·Σx² − (Σx)²)(n·Σy² − (Σy)²) ] +/// TSI = sign(slope) · r^2 (slope sign = sign of n·Σxy − Σx·Σy) +/// ``` +/// +/// `r^2` in `[0, 1]` measures how well a straight line explains the price over +/// the window — how *trendy* the segment is, regardless of direction. Carrying +/// the slope sign turns it into a directional reading in `[-1, 1]`: values near +/// `+1` are a strong, clean uptrend; near `-1` a strong downtrend; near `0` a +/// flat or noisy market with no linear structure. A window of constant prices +/// (zero variance in `y`) has no defined trend and returns `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, TrendStrengthIndex}; +/// +/// let mut indicator = TrendStrengthIndex::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// // A clean ramp is a perfect uptrend -> r^2 = 1. +/// assert!((last.unwrap() - 1.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct TrendStrengthIndex { + period: usize, + buf: VecDeque, +} + +impl TrendStrengthIndex { + /// Construct a Trend Strength Index over the given window. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or [`Error::InvalidPeriod`] + /// if `period == 1` (a regression needs at least two points). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period == 1 { + return Err(Error::InvalidPeriod { + message: "period must be >= 2 for a regression", + }); + } + Ok(Self { + period, + buf: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for TrendStrengthIndex { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return None; + } + self.buf.push_back(price); + if self.buf.len() > self.period { + self.buf.pop_front(); + } + if self.buf.len() < self.period { + return None; + } + + let count = self.period as f64; + let mut sum_x = 0.0; + let mut sum_xx = 0.0; + let mut sum_y = 0.0; + let mut sum_yy = 0.0; + let mut sum_xy = 0.0; + for (idx, &price) in self.buf.iter().enumerate() { + let x = idx as f64; + sum_x += x; + sum_xx += x * x; + sum_y += price; + sum_yy += price * price; + sum_xy += x * price; + } + + let cov = count.mul_add(sum_xy, -(sum_x * sum_y)); + let var_x = count.mul_add(sum_xx, -(sum_x * sum_x)); + let var_y = count.mul_add(sum_yy, -(sum_y * sum_y)); + if var_y <= 0.0 { + return Some(0.0); + } + let r2 = (cov * cov) / (var_x * var_y); + Some(if cov >= 0.0 { r2 } else { -r2 }) + } + + fn reset(&mut self) { + self.buf.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.buf.len() >= self.period + } + + fn name(&self) -> &'static str { + "TrendStrengthIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_period() { + assert!(matches!(TrendStrengthIndex::new(0), Err(Error::PeriodZero))); + assert!(matches!( + TrendStrengthIndex::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let tsi = TrendStrengthIndex::new(20).unwrap(); + assert_eq!(tsi.period(), 20); + assert_eq!(tsi.warmup_period(), 20); + assert_eq!(tsi.name(), "TrendStrengthIndex"); + assert!(!tsi.is_ready()); + } + + #[test] + fn warmup_emits_at_period() { + let mut tsi = TrendStrengthIndex::new(4).unwrap(); + let inputs: Vec = (0..6).map(f64::from).collect(); + let out = tsi.batch(&inputs); + assert!(out[2].is_none()); + assert!(out[3].is_some()); + } + + #[test] + fn perfect_uptrend_is_plus_one() { + let mut tsi = TrendStrengthIndex::new(10).unwrap(); + let inputs: Vec = (0..10).map(f64::from).collect(); + let last = tsi.batch(&inputs).last().unwrap().unwrap(); + assert_relative_eq!(last, 1.0, epsilon = 1e-9); + } + + #[test] + fn perfect_downtrend_is_minus_one() { + let mut tsi = TrendStrengthIndex::new(10).unwrap(); + let inputs: Vec = (0..10).map(|i| 100.0 - f64::from(i)).collect(); + let last = tsi.batch(&inputs).last().unwrap().unwrap(); + assert_relative_eq!(last, -1.0, epsilon = 1e-9); + } + + #[test] + fn flat_market_returns_zero() { + let mut tsi = TrendStrengthIndex::new(8).unwrap(); + let inputs = [42.0; 12]; + let last = tsi.batch(&inputs).last().unwrap().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn noisy_trend_is_between() { + // An upward drift with noise: positive but not a perfect fit. + let mut tsi = TrendStrengthIndex::new(12).unwrap(); + let inputs: Vec = (0..12) + .map(|i| f64::from(i) + if i % 2 == 0 { 0.0 } else { 3.0 }) + .collect(); + let last = tsi.batch(&inputs).last().unwrap().unwrap(); + assert!(last > 0.0 && last < 1.0, "tsi {last} should be in (0, 1)"); + } + + #[test] + fn reset_clears_state() { + let mut tsi = TrendStrengthIndex::new(10).unwrap(); + let inputs: Vec = (0..10).map(f64::from).collect(); + tsi.batch(&inputs); + assert!(tsi.is_ready()); + tsi.reset(); + assert!(!tsi.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let inputs: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = TrendStrengthIndex::new(15).unwrap(); + let mut b = TrendStrengthIndex::new(15).unwrap(); + assert_eq!( + a.batch(&inputs), + inputs.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/trendflex.rs b/crates/wickra-core/src/indicators/trendflex.rs new file mode 100644 index 0000000..b00bad3 --- /dev/null +++ b/crates/wickra-core/src/indicators/trendflex.rs @@ -0,0 +1,234 @@ +//! Ehlers Trendflex — a trend-sensitive sibling of Reflex. +#![allow(clippy::doc_markdown)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' **Trendflex** — the trend-sensitive companion to +/// [`Reflex`](crate::Reflex): it averages how far the SuperSmoothed price sits +/// above or below its values over the lookback, then self-normalises. +/// +/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*, +/// Feb 2020): +/// +/// ```text +/// Filt = SuperSmoother(price, period) +/// sum = mean over i=1..period of ( Filt[0] − Filt[i] ) +/// ms = 0.04·sum² + 0.96·ms[−1] (adaptive normaliser) +/// Trendflex = sum / sqrt(ms) (0 if ms == 0) +/// ``` +/// +/// Where Reflex measures deviation from the straight *line* across the window +/// (cycle sensitive, near zero lag), Trendflex measures deviation from the +/// window's *values* (trend sensitive). It stays pinned to one side of zero +/// during a trend and oscillates through zero in a range, so it doubles as a +/// trend/range gauge. The adaptive mean-square normaliser keeps the output near a +/// `±3` band on any instrument. +/// +/// The first value lands after `period + 1` SuperSmoothed samples. Each `update` +/// is O(`period`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Trendflex}; +/// +/// let mut indicator = Trendflex::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Trendflex { + period: usize, + smoother: SuperSmoother, + filt: VecDeque, + ms: f64, + last: Option, +} + +impl Trendflex { + /// Construct a Trendflex with the given lookback `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + smoother: SuperSmoother::new(period)?, + filt: VecDeque::with_capacity(period + 1), + ms: 0.0, + last: None, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Trendflex { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let filt = self.smoother.update(price)?; + if self.filt.len() == self.period + 1 { + self.filt.pop_front(); + } + self.filt.push_back(filt); + if self.filt.len() < self.period + 1 { + return None; + } + let newest = self.filt[self.period]; + let mut sum = 0.0; + for i in 1..=self.period { + sum += newest - self.filt[self.period - i]; + } + sum /= self.period as f64; + self.ms = 0.04 * sum * sum + 0.96 * self.ms; + let trendflex = if self.ms > 0.0 { + sum / self.ms.sqrt() + } else { + 0.0 + }; + self.last = Some(trendflex); + Some(trendflex) + } + + fn reset(&mut self) { + self.smoother.reset(); + self.filt.clear(); + self.ms = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Trendflex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Trendflex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let t = Trendflex::new(20).unwrap(); + assert_eq!(t.period(), 20); + assert_eq!(t.warmup_period(), 21); + assert_eq!(t.name(), "Trendflex"); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut t = Trendflex::new(5).unwrap(); + let xs: Vec = (0..12).map(f64::from).collect(); + let out = t.batch(&xs); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_input_is_zero() { + let mut t = Trendflex::new(10).unwrap(); + for v in t.batch(&[50.0; 100]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn uptrend_is_positive() { + // A steady rise keeps the current filtered value above its past values. + let mut t = Trendflex::new(10).unwrap(); + let out: Vec = t + .batch(&(0..200).map(f64::from).collect::>()) + .into_iter() + .flatten() + .skip(100) + .collect(); + for v in out { + assert!(v > 0.0, "uptrend should be positive, got {v}"); + } + } + + #[test] + fn downtrend_is_negative() { + let mut t = Trendflex::new(10).unwrap(); + let out: Vec = t + .batch(&(0..200).map(|i| 200.0 - f64::from(i)).collect::>()) + .into_iter() + .flatten() + .skip(100) + .collect(); + for v in out { + assert!(v < 0.0, "downtrend should be negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite() { + let mut t = Trendflex::new(10).unwrap(); + t.batch(&(0..40).map(f64::from).collect::>()); + let before = t.value(); + assert_eq!(t.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut t = Trendflex::new(10).unwrap(); + t.batch(&(0..40).map(f64::from).collect::>()); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = Trendflex::new(20).unwrap().batch(&xs); + let mut b = Trendflex::new(20).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/treynor_ratio.rs b/crates/wickra-core/src/indicators/treynor_ratio.rs new file mode 100644 index 0000000..52c89ed --- /dev/null +++ b/crates/wickra-core/src/indicators/treynor_ratio.rs @@ -0,0 +1,224 @@ +//! Rolling Treynor Ratio. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling Treynor Ratio. +/// +/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over +/// the trailing window of `period` pairs: +/// +/// ```text +/// cov_ab = (1/n) · Σ a·b − ā·b̄ +/// var_b = (1/n) · Σ b² − b̄² +/// Beta = cov_ab / var_b +/// Treynor = (mean(asset) − risk_free) / Beta +/// ``` +/// +/// Treynor is Sharpe's market-risk cousin: it divides excess return by the +/// asset's sensitivity to the benchmark (Beta) rather than by the asset's +/// own volatility. Useful for diversified portfolios where idiosyncratic +/// volatility has been mostly diversified away and the dominant remaining +/// risk is systematic / market exposure. +/// +/// A flat benchmark window has zero variance and the indicator returns +/// `0.0` rather than `NaN`. A near-zero `Beta` makes the ratio explode by +/// construction; callers should treat extreme values with the usual care. +/// +/// Each `update` is O(1) — running sums maintain `Σa`, `Σb`, `Σb²`, `Σa·b` +/// as the window slides. +#[derive(Debug, Clone)] +pub struct TreynorRatio { + period: usize, + risk_free: f64, + window: VecDeque<(f64, f64)>, + sum_a: f64, + sum_b: f64, + sum_bb: f64, + sum_ab: f64, +} + +impl TreynorRatio { + /// Construct a new rolling Treynor Ratio. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`. + pub fn new(period: usize, risk_free: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "treynor ratio needs period >= 2", + }); + } + Ok(Self { + period, + risk_free, + window: VecDeque::with_capacity(period), + sum_a: 0.0, + sum_b: 0.0, + sum_bb: 0.0, + sum_ab: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured per-period risk-free rate. + pub const fn risk_free(&self) -> f64 { + self.risk_free + } +} + +impl Indicator for TreynorRatio { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + let (oa, ob) = self.window.pop_front().expect("non-empty"); + self.sum_a -= oa; + self.sum_b -= ob; + self.sum_bb -= ob * ob; + self.sum_ab -= oa * ob; + } + self.window.push_back((a, b)); + self.sum_a += a; + self.sum_b += b; + self.sum_bb += b * b; + self.sum_ab += a * b; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean_a = self.sum_a / n; + let mean_b = self.sum_b / n; + let var_b = (self.sum_bb / n) - mean_b * mean_b; + if var_b <= 0.0 { + return Some(0.0); + } + let cov_ab = (self.sum_ab / n) - mean_a * mean_b; + let beta = cov_ab / var_b; + if beta == 0.0 { + return Some(0.0); + } + Some((mean_a - self.risk_free) / beta) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_a = 0.0; + self.sum_b = 0.0; + self.sum_bb = 0.0; + self.sum_ab = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "TreynorRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_period_less_than_two() { + assert!(matches!( + TreynorRatio::new(1, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let t = TreynorRatio::new(20, 0.001).unwrap(); + assert_eq!(t.period(), 20); + assert_relative_eq!(t.risk_free(), 0.001, epsilon = 1e-12); + assert_eq!(t.name(), "TreynorRatio"); + assert_eq!(t.warmup_period(), 20); + } + + #[test] + fn reference_beta_two_payoff() { + // a_i = 2 * b_i with non-zero mean. + // Beta should be 2; mean_a = 2 * mean_b; Treynor = mean_b. + let mut t = TreynorRatio::new(20, 0.0).unwrap(); + let inputs: Vec<(f64, f64)> = (1..=20) + .map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01)) + .collect(); + let out = t.batch(&inputs); + let last = out[19].unwrap(); + let expected = inputs.iter().map(|(_, b)| *b).sum::() / 20.0; + assert_relative_eq!(last, expected, epsilon = 1e-9); + } + + #[test] + fn flat_benchmark_yields_zero() { + // Benchmark all 0 -> var_b = 0 -> indicator returns 0.0. + let mut t = TreynorRatio::new(4, 0.0).unwrap(); + let out = t.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.03, 0.0)]); + assert_eq!(out[3], Some(0.0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut t = TreynorRatio::new(3, 0.0).unwrap(); + assert_eq!(t.update((f64::NAN, 0.0)), None); + assert_eq!(t.update((0.0, f64::INFINITY)), None); + } + + #[test] + fn reset_clears_state() { + let mut t = TreynorRatio::new(3, 0.0).unwrap(); + t.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update((0.01, 0.005)), None); + } + + #[test] + fn batch_equals_streaming() { + let inputs: Vec<(f64, f64)> = (0..50) + .map(|i| { + let b = (f64::from(i) * 0.2).sin() * 0.01; + (1.5 * b + 0.001, b) + }) + .collect(); + let batch = TreynorRatio::new(10, 0.0).unwrap().batch(&inputs); + let mut s = TreynorRatio::new(10, 0.0).unwrap(); + let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn zero_beta_returns_zero() { + // Constant asset returns vs varying benchmark force cov(a,b) = 0, + // hence beta = 0 — the explicit zero-beta short-circuit. + let mut t = TreynorRatio::new(4, 0.0).unwrap(); + let pairs: [(f64, f64); 4] = [(0.01, 0.005), (0.01, -0.002), (0.01, 0.001), (0.01, 0.003)]; + let mut last = None; + for p in pairs { + last = t.update(p); + } + assert_eq!(last, Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/triangle.rs b/crates/wickra-core/src/indicators/triangle.rs new file mode 100644 index 0000000..22e2586 --- /dev/null +++ b/crates/wickra-core/src/indicators/triangle.rs @@ -0,0 +1,181 @@ +//! Triangle (ascending / descending / symmetrical) chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Triangle — a consolidation pattern bounded by two converging trendlines, +/// detected from the two most recent swing highs and lows. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated on every +/// bar that confirms a new pivot once four pivots exist: +/// +/// ```text +/// ascending : flat highs + rising lows → +1 (bullish bias) +/// descending : falling highs + flat lows → -1 (bearish bias) +/// symmetrical : falling highs + rising lows → +1 if the last pivot is a low +/// (an up-bounce), else -1 +/// ``` +/// +/// "Flat" means the two highs (or lows) are within [`LEVEL_TOLERANCE`] (3%) of +/// each other; "rising"/"falling" means they differ by more than that tolerance. +/// The symmetrical case is directionally neutral, so its sign follows the +/// momentum of the most recently confirmed swing. Output is `+1.0` / `-1.0` / +/// `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct Triangle { + swing: SwingTracker, + has_emitted: bool, +} + +impl Triangle { + /// Construct a new Triangle detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for Triangle { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Triangle { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let (high_old, high_new, low_old, low_new) = recent_legs(pivots); + let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE); + let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE); + let rising_lows = low_new > low_old * (1.0 + LEVEL_TOLERANCE); + let falling_highs = high_new < high_old * (1.0 - LEVEL_TOLERANCE); + let last_is_high = pivots[pivots.len() - 1].direction > 0.0; + + if flat_highs && rising_lows { + return Some(1.0); // ascending + } + if falling_highs && flat_lows { + return Some(-1.0); // descending + } + if falling_highs && rising_lows { + // symmetrical: lean with the latest swing's momentum. + return Some(if last_is_high { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Triangle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Triangle::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Triangle::new(); + assert_eq!(indicator.name(), "Triangle"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!Triangle::default().is_ready()); + } + + #[test] + fn ascending_triangle_is_plus_one() { + // Flat highs (120, 120), rising lows (100 → 110). + let out = run(&[130.0, 100.0, 120.0, 110.0, 120.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn descending_triangle_is_minus_one() { + // Falling highs (120 → 110), flat lows (100, 99). + let out = run(&[120.0, 100.0, 110.0, 99.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn symmetrical_triangle_ending_low_is_plus_one() { + // Falling highs (120 → 113), rising lows (100 → 106); last pivot a low. + let out = run(&[120.0, 100.0, 113.0, 106.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn symmetrical_triangle_ending_high_is_minus_one() { + // Same convergence but ending on a high pivot. + let out = run(&[130.0, 100.0, 120.0, 106.0, 113.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn expanding_swings_are_not_a_triangle() { + // Rising highs and falling lows (broadening) → no converging triangle. + let out = run(&[110.0, 100.0, 130.0, 80.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Triangle::new(); + for c in candles_for_pivots(&[130.0, 100.0, 120.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[130.0, 100.0, 120.0, 110.0, 120.0]); + let mut a = Triangle::new(); + let mut b = Triangle::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/trima.rs b/crates/wickra-core/src/indicators/trima.rs new file mode 100644 index 0000000..b7f7c11 --- /dev/null +++ b/crates/wickra-core/src/indicators/trima.rs @@ -0,0 +1,191 @@ +//! Triangular Moving Average. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Sma; + +/// Triangular Moving Average — a simple moving average applied twice, which +/// triangular-weights the window so the middle bars carry the most weight and +/// the edges the least. +/// +/// For period `n` the two stacked SMAs use lengths `n1` and `n2`: +/// an odd `n` uses `n1 = n2 = (n + 1) / 2`; an even `n` uses `n1 = n / 2` and +/// `n2 = n / 2 + 1`. Either way the first output lands after exactly `n` +/// inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Trima}; +/// +/// let mut indicator = Trima::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Trima { + period: usize, + inner: Sma, + outer: Sma, +} + +impl Trima { + /// Construct a new TRIMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let (n1, n2) = if period % 2 == 1 { + (period.div_ceil(2), period.div_ceil(2)) + } else { + (period / 2, period / 2 + 1) + }; + Ok(Self { + period, + inner: Sma::new(n1)?, + outer: Sma::new(n2)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub fn value(&self) -> Option { + self.outer.value() + } +} + +impl Indicator for Trima { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; do not double-feed the inner SMA's + // stale value into the outer SMA. + return self.outer.value(); + } + // Genuine stacking: the outer SMA consumes the inner SMA's output. + match self.inner.update(input) { + Some(v) => self.outer.update(v), + None => None, + } + } + + fn reset(&mut self) { + self.inner.reset(); + self.outer.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.outer.is_ready() + } + + fn name(&self) -> &'static str { + "TRIMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Trima::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (59-66) and the + /// Indicator-impl `name` body (99-101). Existing tests inspect + /// TRIMA output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut t = Trima::new(5).unwrap(); + assert_eq!(t.period(), 5); + assert_eq!(t.name(), "TRIMA"); + assert_eq!(t.value(), None); + for i in 1..=t.warmup_period() { + t.update(f64::from(u32::try_from(i).unwrap())); + } + assert!(t.value().is_some()); + } + + #[test] + fn odd_period_reference_values() { + // TRIMA(5) is SMA(3) of SMA(3). + // SMA(3) of 1..=7 -> [_,_,2,3,4,5,6]; SMA(3) of that -> [_,_,_,_,3,4,5]. + let mut trima = Trima::new(5).unwrap(); + let out = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + assert_eq!(out[0], None); + assert_eq!(out[3], None); + assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12); + assert_relative_eq!(out[5].unwrap(), 4.0, epsilon = 1e-12); + assert_relative_eq!(out[6].unwrap(), 5.0, epsilon = 1e-12); + } + + #[test] + fn first_emission_at_warmup_period() { + // Even period: TRIMA(6) -> SMA(3) of SMA(4); first value at input 6. + let mut trima = Trima::new(6).unwrap(); + let out = trima.batch(&(1..=10).map(f64::from).collect::>()); + assert_eq!(trima.warmup_period(), 6); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut trima = Trima::new(7).unwrap(); + let out = trima.batch(&[42.0; 20]); + for x in out.iter().skip(6) { + assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut trima = Trima::new(5).unwrap(); + let ready = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = ready[4]; + assert!(last.is_some()); + assert_eq!(trima.update(f64::NAN), last); + } + + #[test] + fn reset_clears_state() { + let mut trima = Trima::new(5).unwrap(); + trima.batch(&(1..=10).map(f64::from).collect::>()); + assert!(trima.is_ready()); + trima.reset(); + assert!(!trima.is_ready()); + assert_eq!(trima.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40).map(f64::from).collect(); + let batch = Trima::new(8).unwrap().batch(&prices); + let mut b = Trima::new(8).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/trin.rs b/crates/wickra-core/src/indicators/trin.rs new file mode 100644 index 0000000..1d43488 --- /dev/null +++ b/crates/wickra-core/src/indicators/trin.rs @@ -0,0 +1,171 @@ +//! TRIN / Arms Index — the advance-decline ratio over the up-down volume ratio. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// TRIN (Arms Index) — `(advancers / decliners) / (advancing volume / declining +/// volume)`. +/// +/// The TRIN compares the breadth of a move in *issues* to the breadth of the move +/// in *volume*. A value near `1.0` means advancing issues and advancing volume are +/// in balance; a value below `1.0` is bullish (volume is concentrated in advancing +/// issues relative to their count); a value above `1.0` is bearish (declining +/// issues are absorbing disproportionate volume). +/// +/// To stay finite on degenerate ticks the decliner count is floored to one and +/// both volume sums are floored to `1.0`, so a tick with no declining issues or no +/// volume on one side still yields a defined reading instead of a division by +/// zero. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, Trin}; +/// +/// let mut trin = Trin::new(); +/// // 3 advancers / 1 decliner = 3; adv vol 150 / dec vol 50 = 3; TRIN = 1.0. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 50.0, false, false), +/// Member::new(1.0, 50.0, false, false), +/// Member::new(1.0, 50.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(trin.update(tick), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Trin { + has_emitted: bool, +} + +impl Trin { + /// Construct a new TRIN / Arms Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Trin { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancers = section.advancers() as f64; + let decliners = section.decliners().max(1) as f64; + let advancing_volume = section.advancing_volume().max(1.0); + let declining_volume = section.declining_volume().max(1.0); + let ad_ratio = advancers / decliners; + let volume_ratio = advancing_volume / declining_volume; + self.has_emitted = true; + Some(ad_ratio / volume_ratio) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Trin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let trin = Trin::new(); + assert_eq!(trin.name(), "Trin"); + assert_eq!(trin.warmup_period(), 1); + assert!(!trin.is_ready()); + } + + #[test] + fn balanced_breadth_yields_one() { + let mut trin = Trin::new(); + let value = trin + .update(tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)])) + .unwrap(); + assert!((value - 1.0).abs() < 1e-9); + assert!(trin.is_ready()); + } + + #[test] + fn zero_decliners_and_volume_are_floored() { + let mut trin = Trin::new(); + // 2 advancers, 0 decliners, adv vol 100, dec vol 0. + // ad_ratio = 2 / max(0,1) = 2; volume_ratio = 100 / max(0,1) = 100; TRIN = 0.02. + let value = trin.update(tick(&[(1.0, 50.0), (1.0, 50.0)])).unwrap(); + assert!((value - 0.02).abs() < 1e-9); + } + + #[test] + fn heavy_declining_volume_pushes_above_one() { + let mut trin = Trin::new(); + // 2 adv / 2 dec = 1; adv vol 20 / dec vol 80 = 0.25; TRIN = 4.0. + let value = trin + .update(tick(&[ + (1.0, 10.0), + (1.0, 10.0), + (-1.0, 40.0), + (-1.0, 40.0), + ])) + .unwrap(); + assert!((value - 4.0).abs() < 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut trin = Trin::new(); + trin.update(tick(&[(1.0, 10.0), (-1.0, 10.0)])); + assert!(trin.is_ready()); + trin.reset(); + assert!(!trin.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)]), + tick(&[(1.0, 50.0), (1.0, 50.0)]), + tick(&[(1.0, 10.0), (1.0, 10.0), (-1.0, 40.0), (-1.0, 40.0)]), + ]; + let mut a = Trin::new(); + let mut b = Trin::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/triple_top_bottom.rs b/crates/wickra-core/src/indicators/triple_top_bottom.rs new file mode 100644 index 0000000..913c394 --- /dev/null +++ b/crates/wickra-core/src/indicators/triple_top_bottom.rs @@ -0,0 +1,160 @@ +//! Triple Top / Triple Bottom reversal chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Triple Top / Triple Bottom — a three-peak (or three-trough) reversal pattern, +/// a stronger variant of the double top/bottom. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%). A pattern is +/// recognised on the bar that confirms the **third** matching extreme: +/// +/// ```text +/// triple top : High₁ , Low , High₂ , Low , High₃ High₁ ≈ High₂ ≈ High₃ → -1 +/// triple bottom : Low₁ , High, Low₂ , High, Low₃ Low₁ ≈ Low₂ ≈ Low₃ → +1 +/// ``` +/// +/// The three same-direction extremes (positions `n-5`, `n-3`, `n-1` in the pivot +/// history) must all lie within [`LEVEL_TOLERANCE`] (3%) of one another. +/// +/// Output is `+1.0` for a triple bottom, `-1.0` for a triple top, and `0.0` +/// otherwise; never `None`. +#[derive(Debug, Clone)] +pub struct TripleTopBottom { + swing: SwingTracker, + has_emitted: bool, +} + +impl TripleTopBottom { + /// Construct a new Triple Top / Triple Bottom detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for TripleTopBottom { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for TripleTopBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let n = pivots.len(); + let first = pivots[n - 5]; + let middle = pivots[n - 3]; + let last = pivots[n - 1]; + let outer_match = approx_equal(first.price, middle.price, LEVEL_TOLERANCE); + let inner_match = approx_equal(middle.price, last.price, LEVEL_TOLERANCE); + if outer_match && inner_match { + return Some(if last.direction > 0.0 { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Five confirmed pivots are needed; the earliest bar that can confirm a + // fifth pivot is the sixth. + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TripleTopBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = TripleTopBottom::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = TripleTopBottom::new(); + assert_eq!(indicator.name(), "TripleTopBottom"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!TripleTopBottom::default().is_ready()); + } + + #[test] + fn triple_top_is_minus_one() { + // Three ~equal highs (120, 121, 119) → triple top on the third. + let out = run(&[120.0, 100.0, 121.0, 99.0, 119.0]); + assert_eq!(*out.last().unwrap(), -1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn triple_bottom_is_plus_one() { + // Lead high then three ~equal lows (100, 99, 101) → triple bottom. + let out = run(&[130.0, 100.0, 120.0, 99.0, 122.0, 101.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn unequal_third_peak_does_not_trigger() { + // Third high (140) diverges from the first two (120, 121) → no pattern. + let out = run(&[120.0, 100.0, 121.0, 99.0, 140.0]); + assert_eq!(*out.last().unwrap(), 0.0); + assert!(out.iter().all(|&x| x == 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = TripleTopBottom::new(); + for c in candles_for_pivots(&[120.0, 100.0, 121.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0, 119.0]); + let mut a = TripleTopBottom::new(); + let mut b = TripleTopBottom::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/tristar.rs b/crates/wickra-core/src/indicators/tristar.rs new file mode 100644 index 0000000..86d298b --- /dev/null +++ b/crates/wickra-core/src/indicators/tristar.rs @@ -0,0 +1,204 @@ +#![allow(clippy::doc_markdown)] + +//! Tristar — a three-doji reversal pattern. +//! +//! A Tristar is three consecutive Doji candles where the middle one gaps away +//! from its neighbours, forming a star. A bearish Tristar (top) has the middle +//! doji sitting above the other two; a bullish Tristar (bottom) has it below. +//! +//! - **Bullish** (`+1.0`): three dojis, the middle doji's body centre below both +//! neighbours' body centres. +//! - **Bearish** (`-1.0`): three dojis, the middle above both neighbours. +//! - Otherwise the output is `0.0`. +//! +//! A doji is a candle whose body is `<= 0.1 * range`. The three-bar lookback means +//! the first value lands on the third candle. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Body-centre of a candle. +fn body_mid(candle: Candle) -> f64 { + f64::midpoint(candle.open, candle.close) +} + +/// Whether a candle is a doji (body small relative to range). +fn is_doji(candle: Candle) -> bool { + let body = (candle.close - candle.open).abs(); + let range = candle.high - candle.low; + range > 0.0 && body <= 0.1 * range +} + +/// Tristar — three-doji star reversal detector. +/// # Example +/// +/// ``` +/// use wickra_core::{Tristar, Candle, Indicator}; +/// +/// let mut indicator = Tristar::new(); +/// // `None` during warmup, then `Some(_)` once enough bars are seen. +/// let mut out = None; +/// for i in 0..40i64 { +/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0; +/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap(); +/// out = indicator.update(candle); +/// } +/// let _ = out; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Tristar { + c1: Option, + c2: Option, + last_value: Option, +} + +impl Tristar { + /// Construct a new `Tristar`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Latest emitted signal if available. + pub const fn value(&self) -> Option { + self.last_value + } +} + +impl Indicator for Tristar { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let (Some(first), Some(middle)) = (self.c1, self.c2) else { + self.c1 = self.c2; + self.c2 = Some(candle); + self.last_value = Some(0.0); + return Some(0.0); + }; + let v = if is_doji(first) && is_doji(middle) && is_doji(candle) { + let mid = body_mid(middle); + let n1 = body_mid(first); + let n3 = body_mid(candle); + if mid > n1 && mid > n3 { + -1.0 + } else if mid < n1 && mid < n3 { + 1.0 + } else { + 0.0 + } + } else { + 0.0 + }; + self.c1 = self.c2; + self.c2 = Some(candle); + self.last_value = Some(v); + Some(v) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "Tristar" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + /// A doji centred at `mid` (tiny body, symmetric shadows). + fn doji(mid: f64) -> Candle { + Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0) + } + + /// A non-doji (big body). + fn solid(open: f64, close: f64) -> Candle { + Candle::new_unchecked( + open, + open.max(close) + 0.1, + open.min(close) - 0.1, + close, + 0.0, + 0, + ) + } + + #[test] + fn accessors_and_metadata() { + let t = Tristar::new(); + assert_eq!(t.warmup_period(), 3); + assert_eq!(t.name(), "Tristar"); + assert!(!t.is_ready()); + assert_eq!(t.value(), None); + } + + #[test] + fn first_two_bars_seed_without_signal() { + let mut t = Tristar::new(); + assert_eq!(t.update(doji(100.0)), Some(0.0)); + assert_eq!(t.update(doji(100.0)), Some(0.0)); + assert!(t.update(doji(100.0)).is_some()); + } + + #[test] + fn bearish_tristar_top() { + // middle doji centred above the two neighbours -> top -> -1. + let mut t = Tristar::new(); + t.update(doji(100.0)); + t.update(doji(105.0)); // middle, highest + assert_eq!(t.update(doji(100.0)), Some(-1.0)); + } + + #[test] + fn bullish_tristar_bottom() { + let mut t = Tristar::new(); + t.update(doji(100.0)); + t.update(doji(95.0)); // middle, lowest + assert_eq!(t.update(doji(100.0)), Some(1.0)); + } + + #[test] + fn non_doji_is_zero() { + let mut t = Tristar::new(); + t.update(doji(100.0)); + t.update(solid(100.0, 110.0)); // not a doji + assert_eq!(t.update(doji(100.0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut t = Tristar::new(); + t.update(doji(100.0)); + t.update(doji(105.0)); + t.update(doji(100.0)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(doji(100.0)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0)) + .collect(); + let batch = Tristar::new().batch(&candles); + let mut b = Tristar::new(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/trix.rs b/crates/wickra-core/src/indicators/trix.rs new file mode 100644 index 0000000..6e8c01e --- /dev/null +++ b/crates/wickra-core/src/indicators/trix.rs @@ -0,0 +1,170 @@ +//! TRIX: triple-smoothed EMA percent rate of change. + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// TRIX: the 1-period percent rate of change of a triple-smoothed EMA. +/// +/// `TRIX = 100 * (TR_t - TR_{t-1}) / TR_{t-1}` where +/// `TR_t = EMA(EMA(EMA(price)))`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Trix}; +/// +/// let mut indicator = Trix::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Trix { + ema1: Ema, + ema2: Ema, + ema3: Ema, + prev_tr: Option, + period: usize, +} + +impl Trix { + /// # Errors + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + ema1: Ema::new(period)?, + ema2: Ema::new(period)?, + ema3: Ema::new(period)?, + prev_tr: None, + period, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Trix { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + let e1 = self.ema1.update(input)?; + let e2 = self.ema2.update(e1)?; + let e3 = self.ema3.update(e2)?; + match self.prev_tr { + Some(prev) if prev != 0.0 => { + let trix = 100.0 * (e3 - prev) / prev; + self.prev_tr = Some(e3); + Some(trix) + } + Some(_) => { + self.prev_tr = Some(e3); + Some(0.0) + } + None => { + self.prev_tr = Some(e3); + None + } + } + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + self.ema3.reset(); + self.prev_tr = None; + } + + fn warmup_period(&self) -> usize { + // Triple EMA seeds at 3*period-2; plus one extra for the rate of change. + 3 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.prev_tr.is_some() && self.ema3.is_ready() + } + + fn name(&self) -> &'static str { + "TRIX" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn constant_series_yields_zero_trix() { + let mut trix = Trix::new(5).unwrap(); + let out = trix.batch(&[100.0_f64; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 0.0, epsilon = 1e-9); + } + + #[test] + fn rising_series_eventually_positive_trix() { + let prices: Vec = (1..=200).map(f64::from).collect(); + let mut trix = Trix::new(5).unwrap(); + let last = trix.batch(&prices).into_iter().flatten().last().unwrap(); + assert!(last > 0.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80).map(|i| f64::from(i) * 1.3).collect(); + let mut a = Trix::new(7).unwrap(); + let mut b = Trix::new(7).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut trix = Trix::new(5).unwrap(); + trix.batch(&(1..=80).map(f64::from).collect::>()); + assert!(trix.is_ready()); + trix.reset(); + assert!(!trix.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Trix::new(0).is_err()); + } + + /// Cover the const accessor `period` (47-49) and the Indicator-impl + /// `warmup_period` (84-87) + `name` (93-95). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let trix = Trix::new(5).unwrap(); + assert_eq!(trix.period(), 5); + // Triple EMA seeds at 3*5-2 = 13; +1 for the rate-of-change pair = 14. + assert_eq!(trix.warmup_period(), 14); + assert_eq!(trix.name(), "TRIX"); + } + + /// Cover the `Some(_)` match arm at lines 66-68 — the degenerate path + /// where the previous triple-EMA value is exactly 0.0 (which would + /// otherwise divide by zero on the percent-rate formula). A series of + /// all-zero inputs collapses every EMA stage to 0.0, so once the + /// indicator warms up `prev_tr` is `Some(0.0)` and every subsequent + /// emission must take the fallback branch and return 0.0. + #[test] + fn zero_input_series_yields_zero_trix() { + let mut trix = Trix::new(3).unwrap(); + let out = trix.batch(&[0.0_f64; 20]); + let last = out.into_iter().flatten().last().expect("emits"); + assert_eq!(last, 0.0); + } +} diff --git a/crates/wickra-core/src/indicators/true_range.rs b/crates/wickra-core/src/indicators/true_range.rs new file mode 100644 index 0000000..02c0da1 --- /dev/null +++ b/crates/wickra-core/src/indicators/true_range.rs @@ -0,0 +1,158 @@ +//! True Range. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// True Range — the single-bar building block of every ATR-based indicator. +/// +/// ```text +/// TR = max( high − low, |high − close_prev|, |low − close_prev| ) +/// ``` +/// +/// True Range is the greatest of the bar's own range and the two gaps to the +/// previous close, so it captures volatility that opens *between* bars rather +/// than only within them. The first bar has no previous close and falls back +/// to `high − low`. Where [`Atr`](crate::Atr) smooths this series, `TrueRange` +/// exposes it raw, one value per bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TrueRange}; +/// +/// let mut indicator = TrueRange::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TrueRange { + prev_close: Option, + has_emitted: bool, +} + +impl TrueRange { + /// Construct a new True Range indicator. + pub const fn new() -> Self { + Self { + prev_close: None, + has_emitted: false, + } + } +} + +impl Indicator for TrueRange { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tr = candle.true_range(self.prev_close); + self.prev_close = Some(candle.close); + self.has_emitted = true; + Some(tr) + } + + fn reset(&mut self) { + self.prev_close = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TrueRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_values() { + // Bar 1 has no previous close -> TR = high - low = 12 - 8 = 4. + // Bar 2: prev close 11, TR = max(10-9, |10-11|, |9-11|) = max(1, 1, 2) = 2. + let mut tr = TrueRange::new(); + let out = tr.batch(&[c(12.0, 8.0, 11.0, 0), c(10.0, 9.0, 9.5, 1)]); + assert_relative_eq!(out[0].unwrap(), 4.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12); + } + + /// Cover the Indicator-impl `name` body (73-75). + #[test] + fn name_metadata() { + let tr = TrueRange::new(); + assert_eq!(tr.name(), "TrueRange"); + } + + #[test] + fn emits_from_first_candle() { + let mut tr = TrueRange::new(); + assert_eq!(tr.warmup_period(), 1); + assert!(!tr.is_ready()); + assert!(tr.update(c(11.0, 9.0, 10.0, 0)).is_some()); + assert!(tr.is_ready()); + } + + #[test] + fn never_negative() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (i as f64 * 0.3).sin() * 5.0; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut tr = TrueRange::new(); + for v in tr.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "true range must be non-negative, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut tr = TrueRange::new(); + tr.batch(&[c(12.0, 8.0, 10.0, 0), c(13.0, 9.0, 11.0, 1)]); + assert!(tr.is_ready()); + tr.reset(); + assert!(!tr.is_ready()); + // After reset the next bar again has no previous close. + assert_relative_eq!( + tr.update(c(12.0, 8.0, 10.0, 0)).unwrap(), + 4.0, + epsilon = 1e-12 + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = TrueRange::new(); + let mut b = TrueRange::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/tsf.rs b/crates/wickra-core/src/indicators/tsf.rs new file mode 100644 index 0000000..4cca060 --- /dev/null +++ b/crates/wickra-core/src/indicators/tsf.rs @@ -0,0 +1,172 @@ +//! Time Series Forecast (TSF). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Time Series Forecast (`TSF`): the rolling least-squares line projected one bar +/// past the window. +/// +/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits +/// `y = a + b·x` by ordinary least squares and reports the line's value at +/// `x = period` (one step beyond the most recent point): +/// +/// ```text +/// b (slope) = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²) +/// a (intercept) = (Σy − b·Σx) / n +/// TSF = a + b·period +/// ``` +/// +/// Where [`LinearRegression`](crate::LinearRegression) evaluates the fit at the +/// current bar (`a + b·(period − 1)`), `TSF` advances it one further bar, giving a +/// trend-following one-step-ahead forecast. Each update is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Tsf}; +/// +/// let mut indicator = Tsf::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Tsf { + period: usize, + window: VecDeque, + sum_x: f64, + denom: f64, + sum_y: f64, + sum_xy: f64, +} + +impl Tsf { + /// Construct a new rolling time-series forecast over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "time series forecast needs period >= 2", + }); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + sum_y: 0.0, + sum_xy: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Tsf { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.window.len() == self.period { + let y0 = self.window.pop_front().expect("non-empty"); + self.sum_xy = self.sum_xy - self.sum_y + y0; + self.sum_y -= y0; + } + let k = self.window.len() as f64; + self.window.push_back(value); + self.sum_y += value; + self.sum_xy += k * value; + + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom; + let intercept = (self.sum_y - slope * self.sum_x) / n; + Some(intercept + slope * n) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_y = 0.0; + self.sum_xy = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "TSF" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_short_period() { + assert!(matches!(Tsf::new(1), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_report_config() { + let tsf = Tsf::new(5).unwrap(); + assert_eq!(tsf.period(), 5); + assert_eq!(tsf.name(), "TSF"); + assert_eq!(tsf.warmup_period(), 5); + assert!(!tsf.is_ready()); + } + + #[test] + fn reference_value() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, forecast at x = 3 is 12. + let mut tsf = Tsf::new(3).unwrap(); + let out: Vec> = tsf.batch(&[1.0, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-9); + assert!(tsf.is_ready()); + } + + #[test] + fn forecasts_a_clean_line_one_step_ahead() { + // Window [10, 12, 14]: y = 10 + 2x, forecast at x = 3 is 16. + let mut tsf = Tsf::new(3).unwrap(); + let out: Vec> = tsf.batch(&[1.0, 10.0, 12.0, 14.0]); + assert_relative_eq!(out[3].unwrap(), 16.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut tsf = Tsf::new(3).unwrap(); + let _ = tsf.batch(&[1.0, 2.0, 9.0]); + assert!(tsf.is_ready()); + tsf.reset(); + assert!(!tsf.is_ready()); + assert_eq!(tsf.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/tsf_oscillator.rs b/crates/wickra-core/src/indicators/tsf_oscillator.rs new file mode 100644 index 0000000..18ff976 --- /dev/null +++ b/crates/wickra-core/src/indicators/tsf_oscillator.rs @@ -0,0 +1,209 @@ +//! Time Series Forecast Oscillator (TSF Oscillator). + +use crate::error::{Error, Result}; +use crate::indicators::tsf::Tsf; +use crate::traits::Indicator; + +/// Time Series Forecast Oscillator — the percentage gap between the close and +/// the **one-bar-ahead** time-series forecast of the close. +/// +/// ```text +/// TSFOsc_t = 100 · (close_t − TSF(close, period)_t) / close_t +/// ``` +/// +/// where [`Tsf`](crate::Tsf) projects the rolling least-squares line one bar +/// past the window (`a + b·period`). It is the close-relative companion to +/// [`Cfo`](crate::Cfo), which measures the same percentage gap against the +/// regression value at the *current* bar (`a + b·(period − 1)`). Because `TSF` +/// advances one bar further than `LinearRegression`, the two differ by exactly +/// the slope term `100·b/close`: on a trending series `TSFOsc` reads more +/// negative in an uptrend (the forecast has already stepped above price) and +/// more positive in a downtrend. +/// +/// Positive readings mean the close sits *above* its forward forecast (price +/// has overshot the projected trend); negative readings mean it sits below. +/// Wraps the existing `Tsf` so the warmup matches. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, TsfOscillator}; +/// +/// let mut indicator = TsfOscillator::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TsfOscillator { + period: usize, + tsf: Tsf, + current: Option, +} + +impl TsfOscillator { + /// Construct a new TSF oscillator over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is + /// undefined for fewer than two points. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "TSF oscillator needs period >= 2", + }); + } + Ok(Self { + period, + tsf: Tsf::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for TsfOscillator { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + let forecast = self.tsf.update(input)?; + // Hold the previous value if the close is zero — the percentage form + // is undefined and a return of inf would propagate badly. + if input == 0.0 { + return self.current; + } + let value = 100.0 * (input - forecast) / input; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.tsf.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "TsfOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_short_period() { + assert!(matches!( + TsfOscillator::new(1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + TsfOscillator::new(0), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let osc = TsfOscillator::new(14).unwrap(); + assert_eq!(osc.period(), 14); + assert_eq!(osc.warmup_period(), 14); + assert_eq!(osc.name(), "TsfOscillator"); + assert!(!osc.is_ready()); + } + + #[test] + fn reference_value() { + // period 3 over [1, 2, 9]: fit y = 0 + 4x, one-bar-ahead TSF at x = 3 + // is 12. With close = 9, TSFOsc = 100·(9 − 12)/9 = −33.3333…%. + let mut osc = TsfOscillator::new(3).unwrap(); + let out = osc.batch(&[1.0_f64, 2.0, 9.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert_relative_eq!(out[2].unwrap(), -100.0 / 3.0, epsilon = 1e-9); + assert!(osc.is_ready()); + } + + #[test] + fn constant_series_yields_zero() { + // On a flat series the regression slope is 0, so the one-bar-ahead TSF + // equals the constant and close − forecast is exactly 0. + let mut osc = TsfOscillator::new(5).unwrap(); + let out = osc.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn linear_uptrend_reads_negative() { + // Unlike CFO (evaluated at the current bar), the forecast steps one bar + // ahead, so on a rising line the projection sits above the close and the + // oscillator is negative: TSFOsc = −100·slope/close. + let mut osc = TsfOscillator::new(5).unwrap(); + let prices: Vec = (1..=20).map(|i| f64::from(i) * 2.0).collect(); + let out = osc.batch(&prices); + for v in out.iter().skip(4).flatten() { + assert!(*v < 0.0, "uptrend forecast overshoots close, got {v}"); + } + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut osc = TsfOscillator::new(3).unwrap(); + assert_eq!(osc.update(1.0), None); + assert_eq!(osc.update(2.0), None); + assert!(osc.update(3.0).is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let mut a = TsfOscillator::new(14).unwrap(); + let mut b = TsfOscillator::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut osc = TsfOscillator::new(5).unwrap(); + osc.batch(&(1..=20).map(f64::from).collect::>()); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + assert_eq!(osc.update(1.0), None); + } + + #[test] + fn zero_close_holds_value() { + let mut osc = TsfOscillator::new(3).unwrap(); + osc.batch(&[1.0_f64, 2.0, 3.0]); + let before = osc.current; + assert_eq!(osc.update(0.0), before); + } +} diff --git a/crates/wickra-core/src/indicators/tsi.rs b/crates/wickra-core/src/indicators/tsi.rs new file mode 100644 index 0000000..629e222 --- /dev/null +++ b/crates/wickra-core/src/indicators/tsi.rs @@ -0,0 +1,238 @@ +//! True Strength Index. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// True Strength Index — William Blau's double-smoothed momentum oscillator. +/// +/// The 1-bar momentum `price_t − price_{t−1}` and its absolute value are each +/// smoothed twice — first with an EMA of length `long`, then with an EMA of +/// length `short` — and the indicator reports their ratio scaled to a +/// percentage: +/// +/// ```text +/// TSI = 100 · EMA_short(EMA_long(momentum)) / EMA_short(EMA_long(|momentum|)) +/// ``` +/// +/// The double smoothing strips most of the noise while the ratio normalises +/// the result into a roughly `[−100, 100]` oscillator centred on zero: +/// positive means net upward pressure, negative net downward. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Tsi}; +/// +/// let mut indicator = Tsi::new(25, 13).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert_eq!(last, Some(100.0)); // pure uptrend saturates at +100 +/// ``` +#[derive(Debug, Clone)] +pub struct Tsi { + long: usize, + short: usize, + prev_price: Option, + ema_long_mom: Ema, + ema_short_mom: Ema, + ema_long_abs: Ema, + ema_short_abs: Ema, + current: Option, +} + +impl Tsi { + /// Construct a new TSI with the `long` and `short` smoothing periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`. + pub fn new(long: usize, short: usize) -> Result { + if long == 0 || short == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + long, + short, + prev_price: None, + ema_long_mom: Ema::new(long)?, + ema_short_mom: Ema::new(short)?, + ema_long_abs: Ema::new(long)?, + ema_short_abs: Ema::new(short)?, + current: None, + }) + } + + /// The `(long, short)` smoothing periods. + pub const fn periods(&self) -> (usize, usize) { + (self.long, self.short) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Tsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.current; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + + let momentum = input - prev; + let ds_mom = self + .ema_long_mom + .update(momentum) + .and_then(|v| self.ema_short_mom.update(v)); + let ds_abs = self + .ema_long_abs + .update(momentum.abs()) + .and_then(|v| self.ema_short_abs.update(v)); + + match (ds_mom, ds_abs) { + (Some(m), Some(a)) => { + let tsi = if a == 0.0 { + // Flat double-smoothed range: there is no momentum at all. + 0.0 + } else { + 100.0 * m / a + }; + self.current = Some(tsi); + Some(tsi) + } + _ => None, + } + } + + fn reset(&mut self) { + self.prev_price = None; + self.ema_long_mom.reset(); + self.ema_short_mom.reset(); + self.ema_long_abs.reset(); + self.ema_short_abs.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.long + self.short + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "TSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Tsi::new(0, 13), Err(Error::PeriodZero))); + assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `periods` / `value` (70-77) and the + /// Indicator-impl `name` body (137-139). Existing tests inspect + /// TSI output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut tsi = Tsi::new(25, 13).unwrap(); + assert_eq!(tsi.periods(), (25, 13)); + assert_eq!(tsi.name(), "TSI"); + assert_eq!(tsi.value(), None); + for i in 1..=tsi.warmup_period() { + tsi.update(100.0 + f64::from(u32::try_from(i).unwrap())); + } + assert!(tsi.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut tsi = Tsi::new(5, 3).unwrap(); + assert_eq!(tsi.warmup_period(), 8); + let out = tsi.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().take(7) { + assert!(v.is_none()); + } + assert!(out[7].is_some()); + } + + #[test] + fn pure_uptrend_saturates_at_plus_100() { + // Every momentum is +1, so |momentum| == momentum and the ratio is 1. + let mut tsi = Tsi::new(5, 3).unwrap(); + let out = tsi.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().skip(8).flatten() { + assert_relative_eq!(*v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn pure_downtrend_saturates_at_minus_100() { + let mut tsi = Tsi::new(5, 3).unwrap(); + let out = tsi.batch(&(1..=40).rev().map(f64::from).collect::>()); + for v in out.iter().skip(8).flatten() { + assert_relative_eq!(*v, -100.0, epsilon = 1e-9); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut tsi = Tsi::new(5, 3).unwrap(); + let out = tsi.batch(&[50.0; 40]); + for v in out.iter().skip(8).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut tsi = Tsi::new(5, 3).unwrap(); + let out = tsi.batch(&(1..=40).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(tsi.update(f64::NAN), last); + assert_eq!(tsi.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut tsi = Tsi::new(5, 3).unwrap(); + tsi.batch(&(1..=40).map(f64::from).collect::>()); + assert!(tsi.is_ready()); + tsi.reset(); + assert!(!tsi.is_ready()); + assert_eq!(tsi.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 9.0) + .collect(); + let batch = Tsi::new(13, 7).unwrap().batch(&prices); + let mut b = Tsi::new(13, 7).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/tsv.rs b/crates/wickra-core/src/indicators/tsv.rs new file mode 100644 index 0000000..28c16c1 --- /dev/null +++ b/crates/wickra-core/src/indicators/tsv.rs @@ -0,0 +1,205 @@ +//! Time Segmented Volume (Worden). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Time Segmented Volume (Don Worden) — a rolling sum of *signed* volume +/// weighted by the bar's close-to-close move. +/// +/// Each bar's contribution is the close change times the bar volume. Summed +/// over a fixed window, the result quantifies the net accumulation (positive) +/// or distribution (negative) over that span: +/// +/// ```text +/// flow_t = (close_t − close_{t−1}) · volume_t (signed money flow) +/// TSV_t = Σ_{i = t−period+1}^{t} flow_i (rolling window sum) +/// ``` +/// +/// The first candle only seeds `close_{t−1}`; the first flow lands at bar 2, +/// and the first TSV emission lands once the window has accumulated `period` +/// flows — i.e. at bar `period + 1`. Worden's original TC2000 implementation +/// often charts an additional EMA smoothing of TSV as a signal line; that is +/// left to the caller via [`crate::Ema`] composition. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Tsv}; +/// +/// let mut indicator = Tsv::new(18).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Tsv { + period: usize, + prev_close: Option, + window: VecDeque, + sum: f64, +} + +impl Tsv { + /// Construct a new TSV with the given rolling window length. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_close: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Tsv { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let flow = (candle.close - prev) * candle.volume; + self.prev_close = Some(candle.close); + + if self.window.len() == self.period { + self.sum -= self.window.pop_front().expect("non-empty"); + } + self.window.push_back(flow); + self.sum += flow; + if self.window.len() < self.period { + return None; + } + Some(self.sum) + } + + fn reset(&mut self) { + self.prev_close = None; + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // One seed bar for `prev_close`, then `period` flows to fill the window. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "TSV" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Tsv::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let t = Tsv::new(18).unwrap(); + assert_eq!(t.period(), 18); + assert_eq!(t.name(), "TSV"); + assert_eq!(t.warmup_period(), 19); + } + + #[test] + fn constant_close_yields_zero() { + // Flat close -> every flow is zero -> rolling sum stays at zero. + let candles: Vec = (0..30).map(|i| c(10.0, 100.0, i)).collect(); + let mut t = Tsv::new(5).unwrap(); + for v in t.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_window_sum() { + // closes = [10, 11, 13, 12, 14, 15] + // volumes = [.., 100, 200, 150, 50, 200] + // flows = [None, (1)*100=100, (2)*200=400, (-1)*150=-150, (2)*50=100, (1)*200=200] + // period = 3: first emission at bar index 3 (the 4th flow, since one bar seeds). + // Wait: bar 0 seeds, bars 1..5 produce 5 flows. Window of 3 fills at the + // 3rd flow, i.e. bar index 3. + // bar 3 -> window = [100, 400, -150] -> sum = 350. + // bar 4 -> window = [400, -150, 100] -> sum = 350. + // bar 5 -> window = [-150, 100, 200] -> sum = 150. + let mut t = Tsv::new(3).unwrap(); + let out = t.batch(&[ + c(10.0, 50.0, 0), + c(11.0, 100.0, 1), + c(13.0, 200.0, 2), + c(12.0, 150.0, 3), + c(14.0, 50.0, 4), + c(15.0, 200.0, 5), + ]); + assert!(out[0].is_none() && out[1].is_none() && out[2].is_none()); + assert_relative_eq!(out[3].unwrap(), 350.0, epsilon = 1e-9); + assert_relative_eq!(out[4].unwrap(), 350.0, epsilon = 1e-9); + assert_relative_eq!(out[5].unwrap(), 150.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + (i % 7) as f64 * 10.0, + i, + ) + }) + .collect(); + let mut a = Tsv::new(18).unwrap(); + let mut b = Tsv::new(18).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40).map(|i| c(10.0 + i as f64, 100.0, i)).collect(); + let mut t = Tsv::new(10).unwrap(); + t.batch(&candles); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/ttm_squeeze.rs b/crates/wickra-core/src/indicators/ttm_squeeze.rs new file mode 100644 index 0000000..feed27f --- /dev/null +++ b/crates/wickra-core/src/indicators/ttm_squeeze.rs @@ -0,0 +1,302 @@ +//! TTM Squeeze (John Carter). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::indicators::bollinger::BollingerBands; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TTM Squeeze output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TtmSqueezeOutput { + /// `1.0` while the squeeze is *on* (Bollinger Bands sit inside the Keltner + /// Channel), `0.0` otherwise. The squeeze releases — the signal flips back + /// to `0.0` — when volatility expands and BB pierce KC. + pub squeeze: f64, + /// Detrended momentum: linear-regression endpoint of + /// `close − (midpoint(highest_high, lowest_low, period) + SMA(close, period)) / 2`. + /// Histogram-like reading that swings positive in a breakout up, negative + /// in a breakout down; trade direction on the squeeze release follows the + /// sign of `momentum`. + pub momentum: f64, +} + +/// TTM Squeeze (John Carter): a Bollinger-vs-Keltner volatility squeeze paired +/// with a detrended-close momentum reading. +/// +/// Carter's setup detects coiled markets (low realised volatility relative to +/// ATR) and the *direction* of the breakout when they uncoil: +/// +/// ```text +/// squeeze = 1.0 if BollingerBands(period, bb_mult) +/// ⊂ KeltnerChannels-like(SMA(period), ATR(period), kc_mult) +/// else 0.0 +/// +/// hl_mid = (max(high, period) + min(low, period)) / 2 +/// detrend = close − (hl_mid + SMA(close, period)) / 2 +/// momentum = LinearRegression(detrend, period) // endpoint +/// ``` +/// +/// The "Keltner-like" envelope here uses an *SMA* centerline (not the EMA of +/// typical price that [`Keltner`](crate::Keltner) uses) plus an ATR offset, +/// exactly as Carter's original publication and every chart-vendor +/// implementation define it. Common parameters: `period = 20`, `bb_mult = 2.0`, +/// `kc_mult = 1.5`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TtmSqueeze}; +/// +/// let mut indicator = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TtmSqueeze { + period: usize, + kc_mult: f64, + bb: BollingerBands, + sma_close: Sma, + atr: Atr, + highs: VecDeque, + lows: VecDeque, + closes: VecDeque, + // Pre-computed OLS constants over `x = 0..period − 1`. + sum_x: f64, + denom: f64, +} + +impl TtmSqueeze { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if either multiplier is not strictly + /// positive and finite. `period >= 2` is required for the linear-regression + /// momentum component. + pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "TTM squeeze needs period >= 2 for the momentum regression", + }); + } + if !bb_mult.is_finite() || bb_mult <= 0.0 || !kc_mult.is_finite() || kc_mult <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + let n = period as f64; + let sum_x = n * (n - 1.0) / 2.0; + let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0; + Ok(Self { + period, + kc_mult, + bb: BollingerBands::new(period, bb_mult)?, + sma_close: Sma::new(period)?, + atr: Atr::new(period)?, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + closes: VecDeque::with_capacity(period), + sum_x, + denom: n * sum_xx - sum_x * sum_x, + }) + } + + /// John Carter's classic configuration: `period = 20`, `bb_mult = 2.0`, + /// `kc_mult = 1.5`. + pub fn classic() -> Self { + Self::new(20, 2.0, 1.5).expect("classic TTM Squeeze parameters are valid") + } + + /// Configured `(period, bb_mult, kc_mult)`. + pub fn parameters(&self) -> (usize, f64, f64) { + (self.period, self.bb.multiplier(), self.kc_mult) + } +} + +impl Indicator for TtmSqueeze { + type Input = Candle; + type Output = TtmSqueezeOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + self.closes.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + self.closes.push_back(candle.close); + + // Feed all three sub-indicators unconditionally so they warm up in + // lock-step. ATR returns its first value at bar `period` (Wilder + // seeds), the SMA and BB on bar `period` as well. + let bb = self.bb.update(candle.close); + let mid = self.sma_close.update(candle.close); + let atr = self.atr.update(candle); + let (bb, mid, atr) = (bb?, mid?, atr?); + + let kc_upper = mid + self.kc_mult * atr; + let kc_lower = mid - self.kc_mult * atr; + let squeeze = f64::from(bb.upper <= kc_upper && bb.lower >= kc_lower); + + // Detrended close. The reference forms it as the deviation of close + // from the average of the rolling high-low midpoint and the SMA of + // close, then runs a linear regression of that series. + let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let hl_mid = f64::midpoint(hi, lo); + // Build the detrended window over the closes currently in `closes`. + // We need all `period` closes to fit the regression, which is + // guaranteed once `bb` / `mid` are ready. + let baseline = f64::midpoint(hl_mid, mid); + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + for (i, &c) in self.closes.iter().enumerate() { + let y = c - baseline; + let x = i as f64; + sum_y += y; + sum_xy += x * y; + } + let n = self.period as f64; + let slope = (n * sum_xy - self.sum_x * sum_y) / self.denom; + let intercept = (sum_y - slope * self.sum_x) / n; + let momentum = intercept + slope * (n - 1.0); + + Some(TtmSqueezeOutput { squeeze, momentum }) + } + + fn reset(&mut self) { + self.bb.reset(); + self.sma_close.reset(); + self.atr.reset(); + self.highs.clear(); + self.lows.clear(); + self.closes.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.bb.is_ready() && self.sma_close.is_ready() && self.atr.is_ready() + } + + fn name(&self) -> &'static str { + "TtmSqueeze" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn rejects_invalid_period() { + assert!(TtmSqueeze::new(0, 2.0, 1.5).is_err()); + assert!(TtmSqueeze::new(1, 2.0, 1.5).is_err()); + } + + #[test] + fn rejects_non_positive_multipliers() { + assert!(matches!( + TtmSqueeze::new(20, 0.0, 1.5), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + TtmSqueeze::new(20, 2.0, -1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + TtmSqueeze::new(20, f64::NAN, 1.5), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let s = TtmSqueeze::classic(); + let (p, b, k) = s.parameters(); + assert_eq!(p, 20); + assert_relative_eq!(b, 2.0, epsilon = 1e-12); + assert_relative_eq!(k, 1.5, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 20); + assert_eq!(s.name(), "TtmSqueeze"); + } + + #[test] + fn flat_market_has_zero_momentum() { + let candles: Vec = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + let last = s.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.momentum, 0.0, epsilon = 1e-9); + // With zero volatility both BB and KC collapse to a point, so the + // squeeze is trivially "on". + assert_relative_eq!(last.squeeze, 1.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + let mut b = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i))) + .collect(); + let mut s = TtmSqueeze::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + /// Squeeze fires only after `period` candles, never before. + #[test] + fn warmup_returns_none() { + let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + for i in 0..19 { + let base = 100.0 + f64::from(i); + assert!(s.update(c(base + 1.0, base - 1.0, base)).is_none()); + } + assert!(s.update(c(121.0, 119.0, 120.0)).is_some()); + } + + /// Squeeze flag is binary — `0.0` or `1.0`. + #[test] + fn squeeze_is_binary() { + let candles: Vec = (0..60) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.4).sin() * 2.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap(); + for o in s.batch(&candles).into_iter().flatten() { + assert!(o.squeeze == 0.0 || o.squeeze == 1.0); + } + } +} diff --git a/crates/wickra-core/src/indicators/ttm_trend.rs b/crates/wickra-core/src/indicators/ttm_trend.rs new file mode 100644 index 0000000..7df0194 --- /dev/null +++ b/crates/wickra-core/src/indicators/ttm_trend.rs @@ -0,0 +1,167 @@ +//! TTM Trend — John Carter's bar-coloring trend filter. + +use crate::error::Result; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TTM Trend: compares the current close to the simple moving average of the +/// recent median prices `(high + low) / 2`. A close above that reference colors +/// the bar as an uptrend (`+1.0`); a close at or below it as a downtrend +/// (`-1.0`). +/// +/// ```text +/// reference = SMA((high + low) / 2, period) +/// TTM Trend = +1 if close > reference +/// -1 otherwise +/// ``` +/// +/// The classic TTM Trend uses the trailing six bars. The signal is a regime +/// label rather than a level: it stays `None` during warmup and then emits +/// `±1.0` on every bar. +/// +/// Reference: John Carter, *Mastering the Trade*, 2005. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TtmTrend}; +/// +/// let mut indicator = TtmTrend::new(6).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert_eq!(last, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct TtmTrend { + period: usize, + sma: Sma, +} + +impl TtmTrend { + /// Construct a TTM Trend over the given lookback. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + sma: Sma::new(period)?, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for TtmTrend { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let median = f64::midpoint(candle.high, candle.low); + let reference = self.sma.update(median)?; + Some(if candle.close > reference { 1.0 } else { -1.0 }) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.sma.is_ready() + } + + fn name(&self) -> &'static str { + "TtmTrend" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::traits::BatchExt; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TtmTrend::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let t = TtmTrend::new(6).unwrap(); + assert_eq!(t.period(), 6); + assert_eq!(t.warmup_period(), 6); + assert_eq!(t.name(), "TtmTrend"); + assert!(!t.is_ready()); + } + + #[test] + fn warmup_then_emits() { + let mut t = TtmTrend::new(3).unwrap(); + let candles: Vec = (0..3).map(|i| candle(13.0, 9.0, 12.0, i)).collect(); + let out = t.batch(&candles); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_some()); + } + + #[test] + fn close_above_reference_is_uptrend() { + // Close (12) sits above the median reference (13 + 9) / 2 = 11 -> +1. + let mut t = TtmTrend::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect(); + assert_eq!(t.batch(&candles).last().unwrap().unwrap(), 1.0); + } + + #[test] + fn close_at_or_below_reference_is_downtrend() { + // Constant median 10, close equal to the reference -> not strictly above -> -1. + let mut t = TtmTrend::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + assert_eq!(t.batch(&candles).last().unwrap().unwrap(), -1.0); + } + + #[test] + fn reset_clears_state() { + let mut t = TtmTrend::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect(); + t.batch(&candles); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.25).sin() * 4.0; + candle(base + 1.0, base - 1.0, base + (i as f64 * 0.5).cos(), i) + }) + .collect(); + let mut a = TtmTrend::new(6).unwrap(); + let mut b = TtmTrend::new(6).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/turn_of_month.rs b/crates/wickra-core/src/indicators/turn_of_month.rs new file mode 100644 index 0000000..f28eb79 --- /dev/null +++ b/crates/wickra-core/src/indicators/turn_of_month.rs @@ -0,0 +1,275 @@ +//! Turn-of-Month Effect — the mean daily return of sessions that fall inside the +//! turn-of-month window (the last `n_last` and first `n_first` days of a month). + +use crate::calendar::{civil_from_timestamp, days_in_month}; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Whether a day-of-month lies in the turn-of-month window. +/// +/// The window is the first `n_first` calendar days plus the last `n_last` days of +/// the month (`days_in_month - n_last < dom`). +fn in_turn_window(dom: u32, dim: u32, n_first: u32, n_last: u32) -> bool { + dom <= n_first || dom > dim.saturating_sub(n_last) +} + +/// Turn-of-Month effect: the running mean of daily close-to-close returns for the +/// sessions that fall in the turn-of-month window. +/// +/// Each completed session (the wall-clock day of +/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`) +/// contributes its return `close / previous_close - 1`. Only sessions whose +/// day-of-month is within the first `n_first` or last `n_last` days of their month +/// are averaged; the rest are ignored. The classic effect uses `n_first = 3`, +/// `n_last = 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TurnOfMonth}; +/// +/// let day = 24 * 3_600_000; +/// // 2021-01-29 .. 02-02 — all turn-of-month days with n_first=3, n_last=1. +/// let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); +/// let start = 1_611_878_400_000; // 2021-01-29 00:00 UTC +/// let mut last = None; +/// for (i, close) in [100.0, 101.0, 102.0, 103.0].iter().enumerate() { +/// let ts = start + i as i64 * day; +/// last = tom.update(Candle::new(*close, *close, *close, *close, 1.0, ts).unwrap()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TurnOfMonth { + n_first: u32, + n_last: u32, + utc_offset_minutes: i32, + day: Option<(i64, u32, u32)>, + cur_close: f64, + prev_day_close: Option, + sum: f64, + count: u64, +} + +impl TurnOfMonth { + /// Construct a Turn-of-Month indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if both `n_first` and `n_last` are zero (the + /// window would never include a day). + pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> Result { + if n_first == 0 && n_last == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + n_first, + n_last, + utc_offset_minutes, + day: None, + cur_close: 0.0, + prev_day_close: None, + sum: 0.0, + count: 0, + }) + } + + /// Classic turn-of-month window: first 3 and last 1 day of the month. + pub fn classic() -> Self { + Self::new(3, 1, 0).expect("classic turn-of-month window is valid") + } + + /// Configured `(n_first, n_last, utc_offset_minutes)`. + pub const fn params(&self) -> (u32, u32, i32) { + (self.n_first, self.n_last, self.utc_offset_minutes) + } + + /// Most recent mean turn-of-month return if any in-window day has completed. + pub fn value(&self) -> Option { + if self.count == 0 { + None + } else { + Some(self.sum / self.count as f64) + } + } + + /// Settle the just-finished day `(year, month, dom)` whose last close is + /// `self.cur_close`, then start `next_key`. + fn roll_into( + &mut self, + year: i64, + month: u32, + dom: u32, + next_key: (i64, u32, u32), + close: f64, + ) { + if let Some(prev) = self.prev_day_close { + let ret = if prev == 0.0 { + 0.0 + } else { + self.cur_close / prev - 1.0 + }; + if in_turn_window(dom, days_in_month(year, month), self.n_first, self.n_last) { + self.sum += ret; + self.count += 1; + } + } + self.prev_day_close = Some(self.cur_close); + self.day = Some(next_key); + self.cur_close = close; + } +} + +impl Indicator for TurnOfMonth { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let key = (civil.year, civil.month, civil.day); + match self.day { + Some(prev) if prev == key => { + self.cur_close = candle.close; + } + Some((year, month, dom)) => { + self.roll_into(year, month, dom, key, candle.close); + } + None => { + self.day = Some(key); + self.cur_close = candle.close; + } + } + self.value() + } + + fn reset(&mut self) { + self.day = None; + self.cur_close = 0.0; + self.prev_day_close = None; + self.sum = 0.0; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.count > 0 + } + + fn name(&self) -> &'static str { + "TurnOfMonth" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const DAY: i64 = 24 * 3_600_000; + // 2021-01-28 00:00 UTC. + const JAN28_2021: i64 = 1_611_792_000_000; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn window_predicate_branches() { + // First-days branch. + assert!(in_turn_window(1, 31, 3, 1)); + assert!(in_turn_window(3, 31, 3, 1)); + assert!(!in_turn_window(4, 31, 3, 1)); + // Last-days branch. + assert!(in_turn_window(31, 31, 3, 1)); + assert!(!in_turn_window(30, 31, 3, 1)); + // Saturating subtraction when n_last exceeds the month length. + assert!(in_turn_window(1, 28, 0, 40)); + } + + #[test] + fn rejects_empty_window() { + assert!(matches!(TurnOfMonth::new(0, 0, 0), Err(Error::PeriodZero))); + } + + #[test] + fn metadata_and_accessors() { + let tom = TurnOfMonth::classic(); + assert_eq!(tom.params(), (3, 1, 0)); + assert_eq!(tom.name(), "TurnOfMonth"); + assert_eq!(tom.warmup_period(), 2); + assert!(!tom.is_ready()); + assert!(tom.value().is_none()); + } + + #[test] + fn averages_in_window_returns_only() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + // 2021-01-28 (out of window, no prior close): close 100. + assert!(tom.update(c(100.0, JAN28_2021)).is_none()); + // 2021-01-29 (out of window: dom 29, dim 31 -> 29 <= 30): return ignored. + assert!(tom.update(c(110.0, JAN28_2021 + DAY)).is_none()); + // 2021-01-30 (out of window): completes 01-29; still none. + assert!(tom.update(c(120.0, JAN28_2021 + 2 * DAY)).is_none()); + // 2021-01-31 (last day, in window): completes 01-30 (out). Still none. + assert!(tom.update(c(121.0, JAN28_2021 + 3 * DAY)).is_none()); + // 2021-02-01 (first day, in window): completes 01-31 (in window). + // return = 121 / 120 - 1. + let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap(); + assert_relative_eq!(v, 121.0 / 120.0 - 1.0); + assert!(tom.is_ready()); + } + + #[test] + fn zero_prev_close_contributes_zero() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + // 2021-01-30 closes at 0 — becomes the prior close for 01-31. + tom.update(c(0.0, JAN28_2021 + 2 * DAY)); + // 2021-01-31 (last day, in window): finalizes 01-30 with no prior -> no + // contribution, but records prev_day_close = 0. + tom.update(c(5.0, JAN28_2021 + 3 * DAY)); + // 2021-02-01 (in window): finalizes 01-31 with prev_close 0 -> ret 0. + let v = tom.update(c(50.0, JAN28_2021 + 4 * DAY)).unwrap(); + assert_relative_eq!(v, 0.0); + } + + #[test] + fn same_day_bars_use_latest_close() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + // 2021-01-30 closes at 100 (prior day, sets prev_day_close). + tom.update(c(100.0, JAN28_2021 + 2 * DAY)); + // 2021-01-31 two bars on the same day; the later close (120) wins. + tom.update(c(110.0, JAN28_2021 + 3 * DAY)); + tom.update(c(120.0, JAN28_2021 + 3 * DAY + 3_600_000)); + // 2021-02-01 (in window) finalizes 01-31: return = 120 / 100 - 1 = 0.20. + let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap(); + assert_relative_eq!(v, 0.20); + } + + #[test] + fn reset_clears_state() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + tom.update(c(121.0, JAN28_2021 + 3 * DAY)); + tom.update(c(130.0, JAN28_2021 + 4 * DAY)); + tom.reset(); + assert!(!tom.is_ready()); + assert!(tom.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(100.0 + f64::from(i), JAN28_2021 + i64::from(i) * DAY)) + .collect(); + let mut a = TurnOfMonth::new(3, 2, 0).unwrap(); + let mut b = TurnOfMonth::new(3, 2, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/tweezer.rs b/crates/wickra-core/src/indicators/tweezer.rs new file mode 100644 index 0000000..4aeb155 --- /dev/null +++ b/crates/wickra-core/src/indicators/tweezer.rs @@ -0,0 +1,223 @@ +//! Tweezer Top / Bottom candlestick pattern. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Tweezer — a 2-bar reversal pattern where two consecutive candles share an +/// extreme. +/// +/// ```text +/// tol = tolerance * |prev.high| + tolerance * |prev.low| (per leg) +/// tweezer_top = |curr.high − prev.high| <= tol_high +/// tweezer_bot = |curr.low − prev.low| <= tol_low +/// ``` +/// +/// The output is `−1.0` for a Tweezer Top (matched highs), `+1.0` for a +/// Tweezer Bottom (matched lows), and `0.0` otherwise. If *both* extremes +/// match — a flat pair of candles — the bottom wins by convention (bullish +/// rejection of the low). `tolerance` defaults to `0.001` (10 bps relative) +/// and must lie in `[0, 1)`. +/// +/// Pattern-shape check only — no trend filter is applied; combine with a trend +/// indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector already emits the uniform candlestick sign convention shared +/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no +/// pattern — so it drops straight into a machine-learning feature matrix where +/// the bullish and bearish variants of the pattern occupy a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Tweezer}; +/// +/// let mut indicator = Tweezer::new(); +/// indicator.update(Candle::new(11.0, 12.0, 9.5, 9.6, 1.0, 0).unwrap()); +/// // Matching low. +/// let out = indicator.update(Candle::new(9.7, 10.5, 9.5, 10.2, 1.0, 1).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Tweezer { + tolerance: f64, + prev: Option, + has_emitted: bool, +} + +impl Default for Tweezer { + fn default() -> Self { + Self::new() + } +} + +impl Tweezer { + /// Construct a Tweezer detector with the default relative tolerance (1e-3). + pub const fn new() -> Self { + Self { + tolerance: 0.001, + prev: None, + has_emitted: false, + } + } + + /// Construct a Tweezer detector with a custom relative tolerance. + /// + /// `tolerance` must lie in `[0, 1)`. + pub fn with_tolerance(tolerance: f64) -> Result { + if !(0.0..1.0).contains(&tolerance) { + return Err(Error::InvalidPeriod { + message: "tweezer tolerance must lie in [0, 1)", + }); + } + Ok(Self { + tolerance, + prev: None, + has_emitted: false, + }) + } + + /// Configured relative tolerance. + pub fn tolerance(&self) -> f64 { + self.tolerance + } +} + +impl Indicator for Tweezer { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let prev = self.prev; + self.prev = Some(candle); + let Some(p) = prev else { + return Some(0.0); + }; + let tol_high = self.tolerance * p.high.abs().max(candle.high.abs()); + let tol_low = self.tolerance * p.low.abs().max(candle.low.abs()); + let match_low = (candle.low - p.low).abs() <= tol_low; + let match_high = (candle.high - p.high).abs() <= tol_high; + if match_low { + Some(1.0) + } else if match_high { + Some(-1.0) + } else { + Some(0.0) + } + } + + fn reset(&mut self) { + self.prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Tweezer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_tolerance() { + assert!(Tweezer::with_tolerance(-0.01).is_err()); + assert!(Tweezer::with_tolerance(1.0).is_err()); + } + + #[test] + fn accepts_valid_tolerance() { + let t = Tweezer::with_tolerance(0.0).unwrap(); + assert!((t.tolerance() - 0.0).abs() < 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let t = Tweezer::default(); + assert_eq!(t.name(), "Tweezer"); + assert_eq!(t.warmup_period(), 2); + assert!(!t.is_ready()); + assert!((t.tolerance() - 0.001).abs() < 1e-12); + } + + #[test] + fn tweezer_bottom_is_plus_one() { + let mut t = Tweezer::new(); + assert_eq!(t.update(c(11.0, 12.0, 9.5, 9.6, 0)), Some(0.0)); + // Matching low 9.5. + assert_eq!(t.update(c(9.7, 10.5, 9.5, 10.2, 1)), Some(1.0)); + } + + #[test] + fn tweezer_top_is_minus_one() { + let mut t = Tweezer::new(); + assert_eq!(t.update(c(9.0, 12.0, 8.5, 11.0, 0)), Some(0.0)); + // Matching high 12.0. + assert_eq!(t.update(c(11.5, 12.0, 11.0, 11.4, 1)), Some(-1.0)); + } + + #[test] + fn distinct_extremes_yield_zero() { + let mut t = Tweezer::new(); + t.update(c(10.0, 11.0, 9.0, 10.5, 0)); + assert_eq!(t.update(c(10.6, 11.5, 9.6, 11.2, 1)), Some(0.0)); + } + + #[test] + fn first_bar_returns_zero() { + let mut t = Tweezer::new(); + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0)); + } + + #[test] + fn matched_both_extremes_prefers_bottom() { + // Identical candles match both highs and lows -> bottom (+1.0). + let mut t = Tweezer::new(); + t.update(c(10.0, 11.0, 9.0, 10.5, 0)); + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 1)), Some(1.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (i as f64 * 0.1).sin(); + c(base, base + 2.0, base - 2.0, base + 0.5, i) + }) + .collect(); + let mut a = Tweezer::new(); + let mut b = Tweezer::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Tweezer::new(); + t.update(c(10.0, 11.0, 9.0, 10.5, 0)); + t.update(c(10.0, 11.0, 9.0, 10.5, 1)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/twiggs_money_flow.rs b/crates/wickra-core/src/indicators/twiggs_money_flow.rs new file mode 100644 index 0000000..6ad08c2 --- /dev/null +++ b/crates/wickra-core/src/indicators/twiggs_money_flow.rs @@ -0,0 +1,314 @@ +//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range** +/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum. +/// +/// ```text +/// TRH = max(high, prev_close) (true high) +/// TRL = min(low, prev_close) (true low) +/// ad = volume * (2*close − TRH − TRL) / (TRH − TRL) (0 if TRH == TRL) +/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period) +/// ``` +/// +/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces +/// the bar's raw high/low with the *true* high/low (folding in the prior close so +/// gaps count), and it smooths the accumulated money flow and the volume with a +/// Wilder exponential average rather than a flat `period`-sum, so the oscillator +/// reacts faster and never jumps when a large bar drops out of a window. The +/// output is bounded in roughly `[−1, +1]`: positive means buying pressure +/// (closes biased toward the true high), negative means selling pressure. +/// +/// The first candle seeds the reference close; the next `period` bars seed both +/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch +/// of zero volume makes the denominator average `0`, in which case the oscillator +/// reports `0` rather than `0 / 0`. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow}; +/// +/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TwiggsMoneyFlow { + period: usize, + prev_close: Option, + seed_ad: f64, + seed_vol: f64, + seed_count: usize, + ad_ema: Option, + vol_ema: Option, + last: Option, +} + +impl TwiggsMoneyFlow { + /// Construct a new Twiggs Money Flow with the given smoothing `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_close: None, + seed_ad: 0.0, + seed_vol: 0.0, + seed_count: 0, + ad_ema: None, + vol_ema: None, + last: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn ratio(ad_ema: f64, vol_ema: f64) -> f64 { + if vol_ema == 0.0 { + 0.0 + } else { + ad_ema / vol_ema + } + } +} + +impl Indicator for TwiggsMoneyFlow { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let trh = candle.high.max(prev_close); + let trl = candle.low.min(prev_close); + let range = trh - trl; + let ad = if range > 0.0 { + candle.volume * (2.0 * candle.close - trh - trl) / range + } else { + 0.0 + }; + self.prev_close = Some(candle.close); + + if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) { + let n = self.period as f64; + let new_ad = ad_ema + (ad - ad_ema) / n; + let new_vol = vol_ema + (candle.volume - vol_ema) / n; + self.ad_ema = Some(new_ad); + self.vol_ema = Some(new_vol); + let v = Self::ratio(new_ad, new_vol); + self.last = Some(v); + return Some(v); + } + + self.seed_ad += ad; + self.seed_vol += candle.volume; + self.seed_count += 1; + if self.seed_count == self.period { + let n = self.period as f64; + let ad_ema = self.seed_ad / n; + let vol_ema = self.seed_vol / n; + self.ad_ema = Some(ad_ema); + self.vol_ema = Some(vol_ema); + let v = Self::ratio(ad_ema, vol_ema); + self.last = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.prev_close = None; + self.seed_ad = 0.0; + self.seed_vol = 0.0; + self.seed_count = 0; + self.ad_ema = None; + self.vol_ema = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TwiggsMoneyFlow" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn flat_bars_drive_tmf_to_zero() { + // A flat bar (high == low == close == prior close) gives a zero two-bar + // range, so the accumulation term falls back to 0.0 and TMF settles at + // zero. Exercises the `range == 0` guard. + let mut tmf = TwiggsMoneyFlow::new(2).unwrap(); + let flat: Vec = (0..6) + .map(|_| candle(100.0, 100.0, 100.0, 1_000.0)) + .collect(); + let last = tmf.batch(&flat).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let tmf = TwiggsMoneyFlow::new(21).unwrap(); + assert_eq!(tmf.period(), 21); + assert_eq!(tmf.warmup_period(), 22); + assert_eq!(tmf.name(), "TwiggsMoneyFlow"); + assert!(!tmf.is_ready()); + assert_eq!(tmf.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..8) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base, 1_000.0) + }) + .collect(); + let out = tmf.batch(&candles); + // warmup_period == period + 1 == 4: first emission at index 3. + for o in out.iter().take(3) { + assert!(o.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn closes_at_true_high_is_positive() { + // Every bar closes at its high -> strong buying pressure -> TMF -> +1. + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i); + // open=low=base-1, high=close=base+1 -> closes at the top. + Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0) + }) + .collect(); + let last = tmf.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last > 0.9, + "closing at the high should drive TMF near +1, got {last}" + ); + } + + #[test] + fn closes_at_true_low_is_negative() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 - f64::from(i); + // closes at the low. + Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0) + }) + .collect(); + let last = tmf.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last < -0.5, + "closing at the low should drive TMF negative, got {last}" + ); + } + + #[test] + fn zero_volume_yields_zero() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base, 0.0) + }) + .collect(); + for v in tmf.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_in_range() { + let mut tmf = TwiggsMoneyFlow::new(21).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0) + }) + .collect(); + for v in tmf.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base, 1_000.0) + }) + .collect(); + tmf.batch(&candles); + assert!(tmf.is_ready()); + tmf.reset(); + assert!(!tmf.is_ready()); + assert_eq!(tmf.value(), None); + assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i)) + }) + .collect(); + let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles); + let mut b = TwiggsMoneyFlow::new(21).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/two_crows.rs b/crates/wickra-core/src/indicators/two_crows.rs new file mode 100644 index 0000000..c45afc4 --- /dev/null +++ b/crates/wickra-core/src/indicators/two_crows.rs @@ -0,0 +1,186 @@ +//! Two Crows candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Two Crows — a 3-bar bearish reversal pattern that appears after an advance. +/// +/// ```text +/// bar1 green (long white) +/// bar2 red & its body gaps up above bar1's body (bar2.close > bar1.close) +/// bar3 red & opens inside bar2's body (bar2.close < bar3.open < bar2.open) +/// & closes inside bar1's body (bar1.open < bar3.close < bar1.close) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Two Crows is +/// a single-direction (bearish-only) pattern, so it never emits `+1.0`. The +/// first two bars always return `0.0` because the three-bar window is not yet +/// filled. Pattern-shape check only — no trend filter is applied; combine with a +/// trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TwoCrows}; +/// +/// let mut indicator = TwoCrows::new(); +/// indicator.update(Candle::new(10.0, 12.2, 9.9, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(14.0, 14.2, 12.9, 13.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(13.5, 13.6, 10.9, 11.0, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TwoCrows { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl TwoCrows { + /// Construct a new Two Crows detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for TwoCrows { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (pp, p) else { + return Some(0.0); + }; + if bar1.close > bar1.open + && bar2.close < bar2.open + && bar2.close > bar1.close + && candle.close < candle.open + && candle.open < bar2.open + && candle.open > bar2.close + && candle.close > bar1.open + && candle.close < bar1.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TwoCrows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = TwoCrows::new(); + assert_eq!(t.name(), "TwoCrows"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn two_crows_is_minus_one() { + let mut t = TwoCrows::new(); + // bar1 green 10->12; bar2 red 14->13 (body above bar1); bar3 red + // opens 13.5 (inside [13,14]) and closes 11 (inside [10,12]). + assert_eq!(t.update(c(10.0, 12.2, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.2, 12.9, 13.0, 1)), Some(0.0)); + assert_eq!(t.update(c(13.5, 13.6, 10.9, 11.0, 2)), Some(-1.0)); + } + + #[test] + fn no_gap_up_yields_zero() { + let mut t = TwoCrows::new(); + // bar2 red but its body does not gap above bar1's body. + t.update(c(10.0, 12.2, 9.9, 12.0, 0)); + t.update(c(11.5, 12.0, 10.4, 11.0, 1)); + assert_eq!(t.update(c(11.0, 11.2, 9.9, 10.5, 2)), Some(0.0)); + } + + #[test] + fn third_close_below_first_body_yields_zero() { + let mut t = TwoCrows::new(); + t.update(c(10.0, 12.2, 9.9, 12.0, 0)); + t.update(c(14.0, 14.2, 12.9, 13.0, 1)); + // bar3 closes 9.5, below bar1's body low (10) -> not Two Crows. + assert_eq!(t.update(c(13.5, 13.6, 9.4, 9.5, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = TwoCrows::new(); + assert_eq!(t.update(c(10.0, 12.2, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.2, 12.9, 13.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 3 == 0 { + c(base, base + 0.5, base - 1.0, base + 0.4, i) + } else { + c(base + 1.5, base + 1.7, base - 0.2, base + 0.6, i) + } + }) + .collect(); + let mut a = TwoCrows::new(); + let mut b = TwoCrows::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = TwoCrows::new(); + t.update(c(10.0, 12.2, 9.9, 12.0, 0)); + t.update(c(14.0, 14.2, 12.9, 13.0, 1)); + t.update(c(13.5, 13.6, 10.9, 11.0, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 12.2, 9.9, 12.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/typical_price.rs b/crates/wickra-core/src/indicators/typical_price.rs new file mode 100644 index 0000000..343828a --- /dev/null +++ b/crates/wickra-core/src/indicators/typical_price.rs @@ -0,0 +1,128 @@ +//! Typical Price. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Typical Price — the bar's `(high + low + close) / 3`. +/// +/// A single representative price per bar that weights the close no more +/// heavily than the two extremes. It is the price series that +/// [`Cci`](crate::Cci) and [`Mfi`](crate::Mfi) are built on, and a common +/// input to feed other indicators in place of the raw close. As a stateless +/// per-bar transform it emits a value from the very first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TypicalPrice}; +/// +/// let mut indicator = TypicalPrice::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TypicalPrice { + has_emitted: bool, +} + +impl TypicalPrice { + /// Construct a new Typical Price transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TypicalPrice { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.typical_price()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TypicalPrice" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9. + let mut tp = TypicalPrice::new(); + assert_relative_eq!( + tp.update(candle(9.0, 12.0, 6.0, 9.0, 0)).unwrap(), + 9.0, + epsilon = 1e-12 + ); + } + + /// Cover the Indicator-impl `name` body (62-64). + #[test] + fn name_metadata() { + let tp = TypicalPrice::new(); + assert_eq!(tp.name(), "TypicalPrice"); + } + + #[test] + fn emits_from_first_candle() { + let mut tp = TypicalPrice::new(); + assert_eq!(tp.warmup_period(), 1); + assert!(!tp.is_ready()); + assert!(tp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(tp.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut tp = TypicalPrice::new(); + tp.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(tp.is_ready()); + tp.reset(); + assert!(!tp.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = TypicalPrice::new(); + let mut b = TypicalPrice::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/ulcer_index.rs b/crates/wickra-core/src/indicators/ulcer_index.rs new file mode 100644 index 0000000..3bfe225 --- /dev/null +++ b/crates/wickra-core/src/indicators/ulcer_index.rs @@ -0,0 +1,341 @@ +//! Ulcer Index. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ulcer Index — Peter Martin's downside-only volatility / risk measure. +/// +/// Standard deviation punishes upside and downside moves equally; the Ulcer +/// Index measures only the **pain of drawdowns**. For each bar it computes the +/// percentage drop from the highest price of the trailing window, squares it, +/// and reports the root-mean-square over the window: +/// +/// ```text +/// drawdown_t = 100 · (price_t − max(price, period)_t) / max(price, period)_t +/// UlcerIndex = √( mean( drawdown² over period ) ) +/// ``` +/// +/// A pure up-trend never trades below its own running high, so its Ulcer Index +/// is `0`; the deeper and longer the drawdowns, the higher the reading. It is +/// the volatility measure of choice for risk-adjusted return ratios (the +/// "Martin ratio" / UPI). +/// +/// Each `update` is amortised O(1): the trailing maximum is tracked with a +/// monotonically-decreasing deque of `(index, price)` pairs, so the indicator +/// honours the `Indicator` trait's O(1)-per-tick contract even for long +/// windows. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, UlcerIndex}; +/// +/// let mut indicator = UlcerIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 8.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct UlcerIndex { + period: usize, + /// 1-based count of finite inputs seen so far; used as the monotonic index + /// that expires entries from `max_dq`. + count: u64, + /// Monotonically-decreasing deque of `(index, price)` over the trailing + /// `period` inputs. The front holds the current trailing maximum in O(1). + max_dq: VecDeque<(u64, f64)>, + /// Rolling window of the last `period` squared percentage drawdowns. + drawdowns_sq: VecDeque, + sum_sq: f64, + last: Option, +} + +impl UlcerIndex { + /// Construct a new Ulcer Index with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + count: 0, + max_dq: VecDeque::with_capacity(period), + drawdowns_sq: VecDeque::with_capacity(period), + sum_sq: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for UlcerIndex { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.last; + } + self.count += 1; + // Drop tail entries that can never be the trailing max again — every + // entry `≤ input` is dominated by `input` and at least as old. + while let Some(&(_, back)) = self.max_dq.back() { + if back <= input { + self.max_dq.pop_back(); + } else { + break; + } + } + self.max_dq.push_back((self.count, input)); + // Expire the head once it falls out of the trailing `period`-window. + let window_lo = self.count.saturating_sub(self.period as u64 - 1); + while let Some(&(idx, _)) = self.max_dq.front() { + if idx < window_lo { + self.max_dq.pop_front(); + } else { + break; + } + } + if self.count < self.period as u64 { + return None; + } + // Front is the trailing max in O(1). + let max_price = self.max_dq.front().expect("non-empty").1; + let drawdown = if max_price == 0.0 { + 0.0 + } else { + 100.0 * (input - max_price) / max_price + }; + let sq = drawdown * drawdown; + + if self.drawdowns_sq.len() == self.period { + self.sum_sq -= self.drawdowns_sq.pop_front().expect("window is non-empty"); + } + self.drawdowns_sq.push_back(sq); + self.sum_sq += sq; + if self.drawdowns_sq.len() < self.period { + return None; + } + let ui = (self.sum_sq / self.period as f64).sqrt(); + self.last = Some(ui); + Some(ui) + } + + fn reset(&mut self) { + self.count = 0; + self.max_dq.clear(); + self.drawdowns_sq.clear(); + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // `period` inputs fill the trailing-max window; the first drawdown is + // computable on bar `period` (the window is full for the first time); + // another `period - 1` drawdowns then fill the RMS window. The two + // windows overlap by one bar, so `warmup_period() == 2 * period - 1`. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "UlcerIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(UlcerIndex::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (lines 77-85) and the + /// Indicator-impl `name` body (162-164). `warmup_period` is covered + /// already by `reference_values`. + #[test] + fn accessors_and_metadata() { + let mut ui = UlcerIndex::new(14).unwrap(); + assert_eq!(ui.period(), 14); + assert_eq!(ui.name(), "UlcerIndex"); + assert_eq!(ui.value(), None); + // Drive past warmup so value() flips to Some. + for i in 0..ui.warmup_period() { + ui.update(100.0 + (i as f64).sin() * 5.0); + } + assert!(ui.value().is_some()); + } + + /// Cover the `max_price == 0.0` defensive branch (line 123). All + /// other tests use prices > 0, so the trailing-max divisor is always + /// positive. Feed a stream of zeros — the trailing max is exactly + /// 0.0 and the drawdown computation would otherwise hit a 0/0 NaN. + /// The indicator must emit exactly 0.0 (drawdown is 0% by convention). + #[test] + fn zero_max_price_yields_zero_drawdown() { + let mut ui = UlcerIndex::new(3).unwrap(); + let out = ui.batch(&[0.0_f64; 10]); + let last = out.into_iter().flatten().last().expect("emits"); + assert_eq!(last, 0.0); + } + + #[test] + fn reference_values() { + // UlcerIndex(2): warmup = 3. + // [10, 8, 12, 9]: + // bar 3: window [8,12], max 12, drawdown 0; sq window [400, 0] + // -> UI = sqrt(200). + // bar 4: window [12,9], max 12, drawdown -25, sq 625; sq window [0, 625] + // -> UI = sqrt(312.5). + let mut ui = UlcerIndex::new(2).unwrap(); + let out = ui.batch(&[10.0, 8.0, 12.0, 9.0]); + assert_eq!(ui.warmup_period(), 3); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 200.0_f64.sqrt(), epsilon = 1e-12); + assert_relative_eq!(out[3].unwrap(), 312.5_f64.sqrt(), epsilon = 1e-12); + } + + #[test] + fn pure_uptrend_yields_zero() { + // Price never trades below its own running high: no drawdown at all. + let mut ui = UlcerIndex::new(5).unwrap(); + let out = ui.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().skip(ui.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut ui = UlcerIndex::new(5).unwrap(); + let out = ui.batch(&[50.0; 30]); + for v in out.iter().skip(ui.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut ui = UlcerIndex::new(14).unwrap(); + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 15.0) + .collect(); + for v in ui.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "Ulcer Index must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut ui = UlcerIndex::new(2).unwrap(); + let out = ui.batch(&[10.0, 8.0, 12.0, 9.0]); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(ui.update(f64::NAN), last); + assert_eq!(ui.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut ui = UlcerIndex::new(3).unwrap(); + ui.batch(&[10.0, 8.0, 12.0, 9.0, 11.0, 7.0]); + assert!(ui.is_ready()); + ui.reset(); + assert!(!ui.is_ready()); + assert_eq!(ui.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let batch = UlcerIndex::new(14).unwrap().batch(&prices); + let mut b = UlcerIndex::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + /// Monotone-deque equivalence: the O(1) implementation must produce exactly + /// the same per-tick values as a naive O(n) trailing-max scan, on inputs + /// chosen to exercise every deque-maintenance path: + /// strictly increasing (everything is dominated and gets popped), + /// strictly decreasing (nothing is popped, head expires when the window + /// slides), + /// and constants (ties — the `<= input` pop rule keeps a single newest + /// entry). + #[test] + fn monotone_deque_matches_naive_max_on_adversarial_inputs() { + fn naive_max(prices: &[f64], period: usize, t: usize) -> f64 { + let lo = t + 1 - period; + prices[lo..=t] + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max) + } + + fn check(prices: &[f64], period: usize) { + let mut ui = UlcerIndex::new(period).unwrap(); + for (i, p) in prices.iter().enumerate() { + let _ = ui.update(*p); + if i + 1 >= period { + let trailing_max = ui.max_dq.front().expect("non-empty").1; + let naive = naive_max(prices, period, i); + assert!( + (trailing_max - naive).abs() < 1e-12, + "trailing max diverges at t={i}: deque={trailing_max}, naive={naive}", + ); + } + } + } + + // Strictly increasing — every push pops the entire deque tail. + let increasing: Vec = (1..=50).map(f64::from).collect(); + check(&increasing, 5); + check(&increasing, 14); + + // Strictly decreasing — pushes never pop the tail; the head expires. + let decreasing: Vec = (1..=50).rev().map(f64::from).collect(); + check(&decreasing, 5); + check(&decreasing, 14); + + // All-equal — `back <= input` pops on equality, leaving a length-1 + // deque containing only the most recent index. + let constant = vec![42.0; 50]; + check(&constant, 5); + check(&constant, 14); + + // Mixed sawtooth — exercises every code path. + let mixed: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 20.0) + .collect(); + check(&mixed, 7); + check(&mixed, 30); + } +} diff --git a/crates/wickra-core/src/indicators/ultimate_oscillator.rs b/crates/wickra-core/src/indicators/ultimate_oscillator.rs new file mode 100644 index 0000000..da53089 --- /dev/null +++ b/crates/wickra-core/src/indicators/ultimate_oscillator.rs @@ -0,0 +1,328 @@ +//! Ultimate Oscillator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Ultimate Oscillator — Larry Williams' three-timeframe momentum oscillator. +/// +/// A single-timeframe oscillator can give false divergence signals when the +/// chosen lookback does not match the swing being measured. The Ultimate +/// Oscillator blends *three* lookbacks into one bounded `[0, 100]` reading, +/// weighting the fastest most heavily: +/// +/// ```text +/// true_low_t = min(low_t, close_{t−1}) +/// BP_t = close_t − true_low_t (buying pressure) +/// TR_t = max(high_t, close_{t−1}) − true_low_t (true range) +/// avg_n = Σ BP over n / Σ TR over n +/// UO = 100 · (4·avg_short + 2·avg_mid + avg_long) / 7 +/// ``` +/// +/// The conventional periods are `7`, `14` and `28`. A fully flat window (zero +/// true range) contributes the neutral ratio `0.5`, so a flat market reads +/// `50`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, UltimateOscillator}; +/// +/// let mut indicator = UltimateOscillator::new(7, 14, 28).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct UltimateOscillator { + short: usize, + mid: usize, + long: usize, + longest: usize, + prev_close: Option, + /// Rolling window of `(buying_pressure, true_range)` pairs. + window: VecDeque<(f64, f64)>, + sum_bp_short: f64, + sum_tr_short: f64, + sum_bp_mid: f64, + sum_tr_mid: f64, + sum_bp_long: f64, + sum_tr_long: f64, + pairs: usize, + last: Option, +} + +impl UltimateOscillator { + /// Construct a new Ultimate Oscillator with the three lookback periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is `0`. + pub fn new(short: usize, mid: usize, long: usize) -> Result { + if short == 0 || mid == 0 || long == 0 { + return Err(Error::PeriodZero); + } + let longest = short.max(mid).max(long); + Ok(Self { + short, + mid, + long, + longest, + prev_close: None, + window: VecDeque::with_capacity(longest + 1), + sum_bp_short: 0.0, + sum_tr_short: 0.0, + sum_bp_mid: 0.0, + sum_tr_mid: 0.0, + sum_bp_long: 0.0, + sum_tr_long: 0.0, + pairs: 0, + last: None, + }) + } + + /// Classic Ultimate Oscillator: periods `7`, `14`, `28`. + pub fn classic() -> Self { + Self::new(7, 14, 28).expect("classic Ultimate Oscillator periods are valid") + } + + /// The `(short, mid, long)` periods. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.short, self.mid, self.long) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for UltimateOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + // The first bar has no previous close, so no BP/TR can be formed. + self.prev_close = Some(candle.close); + return None; + }; + self.prev_close = Some(candle.close); + + let true_low = candle.low.min(prev_close); + let bp = candle.close - true_low; + let tr = candle.high.max(prev_close) - true_low; + + self.window.push_back((bp, tr)); + let n = self.window.len(); + self.sum_bp_short += bp; + self.sum_tr_short += tr; + self.sum_bp_mid += bp; + self.sum_tr_mid += tr; + self.sum_bp_long += bp; + self.sum_tr_long += tr; + if n > self.short { + let (b, t) = self.window[n - 1 - self.short]; + self.sum_bp_short -= b; + self.sum_tr_short -= t; + } + if n > self.mid { + let (b, t) = self.window[n - 1 - self.mid]; + self.sum_bp_mid -= b; + self.sum_tr_mid -= t; + } + if n > self.long { + let (b, t) = self.window[n - 1 - self.long]; + self.sum_bp_long -= b; + self.sum_tr_long -= t; + } + if self.window.len() > self.longest { + self.window.pop_front(); + } + + self.pairs += 1; + if self.pairs < self.longest { + return None; + } + + let avg = |bp_sum: f64, tr_sum: f64| { + if tr_sum == 0.0 { + // A fully flat window has no range; contribute the midpoint. + 0.5 + } else { + bp_sum / tr_sum + } + }; + let avg_short = avg(self.sum_bp_short, self.sum_tr_short); + let avg_mid = avg(self.sum_bp_mid, self.sum_tr_mid); + let avg_long = avg(self.sum_bp_long, self.sum_tr_long); + let uo = 100.0 * (4.0 * avg_short + 2.0 * avg_mid + avg_long) / 7.0; + self.last = Some(uo); + Some(uo) + } + + fn reset(&mut self) { + self.prev_close = None; + self.window.clear(); + self.sum_bp_short = 0.0; + self.sum_tr_short = 0.0; + self.sum_bp_mid = 0.0; + self.sum_tr_mid = 0.0; + self.sum_bp_long = 0.0; + self.sum_tr_long = 0.0; + self.pairs = 0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first BP/TR pair needs a previous close, then the longest window + // must fill. + self.longest + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "UltimateOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Build a flat candle (open = high = low = close). + fn flat(price: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + UltimateOscillator::new(0, 14, 28), + Err(Error::PeriodZero) + )); + assert!(matches!( + UltimateOscillator::new(7, 0, 28), + Err(Error::PeriodZero) + )); + assert!(matches!( + UltimateOscillator::new(7, 14, 0), + Err(Error::PeriodZero) + )); + } + + /// Cover the const accessors `periods` / `value` (96-103) and the + /// Indicator-impl `name` body (193-195). `warmup_period` is covered + /// by `first_emission_at_warmup_period`. + #[test] + fn accessors_and_metadata() { + let mut uo = UltimateOscillator::new(7, 14, 28).unwrap(); + assert_eq!(uo.periods(), (7, 14, 28)); + assert_eq!(uo.name(), "UltimateOscillator"); + assert_eq!(uo.value(), None); + let warmup = i64::try_from(uo.warmup_period()).unwrap(); + let candles: Vec = (0..warmup) + .map(|i| { + let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; + Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap() + }) + .collect(); + for c in &candles { + uo.update(*c); + } + assert!(uo.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut uo = UltimateOscillator::new(2, 3, 5).unwrap(); + assert_eq!(uo.warmup_period(), 6); + let candles: Vec = (0..20).map(|i| flat(100.0 + i as f64, i)).collect(); + let out = uo.batch(&candles); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn pure_uptrend_saturates_at_100() { + // Each flat candle closes higher: BP == TR every bar, so every ratio + // is 1 and UO is 100. + let mut uo = UltimateOscillator::new(2, 3, 5).unwrap(); + let candles: Vec = (0..30).map(|i| flat(100.0 + i as f64, i)).collect(); + for v in uo.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn pure_downtrend_saturates_at_0() { + // Each flat candle closes lower: BP is 0 every bar, so UO is 0. + let mut uo = UltimateOscillator::new(2, 3, 5).unwrap(); + let candles: Vec = (0..30).map(|i| flat(100.0 - i as f64, i)).collect(); + for v in uo.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn flat_market_reads_50() { + // Every bar identical: zero true range everywhere -> neutral 50. + let mut uo = UltimateOscillator::new(2, 3, 5).unwrap(); + let candles: Vec = (0..30).map(|i| flat(100.0, i)).collect(); + for v in uo.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 50.0, epsilon = 1e-9); + } + } + + #[test] + fn output_stays_within_0_100() { + let mut uo = UltimateOscillator::classic(); + let candles: Vec = (0..200) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.2).sin() * 12.0; + Candle::new(mid, mid + 3.0, mid - 3.0, mid + 1.0, 10.0, i).unwrap() + }) + .collect(); + for v in uo.batch(&candles).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "UO out of range: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut uo = UltimateOscillator::new(2, 3, 5).unwrap(); + let candles: Vec = (0..20).map(|i| flat(100.0 + i as f64, i)).collect(); + uo.batch(&candles); + assert!(uo.is_ready()); + uo.reset(); + assert!(!uo.is_ready()); + assert_eq!(uo.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0; + Candle::new(mid, mid + 2.0, mid - 2.0, mid + 0.5, 10.0, i).unwrap() + }) + .collect(); + let batch = UltimateOscillator::classic().batch(&candles); + let mut b = UltimateOscillator::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/unique_three_river.rs b/crates/wickra-core/src/indicators/unique_three_river.rs new file mode 100644 index 0000000..acdec06 --- /dev/null +++ b/crates/wickra-core/src/indicators/unique_three_river.rs @@ -0,0 +1,256 @@ +//! Unique Three River candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Unique Three River (Bottom) — a 3-bar bullish reversal. A long black candle is +/// followed by a smaller black candle whose body sits inside the first but whose +/// long lower shadow probes a new low, then a small white candle that stays below +/// the second body. The fresh low that fails to hold marks an exhausted decline. +/// +/// ```text +/// bar1 long black: open1 − close1 >= 0.5 * (high1 − low1) +/// bar2 black, body inside bar1's body, with a new low (low2 < low1) +/// bar3 small white, contained below bar2's body (high3 <= close2) +/// small body: close3 − open3 <= 0.3 * (high3 − low3) +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Unique Three +/// River is a single-direction (bullish-only) reversal, so it never emits `−1.0`. +/// The first two bars always return `0.0` because the three-bar window is not yet +/// filled. Body thresholds follow the geometric house style rather than TA-Lib's +/// rolling averages. Pattern-shape check only — no trend filter is applied; combine +/// with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, UniqueThreeRiver}; +/// +/// let mut indicator = UniqueThreeRiver::new(); +/// indicator.update(Candle::new(15.0, 15.1, 10.0, 10.5, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(14.0, 14.1, 9.0, 11.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(10.2, 10.9, 9.5, 10.4, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct UniqueThreeRiver { + c1: Option, + c2: Option, + has_emitted: bool, +} + +impl UniqueThreeRiver { + /// Construct a new Unique Three River detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + has_emitted: false, + } + } +} + +impl Indicator for UniqueThreeRiver { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + self.c1 = self.c2; + self.c2 = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + // bar1 is a long black body. + if bar1.open <= bar1.close { + return Some(0.0); + } + let range1 = bar1.high - bar1.low; + if bar1.open - bar1.close < 0.5 * range1 { + return Some(0.0); + } + // bar2 is black, its body inside bar1's body, with a new low. + if bar2.open <= bar2.close { + return Some(0.0); + } + if bar2.open > bar1.open || bar2.close < bar1.close { + return Some(0.0); + } + if bar2.low >= bar1.low { + return Some(0.0); + } + // bar3 is a small white candle contained below bar2's body. + if candle.close <= candle.open { + return Some(0.0); + } + let range3 = candle.high - candle.low; + if candle.close - candle.open > 0.3 * range3 { + return Some(0.0); + } + if candle.high > bar2.close { + return Some(0.0); + } + Some(1.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "UniqueThreeRiver" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = UniqueThreeRiver::new(); + assert_eq!(t.name(), "UniqueThreeRiver"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn unique_three_river_is_plus_one() { + let mut t = UniqueThreeRiver::new(); + assert_eq!(t.update(c(15.0, 15.1, 10.0, 10.5, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 9.0, 11.0, 1)), Some(0.0)); + assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(1.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = UniqueThreeRiver::new(); + assert_eq!(t.update(c(15.0, 15.1, 10.0, 10.5, 0)), Some(0.0)); + assert_eq!(t.update(c(14.0, 14.1, 9.0, 11.0, 1)), Some(0.0)); + } + + #[test] + fn first_bar_not_black_yields_zero() { + let mut t = UniqueThreeRiver::new(); + // bar1 white. + t.update(c(10.5, 15.1, 10.0, 15.0, 0)); + t.update(c(14.0, 14.1, 9.0, 11.0, 1)); + assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0)); + } + + #[test] + fn first_bar_short_body_yields_zero() { + let mut t = UniqueThreeRiver::new(); + // bar1 black but its body is short relative to range. + t.update(c(15.0, 15.1, 10.0, 14.5, 0)); + t.update(c(14.0, 14.1, 9.0, 11.0, 1)); + assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0)); + } + + #[test] + fn second_bar_not_black_yields_zero() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + // bar2 white. + t.update(c(11.0, 14.1, 9.0, 13.0, 1)); + assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0)); + } + + #[test] + fn second_bar_not_inside_yields_zero() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + // bar2 black but opens above bar1's open -> body not inside. + t.update(c(16.0, 16.1, 9.0, 11.0, 1)); + assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0)); + } + + #[test] + fn second_bar_no_new_low_yields_zero() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + // bar2 black, inside, but does not make a new low. + t.update(c(14.0, 14.1, 10.5, 11.0, 1)); + assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0)); + } + + #[test] + fn third_bar_not_white_yields_zero() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + t.update(c(14.0, 14.1, 9.0, 11.0, 1)); + // bar3 black. + assert_eq!(t.update(c(10.6, 10.9, 9.5, 10.2, 2)), Some(0.0)); + } + + #[test] + fn third_bar_large_body_yields_zero() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + t.update(c(14.0, 14.1, 9.0, 11.0, 1)); + // bar3 white but with a large body. + assert_eq!(t.update(c(9.6, 10.9, 9.5, 10.8, 2)), Some(0.0)); + } + + #[test] + fn third_bar_not_below_second_yields_zero() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + t.update(c(14.0, 14.1, 9.0, 11.0, 1)); + // bar3 small white but pokes above bar2's close. + assert_eq!(t.update(c(10.5, 11.5, 10.4, 10.7, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 200.0 - i as f64; + c(base, base + 0.1, base - 5.2, base - 5.0, i) + }) + .collect(); + let mut a = UniqueThreeRiver::new(); + let mut b = UniqueThreeRiver::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = UniqueThreeRiver::new(); + t.update(c(15.0, 15.1, 10.0, 10.5, 0)); + t.update(c(14.0, 14.1, 9.0, 11.0, 1)); + t.update(c(10.2, 10.9, 9.5, 10.4, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(15.0, 15.1, 10.0, 10.5, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/universal_oscillator.rs b/crates/wickra-core/src/indicators/universal_oscillator.rs new file mode 100644 index 0000000..47599f3 --- /dev/null +++ b/crates/wickra-core/src/indicators/universal_oscillator.rs @@ -0,0 +1,254 @@ +//! Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle. +#![allow(clippy::doc_markdown)] + +use crate::error::{Error, Result}; +use crate::indicators::super_smoother::SuperSmoother; +use crate::traits::Indicator; + +/// Ehlers' **Universal Oscillator** — a cycle oscillator that whitens the price +/// series, SuperSmooths it, then normalises with an automatic gain control (AGC) +/// to swing in `[−1, +1]`. +/// +/// From John Ehlers' *Cycle Analytics for Traders* (2013): +/// +/// ```text +/// WhiteNoise = (price_t − price_{t−2}) / 2 (flat-spectrum prewhitening) +/// Filt = SuperSmoother(WhiteNoise, period) +/// Peak = max(|Filt|, 0.991 · Peak_{t−1}) (decaying peak / AGC) +/// Universal = Filt / Peak (0 if Peak == 0) +/// ``` +/// +/// "Whitening" the input (a two-bar difference) flattens its power spectrum so the +/// SuperSmoother responds equally to all cycles rather than being dominated by the +/// trend. The automatic gain control divides by a slowly-decaying running peak, so +/// the output is amplitude-normalised to `[−1, +1]` and behaves consistently +/// across instruments and volatility regimes — hence "universal". Read it like any +/// bounded oscillator: turns near the rails flag cycle extremes, zero-crossings +/// flag cycle direction changes. +/// +/// The first value lands once a two-bar difference exists (`warmup_period == 3`). +/// Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, UniversalOscillator}; +/// +/// let mut indicator = UniversalOscillator::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct UniversalOscillator { + period: usize, + smoother: SuperSmoother, + prev_price_1: Option, + prev_price_2: Option, + peak: f64, + last: Option, +} + +impl UniversalOscillator { + /// Construct a Universal Oscillator with the given SuperSmoother `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + smoother: SuperSmoother::new(period)?, + prev_price_1: None, + prev_price_2: None, + peak: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for UniversalOscillator { + type Input = f64; + type Output = f64; + + fn update(&mut self, price: f64) -> Option { + if !price.is_finite() { + return self.last; + } + let Some(p2) = self.prev_price_2 else { + self.prev_price_2 = self.prev_price_1; + self.prev_price_1 = Some(price); + return None; + }; + let white_noise = (price - p2) / 2.0; + if !white_noise.is_finite() { + // `price - p2` can overflow to +/-inf even when both are finite; + // skip the bar rather than feeding a non-finite value downstream. + self.prev_price_2 = self.prev_price_1; + self.prev_price_1 = Some(price); + return self.last; + } + let filt = self + .smoother + .update(white_noise) + .expect("supersmoother emits"); + self.peak = filt.abs().max(0.991 * self.peak); + let universal = if self.peak > 0.0 { + (filt / self.peak).clamp(-1.0, 1.0) + } else { + 0.0 + }; + self.prev_price_2 = self.prev_price_1; + self.prev_price_1 = Some(price); + self.last = Some(universal); + Some(universal) + } + + fn reset(&mut self) { + self.smoother.reset(); + self.prev_price_1 = None; + self.prev_price_2 = None; + self.peak = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "UniversalOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + UniversalOscillator::new(0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let u = UniversalOscillator::new(20).unwrap(); + assert_eq!(u.period(), 20); + assert_eq!(u.warmup_period(), 3); + assert_eq!(u.name(), "UniversalOscillator"); + assert!(!u.is_ready()); + assert_eq!(u.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut u = UniversalOscillator::new(20).unwrap(); + let out = u.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(out[0].is_none()); + assert!(out[1].is_none()); + assert!(out[2].is_some()); + } + + #[test] + fn constant_input_is_zero() { + // A flat input whitens to zero -> output 0. + let mut u = UniversalOscillator::new(20).unwrap(); + for v in u.batch(&[50.0; 200]).into_iter().flatten() { + assert!(v.abs() < 1e-9); + } + } + + #[test] + fn output_in_range() { + let mut u = UniversalOscillator::new(20).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + for v in u.batch(&xs).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn cyclic_input_swings_both_signs() { + let mut u = UniversalOscillator::new(20).unwrap(); + let xs: Vec = (0..400) + .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0) + .collect(); + let out: Vec = u.batch(&xs).into_iter().flatten().skip(100).collect(); + assert!(out.iter().any(|&v| v > 0.5)); + assert!(out.iter().any(|&v| v < -0.5)); + } + + #[test] + fn ignores_non_finite() { + let mut u = UniversalOscillator::new(20).unwrap(); + u.batch( + &(0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin()) + .collect::>(), + ); + let before = u.value(); + assert_eq!(u.update(f64::NAN), before); + } + + #[test] + fn reset_clears_state() { + let mut u = UniversalOscillator::new(20).unwrap(); + u.batch( + &(0..40) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin()) + .collect::>(), + ); + assert!(u.is_ready()); + u.reset(); + assert!(!u.is_ready()); + assert_eq!(u.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let xs: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = UniversalOscillator::new(20).unwrap().batch(&xs); + let mut b = UniversalOscillator::new(20).unwrap(); + let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_white_noise_is_skipped() { + // `price - p2` can overflow to infinity even when both prices are + // finite; the non-finite white-noise term must be skipped, not fed to + // the smoother (which would otherwise yield `None` on the first bar). + let mut u = UniversalOscillator::new(20).unwrap(); + assert_eq!(u.update(-1e308), None); + assert_eq!(u.update(0.0), None); + // (1e308 - (-1e308)) overflows to +inf -> white_noise non-finite. + assert_eq!(u.update(1e308), None); + } +} diff --git a/crates/wickra-core/src/indicators/up_down_volume_ratio.rs b/crates/wickra-core/src/indicators/up_down_volume_ratio.rs new file mode 100644 index 0000000..d8e8f06 --- /dev/null +++ b/crates/wickra-core/src/indicators/up_down_volume_ratio.rs @@ -0,0 +1,143 @@ +//! Up/Down Volume Ratio — advancing volume divided by declining volume. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Up/Down Volume Ratio — total advancing volume divided by total declining +/// volume across a universe. +/// +/// On each [`CrossSection`] tick the ratio is `advancing volume / declining +/// volume`. A reading above one means more volume is trading in advancing issues +/// than declining ones (accumulation); a reading below one means distribution. +/// Sustained extremes are used to flag breadth thrusts and washout bottoms. +/// +/// When a tick has no declining volume the denominator is floored to `1.0`, so the +/// ratio stays finite (it degrades to the advancing-volume total) instead of +/// dividing by zero. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, UpDownVolumeRatio}; +/// +/// let mut udv = UpDownVolumeRatio::new(); +/// // advancing volume 150, declining volume 50 -> ratio 3.0. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 150.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(udv.update(tick), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct UpDownVolumeRatio { + has_emitted: bool, +} + +impl UpDownVolumeRatio { + /// Construct a new Up/Down Volume Ratio indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for UpDownVolumeRatio { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancing_volume = section.advancing_volume(); + let declining_volume = section.declining_volume().max(1.0); + self.has_emitted = true; + Some(advancing_volume / declining_volume) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "UpDownVolumeRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let udv = UpDownVolumeRatio::new(); + assert_eq!(udv.name(), "UpDownVolumeRatio"); + assert_eq!(udv.warmup_period(), 1); + assert!(!udv.is_ready()); + } + + #[test] + fn first_tick_emits_ratio() { + let mut udv = UpDownVolumeRatio::new(); + assert_eq!(udv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(3.0)); + assert!(udv.is_ready()); + } + + #[test] + fn zero_declining_volume_floors_denominator() { + let mut udv = UpDownVolumeRatio::new(); + // advancing volume 100, declining volume 0 -> 100 / max(0, 1) = 100.0. + assert_eq!(udv.update(tick(&[(1.0, 100.0)])), Some(100.0)); + } + + #[test] + fn reset_clears_state() { + let mut udv = UpDownVolumeRatio::new(); + udv.update(tick(&[(1.0, 10.0), (-1.0, 10.0)])); + assert!(udv.is_ready()); + udv.reset(); + assert!(!udv.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 150.0), (-1.0, 50.0)]), + tick(&[(1.0, 100.0)]), + tick(&[(1.0, 20.0), (-1.0, 80.0)]), + ]; + let mut a = UpDownVolumeRatio::new(); + let mut b = UpDownVolumeRatio::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/upside_gap_three_methods.rs b/crates/wickra-core/src/indicators/upside_gap_three_methods.rs new file mode 100644 index 0000000..906ab0d --- /dev/null +++ b/crates/wickra-core/src/indicators/upside_gap_three_methods.rs @@ -0,0 +1,210 @@ +//! Upside Gap Three Methods candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Upside Gap Three Methods — a 3-bar bullish continuation. Two white candles +/// advance with an upside body gap between them, then a black candle opens inside +/// the second body and closes inside the first body, partially filling the gap +/// without erasing the prior advance. +/// +/// ```text +/// bar1 white, bar2 white +/// upside body gap: open2 > close1 (bar2's body sits entirely above bar1's) +/// bar3 black, opens within bar2's body and closes within bar1's body +/// ``` +/// +/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Upside Gap +/// Three Methods is a single-direction (bullish-only) continuation, so it never +/// emits `−1.0`; its bearish mirror is [`crate::DownsideGapThreeMethods`]. The +/// first two bars always return `0.0` because the three-bar window is not yet +/// filled. Pattern-shape check only — no trend filter is applied; combine with a +/// trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, UpsideGapThreeMethods}; +/// +/// let mut indicator = UpsideGapThreeMethods::new(); +/// indicator.update(Candle::new(10.0, 11.2, 9.8, 11.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(12.0, 13.2, 11.9, 13.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(12.5, 12.6, 10.4, 10.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct UpsideGapThreeMethods { + c1: Option, + c2: Option, + has_emitted: bool, +} + +impl UpsideGapThreeMethods { + /// Construct a new Upside Gap Three Methods detector. + pub const fn new() -> Self { + Self { + c1: None, + c2: None, + has_emitted: false, + } + } +} + +impl Indicator for UpsideGapThreeMethods { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let bar1 = self.c1; + let bar2 = self.c2; + self.c1 = self.c2; + self.c2 = Some(candle); + let (Some(bar1), Some(bar2)) = (bar1, bar2) else { + return Some(0.0); + }; + // bar1 and bar2 are both white. + if bar1.close <= bar1.open || bar2.close <= bar2.open { + return Some(0.0); + } + // Upside body gap: bar2's body sits entirely above bar1's. + if bar2.open <= bar1.close { + return Some(0.0); + } + // bar3 is black. + if candle.close >= candle.open { + return Some(0.0); + } + // bar3 opens within bar2's body and closes within bar1's body. + if candle.open > bar2.open + && candle.open < bar2.close + && candle.close > bar1.open + && candle.close < bar1.close + { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.c1 = None; + self.c2 = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "UpsideGapThreeMethods" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let t = UpsideGapThreeMethods::new(); + assert_eq!(t.name(), "UpsideGapThreeMethods"); + assert_eq!(t.warmup_period(), 3); + assert!(!t.is_ready()); + } + + #[test] + fn upside_gap_three_methods_is_plus_one() { + let mut t = UpsideGapThreeMethods::new(); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 13.2, 11.9, 13.0, 1)), Some(0.0)); + assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(1.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut t = UpsideGapThreeMethods::new(); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + assert_eq!(t.update(c(12.0, 13.2, 11.9, 13.0, 1)), Some(0.0)); + } + + #[test] + fn non_white_first_bars_yield_zero() { + let mut t = UpsideGapThreeMethods::new(); + // bar1 is black. + t.update(c(11.0, 11.2, 9.8, 10.0, 0)); + t.update(c(12.0, 13.2, 11.9, 13.0, 1)); + assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(0.0)); + } + + #[test] + fn no_gap_yields_zero() { + let mut t = UpsideGapThreeMethods::new(); + t.update(c(10.0, 13.2, 9.8, 13.0, 0)); + // bar2 opens below bar1's close -> no upside body gap. + t.update(c(11.0, 13.2, 10.9, 12.5, 1)); + assert_eq!(t.update(c(12.0, 12.6, 10.4, 10.5, 2)), Some(0.0)); + } + + #[test] + fn third_bar_not_black_yields_zero() { + let mut t = UpsideGapThreeMethods::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 13.2, 11.9, 13.0, 1)); + // bar3 white. + assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(0.0)); + } + + #[test] + fn third_bar_outside_bodies_yields_zero() { + let mut t = UpsideGapThreeMethods::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 13.2, 11.9, 13.0, 1)); + // bar3 black but closes below bar1's body. + assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 2)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base, base + 5.2, base - 0.1, base + 5.0, i) + }) + .collect(); + let mut a = UpsideGapThreeMethods::new(); + let mut b = UpsideGapThreeMethods::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = UpsideGapThreeMethods::new(); + t.update(c(10.0, 11.2, 9.8, 11.0, 0)); + t.update(c(12.0, 13.2, 11.9, 13.0, 1)); + t.update(c(12.5, 12.6, 10.4, 10.5, 2)); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0)); + } +} diff --git a/crates/wickra-core/src/indicators/upside_gap_two_crows.rs b/crates/wickra-core/src/indicators/upside_gap_two_crows.rs new file mode 100644 index 0000000..423a848 --- /dev/null +++ b/crates/wickra-core/src/indicators/upside_gap_two_crows.rs @@ -0,0 +1,189 @@ +//! Upside Gap Two Crows candlestick pattern. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Upside Gap Two Crows — a 3-bar bearish reversal that appears after an +/// advance. Two black candles gap up above a long white candle; the second +/// black candle engulfs the first crow yet still closes above the white body, +/// leaving the upside gap open. +/// +/// ```text +/// bar1 green (long white) +/// bar2 red & its body gaps up above bar1's body (bar2.close > bar1.close) +/// bar3 red & opens above bar2's open (bar3.open > bar2.open) +/// & closes below bar2's close (bar3.close < bar2.close) +/// & closes above bar1's close (bar3.close > bar1.close) +/// ``` +/// +/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Upside Gap +/// Two Crows is a single-direction (bearish-only) pattern, so it never emits +/// `+1.0`. The first two bars always return `0.0` because the three-bar window +/// is not yet filled. Pattern-shape check only — no trend filter is applied; +/// combine with a trend indicator for actionable signals. +/// +/// # Signed ±1 encoding +/// +/// This detector emits the uniform candlestick sign convention shared across the +/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into +/// a machine-learning feature matrix as a single dimension. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, UpsideGapTwoCrows}; +/// +/// let mut indicator = UpsideGapTwoCrows::new(); +/// indicator.update(Candle::new(10.0, 12.2, 9.9, 12.0, 1.0, 0).unwrap()); +/// indicator.update(Candle::new(14.0, 14.2, 12.9, 13.0, 1.0, 1).unwrap()); +/// let out = indicator +/// .update(Candle::new(15.0, 15.2, 12.4, 12.5, 1.0, 2).unwrap()); +/// assert_eq!(out, Some(-1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct UpsideGapTwoCrows { + prev: Option, + prev_prev: Option, + has_emitted: bool, +} + +impl UpsideGapTwoCrows { + /// Construct a new Upside Gap Two Crows detector. + pub const fn new() -> Self { + Self { + prev: None, + prev_prev: None, + has_emitted: false, + } + } +} + +impl Indicator for UpsideGapTwoCrows { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let pp = self.prev_prev; + let p = self.prev; + self.prev_prev = self.prev; + self.prev = Some(candle); + let (Some(bar1), Some(bar2)) = (pp, p) else { + return Some(0.0); + }; + if bar1.close > bar1.open + && bar2.close < bar2.open + && bar2.close > bar1.close + && candle.close < candle.open + && candle.open > bar2.open + && candle.close < bar2.close + && candle.close > bar1.close + { + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.prev = None; + self.prev_prev = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 3 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "UpsideGapTwoCrows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let u = UpsideGapTwoCrows::new(); + assert_eq!(u.name(), "UpsideGapTwoCrows"); + assert_eq!(u.warmup_period(), 3); + assert!(!u.is_ready()); + } + + #[test] + fn upside_gap_two_crows_is_minus_one() { + let mut u = UpsideGapTwoCrows::new(); + // bar1 green 10->12; bar2 red 14->13 gapping up; bar3 red opens 15 + // (above bar2 open) and closes 12.5 (below bar2 close, above bar1 close). + assert_eq!(u.update(c(10.0, 12.2, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(u.update(c(14.0, 14.2, 12.9, 13.0, 1)), Some(0.0)); + assert_eq!(u.update(c(15.0, 15.2, 12.4, 12.5, 2)), Some(-1.0)); + } + + #[test] + fn closing_the_first_gap_yields_zero() { + let mut u = UpsideGapTwoCrows::new(); + u.update(c(10.0, 12.2, 9.9, 12.0, 0)); + u.update(c(14.0, 14.2, 12.9, 13.0, 1)); + // bar3 closes 11.5, below bar1's close (12) -> gap closed, not the pattern. + assert_eq!(u.update(c(15.0, 15.2, 11.4, 11.5, 2)), Some(0.0)); + } + + #[test] + fn no_gap_up_yields_zero() { + let mut u = UpsideGapTwoCrows::new(); + u.update(c(10.0, 12.2, 9.9, 12.0, 0)); + // bar2's body does not gap above bar1's body. + u.update(c(11.5, 12.0, 10.4, 11.0, 1)); + assert_eq!(u.update(c(12.0, 12.2, 10.9, 11.5, 2)), Some(0.0)); + } + + #[test] + fn first_two_bars_return_zero() { + let mut u = UpsideGapTwoCrows::new(); + assert_eq!(u.update(c(10.0, 12.2, 9.9, 12.0, 0)), Some(0.0)); + assert_eq!(u.update(c(14.0, 14.2, 12.9, 13.0, 1)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + if i % 3 == 0 { + c(base, base + 0.5, base - 1.0, base + 0.4, i) + } else { + c(base + 1.5, base + 1.7, base - 0.2, base + 0.6, i) + } + }) + .collect(); + let mut a = UpsideGapTwoCrows::new(); + let mut b = UpsideGapTwoCrows::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut u = UpsideGapTwoCrows::new(); + u.update(c(10.0, 12.2, 9.9, 12.0, 0)); + u.update(c(14.0, 14.2, 12.9, 13.0, 1)); + u.update(c(15.0, 15.2, 12.4, 12.5, 2)); + assert!(u.is_ready()); + u.reset(); + assert!(!u.is_ready()); + assert_eq!(u.update(c(10.0, 12.2, 9.9, 12.0, 0)), Some(0.0)); + } +} 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 0000000..2f2b04d --- /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/indicators/value_area.rs b/crates/wickra-core/src/indicators/value_area.rs new file mode 100644 index 0000000..be82368 --- /dev/null +++ b/crates/wickra-core/src/indicators/value_area.rs @@ -0,0 +1,430 @@ +//! Value Area (Point of Control + Value Area High / Low). +//! +//! Market-profile-style volume distribution over the last `period` candles, +//! bucketed into `bin_count` price bins. Each candle's volume is spread +//! uniformly across its `[low, high]` range (bin-approximation); single-print +//! bars (`low == high`) dump their whole volume into a single bin. The +//! Point of Control (POC) is the bin with the highest cumulative volume; the +//! Value Area expands outward from the POC, always absorbing the +//! higher-volume neighbour next, until the configured percentage of total +//! volume (default 70%) is enclosed. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Value Area output: Point of Control, Value Area High and Value Area Low. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ValueAreaOutput { + /// Point of Control — price of the bin with the highest cumulative volume. + pub poc: f64, + /// Value Area High — upper bound of the bins that together hold + /// `value_area_pct` of the rolling-window volume. + pub vah: f64, + /// Value Area Low — lower bound of those same bins. + pub val: f64, +} + +/// Rolling Value Area indicator over the last `period` candles. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ValueArea}; +/// +/// let mut va = ValueArea::new(5, 50, 0.70).unwrap(); +/// for i in 0..10 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// va.update(candle); +/// } +/// assert!(va.is_ready()); +/// ``` +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone)] +pub struct ValueArea { + period: usize, + bin_count: usize, + value_area_pct: f64, + window: VecDeque, + last: Option, +} + +impl ValueArea { + /// Construct a Value Area indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero, + /// and [`Error::InvalidPeriod`] if `value_area_pct` is not in `(0, 1]`. + pub fn new(period: usize, bin_count: usize, value_area_pct: f64) -> Result { + if period == 0 || bin_count == 0 { + return Err(Error::PeriodZero); + } + if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 { + return Err(Error::InvalidPeriod { + message: "value_area_pct must be in (0, 1]", + }); + } + Ok(Self { + period, + bin_count, + value_area_pct, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Classic Value Area: 20-bar rolling window, 50 bins, 70% concentration. + pub fn classic() -> Self { + Self::new(20, 50, 0.70).expect("classic ValueArea params are valid") + } + + /// Configured `(period, bin_count, value_area_pct)`. + pub const fn params(&self) -> (usize, usize, f64) { + (self.period, self.bin_count, self.value_area_pct) + } + + /// Most recent output if available. + pub const fn value(&self) -> Option { + self.last + } + + fn compute(&self) -> ValueAreaOutput { + // Window-wide low / high spans the histogram domain. + let mut win_low = f64::INFINITY; + let mut win_high = f64::NEG_INFINITY; + for c in &self.window { + if c.low < win_low { + win_low = c.low; + } + if c.high > win_high { + win_high = c.high; + } + } + let span = win_high - win_low; + let mut bins = vec![0.0_f64; self.bin_count]; + + // Distribute each candle's volume across its [low, high] range. A + // degenerate `low == high` bar drops its entire volume into one bin. + if span <= 0.0 { + // All bars are single-print at the same price — POC = that price, + // VAH = VAL = that price. + let total: f64 = self.window.iter().map(|c| c.volume).sum(); + bins[0] = total; + return ValueAreaOutput { + poc: win_low, + vah: win_low, + val: win_low, + }; + } + let bin_width = span / self.bin_count as f64; + for c in &self.window { + if c.volume == 0.0 { + continue; + } + if c.high <= c.low { + let idx = self.price_to_bin(c.low, win_low, bin_width); + bins[idx] += c.volume; + continue; + } + let lo_idx = self.price_to_bin(c.low, win_low, bin_width); + let hi_idx = self.price_to_bin(c.high, win_low, bin_width); + let touched = hi_idx - lo_idx + 1; + let share = c.volume / touched as f64; + for b in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *b += share; + } + } + + let total: f64 = bins.iter().sum(); + // POC = bin with highest volume. + let mut poc_idx = 0_usize; + let mut poc_vol = bins[0]; + for (i, v) in bins.iter().enumerate().skip(1) { + if *v > poc_vol { + poc_vol = *v; + poc_idx = i; + } + } + + // Expand Value Area outward from POC. At each step take the + // higher-volume neighbour (up or down). Equal volumes break upward, + // matching the CME convention. The loop condition guarantees at + // least one of `can_go_up` / `can_go_down` is true on every body + // entry, so the inner `else` branch is always reachable. + let target = total * self.value_area_pct; + let mut accumulated = poc_vol; + let mut lo = poc_idx; + let mut hi = poc_idx; + while accumulated < target && (lo > 0 || hi + 1 < self.bin_count) { + let can_go_up = hi + 1 < self.bin_count; + let can_go_down = lo > 0; + let up_v = if can_go_up { + bins[hi + 1] + } else { + f64::NEG_INFINITY + }; + let down_v = if can_go_down { + bins[lo - 1] + } else { + f64::NEG_INFINITY + }; + if can_go_up && (up_v >= down_v || !can_go_down) { + hi += 1; + accumulated += up_v; + } else { + lo -= 1; + accumulated += down_v; + } + } + + let bin_mid = |i: usize| win_low + bin_width * (i as f64 + 0.5); + ValueAreaOutput { + poc: bin_mid(poc_idx), + vah: win_low + bin_width * (hi as f64 + 1.0), + val: win_low + bin_width * lo as f64, + } + } + + fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize { + // Clamp the float into [0, bin_count - 1] before casting so the + // `as usize` step cannot overflow or wrap. + let raw = ((price - win_low) / bin_width).floor(); + let max = (self.bin_count - 1) as f64; + raw.clamp(0.0, max) as usize + } +} + +impl Indicator for ValueArea { + type Input = Candle; + type Output = ValueAreaOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "ValueArea" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(ValueArea::new(0, 50, 0.7), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_zero_bin_count() { + assert!(matches!(ValueArea::new(20, 0, 0.7), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_invalid_value_area_pct() { + assert!(matches!( + ValueArea::new(20, 50, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ValueArea::new(20, 50, 1.5), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ValueArea::new(20, 50, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let v = ValueArea::new(20, 50, 0.7).unwrap(); + assert_eq!(v.params(), (20, 50, 0.7)); + assert_eq!(v.name(), "ValueArea"); + assert_eq!(v.warmup_period(), 20); + assert!(v.value().is_none()); + } + + #[test] + fn classic_is_constructible() { + let v = ValueArea::classic(); + assert_eq!(v.params(), (20, 50, 0.70)); + } + + #[test] + fn warmup_emits_after_period() { + let mut v = ValueArea::new(5, 10, 0.7).unwrap(); + for i in 0..4 { + let base = 100.0; + assert!(v + .update(c(base, base + 1.0, base - 1.0, base, 10.0, i)) + .is_none()); + } + let out = v + .update(c(100.0, 101.0, 99.0, 100.0, 10.0, 4)) + .expect("ready after period"); + // All five bars are identical, so POC == bar mid; VAH/VAL bracket + // the window high/low. + assert!(out.vah >= out.poc); + assert!(out.poc >= out.val); + assert!(v.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (i as f64).sin(); + c(base, base + 1.0, base - 1.0, base, 10.0 + i as f64, i) + }) + .collect(); + let mut a = ValueArea::new(10, 20, 0.7).unwrap(); + let mut b = ValueArea::new(10, 20, 0.7).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(100.0, 101.0, 99.0, 100.0, 10.0, i)) + .collect(); + let mut v = ValueArea::new(5, 10, 0.7).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } + + #[test] + fn constant_single_print_yields_collapsed_value_area() { + // Every bar trades at exactly 100 (low == high == 100) — the + // histogram has zero span so POC == VAH == VAL == 100. + let candles: Vec = (0..10) + .map(|i| c(100.0, 100.0, 100.0, 100.0, 5.0, i)) + .collect(); + let mut v = ValueArea::new(5, 20, 0.7).unwrap(); + let out = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(out.poc, 100.0, epsilon = 1e-12); + assert_relative_eq!(out.vah, 100.0, epsilon = 1e-12); + assert_relative_eq!(out.val, 100.0, epsilon = 1e-12); + } + + #[test] + fn single_print_bar_in_mixed_window_dumps_volume_into_one_bin() { + // Mix of wide-range bars (drive the window's span > 0) and one + // single-print bar at price 102 with massive volume. The single-print + // bar must dump its entire volume into one bin, making the POC land + // exactly on the bin that contains 102. + let candles = vec![ + c(100.0, 100.5, 99.5, 100.0, 1.0, 0), + c(100.0, 100.5, 99.5, 100.0, 1.0, 1), + c(102.0, 102.0, 102.0, 102.0, 1000.0, 2), + c(100.0, 100.5, 99.5, 100.0, 1.0, 3), + c(100.0, 100.5, 99.5, 100.0, 1.0, 4), + ]; + let mut v = ValueArea::new(5, 50, 0.70).unwrap(); + let out = v.batch(&candles).into_iter().flatten().last().unwrap(); + // POC must sit in the high-volume bin that holds price 102. + assert!( + (101.9..=102.1).contains(&out.poc), + "POC {} not near 102", + out.poc + ); + } + + #[test] + fn concentrated_volume_locates_poc_at_high_volume_bar() { + // Bars 0..3 sit at price 100 with volume 1; bar 4 dumps massive + // volume at price 110. POC must land near 110. + let mut candles = vec![ + c(100.0, 100.5, 99.5, 100.0, 1.0, 0), + c(100.0, 100.5, 99.5, 100.0, 1.0, 1), + c(100.0, 100.5, 99.5, 100.0, 1.0, 2), + c(100.0, 100.5, 99.5, 100.0, 1.0, 3), + ]; + candles.push(c(110.0, 110.5, 109.5, 110.0, 1000.0, 4)); + let mut v = ValueArea::new(5, 50, 0.70).unwrap(); + let out = v.batch(&candles).into_iter().flatten().last().unwrap(); + // POC must fall inside the high-volume bar's [low, high] range; ties + // among equal-volume bins resolve to the lowest index, so the POC + // sits on the left edge of bar 4's range rather than at its midpoint. + assert!( + (109.5..=110.5).contains(&out.poc), + "POC {} not inside [109.5, 110.5]", + out.poc + ); + // VAH and VAL bracket POC. + assert!(out.vah >= out.poc); + assert!(out.val <= out.poc); + } + + #[test] + fn value_area_brackets_point_of_control() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + (i as f64).cos() * 2.0; + c(base, base + 0.5, base - 0.5, base, 10.0, i) + }) + .collect(); + let mut v = ValueArea::new(15, 30, 0.70).unwrap(); + for o in v.batch(&candles).into_iter().flatten() { + assert!(o.vah >= o.poc, "VAH {} < POC {}", o.vah, o.poc); + assert!(o.val <= o.poc, "VAL {} > POC {}", o.val, o.poc); + } + } + + #[test] + fn zero_volume_bars_are_skipped_in_histogram() { + // Only bar 4 carries any volume — POC must land at its mid. + let candles = vec![ + c(100.0, 100.5, 99.5, 100.0, 0.0, 0), + c(100.0, 100.5, 99.5, 100.0, 0.0, 1), + c(100.0, 100.5, 99.5, 100.0, 0.0, 2), + c(100.0, 100.5, 99.5, 100.0, 0.0, 3), + c(100.0, 100.5, 99.5, 100.0, 50.0, 4), + ]; + let mut v = ValueArea::new(5, 20, 0.7).unwrap(); + let out = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(out.poc.is_finite()); + assert!(out.vah.is_finite()); + assert!(out.val.is_finite()); + } +} diff --git a/crates/wickra-core/src/indicators/value_at_risk.rs b/crates/wickra-core/src/indicators/value_at_risk.rs new file mode 100644 index 0000000..2f3a5d9 --- /dev/null +++ b/crates/wickra-core/src/indicators/value_at_risk.rs @@ -0,0 +1,227 @@ +//! Rolling historical Value-at-Risk (`VaR`). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling historical Value-at-Risk. +/// +/// Input is treated as a period return. Over the trailing window of `period` +/// returns the indicator reports the empirical lower-tail quantile at the +/// given `confidence` level (e.g. `0.95` = the 95 %-confident worst-case +/// loss). The output is the **magnitude** of that loss, sign-flipped to be a +/// non-negative number (so a 5 % `VaR` is reported as `0.05`, not `-0.05`): +/// +/// ```text +/// q = (1 − confidence) +/// VaR_t = − percentile(returns over window, q · 100) if it is negative +/// VaR_t = 0 otherwise +/// ``` +/// +/// `percentile` uses linear interpolation between the two closest order +/// statistics ("type 7" in R / `NumPy` default). If the q-quantile of the +/// window is itself non-negative (a window where every return was at or above +/// zero) the indicator returns `0.0` — there is no loss to report. +/// +/// Each `update` is O(period · log period) due to the window-sort. Good +/// enough for the typical `period ≤ 252` rolling-VaR workflow. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ValueAtRisk}; +/// +/// let mut var = ValueAtRisk::new(100, 0.95).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = var.update((f64::from(i) * 0.1).sin() * 0.02); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ValueAtRisk { + period: usize, + confidence: f64, + window: VecDeque, +} + +impl ValueAtRisk { + /// Construct a new rolling historical `VaR`. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2`, or if + /// `confidence` is outside the open interval `(0, 1)`. + pub fn new(period: usize, confidence: f64) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "value-at-risk needs period >= 2", + }); + } + if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 { + return Err(Error::InvalidPeriod { + message: "confidence must lie strictly between 0 and 1", + }); + } + Ok(Self { + period, + confidence, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured confidence level. + pub const fn confidence(&self) -> f64 { + self.confidence + } +} + +/// Linear-interpolated percentile (type 7 / `NumPy` default) on a sorted slice. +fn percentile_sorted(sorted: &[f64], q: f64) -> f64 { + let n = sorted.len(); + let pos = q * (n - 1) as f64; + let lo = pos.floor() as usize; + let hi = pos.ceil() as usize; + if lo == hi { + sorted[lo] + } else { + let frac = pos - lo as f64; + sorted[lo] + (sorted[hi] - sorted[lo]) * frac + } +} + +impl Indicator for ValueAtRisk { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.period { + return None; + } + let mut sorted: Vec = self.window.iter().copied().collect(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let q = 1.0 - self.confidence; + let cut = percentile_sorted(&sorted, q); + // Loss magnitude (sign-flipped); 0 if quantile is non-negative. + Some((-cut).max(0.0)) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "ValueAtRisk" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_params() { + assert!(matches!( + ValueAtRisk::new(1, 0.95), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ValueAtRisk::new(20, 0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ValueAtRisk::new(20, 1.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + ValueAtRisk::new(20, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let v = ValueAtRisk::new(100, 0.95).unwrap(); + assert_eq!(v.period(), 100); + assert_relative_eq!(v.confidence(), 0.95, epsilon = 1e-12); + assert_eq!(v.name(), "ValueAtRisk"); + assert_eq!(v.warmup_period(), 100); + } + + #[test] + fn reference_value() { + // returns = -5,-4,-3,-2,-1,0,1,2,3,4 (each *0.01), confidence 0.95. + // q = 0.05, sorted positions 0..9, pos = 0.05*9 = 0.45, + // -> -0.05 + (-0.04 - (-0.05))*0.45 = -0.05 + 0.0045 = -0.0455. + // VaR = 0.0455. + let mut v = ValueAtRisk::new(10, 0.95).unwrap(); + let returns: Vec = (-5..5).map(|i| f64::from(i) * 0.01).collect(); + let out = v.batch(&returns); + assert_relative_eq!(out[9].unwrap(), 0.0455, epsilon = 1e-9); + } + + #[test] + fn all_positive_returns_yield_zero() { + let mut v = ValueAtRisk::new(5, 0.95).unwrap(); + let out = v.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]); + assert_eq!(out[4], Some(0.0)); + } + + #[test] + fn ignores_non_finite_input() { + let mut v = ValueAtRisk::new(3, 0.95).unwrap(); + assert_eq!(v.update(f64::NAN), None); + assert_eq!(v.update(f64::INFINITY), None); + } + + #[test] + fn reset_clears_state() { + let mut v = ValueAtRisk::new(3, 0.95).unwrap(); + v.batch(&[-0.01, -0.02, -0.03]); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(0.01), None); + } + + #[test] + fn batch_equals_streaming() { + let returns: Vec = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect(); + let batch = ValueAtRisk::new(10, 0.95).unwrap().batch(&returns); + let mut s = ValueAtRisk::new(10, 0.95).unwrap(); + let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn integer_position_quantile_branch() { + // period=5, confidence=0.75 -> q=0.25, n-1=4 -> pos=1.0 (integer), + // so the percentile helper takes the `lo == hi` branch. + let mut v = ValueAtRisk::new(5, 0.75).unwrap(); + let out = v.batch(&[-0.05, -0.04, -0.03, -0.02, -0.01]); + // sorted = same order; sorted[1] = -0.04, so VaR = 0.04 exactly. + assert_relative_eq!(out[4].unwrap(), 0.04, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/variance.rs b/crates/wickra-core/src/indicators/variance.rs new file mode 100644 index 0000000..90ecd60 --- /dev/null +++ b/crates/wickra-core/src/indicators/variance.rs @@ -0,0 +1,195 @@ +//! Rolling population variance. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling population variance over the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ price +/// Variance = (1/n) · Σ price² − mean² +/// ``` +/// +/// Variance is the squared standard deviation. It is the second central +/// moment of the rolling distribution and the natural input to risk +/// calculations that expect squared returns (e.g. portfolio variance, +/// covariance matrices). Use [`crate::StdDev`] when you need the +/// scale-preserving square root instead. +/// +/// Floating-point cancellation can drive the running expression slightly +/// negative on perfectly constant inputs; the result is clamped to zero +/// before being returned so it stays a valid variance. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Variance}; +/// +/// let mut indicator = Variance::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Variance { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl Variance { + /// Construct a new rolling variance with the given period. + /// + /// # 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: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Variance { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.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(value); + self.sum += value; + self.sum_sq += value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + Some((self.sum_sq / n - mean * mean).max(0.0)) + } + + 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 { + "Variance" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Variance::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = Variance::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.warmup_period(), 14); + assert_eq!(v.name(), "Variance"); + } + + #[test] + fn reference_value() { + // Variance(3) of [2, 4, 6]: mean = 4, variance = (4 + 0 + 4) / 3 = 8/3. + let mut v = Variance::new(3).unwrap(); + let out = v.batch(&[2.0, 4.0, 6.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 8.0 / 3.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut v = Variance::new(5).unwrap(); + for o in v.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(o, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut v = Variance::new(5).unwrap(); + let out = v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, x) in out.iter().enumerate().take(4) { + assert!(x.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some()); + } + + #[test] + fn reset_clears_state() { + let mut v = Variance::new(5).unwrap(); + v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(1.0), None); + } + + #[test] + fn equals_stddev_squared() { + // The rolling Variance must equal the rolling population StdDev squared. + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 7.0) + .collect(); + let mut var = Variance::new(14).unwrap(); + let mut sd = crate::StdDev::new(14).unwrap(); + for &p in &prices { + let (v, s) = (var.update(p), sd.update(p)); + assert_eq!(v.is_some(), s.is_some()); + if let (Some(v), Some(s)) = (v, s) { + assert_relative_eq!(v, s * s, epsilon = 1e-9); + } + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).cos() * 10.0) + .collect(); + let batch = Variance::new(14).unwrap().batch(&prices); + let mut b = Variance::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/variance_ratio.rs b/crates/wickra-core/src/indicators/variance_ratio.rs new file mode 100644 index 0000000..5be115e --- /dev/null +++ b/crates/wickra-core/src/indicators/variance_ratio.rs @@ -0,0 +1,282 @@ +//! Lo–MacKinlay variance-ratio test on the spread of two series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Lo–MacKinlay variance ratio of the spread `a − b` at horizon `q`. +/// +/// Each `update` takes one `(a, b)` price pair and forms the spread +/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator +/// compares the variance of `q`-step changes against `q` times the variance of +/// one-step changes: +/// +/// ```text +/// rₜ = sₜ − sₜ₋₁ (one-step changes) +/// VR(q) = Var(Σ of q consecutive r) / (q · Var(r)) +/// ``` +/// +/// Under a random walk the variance of returns grows linearly with the horizon, +/// so `VR(q) = 1`. Departures reveal autocorrelation structure: +/// +/// * `VR(q) < 1` — **mean reversion** (negatively autocorrelated changes): the +/// spread's moves partly cancel, the regime pairs traders exploit. +/// * `VR(q) ≈ 1` — a **random walk**: no exploitable structure. +/// * `VR(q) > 1` — **momentum / trending** (positively autocorrelated changes). +/// +/// The estimator uses overlapping `q`-step windows. When the one-step changes +/// have zero variance (a flat spread) the ratio is undefined and the indicator +/// returns the null value `1`. The output is always `≥ 0`. +/// +/// Each `update` is `O(period)`, bounded by the fixed window. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, VarianceRatio}; +/// +/// let mut vr = VarianceRatio::new(60, 2).unwrap(); +/// let mut last = None; +/// for t in 0..200 { +/// let b = 100.0 + f64::from(t); +/// // A fast, choppy spread mean-reverts (negatively autocorrelated +/// // changes) ⇒ VR(2) < 1. +/// let a = b + 2.0 * (f64::from(t) * 2.5).sin(); +/// last = vr.update((a, b)); +/// } +/// assert!(last.unwrap() < 1.0); +/// ``` +#[derive(Debug, Clone)] +pub struct VarianceRatio { + period: usize, + q: usize, + window: VecDeque, +} + +impl VarianceRatio { + /// Construct a new variance-ratio test. + /// + /// `period` is the look-back window of spreads; `q` is the aggregation + /// horizon (number of one-step changes summed per long-horizon change). + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `q < 2` or if `period < q + 2` + /// (which would leave fewer than two long-horizon observations). + pub fn new(period: usize, q: usize) -> Result { + if q < 2 { + return Err(Error::InvalidPeriod { + message: "variance ratio needs q >= 2", + }); + } + if period < q + 2 { + return Err(Error::InvalidPeriod { + message: "variance ratio needs period >= q + 2", + }); + } + Ok(Self { + period, + q, + window: VecDeque::with_capacity(period), + }) + } + + /// Configured look-back window of spreads. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured aggregation horizon `q`. + pub const fn q(&self) -> usize { + self.q + } +} + +impl Indicator for VarianceRatio { + type Input = (f64, f64); + type Output = f64; + + fn update(&mut self, input: (f64, f64)) -> Option { + let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(a - b); + if self.window.len() < self.period { + return None; + } + let spreads: Vec = self.window.iter().copied().collect(); + // One-step changes. + let returns: Vec = spreads.windows(2).map(|w| w[1] - w[0]).collect(); + let m = returns.len() as f64; + let mean = returns.iter().sum::() / m; + let var_one = returns.iter().map(|r| (r - mean) * (r - mean)).sum::() / m; + if var_one <= 0.0 { + // Flat spread: the random-walk null value. + return Some(1.0); + } + // Overlapping q-step changes; their mean is q·mean by construction. + let q_mean = self.q as f64 * mean; + let long: Vec = returns.windows(self.q).map(|w| w.iter().sum()).collect(); + let count = long.len() as f64; + let var_q = long + .iter() + .map(|y| (y - q_mean) * (y - q_mean)) + .sum::() + / count; + Some(var_q / (self.q as f64 * var_one)) + } + + 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 { + "VarianceRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_bad_parameters() { + assert!(VarianceRatio::new(10, 1).is_err()); // q must be >= 2 + assert!(VarianceRatio::new(3, 2).is_err()); // period must be >= q + 2 + assert!(VarianceRatio::new(4, 2).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let vr = VarianceRatio::new(60, 4).unwrap(); + assert_eq!(vr.period(), 60); + assert_eq!(vr.q(), 4); + assert_eq!(vr.warmup_period(), 60); + assert_eq!(vr.name(), "VarianceRatio"); + assert!(!vr.is_ready()); + } + + #[test] + fn warmup_returns_none() { + let mut vr = VarianceRatio::new(4, 2).unwrap(); + assert_eq!(vr.update((1.0, 0.0)), None); + assert_eq!(vr.update((2.0, 0.0)), None); + assert_eq!(vr.update((3.0, 0.0)), None); + assert!(vr.update((4.0, 0.0)).is_some()); + assert!(vr.is_ready()); + } + + #[test] + fn alternating_changes_give_zero_ratio() { + // Spreads 0,2,1,3,2 ⇒ changes 2,-1,2,-1; q = 2 overlapping sums are all + // 1 (constant) ⇒ Var(q) = 0 ⇒ VR = 0 (perfect mean reversion). + let pairs = [(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)]; + let last = VarianceRatio::new(5, 2) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn oscillating_spread_is_below_one() { + let pairs: Vec<(f64, f64)> = (0..200) + .map(|t| { + let b = 100.0 + f64::from(t); + (b + 2.0 * (f64::from(t) * 2.5).sin(), b) + }) + .collect(); + let last = VarianceRatio::new(60, 2) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert!(last < 1.0, "VR {last}"); + } + + #[test] + fn flat_spread_returns_one() { + let pairs: Vec<(f64, f64)> = (0..30) + .map(|t| (5.0 + f64::from(t), f64::from(t))) + .collect(); + let last = VarianceRatio::new(10, 3) + .unwrap() + .batch(&pairs) + .into_iter() + .flatten() + .last() + .unwrap(); + assert_eq!(last, 1.0); + } + + #[test] + fn output_non_negative() { + let pairs: Vec<(f64, f64)> = (0..150) + .map(|t| { + let b = 50.0 + 0.3 * f64::from(t); + (b + (f64::from(t) * 0.5).sin() * 2.0, b) + }) + .collect(); + let mut vr = VarianceRatio::new(40, 4).unwrap(); + for v in vr.batch(&pairs).into_iter().flatten() { + assert!(v >= 0.0, "VR {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut vr = VarianceRatio::new(6, 2).unwrap(); + for t in 0..12 { + vr.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t))); + } + assert!(vr.is_ready()); + vr.reset(); + assert!(!vr.is_ready()); + assert_eq!(vr.update((1.0, 0.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let pairs: Vec<(f64, f64)> = (0..100) + .map(|t| { + let b = 30.0 + 0.7 * f64::from(t); + (b + (f64::from(t) * 0.4).sin() * 1.5, b) + }) + .collect(); + let batch = VarianceRatio::new(32, 3).unwrap().batch(&pairs); + let mut vr = VarianceRatio::new(32, 3).unwrap(); + let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn non_finite_input_returns_none() { + let mut vr = VarianceRatio::new(4, 2).unwrap(); + assert_eq!(vr.update((f64::NAN, 1.0)), None); + assert_eq!(vr.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(vr.update((1.0, 0.0)), None); + assert_eq!(vr.update((2.0, 0.0)), None); + assert_eq!(vr.update((3.0, 0.0)), None); + assert!(vr.update((4.0, 0.0)).is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/vertical_horizontal_filter.rs b/crates/wickra-core/src/indicators/vertical_horizontal_filter.rs new file mode 100644 index 0000000..76c6410 --- /dev/null +++ b/crates/wickra-core/src/indicators/vertical_horizontal_filter.rs @@ -0,0 +1,214 @@ +//! Vertical Horizontal Filter. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Vertical Horizontal Filter — Adam White's trend-versus-range gauge. +/// +/// ```text +/// VHF = (highest_close(n) − lowest_close(n)) / Σ|close − close_prev|(n) +/// ``` +/// +/// The numerator is the *net* distance price covered over the window; the +/// denominator is the *total* distance it walked. Their ratio lives in +/// `[0, 1]`: a clean trend walks almost only in its net direction, so `VHF` +/// approaches `1`; a choppy market doubles back constantly, inflating the +/// denominator and pushing `VHF` toward `0`. It answers the same question as +/// the [`ChoppinessIndex`](crate::ChoppinessIndex) on an inverted scale. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, VerticalHorizontalFilter}; +/// +/// let mut indicator = VerticalHorizontalFilter::new(28).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VerticalHorizontalFilter { + period: usize, + closes: VecDeque, + prev_close: Option, + diffs: VecDeque, + diff_sum: f64, +} + +impl VerticalHorizontalFilter { + /// Construct a new Vertical Horizontal Filter over `period` closes. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + closes: VecDeque::with_capacity(period), + prev_close: None, + diffs: VecDeque::with_capacity(period), + diff_sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for VerticalHorizontalFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + if self.closes.len() == self.period { + self.closes.pop_front(); + } + self.closes.push_back(value); + + if let Some(prev) = self.prev_close { + let diff = (value - prev).abs(); + if self.diffs.len() == self.period { + self.diff_sum -= self.diffs.pop_front().expect("non-empty"); + } + self.diffs.push_back(diff); + self.diff_sum += diff; + } + self.prev_close = Some(value); + + if self.closes.len() < self.period || self.diffs.len() < self.period { + return None; + } + let highest = self + .closes + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let lowest = self.closes.iter().copied().fold(f64::INFINITY, f64::min); + if self.diff_sum == 0.0 { + // A flat window walked nowhere — no trend to filter. + return Some(0.0); + } + Some((highest - lowest) / self.diff_sum) + } + + fn reset(&mut self) { + self.closes.clear(); + self.prev_close = None; + self.diffs.clear(); + self.diff_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // `period` closes fill the high/low window; the `period`-th diff needs + // one extra input because the first input has nothing to diff against. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.diffs.len() == self.period + } + + fn name(&self) -> &'static str { + "VerticalHorizontalFilter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values_pure_uptrend() { + // Closes 1,2,…: every diff is 1 (Σ = period), the n-close span is + // period − 1, so VHF = (period − 1) / period. For period 5: 4/5 = 0.8. + let mut vhf = VerticalHorizontalFilter::new(5).unwrap(); + let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert_relative_eq!(out[5].unwrap(), 0.8, epsilon = 1e-12); + assert_eq!(vhf.warmup_period(), 6); + } + + #[test] + fn choppy_series_reads_low() { + // A market that oscillates between two prices covers a tiny net span + // while walking a long way -> VHF near zero. + let prices: Vec = (0..40) + .map(|i| if i % 2 == 0 { 10.0 } else { 11.0 }) + .collect(); + let mut vhf = VerticalHorizontalFilter::new(10).unwrap(); + for v in vhf.batch(&prices).into_iter().flatten() { + assert!(v < 0.2, "a choppy series should read low, got {v}"); + } + } + + #[test] + fn flat_series_yields_zero() { + let mut vhf = VerticalHorizontalFilter::new(8).unwrap(); + for v in vhf.batch(&[50.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn stays_within_unit_range() { + let prices: Vec = (0..120) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut vhf = VerticalHorizontalFilter::new(28).unwrap(); + for v in vhf.batch(&prices).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "VHF {v} outside [0, 1]"); + } + } + + #[test] + fn rejects_zero_period() { + assert!(VerticalHorizontalFilter::new(0).is_err()); + } + + /// Cover the const accessor `period` (61-63) and the Indicator-impl + /// `name` body (119-121). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let vhf = VerticalHorizontalFilter::new(28).unwrap(); + assert_eq!(vhf.period(), 28); + assert_eq!(vhf.name(), "VerticalHorizontalFilter"); + } + + #[test] + fn reset_clears_state() { + let mut vhf = VerticalHorizontalFilter::new(8).unwrap(); + vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]); + assert!(vhf.is_ready()); + vhf.reset(); + assert!(!vhf.is_ready()); + assert_eq!(vhf.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = VerticalHorizontalFilter::new(28).unwrap(); + let mut b = VerticalHorizontalFilter::new(28).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/vidya.rs b/crates/wickra-core/src/indicators/vidya.rs new file mode 100644 index 0000000..7d00ec4 --- /dev/null +++ b/crates/wickra-core/src/indicators/vidya.rs @@ -0,0 +1,193 @@ +//! Variable Index Dynamic Average (VIDYA). + +use crate::error::{Error, Result}; +use crate::indicators::cmo::Cmo; +use crate::traits::Indicator; + +/// Tushar Chande's Variable Index Dynamic Average — an EMA whose smoothing +/// factor is scaled by the absolute Chande Momentum Oscillator (`CMO`). +/// +/// Strong directional momentum (high `|CMO|`) pushes the effective smoothing +/// constant toward the EMA-of-`period`'s natural rate; flat / choppy windows +/// (`|CMO|` close to zero) shrink it toward zero so VIDYA coasts on its prior +/// value: +/// +/// ```text +/// alpha_base = 2 / (period + 1) +/// alpha_t = alpha_base * |CMO(cmo_period)| / 100 +/// VIDYA_t = alpha_t * price_t + (1 - alpha_t) * VIDYA_{t-1} +/// ``` +/// +/// The series is seeded with the first price emitted after the `CMO` +/// warm-up (i.e. after `cmo_period + 1` inputs). +/// +/// Reference: Tushar Chande, *Stocks & Commodities*, 1992. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Vidya}; +/// +/// let mut vidya = Vidya::new(14, 9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = vidya.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Vidya { + period: usize, + cmo_period: usize, + alpha_base: f64, + cmo: Cmo, + current: Option, +} + +impl Vidya { + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero. + pub fn new(period: usize, cmo_period: usize) -> Result { + if period == 0 || cmo_period == 0 { + return Err(Error::PeriodZero); + } + let alpha_base = 2.0 / (period as f64 + 1.0); + Ok(Self { + period, + cmo_period, + alpha_base, + cmo: Cmo::new(cmo_period)?, + current: None, + }) + } + + /// Configured `(period, cmo_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.cmo_period) + } +} + +impl Indicator for Vidya { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + let cmo = self.cmo.update(input)?; + let alpha = self.alpha_base * (cmo.abs() / 100.0); + let prev = self.current.unwrap_or(input); + let next = alpha * input + (1.0 - alpha) * prev; + self.current = Some(next); + Some(next) + } + + fn reset(&mut self) { + self.cmo.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.cmo_period + 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "VIDYA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Vidya::new(0, 9), Err(Error::PeriodZero))); + assert!(matches!(Vidya::new(14, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = Vidya::new(14, 9).unwrap(); + assert_eq!(v.periods(), (14, 9)); + assert_eq!(v.warmup_period(), 10); + assert_eq!(v.name(), "VIDYA"); + } + + #[test] + fn constant_series_yields_the_constant() { + // Flat input -> CMO = 0 -> alpha = 0 -> VIDYA holds its seed value. + let mut v = Vidya::new(14, 4).unwrap(); + let out = v.batch(&[42.0_f64; 30]); + for x in out.iter().skip(4).flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_alpha_equals_base() { + // Monotonic uptrend: CMO saturates at +100, so alpha = alpha_base. + // After warmup the recurrence is a plain EMA with that alpha; once + // the series is long enough VIDYA closely tracks the latest input. + let mut v = Vidya::new(2, 4).unwrap(); + let prices: Vec = (1..=40).map(f64::from).collect(); + let out = v.batch(&prices); + let last = out.last().unwrap().unwrap(); + let latest = *prices.last().unwrap(); + // alpha_base = 2/3, EMA(2) tracks close — last value is within 2 of + // the latest input after this many bars. + assert!( + (latest - last).abs() < 2.0, + "VIDYA should track close on a clean uptrend: {last} vs {latest}" + ); + } + + #[test] + fn warmup_emits_first_value_at_cmo_period_plus_one() { + let mut v = Vidya::new(14, 3).unwrap(); + assert_eq!(v.warmup_period(), 4); + assert_eq!(v.update(10.0), None); + assert_eq!(v.update(11.0), None); + assert_eq!(v.update(12.0), None); + assert!(v.update(13.0).is_some()); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Vidya::new(14, 9).unwrap(); + let mut b = Vidya::new(14, 9).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut v = Vidya::new(14, 9).unwrap(); + v.batch(&(1..=40).map(f64::from).collect::>()); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut v = Vidya::new(14, 4).unwrap(); + v.batch(&(1..=20).map(f64::from).collect::>()); + let before = v.update(21.0).unwrap(); + assert_eq!(v.update(f64::NAN), Some(before)); + assert_eq!(v.update(f64::INFINITY), Some(before)); + } +} diff --git a/crates/wickra-core/src/indicators/volatility_cone.rs b/crates/wickra-core/src/indicators/volatility_cone.rs new file mode 100644 index 0000000..543dbf8 --- /dev/null +++ b/crates/wickra-core/src/indicators/volatility_cone.rs @@ -0,0 +1,381 @@ +//! Volatility Cone — current realized volatility within its historical envelope. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`VolatilityCone`]: the current realized volatility together with +/// the envelope (the "cone") it sits inside over the lookback window. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VolatilityConeOutput { + /// Latest realized volatility (sample stddev of log returns over `window`). + pub current: f64, + /// Lowest realized volatility seen over the `lookback` window. + pub min: f64, + /// Median realized volatility over the `lookback` window. + pub median: f64, + /// Highest realized volatility seen over the `lookback` window. + pub max: f64, + /// Percentile rank of `current` within the lookback distribution, in + /// `[0, 100]` — the share of stored volatilities `<= current`, times 100. + pub percentile: f64, +} + +/// Sample standard deviation from a running `(sum, sum_of_squares, count)`. +fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 { + let n = count as f64; + let mean = sum / n; + let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + variance.sqrt() +} + +/// Volatility Cone — the current realized volatility positioned within the +/// historical range ("cone") of realized volatilities over a lookback window. +/// +/// ```text +/// r_t = ln(close_t / close_{t−1}) +/// vol_t = stddev_sample(r over window) (rolling realized volatility) +/// cone = { min, median, max, percentile } of vol over the last `lookback` +/// ``` +/// +/// A volatility cone (Burghardt & Lane 1990) shows whether current volatility is +/// high or low *relative to its own history*, rather than as an absolute number. +/// This streaming form tracks one horizon: it maintains the rolling realized +/// volatility of log returns over `window`, then reports the latest reading +/// (`current`) alongside the `min`, `median`, `max` and percentile rank of that +/// volatility series over the trailing `lookback`. `current` always lies within +/// `[min, max]` because it is itself the newest member of the lookback set. +/// +/// Only the candle's **close** is used (the log-return series); the high and low +/// are ignored. The volatility is per-period (sample stddev of log returns, not +/// annualised) — multiply by `√trading_periods` for an annual figure. Each +/// `update` is O(`lookback log lookback`) from sorting the envelope. +/// +/// Non-positive closes are ignored (the log return would be undefined): the tick +/// is dropped, state is left untouched, and the last value is returned. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolatilityCone}; +/// +/// let mut indicator = VolatilityCone::new(20, 60).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let c = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; +/// let candle = Candle::new(c, c + 1.0, c - 1.0, c, 1_000.0, 0).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolatilityCone { + window: usize, + lookback: usize, + prev_close: Option, + /// Rolling window of log returns for the inner realized-volatility series. + returns: VecDeque, + ret_sum: f64, + ret_sum_sq: f64, + /// Rolling window of realized-volatility readings (the cone envelope). + vols: VecDeque, + last: Option, +} + +impl VolatilityCone { + /// Construct a new volatility-cone indicator. + /// + /// `window` is the realized-volatility estimation window; `lookback` is the + /// number of volatility readings forming the historical cone. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either argument is `0`, or + /// [`Error::InvalidPeriod`] if `window < 2` (a sample stddev needs two + /// returns) or `lookback < 2` (an envelope needs at least two readings). + pub fn new(window: usize, lookback: usize) -> Result { + if window == 0 || lookback == 0 { + return Err(Error::PeriodZero); + } + if window < 2 || lookback < 2 { + return Err(Error::InvalidPeriod { + message: "volatility cone window and lookback must both be >= 2", + }); + } + Ok(Self { + window, + lookback, + prev_close: None, + returns: VecDeque::with_capacity(window), + ret_sum: 0.0, + ret_sum_sq: 0.0, + vols: VecDeque::with_capacity(lookback), + last: None, + }) + } + + /// Configured `(window, lookback)`. + pub const fn windows(&self) -> (usize, usize) { + (self.window, self.lookback) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for VolatilityCone { + type Input = Candle; + type Output = VolatilityConeOutput; + + fn update(&mut self, candle: Candle) -> Option { + let price = candle.close; + // A log return is undefined for a non-positive close; skip the tick. + if price <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_close else { + self.prev_close = Some(price); + return None; + }; + self.prev_close = Some(price); + // `prev` came from `self.prev_close`, gated by the guard above, so it is + // positive — the log return is always well-defined. + let r = (price / prev).ln(); + + // Stage one: rolling sample volatility of log returns. + if self.returns.len() == self.window { + let old = self.returns.pop_front().expect("returns window non-empty"); + self.ret_sum -= old; + self.ret_sum_sq -= old * old; + } + self.returns.push_back(r); + self.ret_sum += r; + self.ret_sum_sq += r * r; + if self.returns.len() < self.window { + return None; + } + let current = sample_stddev(self.ret_sum, self.ret_sum_sq, self.window); + + // Stage two: maintain the lookback envelope of volatility readings. + if self.vols.len() == self.lookback { + self.vols.pop_front(); + } + self.vols.push_back(current); + if self.vols.len() < self.lookback { + return None; + } + + let mut sorted: Vec = self.vols.iter().copied().collect(); + sorted.sort_by(f64::total_cmp); + let min = sorted[0]; + let max = sorted[self.lookback - 1]; + let mid = self.lookback / 2; + let median = if self.lookback % 2 == 1 { + sorted[mid] + } else { + f64::midpoint(sorted[mid - 1], sorted[mid]) + }; + let count_le = self.vols.iter().filter(|&&v| v <= current).count(); + let percentile = count_le as f64 / self.lookback as f64 * 100.0; + + let out = VolatilityConeOutput { + current, + min, + median, + max, + percentile, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev_close = None; + self.returns.clear(); + self.ret_sum = 0.0; + self.ret_sum_sq = 0.0; + self.vols.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One previous close for the first return, `window` returns for the + // first volatility, then `lookback` volatilities for the envelope. + self.window + self.lookback + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolatilityCone" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Candle whose close drives the indicator (open = high = low = close here). + fn close_candle(close: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_window() { + assert!(matches!(VolatilityCone::new(0, 10), Err(Error::PeriodZero))); + assert!(matches!(VolatilityCone::new(10, 0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_window_one() { + assert!(matches!( + VolatilityCone::new(1, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolatilityCone::new(10, 1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let vc = VolatilityCone::new(20, 60).unwrap(); + assert_eq!(vc.windows(), (20, 60)); + assert_eq!(vc.warmup_period(), 80); + assert_eq!(vc.name(), "VolatilityCone"); + assert!(!vc.is_ready()); + assert_eq!(vc.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut vc = VolatilityCone::new(2, 2).unwrap(); + let prices = [100.0, 110.0, 121.0, 100.0, 105.0, 99.0]; + let candles: Vec = prices.iter().map(|p| close_candle(*p)).collect(); + let out = vc.batch(&candles); + let warmup = vc.warmup_period(); // 4 + assert_eq!(warmup, 4); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn known_value() { + // window = 2 -> vol = |r_t − r_{t−1}| / √2; lookback = 2. + // prices: r1 = r2 = ln(1.1), r3 = ln(100/121). + let mut vc = VolatilityCone::new(2, 2).unwrap(); + let candles: Vec = [100.0, 110.0, 121.0, 100.0] + .iter() + .map(|p| close_candle(*p)) + .collect(); + let out = vc.batch(&candles); + let r2 = (121.0_f64 / 110.0).ln(); + let r3 = (100.0_f64 / 121.0).ln(); + let vol2 = (r2 - r3).abs() / 2.0_f64.sqrt(); + let o = out[3].unwrap(); + assert_relative_eq!(o.current, vol2, epsilon = 1e-9); + assert_relative_eq!(o.min, 0.0, epsilon = 1e-9); // vol1 = 0 (r1 == r2) + assert_relative_eq!(o.max, vol2, epsilon = 1e-9); + assert_relative_eq!(o.median, vol2 / 2.0, epsilon = 1e-9); + assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-9); + } + + #[test] + fn odd_lookback_median_is_middle() { + // lookback = 3 picks the middle of the sorted envelope. + let mut vc = VolatilityCone::new(2, 3).unwrap(); + let candles: Vec = [100.0, 101.0, 103.0, 100.0, 104.0, 99.0, 106.0] + .iter() + .map(|p| close_candle(*p)) + .collect(); + let out = vc.batch(&candles); + let o = out.last().unwrap().unwrap(); + assert!(o.min <= o.median && o.median <= o.max); + } + + #[test] + fn envelope_brackets_current() { + let mut vc = VolatilityCone::new(10, 30).unwrap(); + let candles: Vec = (0..200) + .map(|i| close_candle(100.0 + (f64::from(i) * 0.3).sin() * 12.0)) + .collect(); + for o in vc.batch(&candles).into_iter().flatten() { + assert!(o.min <= o.current && o.current <= o.max); + assert!(o.min <= o.median && o.median <= o.max); + assert!(o.percentile > 0.0 && o.percentile <= 100.0); + } + } + + #[test] + fn constant_series_yields_zero_cone() { + let mut vc = VolatilityCone::new(5, 5).unwrap(); + let candles: Vec = (0..40).map(|_| close_candle(100.0)).collect(); + for o in vc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(o.current, 0.0, epsilon = 1e-12); + assert_relative_eq!(o.min, 0.0, epsilon = 1e-12); + assert_relative_eq!(o.max, 0.0, epsilon = 1e-12); + assert_relative_eq!(o.median, 0.0, epsilon = 1e-12); + assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-12); + } + } + + #[test] + fn skips_non_positive_close() { + let mut vc = VolatilityCone::new(2, 2).unwrap(); + let candles: Vec = [100.0, 110.0, 121.0, 100.0] + .iter() + .map(|p| close_candle(*p)) + .collect(); + let warmup = vc.batch(&candles); + let baseline = warmup.last().copied().flatten().expect("warmed up"); + // A non-positive close is skipped and the previous value is returned. + assert_eq!(vc.update(close_candle(0.0)), Some(baseline)); + // State untouched: a clone advanced by the same real tick agrees. + let mut control = vc.clone(); + let after = vc.update(close_candle(105.0)).expect("ready"); + assert_eq!(control.update(close_candle(105.0)).expect("ready"), after); + } + + #[test] + fn skips_non_positive_before_first_close() { + let mut vc = VolatilityCone::new(2, 2).unwrap(); + assert_eq!(vc.update(close_candle(0.0)), None); + assert_eq!(vc.update(close_candle(100.0)), None); + } + + #[test] + fn reset_clears_state() { + let mut vc = VolatilityCone::new(2, 2).unwrap(); + let candles: Vec = [100.0, 110.0, 121.0, 100.0, 105.0] + .iter() + .map(|p| close_candle(*p)) + .collect(); + vc.batch(&candles); + assert!(vc.is_ready()); + vc.reset(); + assert!(!vc.is_ready()); + assert_eq!(vc.value(), None); + assert_eq!(vc.update(close_candle(100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..200) + .map(|i| close_candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0)) + .collect(); + let batch = VolatilityCone::new(10, 30).unwrap().batch(&candles); + let mut b = VolatilityCone::new(10, 30).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volatility_of_volatility.rs b/crates/wickra-core/src/indicators/volatility_of_volatility.rs new file mode 100644 index 0000000..c0d4c8f --- /dev/null +++ b/crates/wickra-core/src/indicators/volatility_of_volatility.rs @@ -0,0 +1,333 @@ +//! Volatility of Volatility — the dispersion of a rolling volatility series. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Sample standard deviation from a running `(sum, sum_of_squares, count)`. +/// +/// Uses Bessel's correction (divisor `n − 1`) and clamps a tiny negative +/// floating-point residual to zero before the square root. +fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 { + let n = count as f64; + let mean = sum / n; + let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + variance.sqrt() +} + +/// Volatility of Volatility — the standard deviation of a rolling realized- +/// volatility series ("vol-of-vol"). +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// vol_t = stddev_sample(r over vol_window) (rolling realized volatility) +/// VoV = stddev_sample(vol over vov_window) (dispersion of that series) +/// ``` +/// +/// This is a two-stage estimator: the first stage measures the rolling sample +/// volatility of log returns (the same quantity +/// [`HistoricalVolatility`](crate::HistoricalVolatility) annualises), and the +/// second stage measures how much *that* volatility itself moves. A high +/// vol-of-vol means the volatility regime is unstable — turbulent periods +/// alternate with calm ones — which is exactly the convexity that long-gamma and +/// volatility-trading strategies care about. Both stages use the unbiased +/// `n − 1` sample standard deviation. Each `update` is O(1). +/// +/// Non-finite and non-positive prices are ignored (the log return would be +/// undefined): the tick is dropped, state is left untouched, and the last value +/// is returned. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, VolatilityOfVolatility}; +/// +/// let mut indicator = VolatilityOfVolatility::new(20, 20).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolatilityOfVolatility { + vol_window: usize, + vov_window: usize, + prev_price: Option, + /// Rolling window of log returns (stage one). + returns: VecDeque, + ret_sum: f64, + ret_sum_sq: f64, + /// Rolling window of realized-volatility readings (stage two). + vols: VecDeque, + vol_sum: f64, + vol_sum_sq: f64, + last: Option, +} + +impl VolatilityOfVolatility { + /// Construct a new vol-of-vol indicator. + /// + /// `vol_window` is the window for the inner realized-volatility series; + /// `vov_window` is the window over which its dispersion is measured. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either window is `0`, or + /// [`Error::InvalidPeriod`] if either is `1` (a sample standard deviation + /// needs at least two observations). + pub fn new(vol_window: usize, vov_window: usize) -> Result { + if vol_window == 0 || vov_window == 0 { + return Err(Error::PeriodZero); + } + if vol_window < 2 || vov_window < 2 { + return Err(Error::InvalidPeriod { + message: "vol-of-vol windows must both be >= 2", + }); + } + Ok(Self { + vol_window, + vov_window, + prev_price: None, + returns: VecDeque::with_capacity(vol_window), + ret_sum: 0.0, + ret_sum_sq: 0.0, + vols: VecDeque::with_capacity(vov_window), + vol_sum: 0.0, + vol_sum_sq: 0.0, + last: None, + }) + } + + /// Configured `(vol_window, vov_window)`. + pub const fn windows(&self) -> (usize, usize) { + (self.vol_window, self.vov_window) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for VolatilityOfVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Non-finite / non-positive prices are skipped: `ln(input / prev)` is + // undefined, so the tick must not enter the return window. + if !input.is_finite() || input <= 0.0 { + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + // `prev` came from `self.prev_price`, gated by the guard above, so it is + // finite and positive — the log return is always well-defined. + let r = (input / prev).ln(); + + // Stage one: rolling sample volatility of log returns. + if self.returns.len() == self.vol_window { + let old = self.returns.pop_front().expect("returns window non-empty"); + self.ret_sum -= old; + self.ret_sum_sq -= old * old; + } + self.returns.push_back(r); + self.ret_sum += r; + self.ret_sum_sq += r * r; + if self.returns.len() < self.vol_window { + return None; + } + let vol = sample_stddev(self.ret_sum, self.ret_sum_sq, self.vol_window); + + // Stage two: rolling sample dispersion of the volatility series. + if self.vols.len() == self.vov_window { + let old = self.vols.pop_front().expect("vols window non-empty"); + self.vol_sum -= old; + self.vol_sum_sq -= old * old; + } + self.vols.push_back(vol); + self.vol_sum += vol; + self.vol_sum_sq += vol * vol; + if self.vols.len() < self.vov_window { + return None; + } + let vov = sample_stddev(self.vol_sum, self.vol_sum_sq, self.vov_window); + self.last = Some(vov); + Some(vov) + } + + fn reset(&mut self) { + self.prev_price = None; + self.returns.clear(); + self.ret_sum = 0.0; + self.ret_sum_sq = 0.0; + self.vols.clear(); + self.vol_sum = 0.0; + self.vol_sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One previous price for the first return, `vol_window` returns for the + // first volatility, then `vov_window` volatilities for the dispersion. + // The two windows overlap on the bar axis, so this is the sum. + self.vol_window + self.vov_window + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolatilityOfVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use crate::HistoricalVolatility; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_window() { + assert!(matches!( + VolatilityOfVolatility::new(0, 10), + Err(Error::PeriodZero) + )); + assert!(matches!( + VolatilityOfVolatility::new(10, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_window_one() { + assert!(matches!( + VolatilityOfVolatility::new(1, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolatilityOfVolatility::new(10, 1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let vov = VolatilityOfVolatility::new(20, 10).unwrap(); + assert_eq!(vov.windows(), (20, 10)); + assert_eq!(vov.warmup_period(), 30); + assert_eq!(vov.name(), "VolatilityOfVolatility"); + assert!(!vov.is_ready()); + assert_eq!(vov.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut vov = VolatilityOfVolatility::new(3, 3).unwrap(); + let prices: Vec = (1..=20) + .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 4.0) + .collect(); + let out = vov.batch(&prices); + let warmup = vov.warmup_period(); // 6 + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn matches_two_stage_reference() { + // Stage one equals HistoricalVolatility(vol_window, 1) / 100 (sample + // stddev of log returns); stage two is the sample stddev of that series. + let (vol_window, vov_window) = (3, 3); + let prices: Vec = [100.0, 102.0, 101.0, 104.0, 103.5, 106.0, 105.0, 108.0].to_vec(); + + let mut hv = HistoricalVolatility::new(vol_window, 1).unwrap(); + let vol_series: Vec = hv + .batch(&prices) + .into_iter() + .flatten() + .map(|v| v / 100.0) + .collect(); + // Sample stddev of the last `vov_window` volatilities. + let tail = &vol_series[vol_series.len() - vov_window..]; + let sum: f64 = tail.iter().sum(); + let sum_sq: f64 = tail.iter().map(|v| v * v).sum(); + let expected = sample_stddev(sum, sum_sq, vov_window); + + let mut vov = VolatilityOfVolatility::new(vol_window, vov_window).unwrap(); + let out = vov.batch(&prices); + assert_relative_eq!(out.last().unwrap().unwrap(), expected, epsilon = 1e-9); + } + + #[test] + fn constant_series_yields_zero() { + let mut vov = VolatilityOfVolatility::new(5, 5).unwrap(); + for v in vov.batch(&[100.0; 60]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut vov = VolatilityOfVolatility::new(10, 10).unwrap(); + let prices: Vec = (1..=300) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in vov.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "vol-of-vol must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut vov = VolatilityOfVolatility::new(3, 3).unwrap(); + let out = vov.batch(&(1..=40).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(vov.update(f64::NAN), last); + assert_eq!(vov.update(f64::INFINITY), last); + } + + #[test] + fn skips_non_positive_prices() { + let mut vov = VolatilityOfVolatility::new(3, 3).unwrap(); + let warmup = vov.batch(&(1..=40).map(f64::from).collect::>()); + let baseline = warmup.last().copied().flatten().expect("warmed up"); + assert_eq!(vov.update(-5.0), Some(baseline)); + assert_eq!(vov.update(0.0), Some(baseline)); + // State untouched: a clone advanced by the same real tick agrees. + let mut control = vov.clone(); + let after = vov.update(41.0).expect("ready"); + assert_eq!(control.update(41.0).expect("ready"), after); + } + + #[test] + fn reset_clears_state() { + let mut vov = VolatilityOfVolatility::new(3, 3).unwrap(); + vov.batch(&(1..=40).map(f64::from).collect::>()); + assert!(vov.is_ready()); + vov.reset(); + assert!(!vov.is_ready()); + assert_eq!(vov.value(), None); + assert_eq!(vov.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = VolatilityOfVolatility::new(10, 10).unwrap().batch(&prices); + let mut b = VolatilityOfVolatility::new(10, 10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volatility_ratio.rs b/crates/wickra-core/src/indicators/volatility_ratio.rs new file mode 100644 index 0000000..cbd00a2 --- /dev/null +++ b/crates/wickra-core/src/indicators/volatility_ratio.rs @@ -0,0 +1,285 @@ +//! Schwager's Volatility Ratio — today's true range versus its typical level. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Schwager's Volatility Ratio — the current bar's true range divided by the +/// exponential moving average of the *prior* true ranges. +/// +/// ```text +/// TR_t = true range of bar t +/// VR_t = TR_t / EMA_n(TR through bar t−1) +/// ``` +/// +/// Jack Schwager's volatility ratio measures how today's range compares to its +/// recent typical level: a reading above `2.0` marks a **wide-ranging day** — +/// today's true range is more than twice the smoothed average — which often +/// precedes or accompanies a reversal. The denominator is the exponential +/// moving average of true range *excluding the current bar*, seeded with the +/// simple average of the first `period` true ranges, so a single large bar +/// stands out instead of inflating its own benchmark. +/// +/// True range is `max(high − low, |high − prev_close|, |low − prev_close|)`, +/// identical to the [`Atr`](crate::Atr) building block, but here it is compared +/// to a *standard* EMA (smoothing `2 / (period + 1)`) rather than Wilder +/// smoothing, which keeps the ratio distinct from `TR / ATR`. Each `update` is +/// O(1). +/// +/// A flat market drives every true range — and the EMA — to `0`; the ratio is +/// then `0.0` rather than an undefined `0 / 0`. `Candle::new` rejects non-finite +/// fields, so no in-method finiteness guard is needed. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolatilityRatio}; +/// +/// let mut indicator = VolatilityRatio::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolatilityRatio { + period: usize, + alpha: f64, + prev_close: Option, + /// Sum and count of the first `period` true ranges, used to seed the EMA. + seed_sum: f64, + seed_count: usize, + /// EMA of true range through the previous bar; `None` until seeded. + ema: Option, + last: Option, +} + +impl VolatilityRatio { + /// Construct a new volatility-ratio indicator. + /// + /// `period` is the number of true ranges that seed and smooth the + /// denominator EMA. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + alpha: 2.0 / (period as f64 + 1.0), + prev_close: None, + seed_sum: 0.0, + seed_count: 0, + ema: None, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for VolatilityRatio { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // The first bar has no previous close, so no true range can be formed. + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let tr = candle.true_range(Some(prev_close)); + self.prev_close = Some(candle.close); + + match self.ema { + None => { + // Seeding the EMA with the simple average of the first `period` + // true ranges; emit nothing until it is established. + self.seed_sum += tr; + self.seed_count += 1; + if self.seed_count == self.period { + self.ema = Some(self.seed_sum / self.period as f64); + } + None + } + Some(prev_ema) => { + // Denominator excludes the current bar (it is the EMA through the + // previous bar). A flat benchmark yields 0.0, not 0/0. + let vr = if prev_ema > 0.0 { tr / prev_ema } else { 0.0 }; + self.ema = Some(self.alpha * tr + (1.0 - self.alpha) * prev_ema); + self.last = Some(vr); + Some(vr) + } + } + } + + fn reset(&mut self) { + self.prev_close = None; + self.seed_sum = 0.0; + self.seed_count = 0; + self.ema = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // Bar 1 sets the previous close; bars 2..=period+1 seed the EMA; the + // first ratio is emitted on bar period + 2. + self.period + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolatilityRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Build a candle with the given high/low/close (open = low, fixed volume). + fn candle(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, 1_000.0, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(VolatilityRatio::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let vr = VolatilityRatio::new(14).unwrap(); + assert_eq!(vr.period(), 14); + assert_eq!(vr.warmup_period(), 16); + assert_eq!(vr.name(), "VolatilityRatio"); + assert!(!vr.is_ready()); + assert_eq!(vr.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut vr = VolatilityRatio::new(3).unwrap(); + // Build enough constant-range candles to reach warmup. + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + let out = vr.batch(&candles); + // warmup_period == period + 2 == 5: the first emission is at index 4. + let warmup = vr.warmup_period(); + assert_eq!(warmup, 5); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn wide_ranging_day_exceeds_two() { + // Steady true range of 2.0 seeds the EMA, then one bar with a far wider + // range pushes the ratio above 2.0. + let mut vr = VolatilityRatio::new(3).unwrap(); + let mut candles: Vec = (0..6) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) // TR = 2.0 each + }) + .collect(); + // A wide bar: range 10 around the last close (~105). + candles.push(candle(110.0, 100.0, 105.0)); + let out = vr.batch(&candles); + let last = out.last().unwrap().unwrap(); + assert!(last > 2.0, "wide-ranging day should exceed 2.0, got {last}"); + } + + #[test] + fn steady_range_ratio_is_one() { + // Constant true range -> EMA equals it -> ratio is exactly 1.0. + let mut vr = VolatilityRatio::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) // TR = 2.0 each + }) + .collect(); + let out = vr.batch(&candles); + assert_relative_eq!(out.last().unwrap().unwrap(), 1.0, epsilon = 1e-9); + } + + #[test] + fn flat_market_yields_zero() { + // Zero-range candles: TR = 0, EMA = 0, ratio guarded to 0.0. + let mut vr = VolatilityRatio::new(3).unwrap(); + let candles: Vec = (0..10).map(|_| candle(100.0, 100.0, 100.0)).collect(); + let out = vr.batch(&candles); + for v in out.into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut vr = VolatilityRatio::new(14).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + candle(base + 2.0, base - 2.0, base + 0.5) + }) + .collect(); + for v in vr.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "volatility ratio must be non-negative, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut vr = VolatilityRatio::new(3).unwrap(); + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + vr.batch(&candles); + assert!(vr.is_ready()); + vr.reset(); + assert!(!vr.is_ready()); + assert_eq!(vr.value(), None); + assert_eq!(vr.update(candle(101.0, 99.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + candle(base + 2.0, base - 1.5, base + 0.5) + }) + .collect(); + let batch = VolatilityRatio::new(14).unwrap().batch(&candles); + let mut b = VolatilityRatio::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volty_stop.rs b/crates/wickra-core/src/indicators/volty_stop.rs new file mode 100644 index 0000000..a189b76 --- /dev/null +++ b/crates/wickra-core/src/indicators/volty_stop.rs @@ -0,0 +1,274 @@ +//! Volty Stop (Volatility Stop, Kase). + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volty Stop — Cynthia Kase's volatility-anchored trailing stop. The stop is +/// hung off the *extreme close* recorded since the current trade was opened, +/// not off the most recent bar, which keeps it tight without giving back gains +/// when price pulls back inside the trend. +/// +/// ```text +/// long: anchor = max_close_since_long +/// stop_t = anchor − multiplier · ATR +/// flip-to-short on close < stop_t -> anchor = close, stop = close + mult · ATR +/// short: anchor = min_close_since_short +/// stop_t = anchor + multiplier · ATR +/// flip-to-long on close > stop_t -> anchor = close, stop = close − mult · ATR +/// ``` +/// +/// The anchor only ratchets in the trade's favour, so the stop tightens as +/// price reaches new extremes. Compared to the +/// [`AtrTrailingStop`](crate::AtrTrailingStop) — which re-anchors on every +/// bar's close — Volty Stop's extreme-anchor design gives back less on +/// pullbacks while keeping the same ATR-based volatility scaling. A common +/// configuration is `ATR(14)` with a `2.0` multiplier. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VoltyStop}; +/// +/// let mut indicator = VoltyStop::new(14, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VoltyStop { + atr: Atr, + atr_period: usize, + multiplier: f64, + anchor: Option, + long: bool, +} + +impl VoltyStop { + /// Construct a Volty Stop with an explicit ATR period and band multiplier. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + atr_period, + multiplier, + anchor: None, + long: true, + }) + } + + /// A common configuration: `ATR(14)` with a `2.0` multiplier. + pub fn classic() -> Self { + Self::new(14, 2.0).expect("classic Volty Stop params are valid") + } + + /// Configured `(atr_period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.atr_period, self.multiplier) + } +} + +impl Indicator for VoltyStop { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let band = self.multiplier * atr; + let close = candle.close; + + let (anchor, long) = match (self.anchor, self.long) { + (Some(prev_anchor), true) => { + let stop = prev_anchor - band; + if close < stop { + // Close-through long stop -> flip short, anchor at close. + (close, false) + } else { + // Ratchet the anchor up to today's close if higher. + (prev_anchor.max(close), true) + } + } + (Some(prev_anchor), false) => { + let stop = prev_anchor + band; + if close > stop { + (close, true) + } else { + (prev_anchor.min(close), false) + } + } + // First ATR-ready bar seeds a long anchor at the close. + (None, _) => (close, true), + }; + self.anchor = Some(anchor); + self.long = long; + let stop = if long { anchor - band } else { anchor + band }; + Some(stop) + } + + fn reset(&mut self) { + self.atr.reset(); + self.anchor = None; + self.long = true; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.anchor.is_some() + } + + fn name(&self) -> &'static str { + "VoltyStop" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_params() { + assert!(VoltyStop::new(0, 2.0).is_err()); + assert!(VoltyStop::new(14, 0.0).is_err()); + assert!(VoltyStop::new(14, -1.0).is_err()); + assert!(VoltyStop::new(14, f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = VoltyStop::classic(); + let (p, m) = s.params(); + assert_eq!(p, 14); + assert_relative_eq!(m, 2.0, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "VoltyStop"); + } + + #[test] + fn first_emission_matches_warmup() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = VoltyStop::new(8, 2.0).unwrap(); + let out = s.batch(&candles); + for (i, v) in out.iter().enumerate().take(7) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[7].is_some()); + } + + #[test] + fn reference_values_flat_market() { + // H=11, L=9, C=10 -> TR=2 -> ATR=2; band = 2·2 = 4; anchor stays at 10; stop = 10-4 = 6. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut s = VoltyStop::new(5, 2.0).unwrap(); + for v in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 6.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_anchor_ratchets_up_with_close() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = VoltyStop::new(14, 3.0).unwrap(); + let emitted: Vec<(f64, f64)> = s + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + for w in emitted.windows(2) { + assert!( + w[1].0 >= w[0].0 - 1e-9, + "stop must not loosen in an uptrend" + ); + } + for &(stop, close) in &emitted { + assert!(stop < close, "uptrend stop should sit below the close"); + } + } + + #[test] + fn stop_flips_on_reversal() { + let mut candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + candles.extend((0..40).map(|i| { + let base = 140.0 - 3.0 * i as f64; + c(base + 1.0, base - 1.0, base, 40 + i) + })); + let mut s = VoltyStop::new(14, 3.0).unwrap(); + let paired: Vec<(f64, f64)> = s + .batch(&candles) + .into_iter() + .zip(candles.iter()) + .filter_map(|(o, c)| o.map(|v| (v, c.close))) + .collect(); + assert!(paired.iter().any(|&(stop, close)| stop < close)); + assert!(paired.iter().any(|&(stop, close)| stop > close)); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = VoltyStop::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = VoltyStop::classic(); + let mut b = VoltyStop::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/volume_bars.rs b/crates/wickra-core/src/indicators/volume_bars.rs new file mode 100644 index 0000000..b9ad070 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_bars.rs @@ -0,0 +1,217 @@ +//! Volume bar builder — close a bar each time accumulated volume reaches a threshold. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::BarBuilder; + +/// One completed volume bar (an OHLCV aggregate spanning ~`volume_per_bar` of volume). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VolumeBar { + /// Open of the first candle in the bar. + pub open: f64, + /// Highest high across the bar. + pub high: f64, + /// Lowest low across the bar. + pub low: f64, + /// Close of the candle that closed the bar. + pub close: f64, + /// Accumulated volume in the bar (`>= volume_per_bar`; the crossing candle's + /// overshoot is kept in the bar that closes). + pub volume: f64, +} + +/// Volume bar builder — emits a bar each time accumulated volume reaches +/// `volume_per_bar`. +/// +/// Where [`TickBars`](crate::TickBars) sample on trade *count*, volume bars sample on +/// traded *quantity*: a bar closes once the candles fed into it have accumulated at +/// least `volume_per_bar` of volume. This gives each bar roughly equal participation, +/// which de-emphasises quiet periods and resolves bursts of heavy trading into more +/// bars. The companion [`DollarBars`](crate::DollarBars) builder uses traded *value* +/// (`price × volume`) instead, which is more robust to price-level drift over long +/// histories. +/// +/// The bar is candle-granular: at most one bar closes per candle, and the candle +/// that crosses the threshold closes the bar with its overshoot included (the next +/// bar starts fresh). [`BarBuilder::update`] therefore returns either an empty vector +/// or a single [`VolumeBar`]. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BarBuilder, Candle, VolumeBars}; +/// +/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap(); +/// let mut bars = VolumeBars::new(100.0).unwrap(); +/// assert!(bars.update(c(10.0, 60.0)).is_empty()); +/// let out = bars.update(c(10.5, 60.0)); // 120 >= 100 -> close +/// assert_eq!(out.len(), 1); +/// assert_eq!(out[0].volume, 120.0); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeBars { + volume_per_bar: f64, + count: usize, + open: f64, + high: f64, + low: f64, + close: f64, + accumulated: f64, +} + +impl VolumeBars { + /// Construct a volume-bar builder with the given volume threshold. + /// + /// # Errors + /// + /// Returns [`Error::InvalidPeriod`] if `volume_per_bar` is not finite and positive. + pub fn new(volume_per_bar: f64) -> Result { + if !volume_per_bar.is_finite() || volume_per_bar <= 0.0 { + return Err(Error::InvalidPeriod { + message: "volume_per_bar must be finite and positive", + }); + } + Ok(Self { + volume_per_bar, + count: 0, + open: 0.0, + high: 0.0, + low: 0.0, + close: 0.0, + accumulated: 0.0, + }) + } + + /// Configured volume threshold per bar. + pub const fn volume_per_bar(&self) -> f64 { + self.volume_per_bar + } + + /// Volume accumulated into the in-progress bar. + pub const fn accumulated(&self) -> f64 { + self.accumulated + } +} + +impl BarBuilder for VolumeBars { + type Bar = VolumeBar; + + fn update(&mut self, candle: Candle) -> Vec { + if self.count == 0 { + self.open = candle.open; + self.high = candle.high; + self.low = candle.low; + } else { + self.high = self.high.max(candle.high); + self.low = self.low.min(candle.low); + } + self.close = candle.close; + self.accumulated += candle.volume; + self.count += 1; + if self.accumulated < self.volume_per_bar { + return Vec::new(); + } + let bar = VolumeBar { + open: self.open, + high: self.high, + low: self.low, + close: self.close, + volume: self.accumulated, + }; + self.count = 0; + self.accumulated = 0.0; + vec![bar] + } + + fn reset(&mut self) { + self.count = 0; + self.accumulated = 0.0; + } + + fn name(&self) -> &'static str { + "VolumeBars" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new(open, high, low, close, volume, 0).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(matches!( + VolumeBars::new(0.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolumeBars::new(-100.0), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolumeBars::new(f64::INFINITY), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let bars = VolumeBars::new(1000.0).unwrap(); + assert_relative_eq!(bars.volume_per_bar(), 1000.0, epsilon = 1e-12); + assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12); + assert_eq!(bars.name(), "VolumeBars"); + } + + #[test] + fn closes_when_threshold_reached() { + let mut bars = VolumeBars::new(100.0).unwrap(); + assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty()); + let out = bars.update(candle(10.5, 10.5, 10.5, 10.5, 60.0)); + assert_eq!(out.len(), 1); + assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12); + } + + #[test] + fn aggregates_ohlc() { + let mut bars = VolumeBars::new(100.0).unwrap(); + bars.update(candle(10.0, 11.0, 9.0, 10.5, 50.0)); + let out = bars.update(candle(10.5, 12.0, 10.0, 11.0, 60.0)); + assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12); + assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12); + assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12); + assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12); + } + + #[test] + fn below_threshold_emits_nothing() { + let mut bars = VolumeBars::new(100.0).unwrap(); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0)); + assert_relative_eq!(bars.accumulated(), 30.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut bars = VolumeBars::new(100.0).unwrap(); + bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)); + bars.reset(); + assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12); + assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 60.0)).is_empty()); + } + + #[test] + fn batch_concatenates_completed_bars() { + let mut bars = VolumeBars::new(100.0).unwrap(); + let candles = [ + candle(10.0, 10.0, 10.0, 10.0, 60.0), + candle(10.0, 10.0, 10.0, 10.0, 60.0), + candle(10.0, 10.0, 10.0, 10.0, 60.0), + candle(10.0, 10.0, 10.0, 10.0, 60.0), + ]; + let out = bars.batch(&candles); + assert_eq!(out.len(), 2); + } +} diff --git a/crates/wickra-core/src/indicators/volume_by_time_profile.rs b/crates/wickra-core/src/indicators/volume_by_time_profile.rs new file mode 100644 index 0000000..4bbdce8 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_by_time_profile.rs @@ -0,0 +1,198 @@ +//! Volume-by-Time Profile — the mean traded volume in each intraday bucket. + +use crate::calendar::civil_from_timestamp; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-by-Time Profile output: the per-bucket mean volume. +/// +/// `bins[i]` is the mean volume of all bars whose local time-of-day fell in +/// bucket `i`. Empty buckets read `0.0`. +#[derive(Debug, Clone, PartialEq)] +pub struct VolumeByTimeProfileOutput { + /// Per-bucket mean volume, earliest bucket first. Length equals `buckets`. + pub bins: Vec, +} + +/// Mean traded volume bucketed by local time of day. +/// +/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle) +/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each +/// bar's volume is accumulated into the bucket of its time-of-day, and the +/// profile reports the running mean volume per bucket. Unlike the return +/// profiles, the first bar already produces output (volume needs no prior bar). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeByTimeProfile}; +/// +/// let hour = 3_600_000; +/// let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); +/// let out = prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 500.0, hour).unwrap()).unwrap(); +/// assert_eq!(out.bins.len(), 24); +/// assert_eq!(out.bins[1], 500.0); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeByTimeProfile { + buckets: usize, + utc_offset_minutes: i32, + sum: Vec, + count: Vec, + last: Option, +} + +impl VolumeByTimeProfile { + /// Construct a Volume-by-Time Profile with `buckets` intraday slices. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `buckets == 0`. + pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result { + if buckets == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + buckets, + utc_offset_minutes, + sum: vec![0.0; buckets], + count: vec![0; buckets], + last: None, + }) + } + + /// Configured `(buckets, utc_offset_minutes)`. + pub const fn params(&self) -> (usize, i32) { + (self.buckets, self.utc_offset_minutes) + } + + /// Most recent profile if at least one bar has been seen. + pub fn value(&self) -> Option<&VolumeByTimeProfileOutput> { + self.last.as_ref() + } + + fn bucket_of(&self, minute_of_day: u32) -> usize { + let raw = (minute_of_day as usize * self.buckets) / 1440; + raw.min(self.buckets - 1) + } + + fn snapshot(&self) -> VolumeByTimeProfileOutput { + let bins = self + .sum + .iter() + .zip(&self.count) + .map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 }) + .collect(); + VolumeByTimeProfileOutput { bins } + } +} + +impl Indicator for VolumeByTimeProfile { + type Input = Candle; + type Output = VolumeByTimeProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let bucket = self.bucket_of(civil.minute_of_day()); + self.sum[bucket] += candle.volume; + self.count[bucket] += 1; + let out = self.snapshot(); + self.last = Some(out.clone()); + Some(out) + } + + fn reset(&mut self) { + self.sum.iter_mut().for_each(|x| *x = 0.0); + self.count.iter_mut().for_each(|x| *x = 0); + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeByTimeProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(volume: f64, ts: i64) -> Candle { + Candle::new(100.0, 100.0, 100.0, 100.0, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_buckets() { + assert!(matches!( + VolumeByTimeProfile::new(0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn metadata_and_accessors() { + let prof = VolumeByTimeProfile::new(24, -60).unwrap(); + assert_eq!(prof.params(), (24, -60)); + assert_eq!(prof.name(), "VolumeByTimeProfile"); + assert_eq!(prof.warmup_period(), 1); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + } + + #[test] + fn emits_from_first_bar_and_means_volume() { + let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); + let out = prof.update(c(500.0, HOUR)).unwrap(); // 01:00 -> bucket 1 + assert_eq!(out.bins.len(), 24); + assert_relative_eq!(out.bins[1], 500.0); + assert_relative_eq!(out.bins[0], 0.0); + assert!(prof.is_ready()); + // Next day 01:00, volume 700 -> mean (500 + 700) / 2 = 600. + let out = prof.update(c(700.0, 25 * HOUR)).unwrap(); + assert_relative_eq!(out.bins[1], 600.0); + } + + #[test] + fn last_bucket_clamped() { + let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); + // 23:59 -> minute 1439 -> bucket 23. + let out = prof.update(c(300.0, 23 * HOUR + 59 * 60_000)).unwrap(); + assert_relative_eq!(out.bins[23], 300.0); + } + + #[test] + fn reset_clears_state() { + let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); + prof.update(c(500.0, HOUR)); + prof.reset(); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + let out = prof.update(c(100.0, 2 * HOUR)).unwrap(); + assert_relative_eq!(out.bins[2], 100.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(100.0 + f64::from(i % 8), i64::from(i) * HOUR)) + .collect(); + let mut a = VolumeByTimeProfile::new(12, 0).unwrap(); + let mut b = VolumeByTimeProfile::new(12, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/volume_oscillator.rs b/crates/wickra-core/src/indicators/volume_oscillator.rs new file mode 100644 index 0000000..a3eda3e --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_oscillator.rs @@ -0,0 +1,206 @@ +//! Volume Oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume Oscillator — the percent difference between a fast and a slow SMA +/// of the bar volume. +/// +/// ```text +/// VO_t = 100 · (SMA(volume, fast)_t − SMA(volume, slow)_t) / SMA(volume, slow)_t +/// ``` +/// +/// A positive reading means short-term volume is running above the longer-term +/// average (rising participation), a negative reading the opposite. The line is +/// unbounded above and below `-100`, but stays near zero in stable conditions. +/// Classic configuration is `fast = 14, slow = 28`. The first emission lands +/// after `slow` candles. A slow average of `0` (only possible if every volume +/// in the slow window was zero) collapses the output to `0` rather than NaN. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeOscillator}; +/// +/// let mut indicator = VolumeOscillator::new(14, 28).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeOscillator { + fast_period: usize, + slow_period: usize, + fast: Sma, + slow: Sma, +} + +impl VolumeOscillator { + /// Construct a Volume Oscillator with the given SMA periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "VolumeOscillator needs fast < slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Sma::new(fast)?, + slow: Sma::new(slow)?, + }) + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for VolumeOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let f = self.fast.update(candle.volume); + let s = self.slow.update(candle.volume); + let (fast_v, slow_v) = (f?, s?); + if slow_v == 0.0 { + // Whole slow window is zero-volume — the ratio is undefined; report 0. + return Some(0.0); + } + Some(100.0 * (fast_v - slow_v) / slow_v) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + self.slow_period + } + + fn is_ready(&self) -> bool { + self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "VolumeOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(volume: f64, ts: i64) -> Candle { + Candle::new(10.0, 10.0, 10.0, 10.0, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + VolumeOscillator::new(0, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + VolumeOscillator::new(5, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + VolumeOscillator::new(10, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolumeOscillator::new(28, 14), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let vo = VolumeOscillator::new(14, 28).unwrap(); + assert_eq!(vo.periods(), (14, 28)); + assert_eq!(vo.name(), "VolumeOscillator"); + assert_eq!(vo.warmup_period(), 28); + } + + #[test] + fn constant_volume_yields_zero() { + // Both SMAs equal the constant volume, so (fast - slow) / slow = 0. + let mut vo = VolumeOscillator::new(3, 6).unwrap(); + let candles: Vec = (0..30i64).map(|i| c(500.0, i)).collect(); + for v in vo.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_window_yields_zero() { + // All bars carry zero volume — slow SMA is 0, defensive branch returns 0. + let mut vo = VolumeOscillator::new(2, 4).unwrap(); + let candles: Vec = (0..10i64).map(|i| c(0.0, i)).collect(); + let out = vo.batch(&candles); + assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reference_value() { + // fast=2, slow=4 over volumes [10, 20, 30, 40, 50]: + // bar 4 (index 3): fast=(40+30)/2=35, slow=(10+20+30+40)/4=25, + // VO = 100·(35-25)/25 = 40. + let mut vo = VolumeOscillator::new(2, 4).unwrap(); + let candles = [c(10.0, 0), c(20.0, 1), c(30.0, 2), c(40.0, 3), c(50.0, 4)]; + let out = vo.batch(&candles); + assert!(out[0].is_none() && out[1].is_none() && out[2].is_none()); + assert_relative_eq!(out[3].unwrap(), 40.0, epsilon = 1e-9); + // bar 5 (index 4): fast=(50+40)/2=45, slow=(20+30+40+50)/4=35, + // VO = 100·(45-35)/35 = 1000/35. + assert_relative_eq!(out[4].unwrap(), 1000.0 / 35.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| c(100.0 + ((i % 11) as f64) * 5.0, i)) + .collect(); + let mut a = VolumeOscillator::new(14, 28).unwrap(); + let mut b = VolumeOscillator::new(14, 28).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..60i64).map(|i| c(100.0 + (i as f64), i)).collect(); + let mut vo = VolumeOscillator::new(14, 28).unwrap(); + vo.batch(&candles); + assert!(vo.is_ready()); + vo.reset(); + assert!(!vo.is_ready()); + assert_eq!(vo.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/volume_profile.rs b/crates/wickra-core/src/indicators/volume_profile.rs new file mode 100644 index 0000000..5786f49 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_profile.rs @@ -0,0 +1,333 @@ +//! Volume Profile — the full per-bin volume distribution over a rolling window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume Profile output: the price domain plus the per-bin volume histogram. +/// +/// `bins[i]` holds the volume attributed to the price bucket +/// `[price_low + i * w, price_low + (i + 1) * w)` where +/// `w = (price_high - price_low) / bins.len()`. The histogram sums to the total +/// volume in the rolling window (within floating-point tolerance). +#[derive(Debug, Clone, PartialEq)] +pub struct VolumeProfileOutput { + /// Lowest price in the window — the lower edge of bin 0. + pub price_low: f64, + /// Highest price in the window — the upper edge of the last bin. + pub price_high: f64, + /// Per-bin volume, lowest price bucket first. Length equals `bin_count`. + pub bins: Vec, +} + +/// Rolling Volume Profile over the last `period` candles. +/// +/// Where [`crate::ValueArea`] reduces the same volume distribution to its +/// summary levels (Point of Control, Value Area High / Low), Volume Profile +/// exposes the **full histogram** so callers can inspect, render or post-process +/// the raw distribution. Each candle's volume is spread uniformly across the +/// bins its `[low, high]` range touches; a single-print bar (`low == high`) +/// drops its whole volume into one bin. The histogram domain spans the window's +/// lowest low to its highest high. +/// +/// A window whose bars are all single-print at one price (`price_high == price_low`) +/// is degenerate: the entire volume lands in bin 0 and both edges collapse to +/// that price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeProfile}; +/// +/// let mut vp = VolumeProfile::new(5, 10).unwrap(); +/// let mut last = None; +/// for i in 0..10 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = vp.update(candle); +/// } +/// let profile = last.unwrap(); +/// assert_eq!(profile.bins.len(), 10); +/// ``` +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone)] +pub struct VolumeProfile { + period: usize, + bin_count: usize, + window: VecDeque, + last: Option, +} + +impl VolumeProfile { + /// Construct a Volume Profile indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero. + pub fn new(period: usize, bin_count: usize) -> Result { + if period == 0 || bin_count == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bin_count, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Classic Volume Profile: 20-bar rolling window, 50 bins. + pub fn classic() -> Self { + Self::new(20, 50).expect("classic VolumeProfile params are valid") + } + + /// Configured `(period, bin_count)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bin_count) + } + + /// Most recent profile if available. + pub fn value(&self) -> Option<&VolumeProfileOutput> { + self.last.as_ref() + } + + fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize { + let raw = ((price - win_low) / bin_width).floor(); + let max = (self.bin_count - 1) as f64; + raw.clamp(0.0, max) as usize + } + + fn compute(&self) -> VolumeProfileOutput { + let mut win_low = f64::INFINITY; + let mut win_high = f64::NEG_INFINITY; + for candle in &self.window { + if candle.low < win_low { + win_low = candle.low; + } + if candle.high > win_high { + win_high = candle.high; + } + } + let span = win_high - win_low; + let mut bins = vec![0.0_f64; self.bin_count]; + + if span <= 0.0 { + // All bars are single-print at the same price. + let total: f64 = self.window.iter().map(|candle| candle.volume).sum(); + bins[0] = total; + return VolumeProfileOutput { + price_low: win_low, + price_high: win_low, + bins, + }; + } + + let bin_width = span / self.bin_count as f64; + for candle in &self.window { + if candle.volume == 0.0 { + continue; + } + if candle.high <= candle.low { + let idx = self.price_to_bin(candle.low, win_low, bin_width); + bins[idx] += candle.volume; + continue; + } + let lo_idx = self.price_to_bin(candle.low, win_low, bin_width); + let hi_idx = self.price_to_bin(candle.high, win_low, bin_width); + let touched = hi_idx - lo_idx + 1; + let share = candle.volume / touched as f64; + for bin in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *bin += share; + } + } + + VolumeProfileOutput { + price_low: win_low, + price_high: win_high, + bins, + } + } +} + +impl Indicator for VolumeProfile { + type Input = Candle; + type Output = VolumeProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out.clone()); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(VolumeProfile::new(0, 50), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_zero_bin_count() { + assert!(matches!(VolumeProfile::new(20, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let vp = VolumeProfile::new(20, 50).unwrap(); + assert_eq!(vp.name(), "VolumeProfile"); + assert_eq!(vp.warmup_period(), 20); + assert_eq!(vp.params(), (20, 50)); + assert!(vp.value().is_none()); + assert!(!vp.is_ready()); + } + + #[test] + fn classic_params() { + let vp = VolumeProfile::classic(); + assert_eq!(vp.params(), (20, 50)); + } + + #[test] + fn warms_up_over_period() { + let mut vp = VolumeProfile::new(3, 4).unwrap(); + assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none()); + assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none()); + assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some()); + assert!(vp.is_ready()); + } + + #[test] + fn reference_distribution() { + // Window of 2 candles, 4 bins. + // bar0: single print at 10, vol 100 -> bin 0 gets 100. + // bar1: 10..14, vol 80, spans 4 bins -> 20 each. + // domain: low=10, high=14, width=1 -> bins = [120, 20, 20, 20]. + let mut vp = VolumeProfile::new(2, 4).unwrap(); + assert!(vp.update(c(10.0, 10.0, 10.0, 10.0, 100.0, 0)).is_none()); + let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 80.0, 1)).unwrap(); + assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12); + assert_eq!(out.bins.len(), 4); + assert_relative_eq!(out.bins[0], 120.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[1], 20.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[2], 20.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[3], 20.0, epsilon = 1e-9); + } + + #[test] + fn conserves_total_volume() { + let mut vp = VolumeProfile::new(4, 8).unwrap(); + let candles = [ + c(10.0, 12.0, 9.0, 11.0, 30.0, 0), + c(11.0, 13.0, 10.0, 12.0, 40.0, 1), + c(12.0, 14.0, 11.0, 13.0, 50.0, 2), + c(13.0, 15.0, 12.0, 14.0, 60.0, 3), + ]; + let out = vp.batch(&candles).pop().unwrap().unwrap(); + let total: f64 = out.bins.iter().sum(); + assert_relative_eq!(total, 180.0, epsilon = 1e-9); + } + + #[test] + fn degenerate_single_price_window() { + // All bars single-print at 50 -> domain collapses, all volume in bin 0. + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0)); + let out = vp.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1)).unwrap(); + assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.bins[0], 30.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[1], 0.0, epsilon = 1e-12); + } + + #[test] + fn zero_volume_bars_are_skipped() { + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(10.0, 14.0, 10.0, 12.0, 0.0, 0)); + let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1)).unwrap(); + let total: f64 = out.bins.iter().sum(); + assert_relative_eq!(total, 40.0, epsilon = 1e-9); + } + + #[test] + fn rolling_window_drops_oldest() { + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(100.0, 100.0, 100.0, 100.0, 99.0, 0)); + vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1)); + // Third bar evicts the price-100 bar; domain is now 10..14 only. + let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 2)).unwrap(); + assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12); + let total: f64 = out.bins.iter().sum(); + assert_relative_eq!(total, 80.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)); + vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)); + assert!(vp.is_ready()); + vp.reset(); + assert!(!vp.is_ready()); + assert!(vp.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i % 7); + c( + base, + base + 2.0, + base - 2.0, + base, + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut a = VolumeProfile::new(10, 16).unwrap(); + let mut b = VolumeProfile::new(10, 16).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/volume_rsi.rs b/crates/wickra-core/src/indicators/volume_rsi.rs new file mode 100644 index 0000000..90f3e82 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_rsi.rs @@ -0,0 +1,265 @@ +//! Volume RSI — Wilder's RSI applied to the volume stream. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume RSI — the Relative Strength Index computed on **volume** changes +/// instead of price changes. +/// +/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price* +/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar +/// change in volume: +/// +/// ```text +/// change_t = volume_t − volume_{t−1} +/// gain = max(change, 0), loss = max(−change, 0) +/// avg_gain, avg_loss = Wilder-smoothed over `period` +/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss) +/// ``` +/// +/// Readings above `50` mean volume is expanding (more was added than removed over +/// the smoothing window) and tend to confirm the prevailing move; readings below +/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch +/// of unchanged volume drives both averages to `0` and the indicator reports the +/// neutral `50` rather than an undefined `0 / 0`. +/// +/// Only the candle's **volume** is used. The first bar sets the previous volume, +/// then `period` changes seed Wilder's averages, so the first value lands after +/// `period + 1` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeRsi}; +/// +/// let mut indicator = VolumeRsi::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0; +/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeRsi { + period: usize, + prev_volume: Option, + seed_gains: f64, + seed_losses: f64, + seed_count: usize, + avg_gain: Option, + avg_loss: Option, + last: Option, +} + +impl VolumeRsi { + /// Construct a Volume RSI with the given Wilder smoothing `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_volume: None, + seed_gains: 0.0, + seed_losses: 0.0, + seed_count: 0, + avg_gain: None, + avg_loss: None, + last: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { + let denom = avg_gain + avg_loss; + if denom == 0.0 { + 50.0 + } else { + 100.0 * (avg_gain / denom) + } + } +} + +impl Indicator for VolumeRsi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let volume = candle.volume; + let Some(prev) = self.prev_volume else { + self.prev_volume = Some(volume); + return None; + }; + let change = volume - prev; + self.prev_volume = Some(volume); + let gain = if change > 0.0 { change } else { 0.0 }; + let loss = if change < 0.0 { -change } else { 0.0 }; + + if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) { + let n = self.period as f64; + let new_ag = (ag * (n - 1.0) + gain) / n; + let new_al = (al * (n - 1.0) + loss) / n; + self.avg_gain = Some(new_ag); + self.avg_loss = Some(new_al); + let v = Self::rsi_from_avgs(new_ag, new_al); + self.last = Some(v); + return Some(v); + } + + self.seed_gains += gain; + self.seed_losses += loss; + self.seed_count += 1; + if self.seed_count == self.period { + let n = self.period as f64; + let ag = self.seed_gains / n; + let al = self.seed_losses / n; + self.avg_gain = Some(ag); + self.avg_loss = Some(al); + let v = Self::rsi_from_avgs(ag, al); + self.last = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.prev_volume = None; + self.seed_gains = 0.0; + self.seed_losses = 0.0; + self.seed_count = 0; + self.avg_gain = None; + self.avg_loss = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeRsi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Candle whose only material field here is `volume`. + fn vol_candle(volume: f64) -> Candle { + Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = VolumeRsi::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.warmup_period(), 15); + assert_eq!(v.name(), "VolumeRsi"); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut v = VolumeRsi::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect(); + let out = v.batch(&candles); + // warmup_period == period + 1 == 4: first emission at index 3. + for o in out.iter().take(3) { + assert!(o.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn rising_volume_is_one_hundred() { + // Every change positive -> avg_loss 0 -> RSI 100. + let mut v = VolumeRsi::new(5).unwrap(); + let candles: Vec = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn falling_volume_is_zero() { + let mut v = VolumeRsi::new(5).unwrap(); + let candles: Vec = (1..=40) + .map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0)) + .collect(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn flat_volume_is_neutral() { + // Unchanged volume -> no gains and no losses -> neutral 50. + let mut v = VolumeRsi::new(3).unwrap(); + let candles: Vec = (0..20).map(|_| vol_candle(2_000.0)).collect(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut v = VolumeRsi::new(14).unwrap(); + let candles: Vec = (0..200) + .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0)) + .collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert!((0.0..=100.0).contains(&o)); + } + } + + #[test] + fn reset_clears_state() { + let mut v = VolumeRsi::new(3).unwrap(); + let candles: Vec = (0..20) + .map(|i| vol_candle(1_000.0 + f64::from(i))) + .collect(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + assert_eq!(v.update(vol_candle(1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0)) + .collect(); + let batch = VolumeRsi::new(14).unwrap().batch(&candles); + let mut b = VolumeRsi::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volume_weighted_macd.rs b/crates/wickra-core/src/indicators/volume_weighted_macd.rs new file mode 100644 index 0000000..892a8cd --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_weighted_macd.rs @@ -0,0 +1,270 @@ +//! Volume-Weighted MACD — MACD built on volume-weighted moving averages. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::vwma::Vwma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the +/// fast and slow averages volume-weighted. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VolumeWeightedMacdOutput { + /// Fast VWMA − slow VWMA. + pub macd: f64, + /// EMA of `macd` over the signal period. + pub signal: f64, + /// `macd − signal`. + pub histogram: f64, +} + +/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted** +/// moving averages instead of plain EMAs. +/// +/// ```text +/// macd = VWMA(close, fast) − VWMA(close, slow) +/// signal = EMA(macd, signal_period) +/// histogram = macd − signal +/// ``` +/// +/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential +/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and +/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate +/// the trend estimate and the oscillator leans toward where real participation +/// occurred. Crossovers backed by volume therefore appear sooner and noise from +/// thin bars is damped. The signal line keeps a standard EMA, matching the +/// classic histogram construction. +/// +/// `fast` must be strictly smaller than `slow`. The first output lands after +/// `slow + signal − 1` inputs: `slow` to seed the slow VWMA, then `signal − 1` +/// more to seed the signal EMA. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd}; +/// +/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeWeightedMacd { + fast: Vwma, + slow: Vwma, + signal_ema: Ema, + fast_period: usize, + slow_period: usize, + signal_period: usize, + last: Option, +} + +impl VolumeWeightedMacd { + /// Construct a volume-weighted MACD with the given periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is zero, and + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + if fast == 0 || slow == 0 || signal == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "fast period must be strictly less than slow period", + }); + } + Ok(Self { + fast: Vwma::new(fast)?, + slow: Vwma::new(slow)?, + signal_ema: Ema::new(signal)?, + fast_period: fast, + slow_period: slow, + signal_period: signal, + last: None, + }) + } + + /// Configured periods as `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.signal_period) + } + + /// Most recent fully-computed output if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for VolumeWeightedMacd { + type Input = Candle; + type Output = VolumeWeightedMacdOutput; + + fn update(&mut self, candle: Candle) -> Option { + let fast = self.fast.update(candle); + let slow = self.slow.update(candle); + if let (Some(f), Some(s)) = (fast, slow) { + let macd = f - s; + let signal = self.signal_ema.update(macd)?; + let out = VolumeWeightedMacdOutput { + macd, + signal, + histogram: macd - signal, + }; + self.last = Some(out); + return Some(out); + } + None + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal_ema.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.slow_period + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeWeightedMacd" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, volume, 0) + } + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + VolumeWeightedMacd::new(0, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + VolumeWeightedMacd::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolumeWeightedMacd::new(12, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let m = VolumeWeightedMacd::new(12, 26, 9).unwrap(); + assert_eq!(m.periods(), (12, 26, 9)); + assert_eq!(m.warmup_period(), 34); + assert_eq!(m.name(), "VolumeWeightedMacd"); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0 + f64::from(i), 1_000.0)) + .collect(); + let out = m.batch(&candles); + let warmup = m.warmup_period(); // 4 + 3 - 1 = 6 + assert_eq!(warmup, 6); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn uptrend_has_positive_macd() { + // A steady advance with equal volume -> fast VWMA leads slow -> macd > 0. + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..60) + .map(|i| candle(100.0 + f64::from(i), 1_000.0)) + .collect(); + let last = m.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last.macd > 0.0, + "uptrend should give positive macd, got {}", + last.macd + ); + } + + #[test] + fn histogram_is_macd_minus_signal() { + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..60) + .map(|i| { + candle( + 100.0 + (f64::from(i) * 0.3).sin() * 5.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + for o in m.batch(&candles).into_iter().flatten() { + assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9); + } + } + + #[test] + fn equal_volume_matches_plain_macd() { + // With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses + // SMA-based lines; it should still be a well-defined finite series. + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..60) + .map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0)) + .collect(); + for o in m.batch(&candles).into_iter().flatten() { + assert!(o.macd.is_finite() && o.signal.is_finite()); + } + } + + #[test] + fn reset_clears_state() { + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..40) + .map(|i| candle(100.0 + f64::from(i), 1_000.0)) + .collect(); + m.batch(&candles); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + assert_eq!(m.update(candle(100.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + candle( + 100.0 + (f64::from(i) * 0.25).sin() * 9.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles); + let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volume_weighted_sr.rs b/crates/wickra-core/src/indicators/volume_weighted_sr.rs new file mode 100644 index 0000000..212b9d2 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_weighted_sr.rs @@ -0,0 +1,281 @@ +//! Volume-Weighted Support/Resistance — a volume-weighted high/low band. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`VolumeWeightedSr`]: the volume-weighted support and resistance +/// levels over the lookback. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VolumeWeightedSrOutput { + /// Volume-weighted average low — the support level. + pub support: f64, + /// Volume-weighted average high — the resistance level. + pub resistance: f64, +} + +/// Volume-Weighted Support/Resistance — a band whose edges are the +/// **volume-weighted** average of the recent highs (resistance) and lows +/// (support), so the levels gravitate toward the prices where trading actually +/// happened. +/// +/// ```text +/// support = Σ(low_i · volume_i) / Σ volume_i over the window +/// resistance = Σ(high_i · volume_i) / Σ volume_i over the window +/// ``` +/// +/// Plain high/low channels (e.g. [`Donchian`](crate::Donchian)) weight every bar +/// equally, so a thin spike sets the boundary. Volume-weighting pulls the support +/// and resistance toward the highs and lows that carried real volume — the prices +/// the market agreed mattered — giving levels that tend to hold better. The +/// distance between the two is a volume-aware range estimate. If the window's +/// volume is all zero the band falls back to the equal-weighted average high and +/// low. +/// +/// The first value lands after `period` inputs; each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeWeightedSr}; +/// +/// let mut indicator = VolumeWeightedSr::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; +/// let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeWeightedSr { + period: usize, + highs: VecDeque, + lows: VecDeque, + volumes: VecDeque, + sum_hv: f64, + sum_lv: f64, + sum_v: f64, + sum_h: f64, + sum_l: f64, + last: Option, +} + +impl VolumeWeightedSr { + /// Construct a volume-weighted S/R band over `period` bars. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + volumes: VecDeque::with_capacity(period), + sum_hv: 0.0, + sum_lv: 0.0, + sum_v: 0.0, + sum_h: 0.0, + sum_l: 0.0, + last: None, + }) + } + + /// Configured lookback period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for VolumeWeightedSr { + type Input = Candle; + type Output = VolumeWeightedSrOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + let h = self.highs.pop_front().expect("non-empty"); + let l = self.lows.pop_front().expect("non-empty"); + let v = self.volumes.pop_front().expect("non-empty"); + self.sum_hv -= h * v; + self.sum_lv -= l * v; + self.sum_v -= v; + self.sum_h -= h; + self.sum_l -= l; + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + self.volumes.push_back(candle.volume); + self.sum_hv += candle.high * candle.volume; + self.sum_lv += candle.low * candle.volume; + self.sum_v += candle.volume; + self.sum_h += candle.high; + self.sum_l += candle.low; + if self.highs.len() < self.period { + return None; + } + let n = self.period as f64; + let (support, resistance) = if self.sum_v > 0.0 { + (self.sum_lv / self.sum_v, self.sum_hv / self.sum_v) + } else { + (self.sum_l / n, self.sum_h / n) + }; + let out = VolumeWeightedSrOutput { + support, + resistance, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.volumes.clear(); + self.sum_hv = 0.0; + self.sum_lv = 0.0; + self.sum_v = 0.0; + self.sum_h = 0.0; + self.sum_l = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeWeightedSr" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(VolumeWeightedSr::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = VolumeWeightedSr::new(20).unwrap(); + assert_eq!(v.period(), 20); + assert_eq!(v.warmup_period(), 20); + assert_eq!(v.name(), "VolumeWeightedSr"); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut v = VolumeWeightedSr::new(4).unwrap(); + let candles: Vec = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect(); + let out = v.batch(&candles); + for o in out.iter().take(3) { + assert!(o.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn support_below_resistance() { + let mut v = VolumeWeightedSr::new(10).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.3).sin() * 5.0, + 90.0 + (f64::from(i) * 0.3).cos() * 5.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert!(o.support <= o.resistance); + } + } + + #[test] + fn weights_toward_high_volume_bars() { + // Three low-volume bars at [98,102] and one heavy bar at [108,112]; the + // resistance should be pulled toward the heavy bar's high. + let mut v = VolumeWeightedSr::new(4).unwrap(); + let candles = [ + c(102.0, 98.0, 100.0), + c(102.0, 98.0, 100.0), + c(102.0, 98.0, 100.0), + c(112.0, 108.0, 9_000.0), + ]; + let out = v.batch(&candles).into_iter().flatten().last().unwrap(); + // Volume-weighted resistance sits much closer to 112 than the simple mean (104.5). + assert!( + out.resistance > 108.0, + "resistance {} should lean to the heavy bar", + out.resistance + ); + } + + #[test] + fn zero_volume_falls_back_to_equal_weight() { + let mut v = VolumeWeightedSr::new(3).unwrap(); + let candles = [ + c(102.0, 98.0, 0.0), + c(104.0, 96.0, 0.0), + c(106.0, 94.0, 0.0), + ]; + let out = v.batch(&candles).into_iter().flatten().last().unwrap(); + // Equal-weight averages: high mean = 104, low mean = 96. + assert_relative_eq!(out.resistance, 104.0, epsilon = 1e-9); + assert_relative_eq!(out.support, 96.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut v = VolumeWeightedSr::new(4).unwrap(); + v.batch(&(0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect::>()); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + assert_eq!(v.update(c(102.0, 98.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + c( + 110.0 + (f64::from(i) * 0.25).sin() * 9.0, + 90.0 + (f64::from(i) * 0.25).cos() * 9.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = VolumeWeightedSr::new(20).unwrap().batch(&candles); + let mut b = VolumeWeightedSr::new(20).unwrap(); + let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/vortex.rs b/crates/wickra-core/src/indicators/vortex.rs new file mode 100644 index 0000000..08a6994 --- /dev/null +++ b/crates/wickra-core/src/indicators/vortex.rs @@ -0,0 +1,271 @@ +//! Vortex Indicator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Vortex Indicator output: the two directional movement lines. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VortexOutput { + /// `VI+` — strength of upward (positive) vortex movement. + pub plus: f64, + /// `VI−` — strength of downward (negative) vortex movement. + pub minus: f64, +} + +/// Vortex Indicator — Botes & Siepman's pair of oscillators (`VI+`, `VI−`) that +/// capture the relationship between two consecutive bars. +/// +/// Two "vortex movements" measure how far price travelled against the opposite +/// extreme of the previous bar; each is normalised by the summed true range: +/// +/// ```text +/// VM+_t = |high_t − low_{t−1}| +/// VM−_t = |low_t − high_{t−1}| +/// VI+ = Σ VM+ over n / Σ TR over n +/// VI− = Σ VM− over n / Σ TR over n +/// ``` +/// +/// `VI+` crossing above `VI−` is a bullish signal, the reverse a bearish one; +/// the wider the gap, the stronger the trend. A fully flat window (zero true +/// range) reports `(0, 0)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vortex}; +/// +/// let mut indicator = Vortex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + i as f64; +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Vortex { + period: usize, + prev: Option, + /// Rolling window of `(VM+, VM−, TR)` triples. + window: VecDeque<(f64, f64, f64)>, + sum_vm_plus: f64, + sum_vm_minus: f64, + sum_tr: f64, + last: Option, +} + +impl Vortex { + /// Construct a new Vortex Indicator with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev: None, + window: VecDeque::with_capacity(period), + sum_vm_plus: 0.0, + sum_vm_minus: 0.0, + sum_tr: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Vortex { + type Input = Candle; + type Output = VortexOutput; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + // The first bar has no predecessor to measure against. + self.prev = Some(candle); + return None; + }; + let vm_plus = (candle.high - prev.low).abs(); + let vm_minus = (candle.low - prev.high).abs(); + let tr = candle.true_range(Some(prev.close)); + self.prev = Some(candle); + + if self.window.len() == self.period { + let (old_p, old_m, old_tr) = self.window.pop_front().expect("window is non-empty"); + self.sum_vm_plus -= old_p; + self.sum_vm_minus -= old_m; + self.sum_tr -= old_tr; + } + self.window.push_back((vm_plus, vm_minus, tr)); + self.sum_vm_plus += vm_plus; + self.sum_vm_minus += vm_minus; + self.sum_tr += tr; + + if self.window.len() < self.period { + return None; + } + let out = if self.sum_tr == 0.0 { + // A perfectly flat window has no range to normalise against. + VortexOutput { + plus: 0.0, + minus: 0.0, + } + } else { + VortexOutput { + plus: self.sum_vm_plus / self.sum_tr, + minus: self.sum_vm_minus / self.sum_tr, + } + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum_vm_plus = 0.0; + self.sum_vm_minus = 0.0; + self.sum_tr = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first VM/TR triple needs a previous bar, then the window fills. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Vortex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Vortex::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (84-91) and the + /// Indicator-impl `name` body (157-159). `warmup_period` is covered + /// elsewhere. + #[test] + fn accessors_and_metadata() { + let mut v = Vortex::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.name(), "Vortex"); + assert!(v.value().is_none()); + let warmup = i64::try_from(v.warmup_period()).unwrap(); + let candles: Vec = (0..warmup) + .map(|i| { + let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; + Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap() + }) + .collect(); + for c in &candles { + v.update(*c); + } + assert!(v.value().is_some()); + } + + #[test] + fn reference_values() { + // Vortex(2) over three explicit candles (high, low, close): + // c1 = (10, 8, 9), c2 = (12, 9, 11), c3 = (13, 11, 12). + // bar 2: VM+ = |12-8| = 4, VM- = |9-10| = 1, TR = 3. + // bar 3: VM+ = |13-9| = 4, VM- = |11-12| = 1, TR = 2. + // window sums: VM+ = 8, VM- = 2, TR = 5 -> VI+ = 1.6, VI- = 0.4. + let candles = [ + candle(9.0, 10.0, 8.0, 9.0, 0), + candle(10.0, 12.0, 9.0, 11.0, 1), + candle(12.0, 13.0, 11.0, 12.0, 2), + ]; + let mut v = Vortex::new(2).unwrap(); + let out = v.batch(&candles); + assert_eq!(v.warmup_period(), 3); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + let o = out[2].unwrap(); + assert_relative_eq!(o.plus, 1.6, epsilon = 1e-12); + assert_relative_eq!(o.minus, 0.4, epsilon = 1e-12); + } + + #[test] + fn perfectly_flat_market_yields_zero() { + let mut v = Vortex::new(5).unwrap(); + let candles: Vec = (0..20).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert_relative_eq!(o.plus, 0.0, epsilon = 1e-12); + assert_relative_eq!(o.minus, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn outputs_are_non_negative() { + let mut v = Vortex::new(14).unwrap(); + let candles: Vec = (0..120) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0; + candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i) + }) + .collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert!(o.plus >= 0.0 && o.minus >= 0.0, "negative VI: {o:?}"); + } + } + + #[test] + fn reset_clears_state() { + let mut v = Vortex::new(5).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0, 102.0, 98.0, 101.0, i)) + .collect(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0; + candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i) + }) + .collect(); + let batch = Vortex::new(14).unwrap().batch(&candles); + let mut b = Vortex::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/vpin.rs b/crates/wickra-core/src/indicators/vpin.rs new file mode 100644 index 0000000..c2eee76 --- /dev/null +++ b/crates/wickra-core/src/indicators/vpin.rs @@ -0,0 +1,262 @@ +//! VPIN — Volume-Synchronised Probability of Informed Trading. + +use std::collections::VecDeque; + +use crate::microstructure::{Side, Trade}; +use crate::traits::Indicator; +use crate::{Error, Result}; + +/// VPIN — the Volume-Synchronised Probability of Informed Trading +/// (Easley, López de Prado & O'Hara, 2012). +/// +/// Trades are bucketed into equal-volume buckets of size `bucket_volume`. For +/// each completed bucket the order-flow imbalance is the absolute difference +/// between buy and sell volume; VPIN is that imbalance averaged over the last +/// `num_buckets` buckets and normalised by the bucket size: +/// +/// ```text +/// VPIN = ( Σ |Vᴮ_τ − Vˢ_τ| ) / (num_buckets · bucket_volume) +/// ``` +/// +/// The aggressor [`Side`] of each [`Trade`] classifies its volume directly (no +/// bulk-volume classification needed). A single trade may span several buckets; +/// its volume is split across bucket boundaries. The result lies in `[0, 1]`: +/// values near `1` signal a strongly one-sided, likely-informed flow (a toxic +/// regime), values near `0` a balanced two-sided flow. +/// +/// `Input = Trade`. Because bucket completion is driven by cumulative volume, +/// readiness is data-dependent; [`warmup_period`](Indicator::warmup_period) +/// reports `num_buckets` as the minimum number of trades (one per bucket) and +/// [`is_ready`](Indicator::is_ready) reflects the true bucket count. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Side, Trade, Vpin}; +/// +/// let mut vpin = Vpin::new(10.0, 2).unwrap(); +/// // Two buckets of pure buying => imbalance == bucket size => VPIN 1. +/// let mut last = None; +/// for _ in 0..4 { +/// last = vpin.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()); +/// } +/// assert_eq!(last, Some(1.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Vpin { + bucket_volume: f64, + num_buckets: usize, + cur_buy: f64, + cur_sell: f64, + cur_total: f64, + window: VecDeque, + sum_imbalance: f64, +} + +impl Vpin { + /// Construct a new VPIN estimator. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `num_buckets == 0`, or + /// [`Error::InvalidParameter`] if `bucket_volume` is not finite and + /// positive. + pub fn new(bucket_volume: f64, num_buckets: usize) -> Result { + if num_buckets == 0 { + return Err(Error::PeriodZero); + } + if !bucket_volume.is_finite() || bucket_volume <= 0.0 { + return Err(Error::InvalidParameter { + message: "VPIN bucket_volume must be finite and positive", + }); + } + Ok(Self { + bucket_volume, + num_buckets, + cur_buy: 0.0, + cur_sell: 0.0, + cur_total: 0.0, + window: VecDeque::with_capacity(num_buckets), + sum_imbalance: 0.0, + }) + } + + /// Configured `(bucket_volume, num_buckets)`. + pub const fn params(&self) -> (f64, usize) { + (self.bucket_volume, self.num_buckets) + } + + fn close_bucket(&mut self) { + let imbalance = (self.cur_buy - self.cur_sell).abs(); + if self.window.len() == self.num_buckets { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum_imbalance -= old; + } + self.window.push_back(imbalance); + self.sum_imbalance += imbalance; + self.cur_buy = 0.0; + self.cur_sell = 0.0; + self.cur_total = 0.0; + } +} + +impl Indicator for Vpin { + type Input = Trade; + type Output = f64; + + fn update(&mut self, trade: Trade) -> Option { + let mut remaining = trade.size; + let buy = trade.side == Side::Buy; + // Distribute the trade's volume across one or more buckets. + while remaining > 0.0 { + let capacity = self.bucket_volume - self.cur_total; + let take = remaining.min(capacity); + if buy { + self.cur_buy += take; + } else { + self.cur_sell += take; + } + self.cur_total += take; + remaining -= take; + if self.cur_total >= self.bucket_volume { + self.close_bucket(); + } + } + if self.window.len() < self.num_buckets { + return None; + } + Some(self.sum_imbalance / (self.num_buckets as f64 * self.bucket_volume)) + } + + fn reset(&mut self) { + self.cur_buy = 0.0; + self.cur_sell = 0.0; + self.cur_total = 0.0; + self.window.clear(); + self.sum_imbalance = 0.0; + } + + fn warmup_period(&self) -> usize { + self.num_buckets + } + + fn is_ready(&self) -> bool { + self.window.len() == self.num_buckets + } + + fn name(&self) -> &'static str { + "Vpin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn trade(size: f64, side: Side) -> Trade { + Trade::new(100.0, size, side, 0).unwrap() + } + + #[test] + fn rejects_bad_params() { + assert!(matches!(Vpin::new(10.0, 0), Err(Error::PeriodZero))); + assert!(matches!( + Vpin::new(0.0, 5), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + Vpin::new(f64::NAN, 5), + Err(Error::InvalidParameter { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let vpin = Vpin::new(10.0, 50).unwrap(); + assert_eq!(vpin.params(), (10.0, 50)); + assert_eq!(vpin.warmup_period(), 50); + assert_eq!(vpin.name(), "Vpin"); + assert!(!vpin.is_ready()); + } + + #[test] + fn one_sided_flow_is_one() { + // Every bucket is pure buying => |buy - sell| == bucket size => VPIN 1. + let mut vpin = Vpin::new(10.0, 2).unwrap(); + let mut last = None; + for _ in 0..4 { + last = vpin.update(trade(5.0, Side::Buy)); + } + assert_relative_eq!(last.unwrap(), 1.0, epsilon = 1e-12); + assert!(vpin.is_ready()); + } + + #[test] + fn balanced_flow_is_zero() { + // Each bucket holds equal buy and sell volume => imbalance 0 => VPIN 0. + let mut vpin = Vpin::new(10.0, 2).unwrap(); + let mut last = None; + for _ in 0..4 { + vpin.update(trade(5.0, Side::Buy)); + last = vpin.update(trade(5.0, Side::Sell)); + } + assert_relative_eq!(last.unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn large_trade_spans_multiple_buckets() { + // A single 25-unit buy fills 2 full buckets (size 10) plus 5 into a + // third. Two buckets close => both pure buy => imbalance 10 each. + let mut vpin = Vpin::new(10.0, 2).unwrap(); + let out = vpin.update(trade(25.0, Side::Buy)); + // After 2 closed buckets the window is full: VPIN = (10+10)/(2*10) = 1. + assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn output_within_bounds() { + let mut vpin = Vpin::new(7.0, 4).unwrap(); + for i in 0..200 { + let side = if i % 3 == 0 { Side::Sell } else { Side::Buy }; + if let Some(v) = vpin.update(trade(1.0 + f64::from(i % 5), side)) { + assert!((0.0..=1.0).contains(&v), "out of bounds: {v}"); + } + } + } + + #[test] + fn zero_size_trade_is_noop() { + let mut vpin = Vpin::new(10.0, 1).unwrap(); + assert_eq!(vpin.update(trade(0.0, Side::Buy)), None); + // A full bucket of buying then closes it: VPIN 1. + let out = vpin.update(trade(10.0, Side::Buy)); + assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut vpin = Vpin::new(10.0, 2).unwrap(); + for _ in 0..4 { + vpin.update(trade(5.0, Side::Buy)); + } + assert!(vpin.is_ready()); + vpin.reset(); + assert!(!vpin.is_ready()); + assert_eq!(vpin.update(trade(5.0, Side::Buy)), None); + } + + #[test] + fn batch_equals_streaming() { + let trades: Vec = (0..120) + .map(|i| { + let side = if i % 2 == 0 { Side::Buy } else { Side::Sell }; + trade(1.0 + f64::from(i % 4), side) + }) + .collect(); + let batch = Vpin::new(8.0, 5).unwrap().batch(&trades); + let mut b = Vpin::new(8.0, 5).unwrap(); + let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/vpt.rs b/crates/wickra-core/src/indicators/vpt.rs new file mode 100644 index 0000000..0bf58c9 --- /dev/null +++ b/crates/wickra-core/src/indicators/vpt.rs @@ -0,0 +1,203 @@ +//! Volume-Price Trend. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-Price Trend — a cumulative volume line weighted by percentage price +/// change. +/// +/// VPT is a close relative of [`Obv`](crate::Obv), but instead of adding the +/// full bar volume on every up-close it adds volume scaled by the *size* of +/// the move: +/// +/// ```text +/// VPT_t = VPT_{t−1} + volume_t · (close_t − close_{t−1}) / close_{t−1} +/// ``` +/// +/// A big move on heavy volume moves the line far; a small move on the same +/// volume barely nudges it. The running total is unbounded — its slope and its +/// divergence from price are what carry the signal. The first bar establishes +/// the baseline at `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumePriceTrend}; +/// +/// let mut indicator = VolumePriceTrend::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct VolumePriceTrend { + prev_close: Option, + total: f64, + has_emitted: bool, +} + +impl VolumePriceTrend { + /// Construct a new Volume-Price Trend starting at zero. + pub const fn new() -> Self { + Self { + prev_close: None, + total: 0.0, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.total) + } else { + None + } + } +} + +impl Indicator for VolumePriceTrend { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let Some(prev) = self.prev_close else { + // The first candle establishes the baseline at 0. + self.prev_close = Some(candle.close); + return Some(self.total); + }; + let roc = if prev == 0.0 { + // Undefined ratio against a zero previous close. + 0.0 + } else { + (candle.close - prev) / prev + }; + self.total += candle.volume * roc; + self.prev_close = Some(candle.close); + Some(self.total) + } + + fn reset(&mut self) { + self.prev_close = None; + self.total = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "VPT" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // closes [10, 11, 9], volumes [100, 200, 300]: + // bar 1: baseline 0. + // bar 2: VPT += 200 · (11-10)/10 = 20 -> 20. + // bar 3: VPT += 300 · (9-11)/11 = -600/11 -> 20 - 600/11. + let mut vpt = VolumePriceTrend::new(); + let out = vpt.batch(&[ + candle(10.0, 100.0, 0), + candle(11.0, 200.0, 1), + candle(9.0, 300.0, 2), + ]); + assert_relative_eq!(out[0].unwrap(), 0.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 20.0, epsilon = 1e-12); + assert_relative_eq!(out[2].unwrap(), 20.0 - 600.0 / 11.0, epsilon = 1e-12); + } + + /// Cover the `value()` Some branch (line 57) and the Indicator-impl + /// `name` body (100-102). `reset_clears_state` hits only the None + /// branch; the name was never queried. + #[test] + fn accessors_and_metadata() { + let mut vpt = VolumePriceTrend::new(); + assert_eq!(vpt.name(), "VPT"); + assert_eq!(vpt.value(), None); + vpt.update(candle(100.0, 50.0, 0)); + assert_eq!(vpt.value(), Some(0.0)); + } + + /// Cover the `prev == 0.0` defensive branch (line 77) — the previous + /// close is exactly 0, making the percentage ROC undefined. The + /// indicator must contribute 0 to the running total rather than NaN. + #[test] + fn zero_previous_close_contributes_zero() { + let mut vpt = VolumePriceTrend::new(); + vpt.update(candle(0.0, 100.0, 0)); // baseline; prev_close = 0 + let v = vpt.update(candle(50.0, 200.0, 1)).expect("emits"); + // ROC fallback is 0, so total stays at 0. + assert_eq!(v, 0.0); + } + + #[test] + fn emits_from_first_candle_at_zero() { + let mut vpt = VolumePriceTrend::new(); + assert_eq!(vpt.warmup_period(), 1); + assert_eq!(vpt.update(candle(100.0, 50.0, 0)), Some(0.0)); + } + + #[test] + fn constant_close_keeps_line_flat() { + // No price change -> no contribution regardless of volume. + let mut vpt = VolumePriceTrend::new(); + let candles: Vec = (0..20).map(|i| candle(100.0, 500.0, i)).collect(); + for v in vpt.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut vpt = VolumePriceTrend::new(); + vpt.batch(&[ + candle(10.0, 100.0, 0), + candle(11.0, 100.0, 1), + candle(12.0, 100.0, 2), + ]); + assert!(vpt.is_ready()); + vpt.reset(); + assert!(!vpt.is_ready()); + assert_eq!(vpt.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + candle( + 100.0 + (i as f64 * 0.3).sin() * 8.0, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let batch = VolumePriceTrend::new().batch(&candles); + let mut b = VolumePriceTrend::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/vwap.rs b/crates/wickra-core/src/indicators/vwap.rs new file mode 100644 index 0000000..7662b54 --- /dev/null +++ b/crates/wickra-core/src/indicators/vwap.rs @@ -0,0 +1,315 @@ +//! Volume-Weighted Average Price (VWAP). +//! +//! Two variants are offered: a cumulative `Vwap` that runs forever (the +//! intraday convention), and a rolling-window `RollingVwap` for streaming bots +//! that need a finite-memory price benchmark. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Cumulative session VWAP. Call [`Indicator::reset`] at the start of each +/// session (e.g. trading-day boundary) to restart the accumulation. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vwap}; +/// +/// let mut indicator = Vwap::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Vwap { + sum_pv: f64, + sum_v: f64, + has_emitted: bool, +} + +impl Vwap { + /// Construct a fresh cumulative VWAP. + pub const fn new() -> Self { + Self { + sum_pv: 0.0, + sum_v: 0.0, + has_emitted: false, + } + } + + /// Current VWAP if at least one candle with non-zero volume has been observed. + pub fn value(&self) -> Option { + if self.sum_v == 0.0 { + None + } else { + Some(self.sum_pv / self.sum_v) + } + } +} + +impl Indicator for Vwap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tp = candle.typical_price(); + self.sum_pv += tp * candle.volume; + self.sum_v += candle.volume; + if self.sum_v == 0.0 { + return None; + } + self.has_emitted = true; + Some(self.sum_pv / self.sum_v) + } + + fn reset(&mut self) { + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "VWAP" + } +} + +/// Rolling-window VWAP: a finite-memory variant for bots that don't want +/// unbounded accumulation. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, RollingVwap}; +/// +/// let mut indicator = RollingVwap::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RollingVwap { + period: usize, + window: VecDeque<(f64, f64)>, // (typical_price * volume, volume) + sum_pv: f64, + sum_v: f64, +} + +impl RollingVwap { + /// # 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_pv: 0.0, + sum_v: 0.0, + }) + } + + /// Configured rolling window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for RollingVwap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let pv = candle.typical_price() * candle.volume; + if self.window.len() == self.period { + let (old_pv, old_v) = self.window.pop_front().expect("non-empty"); + self.sum_pv -= old_pv; + self.sum_v -= old_v; + } + self.window.push_back((pv, candle.volume)); + self.sum_pv += pv; + self.sum_v += candle.volume; + if self.window.len() < self.period || self.sum_v == 0.0 { + return None; + } + Some(self.sum_pv / self.sum_v) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_pv = 0.0; + self.sum_v = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period && self.sum_v > 0.0 + } + + fn name(&self) -> &'static str { + "RollingVWAP" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(price: f64, volume: f64) -> Candle { + Candle::new(price, price, price, price, volume, 0).unwrap() + } + + #[test] + fn cumulative_vwap_equal_volumes_equals_mean() { + let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0)]; + let mut v = Vwap::new(); + let out = v.batch(&candles); + assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12); + } + + /// Cover the `Some` branch of `Vwap::value()` (line 53). The only other + /// test that calls `value()` is `cumulative_reset_clears_state`, which + /// calls it after `reset()` so `sum_v == 0` and the `None` branch fires. + #[test] + fn cumulative_value_some_branch_after_update() { + let mut v = Vwap::new(); + // typical_price of a flat OHLC bar equals the price itself. + v.update(c(42.0, 5.0)); + assert_relative_eq!(v.value().expect("non-zero volume"), 42.0, epsilon = 1e-12); + } + + /// Cover the `return None` early-out inside `Vwap::update` (line 67), + /// reached when the running `sum_v` is still 0 after adding the latest + /// candle's volume — i.e. the first candle has volume 0. Existing tests + /// only use strictly positive volumes, so the early-return never fired. + #[test] + fn cumulative_zero_volume_first_candle_returns_none() { + let mut v = Vwap::new(); + let out = v.update(c(42.0, 0.0)); + assert_eq!(out, None); + assert!(!v.is_ready()); + // Adding a non-zero candle afterwards still works as expected. + let out2 = v.update(c(10.0, 4.0)); + assert_relative_eq!(out2.expect("now warmed"), 10.0, epsilon = 1e-12); + } + + /// Cover the cumulative `Vwap` Indicator-impl metadata: `warmup_period` + /// (lines 79-81) and `name` (lines 87-89). Existing tests inspected + /// only the numeric output, never the metadata surface. + #[test] + fn cumulative_metadata() { + let v = Vwap::new(); + assert_eq!(v.warmup_period(), 1); + assert_eq!(v.name(), "VWAP"); + } + + #[test] + fn cumulative_vwap_weighted() { + // Two candles: 10@1 and 20@3 -> (10*1 + 20*3) / (1+3) = 70/4 = 17.5 + let candles = vec![c(10.0, 1.0), c(20.0, 3.0)]; + let mut v = Vwap::new(); + let out = v.batch(&candles); + assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12); + } + + /// Cover the `RollingVwap` accessors and metadata: `period` + /// (lines 134-136), `warmup_period` (165-167), `name` (173-175). + /// Existing rolling tests called `update`/`batch`/`reset`/`is_ready` + /// only, never queried the configuration or metadata. + #[test] + fn rolling_accessors_and_metadata() { + let v = RollingVwap::new(7).unwrap(); + assert_eq!(v.period(), 7); + assert_eq!(v.warmup_period(), 7); + assert_eq!(v.name(), "RollingVWAP"); + } + + #[test] + fn rolling_vwap_window_slides() { + let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0), c(40.0, 1.0)]; + let mut v = RollingVwap::new(3).unwrap(); + let out = v.batch(&candles); + assert!(out[1].is_none()); + // index 2 -> (10+20+30)/3 = 20 + assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12); + // index 3 -> (20+30+40)/3 = 30 + assert_relative_eq!(out[3].unwrap(), 30.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming_cumulative() { + let candles: Vec = (1..20).map(|i| c(f64::from(i), 1.0)).collect(); + let mut a = Vwap::new(); + let mut b = Vwap::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn batch_equals_streaming_rolling() { + let candles: Vec = (1..30) + .map(|i| c(f64::from(i), f64::from(i % 5 + 1))) + .collect(); + let mut a = RollingVwap::new(10).unwrap(); + let mut b = RollingVwap::new(10).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rolling_rejects_zero_period() { + assert!(RollingVwap::new(0).is_err()); + } + + #[test] + fn cumulative_reset_clears_state() { + let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0)]; + let mut v = Vwap::new(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + } + + #[test] + fn rolling_reset_clears_state() { + let candles: Vec = (1..=10).map(|i| c(f64::from(i), 1.0)).collect(); + let mut v = RollingVwap::new(5).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/vwap_stddev_bands.rs b/crates/wickra-core/src/indicators/vwap_stddev_bands.rs new file mode 100644 index 0000000..bc45086 --- /dev/null +++ b/crates/wickra-core/src/indicators/vwap_stddev_bands.rs @@ -0,0 +1,251 @@ +//! VWAP Standard-Deviation Bands. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `VWAP` `StdDev` Bands output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VwapStdDevBandsOutput { + /// Upper band: `vwap + multiplier · sigma`. + pub upper: f64, + /// Middle band: cumulative VWAP of typical price. + pub middle: f64, + /// Lower band: `vwap − multiplier · sigma`. + pub lower: f64, + /// Volume-weighted standard deviation of typical price about VWAP. + pub stddev: f64, +} + +/// VWAP with volume-weighted standard-deviation envelopes. +/// +/// ```text +/// tp_i = typical_price(candle_i) // (high + low + close) / 3 +/// sum_v = Σ volume_i +/// sum_pv = Σ tp_i · volume_i +/// sum_p2v = Σ tp_i² · volume_i +/// vwap = sum_pv / sum_v +/// variance = sum_p2v / sum_v − vwap² // volume-weighted population variance +/// sigma = sqrt(max(variance, 0)) +/// upper/lower = vwap ± multiplier · sigma +/// ``` +/// +/// The cumulative running sums make every update O(1) with no per-bar replay, +/// matching the streaming contract of [`Vwap`](crate::Vwap). VWAP and its +/// stddev bands are an intraday-session tool: call [`Indicator::reset`] at +/// the start of each session boundary so the accumulators do not span the gap. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VwapStdDevBands}; +/// +/// let mut indicator = VwapStdDevBands::new(2.0).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VwapStdDevBands { + multiplier: f64, + sum_pv: f64, + sum_p2v: f64, + sum_v: f64, + has_emitted: bool, +} + +impl VwapStdDevBands { + /// # Errors + /// Returns [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + multiplier, + sum_pv: 0.0, + sum_p2v: 0.0, + sum_v: 0.0, + has_emitted: false, + }) + } + + /// Configured multiplier. + pub const fn multiplier(&self) -> f64 { + self.multiplier + } +} + +impl Indicator for VwapStdDevBands { + type Input = Candle; + type Output = VwapStdDevBandsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let tp = candle.typical_price(); + self.sum_pv += tp * candle.volume; + self.sum_p2v += tp * tp * candle.volume; + self.sum_v += candle.volume; + if self.sum_v == 0.0 { + return None; + } + self.has_emitted = true; + let vwap = self.sum_pv / self.sum_v; + // Volume-weighted population variance; clamp tiny negative cancellation + // noise back to zero on near-constant inputs. + let var = (self.sum_p2v / self.sum_v - vwap * vwap).max(0.0); + let sigma = var.sqrt(); + Some(VwapStdDevBandsOutput { + upper: vwap + self.multiplier * sigma, + middle: vwap, + lower: vwap - self.multiplier * sigma, + stddev: sigma, + }) + } + + fn reset(&mut self) { + self.sum_pv = 0.0; + self.sum_p2v = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "VwapStdDevBands" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64, v: f64) -> Candle { + Candle::new(cl, h, l, cl, v, 0).unwrap() + } + + #[test] + fn rejects_non_positive_multiplier() { + assert!(matches!( + VwapStdDevBands::new(0.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + VwapStdDevBands::new(-1.0), + Err(Error::NonPositiveMultiplier) + )); + assert!(matches!( + VwapStdDevBands::new(f64::NAN), + Err(Error::NonPositiveMultiplier) + )); + } + + #[test] + fn accessors_and_metadata() { + let v = VwapStdDevBands::new(2.0).unwrap(); + assert_relative_eq!(v.multiplier(), 2.0, epsilon = 1e-12); + assert_eq!(v.warmup_period(), 1); + assert_eq!(v.name(), "VwapStdDevBands"); + } + + #[test] + fn zero_volume_returns_none() { + let mut v = VwapStdDevBands::new(2.0).unwrap(); + assert!(v.update(c(10.0, 10.0, 10.0, 0.0)).is_none()); + } + + #[test] + fn constant_price_collapses_bands() { + let candles: Vec = (0..10).map(|_| c(10.0, 10.0, 10.0, 5.0)).collect(); + let mut v = VwapStdDevBands::new(2.0).unwrap(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-9); + assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9); + assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (0..50) + .map(|i| { + let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; + c(m + 1.0, m - 1.0, m, 1.0 + f64::from(i % 5)) + }) + .collect(); + let mut v = VwapStdDevBands::new(2.0).unwrap(); + for o in v.batch(&candles).into_iter().flatten() { + assert!(o.upper >= o.middle); + assert!(o.middle >= o.lower); + assert!(o.stddev >= 0.0); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + c( + f64::from(i) + 2.0, + f64::from(i), + f64::from(i) + 1.0, + 1.0 + f64::from(i % 4), + ) + }) + .collect(); + let mut a = VwapStdDevBands::new(2.0).unwrap(); + let mut b = VwapStdDevBands::new(2.0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..10) + .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i), 1.0)) + .collect(); + let mut v = VwapStdDevBands::new(2.0).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + // After reset a zero-volume bar still returns `None` (volume is + // required to define the volume-weighted average). + assert_eq!(v.update(c(10.0, 10.0, 10.0, 0.0)), None); + } + + /// Reference: two equal-volume bars at typical prices `tp = 8` and `tp = 12`. + /// VWAP = (8 + 12) / 2 = 10. Volume-weighted population variance = + /// (64 + 144) / 2 − 100 = 4. Sigma = 2. With multiplier 1.5: upper = 13, + /// lower = 7. + #[test] + fn reference_values() { + // typical_price = (high + low + close) / 3. Choose bars where this is + // exactly 8 and 12. Bar A: high=8, low=8, close=8 → tp=8. + // Bar B: high=12, low=12, close=12 → tp=12. + let candles = [c(8.0, 8.0, 8.0, 1.0), c(12.0, 12.0, 12.0, 1.0)]; + let mut v = VwapStdDevBands::new(1.5).unwrap(); + let _ = v.update(candles[0]); + let out = v.update(candles[1]).unwrap(); + assert_relative_eq!(out.middle, 10.0, epsilon = 1e-9); + assert_relative_eq!(out.stddev, 2.0, epsilon = 1e-9); + assert_relative_eq!(out.upper, 13.0, epsilon = 1e-9); + assert_relative_eq!(out.lower, 7.0, epsilon = 1e-9); + } +} diff --git a/crates/wickra-core/src/indicators/vwma.rs b/crates/wickra-core/src/indicators/vwma.rs new file mode 100644 index 0000000..3688f2f --- /dev/null +++ b/crates/wickra-core/src/indicators/vwma.rs @@ -0,0 +1,254 @@ +//! Volume-Weighted Moving Average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-Weighted Moving Average over a rolling window of `period` candles. +/// +/// Each close is weighted by its own bar volume: +/// +/// ```text +/// VWMA_t = Σ(close_i · volume_i) / Σ(volume_i) over the last `period` bars +/// ``` +/// +/// High-volume bars pull the average toward their close, so VWMA reacts to +/// price moves that the market actually participated in and largely ignores +/// thin, low-conviction bars. +/// +/// If every candle in the window has zero volume the weighted mean is +/// undefined; the indicator then falls back to the **unweighted** mean of the +/// `period` closes, so the output is always finite. The first output lands +/// after exactly `period` candles. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vwma}; +/// +/// let mut indicator = Vwma::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Vwma { + period: usize, + /// Rolling window of `(close, volume)` pairs, oldest at the front. + window: VecDeque<(f64, f64)>, + sum_pv: f64, + sum_v: f64, + sum_close: f64, + current: Option, +} + +impl Vwma { + /// Construct a new VWMA with the given period. + /// + /// # 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_pv: 0.0, + sum_v: 0.0, + sum_close: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Vwma { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + let volume = candle.volume; + if self.window.len() == self.period { + let (old_close, old_volume) = self.window.pop_front().expect("window is non-empty"); + self.sum_pv -= old_close * old_volume; + self.sum_v -= old_volume; + self.sum_close -= old_close; + } + self.window.push_back((close, volume)); + self.sum_pv += close * volume; + self.sum_v += volume; + self.sum_close += close; + if self.window.len() < self.period { + return None; + } + let value = if self.sum_v > 0.0 { + self.sum_pv / self.sum_v + } else { + // Degenerate window: every bar had zero volume. Fall back to the + // plain mean of the closes so the output stays finite. + self.sum_close / self.period as f64 + }; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.sum_close = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "VWMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Build a flat candle with a given close and volume. + fn candle(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Vwma::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (72-79) and the + /// Indicator-impl `name` body (129-131). Existing tests inspect + /// VWMA output but never query the metadata. + #[test] + fn accessors_and_metadata() { + let mut v = Vwma::new(5).unwrap(); + assert_eq!(v.period(), 5); + assert_eq!(v.name(), "VWMA"); + assert_eq!(v.value(), None); + for i in 1..=5i64 { + let p = 100.0 + i as f64; + v.update(Candle::new(p, p, p, p, 1.0, i).unwrap()); + } + assert!(v.value().is_some()); + } + + #[test] + fn reference_value() { + // VWMA(2): (10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5. + let mut vwma = Vwma::new(2).unwrap(); + assert_eq!(vwma.update(candle(10.0, 1.0, 0)), None); + assert_relative_eq!( + vwma.update(candle(20.0, 3.0, 1)).unwrap(), + 17.5, + epsilon = 1e-12 + ); + // Window slides: (20·3 + 30·1) / (3 + 1) = 90 / 4 = 22.5. + assert_relative_eq!( + vwma.update(candle(30.0, 1.0, 2)).unwrap(), + 22.5, + epsilon = 1e-12 + ); + } + + #[test] + fn zero_volume_window_falls_back_to_unweighted_mean() { + let mut vwma = Vwma::new(2).unwrap(); + assert_eq!(vwma.update(candle(10.0, 0.0, 0)), None); + // Both bars have zero volume: fall back to mean(10, 20) = 15. + assert_relative_eq!( + vwma.update(candle(20.0, 0.0, 1)).unwrap(), + 15.0, + epsilon = 1e-12 + ); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut vwma = Vwma::new(5).unwrap(); + let candles: Vec = (0..30).map(|i| candle(42.0, 3.0, i)).collect(); + let out = vwma.batch(&candles); + for x in out.iter().skip(4).flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn high_volume_bar_pulls_the_average() { + // A heavy bar at a higher close drags VWMA above the simple mean. + let mut vwma = Vwma::new(3).unwrap(); + vwma.update(candle(10.0, 1.0, 0)); + vwma.update(candle(10.0, 1.0, 1)); + let v = vwma.update(candle(20.0, 100.0, 2)).unwrap(); + let simple_mean = (10.0 + 10.0 + 20.0) / 3.0; + assert!( + v > simple_mean, + "{v} should exceed simple mean {simple_mean}" + ); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut vwma = Vwma::new(4).unwrap(); + assert_eq!(vwma.warmup_period(), 4); + for i in 0..3 { + assert_eq!(vwma.update(candle(10.0, 1.0, i)), None); + } + assert!(vwma.update(candle(10.0, 1.0, 3)).is_some()); + } + + #[test] + fn reset_clears_state() { + let mut vwma = Vwma::new(3).unwrap(); + let candles: Vec = (0..10).map(|i| candle(10.0 + i as f64, 2.0, i)).collect(); + vwma.batch(&candles); + assert!(vwma.is_ready()); + vwma.reset(); + assert!(!vwma.is_ready()); + assert_eq!(vwma.update(candle(10.0, 1.0, 0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c, 1.0 + (i % 7) as f64, i) + }) + .collect(); + let batch = Vwma::new(8).unwrap().batch(&candles); + let mut b = Vwma::new(8).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/vzo.rs b/crates/wickra-core/src/indicators/vzo.rs new file mode 100644 index 0000000..62f7c68 --- /dev/null +++ b/crates/wickra-core/src/indicators/vzo.rs @@ -0,0 +1,218 @@ +//! Volume Zone Oscillator (Walid Khalil). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Walid Khalil's Volume Zone Oscillator — a normalised version of OBV-style +/// volume flow that swings within `[−100, 100]`. +/// +/// Each bar contributes a *signed volume*: `+volume` on an up day, `−volume` on +/// a down day, `0` on an unchanged close. The VZO is the ratio of an EMA of +/// that signed volume to an EMA of the absolute volume, scaled by `100`: +/// +/// ```text +/// R_t = sign(close_t − close_{t−1}) · volume_t +/// VP_t = EMA(R, period)_t (smoothed signed volume) +/// TV_t = EMA(volume, period)_t (smoothed absolute volume) +/// VZO_t = 100 · VP_t / TV_t +/// ``` +/// +/// Khalil's interpretation: `VZO > +60` overbought, `< −60` oversold, with the +/// zero line acting as a trend filter. The first bar only seeds the previous +/// close; both EMAs then need `period` samples to seed, so the first emission +/// lands at bar `period + 1`. A `TV_t == 0` (every bar had zero volume) +/// collapses the output to `0` instead of NaN. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vzo}; +/// +/// let mut indicator = Vzo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Vzo { + period: usize, + vp: Ema, + tv: Ema, + prev_close: Option, +} + +impl Vzo { + /// Construct a new VZO with the given EMA smoothing period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + vp: Ema::new(period)?, + tv: Ema::new(period)?, + prev_close: None, + }) + } + + /// Configured EMA smoothing period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Vzo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let signed_volume = match self.prev_close { + None => { + self.prev_close = Some(candle.close); + return None; + } + Some(prev) => { + if candle.close > prev { + candle.volume + } else if candle.close < prev { + -candle.volume + } else { + 0.0 + } + } + }; + self.prev_close = Some(candle.close); + let vp = self.vp.update(signed_volume); + let tv = self.tv.update(candle.volume); + let (vp_v, tv_v) = (vp?, tv?); + if tv_v == 0.0 { + // No volume in the smoothing window -> ratio undefined; report 0. + return Some(0.0); + } + Some(100.0 * vp_v / tv_v) + } + + fn reset(&mut self) { + self.vp.reset(); + self.tv.reset(); + self.prev_close = None; + } + + fn warmup_period(&self) -> usize { + // One seed bar plus the EMA seed. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.vp.is_ready() && self.tv.is_ready() + } + + fn name(&self) -> &'static str { + "VZO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Vzo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = Vzo::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.name(), "VZO"); + assert_eq!(v.warmup_period(), 15); + } + + #[test] + fn strictly_rising_series_saturates_to_plus_100() { + // Every bar is an up-day with identical volume -> signed_volume == volume + // on every bar -> VP and TV EMAs are equal -> ratio = 1 -> VZO = +100. + let candles: Vec = (0..60i64).map(|i| c(10.0 + i as f64, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + let out = v.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn strictly_falling_series_saturates_to_minus_100() { + let candles: Vec = (0..60i64).map(|i| c(200.0 - i as f64, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + let out = v.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert_relative_eq!(last, -100.0, epsilon = 1e-9); + } + + #[test] + fn flat_close_yields_zero() { + // signed_volume = 0 forever -> VP_EMA stays at 0 -> ratio = 0. + let candles: Vec = (0..40).map(|i| c(10.0, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + for x in v.batch(&candles).into_iter().flatten() { + assert_relative_eq!(x, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn zero_volume_window_yields_zero() { + // All bars carry zero volume -> tv_v == 0 -> defensive branch fires. + let candles: Vec = (0..20i64).map(|i| c(10.0 + i as f64, 0.0, i)).collect(); + let mut v = Vzo::new(3).unwrap(); + let out = v.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + (i % 7) as f64 * 10.0, + i, + ) + }) + .collect(); + let mut a = Vzo::new(14).unwrap(); + let mut b = Vzo::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40i64).map(|i| c(10.0 + i as f64, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/wad.rs b/crates/wickra-core/src/indicators/wad.rs new file mode 100644 index 0000000..0a1d133 --- /dev/null +++ b/crates/wickra-core/src/indicators/wad.rs @@ -0,0 +1,206 @@ +//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Williams Accumulation/Distribution — a cumulative price-only line that adds +/// the day's accumulation on up-closes and subtracts the day's distribution on +/// down-closes. +/// +/// ```text +/// if close > prev_close: AD = close − min(low, prev_close) (true low) +/// if close < prev_close: AD = close − max(high, prev_close) (true high) +/// if close = prev_close: AD = 0 +/// WAD_t = WAD_{t−1} + AD +/// ``` +/// +/// Larry Williams' A/D line (distinct from Chaikin's volume-based +/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as +/// how far price closed above the *true low* on up-days and distribution as how +/// far it closed below the *true high* on down-days, then accumulates the result. +/// A rising WAD that diverges from a flat or falling price is the classic +/// accumulation signal; a falling WAD under a rising price warns of distribution. +/// +/// The line is unbounded and its absolute level is meaningless — only its slope +/// and divergences against price matter. The first candle has no previous close, +/// so it seeds the reference and emits nothing; thereafter every bar emits the +/// running total. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Wad}; +/// +/// let mut indicator = Wad::new(); +/// let mut last = None; +/// for i in 0..20 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Wad { + prev_close: Option, + line: f64, + last: Option, +} + +impl Wad { + /// Construct a new Williams A/D line. The line is parameter-free. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Wad { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let ad = if candle.close > prev_close { + candle.close - candle.low.min(prev_close) + } else if candle.close < prev_close { + candle.close - candle.high.max(prev_close) + } else { + 0.0 + }; + self.line += ad; + self.prev_close = Some(candle.close); + self.last = Some(self.line); + Some(self.line) + } + + fn reset(&mut self) { + self.prev_close = None; + self.line = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first bar only seeds the reference close; the first value lands on + // the second bar. + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Wad" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, 1_000.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let wad = Wad::new(); + assert_eq!(wad.warmup_period(), 2); + assert_eq!(wad.name(), "Wad"); + assert!(!wad.is_ready()); + assert_eq!(wad.value(), None); + } + + #[test] + fn first_bar_seeds_without_output() { + let mut wad = Wad::new(); + assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None); + assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some()); + } + + #[test] + fn up_close_accumulates() { + // close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100; + // AD = 101 - 100 = 1. + let mut wad = Wad::new(); + wad.update(candle(101.0, 99.0, 100.0)); + let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap(); + assert_relative_eq!(v, 1.0, epsilon = 1e-9); + } + + #[test] + fn down_close_distributes() { + // close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101; + // AD = 99 - 101 = -2. + let mut wad = Wad::new(); + wad.update(candle(102.0, 100.0, 100.0)); + let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap(); + assert_relative_eq!(v, -2.0, epsilon = 1e-9); + } + + #[test] + fn unchanged_close_adds_nothing() { + let mut wad = Wad::new(); + wad.update(candle(101.0, 99.0, 100.0)); + let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn pure_uptrend_is_monotone() { + let mut wad = Wad::new(); + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut prev = f64::NEG_INFINITY; + for v in wad.batch(&candles).into_iter().flatten() { + assert!(v >= prev, "WAD must rise in an uptrend"); + prev = v; + } + } + + #[test] + fn reset_clears_state() { + let mut wad = Wad::new(); + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + wad.batch(&candles); + assert!(wad.is_ready()); + wad.reset(); + assert!(!wad.is_ready()); + assert_eq!(wad.value(), None); + assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0; + candle(base + 2.0, base - 2.0, base + 0.5) + }) + .collect(); + let batch = Wad::new().batch(&candles); + let mut b = Wad::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/wave_pm.rs b/crates/wickra-core/src/indicators/wave_pm.rs new file mode 100644 index 0000000..6a45837 --- /dev/null +++ b/crates/wickra-core/src/indicators/wave_pm.rs @@ -0,0 +1,212 @@ +//! Wave PM — Cynthia Kase's peak-momentum statistic (Wickra reconstruction). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Wave PM (Peak Momentum): a `0..100` statistic that rises when the current +/// `length`-bar momentum is large relative to its own recent energy — Cynthia +/// Kase's gauge of how "peaked" the move is. +/// +/// ```text +/// m = close_t - close_{t-length} (length-bar momentum) +/// energy = EMA(m^2, length) (mean squared momentum) +/// raw = 1 - exp( -m^2 / (2 * energy) ) (0 if energy == 0) +/// WavePM = 100 * EMA(raw, smoothing) +/// ``` +/// +/// The momentum `m` is normalised by its recent variance (`energy`): a move that +/// merely matches its typical energy sits at the baseline +/// `100·(1 − e^{−1/2}) ≈ 39.35`, while a momentum *spike* that exceeds recent +/// energy drives the reading toward `100`. A flat market (`m = 0`) reads `0`. +/// High readings mark a peaking, possibly exhausted move rather than a fresh one. +/// +/// Kase's published `WavePM` is platform-specific; this is Wickra's faithful +/// reconstruction of its variance-normalised peak-momentum form. The exact +/// constants differ from any single vendor implementation, but the shape — flat +/// at zero, a fixed baseline on a steady trend, and saturation on an +/// acceleration — matches the indicator's intent. +/// +/// Reference: Cynthia Kase, *Trading with the Odds*, 1996 (Wickra reconstruction). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, WavePm}; +/// +/// let mut indicator = WavePm::new(10, 3).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct WavePm { + length: usize, + smoothing: usize, + closes: VecDeque, + energy_ema: Ema, + smooth_ema: Ema, +} + +impl WavePm { + /// Construct a Wave PM with the momentum `length` and the output `smoothing` + /// period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `length == 0` or `smoothing == 0`. + pub fn new(length: usize, smoothing: usize) -> Result { + if length == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + length, + smoothing, + closes: VecDeque::with_capacity(length + 1), + energy_ema: Ema::new(length)?, + smooth_ema: Ema::new(smoothing)?, + }) + } + + /// Configured `(length, smoothing)`. + pub const fn periods(&self) -> (usize, usize) { + (self.length, self.smoothing) + } +} + +impl Indicator for WavePm { + type Input = f64; + type Output = f64; + + fn update(&mut self, close: f64) -> Option { + self.closes.push_back(close); + if self.closes.len() > self.length + 1 { + self.closes.pop_front(); + } + if self.closes.len() <= self.length { + return None; + } + + let oldest = *self.closes.front().unwrap_or(&close); + let momentum = close - oldest; + let energy = self.energy_ema.update(momentum * momentum)?; + let raw = if energy <= 0.0 { + 0.0 + } else { + 1.0 - (-(momentum * momentum) / (2.0 * energy)).exp() + }; + self.smooth_ema.update(raw).map(|v| v * 100.0) + } + + fn reset(&mut self) { + self.closes.clear(); + self.energy_ema.reset(); + self.smooth_ema.reset(); + } + + fn warmup_period(&self) -> usize { + 2 * self.length + self.smoothing - 1 + } + + fn is_ready(&self) -> bool { + self.smooth_ema.is_ready() + } + + fn name(&self) -> &'static str { + "WavePm" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(WavePm::new(0, 3), Err(Error::PeriodZero))); + assert!(matches!(WavePm::new(10, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let w = WavePm::new(10, 3).unwrap(); + assert_eq!(w.periods(), (10, 3)); + // 2*10 + 3 - 1 = 22. + assert_eq!(w.warmup_period(), 22); + assert_eq!(w.name(), "WavePm"); + assert!(!w.is_ready()); + } + + #[test] + fn warmup_emits_at_expected_bar() { + let mut w = WavePm::new(3, 2).unwrap(); + // warmup = 2*3 + 2 - 1 = 7 -> first value at input 7 (index 6). + let inputs: Vec = (0..12).map(f64::from).collect(); + let out = w.batch(&inputs); + assert!(out[5].is_none()); + assert!(out[6].is_some()); + } + + #[test] + fn flat_market_reads_zero() { + let mut w = WavePm::new(4, 2).unwrap(); + let inputs = [50.0; 20]; + let last = w.batch(&inputs).last().unwrap().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn steady_trend_reads_baseline() { + // Constant-slope ramp: momentum equals its own energy every bar, so the + // reading pins to the baseline 100*(1 - e^-0.5). + let mut w = WavePm::new(10, 3).unwrap(); + let inputs: Vec = (0..60).map(|i| f64::from(i) * 5.0).collect(); + let last = w.batch(&inputs).last().unwrap().unwrap(); + let baseline = 100.0 * (1.0 - (-0.5_f64).exp()); + assert_relative_eq!(last, baseline, epsilon = 1e-9); + } + + #[test] + fn acceleration_reads_above_baseline() { + // A quadratic path: momentum keeps outrunning its lagged energy, so the + // reading sits above the steady-trend baseline. + let mut w = WavePm::new(10, 3).unwrap(); + let inputs: Vec = (0..60).map(|i| f64::from(i * i) * 0.1).collect(); + let last = w.batch(&inputs).last().unwrap().unwrap(); + let baseline = 100.0 * (1.0 - (-0.5_f64).exp()); + assert!( + last > baseline, + "accelerating wpm {last} should exceed {baseline}" + ); + assert!(last <= 100.0, "wpm {last} must stay <= 100"); + } + + #[test] + fn reset_clears_state() { + let mut w = WavePm::new(10, 3).unwrap(); + let inputs: Vec = (0..60).map(|i| f64::from(i) * 5.0).collect(); + w.batch(&inputs); + assert!(w.is_ready()); + w.reset(); + assert!(!w.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let inputs: Vec = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = WavePm::new(10, 3).unwrap(); + let mut b = WavePm::new(10, 3).unwrap(); + assert_eq!( + a.batch(&inputs), + inputs.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/wave_trend.rs b/crates/wickra-core/src/indicators/wave_trend.rs new file mode 100644 index 0000000..cf84115 --- /dev/null +++ b/crates/wickra-core/src/indicators/wave_trend.rs @@ -0,0 +1,334 @@ +//! Wave Trend Oscillator (`LazyBear`). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wave Trend Oscillator output: the two lines `wt1` (the oscillator) and +/// `wt2` (the signal SMA). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WaveTrendOutput { + /// `wt1` — the smoothed channel index. + pub wt1: f64, + /// `wt2` — the SMA-smoothed signal line. + pub wt2: f64, +} + +/// `LazyBear`'s Wave Trend Oscillator — a two-line momentum gauge built from +/// the typical price and three cascaded EMAs. +/// +/// For each candle let `ap_t = (high + low + close) / 3`: +/// +/// ```text +/// esa_t = EMA(ap, channel_period) +/// d_t = EMA(|ap − esa|, channel_period) +/// ci_t = (ap_t − esa_t) / (0.015 * d_t) +/// wt1_t = EMA(ci, average_period) +/// wt2_t = SMA(wt1, signal_period) +/// ``` +/// +/// Bullish trigger: `wt1` crossing above `wt2` from an oversold region +/// (typically `wt1 < -60`); bearish trigger: the mirror crossover above +/// `+60`. The indicator is mean-reverting around zero, so it is most useful +/// at extremes. +/// +/// The canonical `LazyBear` defaults are +/// `(channel_period = 10, average_period = 21, signal_period = 4)`; warmup is +/// `channel_period + average_period + signal_period − 2`. +/// +/// Non-finite `d` (a zero-volatility seed where the absolute-deviation EMA +/// has not yet recorded any movement) collapses the channel index to zero. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WaveTrend}; +/// +/// let mut indicator = WaveTrend::classic().unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct WaveTrend { + channel_period: usize, + average_period: usize, + signal_period: usize, + esa: Ema, + dev_ema: Ema, + tci: Ema, + signal: Sma, + last: Option, +} + +impl WaveTrend { + /// Construct a new Wave Trend Oscillator with explicit periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is `0`. + pub fn new(channel_period: usize, average_period: usize, signal_period: usize) -> Result { + if channel_period == 0 || average_period == 0 || signal_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + channel_period, + average_period, + signal_period, + esa: Ema::new(channel_period)?, + dev_ema: Ema::new(channel_period)?, + tci: Ema::new(average_period)?, + signal: Sma::new(signal_period)?, + last: None, + }) + } + + /// `LazyBear`'s classic Wave Trend: `(channel = 10, average = 21, signal = 4)`. + /// + /// # Errors + /// + /// None in practice — all periods are non-zero. + pub fn classic() -> Result { + Self::new(10, 21, 4) + } + + /// Configured `(channel_period, average_period, signal_period)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.channel_period, self.average_period, self.signal_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for WaveTrend { + type Input = Candle; + type Output = WaveTrendOutput; + + fn update(&mut self, candle: Candle) -> Option { + let ap = (candle.high + candle.low + candle.close) / 3.0; + + // Stage 1: ESA = EMA(ap, channel_period). Must be ready before we + // can compute the absolute deviation EMA against it. + let esa = self.esa.update(ap)?; + + // Stage 2: deviation EMA tracks |ap - esa|. + let d = self.dev_ema.update((ap - esa).abs())?; + + // Stage 3: channel index. On a perfectly flat market `(ap - esa)` + // and `d` are both within an ULP or two of zero; their ratio is + // mathematically indeterminate and would otherwise produce garbage + // like `-66.67 = -1 / 0.015`. Treat any sub-ULP deviation as zero, + // matching pandas-ta's flat-market behaviour. The threshold scales + // with `esa` so it adapts to any price magnitude. + let flat_tol = esa.abs().max(1.0) * 16.0 * f64::EPSILON; + let ci = if d <= flat_tol { + 0.0 + } else { + (ap - esa) / (0.015 * d) + }; + + // Stage 4: wt1 = EMA(ci, average_period). + let wt1 = self.tci.update(ci)?; + + // Stage 5: wt2 = SMA(wt1, signal_period). + let wt2 = self.signal.update(wt1)?; + + let out = WaveTrendOutput { wt1, wt2 }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.esa.reset(); + self.dev_ema.reset(); + self.tci.reset(); + self.signal.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // EMA(esa) first emits at input `channel_period`; the second EMA + // (deviation) takes its input from the same bar and emits at the + // same `channel_period`-th input (it can already start computing + // |ap - esa| as soon as esa is ready, and the EMA-of-EMA construction + // uses the inner EMA's first valid output as its first input — + // however because we gate via `?` on both stages, the second EMA's + // first valid input is at the channel_period-th input, then itself + // needs channel_period - 1 more inputs to warm... but our Ema + // implementation seeds via SMA on the first `period` inputs, so the + // dev_ema needs channel_period inputs of |ap - esa| values. + // + // Actually: esa emits at input `channel_period` (1-based). dev_ema + // gets fed starting at that input, and needs `channel_period` inputs + // of its own to first emit: at the `2 * channel_period - 1`-th input + // dev_ema is ready (it has consumed channel_period inputs starting + // from the channel_period-th). tci then needs `average_period` + // inputs of `ci`, so it's ready at `2 * channel_period - 1 + + // average_period - 1`. Signal needs `signal_period` inputs of wt1 + // → ready at `2 * channel_period - 1 + average_period - 1 + + // signal_period - 1` = `2 * channel_period + average_period + + // signal_period - 3`. + 2 * self.channel_period + self.average_period + self.signal_period - 3 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "WaveTrend" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(c, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(WaveTrend::new(0, 21, 4), Err(Error::PeriodZero))); + assert!(matches!(WaveTrend::new(10, 0, 4), Err(Error::PeriodZero))); + assert!(matches!(WaveTrend::new(10, 21, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut w = WaveTrend::classic().unwrap(); + assert_eq!(w.periods(), (10, 21, 4)); + assert_eq!(w.name(), "WaveTrend"); + // 2 * 10 + 21 + 4 - 3 = 42. + assert_eq!(w.warmup_period(), 42); + assert!(w.value().is_none()); + let candles: Vec = (0..80_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + for c in &candles { + w.update(*c); + } + assert!(w.value().is_some()); + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..60_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + let mut w = WaveTrend::new(5, 8, 3).unwrap(); + let warmup = 2 * 5 + 8 + 3 - 3; // 18 + assert_eq!(w.warmup_period(), warmup); + let out = w.batch(&candles); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn constant_series_yields_zero_lines() { + // Flat market: every ap equals esa within an ULP, so the + // flat-tolerance guard collapses ci to 0 and both lines remain at 0. + let candles: Vec = (0..80_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect(); + let mut w = WaveTrend::new(5, 8, 3).unwrap(); + let last = w.batch(&candles).into_iter().flatten().last().unwrap(); + assert_eq!(last.wt1, 0.0); + assert_eq!(last.wt2, 0.0); + } + + #[test] + fn pure_uptrend_is_positive() { + let candles: Vec = (0..120_i64) + .map(|i| { + let base = 100.0 + (i as f64) * 0.5; + candle(base + 1.0, base - 0.5, base + 0.5, i) + }) + .collect(); + let mut w = WaveTrend::classic().unwrap(); + let last = w.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last.wt1 > 0.0, + "uptrend wt1 should be positive, got {}", + last.wt1 + ); + assert!( + last.wt2 > 0.0, + "uptrend wt2 should be positive, got {}", + last.wt2 + ); + } + + #[test] + fn pure_downtrend_is_negative() { + let candles: Vec = (0..120_i64) + .map(|i| { + let base = 200.0 - (i as f64) * 0.5; + candle(base + 1.0, base - 0.5, base - 0.5, i) + }) + .collect(); + let mut w = WaveTrend::classic().unwrap(); + let last = w.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last.wt1 < 0.0); + assert!(last.wt2 < 0.0); + } + + #[test] + fn outputs_remain_finite() { + let candles: Vec = (0..200_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.3).sin() * 8.0; + candle(p + 2.0, p - 2.0, p, i) + }) + .collect(); + let mut w = WaveTrend::classic().unwrap(); + for v in w.batch(&candles).into_iter().flatten() { + assert!(v.wt1.is_finite() && v.wt2.is_finite()); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120_i64) + .map(|i| { + let p = 100.0 + ((i as f64) * 0.27).sin() * 6.0; + candle(p + 1.5, p - 1.5, p, i) + }) + .collect(); + let mut a = WaveTrend::classic().unwrap(); + let mut b = WaveTrend::classic().unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|c| b.update(*c)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..80_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut w = WaveTrend::classic().unwrap(); + w.batch(&candles); + assert!(w.is_ready()); + w.reset(); + assert!(!w.is_ready()); + assert_eq!(w.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/wedge.rs b/crates/wickra-core/src/indicators/wedge.rs new file mode 100644 index 0000000..cc08da5 --- /dev/null +++ b/crates/wickra-core/src/indicators/wedge.rs @@ -0,0 +1,157 @@ +//! Wedge (rising / falling) reversal chart pattern. + +use crate::indicators::pattern_swing::{recent_legs, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wedge — a pattern where both trendlines slope the same way but converge, +/// signalling exhaustion of the prevailing move. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the +/// last two swing highs and lows: +/// +/// ```text +/// rising wedge : highs rising AND lows rising, lows rising faster → -1 (bearish) +/// falling wedge : highs falling AND lows falling, highs falling faster → +1 (bullish) +/// ``` +/// +/// Convergence is the key: in a rising wedge the lower trendline climbs faster +/// than the upper (the range narrows from below); in a falling wedge the upper +/// trendline drops faster than the lower. Output is `+1.0` / `-1.0` / `0.0`; +/// never `None`. +#[derive(Debug, Clone)] +pub struct Wedge { + swing: SwingTracker, + has_emitted: bool, +} + +impl Wedge { + /// Construct a new Wedge detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for Wedge { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Wedge { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let (high_old, high_new, low_old, low_new) = recent_legs(pivots); + let high_slope = high_new - high_old; + let low_slope = low_new - low_old; + + // Rising wedge: both lines slope up, lower line steeper (converging) → bearish. + if high_slope > 0.0 && low_slope > 0.0 && low_slope > high_slope { + return Some(-1.0); + } + // Falling wedge: both lines slope down, upper line steeper → bullish. + if high_slope < 0.0 && low_slope < 0.0 && high_slope < low_slope { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Wedge" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Wedge::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Wedge::new(); + assert_eq!(indicator.name(), "Wedge"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!Wedge::default().is_ready()); + } + + #[test] + fn rising_wedge_is_minus_one() { + // Highs 100 → 103 (+3), lows 90 → 94 (+4, steeper) → rising wedge. + let out = run(&[110.0, 90.0, 100.0, 94.0, 103.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn falling_wedge_is_plus_one() { + // Highs 120 → 106 (-14, steeper), lows 100 → 99 (-1) → falling wedge. + let out = run(&[120.0, 100.0, 106.0, 99.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn diverging_swings_are_not_a_wedge() { + // Rising highs but falling lows (broadening) → no wedge. + let out = run(&[110.0, 100.0, 130.0, 80.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Wedge::new(); + for c in candles_for_pivots(&[110.0, 90.0, 100.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[110.0, 90.0, 100.0, 94.0, 103.0]); + let mut a = Wedge::new(); + let mut b = Wedge::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/weighted_close.rs b/crates/wickra-core/src/indicators/weighted_close.rs new file mode 100644 index 0000000..504cf96 --- /dev/null +++ b/crates/wickra-core/src/indicators/weighted_close.rs @@ -0,0 +1,127 @@ +//! Weighted Close. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Weighted Close — the bar's `(high + low + 2·close) / 4`. +/// +/// A representative per-bar price that, unlike the [`TypicalPrice`](crate::TypicalPrice), +/// gives the close double weight — useful when the closing print matters more +/// than the extremes for your strategy. As a stateless per-bar transform it +/// emits a value from the very first candle. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WeightedClose}; +/// +/// let mut indicator = WeightedClose::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct WeightedClose { + has_emitted: bool, +} + +impl WeightedClose { + /// Construct a new Weighted Close transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for WeightedClose { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + Some(candle.weighted_close()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "WeightedClose" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value() { + // (high + low + 2·close) / 4 = (12 + 8 + 2·11) / 4 = 42 / 4 = 10.5. + let mut wc = WeightedClose::new(); + assert_relative_eq!( + wc.update(candle(10.0, 12.0, 8.0, 11.0, 0)).unwrap(), + 10.5, + epsilon = 1e-12 + ); + } + + /// Cover the Indicator-impl `name` body (61-63). + #[test] + fn name_metadata() { + let wc = WeightedClose::new(); + assert_eq!(wc.name(), "WeightedClose"); + } + + #[test] + fn emits_from_first_candle() { + let mut wc = WeightedClose::new(); + assert_eq!(wc.warmup_period(), 1); + assert!(!wc.is_ready()); + assert!(wc.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(wc.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut wc = WeightedClose::new(); + wc.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(wc.is_ready()); + wc.reset(); + assert!(!wc.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = WeightedClose::new(); + let mut b = WeightedClose::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/wick_ratio.rs b/crates/wickra-core/src/indicators/wick_ratio.rs new file mode 100644 index 0000000..b76877a --- /dev/null +++ b/crates/wickra-core/src/indicators/wick_ratio.rs @@ -0,0 +1,192 @@ +//! Wick Ratio — the shadow imbalance of a bar. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wick Ratio — the signed imbalance between the upper and lower shadows as a +/// fraction of the bar's range. +/// +/// ```text +/// upper_wick = high − max(open, close) +/// lower_wick = min(open, close) − low +/// WickRatio = (upper_wick − lower_wick) / (high − low) +/// ``` +/// +/// The result lives in `[−1, +1]`: `+1` is a bar that is all upper shadow (a +/// long rejection of higher prices, classic shooting-star geometry), `−1` all +/// lower shadow (a long rejection of lower prices, hammer geometry), and `0` +/// either a symmetric bar or a wickless one. Where +/// [`BodySizePct`](crate::BodySizePct) measures how much of the range is body, +/// this measures *which side* the wicks fall on — the rejection asymmetry many +/// reversal setups depend on. A zero-range bar yields `0`. +/// +/// This is a stateless per-bar transform: every candle produces one value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WickRatio}; +/// +/// let mut indicator = WickRatio::new(); +/// // upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +0.8333. +/// let c = Candle::new(10.0, 13.0, 10.0, 10.5, 10.0, 0).unwrap(); +/// assert!((indicator.update(c).unwrap() - 2.5 / 3.0).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct WickRatio { + has_emitted: bool, +} + +impl WickRatio { + /// Construct a new Wick Ratio transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for WickRatio { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + let out = if range == 0.0 { + // A zero-range bar has no shadows to compare. + 0.0 + } else { + let body_top = candle.open.max(candle.close); + let body_bottom = candle.open.min(candle.close); + let upper_wick = candle.high - body_top; + let lower_wick = body_bottom - candle.low; + (upper_wick - lower_wick) / range + }; + Some(out) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "WickRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn upper_shadow_dominates_is_positive() { + // upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +2.5/3. + let mut wr = WickRatio::new(); + assert_relative_eq!( + wr.update(candle(10.0, 13.0, 10.0, 10.5, 0)).unwrap(), + 2.5 / 3.0, + epsilon = 1e-12 + ); + } + + #[test] + fn lower_shadow_dominates_is_negative() { + // Hammer: long lower shadow -> negative. + // open 12, close 12.5, high 13, low 9: upper 0.5, lower 3, range 4. + let mut wr = WickRatio::new(); + assert_relative_eq!( + wr.update(candle(12.0, 13.0, 9.0, 12.5, 0)).unwrap(), + (0.5 - 3.0) / 4.0, + epsilon = 1e-12 + ); + } + + #[test] + fn symmetric_wicks_are_zero() { + // Equal upper and lower shadows -> 0. + let mut wr = WickRatio::new(); + assert_relative_eq!( + wr.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn zero_range_bar_yields_zero() { + let mut wr = WickRatio::new(); + assert_relative_eq!( + wr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn stays_within_unit_range() { + let candles: Vec = (0..100) + .map(|i| { + let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0; + let close = mid + (f64::from(i) * 0.5).cos() * 2.0; + candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i)) + }) + .collect(); + let mut wr = WickRatio::new(); + for v in wr.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "WickRatio {v} outside [-1, 1]"); + } + } + + #[test] + fn name_metadata() { + let wr = WickRatio::new(); + assert_eq!(wr.name(), "WickRatio"); + } + + #[test] + fn emits_from_first_candle() { + let mut wr = WickRatio::new(); + assert_eq!(wr.warmup_period(), 1); + assert!(!wr.is_ready()); + assert!(wr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(wr.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut wr = WickRatio::new(); + wr.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(wr.is_ready()); + wr.reset(); + assert!(!wr.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i)) + }) + .collect(); + let mut a = WickRatio::new(); + let mut b = WickRatio::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/williams_fractals.rs b/crates/wickra-core/src/indicators/williams_fractals.rs new file mode 100644 index 0000000..b40d3ce --- /dev/null +++ b/crates/wickra-core/src/indicators/williams_fractals.rs @@ -0,0 +1,242 @@ +//! Williams Fractals (Bill Williams). + +use std::collections::VecDeque; + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Williams Fractals output for one bar. +/// +/// Each field is `Some(price)` when a fractal high/low was confirmed at the +/// **centre** of the most recent five-bar window, and `None` otherwise. Up and +/// down fractals are independent and can coincide (a centre bar can be both +/// the maximum high and the minimum low of the window). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WilliamsFractalsOutput { + /// Up fractal: the centre bar's high, if it is strictly greater than the + /// two highs to its left and the two highs to its right. + pub up: Option, + /// Down fractal: the centre bar's low, if it is strictly less than the + /// two lows to its left and the two lows to its right. + pub down: Option, +} + +/// Williams Fractals — Bill Williams' five-bar swing detector. A bar is an +/// **up fractal** if its high is strictly above the highs of the two bars +/// immediately before and the two bars immediately after. A bar is a +/// **down fractal** if its low is strictly below the lows of those same four +/// neighbours. Because confirmation requires two bars to the right of the +/// candidate, the indicator inherently lags by two bars. +/// +/// The first output lands at the fifth candle and corresponds to the third +/// candle (the centre of the window). Subsequent outputs slide the window by +/// one bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WilliamsFractals}; +/// +/// let mut wf = WilliamsFractals::new(); +/// // Build a V-shape with a clear high at index 2. +/// let highs = [1.0, 2.0, 5.0, 2.0, 1.0]; +/// for (i, &h) in highs.iter().enumerate() { +/// let c = Candle::new(h, h, h - 0.5, h, 1.0, i as i64).unwrap(); +/// let _ = wf.update(c); +/// } +/// // At candle 5 the third bar's high of 5.0 is confirmed as an up fractal. +/// ``` +#[derive(Debug, Clone)] +pub struct WilliamsFractals { + // Five-bar window of (high, low) pairs. The centre is at index 2. + window: VecDeque<(f64, f64)>, +} + +impl Default for WilliamsFractals { + fn default() -> Self { + Self::new() + } +} + +impl WilliamsFractals { + /// Construct a new Williams Fractals indicator. The window size is fixed + /// at five bars (two left, centre, two right). + pub fn new() -> Self { + Self { + window: VecDeque::with_capacity(5), + } + } +} + +impl Indicator for WilliamsFractals { + type Input = Candle; + type Output = WilliamsFractalsOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == 5 { + self.window.pop_front(); + } + self.window.push_back((candle.high, candle.low)); + if self.window.len() < 5 { + return None; + } + let (h0, _) = self.window[0]; + let (h1, _) = self.window[1]; + let (h2, l2) = self.window[2]; + let (h3, _) = self.window[3]; + let (h4, _) = self.window[4]; + let (_, l0) = self.window[0]; + let (_, l1) = self.window[1]; + let (_, l3) = self.window[3]; + let (_, l4) = self.window[4]; + + let up = if h2 > h0 && h2 > h1 && h2 > h3 && h2 > h4 { + Some(h2) + } else { + None + }; + let down = if l2 < l0 && l2 < l1 && l2 < l3 && l2 < l4 { + Some(l2) + } else { + None + }; + Some(WilliamsFractalsOutput { up, down }) + } + + fn reset(&mut self) { + self.window.clear(); + } + + fn warmup_period(&self) -> usize { + 5 + } + + fn is_ready(&self) -> bool { + self.window.len() == 5 + } + + fn name(&self) -> &'static str { + "WilliamsFractals" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, ts: i64) -> Candle { + Candle::new(l, h, l, l, 1.0, ts).unwrap() + } + + #[test] + fn isolated_peak_is_detected_as_up_fractal() { + let mut wf = WilliamsFractals::new(); + // Highs 1, 2, 5, 2, 1 -> centre (5) is strictly above its four neighbours. + let highs = [1.0, 2.0, 5.0, 2.0, 1.0]; + let mut last = None; + for (i, &h) in highs.iter().enumerate() { + last = wf.update(c(h, h - 0.5, i64::try_from(i).unwrap())); + } + let o = last.expect("fifth bar emits"); + assert_eq!(o.up, Some(5.0)); + assert_eq!(o.down, None); + } + + #[test] + fn isolated_trough_is_detected_as_down_fractal() { + let mut wf = WilliamsFractals::new(); + // Lows 5, 4, 1, 4, 5 -> centre is the trough. + let lows = [5.0, 4.0, 1.0, 4.0, 5.0]; + let mut last = None; + for (i, &l) in lows.iter().enumerate() { + last = wf.update(c(l + 0.5, l, i64::try_from(i).unwrap())); + } + let o = last.expect("fifth bar emits"); + assert_eq!(o.down, Some(1.0)); + assert_eq!(o.up, None); + } + + #[test] + fn monotonic_series_yields_no_fractals() { + let mut wf = WilliamsFractals::new(); + let mut emitted = 0_usize; + for i in 0..10 { + let h = f64::from(i) + 2.0; + let l = f64::from(i); + if let Some(o) = wf.update(c(h, l, i64::from(i))) { + emitted += 1; + assert_eq!(o.up, None); + assert_eq!(o.down, None); + } + } + assert!(emitted >= 6); + } + + #[test] + fn equal_neighbour_is_not_a_fractal() { + // Centre tied with neighbour -> strict inequality fails -> no fractal. + let mut wf = WilliamsFractals::new(); + let highs = [1.0, 5.0, 5.0, 2.0, 1.0]; + let mut last = None; + for (i, &h) in highs.iter().enumerate() { + last = wf.update(c(h, h - 0.5, i64::try_from(i).unwrap())); + } + let o = last.unwrap(); + assert_eq!(o.up, None); + } + + #[test] + fn first_four_bars_return_none() { + let mut wf = WilliamsFractals::new(); + for i in 0..4 { + assert_eq!(wf.update(c(10.0, 9.0, i)), None); + } + assert!(!wf.is_ready()); + } + + #[test] + fn warmup_period_is_five() { + assert_eq!(WilliamsFractals::new().warmup_period(), 5); + } + + #[test] + fn reset_clears_state() { + let mut wf = WilliamsFractals::new(); + for i in 0..5 { + wf.update(c(10.0, 9.0, i)); + } + assert!(wf.is_ready()); + wf.reset(); + assert!(!wf.is_ready()); + assert_eq!(wf.update(c(10.0, 9.0, 0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), i64::from(i))) + .collect(); + let mut a = WilliamsFractals::new(); + let mut b = WilliamsFractals::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let wf = WilliamsFractals::new(); + assert_eq!(wf.warmup_period(), 5); + assert_eq!(wf.name(), "WilliamsFractals"); + } + + #[test] + fn default_matches_new() { + let a = WilliamsFractals::new(); + let b = WilliamsFractals::default(); + assert_eq!(a.is_ready(), b.is_ready()); + assert_eq!(a.warmup_period(), b.warmup_period()); + } +} diff --git a/crates/wickra-core/src/indicators/williams_r.rs b/crates/wickra-core/src/indicators/williams_r.rs new file mode 100644 index 0000000..cc94880 --- /dev/null +++ b/crates/wickra-core/src/indicators/williams_r.rs @@ -0,0 +1,199 @@ +//! Williams %R. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the lookback window. +/// +/// Values lie in `[-100, 0]` and approximate the mirror image of the fast +/// Stochastic %K. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WilliamsR}; +/// +/// let mut indicator = WilliamsR::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct WilliamsR { + period: usize, + candles: VecDeque, +} + +impl WilliamsR { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + candles: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for WilliamsR { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.candles.len() == self.period { + self.candles.pop_front(); + } + self.candles.push_back(candle); + if self.candles.len() < self.period { + return None; + } + let hh = self + .candles + .iter() + .map(|c| c.high) + .fold(f64::NEG_INFINITY, f64::max); + let ll = self + .candles + .iter() + .map(|c| c.low) + .fold(f64::INFINITY, f64::min); + let range = hh - ll; + if range == 0.0 { + return Some(-50.0); + } + Some(-100.0 * (hh - candle.close) / range) + } + + fn reset(&mut self) { + self.candles.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.candles.len() == self.period + } + + fn name(&self) -> &'static str { + "WilliamsR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64) -> Candle { + Candle::new(cl, h, l, cl, 1.0, 0).unwrap() + } + + #[test] + fn close_at_high_yields_zero() { + let candles = vec![c(10.0, 8.0, 9.0), c(11.0, 9.0, 10.0), c(12.0, 10.0, 12.0)]; + let mut w = WilliamsR::new(3).unwrap(); + let out = w.batch(&candles); + assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn close_at_low_yields_minus_100() { + let candles = vec![c(12.0, 10.0, 11.0), c(11.0, 9.0, 10.0), c(10.0, 8.0, 8.0)]; + let mut w = WilliamsR::new(3).unwrap(); + let out = w.batch(&candles); + assert_relative_eq!(out[2].unwrap(), -100.0, epsilon = 1e-12); + } + + #[test] + fn within_range() { + let candles: Vec = (0..100) + .map(|i| { + let m = 50.0 + (f64::from(i) * 0.3).sin() * 5.0; + c(m + 1.0, m - 1.0, m) + }) + .collect(); + let mut w = WilliamsR::new(14).unwrap(); + for v in w.batch(&candles).into_iter().flatten() { + assert!((-100.0..=0.0).contains(&v), "%R out of range: {v}"); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut a = WilliamsR::new(5).unwrap(); + let mut b = WilliamsR::new(5).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(WilliamsR::new(0).is_err()); + } + + /// Cover the const accessor `period` (49-51) and the Indicator-impl + /// `warmup_period` (87-89) + `name` (95-97). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let w = WilliamsR::new(14).unwrap(); + assert_eq!(w.period(), 14); + assert_eq!(w.warmup_period(), 14); + assert_eq!(w.name(), "WilliamsR"); + } + + /// Cover the `range == 0.0` defensive branch (line 78). All other + /// tests use H != L candles so the lookback range is always positive. + /// Feed a stream of perfectly flat candles (H == L == close) — the + /// lookback hi/lo coincide and the divide-by-zero guard fires, + /// returning the neutral mid-range value -50.0. + #[test] + fn zero_range_yields_minus_fifty() { + let candles: Vec = (0..5).map(|_| c(10.0, 10.0, 10.0)).collect(); + let mut w = WilliamsR::new(3).unwrap(); + let last = w + .batch(&candles) + .into_iter() + .flatten() + .last() + .expect("emits"); + assert_eq!(last, -50.0); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0)) + .collect(); + let mut w = WilliamsR::new(5).unwrap(); + w.batch(&candles); + assert!(w.is_ready()); + w.reset(); + assert!(!w.is_ready()); + assert_eq!(w.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/win_rate.rs b/crates/wickra-core/src/indicators/win_rate.rs new file mode 100644 index 0000000..a1f998c --- /dev/null +++ b/crates/wickra-core/src/indicators/win_rate.rs @@ -0,0 +1,194 @@ +//! Win Rate — the fraction of winning returns over a rolling window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Win Rate — the fraction of strictly-positive returns among the last `period` +/// returns, in `[0, 1]`. +/// +/// ```text +/// WinRate = #(rᵢ > 0) / period +/// ``` +/// +/// Feed a stream of per-trade or per-bar returns (or `PnL`); the indicator reports +/// the rolling hit rate. A return of exactly `0` is treated as a non-win (a +/// flat / scratch), so `WinRate` is the share of the window that strictly made +/// money — the most basic performance statistic and a building block for +/// [`Expectancy`](crate::Expectancy), Kelly sizing, and confidence filters. +/// +/// Each `update` is O(1): the count of wins in the window is maintained +/// incrementally. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, WinRate}; +/// +/// let mut indicator = WinRate::new(4).unwrap(); +/// // returns: +, -, +, + -> 3 of 4 win -> 0.75. +/// let out = indicator.batch(&[1.0, -1.0, 2.0, 1.0]); +/// # use wickra_core::BatchExt; +/// assert_eq!(out[3], Some(0.75)); +/// ``` +#[derive(Debug, Clone)] +pub struct WinRate { + period: usize, + window: VecDeque, + wins: usize, +} + +impl WinRate { + /// Construct a new Win Rate over the given window. + /// + /// # 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), + wins: 0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for WinRate { + 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("window is non-empty"); + if old > 0.0 { + self.wins -= 1; + } + } + self.window.push_back(ret); + if ret > 0.0 { + self.wins += 1; + } + if self.window.len() < self.period { + return None; + } + Some(self.wins as f64 / self.period as f64) + } + + fn reset(&mut self) { + self.window.clear(); + self.wins = 0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "WinRate" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(WinRate::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let wr = WinRate::new(20).unwrap(); + assert_eq!(wr.period(), 20); + assert_eq!(wr.warmup_period(), 20); + assert_eq!(wr.name(), "WinRate"); + assert!(!wr.is_ready()); + } + + #[test] + fn reference_value() { + // +, -, +, + -> 3 wins of 4 -> 0.75. + let mut wr = WinRate::new(4).unwrap(); + let out = wr.batch(&[1.0, -1.0, 2.0, 1.0]); + assert_relative_eq!(out[3].unwrap(), 0.75, epsilon = 1e-12); + } + + #[test] + fn all_wins_is_one() { + let mut wr = WinRate::new(5).unwrap(); + for v in wr.batch(&[1.0; 10]).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn all_losses_is_zero() { + let mut wr = WinRate::new(5).unwrap(); + for v in wr.batch(&[-1.0; 10]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn flat_returns_are_not_wins() { + // Zeros count as non-wins: 2 wins, 2 flats -> 0.5. + let mut wr = WinRate::new(4).unwrap(); + let out = wr.batch(&[1.0, 0.0, 2.0, 0.0]); + assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12); + } + + #[test] + fn rolling_window_drops_old_wins() { + // period 3: after [+,+,+] -> 1.0, then three losses slide the wins out. + let mut wr = WinRate::new(3).unwrap(); + let out = wr.batch(&[1.0, 1.0, 1.0, -1.0, -1.0, -1.0]); + assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12); + assert_relative_eq!(out[5].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn output_within_bounds() { + let mut wr = WinRate::new(20).unwrap(); + let rets: Vec = (0..200).map(|i| (f64::from(i) * 0.7).sin()).collect(); + for v in wr.batch(&rets).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "out of bounds: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut wr = WinRate::new(5).unwrap(); + wr.batch(&[1.0, -1.0, 1.0, -1.0, 1.0]); + assert!(wr.is_ready()); + wr.reset(); + assert!(!wr.is_ready()); + assert_eq!(wr.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let rets: Vec = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect(); + let batch = WinRate::new(14).unwrap().batch(&rets); + let mut b = WinRate::new(14).unwrap(); + let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/wma.rs b/crates/wickra-core/src/indicators/wma.rs new file mode 100644 index 0000000..6cb1042 --- /dev/null +++ b/crates/wickra-core/src/indicators/wma.rs @@ -0,0 +1,270 @@ +//! Weighted Moving Average (linear weights). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Weighted Moving Average with linear weights `1, 2, ..., period`. +/// +/// Output is `sum(weight_i * price_i) / sum(weights)`. Maintained incrementally in +/// O(1) by keeping the rolling sum of values and the rolling weighted sum. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Wma}; +/// +/// let mut indicator = Wma::new(3).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Wma { + period: usize, + window: VecDeque, + weight_sum: f64, // sum_i (weight_i * value_i) + value_sum: f64, // sum_i (value_i) + weights_total: f64, +} + +impl Wma { + /// Construct a new WMA with the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let n = period as f64; + let weights_total = n * (n + 1.0) / 2.0; + Ok(Self { + period, + window: VecDeque::with_capacity(period), + weight_sum: 0.0, + value_sum: 0.0, + weights_total, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub fn value(&self) -> Option { + if self.window.len() == self.period { + Some(self.weight_sum / self.weights_total) + } else { + None + } + } +} + +impl Indicator for Wma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.value(); + } + if self.window.len() < self.period { + // Warmup. Just accumulate; compute weight_sum once when the window first + // becomes full to avoid having to track changing weights during warmup. + self.window.push_back(input); + self.value_sum += input; + if self.window.len() == self.period { + self.weight_sum = self + .window + .iter() + .enumerate() + .map(|(i, v)| (i as f64 + 1.0) * v) + .sum(); + } + return self.value(); + } + // Steady state: slide the window. With weights [1, 2, ..., period], + // new_weight_sum = old_weight_sum - old_value_sum + period * new_input + // because every retained element's weight drops by one and the newcomer + // enters at weight = period. Order matters: subtract `value_sum` BEFORE + // updating it. + let oldest = self.window.pop_front().expect("window non-empty"); + self.weight_sum = self.weight_sum - self.value_sum + self.period as f64 * input; + self.value_sum = self.value_sum - oldest + input; + self.window.push_back(input); + self.value() + } + + fn reset(&mut self) { + self.window.clear(); + self.weight_sum = 0.0; + self.value_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "WMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Reference implementation: explicit weighted average over a window. + fn wma_naive(prices: &[f64], period: usize) -> Vec> { + let weights_total = (period as f64) * (period as f64 + 1.0) / 2.0; + prices + .iter() + .enumerate() + .map(|(i, _)| { + if i + 1 < period { + None + } else { + let window = &prices[i + 1 - period..=i]; + let s: f64 = window + .iter() + .enumerate() + .map(|(j, p)| (j as f64 + 1.0) * p) + .sum(); + Some(s / weights_total) + } + }) + .collect() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Wma::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessor `period` (56-58) and the Indicator-impl + /// `warmup_period` (111-113) + `name` (119-121). Existing tests never + /// inspect these metadata methods. + #[test] + fn accessors_and_metadata() { + let wma = Wma::new(7).unwrap(); + assert_eq!(wma.period(), 7); + assert_eq!(wma.warmup_period(), 7); + assert_eq!(wma.name(), "WMA"); + } + + #[test] + fn warmup_returns_none() { + let mut wma = Wma::new(3).unwrap(); + assert_eq!(wma.update(1.0), None); + assert_eq!(wma.update(2.0), None); + // WMA(3) of [1,2,3]: oldest = 1 (weight 1), middle = 2 (weight 2), newest = 3 (weight 3) + // -> (1*1 + 2*2 + 3*3) / (1+2+3) = 14/6 + assert_relative_eq!(wma.update(3.0).unwrap(), 14.0 / 6.0, epsilon = 1e-12); + } + + #[test] + fn known_values_period_4() { + // WMA(4) weights 1,2,3,4 (total 10); inputs [1,2,3,4]: + // (1*1 + 2*2 + 3*3 + 4*4) / 10 = (1+4+9+16)/10 = 30/10 = 3.0 + let mut wma = Wma::new(4).unwrap(); + let v = wma.batch(&[1.0, 2.0, 3.0, 4.0]); + assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12); + } + + #[test] + fn matches_naive_over_random_inputs() { + let prices: Vec = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect(); + let mut wma = Wma::new(7).unwrap(); + let got = wma.batch(&prices); + let want = wma_naive(&prices, 7); + for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { + // Same warmup — emission shape must agree at every index. + assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}"); + if let (Some(a), Some(b)) = (g, w) { + assert_relative_eq!(*a, *b, epsilon = 1e-9); + } + } + } + + #[test] + fn period_one_is_pass_through() { + let mut wma = Wma::new(1).unwrap(); + assert_relative_eq!(wma.update(5.5).unwrap(), 5.5, epsilon = 1e-12); + assert_relative_eq!(wma.update(7.5).unwrap(), 7.5, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut wma = Wma::new(4).unwrap(); + wma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(wma.is_ready()); + wma.reset(); + assert!(!wma.is_ready()); + assert_eq!(wma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=20).map(|i| f64::from(i) * 0.5).collect(); + let mut a = Wma::new(5).unwrap(); + let mut b = Wma::new(5).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn ignores_non_finite_input_but_keeps_state() { + let mut wma = Wma::new(3).unwrap(); + wma.update(1.0); + wma.update(2.0); + let ready = wma.update(3.0).expect("WMA(3) ready after three inputs"); + // Non-finite inputs return the last value without mutating the window. + assert_eq!(wma.update(f64::NAN), Some(ready)); + assert_eq!(wma.update(f64::INFINITY), Some(ready)); + // The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4. + assert_relative_eq!( + wma.update(4.0).unwrap(), + (2.0 * 1.0 + 3.0 * 2.0 + 4.0 * 3.0) / 6.0, + epsilon = 1e-12 + ); + } + + proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(48))] + #[test] + fn proptest_matches_naive( + period in 1usize..15, + prices in proptest::collection::vec(-500.0_f64..500.0, 0..120), + ) { + let mut wma = Wma::new(period).unwrap(); + let got = wma.batch(&prices); + let want = wma_naive(&prices, period); + proptest::prop_assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want.iter()) { + match (g, w) { + (None, None) => {} + (Some(a), Some(b)) => proptest::prop_assert!( + (a - b).abs() < 1e-7, + "got={a} want={b}" + ), + _ => proptest::prop_assert!(false, "warmup mismatch"), + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/woodie_pivots.rs b/crates/wickra-core/src/indicators/woodie_pivots.rs new file mode 100644 index 0000000..40e3477 --- /dev/null +++ b/crates/wickra-core/src/indicators/woodie_pivots.rs @@ -0,0 +1,192 @@ +//! Woodie Pivot Points (Tom Williams). + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Woodie Pivot Points output: two resistances, pivot, two supports. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WoodiePivotsOutput { + /// Pivot Point: `(H + L + 2·C) / 4`. + pub pp: f64, + /// Resistance 1: `2·PP − L`. + pub r1: f64, + /// Resistance 2: `PP + (H − L)`. + pub r2: f64, + /// Support 1: `2·PP − H`. + pub s1: f64, + /// Support 2: `PP − (H − L)`. + pub s2: f64, +} + +/// Woodie Pivot Points — Tom Williams' close-weighted pivot variant. +/// +/// ```text +/// PP = (H + L + 2·C) / 4 +/// R1 = 2·PP − L S1 = 2·PP − H +/// R2 = PP + (H − L) S2 = PP − (H − L) +/// ``` +/// +/// The double-weighted close shifts the pivot toward where most of the +/// session's activity actually settled — useful in trending markets where the +/// close is more meaningful than the midpoint. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, WoodiePivots}; +/// +/// let prev = Candle::new(100.0, 110.0, 90.0, 108.0, 1.0, 0).unwrap(); +/// let levels = WoodiePivots::new().update(prev).unwrap(); +/// // Close-weighted PP = (110 + 90 + 2·108)/4 = 104. +/// assert!((levels.pp - 104.0).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct WoodiePivots { + ready: bool, +} + +impl WoodiePivots { + /// Construct a new Woodie Pivot Points indicator. + pub const fn new() -> Self { + Self { ready: false } + } +} + +impl Indicator for WoodiePivots { + type Input = Candle; + type Output = WoodiePivotsOutput; + + fn update(&mut self, candle: Candle) -> Option { + let (h, l, c) = (candle.high, candle.low, candle.close); + let pp = (h + l + 2.0 * c) / 4.0; + let range = h - l; + let out = WoodiePivotsOutput { + pp, + r1: 2.0 * pp - l, + r2: pp + range, + s1: 2.0 * pp - h, + s2: pp - range, + }; + self.ready = true; + Some(out) + } + + fn reset(&mut self) { + self.ready = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.ready + } + + fn name(&self) -> &'static str { + "WoodiePivots" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, h, l, close, 1.0, ts).unwrap() + } + + #[test] + fn formula_reference_values() { + // H=110, L=90, C=108 -> PP = (110+90+216)/4 = 104. + let levels = WoodiePivots::new() + .update(c(110.0, 90.0, 108.0, 0)) + .unwrap(); + assert!((levels.pp - 104.0).abs() < 1e-12); + assert!((levels.r1 - (2.0 * 104.0 - 90.0)).abs() < 1e-12); + assert!((levels.s1 - (2.0 * 104.0 - 110.0)).abs() < 1e-12); + assert!((levels.r2 - (104.0 + 20.0)).abs() < 1e-12); + assert!((levels.s2 - (104.0 - 20.0)).abs() < 1e-12); + } + + #[test] + fn pp_differs_from_classic_when_close_is_skewed() { + // Classic PP = (H+L+C)/3; Woodie PP weights close twice. They agree + // only when C equals (H+L)/2. + let levels = WoodiePivots::new() + .update(c(120.0, 80.0, 110.0, 0)) + .unwrap(); + let classic_pp = (120.0 + 80.0 + 110.0) / 3.0; + assert!((levels.pp - classic_pp).abs() > 1e-6); + // Equal when close = midpoint. + let mid = WoodiePivots::new() + .update(c(120.0, 80.0, 100.0, 0)) + .unwrap(); + let classic_mid = (120.0 + 80.0 + 100.0) / 3.0; + assert!((mid.pp - classic_mid).abs() < 1e-9); + } + + #[test] + fn ordering_resistance_above_pivot_above_support() { + let levels = WoodiePivots::new() + .update(c(120.0, 80.0, 110.0, 0)) + .unwrap(); + assert!(levels.r2 >= levels.r1); + assert!(levels.r1 >= levels.pp); + assert!(levels.pp >= levels.s1); + assert!(levels.s1 >= levels.s2); + } + + #[test] + fn constant_series_collapses_levels() { + let levels = WoodiePivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap(); + assert_eq!(levels.pp, 50.0); + assert_eq!(levels.r2, 50.0); + assert_eq!(levels.s2, 50.0); + } + + #[test] + fn warmup_and_ready() { + let mut p = WoodiePivots::new(); + assert!(!p.is_ready()); + assert_eq!(p.warmup_period(), 1); + p.update(c(11.0, 9.0, 10.0, 0)); + assert!(p.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut p = WoodiePivots::new(); + p.update(c(11.0, 9.0, 10.0, 0)); + p.reset(); + assert!(!p.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0_i32..40) + .map(|i| { + c( + f64::from(i) + 2.0, + f64::from(i), + f64::from(i) + 1.0, + i.into(), + ) + }) + .collect(); + let mut a = WoodiePivots::new(); + let mut b = WoodiePivots::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let p = WoodiePivots::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "WoodiePivots"); + } +} diff --git a/crates/wickra-core/src/indicators/yang_zhang.rs b/crates/wickra-core/src/indicators/yang_zhang.rs new file mode 100644 index 0000000..20abcd6 --- /dev/null +++ b/crates/wickra-core/src/indicators/yang_zhang.rs @@ -0,0 +1,406 @@ +//! Yang-Zhang Volatility (drift- and gap-robust OHLC estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Yang-Zhang Volatility — combines overnight, open-to-close and +/// Rogers-Satchell volatilities into a single drift- and gap-robust +/// estimator. +/// +/// Yang & Zhang (2000) showed that the three estimators below are +/// independent under a driftless GBM with overnight gaps, so a convex +/// combination of their (sample) variances has minimum estimation variance +/// at a specific blending factor `k`: +/// +/// ```text +/// k = 0.34 / (1.34 + (n + 1) / (n − 1)) +/// σ²_on = sample_var(ln(O_t / C_{t-1}) over n bars) // overnight +/// σ²_oc = sample_var(ln(C_t / O_t) over n bars) // open-to-close +/// σ²_rs = mean(ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O) over n bars) // Rogers-Satchell +/// σ²_YZ = σ²_on + k · σ²_oc + (1 − k) · σ²_rs +/// out = √max(σ²_YZ, 0) · √trading_periods · 100 +/// ``` +/// +/// The "sample" variance uses Bessel's correction (divisor `n − 1`), the +/// same convention as [`HistoricalVolatility`](crate::HistoricalVolatility). +/// +/// This is the gold-standard OHLC estimator for assets with both +/// overnight gaps and intraday drift — equities, futures, and any +/// market that doesn't trade continuously. For pure intraday data +/// (where `C_{t-1} == O_t`), the overnight term vanishes and +/// Rogers-Satchell alone is sufficient. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, YangZhangVolatility}; +/// +/// let mut indicator = YangZhangVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct YangZhangVolatility { + period: usize, + trading_periods: usize, + k: f64, + prev_close: Option, + // Each window stores one f64 per bar in the rolling window. + overnight: VecDeque, + open_close: VecDeque, + rs_samples: VecDeque, + sum_on: f64, + sum_sq_on: f64, + sum_oc: f64, + sum_sq_oc: f64, + sum_rs: f64, + last: Option, +} + +impl YangZhangVolatility { + /// Construct a Yang-Zhang Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`, or + /// [`Error::InvalidPeriod`] if `period < 2` (the sample variances + /// inside Yang-Zhang need at least two samples). + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "Yang-Zhang period must be >= 2", + }); + } + let n = period as f64; + let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0)); + Ok(Self { + period, + trading_periods, + k, + prev_close: None, + overnight: VecDeque::with_capacity(period), + open_close: VecDeque::with_capacity(period), + rs_samples: VecDeque::with_capacity(period), + sum_on: 0.0, + sum_sq_on: 0.0, + sum_oc: 0.0, + sum_sq_oc: 0.0, + sum_rs: 0.0, + last: None, + }) + } + + /// Configured `(period, trading_periods)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.trading_periods) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + /// The Yang-Zhang blending factor `k` for this configuration. + pub const fn k(&self) -> f64 { + self.k + } +} + +impl Indicator for YangZhangVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // The overnight log-return needs the previous bar's close. On the + // first candle there is no previous close, so we only seed + // `prev_close` and return None without touching any window. + let Some(prev_c) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + self.prev_close = Some(candle.close); + + // Per-bar samples. `Candle::new` guarantees finite, positive OHLC + // and the ordering invariants, so every ratio is well-defined. + let on_sample = (candle.open / prev_c).ln(); + let oc_sample = (candle.close / candle.open).ln(); + let log_hc = (candle.high / candle.close).ln(); + let log_ho = (candle.high / candle.open).ln(); + let log_lc = (candle.low / candle.close).ln(); + let log_lo = (candle.low / candle.open).ln(); + let rs_sample = log_hc.mul_add(log_ho, log_lc * log_lo); + + // Roll the three windows. + if self.overnight.len() == self.period { + let old_on = self.overnight.pop_front().expect("window non-empty"); + self.sum_on -= old_on; + self.sum_sq_on -= old_on * old_on; + let old_oc = self.open_close.pop_front().expect("window non-empty"); + self.sum_oc -= old_oc; + self.sum_sq_oc -= old_oc * old_oc; + let old_rs = self.rs_samples.pop_front().expect("window non-empty"); + self.sum_rs -= old_rs; + } + self.overnight.push_back(on_sample); + self.sum_on += on_sample; + self.sum_sq_on += on_sample * on_sample; + self.open_close.push_back(oc_sample); + self.sum_oc += oc_sample; + self.sum_sq_oc += oc_sample * oc_sample; + self.rs_samples.push_back(rs_sample); + self.sum_rs += rs_sample; + + if self.overnight.len() < self.period { + return None; + } + + let n = self.period as f64; + let mean_on = self.sum_on / n; + let mean_oc = self.sum_oc / n; + // Sample variances (Bessel's correction). Clamp to zero against + // FP cancellation noise. + let var_on = ((self.sum_sq_on - n * mean_on * mean_on) / (n - 1.0)).max(0.0); + let var_oc = ((self.sum_sq_oc - n * mean_oc * mean_oc) / (n - 1.0)).max(0.0); + // Rogers-Satchell mean: each per-bar sample is already >= 0 by + // construction, so the mean cannot be negative outside of FP. + let var_rs = (self.sum_rs / n).max(0.0); + + let total = var_on + self.k * var_oc + (1.0 - self.k) * var_rs; + let sigma = total.max(0.0).sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev_close = None; + self.overnight.clear(); + self.open_close.clear(); + self.rs_samples.clear(); + self.sum_on = 0.0; + self.sum_sq_on = 0.0; + self.sum_oc = 0.0; + self.sum_sq_oc = 0.0; + self.sum_rs = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One bar to seed `prev_close`, then `period` more bars to fill + // the rolling windows. First emit lands at index `period`, i.e. + // the `(period + 1)`-th input. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "YangZhangVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(o, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + YangZhangVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + YangZhangVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_period_one() { + assert!(matches!( + YangZhangVolatility::new(1, 252), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let yz = YangZhangVolatility::new(20, 252).unwrap(); + assert_eq!(yz.periods(), (20, 252)); + assert_eq!(yz.value(), None); + assert_eq!(yz.warmup_period(), 21); + assert_eq!(yz.name(), "YangZhangVolatility"); + assert!(!yz.is_ready()); + + // k = 0.34 / (1.34 + 21/19) ≈ 0.139 + let n = 20.0; + let expected_k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0)); + assert_relative_eq!(yz.k(), expected_k, epsilon = 1e-12); + } + + #[test] + fn zero_movement_yields_zero() { + // O == H == L == C and constant across bars -> every per-bar sample + // is zero, all three variances are zero, output is zero. + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + let mut yz = YangZhangVolatility::new(14, 1).unwrap(); + for v in yz.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut yz = YangZhangVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + let open = base - 0.1; + let close = base + 0.2; + candle(open, base + half, base - half, close, i64::from(i)) + }) + .collect(); + for v in yz.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Yang-Zhang must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle( + base - 0.05, + base + half, + base - half, + base + 0.3, + i64::from(i), + ) + }) + .collect(); + let raw = YangZhangVolatility::new(10, 1).unwrap().batch(&candles); + let annual = YangZhangVolatility::new(10, 252).unwrap().batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + // period = 5 -> first ready at index 5 (the 6th candle): one bar + // seeds prev_close, the next 5 fill the rolling window. + let candles: Vec = (0..20_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.4).sin() * 3.0; + candle(base, base + 1.0, base - 1.0, base + 0.2, i) + }) + .collect(); + let mut yz = YangZhangVolatility::new(5, 1).unwrap(); + assert_eq!(yz.warmup_period(), 6); + let out = yz.batch(&candles); + for v in out.iter().take(5) { + assert!(v.is_none(), "indicator must still be warming up"); + } + assert!( + out[5].is_some(), + "first value lands at warmup_period - 1 = 5" + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle( + base - 0.05, + base + half, + base - half, + base + 0.5, + i64::from(i), + ) + }) + .collect(); + let batch = YangZhangVolatility::new(14, 252).unwrap().batch(&candles); + let mut streamer = YangZhangVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let mut yz = YangZhangVolatility::new(14, 252).unwrap(); + yz.batch(&candles); + assert!(yz.is_ready()); + yz.reset(); + assert!(!yz.is_ready()); + assert_eq!(yz.value(), None); + assert_eq!(yz.update(candles[0]), None); + } + + #[test] + fn intraday_data_collapses_to_rs_only() { + // If `O_t == C_{t-1}` for every bar (perfect intraday continuity), + // the overnight log-return is zero and `var_on == 0`. If the + // open-to-close return is also constant across bars, `var_oc == 0`. + // Yang-Zhang then reduces to `(1-k) · var_rs`. The arithmetic + // checks out against the closed form. + // + // Construct a series where every bar opens at the previous close + // and has a constant intraday shape: O=10, H=11, L=9, C=10 every + // bar. Then ln(O_t/C_{t-1}) = 0, ln(C/O) = 0, and the RS sample + // is `2 · (ln(11/10) · ln(11/10))` (the ln(9/10)·ln(9/10) term + // matches numerically). + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.0, i)).collect(); + let mut yz = YangZhangVolatility::new(10, 1).unwrap(); + let out = yz.batch(&candles); + + let log_hc = (11.0_f64 / 10.0_f64).ln(); + let log_ho = (11.0_f64 / 10.0_f64).ln(); + let log_lc = (9.0_f64 / 10.0_f64).ln(); + let log_lo = (9.0_f64 / 10.0_f64).ln(); + let rs_sample = log_hc * log_ho + log_lc * log_lo; + let n = 10.0; + let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0)); + // var_on = var_oc = 0 because every sample equals the mean (0). + let total = (1.0 - k) * rs_sample; + let expected = total.max(0.0).sqrt() * 100.0; + + for v in out.iter().skip(11).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } +} diff --git a/crates/wickra-core/src/indicators/yoyo_exit.rs b/crates/wickra-core/src/indicators/yoyo_exit.rs new file mode 100644 index 0000000..27b192e --- /dev/null +++ b/crates/wickra-core/src/indicators/yoyo_exit.rs @@ -0,0 +1,269 @@ +//! Yo-Yo Exit. + +use crate::error::{Error, Result}; +use crate::indicators::atr::Atr; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Yo-Yo Exit — an ATR-based long-only trailing stop that "yo-yos" in and out +/// of the market: when price closes below the trail it exits, and when price +/// recovers `multiplier · ATR` above the same trail it re-enters long. The +/// emitted level is always the *trail itself* (not a flip-to-short stop), so a +/// consumer reads a single line on the chart and toggles the position +/// depending on which side of it the close sits. +/// +/// ```text +/// band = multiplier · ATR +/// in-trade: trail_t = max(trail_{t−1}, close − band) +/// exit when close < trail +/// out: trail held flat at the last in-trade level +/// re-enter when close > trail + band +/// ``` +/// +/// Unlike [`AtrTrailingStop`](crate::AtrTrailingStop) — which always flips to +/// the opposite side — the Yo-Yo only takes longs and treats the off-period +/// as a "wait until price proves itself again" phase. A common configuration +/// is `ATR(14)` with a `2.0` multiplier. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, YoyoExit}; +/// +/// let mut indicator = YoyoExit::new(14, 2.0).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct YoyoExit { + atr: Atr, + atr_period: usize, + multiplier: f64, + trail: Option, + /// `true` while the trail is being ratcheted by new closes; `false` while + /// the strategy is sidelined waiting for a re-entry. + in_trade: bool, +} + +impl YoyoExit { + /// Construct a Yo-Yo Exit with an explicit ATR period and band multiplier. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `atr_period == 0` and + /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly + /// positive and finite. + pub fn new(atr_period: usize, multiplier: f64) -> Result { + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + atr: Atr::new(atr_period)?, + atr_period, + multiplier, + trail: None, + in_trade: true, + }) + } + + /// A common configuration: `ATR(14)` with a `2.0` multiplier. + pub fn classic() -> Self { + Self::new(14, 2.0).expect("classic Yo-Yo Exit params are valid") + } + + /// Configured `(atr_period, multiplier)`. + pub const fn params(&self) -> (usize, f64) { + (self.atr_period, self.multiplier) + } + + /// `true` while the strategy is currently long, `false` while sidelined. + pub const fn in_trade(&self) -> bool { + self.in_trade + } +} + +impl Indicator for YoyoExit { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let band = self.multiplier * atr; + let close = candle.close; + + let trail = match self.trail { + Some(prev) => { + if self.in_trade { + if close < prev { + // Stopped out — sideline, keep the trail flat. + self.in_trade = false; + prev + } else { + // Ratchet up only. + prev.max(close - band) + } + } else if close > prev + band { + // Re-entry trigger — start a new trail anchored on this close. + self.in_trade = true; + close - band + } else { + prev + } + } + // First ATR-ready bar starts a fresh long. + None => close - band, + }; + self.trail = Some(trail); + Some(trail) + } + + fn reset(&mut self) { + self.atr.reset(); + self.trail = None; + self.in_trade = true; + } + + fn warmup_period(&self) -> usize { + self.atr_period + } + + fn is_ready(&self) -> bool { + self.trail.is_some() + } + + fn name(&self) -> &'static str { + "YoyoExit" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_params() { + assert!(YoyoExit::new(0, 2.0).is_err()); + assert!(YoyoExit::new(14, 0.0).is_err()); + assert!(YoyoExit::new(14, -1.0).is_err()); + assert!(YoyoExit::new(14, f64::NAN).is_err()); + } + + #[test] + fn accessors_and_metadata() { + let s = YoyoExit::classic(); + let (p, m) = s.params(); + assert_eq!(p, 14); + assert_relative_eq!(m, 2.0, epsilon = 1e-12); + assert_eq!(s.warmup_period(), 14); + assert_eq!(s.name(), "YoyoExit"); + assert!(s.in_trade()); + } + + #[test] + fn first_emission_matches_warmup() { + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = YoyoExit::new(8, 2.0).unwrap(); + let out = s.batch(&candles); + for (i, v) in out.iter().enumerate().take(7) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[7].is_some()); + } + + #[test] + fn reference_values_flat_market() { + // ATR = 2; band = 4; trail starts at close - band = 10 - 4 = 6 and stays there. + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut s = YoyoExit::new(5, 2.0).unwrap(); + for v in s.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 6.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_trail_ratchets_up() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = YoyoExit::new(14, 3.0).unwrap(); + let emitted: Vec = s.batch(&candles).into_iter().flatten().collect(); + for w in emitted.windows(2) { + assert!(w[1] >= w[0] - 1e-9, "trail must not loosen in an uptrend"); + } + } + + #[test] + fn reentry_after_stop_out() { + // Up-leg sets the trail, big drop stops out, recovery re-enters. + let mut candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + candles.push(c(60.0, 40.0, 50.0, 30)); // stop-out + candles.push(c(60.0, 50.0, 55.0, 31)); // still out + candles.push(c(200.0, 100.0, 200.0, 32)); // strong rally -> re-entry + let mut s = YoyoExit::new(14, 3.0).unwrap(); + // Drive to completion; we just need it to not panic and to flip in_trade + // back to true once a re-entry trigger fires. + for c in &candles { + let _ = s.update(*c); + } + assert!(s.is_ready()); + // Final candle's close (200) is way above the trail, so we're back in. + assert!(s.in_trade()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut s = YoyoExit::classic(); + s.batch(&candles); + assert!(s.is_ready()); + s.reset(); + assert!(!s.is_ready()); + assert!(s.in_trade()); + assert_eq!(s.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = YoyoExit::classic(); + let mut b = YoyoExit::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/z_score.rs b/crates/wickra-core/src/indicators/z_score.rs new file mode 100644 index 0000000..497fba7 --- /dev/null +++ b/crates/wickra-core/src/indicators/z_score.rs @@ -0,0 +1,198 @@ +//! Z-Score. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Z-Score — how many standard deviations the latest price sits from its +/// rolling mean. +/// +/// ```text +/// ZScore = (price − SMA(price, n)) / population_stddev(price, n) +/// ``` +/// +/// A reading of `+2` means price is two standard deviations above its recent +/// average — statistically stretched to the upside; `−2` is the mirror. It is +/// the standard normalisation behind mean-reversion strategies: a large +/// magnitude flags an extension, a return toward `0` flags reversion. A window +/// with zero dispersion (a flat series) yields `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ZScore}; +/// +/// let mut indicator = ZScore::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ZScore { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl ZScore { + /// Construct a new Z-Score over a rolling window of `period` prices. + /// + /// # 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: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ZScore { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if !value.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(value); + self.sum += value; + self.sum_sq += value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Population variance E[x²] − E[x]²; clamp away tiny negative drift. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let std = variance.sqrt(); + if std == 0.0 { + // A window with no dispersion: the price is exactly its own mean. + return Some(0.0); + } + Some((value - mean) / std) + } + + 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 { + "ZScore" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values() { + // Window [1, 3]: mean 2, population variance (1 + 9)/2 − 4 = 1, + // stddev 1; the latest price 3 is (3 − 2) / 1 = 1 stddev above. + let mut z = ZScore::new(2).unwrap(); + let out = z.batch(&[1.0, 3.0]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut z = ZScore::new(10).unwrap(); + for v in z.batch(&[42.0; 30]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn rising_price_is_above_its_mean() { + // A monotonically rising series always sits above its trailing mean. + let prices: Vec = (0..40).map(f64::from).collect(); + let mut z = ZScore::new(10).unwrap(); + for v in z.batch(&prices).into_iter().flatten() { + assert!( + v > 0.0, + "a rising price should score above its mean, got {v}" + ); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut z = ZScore::new(5).unwrap(); + let out = z.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first value lands at index period - 1"); + assert_eq!(z.warmup_period(), 5); + } + + #[test] + fn rejects_zero_period() { + assert!(ZScore::new(0).is_err()); + } + + /// Cover the const accessor `period` (59-61) and the Indicator-impl + /// `name` body (106-108). `warmup_period` is exercised elsewhere. + #[test] + fn accessors_and_metadata() { + let z = ZScore::new(20).unwrap(); + assert_eq!(z.period(), 20); + assert_eq!(z.name(), "ZScore"); + } + + #[test] + fn reset_clears_state() { + let mut z = ZScore::new(5).unwrap(); + z.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + assert_eq!(z.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = ZScore::new(20).unwrap(); + let mut b = ZScore::new(20).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/zero_lag_macd.rs b/crates/wickra-core/src/indicators/zero_lag_macd.rs new file mode 100644 index 0000000..b542e40 --- /dev/null +++ b/crates/wickra-core/src/indicators/zero_lag_macd.rs @@ -0,0 +1,226 @@ +//! Zero-Lag MACD — MACD computed on `ZLEMA` instead of `EMA`. + +use crate::error::{Error, Result}; +use crate::indicators::zlema::Zlema; +use crate::traits::Indicator; + +/// Multi-output for Zero-Lag MACD: the MACD line, its signal line, and the +/// histogram (line − signal). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ZeroLagMacdOutput { + /// Fast `ZLEMA` minus slow `ZLEMA`. + pub macd: f64, + /// `ZLEMA(macd, signal_period)`. + pub signal: f64, + /// `macd − signal`. + pub histogram: f64, +} + +/// Zero-Lag MACD — the standard `MACD` topology with `ZLEMA` substituted for +/// `EMA` everywhere. `ZLEMA`'s de-lagged construction makes the MACD line +/// react faster to trend changes at the cost of slightly noisier readings. +/// +/// ```text +/// macd_t = ZLEMA(close, fast)_t − ZLEMA(close, slow)_t +/// signal_t = ZLEMA(macd, signal_period)_t +/// histogram_t = macd_t − signal_t +/// ``` +/// +/// Default parameters mirror MACD: `(fast = 12, slow = 26, signal = 9)`. +/// `fast` must be strictly less than `slow`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ZeroLagMacd}; +/// +/// let mut zmacd = ZeroLagMacd::classic(); +/// let mut last = None; +/// for i in 0..120 { +/// last = zmacd.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ZeroLagMacd { + fast_period: usize, + slow_period: usize, + signal_period: usize, + fast: Zlema, + slow: Zlema, + signal: Zlema, +} + +impl ZeroLagMacd { + /// # Errors + /// - [`Error::PeriodZero`] if any period is zero. + /// - [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + if fast == 0 || slow == 0 || signal == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "ZeroLagMACD fast period must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + signal_period: signal, + fast: Zlema::new(fast)?, + slow: Zlema::new(slow)?, + signal: Zlema::new(signal)?, + }) + } + + /// MACD-style defaults: `(fast = 12, slow = 26, signal = 9)`. + pub fn classic() -> Self { + Self::new(12, 26, 9).expect("classic Zero-Lag MACD parameters are valid") + } + + /// Configured `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.signal_period) + } +} + +impl Indicator for ZeroLagMacd { + type Input = f64; + type Output = ZeroLagMacdOutput; + + fn update(&mut self, input: f64) -> Option { + // Feed both inner ZLEMAs on every input so the slow one warms in + // parallel with the fast one. + let f = self.fast.update(input); + let s = self.slow.update(input); + let (f, s) = (f?, s?); + let macd = f - s; + let signal = self.signal.update(macd)?; + Some(ZeroLagMacdOutput { + macd, + signal, + histogram: macd - signal, + }) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal.reset(); + } + + fn warmup_period(&self) -> usize { + // ZLEMA(period) warmup is `(period − 1) / 2 + period` = `lag + period`. + // Both fast and slow run in parallel; the slow one dominates. The + // signal ZLEMA then needs its own `lag + period` MACD values on top. + let zlema_warmup = |period: usize| ((period - 1) / 2).saturating_add(period); + zlema_warmup(self.slow_period) + zlema_warmup(self.signal_period) - 1 + } + + fn is_ready(&self) -> bool { + self.signal.is_ready() + } + + fn name(&self) -> &'static str { + "ZeroLagMACD" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(ZeroLagMacd::new(0, 26, 9), Err(Error::PeriodZero))); + assert!(matches!(ZeroLagMacd::new(12, 0, 9), Err(Error::PeriodZero))); + assert!(matches!( + ZeroLagMacd::new(12, 26, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + ZeroLagMacd::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let z = ZeroLagMacd::classic(); + assert_eq!(z.periods(), (12, 26, 9)); + assert_eq!(z.name(), "ZeroLagMACD"); + } + + #[test] + fn classic_factory() { + assert_eq!(ZeroLagMacd::classic().periods(), (12, 26, 9)); + } + + #[test] + fn constant_series_converges_to_zero() { + // Each ZLEMA reproduces a constant, so macd, signal and histogram + // are all 0 after the slowest branch warms. + let mut z = ZeroLagMacd::new(3, 5, 3).unwrap(); + let out = z.batch(&[42.0_f64; 60]); + for v in out.iter().rev().take(5).flatten() { + assert_relative_eq!(v.macd, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.histogram, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn histogram_is_macd_minus_signal() { + let mut z = ZeroLagMacd::classic(); + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + for v in z.batch(&prices).iter().flatten() { + assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12); + } + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = ZeroLagMacd::classic(); + let mut b = ZeroLagMacd::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut z = ZeroLagMacd::classic(); + z.batch(&(1..=120).map(f64::from).collect::>()); + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + } + + #[test] + fn warmup_period_matches_zlema_chain() { + // warmup = zlema_warmup(slow) + zlema_warmup(signal) - 1 + // zlema_warmup(p) = (p - 1) / 2 + p + // (12, 26, 9): zlema_warmup(26) = 12 + 26 = 38; + // zlema_warmup(9) = 4 + 9 = 13. + // warmup = 38 + 13 - 1 = 50. + let z = ZeroLagMacd::new(12, 26, 9).unwrap(); + assert_eq!(z.warmup_period(), 50); + // (3, 5, 3): zlema_warmup(5) = 2 + 5 = 7; zlema_warmup(3) = 1 + 3 = 4. + // warmup = 7 + 4 - 1 = 10. + let z = ZeroLagMacd::new(3, 5, 3).unwrap(); + assert_eq!(z.warmup_period(), 10); + } +} diff --git a/crates/wickra-core/src/indicators/zig_zag.rs b/crates/wickra-core/src/indicators/zig_zag.rs new file mode 100644 index 0000000..3175b71 --- /dev/null +++ b/crates/wickra-core/src/indicators/zig_zag.rs @@ -0,0 +1,289 @@ +//! `ZigZag` — percentage-threshold swing detector. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// `ZigZag` output: the price of the bar that completed the most recent swing +/// and its direction (`+1.0` for a high swing, `-1.0` for a low swing). +/// +/// The price is the high of the bar at which the high-swing was anchored, or +/// the low of the bar at which the low-swing was anchored — i.e. the actual +/// extreme that the swing turns from, not the bar that triggered confirmation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ZigZagOutput { + /// Price of the confirmed swing extreme. + pub swing: f64, + /// Direction: `+1.0` if the swing is a high, `-1.0` if a low. + pub direction: f64, +} + +/// `ZigZag` — a non-repainting percent-threshold swing detector. Tracks the most +/// recent extreme (high or low) and confirms a reversal once price has moved +/// the configured percentage away from it. +/// +/// ```text +/// uptrend (last swing was a low): +/// while highs make new highs, keep updating the pivot high +/// once close (or low) drops by ≥ threshold·high → confirm pivot high +/// +/// downtrend (last swing was a high): +/// while lows make new lows, keep updating the pivot low +/// once close (or high) rises by ≥ threshold·low → confirm pivot low +/// ``` +/// +/// The indicator emits `Some(swing)` only on the bar where a reversal is +/// confirmed, returning the price and direction of the **just-completed** +/// extreme. Bars between confirmations return `None`. The first bar bootstraps +/// the state — it determines an initial reference price but does not emit. +/// +/// The threshold is a fractional change (`0.05` ≈ 5%); it must be strictly +/// positive and below `1.0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ZigZag}; +/// +/// let mut zz = ZigZag::new(0.10).unwrap(); +/// for (i, p) in [100.0, 105.0, 115.0, 100.0, 90.0, 100.0].iter().enumerate() { +/// let c = Candle::new(*p, *p + 0.5, *p - 0.5, *p, 1.0, i as i64).unwrap(); +/// let _ = zz.update(c); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct ZigZag { + threshold: f64, + state: Option, +} + +#[derive(Debug, Clone, Copy)] +struct State { + /// Direction of the running trend: `+1.0` (uptrend tracking a pivot high) + /// or `-1.0` (downtrend tracking a pivot low). + direction: f64, + /// The current candidate extreme price (the running pivot). + extreme: f64, +} + +impl ZigZag { + /// Construct a new `ZigZag` with a fractional reversal threshold (e.g. `0.05` + /// for a 5% swing). + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `threshold` is not in `(0.0, 1.0)` + /// or is not finite. + pub fn new(threshold: f64) -> Result { + if !threshold.is_finite() || threshold <= 0.0 || threshold >= 1.0 { + return Err(Error::InvalidPeriod { + message: "ZigZag threshold must be a finite fraction in (0, 1)", + }); + } + Ok(Self { + threshold, + state: None, + }) + } + + /// Configured reversal threshold (fractional). + pub const fn threshold(&self) -> f64 { + self.threshold + } +} + +impl Indicator for ZigZag { + type Input = Candle; + type Output = ZigZagOutput; + + fn update(&mut self, candle: Candle) -> Option { + let Some(s) = self.state else { + // Bootstrap: seed an uptrend tracking the first candle's high. + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + }); + return None; + }; + + if s.direction > 0.0 { + // Uptrend: keep raising the candidate high; confirm reversal if + // the candle's low has dropped by threshold from the candidate. + if candle.high > s.extreme { + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + }); + return None; + } + if candle.low <= s.extreme * (1.0 - self.threshold) { + // Confirm the swing high; flip to downtrend tracking this bar's low. + let confirmed = ZigZagOutput { + swing: s.extreme, + direction: 1.0, + }; + self.state = Some(State { + direction: -1.0, + extreme: candle.low, + }); + return Some(confirmed); + } + None + } else { + // Downtrend: lower the candidate low; confirm reversal if the + // candle's high has risen by threshold from the candidate. + if candle.low < s.extreme { + self.state = Some(State { + direction: -1.0, + extreme: candle.low, + }); + return None; + } + if candle.high >= s.extreme * (1.0 + self.threshold) { + let confirmed = ZigZagOutput { + swing: s.extreme, + direction: -1.0, + }; + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + }); + return Some(confirmed); + } + None + } + } + + fn reset(&mut self) { + self.state = None; + } + + fn warmup_period(&self) -> usize { + // Bootstrap takes one bar; confirmation of the first swing needs at + // least one more move past the threshold. Best-case the first swing + // lands on the second bar. + 2 + } + + fn is_ready(&self) -> bool { + self.state.is_some() + } + + fn name(&self) -> &'static str { + "ZigZag" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + + fn c(price: f64, ts: i64) -> Candle { + Candle::new(price, price + 0.001, price - 0.001, price, 1.0, ts).unwrap() + } + + fn c_hl(h: f64, l: f64, ts: i64) -> Candle { + Candle::new(l, h, l, l, 1.0, ts).unwrap() + } + + #[test] + fn rejects_invalid_threshold() { + assert!(ZigZag::new(0.0).is_err()); + assert!(ZigZag::new(-0.1).is_err()); + assert!(ZigZag::new(1.0).is_err()); + assert!(ZigZag::new(f64::NAN).is_err()); + assert!(ZigZag::new(f64::INFINITY).is_err()); + } + + #[test] + fn first_bar_only_bootstraps() { + let mut zz = ZigZag::new(0.05).unwrap(); + assert_eq!(zz.update(c(100.0, 0)), None); + assert!(zz.is_ready()); + } + + #[test] + fn confirms_high_swing_on_threshold_drop() { + let mut zz = ZigZag::new(0.10).unwrap(); + // Up to a peak of 120, then a drop to 100 = 16.7% reversal → confirms. + let _ = zz.update(c_hl(100.0, 99.5, 0)); + let _ = zz.update(c_hl(120.0, 119.5, 1)); + let confirmed = zz.update(c_hl(101.0, 100.0, 2)); + let o = confirmed.expect("the third bar's drop triggers confirmation"); + assert!((o.swing - 120.0).abs() < 1e-9); + assert_eq!(o.direction, 1.0); + } + + #[test] + fn confirms_low_swing_on_threshold_rise() { + let mut zz = ZigZag::new(0.10).unwrap(); + // Up to 120 to seed the high pivot, drop to confirm it as a high, + // then rise from the new low pivot by 10% to confirm it as a low. + let _ = zz.update(c_hl(100.0, 99.5, 0)); + let _ = zz.update(c_hl(120.0, 119.5, 1)); + let _ = zz.update(c_hl(101.0, 90.0, 2)); // drop confirms 120-high; new low 90. + let _ = zz.update(c_hl(91.0, 90.5, 3)); + // Rise to 100 from low 90 = 11.1% → confirms low. + let confirmed = zz.update(c_hl(100.0, 99.0, 4)); + let o = confirmed.expect("the rise confirms the low swing"); + assert!((o.swing - 90.0).abs() < 1e-9); + assert_eq!(o.direction, -1.0); + } + + #[test] + fn small_oscillations_yield_no_swings() { + let mut zz = ZigZag::new(0.20).unwrap(); + let _ = zz.update(c(100.0, 0)); + for i in 1..20 { + // Bounce around 100 ± 5; never crosses the 20% threshold. + let p = 100.0 + ((f64::from(i)) * 0.3).sin() * 5.0; + assert!( + zz.update(c(p, i.into())).is_none(), + "unexpected swing at i={i}" + ); + } + } + + #[test] + fn warmup_and_ready_lifecycle() { + let mut zz = ZigZag::new(0.05).unwrap(); + assert!(!zz.is_ready()); + assert_eq!(zz.warmup_period(), 2); + zz.update(c(100.0, 0)); + assert!(zz.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut zz = ZigZag::new(0.10).unwrap(); + let _ = zz.update(c_hl(100.0, 99.0, 0)); + let _ = zz.update(c_hl(120.0, 119.0, 1)); + zz.reset(); + assert!(!zz.is_ready()); + assert_eq!(zz.update(c_hl(110.0, 109.0, 0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let p = 100.0 + (i as f64 * 0.3).sin() * 15.0; + c(p, i) + }) + .collect(); + let mut a = ZigZag::new(0.05).unwrap(); + let mut b = ZigZag::new(0.05).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn accessors_and_metadata() { + let zz = ZigZag::new(0.05).unwrap(); + assert!((zz.threshold() - 0.05).abs() < 1e-12); + assert_eq!(zz.warmup_period(), 2); + assert_eq!(zz.name(), "ZigZag"); + } +} diff --git a/crates/wickra-core/src/indicators/zlema.rs b/crates/wickra-core/src/indicators/zlema.rs new file mode 100644 index 0000000..8f9599f --- /dev/null +++ b/crates/wickra-core/src/indicators/zlema.rs @@ -0,0 +1,204 @@ +//! Zero-Lag Exponential Moving Average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Zero-Lag Exponential Moving Average (Ehlers & Way). +/// +/// A standard EMA applied to a *de-lagged* price series. The de-lagged input +/// is `2·price_t − price_{t−lag}` with `lag = (period − 1) / 2`; adding that +/// momentum term to the current price cancels most of the EMA's group delay, +/// so the average tracks turns far more tightly than a plain [`Ema`]. +/// +/// The first output lands after exactly `lag + period` inputs: `lag` inputs +/// are needed before the de-lagged series is defined, then `period` de-lagged +/// values seed the inner EMA. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Zlema}; +/// +/// let mut indicator = Zlema::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Zlema { + period: usize, + lag: usize, + /// Rolling buffer of the last `lag + 1` raw inputs, oldest at the front. + window: VecDeque, + ema: Ema, +} + +impl Zlema { + /// Construct a new ZLEMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let lag = (period - 1) / 2; + Ok(Self { + period, + lag, + window: VecDeque::with_capacity(lag + 1), + ema: Ema::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Lag offset `(period − 1) / 2` used to de-lag the price series. + pub const fn lag(&self) -> usize { + self.lag + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.ema.value() + } +} + +impl Indicator for Zlema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.ema.value(); + } + if self.window.len() == self.lag + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.lag + 1 { + return None; + } + let lagged = *self.window.front().expect("window is non-empty"); + let de_lagged = 2.0f64.mul_add(input, -lagged); + self.ema.update(de_lagged) + } + + fn reset(&mut self) { + self.window.clear(); + self.ema.reset(); + } + + fn warmup_period(&self) -> usize { + self.lag + self.period + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "ZLEMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Zlema::new(0), Err(Error::PeriodZero))); + } + + /// Cover the const accessors `period` / `value` (62-64, 72-74) and + /// the Indicator-impl `name` body (111-113). `lag` is already covered + /// by `lag_is_half_of_period_minus_one`. + #[test] + fn accessors_and_metadata() { + let mut z = Zlema::new(5).unwrap(); + assert_eq!(z.period(), 5); + assert_eq!(z.name(), "ZLEMA"); + assert_eq!(z.value(), None); + for i in 1..=z.warmup_period() { + z.update(f64::from(u32::try_from(i).unwrap())); + } + assert!(z.value().is_some()); + } + + #[test] + fn lag_is_half_of_period_minus_one() { + assert_eq!(Zlema::new(3).unwrap().lag(), 1); + assert_eq!(Zlema::new(10).unwrap().lag(), 4); + assert_eq!(Zlema::new(1).unwrap().lag(), 0); + } + + #[test] + fn reference_values() { + // ZLEMA(3): lag = 1, de_lagged_t = 2·xt − x_{t-1}, then EMA(3). + // [1,2,3,4,5] -> de-lagged [_, 3, 4, 5, 6]; EMA(3) seeds at the third + // de-lagged value: mean(3,4,5) = 4.0; next = 0.5·6 + 0.5·4 = 5.0. + let mut zlema = Zlema::new(3).unwrap(); + let out = zlema.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_eq!(zlema.warmup_period(), 4); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_eq!(out[2], None); + assert_relative_eq!(out[3].unwrap(), 4.0, epsilon = 1e-12); + assert_relative_eq!(out[4].unwrap(), 5.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_the_constant() { + // De-lagging a constant gives the same constant (2c − c = c). + let mut zlema = Zlema::new(7).unwrap(); + let out = zlema.batch(&[33.0; 60]); + for x in out.iter().skip(zlema.warmup_period() - 1).flatten() { + assert_relative_eq!(*x, 33.0, epsilon = 1e-9); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut zlema = Zlema::new(3).unwrap(); + let out = zlema.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = out[4]; + assert!(last.is_some()); + assert_eq!(zlema.update(f64::NAN), last); + assert_eq!(zlema.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut zlema = Zlema::new(5).unwrap(); + zlema.batch(&(1..=40).map(f64::from).collect::>()); + assert!(zlema.is_ready()); + zlema.reset(); + assert!(!zlema.is_ready()); + assert_eq!(zlema.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let batch = Zlema::new(9).unwrap().batch(&prices); + let mut b = Zlema::new(9).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs new file mode 100644 index 0000000..5e36310 --- /dev/null +++ b/crates/wickra-core/src/lib.rs @@ -0,0 +1,186 @@ +//! `wickra-core`: streaming-first technical indicators. +//! +//! The core engine of Wickra. Every indicator is implemented as a state machine +//! that consumes inputs one at a time via [`Indicator::update`] in constant time. +//! Batch evaluation is provided as a blanket extension trait so the same code +//! path serves both online (tick-by-tick) and offline (historical) workloads. +//! +//! # Design +//! +//! - **Streaming-first.** State is held by the indicator instance, so a new value +//! only re-computes deltas, not the whole series. +//! - **Batch is free.** [`BatchExt::batch`] is a blanket implementation that +//! simply replays `update` over a slice. Writing one implementation gives both +//! APIs. +//! - **Composable.** Indicators implement [`Indicator`] +//! wherever they conceptually take a price, so they can be chained via +//! [`Chain`]. +//! - **No `unsafe`.** The crate forbids `unsafe_code` in the workspace lints. +//! +//! # Quick start +//! +//! ``` +//! use wickra_core::{BatchExt, Indicator, Sma}; +//! +//! // Streaming: +//! let mut sma = Sma::new(3).unwrap(); +//! assert_eq!(sma.update(1.0), None); +//! assert_eq!(sma.update(2.0), None); +//! assert_eq!(sma.update(3.0), Some(2.0)); +//! +//! // Batch (replays `update` internally): +//! let mut sma = Sma::new(3).unwrap(); +//! let out = sma.batch(&[1.0, 2.0, 3.0, 4.0]); +//! assert_eq!(out, vec![None, None, Some(2.0), Some(3.0)]); +//! ``` + +#![cfg_attr(docsrs, feature(doc_cfg))] +// The libtest harness collects every `#[test]` into a compiler-generated array +// of test references. With 2000+ unit tests that array exceeds clippy's 16 KB +// `large_stack_arrays` threshold; the diagnostic is spanless libtest scaffolding, +// not our code, so it cannot be silenced at a call site. Suppress it only in test +// builds — library code is still linted for genuinely large stack arrays. +#![cfg_attr(test, allow(clippy::large_stack_arrays))] + +mod calendar; +mod cross_section; +mod derivatives; +mod error; +mod microstructure; +mod ohlcv; +mod traits; + +pub mod indicators; + +pub use cross_section::{CrossSection, Member}; +pub use derivatives::DerivativesTick; +pub use error::{Error, Result}; +pub use indicators::DollarBar; +pub use indicators::ImbalanceBar; +pub use indicators::LineBreakBar; +pub use indicators::RangeBar; +pub use indicators::RunBar; +pub use indicators::TickBar; +pub use indicators::VolumeBar; +pub use indicators::{ + AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput, + AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCci, AdaptiveCycle, + AdaptiveLaguerreFilter, AdaptiveRsi, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, + Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, + AnchoredVwap, AndrewsPitchfork, AndrewsPitchforkOutput, Apo, Aroon, AroonOscillator, + AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput, AtrTrailingStop, + AutoFib, AutoFibOutput, Autocorrelation, AutocorrelationPeriodogram, AverageDailyRange, + AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, + BandpassFilter, Bat, BeltHold, Beta, BetaNeutralSpread, BetterVolume, BipowerVariation, + BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, + 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, + 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, DollarBars, 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, + HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighLowVolumeNodes, + HighLowVolumeNodesOutput, HighWave, HighpassFilter, Hikkake, HikkakeModified, + HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, + HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, + Ichimoku, IchimokuOutput, IdenticalThreeCrows, ImbalanceBars, InNeck, Inertia, + InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, + IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, + IntradayVolatilityProfileOutput, 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, 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, + PercentB, PercentageTrailingStop, PerpetualPremiumIndex, Pgo, PiercingDarkCloud, Pin, + PivotReversal, PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo, + PpoHistogram, ProfileShape, ProfitFactor, ProjectionBands, ProjectionBandsOutput, + ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, + QuotedSpread, RSquared, RangeBars, RealizedSpread, RealizedVolatility, RecoveryFactor, + RectangleRange, Reflex, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, + RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, + RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, + RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, + Rsx, RunBars, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy, SarExt, SeasonalZScore, + SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, + SessionVwap, ShannonEntropy, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, + SineWave, SineWeightedMa, SinglePrints, Skewness, Sma, Smi, Smma, SmoothedHeikinAshi, + SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, + SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, + StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, + 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, ThreeLineBreakBars, ThreeLineStrike, ThreeOutside, + ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickBars, TickIndex, Tii, TimeBasedStop, + TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TowerTopBottom, TpoProfile, + TpoProfileOutput, TradeImbalance, TradeSignAutocorrelation, TradeVolumeIndex, TrendLabel, + TrendStrengthIndex, Trendflex, 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, UpsidePotentialRatio, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, + VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, + VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeBars, 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 +// strips only `*Output` companions) does not count it as a separate indicator. +pub use indicators::FootprintLevel; +// `MaType` is a moving-average selector enum used by `MacdExt`, re-exported on +// its own line so the indicator-count tooling does not count it as an indicator. +pub use indicators::MaType; +// Bar element types for the alt-chart builders, re-exported on their own lines so +// the indicator-count tooling (which scans only the braced block above) does not +// count them as separate indicators. +pub use indicators::KagiBar; +pub use indicators::PnfColumn; +pub use indicators::RenkoBrick; +pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote}; +pub use ohlcv::{Candle, Tick}; +pub use traits::{BarBuilder, BatchExt, BatchNanExt, Chain, Indicator}; diff --git a/crates/wickra-core/src/microstructure.rs b/crates/wickra-core/src/microstructure.rs new file mode 100644 index 0000000..5f4e8cb --- /dev/null +++ b/crates/wickra-core/src/microstructure.rs @@ -0,0 +1,467 @@ +//! Microstructure value types: order-book snapshots and trades. +//! +//! These are the non-OHLCV inputs consumed by the order-book / trade-flow +//! indicator family. An [`OrderBook`] is a depth snapshot (sorted bid and ask +//! levels); a [`Trade`] is a single executed trade with an aggressor [`Side`]; +//! a [`TradeQuote`] pairs a trade with the mid-price prevailing at execution, +//! the input for spread- and price-impact measures. + +use crate::error::{Error, Result}; + +/// A single order-book price level: a resting quantity at a price. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Level { + /// Price of the level (strictly positive). + pub price: f64, + /// Resting size / quantity at this price (non-negative). + pub size: f64, +} + +impl Level { + /// Construct a level, validating that `price` is finite and strictly + /// positive and `size` is finite and non-negative. + /// + /// # Errors + /// + /// Returns [`Error::InvalidOrderBook`] if the price is not a finite + /// positive number, or the size is not a finite non-negative number. + pub fn new(price: f64, size: f64) -> Result { + if !price.is_finite() || price <= 0.0 { + return Err(Error::InvalidOrderBook { + message: "level price must be finite and positive", + }); + } + if !size.is_finite() || size < 0.0 { + return Err(Error::InvalidOrderBook { + message: "level size must be finite and non-negative", + }); + } + Ok(Self { price, size }) + } + + /// Construct a level without validation. The caller asserts that `price` + /// is finite and positive and `size` is finite and non-negative. + pub const fn new_unchecked(price: f64, size: f64) -> Self { + Self { price, size } + } +} + +/// An order-book depth snapshot. +/// +/// Bids are stored best-first (strictly descending price); asks are stored +/// best-first (strictly ascending price). A valid book is non-empty on both +/// sides and uncrossed (`best_bid < best_ask`). +#[derive(Debug, Clone, PartialEq)] +pub struct OrderBook { + /// Bid levels, best (highest price) first. + pub bids: Vec, + /// Ask levels, best (lowest price) first. + pub asks: Vec, +} + +impl OrderBook { + /// Construct an order book, validating the level and ordering invariants. + /// + /// # Errors + /// + /// Returns [`Error::InvalidOrderBook`] if either side is empty, any level + /// has a non-finite/non-positive price or non-finite/negative size, the + /// bids are not strictly descending in price, the asks are not strictly + /// ascending in price, or the book is crossed/locked (`best_bid >= + /// best_ask`). + pub fn new(bids: Vec, asks: Vec) -> Result { + if bids.is_empty() || asks.is_empty() { + return Err(Error::InvalidOrderBook { + message: "order book must have at least one bid and one ask", + }); + } + for level in bids.iter().chain(asks.iter()) { + if !level.price.is_finite() || level.price <= 0.0 { + return Err(Error::InvalidOrderBook { + message: "level price must be finite and positive", + }); + } + if !level.size.is_finite() || level.size < 0.0 { + return Err(Error::InvalidOrderBook { + message: "level size must be finite and non-negative", + }); + } + } + for pair in bids.windows(2) { + if pair[0].price <= pair[1].price { + return Err(Error::InvalidOrderBook { + message: "bids must be strictly descending in price", + }); + } + } + for pair in asks.windows(2) { + if pair[0].price >= pair[1].price { + return Err(Error::InvalidOrderBook { + message: "asks must be strictly ascending in price", + }); + } + } + if bids[0].price >= asks[0].price { + return Err(Error::InvalidOrderBook { + message: "order book must be uncrossed (best_bid < best_ask)", + }); + } + Ok(Self { bids, asks }) + } + + /// Construct an order book without validation. The caller asserts that all + /// level and ordering invariants hold. + pub const fn new_unchecked(bids: Vec, asks: Vec) -> Self { + Self { bids, asks } + } + + /// The best (highest-price) bid level, or `None` if the bid side is empty. + pub fn best_bid(&self) -> Option { + self.bids.first().copied() + } + + /// The best (lowest-price) ask level, or `None` if the ask side is empty. + pub fn best_ask(&self) -> Option { + self.asks.first().copied() + } + + /// The mid price `(best_bid + best_ask) / 2`, or `None` if either side is + /// empty. + pub fn mid(&self) -> Option { + match (self.best_bid(), self.best_ask()) { + (Some(bid), Some(ask)) => Some(f64::midpoint(bid.price, ask.price)), + _ => None, + } + } +} + +/// The aggressor side of a trade: the side that crossed the spread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + /// A buyer-initiated (aggressive buy) trade. + Buy, + /// A seller-initiated (aggressive sell) trade. + Sell, +} + +impl Side { + /// The signed multiplier for this side: `+1.0` for a buy, `−1.0` for a + /// sell. + pub const fn sign(self) -> f64 { + match self { + Side::Buy => 1.0, + Side::Sell => -1.0, + } + } +} + +/// A single executed trade with an aggressor side. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Trade { + /// Execution price (strictly positive). + pub price: f64, + /// Executed size / quantity (non-negative). + pub size: f64, + /// Aggressor side. + pub side: Side, + /// Trade timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl Trade { + /// Construct a trade, validating that `price` is finite and strictly + /// positive and `size` is finite and non-negative. + /// + /// # Errors + /// + /// Returns [`Error::InvalidTrade`] if the price is not a finite positive + /// number, or the size is not a finite non-negative number. + pub fn new(price: f64, size: f64, side: Side, timestamp: i64) -> Result { + if !price.is_finite() || price <= 0.0 { + return Err(Error::InvalidTrade { + message: "trade price must be finite and positive", + }); + } + if !size.is_finite() || size < 0.0 { + return Err(Error::InvalidTrade { + message: "trade size must be finite and non-negative", + }); + } + Ok(Self { + price, + size, + side, + timestamp, + }) + } + + /// Construct a trade without validation. The caller asserts that `price` + /// is finite and positive and `size` is finite and non-negative. + pub const fn new_unchecked(price: f64, size: f64, side: Side, timestamp: i64) -> Self { + Self { + price, + size, + side, + timestamp, + } + } +} + +/// A trade paired with the mid-price prevailing at execution. +/// +/// This is the input for spread- and price-impact measures (effective spread, +/// realized spread, Kyle's lambda), which relate an executed trade to the +/// quote it traded against. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TradeQuote { + /// The executed trade. + pub trade: Trade, + /// The mid-price prevailing at execution (strictly positive). + pub mid: f64, +} + +impl TradeQuote { + /// Construct a trade-quote, validating that `mid` is finite and strictly + /// positive. The `trade` is assumed already valid. + /// + /// # Errors + /// + /// Returns [`Error::InvalidTrade`] if `mid` is not a finite positive + /// number. + pub fn new(trade: Trade, mid: f64) -> Result { + if !mid.is_finite() || mid <= 0.0 { + return Err(Error::InvalidTrade { + message: "trade-quote mid must be finite and positive", + }); + } + Ok(Self { trade, mid }) + } + + /// Construct a trade-quote without validation. The caller asserts that + /// `mid` is finite and positive. + pub const fn new_unchecked(trade: Trade, mid: f64) -> Self { + Self { trade, mid } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn level_new_accepts_valid() { + let level = Level::new(100.5, 2.0).unwrap(); + assert_eq!(level.price, 100.5); + assert_eq!(level.size, 2.0); + } + + #[test] + fn level_new_accepts_zero_size() { + assert!(Level::new(100.0, 0.0).is_ok()); + } + + #[test] + fn level_new_rejects_non_finite_price() { + assert!(matches!( + Level::new(f64::NAN, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + Level::new(f64::INFINITY, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn level_new_rejects_non_positive_price() { + assert!(matches!( + Level::new(0.0, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + Level::new(-1.0, 1.0), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn level_new_rejects_bad_size() { + assert!(matches!( + Level::new(100.0, -1.0), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + Level::new(100.0, f64::NAN), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn level_new_unchecked_preserves_fields() { + let level = Level::new_unchecked(-5.0, -2.0); + assert_eq!(level.price, -5.0); + assert_eq!(level.size, -2.0); + } + + fn lvl(price: f64, size: f64) -> Level { + Level::new(price, size).unwrap() + } + + #[test] + fn order_book_new_accepts_valid() { + let book = OrderBook::new( + vec![lvl(100.0, 2.0), lvl(99.0, 3.0)], + vec![lvl(101.0, 1.0), lvl(102.0, 4.0)], + ) + .unwrap(); + assert_eq!(book.best_bid(), Some(lvl(100.0, 2.0))); + assert_eq!(book.best_ask(), Some(lvl(101.0, 1.0))); + assert_eq!(book.mid(), Some(100.5)); + } + + #[test] + fn order_book_new_rejects_empty_side() { + assert!(matches!( + OrderBook::new(vec![], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + OrderBook::new(vec![lvl(100.0, 1.0)], vec![]), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_bad_level() { + assert!(matches!( + OrderBook::new( + vec![Level::new_unchecked(100.0, -1.0)], + vec![lvl(101.0, 1.0)] + ), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + OrderBook::new( + vec![lvl(100.0, 1.0)], + vec![Level::new_unchecked(f64::NAN, 1.0)] + ), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_misordered_bids() { + assert!(matches!( + OrderBook::new(vec![lvl(99.0, 1.0), lvl(100.0, 1.0)], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_misordered_asks() { + assert!(matches!( + OrderBook::new( + vec![lvl(100.0, 1.0)], + vec![lvl(102.0, 1.0), lvl(101.0, 1.0)] + ), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_rejects_crossed() { + assert!(matches!( + OrderBook::new(vec![lvl(101.0, 1.0)], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + assert!(matches!( + OrderBook::new(vec![lvl(102.0, 1.0)], vec![lvl(101.0, 1.0)]), + Err(Error::InvalidOrderBook { .. }) + )); + } + + #[test] + fn order_book_new_unchecked_allows_empty() { + let book = OrderBook::new_unchecked(vec![], vec![]); + assert_eq!(book.best_bid(), None); + assert_eq!(book.best_ask(), None); + assert_eq!(book.mid(), None); + } + + #[test] + fn side_sign() { + assert_eq!(Side::Buy.sign(), 1.0); + assert_eq!(Side::Sell.sign(), -1.0); + } + + #[test] + fn trade_new_accepts_valid() { + let trade = Trade::new(100.0, 1.5, Side::Buy, 42).unwrap(); + assert_eq!(trade.price, 100.0); + assert_eq!(trade.size, 1.5); + assert_eq!(trade.side, Side::Buy); + assert_eq!(trade.timestamp, 42); + } + + #[test] + fn trade_new_rejects_bad_price() { + assert!(matches!( + Trade::new(0.0, 1.0, Side::Buy, 0), + Err(Error::InvalidTrade { .. }) + )); + assert!(matches!( + Trade::new(f64::NAN, 1.0, Side::Sell, 0), + Err(Error::InvalidTrade { .. }) + )); + } + + #[test] + fn trade_new_rejects_bad_size() { + assert!(matches!( + Trade::new(100.0, -1.0, Side::Buy, 0), + Err(Error::InvalidTrade { .. }) + )); + assert!(matches!( + Trade::new(100.0, f64::INFINITY, Side::Buy, 0), + Err(Error::InvalidTrade { .. }) + )); + } + + #[test] + fn trade_new_unchecked_preserves_fields() { + let trade = Trade::new_unchecked(-1.0, -2.0, Side::Sell, 7); + assert_eq!(trade.price, -1.0); + assert_eq!(trade.size, -2.0); + assert_eq!(trade.side, Side::Sell); + assert_eq!(trade.timestamp, 7); + } + + #[test] + fn trade_quote_new_accepts_valid() { + let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap(); + let tq = TradeQuote::new(trade, 99.5).unwrap(); + assert_eq!(tq.trade, trade); + assert_eq!(tq.mid, 99.5); + } + + #[test] + fn trade_quote_new_rejects_bad_mid() { + let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap(); + assert!(matches!( + TradeQuote::new(trade, 0.0), + Err(Error::InvalidTrade { .. }) + )); + assert!(matches!( + TradeQuote::new(trade, f64::NAN), + Err(Error::InvalidTrade { .. }) + )); + } + + #[test] + fn trade_quote_new_unchecked_preserves_fields() { + let trade = Trade::new_unchecked(100.0, 1.0, Side::Buy, 0); + let tq = TradeQuote::new_unchecked(trade, -1.0); + assert_eq!(tq.mid, -1.0); + assert_eq!(tq.trade, trade); + } +} diff --git a/crates/wickra-core/src/ohlcv.rs b/crates/wickra-core/src/ohlcv.rs new file mode 100644 index 0000000..168f76f --- /dev/null +++ b/crates/wickra-core/src/ohlcv.rs @@ -0,0 +1,327 @@ +//! OHLCV value types: candles and ticks. + +use crate::error::{Error, Result}; + +/// A single OHLCV bar. +/// +/// Timestamps are unitless `i64` values so callers can use whatever epoch resolution +/// they prefer (milliseconds, microseconds, seconds…). Wickra never inspects them +/// numerically beyond passing them through. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Candle { + /// Bar open price. + pub open: f64, + /// Bar high price. + pub high: f64, + /// Bar low price. + pub low: f64, + /// Bar close price. + pub close: f64, + /// Bar volume. + pub volume: f64, + /// Bar timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl Candle { + /// Construct a new candle, validating the OHLC relationships and finiteness. + /// + /// # Errors + /// + /// Returns [`Error::InvalidCandle`] if any of these invariants are violated: + /// - `high >= max(open, close, low)` + /// - `low <= min(open, close, high)` + /// - all of `open`, `high`, `low`, `close`, `volume` are finite + /// - `volume >= 0` + pub fn new( + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, + timestamp: i64, + ) -> Result { + if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) { + return Err(Error::InvalidCandle { + message: "open, high, low, close must all be finite", + }); + } + if !volume.is_finite() { + return Err(Error::InvalidCandle { + message: "volume must be finite", + }); + } + if volume < 0.0 { + return Err(Error::InvalidCandle { + message: "volume must be non-negative", + }); + } + if high < low { + return Err(Error::InvalidCandle { + message: "high must be >= low", + }); + } + if high < open || high < close { + return Err(Error::InvalidCandle { + message: "high must be >= open and >= close", + }); + } + if low > open || low > close { + return Err(Error::InvalidCandle { + message: "low must be <= open and <= close", + }); + } + Ok(Self { + open, + high, + low, + close, + volume, + timestamp, + }) + } + + /// Construct a candle without validation. The caller asserts that all OHLC + /// invariants hold and that no field is NaN or infinite. + pub const fn new_unchecked( + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, + timestamp: i64, + ) -> Self { + Self { + open, + high, + low, + close, + volume, + timestamp, + } + } + + /// The typical price `(high + low + close) / 3`. Used by CCI, MFI, VWAP, etc. + #[inline] + pub fn typical_price(&self) -> f64 { + (self.high + self.low + self.close) / 3.0 + } + + /// The mid price `(high + low) / 2`. + #[inline] + pub fn median_price(&self) -> f64 { + f64::midpoint(self.high, self.low) + } + + /// The weighted close `(high + low + 2*close) / 4`. + #[inline] + pub fn weighted_close(&self) -> f64 { + (self.high + self.low + 2.0 * self.close) / 4.0 + } + + /// The average price `(open + high + low + close) / 4`. + #[inline] + pub fn avg_price(&self) -> f64 { + (self.open + self.high + self.low + self.close) / 4.0 + } + + /// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`. + /// If no previous close is supplied, falls back to `high - low`. + #[inline] + pub fn true_range(&self, prev_close: Option) -> f64 { + let hl = self.high - self.low; + match prev_close { + Some(prev) => { + let hp = (self.high - prev).abs(); + let lp = (self.low - prev).abs(); + hl.max(hp).max(lp) + } + None => hl, + } + } +} + +/// A single trade tick. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Tick { + /// Trade price. + pub price: f64, + /// Trade size. + pub volume: f64, + /// Trade timestamp (caller-defined epoch / resolution). + pub timestamp: i64, +} + +impl Tick { + /// Construct a new tick, validating finiteness and non-negativity of volume. + /// + /// # Errors + /// + /// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite, + /// or [`Error::InvalidTick`] for `volume < 0`. (Audit finding R14 — previously + /// returned [`Error::InvalidCandle`], which is semantically wrong for a tick.) + pub fn new(price: f64, volume: f64, timestamp: i64) -> Result { + if !price.is_finite() || !volume.is_finite() { + return Err(Error::NonFiniteInput); + } + if volume < 0.0 { + return Err(Error::InvalidTick { + message: "tick volume must be non-negative", + }); + } + Ok(Self { + price, + volume, + timestamp, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candle_new_accepts_valid_ohlc() { + let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap(); + assert_eq!(c.open, 10.0); + assert_eq!(c.high, 11.0); + assert_eq!(c.low, 9.0); + assert_eq!(c.close, 10.5); + assert_eq!(c.volume, 100.0); + assert_eq!(c.timestamp, 1); + } + + #[test] + fn candle_new_rejects_high_below_low() { + let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidCandle { .. })); + } + + #[test] + fn candle_new_rejects_high_below_close() { + let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidCandle { .. })); + } + + #[test] + fn candle_new_rejects_low_above_open() { + let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidCandle { .. })); + } + + #[test] + fn candle_new_rejects_negative_volume() { + let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidCandle { .. })); + } + + #[test] + fn candle_new_rejects_nan_price() { + let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidCandle { .. })); + } + + /// Cover the unchecked constructor `Candle::new_unchecked` (lines 86-102). + /// Every existing test routes through the validating `Candle::new`, so the + /// unchecked path is dead. + /// + /// The first assertion shows that a valid set of fields round-trips + /// verbatim. The second feeds `high < low` (which `Candle::new` would + /// reject with `Error::InvalidCandle`) and asserts the unchecked + /// constructor still produces the struct as-is — documenting and + /// enforcing the API contract that the unchecked variant performs no + /// validation and is the caller's responsibility. + #[test] + fn candle_new_unchecked_preserves_fields_verbatim() { + let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42); + assert_eq!(c.open, 1.0); + assert_eq!(c.high, 2.0); + assert_eq!(c.low, 0.5); + assert_eq!(c.close, 1.5); + assert_eq!(c.volume, 100.0); + assert_eq!(c.timestamp, 42); + + // Skip-validation contract: an OHLC combination that the checked + // constructor rejects (high < low) is still built without error. + assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err()); + let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0); + assert_eq!(unchecked.high, 9.0); + assert_eq!(unchecked.low, 10.0); + } + + #[test] + fn candle_typical_price() { + let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap(); + assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0); + } + + #[test] + fn candle_median_price() { + let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap(); + assert_eq!(c.median_price(), 10.0); + } + + #[test] + fn candle_weighted_close() { + let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap(); + assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0); + } + + #[test] + fn candle_true_range_without_prev() { + let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap(); + assert_eq!(c.true_range(None), 4.0); + } + + #[test] + fn candle_true_range_with_gap_up() { + // Previous close 6, today's range 8-12: gap covered by |H-prev|=6 + let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap(); + assert_eq!(c.true_range(Some(6.0)), 6.0); + } + + #[test] + fn candle_true_range_with_gap_down() { + // Previous close 14, today's range 8-12: gap covered by |L-prev|=6 + let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap(); + assert_eq!(c.true_range(Some(14.0)), 6.0); + } + + #[test] + fn tick_new_accepts_valid() { + let t = Tick::new(100.5, 0.5, 42).unwrap(); + assert_eq!(t.price, 100.5); + assert_eq!(t.volume, 0.5); + assert_eq!(t.timestamp, 42); + } + + #[test] + fn tick_new_rejects_nan() { + assert!(matches!( + Tick::new(f64::NAN, 1.0, 0), + Err(Error::NonFiniteInput) + )); + } + + #[test] + fn tick_new_rejects_inf() { + assert!(matches!( + Tick::new(f64::INFINITY, 1.0, 0), + Err(Error::NonFiniteInput) + )); + } + + #[test] + fn tick_new_rejects_negative_volume() { + // Audit R14: the variant is `InvalidTick`, not `InvalidCandle` — a tick + // is not a candle, and downstream pipelines should be able to match on + // the correct semantic. + let err = Tick::new(100.0, -1.0, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidTick { .. })); + assert!( + err.to_string().contains("tick volume"), + "expected the InvalidTick message in the formatted error, got {err}" + ); + } +} diff --git a/crates/wickra-core/src/traits.rs b/crates/wickra-core/src/traits.rs new file mode 100644 index 0000000..39788fb --- /dev/null +++ b/crates/wickra-core/src/traits.rs @@ -0,0 +1,423 @@ +//! Core traits: the [`Indicator`] state machine and the [`BatchExt`] blanket extension. + +use crate::ohlcv::Candle; + +/// A streaming technical indicator. +/// +/// Every indicator in Wickra implements this trait. The contract is: +/// +/// - [`update`](Indicator::update) is called once per input point and must be O(1) in +/// the input length. Pre-existing buffered state may be touched, but no full +/// recomputation over the entire series is permitted. +/// - The returned `Option` is `None` while the indicator is still in its +/// *warmup* phase (insufficient inputs to produce a defined value), and `Some` +/// once it is ready. +/// - [`reset`](Indicator::reset) clears all state, returning the indicator to the +/// exact configuration it had immediately after construction. +/// +/// Implementors that consume scalar prices use `Input = f64` so they automatically +/// gain access to chaining via [`Chain`]. +pub trait Indicator { + /// Type of one input data point (typically `f64` for a price, or `Candle` / `Tick`). + type Input; + /// Type of one output value. + type Output; + + /// Feed one new data point into the indicator and return the freshly computed + /// output, or `None` if the indicator is still warming up. + fn update(&mut self, input: Self::Input) -> Option; + + /// Reset all internal state, leaving the indicator equivalent to a freshly + /// constructed instance with the same parameters. + fn reset(&mut self); + + /// Number of inputs required before the first non-`None` output can be produced. + fn warmup_period(&self) -> usize; + + /// Whether the indicator has emitted at least one value since the last reset. + fn is_ready(&self) -> bool; + + /// Stable, human-readable indicator name. Used by chaining and diagnostics. + fn name(&self) -> &'static str; +} + +/// Blanket extension that adds batch evaluation to every [`Indicator`]. +/// +/// The naive `batch` simply replays `update` over a slice, which is always correct +/// because `update` is the only state transition. Concrete indicators may override +/// `batch` if they have a faster vectorized path; the default keeps the contract +/// `batch == repeated update`. +pub trait BatchExt: Indicator { + /// Run the indicator over a slice of inputs in order, returning one output (or + /// `None` during warmup) per input. + fn batch(&mut self, inputs: &[Self::Input]) -> Vec> + where + Self::Input: Clone, + { + let mut out = Vec::with_capacity(inputs.len()); + for x in inputs { + out.push(self.update(x.clone())); + } + out + } + + /// Run an independent copy of the indicator over each input series in parallel. + /// + /// Each asset is processed by its own fresh instance built via `make`, so state + /// never leaks across assets. Requires the `parallel` feature (enabled by + /// default), which pulls in `rayon`. + #[cfg(feature = "parallel")] + fn batch_parallel( + inputs_per_asset: &[Vec], + make: F, + ) -> Vec>> + where + Self: Sized + Send, + Self::Input: Sync + Clone, + Self::Output: Send, + F: Fn() -> Self + Sync + Send, + { + use rayon::prelude::*; + inputs_per_asset + .par_iter() + .map(|series| { + let mut ind = make(); + ind.batch(series) + }) + .collect() + } +} + +impl BatchExt for T {} + +/// Fast batch for scalar `f64 -> f64` indicators. +/// +/// The generic [`BatchExt::batch`] returns `Vec>` — 16 bytes per +/// element (no niche fits an arbitrary `f64`), which a caller wanting a dense +/// `f64` series then has to walk a second time to map warmup `None`s to `NaN`. +/// This skips both the wide intermediate and the second pass: one allocation, +/// one pass, warmup encoded as `NaN`. The default body is bit-identical to +/// replaying `update`; indicators with a vectorizable closed form override it +/// with an inherent `batch_nan` of the same name, which wins method resolution +/// over this trait default. +pub trait BatchNanExt: Indicator { + /// One `f64` per input, warmup positions filled with `NaN`. + fn batch_nan(&mut self, inputs: &[f64]) -> Vec { + let mut out = Vec::with_capacity(inputs.len()); + for &x in inputs { + out.push(self.update(x).unwrap_or(f64::NAN)); + } + out + } +} + +impl> BatchNanExt for T {} + +/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi, +/// Point-and-Figure) that turns a candle stream into a stream of price-driven +/// bars. +/// +/// Bar builders are deliberately **not** [`Indicator`]s: a single input candle +/// may complete zero, one, or many bars (a large move can print several Renko +/// bricks at once), which breaks the `update -> Option` one-in-one-out +/// contract and the `batch == repeated update` length invariant. They get their +/// own trait instead, returning a `Vec` of freshly completed bars per candle. +/// +/// The contract is: +/// +/// - [`update`](BarBuilder::update) ingests one candle and returns every bar it +/// *completed* on that candle, in chronological order. An empty vector means +/// the move was not large enough to finish a bar yet. +/// - [`reset`](BarBuilder::reset) clears all state, returning the builder to the +/// configuration it had immediately after construction. +/// - [`batch`](BarBuilder::batch) concatenates the bars from replaying `update` +/// over a slice; the flattened length is data-dependent, not the input length. +/// +/// Bar builders cannot participate in [`Chain`] (which requires +/// `Indicator`); feed a downstream indicator from the +/// bars' close prices manually if you need to chain off them. +/// +/// ```text +/// let mut renko = RenkoBars::new(1.0).unwrap(); +/// let bricks = renko.update(candle); // Vec: 0..n completed bricks +/// ``` +pub trait BarBuilder { + /// Type of one completed bar. + type Bar; + + /// Feed one candle and return every bar completed on it (possibly none). + fn update(&mut self, candle: Candle) -> Vec; + + /// Reset all internal state to the freshly-constructed configuration. + fn reset(&mut self); + + /// Stable, human-readable builder name. + fn name(&self) -> &'static str; + + /// Replay `update` over a slice, concatenating all completed bars. The + /// result length is data-dependent (not the input length). + fn batch(&mut self, candles: &[Candle]) -> Vec { + let mut out = Vec::new(); + for candle in candles { + out.extend(self.update(*candle)); + } + out + } +} + +/// Chain two indicators so the output of the first becomes the input of the second. +/// +/// Both indicators must agree on `f64` as the bridging type, which is the common +/// case for price-in/value-out indicators. The chain itself is an indicator, so +/// chains can be nested arbitrarily. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Chain, Ema, Indicator, Rsi}; +/// +/// // RSI(7) on top of EMA(14). EMA seeds at input 14, then RSI needs 7+1 more +/// // valid inputs to emit, so the chain becomes ready at input 21. +/// let mut chain = Chain::new(Ema::new(14).unwrap(), Rsi::new(7).unwrap()); +/// for i in 1..=21 { +/// chain.update(f64::from(i)); +/// } +/// assert!(chain.is_ready()); +/// ``` +#[derive(Debug, Clone)] +pub struct Chain +where + A: Indicator, + B: Indicator, +{ + first: A, + second: B, +} + +impl Chain +where + A: Indicator, + B: Indicator, +{ + /// Construct a chain whose inputs flow through `first` and then `second`. + pub const fn new(first: A, second: B) -> Self { + Self { first, second } + } + + /// Add a third stage on top. + pub fn then(self, third: C) -> Chain + where + C: Indicator, + Self: Indicator, + { + Chain::new(self, third) + } + + /// Borrow the upstream indicator. + pub const fn first(&self) -> &A { + &self.first + } + + /// Borrow the downstream indicator. + pub const fn second(&self) -> &B { + &self.second + } +} + +impl Indicator for Chain +where + A: Indicator, + B: Indicator, +{ + type Input = f64; + type Output = B::Output; + + fn update(&mut self, input: f64) -> Option { + self.first.update(input).and_then(|v| self.second.update(v)) + } + + fn reset(&mut self) { + self.first.reset(); + self.second.reset(); + } + + fn warmup_period(&self) -> usize { + // Conservative upper bound: both stages must warm up. + self.first.warmup_period() + self.second.warmup_period() + } + + fn is_ready(&self) -> bool { + self.first.is_ready() && self.second.is_ready() + } + + fn name(&self) -> &'static str { + "Chain" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A trivial test indicator: identity (passes input through). + #[derive(Debug, Default)] + struct Identity { + seen: bool, + } + + impl Indicator for Identity { + type Input = f64; + type Output = f64; + fn update(&mut self, input: f64) -> Option { + self.seen = true; + Some(input) + } + fn reset(&mut self) { + self.seen = false; + } + fn warmup_period(&self) -> usize { + 0 + } + fn is_ready(&self) -> bool { + self.seen + } + fn name(&self) -> &'static str { + "Identity" + } + } + + /// Another trivial test indicator: scales input by 2. + #[derive(Debug, Default)] + struct Doubler { + seen: bool, + } + + impl Indicator for Doubler { + type Input = f64; + type Output = f64; + fn update(&mut self, input: f64) -> Option { + self.seen = true; + Some(input * 2.0) + } + fn reset(&mut self) { + self.seen = false; + } + fn warmup_period(&self) -> usize { + 0 + } + fn is_ready(&self) -> bool { + self.seen + } + fn name(&self) -> &'static str { + "Doubler" + } + } + + #[test] + fn batch_replays_update() { + let mut id = Identity::default(); + let out = id.batch(&[1.0, 2.0, 3.0]); + assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]); + } + + /// The blanket [`BatchNanExt::batch_nan`] default (used by every scalar + /// indicator without an inherent fast path) maps `update` outputs to a dense + /// `f64` series, warmup `None` becoming `NaN`. `Identity` is always ready, so + /// the result is just the inputs back. + #[test] + fn batch_nan_default_maps_none_to_nan() { + let mut id = Identity::default(); + let out = id.batch_nan(&[1.0, 2.0, 3.0]); + assert_eq!(out, vec![1.0, 2.0, 3.0]); + } + + #[test] + fn chain_pipes_first_into_second() { + let mut c = Chain::new(Doubler::default(), Doubler::default()); + // 5 -> 10 -> 20 + assert_eq!(c.update(5.0), Some(20.0)); + } + + #[test] + fn chain_is_ready_only_after_both_stages_emit() { + let mut c = Chain::new(Doubler::default(), Doubler::default()); + assert!(!c.is_ready()); + c.update(1.0); + assert!(c.is_ready()); + } + + #[test] + fn chain_reset_propagates() { + let mut c = Chain::new(Doubler::default(), Doubler::default()); + c.update(1.0); + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + } + + #[test] + fn chain_three_levels_via_then() { + let c = Chain::new(Doubler::default(), Doubler::default()).then(Doubler::default()); + let mut c = c; + // 1 -> 2 -> 4 -> 8 + assert_eq!(c.update(1.0), Some(8.0)); + } + + /// Cover the `Chain::first` / `Chain::second` borrow accessors and the + /// `Chain::warmup_period` + `Chain::name` Indicator-impl bodies. + /// + /// Existing chain tests only invoked the Indicator surface (`update`, + /// `reset`, `is_ready`) on the wrapped `Chain`. The const borrow accessors + /// and the `warmup_period` / `name` impls were never traversed, so Codecov + /// flagged traits.rs lines 140-142, 145-147, 167-170, 176-178 as missed. + /// `chain.warmup_period()` also reaches `Doubler::warmup_period` + /// (228-230), and `chain.first().name()` reaches `Doubler::name` + /// (234-236) — both helper methods were uncovered for the same reason. + #[test] + fn chain_accessors_and_metadata() { + let chain = Chain::new(Doubler::default(), Doubler::default()); + // Borrow accessors return the wrapped stages; query each via .name() + // so Doubler::name (lines 234-236) is also exercised. + assert_eq!(chain.first().name(), "Doubler"); + assert_eq!(chain.second().name(), "Doubler"); + // Doubler::warmup_period (lines 228-230) is 0; Chain::warmup_period + // sums the two, so the result must also be 0. + assert_eq!(chain.first().warmup_period(), 0); + assert_eq!(chain.second().warmup_period(), 0); + assert_eq!(chain.warmup_period(), 0); + // Chain::name returns the literal "Chain" (line 177). + assert_eq!(chain.name(), "Chain"); + } + + /// Cover the full Indicator surface of the `Identity` test helper: + /// `reset` (198-200), `warmup_period` (201-203), `is_ready` (204-206), + /// and `name` (207-209). The only other test using `Identity` + /// (`batch_replays_update`) calls `batch`, which exercises `update` + /// alone, leaving the remaining four trait methods uncovered. + #[test] + fn identity_helper_full_indicator_surface() { + let mut id = Identity::default(); + // warmup_period is the literal 0; name is the literal "Identity". + assert_eq!(id.warmup_period(), 0); + assert_eq!(id.name(), "Identity"); + // is_ready exercises the `self.seen` return with seen=false first… + assert!(!id.is_ready()); + // …then with seen=true after a single update. + let out = id.update(42.0); + assert_eq!(out, Some(42.0)); + assert!(id.is_ready()); + // reset() flips seen back to false; is_ready reflects it. + id.reset(); + assert!(!id.is_ready()); + } + + #[cfg(feature = "parallel")] + #[test] + fn batch_parallel_runs_independent_instances() { + let series: Vec> = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]; + let out = Doubler::batch_parallel(&series, Doubler::default); + assert_eq!(out.len(), 2); + assert_eq!(out[0], vec![Some(2.0), Some(4.0), Some(6.0)]); + assert_eq!(out[1], vec![Some(8.0), Some(10.0), Some(12.0)]); + } +} diff --git a/crates/wickra-core/tests/invariants.rs b/crates/wickra-core/tests/invariants.rs new file mode 100644 index 0000000..202168d --- /dev/null +++ b/crates/wickra-core/tests/invariants.rs @@ -0,0 +1,1075 @@ +//! Property-based invariants every indicator must uphold. +//! +//! Three properties are checked against random input sequences, rolled out over +//! the whole catalogue so a regression in any single indicator surfaces here: +//! +//! 1. **batch == streaming** — `batch()` must replay `update()` exactly. +//! 2. **reset == fresh** — after `reset()`, re-feeding the same data must match +//! a freshly constructed instance. +//! 3. **non-finite is rejected without poisoning** — a NaN/inf tick returns +//! `None` and leaves state identical to never having seen it. `Candle` and the +//! other validated input types cannot be non-finite, so this applies to the +//! `f64` and `(f64, f64)` families. This is the regression net that would have +//! caught the pairwise non-finite bug (#251). + +use proptest::prelude::*; +use wickra_core::*; + +// --- generic invariant bodies ------------------------------------------------ + +// One generic body for every `Indicator` family. Inputs are compared via Debug +// so bit-identical NaN outputs count as equal (NaN != NaN would otherwise break +// equality for indicators that emit NaN on degenerate input — these properties +// are about determinism, not NaN-freeness). +fn check_seq(make: impl Fn() -> I, xs: &[I::Input]) -> std::result::Result<(), TestCaseError> +where + I: Indicator, + I::Input: Clone, + I::Output: std::fmt::Debug, +{ + let batch = make().batch(xs); + let mut s = make(); + let stream: Vec<_> = xs.iter().map(|x| s.update(x.clone())).collect(); + prop_assert_eq!( + format!("{batch:?}"), + format!("{stream:?}"), + "batch != streaming" + ); + + let mut a = make(); + let _ = a.batch(xs); + a.reset(); + let after: Vec<_> = xs.iter().map(|x| a.update(x.clone())).collect(); + prop_assert_eq!( + format!("{after:?}"), + format!("{stream:?}"), + "reset != fresh" + ); + Ok(()) +} + +// BarBuilder has a different shape (`update(Candle) -> Vec`), so it gets its +// own body. `batch` concatenates per-candle bars; streaming flat-maps them. +fn check_bars(make: impl Fn() -> B, candles: &[Candle]) -> std::result::Result<(), TestCaseError> +where + B: BarBuilder, + B::Bar: std::fmt::Debug, +{ + let batch = make().batch(candles); + let mut s = make(); + let stream: Vec<_> = candles.iter().flat_map(|&c| s.update(c)).collect(); + prop_assert_eq!( + format!("{batch:?}"), + format!("{stream:?}"), + "batch != streaming" + ); + + let mut a = make(); + let _ = a.batch(candles); + a.reset(); + let after: Vec<_> = candles.iter().flat_map(|&c| a.update(c)).collect(); + prop_assert_eq!( + format!("{after:?}"), + format!("{stream:?}"), + "reset != fresh" + ); + Ok(()) +} + +// Non-finite rejection for the `f64` family: a NaN/inf tick returns `None` and +// does not poison state (the poisoned run must match a clean one). Only applies +// to scalar/pairwise inputs — `Candle` and the exotic types validate finiteness +// at construction. +fn check_scalar_nonfinite( + make: impl Fn() -> I, + xs: &[f64], +) -> std::result::Result<(), TestCaseError> +where + I: Indicator, + I::Output: std::fmt::Debug, +{ + let mut s = make(); + let clean: Vec<_> = xs.iter().map(|&x| s.update(x)).collect(); + let mut g = make(); + prop_assert!(g.update(f64::NAN).is_none(), "NaN not rejected"); + prop_assert!(g.update(f64::INFINITY).is_none(), "inf not rejected"); + prop_assert!(g.update(f64::NEG_INFINITY).is_none(), "-inf not rejected"); + let poisoned: Vec<_> = xs.iter().map(|&x| g.update(x)).collect(); + prop_assert_eq!( + format!("{poisoned:?}"), + format!("{clean:?}"), + "non-finite poisoned state" + ); + Ok(()) +} + +// Non-finite rejection for the `(f64, f64)` family. +fn check_pairwise_nonfinite( + make: impl Fn() -> I, + xs: &[(f64, f64)], +) -> std::result::Result<(), TestCaseError> +where + I: Indicator, + I::Output: std::fmt::Debug, +{ + let mut s = make(); + let clean: Vec<_> = xs.iter().map(|&p| s.update(p)).collect(); + let mut g = make(); + prop_assert!(g.update((f64::NAN, 1.0)).is_none(), "NaN not rejected"); + prop_assert!(g.update((1.0, f64::INFINITY)).is_none(), "inf not rejected"); + let poisoned: Vec<_> = xs.iter().map(|&p| g.update(p)).collect(); + prop_assert_eq!( + format!("{poisoned:?}"), + format!("{clean:?}"), + "non-finite poisoned state" + ); + Ok(()) +} + +// --- per-family roll-out macros ---------------------------------------------- + +macro_rules! scalar_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + #[test] + fn $name(xs in prop::collection::vec(-1.0e6f64..1.0e6, 0..160)) { + check_seq(|| { $($ctor)+ }, &xs)?; + check_scalar_nonfinite(|| { $($ctor)+ }, &xs)?; + } + } + }; +} + +macro_rules! candle_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + #[test] + fn $name( + raw in prop::collection::vec( + (1.0e1f64..1.0e4, 0.0f64..200.0, 0.0f64..1.0, 0.0f64..1.0), + 0..160, + ) + ) { + // (low, range, open_frac, close_frac) -> a valid OHLC candle. + let candles: Vec = raw + .iter() + .map(|&(low, range, of, cf)| { + Candle::new(low + range * of, low + range, low, low + range * cf, 1.0, 0) + .expect("constructed valid candle") + }) + .collect(); + check_seq(|| { $($ctor)+ }, &candles)?; + } + } + }; +} + +macro_rules! pair_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + #[test] + fn $name( + xs in prop::collection::vec((-1.0e6f64..1.0e6, -1.0e6f64..1.0e6), 0..160) + ) { + check_seq(|| { $($ctor)+ }, &xs)?; + check_pairwise_nonfinite(|| { $($ctor)+ }, &xs)?; + } + } + }; +} + +macro_rules! cross_section_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + #[test] + fn $name( + raw in prop::collection::vec( + prop::collection::vec( + (-1.0e3f64..1.0e3, 0.0f64..1.0e6, any::(), any::(), any::(), any::()), + 4..=4, + ), + 0..80, + ) + ) { + let ticks: Vec = raw + .iter() + .map(|members| { + let m = members + .iter() + .map(|&(c, v, nh, nl, a, o)| Member::with_signals(c, v, nh, nl, a, o)) + .collect(); + CrossSection::new(m, 0).expect("valid cross-section") + }) + .collect(); + check_seq(|| { $($ctor)+ }, &ticks)?; + } + } + }; +} + +macro_rules! trade_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + #[test] + fn $name(raw in prop::collection::vec((1.0f64..1.0e5, 0.0f64..1.0e4, any::()), 0..160)) { + let ticks: Vec = raw + .iter() + .map(|&(p, s, b)| Trade::new(p, s, if b { Side::Buy } else { Side::Sell }, 0).expect("valid trade")) + .collect(); + check_seq(|| { $($ctor)+ }, &ticks)?; + } + } + }; +} + +macro_rules! deriv_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + #[test] + fn $name( + raw in prop::collection::vec( + ( + (-1.0f64..1.0, 1.0f64..1.0e5, 1.0f64..1.0e5, 1.0f64..1.0e5), + (0.0f64..1.0e6, 0.0f64..1.0e6, 0.0f64..1.0e6, 0.0f64..1.0e6), + (0.0f64..1.0e6, 0.0f64..1.0e6, 0.0f64..1.0e6), + ), + 0..120, + ) + ) { + let ticks: Vec = raw + .iter() + .map(|&((fr, mk, ix, fu), (oi, ls, ss, tbv), (tsv, ll, sl))| { + DerivativesTick::new(fr, mk, ix, fu, oi, ls, ss, tbv, tsv, ll, sl, 0).expect("valid deriv tick") + }) + .collect(); + check_seq(|| { $($ctor)+ }, &ticks)?; + } + } + }; +} + +macro_rules! orderbook_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + #[test] + fn $name(raw in prop::collection::vec((1.0e2f64..1.0e4, 0.1f64..10.0, 0.0f64..1.0e4), 0..100)) { + // mid +/- k*gap gives strictly-monotonic, uncrossed books (best_bid < best_ask). + let books: Vec = raw + .iter() + .map(|&(mid, gap, sz)| { + let asks = (1..=3).map(|k| Level::new(mid + f64::from(k) * gap, sz).expect("ask")).collect(); + let bids = (1..=3).map(|k| Level::new(mid - f64::from(k) * gap, sz).expect("bid")).collect(); + OrderBook::new(bids, asks).expect("valid book") + }) + .collect(); + check_seq(|| { $($ctor)+ }, &books)?; + } + } + }; +} + +macro_rules! tradequote_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + #[test] + fn $name(raw in prop::collection::vec((1.0f64..1.0e5, 0.0f64..1.0e4, any::(), 1.0f64..1.0e5), 0..160)) { + let tqs: Vec = raw + .iter() + .map(|&(p, s, b, mid)| { + let tr = Trade::new(p, s, if b { Side::Buy } else { Side::Sell }, 0).expect("trade"); + TradeQuote::new(tr, mid).expect("valid trade-quote") + }) + .collect(); + check_seq(|| { $($ctor)+ }, &tqs)?; + } + } + }; +} + +macro_rules! bar_inv { + ($name:ident, $($ctor:tt)+) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + #[test] + fn $name( + raw in prop::collection::vec( + (1.0e1f64..1.0e4, 0.0f64..200.0, 0.0f64..1.0, 0.0f64..1.0), + 0..160, + ) + ) { + let candles: Vec = raw + .iter() + .map(|&(low, range, of, cf)| { + Candle::new(low + range * of, low + range, low, low + range * cf, 1.0, 0) + .expect("constructed valid candle") + }) + .collect(); + check_bars(|| { $($ctor)+ }, &candles)?; + } + } + }; +} + +// --- catalogue: full roll-out over the whole catalogue (513 of 514 mod entries: +// 503 Indicator + 10 BarBuilder; pattern_swing is neither, see below). --- + +// --- scalar (167) --- +scalar_inv!(inv_adaptive_cycle, AdaptiveCycle::new()); +scalar_inv!( + inv_adaptive_laguerre_filter, + AdaptiveLaguerreFilter::new(13).unwrap() +); +scalar_inv!(inv_adaptive_rsi, AdaptiveRsi::new(14).unwrap()); +scalar_inv!(inv_alma, Alma::new(9, 0.85, 6.0).unwrap()); +scalar_inv!(inv_anchored_rsi, AnchoredRsi::new()); +scalar_inv!(inv_apo, Apo::new(12, 26).unwrap()); +scalar_inv!(inv_autocorrelation, Autocorrelation::new(20, 1).unwrap()); +scalar_inv!( + inv_autocorrelation_periodogram, + AutocorrelationPeriodogram::new(10, 48).unwrap() +); +scalar_inv!(inv_average_drawdown, AverageDrawdown::new(10).unwrap()); +scalar_inv!(inv_bandpass_filter, BandpassFilter::new(20, 0.3).unwrap()); +scalar_inv!(inv_bipower_variation, BipowerVariation::new(20).unwrap()); +scalar_inv!(inv_bollinger, BollingerBands::new(5, 2.0).unwrap()); +scalar_inv!( + inv_bollinger_bandwidth, + BollingerBandwidth::new(20, 2.0).unwrap() +); +scalar_inv!(inv_bomar_bands, BomarBands::new(20, 0.85).unwrap()); +scalar_inv!(inv_burke_ratio, BurkeRatio::new(12).unwrap()); +scalar_inv!(inv_calmar_ratio, CalmarRatio::new(20).unwrap()); +scalar_inv!(inv_center_of_gravity, CenterOfGravity::new(10).unwrap()); +scalar_inv!(inv_cfo, Cfo::new(14).unwrap()); +scalar_inv!(inv_cmo, Cmo::new(14).unwrap()); +scalar_inv!( + inv_coefficient_of_variation, + CoefficientOfVariation::new(20).unwrap() +); +scalar_inv!(inv_common_sense_ratio, CommonSenseRatio::new(20).unwrap()); +scalar_inv!( + inv_conditional_value_at_risk, + ConditionalValueAtRisk::new(100, 0.95).unwrap() +); +scalar_inv!(inv_connors_rsi, ConnorsRsi::new(3, 2, 5).unwrap()); +scalar_inv!(inv_coppock, Coppock::new(14, 11, 10).unwrap()); +scalar_inv!( + inv_correlation_trend_indicator, + CorrelationTrendIndicator::new(20).unwrap() +); +scalar_inv!(inv_cybernetic_cycle, CyberneticCycle::new(10).unwrap()); +scalar_inv!(inv_decycler, Decycler::new(20).unwrap()); +scalar_inv!( + inv_decycler_oscillator, + DecyclerOscillator::new(10, 30).unwrap() +); +scalar_inv!(inv_dema, Dema::new(3).unwrap()); +scalar_inv!( + inv_derivative_oscillator, + DerivativeOscillator::new(14, 5, 3, 9).unwrap() +); +scalar_inv!(inv_detrended_std_dev, DetrendedStdDev::new(14).unwrap()); +scalar_inv!(inv_disparity_index, DisparityIndex::new(14).unwrap()); +scalar_inv!( + inv_double_bollinger, + DoubleBollinger::new(20, 1.0, 2.0).unwrap() +); +scalar_inv!(inv_dpo, Dpo::new(20).unwrap()); +scalar_inv!(inv_drawdown_duration, DrawdownDuration::new()); +scalar_inv!( + inv_dynamic_momentum_index, + DynamicMomentumIndex::new(14).unwrap() +); +scalar_inv!(inv_ehlers_stochastic, EhlersStochastic::new(20).unwrap()); +scalar_inv!(inv_ehma, Ehma::new(9).unwrap()); +scalar_inv!(inv_elder_impulse, ElderImpulse::new(3, 2, 4, 3).unwrap()); +scalar_inv!(inv_ema, Ema::new(3).unwrap()); +scalar_inv!( + inv_empirical_mode_decomposition, + EmpiricalModeDecomposition::new(20, 0.5).unwrap() +); +scalar_inv!( + inv_even_better_sinewave, + EvenBetterSinewave::new(40, 10).unwrap() +); +scalar_inv!(inv_ewma_volatility, EwmaVolatility::new(0.94).unwrap()); +scalar_inv!(inv_expectancy, Expectancy::new(4).unwrap()); +scalar_inv!(inv_fama, Fama::new(0.5, 0.05).unwrap()); +scalar_inv!(inv_fisher_rsi, FisherRsi::new(9).unwrap()); +scalar_inv!(inv_fisher_transform, FisherTransform::new(10).unwrap()); +scalar_inv!(inv_frama, Frama::new(16).unwrap()); +scalar_inv!(inv_gain_loss_ratio, GainLossRatio::new(10).unwrap()); +scalar_inv!(inv_gain_to_pain_ratio, GainToPainRatio::new(12).unwrap()); +scalar_inv!(inv_garch11, Garch11::new(0.000_002, 0.10, 0.88).unwrap()); +scalar_inv!(inv_generalized_dema, GeneralizedDema::new(5, 0.7).unwrap()); +scalar_inv!(inv_geometric_ma, GeometricMa::new(5).unwrap()); +scalar_inv!(inv_highpass_filter, HighpassFilter::new(48).unwrap()); +scalar_inv!(inv_hilbert_dominant_cycle, HilbertDominantCycle::new()); +scalar_inv!( + inv_historical_volatility, + HistoricalVolatility::new(20, 252).unwrap() +); +scalar_inv!(inv_hma, Hma::new(9).unwrap()); +scalar_inv!(inv_holt_winters, HoltWinters::new(0.2, 0.1).unwrap()); +scalar_inv!(inv_ht_dcphase, HtDcPhase::new()); +scalar_inv!(inv_ht_phasor, HtPhasor::new()); +scalar_inv!(inv_ht_trendmode, HtTrendMode::new()); +scalar_inv!(inv_hurst_exponent, HurstExponent::new(100, 4).unwrap()); +scalar_inv!( + inv_instantaneous_trendline, + InstantaneousTrendline::new(20).unwrap() +); +scalar_inv!( + inv_inverse_fisher_transform, + InverseFisherTransform::new(1.0).unwrap() +); +scalar_inv!(inv_jarque_bera, JarqueBera::new(50).unwrap()); +scalar_inv!(inv_jma, Jma::new(14, 0.0, 2).unwrap()); +scalar_inv!(inv_jump_indicator, JumpIndicator::new(20, 3.0).unwrap()); +scalar_inv!(inv_k_ratio, KRatio::new(30).unwrap()); +scalar_inv!(inv_kama, Kama::new(10, 2, 30).unwrap()); +scalar_inv!(inv_kelly_criterion, KellyCriterion::new(10).unwrap()); +scalar_inv!(inv_kst, Kst::new(2, 3, 4, 5, 2, 2, 2, 3, 2).unwrap()); +scalar_inv!(inv_kurtosis, Kurtosis::new(20).unwrap()); +scalar_inv!(inv_laguerre_rsi, LaguerreRsi::new(0.5).unwrap()); +scalar_inv!(inv_linreg, LinearRegression::new(14).unwrap()); +scalar_inv!(inv_linreg_angle, LinRegAngle::new(14).unwrap()); +scalar_inv!(inv_linreg_channel, LinRegChannel::new(20, 2.0).unwrap()); +scalar_inv!(inv_linreg_intercept, LinRegIntercept::new(14).unwrap()); +scalar_inv!(inv_linreg_slope, LinRegSlope::new(14).unwrap()); +scalar_inv!(inv_log_return, LogReturn::new(1).unwrap()); +scalar_inv!(inv_m2_measure, M2Measure::new(20, 0.0, 0.02).unwrap()); +scalar_inv!(inv_ma_envelope, MaEnvelope::new(20, 0.025).unwrap()); +scalar_inv!(inv_macd, MacdIndicator::new(3, 6, 3).unwrap()); +scalar_inv!( + inv_macd_ext, + MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap() +); +scalar_inv!(inv_macd_fix, MacdFix::new(9).unwrap()); +scalar_inv!(inv_macd_histogram, MacdHistogram::new(12, 26, 9).unwrap()); +scalar_inv!(inv_mama, Mama::new(0.5, 0.05).unwrap()); +scalar_inv!(inv_martin_ratio, MartinRatio::new(14).unwrap()); +scalar_inv!(inv_max_drawdown, MaxDrawdown::new(10).unwrap()); +scalar_inv!(inv_mcginley_dynamic, McGinleyDynamic::new(10).unwrap()); +scalar_inv!( + inv_median_absolute_deviation, + MedianAbsoluteDeviation::new(20).unwrap() +); +scalar_inv!(inv_median_channel, MedianChannel::new(20, 2.0).unwrap()); +scalar_inv!(inv_median_ma, MedianMa::new(5).unwrap()); +scalar_inv!(inv_mid_point, MidPoint::new(5).unwrap()); +scalar_inv!(inv_mom, Mom::new(3).unwrap()); +scalar_inv!(inv_omega_ratio, OmegaRatio::new(20, 0.0).unwrap()); +scalar_inv!(inv_pain_index, PainIndex::new(10).unwrap()); +scalar_inv!(inv_percent_b, PercentB::new(20, 2.0).unwrap()); +scalar_inv!( + inv_percentage_trailing_stop, + PercentageTrailingStop::new(5.0).unwrap() +); +scalar_inv!(inv_pmo, Pmo::new(35, 20).unwrap()); +scalar_inv!( + inv_polarized_fractal_efficiency, + PolarizedFractalEfficiency::new(10, 5).unwrap() +); +scalar_inv!(inv_ppo, Ppo::new(12, 26).unwrap()); +scalar_inv!(inv_ppo_histogram, PpoHistogram::new(12, 26, 9).unwrap()); +scalar_inv!(inv_profit_factor, ProfitFactor::new(20).unwrap()); +scalar_inv!(inv_qqe, Qqe::new(14, 5, 4.236).unwrap()); +scalar_inv!(inv_quartile_bands, QuartileBands::new(20).unwrap()); +scalar_inv!(inv_r_squared, RSquared::new(14).unwrap()); +scalar_inv!( + inv_realized_volatility, + RealizedVolatility::new(20).unwrap() +); +scalar_inv!(inv_recovery_factor, RecoveryFactor::new()); +scalar_inv!(inv_reflex, Reflex::new(20).unwrap()); +scalar_inv!(inv_regime_label, RegimeLabel::new(5, 20).unwrap()); +scalar_inv!( + inv_renko_trailing_stop, + RenkoTrailingStop::new(1.0).unwrap() +); +scalar_inv!(inv_rmi, Rmi::new(14, 5).unwrap()); +scalar_inv!(inv_roc, Roc::new(3).unwrap()); +scalar_inv!(inv_rocp, Rocp::new(3).unwrap()); +scalar_inv!(inv_rocr, Rocr::new(3).unwrap()); +scalar_inv!(inv_rocr100, Rocr100::new(3).unwrap()); +scalar_inv!(inv_rolling_iqr, RollingIqr::new(20).unwrap()); +scalar_inv!( + inv_rolling_min_max_scaler, + RollingMinMaxScaler::new(14).unwrap() +); +scalar_inv!( + inv_rolling_percentile_rank, + RollingPercentileRank::new(20).unwrap() +); +scalar_inv!(inv_rolling_quantile, RollingQuantile::new(5, 0.5).unwrap()); +scalar_inv!(inv_roofing_filter, RoofingFilter::new(10, 48).unwrap()); +scalar_inv!(inv_rsi, Rsi::new(3).unwrap()); +scalar_inv!(inv_rsx, Rsx::new(14).unwrap()); +scalar_inv!(inv_rvi_volatility, RviVolatility::new(10).unwrap()); +scalar_inv!(inv_sample_entropy, SampleEntropy::new(50, 2, 0.2).unwrap()); +scalar_inv!(inv_shannon_entropy, ShannonEntropy::new(32, 8).unwrap()); +scalar_inv!(inv_sharpe_ratio, SharpeRatio::new(20, 0.0).unwrap()); +scalar_inv!(inv_sine_wave, SineWave::new()); +scalar_inv!(inv_sine_weighted_ma, SineWeightedMa::new(5).unwrap()); +scalar_inv!(inv_skewness, Skewness::new(20).unwrap()); +scalar_inv!(inv_sma, Sma::new(3).unwrap()); +scalar_inv!(inv_smma, Smma::new(3).unwrap()); +scalar_inv!(inv_sortino_ratio, SortinoRatio::new(20, 0.0).unwrap()); +scalar_inv!(inv_standard_error, StandardError::new(14).unwrap()); +scalar_inv!( + inv_standard_error_bands, + StandardErrorBands::new(21, 2.0).unwrap() +); +scalar_inv!(inv_stc, Stc::new(3, 5, 4, 0.5).unwrap()); +scalar_inv!(inv_std_dev, StdDev::new(20).unwrap()); +scalar_inv!(inv_step_trailing_stop, StepTrailingStop::new(1.0).unwrap()); +scalar_inv!(inv_sterling_ratio, SterlingRatio::new(12).unwrap()); +scalar_inv!(inv_stoch_rsi, StochRsi::new(14, 14).unwrap()); +scalar_inv!(inv_super_smoother, SuperSmoother::new(10).unwrap()); +scalar_inv!(inv_t3, T3::new(5, 0.7).unwrap()); +scalar_inv!(inv_tail_ratio, TailRatio::new(20).unwrap()); +scalar_inv!(inv_tema, Tema::new(3).unwrap()); +scalar_inv!(inv_tii, Tii::new(20, 10).unwrap()); +scalar_inv!(inv_trend_label, TrendLabel::new(10).unwrap()); +scalar_inv!( + inv_trend_strength_index, + TrendStrengthIndex::new(20).unwrap() +); +scalar_inv!(inv_trendflex, Trendflex::new(20).unwrap()); +scalar_inv!(inv_trima, Trima::new(5).unwrap()); +scalar_inv!(inv_trix, Trix::new(3).unwrap()); +scalar_inv!(inv_tsf, Tsf::new(14).unwrap()); +scalar_inv!(inv_tsf_oscillator, TsfOscillator::new(14).unwrap()); +scalar_inv!(inv_tsi, Tsi::new(25, 13).unwrap()); +scalar_inv!(inv_ulcer_index, UlcerIndex::new(14).unwrap()); +scalar_inv!( + inv_universal_oscillator, + UniversalOscillator::new(20).unwrap() +); +scalar_inv!( + inv_upside_potential_ratio, + UpsidePotentialRatio::new(20, 0.0).unwrap() +); +scalar_inv!(inv_value_at_risk, ValueAtRisk::new(100, 0.95).unwrap()); +scalar_inv!(inv_variance, Variance::new(20).unwrap()); +scalar_inv!( + inv_vertical_horizontal_filter, + VerticalHorizontalFilter::new(28).unwrap() +); +scalar_inv!(inv_vidya, Vidya::new(14, 9).unwrap()); +scalar_inv!( + inv_volatility_of_volatility, + VolatilityOfVolatility::new(20, 20).unwrap() +); +scalar_inv!(inv_wave_pm, WavePm::new(10, 3).unwrap()); +scalar_inv!(inv_win_rate, WinRate::new(4).unwrap()); +scalar_inv!(inv_wma, Wma::new(3).unwrap()); +scalar_inv!(inv_z_score, ZScore::new(20).unwrap()); +scalar_inv!(inv_zero_lag_macd, ZeroLagMacd::new(3, 5, 3).unwrap()); +scalar_inv!(inv_zlema, Zlema::new(10).unwrap()); + +// --- candle (261) --- +candle_inv!(inv_abandoned_baby, AbandonedBaby::new()); +candle_inv!(inv_abcd, Abcd::new()); +candle_inv!( + inv_acceleration_bands, + AccelerationBands::new(20, 0.001).unwrap() +); +candle_inv!( + inv_accelerator_oscillator, + AcceleratorOscillator::new(5, 34, 5).unwrap() +); +candle_inv!(inv_ad_oscillator, AdOscillator::new()); +candle_inv!(inv_adaptive_cci, AdaptiveCci::new(20).unwrap()); +candle_inv!(inv_adl, Adl::new()); +candle_inv!(inv_advance_block, AdvanceBlock::new()); +candle_inv!(inv_adx, Adx::new(5).unwrap()); +candle_inv!(inv_adxr, Adxr::new(5).unwrap()); +candle_inv!(inv_alligator, Alligator::new(5, 3, 2).unwrap()); +candle_inv!(inv_anchored_vwap, AnchoredVwap::new()); +candle_inv!(inv_andrews_pitchfork, AndrewsPitchfork::new(2).unwrap()); +candle_inv!(inv_aroon, Aroon::new(5).unwrap()); +candle_inv!(inv_aroon_oscillator, AroonOscillator::new(5).unwrap()); +candle_inv!(inv_atr, Atr::new(5).unwrap()); +candle_inv!(inv_atr_bands, AtrBands::new(14, 3.0).unwrap()); +candle_inv!(inv_atr_ratchet, AtrRatchet::new(14, 4.0, 0.1).unwrap()); +candle_inv!( + inv_atr_trailing_stop, + AtrTrailingStop::new(14, 3.0).unwrap() +); +candle_inv!(inv_auto_fib, AutoFib::new()); +candle_inv!( + inv_average_daily_range, + AverageDailyRange::new(2, 0).unwrap() +); +candle_inv!(inv_avg_price, AvgPrice::new()); +candle_inv!( + inv_awesome_oscillator, + AwesomeOscillator::new(3, 10).unwrap() +); +candle_inv!( + inv_awesome_oscillator_histogram, + AwesomeOscillatorHistogram::new(3, 5, 3).unwrap() +); +candle_inv!(inv_balance_of_power, BalanceOfPower::new()); +candle_inv!(inv_bat, Bat::new()); +candle_inv!(inv_belt_hold, BeltHold::new()); +candle_inv!(inv_better_volume, BetterVolume::new(20).unwrap()); +candle_inv!(inv_body_size_pct, BodySizePct::new()); +candle_inv!(inv_breakaway, Breakaway::new()); +candle_inv!(inv_butterfly, Butterfly::new()); +candle_inv!(inv_camarilla_pivots, Camarilla::new()); +candle_inv!(inv_candle_volume, CandleVolume::new(14).unwrap()); +candle_inv!(inv_cci, Cci::new(5).unwrap()); +candle_inv!(inv_central_pivot_range, CentralPivotRange::new()); +candle_inv!( + inv_chaikin_oscillator, + ChaikinOscillator::new(3, 10).unwrap() +); +candle_inv!( + inv_chaikin_volatility, + ChaikinVolatility::new(10, 10).unwrap() +); +candle_inv!( + inv_chande_kroll_stop, + ChandeKrollStop::new(10, 1.0, 9).unwrap() +); +candle_inv!(inv_chandelier_exit, ChandelierExit::new(22, 3.0).unwrap()); +candle_inv!(inv_choppiness_index, ChoppinessIndex::new(14).unwrap()); +candle_inv!(inv_classic_pivots, ClassicPivots::new()); +candle_inv!(inv_close_vs_open, CloseVsOpen::new()); +candle_inv!(inv_closing_marubozu, ClosingMarubozu::new()); +candle_inv!(inv_cmf, ChaikinMoneyFlow::new(20).unwrap()); +candle_inv!( + inv_composite_profile, + CompositeProfile::new(100, 50, 0.70).unwrap() +); +candle_inv!(inv_concealing_baby_swallow, ConcealingBabySwallow::new()); +candle_inv!(inv_counterattack, Counterattack::new()); +candle_inv!(inv_crab, Crab::new()); +candle_inv!(inv_cup_and_handle, CupAndHandle::new()); +candle_inv!(inv_cypher, Cypher::new()); +candle_inv!(inv_day_of_week_profile, DayOfWeekProfile::new(0)); +candle_inv!(inv_demand_index, DemandIndex::new(10).unwrap()); +candle_inv!(inv_demark_pivots, DemarkPivots::new()); +candle_inv!(inv_doji, Doji::new()); +candle_inv!(inv_doji_star, DojiStar::new()); +candle_inv!(inv_donchian, Donchian::new(5).unwrap()); +candle_inv!(inv_donchian_stop, DonchianStop::new(10).unwrap()); +candle_inv!(inv_double_top_bottom, DoubleTopBottom::new()); +candle_inv!( + inv_downside_gap_three_methods, + DownsideGapThreeMethods::new() +); +candle_inv!(inv_dragonfly_doji, DragonflyDoji::new()); +candle_inv!(inv_dumpling_top, DumplingTop::new(9).unwrap()); +candle_inv!(inv_dx, Dx::new(5).unwrap()); +candle_inv!(inv_ease_of_movement, EaseOfMovement::new(14).unwrap()); +candle_inv!(inv_elder_ray, ElderRay::new(13).unwrap()); +candle_inv!(inv_elder_safezone, ElderSafeZone::new(14, 2.0).unwrap()); +candle_inv!(inv_engulfing, Engulfing::new()); +candle_inv!(inv_equivolume, Equivolume::new(14).unwrap()); +candle_inv!(inv_evening_doji_star, EveningDojiStar::new()); +candle_inv!(inv_evwma, Evwma::new(20).unwrap()); +candle_inv!(inv_falling_three_methods, FallingThreeMethods::new()); +candle_inv!(inv_fib_arcs, FibArcs::new()); +candle_inv!(inv_fib_channel, FibChannel::new()); +candle_inv!(inv_fib_confluence, FibConfluence::new()); +candle_inv!(inv_fib_extension, FibExtension::new()); +candle_inv!(inv_fib_fan, FibFan::new()); +candle_inv!(inv_fib_projection, FibProjection::new()); +candle_inv!(inv_fib_retracement, FibRetracement::new()); +candle_inv!(inv_fib_time_zones, FibTimeZones::new()); +candle_inv!(inv_fibonacci_pivots, FibonacciPivots::new()); +candle_inv!(inv_flag_pennant, FlagPennant::new()); +candle_inv!(inv_force_index, ForceIndex::new(13).unwrap()); +candle_inv!(inv_fractal_chaos_bands, FractalChaosBands::new(2).unwrap()); +candle_inv!(inv_fry_pan_bottom, FryPanBottom::new(9).unwrap()); +candle_inv!(inv_gap_side_by_side_white, GapSideBySideWhite::new()); +candle_inv!( + inv_garman_klass, + GarmanKlassVolatility::new(20, 252).unwrap() +); +candle_inv!(inv_gartley, Gartley::new()); +candle_inv!(inv_gator_oscillator, GatorOscillator::new(5, 3, 2).unwrap()); +candle_inv!(inv_golden_pocket, GoldenPocket::new()); +candle_inv!(inv_gravestone_doji, GravestoneDoji::new()); +candle_inv!(inv_hammer, Hammer::new()); +candle_inv!(inv_hanging_man, HangingMan::new()); +candle_inv!(inv_harami, Harami::new()); +candle_inv!(inv_harami_cross, HaramiCross::new()); +candle_inv!(inv_head_and_shoulders, HeadAndShoulders::new()); +candle_inv!(inv_heikin_ashi, HeikinAshi::new()); +candle_inv!( + inv_heikin_ashi_oscillator, + HeikinAshiOscillator::new(5).unwrap() +); +candle_inv!(inv_high_low_range, HighLowRange::new()); +candle_inv!( + inv_high_low_volume_nodes, + HighLowVolumeNodes::new(20, 24).unwrap() +); +candle_inv!(inv_high_wave, HighWave::new()); +candle_inv!(inv_hikkake, Hikkake::new()); +candle_inv!(inv_hikkake_modified, HikkakeModified::new()); +candle_inv!(inv_hilo_activator, HiLoActivator::new(3).unwrap()); +candle_inv!(inv_homing_pigeon, HomingPigeon::new()); +candle_inv!(inv_hurst_channel, HurstChannel::new(10, 0.5).unwrap()); +candle_inv!(inv_ichimoku, Ichimoku::new(5, 10, 20, 10).unwrap()); +candle_inv!(inv_identical_three_crows, IdenticalThreeCrows::new()); +candle_inv!(inv_in_neck, InNeck::new()); +candle_inv!(inv_inertia, Inertia::new(14, 20).unwrap()); +candle_inv!(inv_initial_balance, InitialBalance::new(3).unwrap()); +candle_inv!(inv_intraday_intensity, IntradayIntensity::new()); +candle_inv!( + inv_intraday_momentum_index, + IntradayMomentumIndex::new(14).unwrap() +); +candle_inv!( + inv_intraday_volatility_profile, + IntradayVolatilityProfile::new(24, 0).unwrap() +); +candle_inv!(inv_inverted_hammer, InvertedHammer::new()); +candle_inv!(inv_kase_devstop, KaseDevStop::new(30, 1.0).unwrap()); +candle_inv!( + inv_kase_permission_stochastic, + KasePermissionStochastic::new(9, 3).unwrap() +); +candle_inv!(inv_keltner, Keltner::new(5, 5, 2.0).unwrap()); +candle_inv!(inv_kicking, Kicking::new()); +candle_inv!(inv_kicking_by_length, KickingByLength::new()); +candle_inv!(inv_kvo, Kvo::new(34, 55).unwrap()); +candle_inv!(inv_ladder_bottom, LadderBottom::new()); +candle_inv!(inv_long_legged_doji, LongLeggedDoji::new()); +candle_inv!(inv_long_line, LongLine::new()); +candle_inv!( + inv_market_facilitation_index, + MarketFacilitationIndex::new() +); +candle_inv!(inv_marubozu, Marubozu::new()); +candle_inv!(inv_mass_index, MassIndex::new(9, 25).unwrap()); +candle_inv!(inv_mat_hold, MatHold::new()); +candle_inv!(inv_matching_low, MatchingLow::new()); +candle_inv!(inv_median_price, MedianPrice::new()); +candle_inv!(inv_mfi, Mfi::new(5).unwrap()); +candle_inv!(inv_mid_price, MidPrice::new(5).unwrap()); +candle_inv!(inv_minus_di, MinusDi::new(5).unwrap()); +candle_inv!(inv_minus_dm, MinusDm::new(5).unwrap()); +candle_inv!(inv_modified_ma_stop, ModifiedMaStop::new(14).unwrap()); +candle_inv!(inv_morning_doji_star, MorningDojiStar::new()); +candle_inv!(inv_morning_evening_star, MorningEveningStar::new()); +candle_inv!(inv_murrey_math_lines, MurreyMathLines::new(64).unwrap()); +candle_inv!(inv_naked_poc, NakedPoc::new(20, 24).unwrap()); +candle_inv!(inv_natr, Natr::new(14).unwrap()); +candle_inv!(inv_new_price_lines, NewPriceLines::new(8).unwrap()); +candle_inv!(inv_nrtr, Nrtr::new(2.0).unwrap()); +candle_inv!(inv_nvi, Nvi::new()); +candle_inv!(inv_obv, Obv::new()); +candle_inv!(inv_on_neck, OnNeck::new()); +candle_inv!(inv_opening_marubozu, OpeningMarubozu::new()); +candle_inv!(inv_opening_range, OpeningRange::new(2).unwrap()); +candle_inv!(inv_overnight_gap, OvernightGap::new(0)); +candle_inv!( + inv_overnight_intraday_return, + OvernightIntradayReturn::new(0) +); +candle_inv!(inv_parkinson, ParkinsonVolatility::new(20, 252).unwrap()); +candle_inv!(inv_pgo, Pgo::new(14).unwrap()); +candle_inv!(inv_piercing_dark_cloud, PiercingDarkCloud::new()); +candle_inv!(inv_pivot_reversal, PivotReversal::new(2, 2).unwrap()); +candle_inv!(inv_plus_di, PlusDi::new(5).unwrap()); +candle_inv!(inv_plus_dm, PlusDm::new(5).unwrap()); +candle_inv!(inv_profile_shape, ProfileShape::new(20, 24).unwrap()); +candle_inv!(inv_projection_bands, ProjectionBands::new(14).unwrap()); +candle_inv!( + inv_projection_oscillator, + ProjectionOscillator::new(14).unwrap() +); +candle_inv!(inv_psar, Psar::new(0.02, 0.02, 0.2).unwrap()); +candle_inv!(inv_pvi, Pvi::new()); +candle_inv!(inv_qstick, Qstick::new(5).unwrap()); +candle_inv!(inv_rectangle_range, RectangleRange::new()); +candle_inv!(inv_rickshaw_man, RickshawMan::new()); +candle_inv!(inv_rising_three_methods, RisingThreeMethods::new()); +candle_inv!( + inv_rogers_satchell, + RogersSatchellVolatility::new(20, 252).unwrap() +); +candle_inv!(inv_rvi, Rvi::new(10).unwrap()); +candle_inv!(inv_rwi, Rwi::new(14).unwrap()); +candle_inv!( + inv_sar_ext, + SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap() +); +candle_inv!(inv_seasonal_z_score, SeasonalZScore::new(0)); +candle_inv!(inv_separating_lines, SeparatingLines::new()); +candle_inv!(inv_session_high_low, SessionHighLow::new(0)); +candle_inv!(inv_session_range, SessionRange::new(0)); +candle_inv!(inv_session_vwap, SessionVwap::new(0)); +candle_inv!(inv_shark, Shark::new()); +candle_inv!(inv_shooting_star, ShootingStar::new()); +candle_inv!(inv_short_line, ShortLine::new()); +candle_inv!(inv_single_prints, SinglePrints::new(20, 24).unwrap()); +candle_inv!(inv_smi, Smi::new(5, 3, 3).unwrap()); +candle_inv!( + inv_smoothed_heikin_ashi, + SmoothedHeikinAshi::new(10).unwrap() +); +candle_inv!(inv_spinning_top, SpinningTop::new()); +candle_inv!(inv_stalled_pattern, StalledPattern::new()); +candle_inv!(inv_starc_bands, StarcBands::new(6, 15, 2.0).unwrap()); +candle_inv!(inv_stick_sandwich, StickSandwich::new()); +candle_inv!(inv_stochastic, Stochastic::new(5, 3).unwrap()); +candle_inv!(inv_stochastic_cci, StochasticCci::new(14).unwrap()); +candle_inv!(inv_super_trend, SuperTrend::new(10, 3.0).unwrap()); +candle_inv!(inv_takuri, Takuri::new()); +candle_inv!(inv_tasuki_gap, TasukiGap::new()); +candle_inv!(inv_td_camouflage, TdCamouflage::new()); +candle_inv!(inv_td_clop, TdClop::new()); +candle_inv!(inv_td_clopwin, TdClopwin::new()); +candle_inv!(inv_td_combo, TdCombo::new(4, 9, 2, 13).unwrap()); +candle_inv!(inv_td_countdown, TdCountdown::new(4, 9, 2, 13).unwrap()); +candle_inv!(inv_td_demarker, TdDeMarker::new(14).unwrap()); +candle_inv!(inv_td_differential, TdDifferential::new()); +candle_inv!(inv_td_dwave, TdDWave::new(2).unwrap()); +candle_inv!(inv_td_lines, TdLines::new(4, 9).unwrap()); +candle_inv!(inv_td_moving_average, TdMovingAverage::new(5, 13).unwrap()); +candle_inv!(inv_td_open, TdOpen::new()); +candle_inv!(inv_td_pressure, TdPressure::new(5).unwrap()); +candle_inv!(inv_td_propulsion, TdPropulsion::new()); +candle_inv!(inv_td_range_projection, TdRangeProjection::new()); +candle_inv!(inv_td_rei, TdRei::new(5).unwrap()); +candle_inv!(inv_td_risk_level, TdRiskLevel::new(4, 9).unwrap()); +candle_inv!(inv_td_sequential, TdSequential::new(4, 9, 2, 13).unwrap()); +candle_inv!(inv_td_setup, TdSetup::new(4, 9).unwrap()); +candle_inv!(inv_td_trap, TdTrap::new()); +candle_inv!(inv_three_drives, ThreeDrives::new()); +candle_inv!(inv_three_inside, ThreeInside::new()); +candle_inv!(inv_three_line_break, ThreeLineBreak::new(3).unwrap()); +candle_inv!(inv_three_line_strike, ThreeLineStrike::new()); +candle_inv!(inv_three_outside, ThreeOutside::new()); +candle_inv!(inv_three_soldiers_or_crows, ThreeSoldiersOrCrows::new()); +candle_inv!(inv_three_stars_in_south, ThreeStarsInSouth::new()); +candle_inv!(inv_thrusting, Thrusting::new()); +candle_inv!(inv_time_based_stop, TimeBasedStop::new(5).unwrap()); +candle_inv!( + inv_time_of_day_return_profile, + TimeOfDayReturnProfile::new(24, 0).unwrap() +); +candle_inv!(inv_tower_top_bottom, TowerTopBottom::new()); +candle_inv!(inv_tpo_profile, TpoProfile::new(5, 10).unwrap()); +candle_inv!(inv_trade_volume_index, TradeVolumeIndex::new(0.5).unwrap()); +candle_inv!(inv_triangle, Triangle::new()); +candle_inv!(inv_triple_top_bottom, TripleTopBottom::new()); +candle_inv!(inv_tristar, Tristar::new()); +candle_inv!(inv_true_range, TrueRange::new()); +candle_inv!(inv_tsv, Tsv::new(18).unwrap()); +candle_inv!(inv_ttm_squeeze, TtmSqueeze::new(20, 2.0, 1.5).unwrap()); +candle_inv!(inv_ttm_trend, TtmTrend::new(6).unwrap()); +candle_inv!(inv_turn_of_month, TurnOfMonth::new(3, 1, 0).unwrap()); +candle_inv!(inv_tweezer, Tweezer::new()); +candle_inv!(inv_twiggs_money_flow, TwiggsMoneyFlow::new(21).unwrap()); +candle_inv!(inv_two_crows, TwoCrows::new()); +candle_inv!(inv_typical_price, TypicalPrice::new()); +candle_inv!( + inv_ultimate_oscillator, + UltimateOscillator::new(7, 14, 28).unwrap() +); +candle_inv!(inv_unique_three_river, UniqueThreeRiver::new()); +candle_inv!(inv_upside_gap_three_methods, UpsideGapThreeMethods::new()); +candle_inv!(inv_upside_gap_two_crows, UpsideGapTwoCrows::new()); +candle_inv!(inv_value_area, ValueArea::new(5, 50, 0.70).unwrap()); +candle_inv!(inv_volatility_cone, VolatilityCone::new(20, 60).unwrap()); +candle_inv!(inv_volatility_ratio, VolatilityRatio::new(14).unwrap()); +candle_inv!(inv_volty_stop, VoltyStop::new(14, 2.0).unwrap()); +candle_inv!( + inv_volume_by_time_profile, + VolumeByTimeProfile::new(24, 0).unwrap() +); +candle_inv!( + inv_volume_oscillator, + VolumeOscillator::new(14, 28).unwrap() +); +candle_inv!(inv_volume_profile, VolumeProfile::new(5, 10).unwrap()); +candle_inv!(inv_volume_rsi, VolumeRsi::new(14).unwrap()); +candle_inv!( + inv_volume_weighted_macd, + VolumeWeightedMacd::new(12, 26, 9).unwrap() +); +candle_inv!(inv_volume_weighted_sr, VolumeWeightedSr::new(20).unwrap()); +candle_inv!(inv_vortex, Vortex::new(14).unwrap()); +candle_inv!(inv_vpt, VolumePriceTrend::new()); +candle_inv!(inv_vwap, Vwap::new()); +candle_inv!(inv_vwap_stddev_bands, VwapStdDevBands::new(2.0).unwrap()); +candle_inv!(inv_vwma, Vwma::new(5).unwrap()); +candle_inv!(inv_vzo, Vzo::new(14).unwrap()); +candle_inv!(inv_wad, Wad::new()); +candle_inv!(inv_wave_trend, WaveTrend::new(5, 8, 3).unwrap()); +candle_inv!(inv_wedge, Wedge::new()); +candle_inv!(inv_weighted_close, WeightedClose::new()); +candle_inv!(inv_wick_ratio, WickRatio::new()); +candle_inv!(inv_williams_fractals, WilliamsFractals::new()); +candle_inv!(inv_williams_r, WilliamsR::new(5).unwrap()); +candle_inv!(inv_woodie_pivots, WoodiePivots::new()); +candle_inv!(inv_yang_zhang, YangZhangVolatility::new(20, 252).unwrap()); +candle_inv!(inv_yoyo_exit, YoyoExit::new(14, 2.0).unwrap()); +candle_inv!(inv_zig_zag, ZigZag::new(0.10).unwrap()); + +// --- pairwise (24) --- +pair_inv!(inv_alpha, Alpha::new(20, 0.001).unwrap()); +pair_inv!(inv_beta, Beta::new(20).unwrap()); +pair_inv!(inv_beta_neutral_spread, BetaNeutralSpread::new(20).unwrap()); +pair_inv!(inv_cointegration, Cointegration::new(30, 1).unwrap()); +pair_inv!(inv_distance_ssd, DistanceSsd::new(20).unwrap()); +pair_inv!(inv_granger_causality, GrangerCausality::new(60, 1).unwrap()); +pair_inv!( + inv_hasbrouck_information_share, + HasbrouckInformationShare::new(20).unwrap() +); +pair_inv!(inv_information_ratio, InformationRatio::new(10).unwrap()); +pair_inv!( + inv_kalman_hedge_ratio, + KalmanHedgeRatio::new(1e-2, 1e-3).unwrap() +); +pair_inv!(inv_kendall_tau, KendallTau::new(20).unwrap()); +pair_inv!( + inv_lead_lag_cross_correlation, + LeadLagCrossCorrelation::new(12, 5).unwrap() +); +pair_inv!(inv_ou_half_life, OuHalfLife::new(40).unwrap()); +pair_inv!(inv_pair_spread_zscore, PairSpreadZScore::new(2, 2).unwrap()); +pair_inv!(inv_pairwise_beta, PairwiseBeta::new(10).unwrap()); +pair_inv!( + inv_pearson_correlation, + PearsonCorrelation::new(20).unwrap() +); +pair_inv!( + inv_relative_strength_ab, + RelativeStrengthAB::new(5, 5).unwrap() +); +pair_inv!( + inv_rolling_correlation, + RollingCorrelation::new(10).unwrap() +); +pair_inv!(inv_rolling_covariance, RollingCovariance::new(5).unwrap()); +pair_inv!( + inv_spearman_correlation, + SpearmanCorrelation::new(10).unwrap() +); +pair_inv!( + inv_spread_ar1_coefficient, + SpreadAr1Coefficient::new(40).unwrap() +); +pair_inv!( + inv_spread_bollinger_bands, + SpreadBollingerBands::new(20, 2.0).unwrap() +); +pair_inv!(inv_spread_hurst, SpreadHurst::new(60).unwrap()); +pair_inv!(inv_treynor_ratio, TreynorRatio::new(20, 0.001).unwrap()); +pair_inv!(inv_variance_ratio, VarianceRatio::new(60, 2).unwrap()); + +// --- cross_section_inv (15) --- +cross_section_inv!(inv_absolute_breadth_index, AbsoluteBreadthIndex::new()); +cross_section_inv!(inv_ad_volume_line, AdVolumeLine::new()); +cross_section_inv!(inv_advance_decline, AdvanceDecline::new()); +cross_section_inv!(inv_advance_decline_ratio, AdvanceDeclineRatio::new()); +cross_section_inv!(inv_breadth_thrust, BreadthThrust::new(2).unwrap()); +cross_section_inv!(inv_bullish_percent_index, BullishPercentIndex::new()); +cross_section_inv!(inv_cumulative_volume_index, CumulativeVolumeIndex::new()); +cross_section_inv!(inv_high_low_index, HighLowIndex::new(2).unwrap()); +cross_section_inv!(inv_mcclellan_oscillator, McClellanOscillator::new()); +cross_section_inv!( + inv_mcclellan_summation_index, + McClellanSummationIndex::new() +); +cross_section_inv!(inv_new_highs_new_lows, NewHighsNewLows::new()); +cross_section_inv!(inv_percent_above_ma, PercentAboveMa::new()); +cross_section_inv!(inv_tick_index, TickIndex::new()); +cross_section_inv!(inv_trin, Trin::new()); +cross_section_inv!(inv_up_down_volume_ratio, UpDownVolumeRatio::new()); + +// --- trade_inv (9) --- +trade_inv!(inv_amihud_illiquidity, AmihudIlliquidity::new(20).unwrap()); +trade_inv!(inv_cvd, CumulativeVolumeDelta::new()); +trade_inv!(inv_footprint, Footprint::new(1.0).unwrap()); +trade_inv!(inv_pin, Pin::new(20).unwrap()); +trade_inv!(inv_roll_measure, RollMeasure::new(20).unwrap()); +trade_inv!(inv_signed_volume, SignedVolume::new()); +trade_inv!(inv_trade_imbalance, TradeImbalance::new(2).unwrap()); +trade_inv!( + inv_trade_sign_autocorrelation, + TradeSignAutocorrelation::new(20).unwrap() +); +trade_inv!(inv_vpin, Vpin::new(10.0, 2).unwrap()); + +// --- deriv_inv (17) --- +deriv_inv!(inv_calendar_spread, CalendarSpread::new()); +deriv_inv!(inv_estimated_leverage_ratio, EstimatedLeverageRatio::new()); +deriv_inv!(inv_funding_basis, FundingBasis::new()); +deriv_inv!( + inv_funding_implied_apr, + FundingImpliedApr::new(1095.0).unwrap() +); +deriv_inv!(inv_funding_rate, FundingRate::new()); +deriv_inv!(inv_funding_rate_mean, FundingRateMean::new(2).unwrap()); +deriv_inv!(inv_funding_rate_zscore, FundingRateZScore::new(2).unwrap()); +deriv_inv!(inv_liquidation_features, LiquidationFeatures::new()); +deriv_inv!(inv_long_short_ratio, LongShortRatio::new()); +deriv_inv!(inv_oi_delta, OpenInterestDelta::new()); +deriv_inv!(inv_oi_price_divergence, OIPriceDivergence::new(1).unwrap()); +deriv_inv!(inv_oi_to_volume_ratio, OiToVolumeRatio::new()); +deriv_inv!(inv_oi_weighted, OIWeighted::new()); +deriv_inv!( + inv_open_interest_momentum, + OpenInterestMomentum::new(5).unwrap() +); +deriv_inv!(inv_perpetual_premium_index, PerpetualPremiumIndex::new()); +deriv_inv!(inv_taker_buy_sell_ratio, TakerBuySellRatio::new()); +deriv_inv!(inv_term_structure_basis, TermStructureBasis::new()); + +// --- orderbook_inv (7) --- +orderbook_inv!(inv_depth_slope, DepthSlope::new()); +orderbook_inv!(inv_microprice, Microprice::new()); +orderbook_inv!(inv_ob_imbalance_full, OrderBookImbalanceFull::new()); +orderbook_inv!(inv_ob_imbalance_top1, OrderBookImbalanceTop1::new()); +orderbook_inv!( + inv_ob_imbalance_topn, + OrderBookImbalanceTopN::new(2).unwrap() +); +orderbook_inv!( + inv_order_flow_imbalance, + OrderFlowImbalance::new(20).unwrap() +); +orderbook_inv!(inv_quoted_spread, QuotedSpread::new()); + +// --- tradequote_inv (3) --- +tradequote_inv!(inv_effective_spread, EffectiveSpread::new()); +tradequote_inv!(inv_kyles_lambda, KylesLambda::new(8).unwrap()); +tradequote_inv!(inv_realized_spread, RealizedSpread::new(1).unwrap()); + +// --- bar_inv (10) --- +bar_inv!(inv_dollar_bars, DollarBars::new(1000.0).unwrap()); +bar_inv!(inv_imbalance_bars, ImbalanceBars::new(3.0).unwrap()); +bar_inv!(inv_kagi_bars, KagiBars::new(2.0).unwrap()); +bar_inv!( + inv_point_and_figure_bars, + PointAndFigureBars::new(1.0, 3).unwrap() +); +bar_inv!(inv_range_bars, RangeBars::new(1.0).unwrap()); +bar_inv!(inv_renko_bars, RenkoBars::new(1.0).unwrap()); +bar_inv!(inv_run_bars, RunBars::new(3).unwrap()); +bar_inv!( + inv_three_line_break_bars, + ThreeLineBreakBars::new(3).unwrap() +); +bar_inv!(inv_tick_bars, TickBars::new(3).unwrap()); +bar_inv!(inv_volume_bars, VolumeBars::new(100.0).unwrap()); diff --git a/crates/wickra-data/Cargo.toml b/crates/wickra-data/Cargo.toml new file mode 100644 index 0000000..fbe0833 --- /dev/null +++ b/crates/wickra-data/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "wickra-data" +description = "Data sources for Wickra: CSV readers, tick-to-candle aggregator, and live exchange feeds." +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +documentation = "https://docs.rs/wickra-data" + +# Render the docs on docs.rs with every feature enabled so the optional +# live-binance feed is documented (otherwise it is hidden behind its feature). +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true + +[dependencies] +# Direct path+version (not the workspace inheritance) so `default-features = false` +# is honoured: depending on wickra-data must NOT force wickra-core's `parallel` +# (rayon) feature on — the WASM binding needs a rayon-free build and the data +# layer never uses the parallel batch path. Native bindings re-enable `parallel` +# through their own wickra-core dependency (cargo unifies the features). +wickra-core = { path = "../wickra-core", version = "0.9.9", default-features = false } +thiserror = { workspace = true } +csv = "1.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Async / live feeds are opt-in: only pulled when a `live-*` feature is requested. +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "time", "io-util"], optional = true } +tokio-tungstenite = { version = "0.29", optional = true, features = ["native-tls"] } +futures-util = { version = "0.3", optional = true } +url = { version = "2", optional = true } +# Blocking HTTP client for the historical REST kline fetcher. ureq 2.x with +# native-tls verifies against the OS trust store (SChannel / Security.framework / +# OpenSSL) — the same backend tokio-tungstenite uses above — so it ships no +# bundled CA roots. (ureq 3.x hard-depends on `webpki-root-certs` regardless of +# TLS backend, which is CDLA-Permissive-2.0 and dead weight under native-tls.) +ureq = { version = "2", default-features = false, features = ["native-tls"], optional = true } +# Direct dependency so the agent can be built with a native-tls connector (ureq +# 2.x does not auto-configure native-tls). Already in the tree via tokio-tungstenite. +native-tls = { version = "0.2", optional = true } + +[features] +default = [] +# Each exchange is gated so users only pay for the WS/REST stack they actually +# want. `live-binance` covers both the live WebSocket feed and the historical +# REST kline fetcher. +live-binance = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "dep:url", "dep:ureq", "dep:native-tls"] +# `live-binance` with a statically built OpenSSL instead of the system one. The +# native-tls stack (tokio-tungstenite + ureq, unified on the same `native-tls` +# crate) links `openssl-sys`, which needs OpenSSL at build time. The manylinux +# and musllinux wheel containers do not provide it — manylinux lacks the headers +# and the musllinux build cross-compiles against a musl sysroot that has no +# OpenSSL at all — so the Linux wheels are built with this feature, which +# compiles OpenSSL from source and links it statically. No-op on macOS/Windows, +# where native-tls uses Security.framework / SChannel and never pulls openssl-sys. +vendored-tls = ["live-binance", "native-tls/vendored"] + +[dev-dependencies] +approx = { workspace = true } +tempfile = "3" diff --git a/crates/wickra-data/src/aggregator.rs b/crates/wickra-data/src/aggregator.rs new file mode 100644 index 0000000..217fde2 --- /dev/null +++ b/crates/wickra-data/src/aggregator.rs @@ -0,0 +1,614 @@ +//! Roll trade ticks up into candles of an arbitrary timeframe. + +use crate::error::{Error, Result}; +use wickra_core::{Candle, Tick}; + +/// Hard cap on the number of placeholder candles a single +/// [`TickAggregator::push`] call may emit when gap-fill is enabled. One +/// million minute-candles is roughly 1.9 years of contiguous one-minute bars +/// — orders of magnitude beyond any realistic missing-data window in +/// production while still keeping the resulting `Vec` to well under +/// 50 MB. Any larger gap is treated as malformed input rather than allowed +/// to OOM the process. +pub const MAX_GAP_FILL_CANDLES: i64 = 1_000_000; + +/// A candle bucket size measured in the same unit as the tick timestamps. +/// +/// Wickra is unit-agnostic about timestamps: choose whichever makes sense for +/// your source (milliseconds for Binance trade events, microseconds for IB, +/// seconds for daily bars). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Timeframe { + bucket: i64, +} + +impl Timeframe { + /// Construct a timeframe with the given bucket size in the chosen unit. + /// + /// # Errors + /// Returns [`Error::InvalidTimeframe`] if `bucket <= 0`. + pub fn new(bucket: i64) -> Result { + if bucket <= 0 { + return Err(Error::InvalidTimeframe(format!( + "bucket size must be positive, got {bucket}" + ))); + } + Ok(Self { bucket }) + } + + /// Convenience: build a millisecond timeframe. + pub fn millis(ms: i64) -> Result { + Self::new(ms) + } + + /// Convenience: build a seconds-resolution timeframe. + pub fn seconds(s: i64) -> Result { + Self::new(s) + } + + /// One-minute timeframe in milliseconds (`60_000`). + pub fn one_minute_ms() -> Self { + Self::new(60_000).expect("60_000 > 0") + } + + /// Convenience: build a timeframe of `n` whole minutes, measured in + /// seconds — consistent with [`Timeframe::seconds`]. + /// + /// `minutes(5)` yields a bucket of `300`, for use with second-resolution + /// timestamps. For millisecond timestamps (Binance) multiply yourself or + /// use [`Timeframe::millis`]. + /// + /// # Errors + /// Returns [`Error::InvalidTimeframe`] if `n` is not positive or if + /// `n * 60` overflows `i64`. + /// + /// ``` + /// use wickra_data::aggregator::Timeframe; + /// assert_eq!(Timeframe::minutes(5)?.bucket(), 300); + /// # Ok::<(), wickra_data::Error>(()) + /// ``` + pub fn minutes(n: i64) -> Result { + let bucket = n + .checked_mul(60) + .ok_or_else(|| Error::InvalidTimeframe(format!("{n} minutes overflows i64 seconds")))?; + Self::new(bucket) + } + + /// Convenience: build a timeframe of `n` whole hours, measured in seconds + /// (`hours(2)` → a bucket of `7_200`). + /// + /// # Errors + /// Returns [`Error::InvalidTimeframe`] if `n` is not positive or if + /// `n * 3_600` overflows `i64`. + /// + /// ``` + /// use wickra_data::aggregator::Timeframe; + /// assert_eq!(Timeframe::hours(2)?.bucket(), 7_200); + /// # Ok::<(), wickra_data::Error>(()) + /// ``` + pub fn hours(n: i64) -> Result { + let bucket = n + .checked_mul(3_600) + .ok_or_else(|| Error::InvalidTimeframe(format!("{n} hours overflows i64 seconds")))?; + Self::new(bucket) + } + + /// Convenience: build a timeframe of `n` whole days, measured in seconds + /// (`days(1)` → a bucket of `86_400`). + /// + /// # Errors + /// Returns [`Error::InvalidTimeframe`] if `n` is not positive or if + /// `n * 86_400` overflows `i64`. + /// + /// ``` + /// use wickra_data::aggregator::Timeframe; + /// assert_eq!(Timeframe::days(1)?.bucket(), 86_400); + /// # Ok::<(), wickra_data::Error>(()) + /// ``` + pub fn days(n: i64) -> Result { + let bucket = n + .checked_mul(86_400) + .ok_or_else(|| Error::InvalidTimeframe(format!("{n} days overflows i64 seconds")))?; + Self::new(bucket) + } + + /// Bucket size. + pub const fn bucket(self) -> i64 { + self.bucket + } + + /// Floor a raw timestamp to this timeframe's bucket boundary. + /// + /// For a timestamp within one bucket of [`i64::MIN`] the mathematically + /// exact boundary lies below `i64::MIN` and cannot be represented; in that + /// (practically unreachable) case the result saturates at `i64::MIN` + /// rather than overflowing and panicking in debug builds. `bucket` is + /// always positive, so `rem_euclid` itself cannot panic. + pub fn floor(self, ts: i64) -> i64 { + ts.saturating_sub(ts.rem_euclid(self.bucket)) + } +} + +/// Incrementally builds candles out of arriving ticks. +/// +/// Each call to [`TickAggregator::push`] returns the candles that closed as a +/// result of the new tick — normally at most one. Use +/// [`TickAggregator::flush`] at the end of a stream to capture the final open +/// bar. +/// +/// # Gaps +/// +/// By default a tick that jumps across one or more empty buckets simply opens +/// the next non-empty bar — the skipped buckets produce no candle, so the +/// output series can have time holes. Enable [`TickAggregator::with_gap_fill`] +/// to instead emit a flat placeholder candle for every skipped bucket, giving +/// downstream indicators an unbroken, evenly spaced series. To bound memory +/// against an adversarial timestamp jump, gap-filling refuses to emit more +/// than [`MAX_GAP_FILL_CANDLES`] placeholders in a single step; a larger gap +/// surfaces as an `Error::Malformed` so the caller can decide how to handle +/// the discontinuity. +#[derive(Debug, Clone)] +pub struct TickAggregator { + timeframe: Timeframe, + open_bar: Option, + fill_gaps: bool, +} + +#[derive(Debug, Clone, Copy)] +struct OpenBar { + bucket_start: i64, + /// Timestamp of the most recently absorbed tick. Used to reject ticks that + /// arrive out of order *within* the current bucket — without it an older + /// tick would silently overwrite `close` with a stale price. + last_ts: i64, + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, +} + +impl OpenBar { + fn from_tick(t: Tick, bucket_start: i64) -> Self { + Self { + bucket_start, + last_ts: t.timestamp, + open: t.price, + high: t.price, + low: t.price, + close: t.price, + volume: t.volume, + } + } + + fn absorb(&mut self, t: Tick) { + if t.price > self.high { + self.high = t.price; + } + if t.price < self.low { + self.low = t.price; + } + self.close = t.price; + self.volume += t.volume; + self.last_ts = t.timestamp; + } + + /// Finalise the bar into a validated [`Candle`]. + /// + /// # Errors + /// Returns [`Error::Core`] if the accumulated `volume` is no longer finite. + /// `volume` is summed across every absorbed tick, so an astronomically + /// long or large run can drift it to `inf`; emitting such a candle would + /// silently poison every downstream indicator, so it is surfaced instead. + /// The OHLC fields are finite and correctly ordered by construction, so + /// `Candle::new` only ever rejects this bar for a non-finite volume. + fn into_candle(self) -> Result { + Candle::new( + self.open, + self.high, + self.low, + self.close, + self.volume, + self.bucket_start, + ) + .map_err(Error::from) + } +} + +impl TickAggregator { + /// Construct a new aggregator for the given timeframe. + pub fn new(timeframe: Timeframe) -> Self { + Self { + timeframe, + open_bar: None, + fill_gaps: false, + } + } + + /// Enable or disable gap filling, returning the (re)configured aggregator. + /// + /// When enabled, [`push`](Self::push) emits a flat candle + /// (`open == high == low == close`, `volume == 0`) for every bucket that is + /// skipped between two consecutive ticks. The flat candle's price is the + /// close of the bar that preceded the gap, so the series stays continuous. + #[must_use] + pub fn with_gap_fill(mut self, fill: bool) -> Self { + self.fill_gaps = fill; + self + } + + /// Whether gap filling is enabled. + pub const fn fills_gaps(&self) -> bool { + self.fill_gaps + } + + /// Push a tick. Returns every candle that closed as a result — an empty + /// vector while the open bar keeps growing, one candle when a bar boundary + /// is crossed, and (with gap filling enabled) additionally one flat candle + /// per skipped bucket. + /// + /// # Errors + /// Returns [`Error::Malformed`] if `tick.timestamp` goes backwards — both + /// across buckets (older than the open bar's start) and within a bucket + /// (older than the last tick absorbed into it) — or if gap filling + /// overflows the timestamp range. Ticks sharing a timestamp are accepted. + pub fn push(&mut self, tick: Tick) -> Result> { + let bucket = self.timeframe.floor(tick.timestamp); + if let Some(mut bar) = self.open_bar { + if bucket < bar.bucket_start { + return Err(Error::Malformed(format!( + "tick timestamp {} is older than the open bar start {}", + tick.timestamp, bar.bucket_start + ))); + } + if bucket > bar.bucket_start { + // Close the previous bar and start a new one with this tick. + let closed = bar.into_candle()?; + let mut out = Vec::with_capacity(1); + out.push(closed); + if self.fill_gaps { + self.fill_between(closed, bucket, &mut out)?; + } + self.open_bar = Some(OpenBar::from_tick(tick, bucket)); + return Ok(out); + } + // Same bucket: reject a tick that predates the last one absorbed, + // which would otherwise overwrite `close` with a stale price. + // Equal timestamps are allowed — several trades can share a + // millisecond. + if tick.timestamp < bar.last_ts { + return Err(Error::Malformed(format!( + "tick timestamp {} predates the last tick {} in the same bucket", + tick.timestamp, bar.last_ts + ))); + } + bar.absorb(tick); + self.open_bar = Some(bar); + return Ok(Vec::new()); + } + self.open_bar = Some(OpenBar::from_tick(tick, bucket)); + Ok(Vec::new()) + } + + /// Append a flat placeholder candle for every empty bucket strictly between + /// the just-closed bar and the next bucket that received a tick. + /// + /// Returns `Error::Malformed` when the gap would exceed + /// [`MAX_GAP_FILL_CANDLES`] — an adversarial timestamp jump (a clock-glitch + /// tick years in the future) must surface as a defined error, not as an + /// out-of-memory panic from allocating millions of placeholder candles. + fn fill_between(&self, prev: Candle, next_bucket: i64, out: &mut Vec) -> Result<()> { + let step = self.timeframe.bucket(); + let start = prev + .timestamp + .checked_add(step) + .ok_or_else(|| Error::Malformed("timestamp overflow while gap-filling".to_string()))?; + if start >= next_bucket { + return Ok(()); + } + + // Compute the gap size up-front so an adversarial timestamp delta + // is refused before we allocate. `step > 0` by `Timeframe::new`'s + // invariant, so the divisor is safe. Saturating the subtraction + // makes the arithmetic infallible; an overflowed-saturated span is + // still far above the cap so the limit check below catches it. + let span = next_bucket.saturating_sub(start); + let gap_count = span / step + i64::from(span % step != 0); + + if gap_count > MAX_GAP_FILL_CANDLES { + return Err(Error::Malformed(format!( + "gap-fill between bucket {} and {next_bucket} would emit {gap_count} \ + flat candles at step {step}, exceeding the {MAX_GAP_FILL_CANDLES} \ + cap; reject the discontinuity instead of allocating", + prev.timestamp + ))); + } + + out.reserve(gap_count as usize); + // Bucket alignment guarantees start + (gap_count - 1) * step ≤ + // next_bucket - step < i64::MAX, so iterating `gap_count` times + // with `saturating_add(step)` cannot reach i64::MAX inside the + // loop body. `prev.close` is finite (it came from a validated + // bar) and volume is exactly 0.0, so the OHLCV invariants hold + // by construction — skip re-validation via Candle::new_unchecked. + let mut t = start; + for _ in 0..gap_count { + out.push(Candle::new_unchecked( + prev.close, prev.close, prev.close, prev.close, 0.0, t, + )); + t = t.saturating_add(step); + } + Ok(()) + } + + /// Drain the currently open bar (if any) and return it. Useful at the end of + /// a backtest or when shutting down a live aggregator. + /// + /// # Errors + /// Returns an error if the open bar's accumulated volume is non-finite + /// (see the internal `OpenBar::into_candle`). + pub fn flush(&mut self) -> Result> { + self.open_bar.take().map(OpenBar::into_candle).transpose() + } + + /// Configured timeframe. + pub const fn timeframe(&self) -> Timeframe { + self.timeframe + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(price: f64, ts: i64) -> Tick { + Tick::new(price, 1.0, ts).unwrap() + } + + #[test] + fn timeframe_rejects_non_positive() { + assert!(Timeframe::new(0).is_err()); + assert!(Timeframe::new(-1).is_err()); + } + + /// Cover the `Timeframe::millis`, `Timeframe::seconds`, and + /// `Timeframe::one_minute_ms` convenience constructors (lines 40-52). + /// All existing tests build Timeframes via `new` / `minutes` / `hours` / + /// `days`, never via the three thin convenience wrappers. + #[test] + fn timeframe_convenience_constructors() { + assert_eq!(Timeframe::millis(250).unwrap().bucket(), 250); + assert!(Timeframe::millis(0).is_err()); + assert_eq!(Timeframe::seconds(30).unwrap().bucket(), 30); + assert!(Timeframe::seconds(-1).is_err()); + // one_minute_ms is the infallible 60_000-ms shortcut. + assert_eq!(Timeframe::one_minute_ms().bucket(), 60_000); + } + + /// Cover the `TickAggregator::timeframe` const accessor (lines 353-355). + /// Existing tests only inspect emitted candles, never query the + /// configured timeframe back out. + #[test] + fn aggregator_timeframe_getter() { + let tf = Timeframe::new(60).unwrap(); + let agg = TickAggregator::new(tf); + assert_eq!(agg.timeframe().bucket(), 60); + } + + #[test] + fn minute_hour_day_constructors_compute_seconds() { + assert_eq!(Timeframe::minutes(1).unwrap().bucket(), 60); + assert_eq!(Timeframe::minutes(5).unwrap().bucket(), 300); + assert_eq!(Timeframe::hours(1).unwrap().bucket(), 3_600); + assert_eq!(Timeframe::hours(4).unwrap().bucket(), 14_400); + assert_eq!(Timeframe::days(1).unwrap().bucket(), 86_400); + assert_eq!(Timeframe::days(7).unwrap().bucket(), 604_800); + } + + #[test] + fn minute_hour_day_constructors_reject_non_positive() { + for n in [0, -1, -60] { + assert!(Timeframe::minutes(n).is_err()); + assert!(Timeframe::hours(n).is_err()); + assert!(Timeframe::days(n).is_err()); + } + } + + #[test] + fn minute_hour_day_constructors_reject_overflow() { + // `n * unit` overflows i64 long before `new`'s sign check runs. + assert!(matches!( + Timeframe::minutes(i64::MAX), + Err(Error::InvalidTimeframe(_)) + )); + assert!(matches!( + Timeframe::hours(i64::MAX), + Err(Error::InvalidTimeframe(_)) + )); + assert!(matches!( + Timeframe::days(i64::MAX), + Err(Error::InvalidTimeframe(_)) + )); + } + + #[test] + fn floors_to_bucket_boundary() { + let tf = Timeframe::new(100).unwrap(); + assert_eq!(tf.floor(0), 0); + assert_eq!(tf.floor(99), 0); + assert_eq!(tf.floor(100), 100); + assert_eq!(tf.floor(150), 100); + assert_eq!(tf.floor(250), 200); + // Negative timestamps still floor toward negative infinity. + assert_eq!(tf.floor(-1), -100); + assert_eq!(tf.floor(-100), -100); + assert_eq!(tf.floor(-101), -200); + } + + #[test] + fn floor_saturates_instead_of_overflowing_at_min() { + let tf = Timeframe::new(100).unwrap(); + // The exact boundary lies below i64::MIN — must not panic. + assert_eq!(tf.floor(i64::MIN), i64::MIN); + // i64::MAX must not overflow either (subtracting a non-negative). + let hi = tf.floor(i64::MAX); + assert!(hi > i64::MAX - 100 && hi % 100 == 0); + } + + #[test] + fn aggregates_ticks_into_one_candle_within_bucket() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + assert!(agg.push(t(10.0, 0)).unwrap().is_empty()); + assert!(agg.push(t(12.0, 15)).unwrap().is_empty()); + assert!(agg.push(t(8.0, 30)).unwrap().is_empty()); + assert!(agg.push(t(11.0, 50)).unwrap().is_empty()); + let bar = agg.flush().unwrap().expect("open bar"); + assert_eq!(bar.open, 10.0); + assert_eq!(bar.high, 12.0); + assert_eq!(bar.low, 8.0); + assert_eq!(bar.close, 11.0); + assert!((bar.volume - 4.0).abs() < 1e-12); + assert_eq!(bar.timestamp, 0); + } + + #[test] + fn emits_candle_on_bucket_crossing() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + agg.push(t(10.0, 0)).unwrap(); + agg.push(t(12.0, 30)).unwrap(); + let closed = agg.push(t(15.0, 60)).unwrap(); + assert_eq!(closed.len(), 1); + let closed = closed[0]; + assert_eq!(closed.open, 10.0); + assert_eq!(closed.high, 12.0); + assert_eq!(closed.low, 10.0); + assert_eq!(closed.close, 12.0); + + // The new tick at ts=60 opens the next bar. + let still_open = agg.flush().unwrap().unwrap(); + assert_eq!(still_open.open, 15.0); + assert_eq!(still_open.timestamp, 60); + } + + #[test] + fn rejects_out_of_order_ticks() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + agg.push(t(10.0, 100)).unwrap(); + let err = agg.push(t(11.0, 30)).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn rejects_same_bucket_out_of_order_tick() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + agg.push(t(10.0, 50)).unwrap(); + // ts=10 is still bucket 0 but predates the tick at ts=50 — rejecting + // it prevents a stale price silently overwriting `close`. + let err = agg.push(t(99.0, 10)).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + // The open bar is untouched: close is still the ts=50 price. + assert_eq!(agg.flush().unwrap().unwrap().close, 10.0); + } + + #[test] + fn accepts_same_bucket_ticks_sharing_a_timestamp() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + agg.push(t(10.0, 20)).unwrap(); + // Two trades in the same millisecond are legitimate. + agg.push(t(12.0, 20)).unwrap(); + agg.push(t(11.0, 20)).unwrap(); + let bar = agg.flush().unwrap().unwrap(); + assert_eq!(bar.high, 12.0); + assert_eq!(bar.close, 11.0); + } + + #[test] + fn flushes_a_non_finite_volume_as_an_error() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + // Two near-max volumes sum to +inf — the closed candle would carry a + // non-finite volume that poisons every downstream indicator. + agg.push(Tick::new(10.0, f64::MAX, 0).unwrap()).unwrap(); + agg.push(Tick::new(10.0, f64::MAX, 1).unwrap()).unwrap(); + let err = agg.flush().unwrap_err(); + assert!(matches!(err, Error::Core(_))); + } + + #[test] + fn skips_empty_buckets_without_gap_fill() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()); + assert!(!agg.fills_gaps()); + agg.push(t(10.0, 0)).unwrap(); + // Jump from bucket 0 straight to bucket 180 — buckets 60 and 120 empty. + let closed = agg.push(t(20.0, 200)).unwrap(); + assert_eq!(closed.len(), 1, "only the real bar closes"); + assert_eq!(closed[0].timestamp, 0); + } + + #[test] + fn gap_fill_rejects_runaway_timestamp_jump() { + // An adversarial clock-glitch tick years in the future must surface + // as an Error::Malformed rather than allocating millions of flat + // candles and OOMing. Found by the `tick_aggregator` fuzz target. + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true); + agg.push(t(10.0, 0)).unwrap(); + // Two-billion-second jump = ~63 years of minute bars = ~33 million + // candles, well above the 1_000_000 cap. + let err = agg.push(t(20.0, 2_000_000_000)).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("gap-fill") && msg.contains("cap"), + "expected a malformed-gap error, got: {msg}" + ); + } + + #[test] + fn gap_fill_at_the_cap_succeeds() { + // Exactly one million minute-buckets between the two ticks (one real + // bar + one million flat fillers + the third tick's open bar) — the + // limit is inclusive, so this must succeed. + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true); + agg.push(t(10.0, 0)).unwrap(); + // bucket 0 closes; jump straight to bucket 60_000_060 (1_000_001 buckets + // away). fill_between emits 1_000_000 flat candles between them, then + // the new tick opens its own bucket. Output: 1 real bar + 1_000_000 fillers. + let out = agg.push(t(20.0, 60_000_060)).unwrap(); + assert_eq!(out.len(), 1 + MAX_GAP_FILL_CANDLES as usize); + } + + #[test] + fn gap_fill_emits_flat_candles_for_skipped_buckets() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true); + assert!(agg.fills_gaps()); + agg.push(t(10.0, 0)).unwrap(); + agg.push(t(13.0, 30)).unwrap(); // still bucket 0, close = 13.0 + // Next tick lands in bucket 180 — buckets 60 and 120 are skipped. + let out = agg.push(t(20.0, 200)).unwrap(); + assert_eq!(out.len(), 3, "real bar + two flat fillers"); + + let real = out[0]; + assert_eq!(real.timestamp, 0); + assert_eq!(real.close, 13.0); + + for (filler, ts) in out[1..].iter().zip([60, 120]) { + assert_eq!(filler.timestamp, ts); + assert_eq!(filler.open, 13.0); + assert_eq!(filler.high, 13.0); + assert_eq!(filler.low, 13.0); + assert_eq!(filler.close, 13.0); + assert_eq!(filler.volume, 0.0); + } + + // The tick at ts=200 opens bucket 180. + assert_eq!(agg.flush().unwrap().unwrap().timestamp, 180); + } + + #[test] + fn gap_fill_emits_nothing_extra_for_adjacent_buckets() { + let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true); + agg.push(t(10.0, 0)).unwrap(); + // Bucket 60 directly follows bucket 0 — no gap to fill. + let out = agg.push(t(11.0, 70)).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].timestamp, 0); + } +} diff --git a/crates/wickra-data/src/csv.rs b/crates/wickra-data/src/csv.rs new file mode 100644 index 0000000..fced311 --- /dev/null +++ b/crates/wickra-data/src/csv.rs @@ -0,0 +1,346 @@ +//! Stream OHLCV candles out of a CSV file. +//! +//! The reader is generic over the column layout, but ships with a sensible +//! default ("timestamp,open,high,low,close,volume") that matches the standard +//! Binance / Yahoo Finance / kaggle dataset format. +//! +//! The reader is defensive about real-world files: a leading UTF-8 byte-order +//! mark is stripped, surrounding whitespace is trimmed from every field, and a +//! file whose header does not name the required columns is rejected with a +//! clear [`Error::Malformed`] instead of silently consuming its first data row. + +use std::path::Path; + +use serde::Deserialize; + +use crate::error::{Error, Result}; +use wickra_core::Candle; + +/// Column names the default OHLCV layout requires. The CSV header must contain +/// every one of these (extra columns are ignored); matching is exact and +/// case-sensitive because the underlying `serde` deserialization maps header +/// names to [`DefaultRow`]'s fields by name. +const REQUIRED_COLUMNS: [&str; 6] = ["timestamp", "open", "high", "low", "close", "volume"]; + +/// Default OHLCV CSV row layout. +/// +/// The timestamp is parsed as an `i64` (for example a Unix epoch). RFC3339 / +/// ISO8601 string timestamps are not handled by this layout; convert them to +/// integers before reading. +#[derive(Debug, Clone, Deserialize)] +pub struct DefaultRow { + pub timestamp: i64, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, +} + +impl DefaultRow { + fn into_candle(self) -> Result { + Candle::new( + self.open, + self.high, + self.low, + self.close, + self.volume, + self.timestamp, + ) + .map_err(Error::from) + } +} + +/// A [`std::io::Read`] adapter that transparently skips a leading UTF-8 +/// byte-order mark. +/// +/// Spreadsheet exporters — Excel in particular — prefix CSV files with the +/// three-byte UTF-8 BOM `EF BB BF`. Left in place it becomes part of the first +/// header name (`\u{feff}timestamp`), which then fails to match the +/// `timestamp` column. This adapter drops the BOM before the CSV parser ever +/// sees it; files without a BOM pass through untouched. +#[derive(Debug)] +pub struct BomStripReader { + inner: R, + /// Whether the leading bytes have been inspected for a BOM yet. + checked: bool, + /// Bytes read during BOM detection that turned out *not* to be a BOM and + /// must still be handed to the consumer. + leftover: Vec, + leftover_pos: usize, +} + +impl BomStripReader { + /// Wrap `inner`, stripping a leading UTF-8 BOM on the first read. + pub fn new(inner: R) -> Self { + Self { + inner, + checked: false, + leftover: Vec::new(), + leftover_pos: 0, + } + } + + /// On the first read, consume up to three bytes and decide whether they + /// form a BOM. A BOM is discarded; anything else is buffered for replay. + fn check_bom(&mut self) -> std::io::Result<()> { + if self.checked { + return Ok(()); + } + self.checked = true; + + let mut probe = [0u8; 3]; + let mut filled = 0; + while filled < probe.len() { + let n = self.inner.read(&mut probe[filled..])?; + if n == 0 { + break; // short source — fewer than 3 bytes total + } + filled += n; + } + + if probe[..filled] != [0xEF, 0xBB, 0xBF] { + // Not a BOM (or a short file): replay every probed byte verbatim. + self.leftover.extend_from_slice(&probe[..filled]); + } + Ok(()) + } +} + +impl std::io::Read for BomStripReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.check_bom()?; + if self.leftover_pos < self.leftover.len() { + let n = (self.leftover.len() - self.leftover_pos).min(buf.len()); + buf[..n].copy_from_slice(&self.leftover[self.leftover_pos..self.leftover_pos + n]); + self.leftover_pos += n; + return Ok(n); + } + self.inner.read(buf) + } +} + +/// Validate that a CSV reader's header row names every required OHLCV column. +fn validate_headers(reader: &mut csv::Reader) -> Result<()> { + let headers = reader.headers()?; + let present: Vec = headers.iter().map(|h| h.trim().to_string()).collect(); + let missing: Vec<&str> = REQUIRED_COLUMNS + .iter() + .copied() + .filter(|col| !present.iter().any(|h| h == col)) + .collect(); + if !missing.is_empty() { + return Err(Error::Malformed(format!( + "CSV header is missing required column(s) [{}]; found [{}] — \ + the first line must be a header naming {}", + missing.join(", "), + present.join(", "), + REQUIRED_COLUMNS.join(",") + ))); + } + Ok(()) +} + +/// Streaming OHLCV CSV reader. +#[derive(Debug)] +pub struct CandleReader { + reader: csv::Reader, +} + +impl CandleReader { + /// Build a trimming CSV reader around `inner` and validate its header. + fn build(inner: R) -> Result { + let mut reader = csv::ReaderBuilder::new() + .has_headers(true) + .trim(csv::Trim::All) + .from_reader(inner); + validate_headers(&mut reader)?; + Ok(Self { reader }) + } +} + +impl CandleReader> { + /// Open a CSV file at `path`. + /// + /// The first line must be a header row naming the OHLCV columns; a leading + /// UTF-8 BOM and whitespace around values are tolerated. + /// + /// # Errors + /// Returns [`Error::Io`] if the file cannot be opened and + /// [`Error::Malformed`] if the header does not contain every required + /// column (`timestamp,open,high,low,close,volume`). + pub fn open>(path: P) -> Result { + let file = std::fs::File::open(path)?; + Self::from_reader(file) + } +} + +impl CandleReader> { + /// Build a reader from any [`std::io::Read`] source. + /// + /// A leading UTF-8 BOM is stripped and the header is validated. + /// + /// # Errors + /// Returns [`Error::Malformed`] if the header does not contain every + /// required column. + pub fn from_reader(inner: R) -> Result { + Self::build(BomStripReader::new(inner)) + } +} + +impl CandleReader { + /// Wrap a pre-built [`csv::Reader`]; useful for testing or for non-default + /// reader configuration. + /// + /// Unlike [`from_reader`](Self::from_reader) this does *not* strip a BOM — + /// the caller owns the reader's configuration — but the header is still + /// validated. + /// + /// # Errors + /// Returns [`Error::Malformed`] if the header does not contain every + /// required column. + pub fn from_csv_reader(mut reader: csv::Reader) -> Result { + validate_headers(&mut reader)?; + Ok(Self { reader }) + } + + /// Iterator over decoded candles. + pub fn candles(&mut self) -> impl Iterator> + '_ { + self.reader.deserialize::().map(|row_res| { + let row = row_res?; + row.into_candle() + }) + } + + /// Read the entire stream into a `Vec`. Convenient for backtests. + pub fn read_all(&mut self) -> Result> { + self.candles().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn reads_well_formed_csv() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + writeln!(tmp, "timestamp,open,high,low,close,volume").unwrap(); + writeln!(tmp, "1,10.0,11.0,9.0,10.5,100").unwrap(); + writeln!(tmp, "2,10.5,11.5,10.0,11.0,150").unwrap(); + writeln!(tmp, "3,11.0,12.0,10.5,11.5,200").unwrap(); + tmp.flush().unwrap(); + + let mut r = CandleReader::open(tmp.path()).unwrap(); + let candles = r.read_all().unwrap(); + assert_eq!(candles.len(), 3); + assert_eq!(candles[0].open, 10.0); + assert_eq!(candles[2].close, 11.5); + assert_eq!(candles[1].timestamp, 2); + } + + #[test] + fn rejects_invalid_ohlc() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + writeln!(tmp, "timestamp,open,high,low,close,volume").unwrap(); + // high < low → core validation rejects it. + writeln!(tmp, "1,10.0,8.0,9.0,9.5,100").unwrap(); + tmp.flush().unwrap(); + + let mut r = CandleReader::open(tmp.path()).unwrap(); + let candles: Result> = r.candles().collect(); + assert!(candles.is_err()); + } + + #[test] + fn from_reader_works_on_in_memory_data() { + let data = "timestamp,open,high,low,close,volume\n1,1,2,0,1,10\n2,1,2,0,1,10\n"; + let mut r = CandleReader::from_reader(data.as_bytes()).unwrap(); + let v = r.read_all().unwrap(); + assert_eq!(v.len(), 2); + } + + #[test] + fn rejects_file_without_header() { + // No header row — the first line is data. Without validation the + // reader would silently swallow it as the header. + let data = "1,10.0,11.0,9.0,10.5,100\n2,10.5,11.5,10.0,11.0,150\n"; + let err = CandleReader::from_reader(data.as_bytes()).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn rejects_header_missing_a_column() { + // "volume" is absent. + let data = "timestamp,open,high,low,close\n1,10.0,11.0,9.0,10.5\n"; + let err = CandleReader::from_reader(data.as_bytes()).unwrap_err(); + // The error variant must be Malformed and the message must mention + // the missing column. Asserting directly (rather than match-and- + // panic-on-other) keeps the assertion's cold path branch-free for + // coverage and still pins the diagnostic. + assert!( + matches!(&err, Error::Malformed(msg) if msg.contains("volume")), + "expected Malformed mentioning 'volume', got {err:?}" + ); + } + + /// Cover `from_csv_reader` (lines 201-204): existing tests use + /// `from_reader` / `open`, which both construct the inner `csv::Reader` + /// internally. Callers that want non-default csv configuration must + /// build the reader themselves and pass it through `from_csv_reader`. + #[test] + fn from_csv_reader_accepts_a_prebuilt_reader() { + let data = "timestamp;open;high;low;close;volume\n1;10.0;11.0;9.0;10.5;100\n"; + let inner = csv::ReaderBuilder::new() + .delimiter(b';') + .from_reader(data.as_bytes()); + let mut r = CandleReader::from_csv_reader(inner).unwrap(); + let candles = r.read_all().unwrap(); + assert_eq!(candles.len(), 1); + assert_eq!(candles[0].close, 10.5); + } + + #[test] + fn strips_leading_utf8_bom() { + // A BOM (\u{feff}) prefixes the header — Excel exports look like this. + let data = "\u{feff}timestamp,open,high,low,close,volume\n1,10.0,11.0,9.0,10.5,100\n"; + let mut r = CandleReader::from_reader(data.as_bytes()).unwrap(); + let v = r.read_all().unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0].timestamp, 1); + assert_eq!(v[0].open, 10.0); + } + + #[test] + fn tolerates_whitespace_around_fields() { + let data = " timestamp , open , high , low , close , volume \n\ + 1 , 10.0 , 11.0 , 9.0 , 10.5 , 100 \n"; + let mut r = CandleReader::from_reader(data.as_bytes()).unwrap(); + let v = r.read_all().unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0].close, 10.5); + assert_eq!(v[0].volume, 100.0); + } + + #[test] + fn bom_stripper_passes_through_non_bom_input() { + use std::io::Read; + let mut out = String::new(); + BomStripReader::new("hello".as_bytes()) + .read_to_string(&mut out) + .unwrap(); + assert_eq!(out, "hello"); + } + + #[test] + fn bom_stripper_handles_short_input() { + use std::io::Read; + let mut out = Vec::new(); + // Two bytes — shorter than a 3-byte BOM. + BomStripReader::new([0x41u8, 0x42u8].as_slice()) + .read_to_end(&mut out) + .unwrap(); + assert_eq!(out, vec![0x41, 0x42]); + } +} diff --git a/crates/wickra-data/src/error.rs b/crates/wickra-data/src/error.rs new file mode 100644 index 0000000..941f77d --- /dev/null +++ b/crates/wickra-data/src/error.rs @@ -0,0 +1,44 @@ +//! Error types specific to the data sources. + +use thiserror::Error; + +/// Errors produced by the data layer. +#[derive(Debug, Error)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("CSV error: {0}")] + Csv(#[from] csv::Error), + + #[error("invalid timeframe: {0}")] + InvalidTimeframe(String), + + #[error("indicator-core error: {0}")] + Core(#[from] wickra_core::Error), + + #[error("malformed payload: {0}")] + Malformed(String), + + /// A live-feed read exceeded its deadline. + #[error("read timed out")] + Timeout, + + #[cfg(feature = "live-binance")] + #[error("websocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[cfg(feature = "live-binance")] + #[error("JSON decode error: {0}")] + Json(#[from] serde_json::Error), + + /// Boxed because ureq 2.x's `Error::Status` carries a full `Response`, which + /// would otherwise make this the dominant enum variant (clippy + /// `large_enum_variant`). + #[cfg(feature = "live-binance")] + #[error("HTTP error: {0}")] + Http(#[from] Box), +} + +/// Convenience alias for `Result`. +pub type Result = core::result::Result; diff --git a/crates/wickra-data/src/lib.rs b/crates/wickra-data/src/lib.rs new file mode 100644 index 0000000..99e784b --- /dev/null +++ b/crates/wickra-data/src/lib.rs @@ -0,0 +1,25 @@ +//! `wickra-data`: offline and online data sources for the Wickra indicator engine. +//! +//! - [`csv`]: stream OHLCV bars out of CSV files without buffering the whole +//! history in memory. +//! - [`aggregator`]: roll trade ticks up into candles of arbitrary timeframes. +//! - [`resample`]: convert a stream of candles from one timeframe to a coarser one. +//! - [`live`] (feature `live-binance`): connect to exchange websockets for live +//! klines, or pull historical klines over REST — both yield typed candles +//! compatible with the rest of the crate. + +#![cfg_attr(docsrs, feature(doc_cfg))] +// `tokio_tungstenite::Error` is large by itself (~200 B). Boxing every Err +// variant per clippy::result_large_err just shifts allocation pressure into +// the hot path. We accept the size because errors are rare in this crate. +#![allow(clippy::result_large_err)] + +pub mod aggregator; +pub mod csv; +pub mod error; +pub mod resample; + +#[cfg(feature = "live-binance")] +pub mod live; + +pub use error::{Error, Result}; diff --git a/crates/wickra-data/src/live.rs b/crates/wickra-data/src/live.rs new file mode 100644 index 0000000..766409b --- /dev/null +++ b/crates/wickra-data/src/live.rs @@ -0,0 +1,5 @@ +//! Live exchange feeds. Each adapter is feature-gated; the Binance adapter +//! lives behind the `live-binance` feature. + +pub mod binance; +pub mod binance_rest; diff --git a/crates/wickra-data/src/live/binance.rs b/crates/wickra-data/src/live/binance.rs new file mode 100644 index 0000000..0950bb5 --- /dev/null +++ b/crates/wickra-data/src/live/binance.rs @@ -0,0 +1,915 @@ +//! Binance spot WebSocket kline feed. +//! +//! Subscribes to Binance's `@kline_` stream and emits a +//! [`KlineEvent`] every time the server pushes a new tick. The event tells you +//! whether the current candle is still open or has just closed. +//! +//! Example (requires the `live-binance` feature): +//! +//! ```no_run +//! use wickra_data::live::binance::{BinanceKlineStream, Interval}; +//! # async fn run() -> wickra_data::Result<()> { +//! let mut stream = BinanceKlineStream::connect(&["BTCUSDT".to_string()], Interval::OneMinute).await?; +//! while let Some(event) = stream.next_event().await? { +//! if event.is_closed { +//! println!("closed {} @ {}", event.symbol, event.candle.close); +//! } +//! } +//! # Ok(()) } +//! ``` + +use std::time::Duration; + +use futures_util::SinkExt; +use futures_util::StreamExt; +use serde::Deserialize; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; + +use crate::error::{Error, Result}; +use wickra_core::Candle; + +/// Tunable knobs for a [`BinanceKlineStream`]. The defaults match Binance's +/// public production endpoint and are right for almost every caller; the +/// fields exist so an integration test or a Binance Testnet user can point +/// the stream at a different base URL and shrink the reconnect timing. +#[derive(Debug, Clone)] +pub struct BinanceConfig { + /// WebSocket endpoint **without** path (e.g. `"wss://stream.binance.com:9443"`). + /// The combined-stream path `/stream?streams=…` is appended internally. + pub base_url: String, + /// Maximum time to wait for the next inbound frame before treating the + /// connection as stalled. Binance pings roughly every 3 minutes, so a + /// healthy but quiet stream stays comfortably inside the 300 s default. + pub read_timeout: Duration, + /// Delay before the first reconnect attempt; doubles on each failure up + /// to [`Self::max_reconnect_backoff`]. + pub initial_reconnect_delay: Duration, + /// Upper bound on the exponential reconnect backoff. + pub max_reconnect_backoff: Duration, + /// How many times [`BinanceKlineStream::next_event`] retries a dropped + /// connection before surfacing the last error. Must be `>= 1`. + pub max_reconnect_attempts: u32, + /// Upper bound on an inbound WebSocket message. Kline frames are tiny; + /// this only caps a pathological or hostile server from forcing an + /// unbounded allocation. + pub max_message_size: usize, + /// Upper bound on a single inbound WebSocket frame. + pub max_frame_size: usize, +} + +impl Default for BinanceConfig { + fn default() -> Self { + Self { + base_url: "wss://stream.binance.com:9443".to_string(), + read_timeout: Duration::from_secs(300), + initial_reconnect_delay: Duration::from_secs(1), + max_reconnect_backoff: Duration::from_secs(30), + max_reconnect_attempts: 6, + max_message_size: 8 << 20, + max_frame_size: 2 << 20, + } + } +} + +/// Supported Binance kline intervals. The `as_str` value matches Binance's +/// wire-format strings (`"1m"`, `"5m"`, `"1h"`, etc.). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Interval { + OneSecond, + OneMinute, + ThreeMinutes, + FiveMinutes, + FifteenMinutes, + ThirtyMinutes, + OneHour, + TwoHours, + FourHours, + SixHours, + EightHours, + TwelveHours, + OneDay, + ThreeDays, + OneWeek, + OneMonth, +} + +impl Interval { + /// Wire-format string used in the stream name. + pub fn as_str(self) -> &'static str { + match self { + Self::OneSecond => "1s", + Self::OneMinute => "1m", + Self::ThreeMinutes => "3m", + Self::FiveMinutes => "5m", + Self::FifteenMinutes => "15m", + Self::ThirtyMinutes => "30m", + Self::OneHour => "1h", + Self::TwoHours => "2h", + Self::FourHours => "4h", + Self::SixHours => "6h", + Self::EightHours => "8h", + Self::TwelveHours => "12h", + Self::OneDay => "1d", + Self::ThreeDays => "3d", + Self::OneWeek => "1w", + Self::OneMonth => "1M", + } + } +} + +/// One push from the Binance kline stream. +#[derive(Debug, Clone)] +pub struct KlineEvent { + /// Symbol in lowercase form as sent by Binance (e.g. `"btcusdt"`). + pub symbol: String, + /// Interval the candle belongs to. + pub interval: Interval, + /// Candle in its current state (may still be open). + pub candle: Candle, + /// Whether the candle has been closed by the server. Closed events are the + /// only ones safe to use for bar-completion logic. + pub is_closed: bool, +} + +/// A live Binance kline stream. +#[derive(Debug)] +pub struct BinanceKlineStream { + socket: WebSocketStream>, + /// Lowercased symbols the stream is subscribed to. Retained so the + /// connection can be rebuilt on a reconnect. + symbols: Vec, + /// Interval requested at connect time. Used to tag every event. + interval: Interval, + /// `true` once the caller invoked [`close`](Self::close). A closed stream + /// is never polled or reconnected again. + closed: bool, + /// Timing / sizing knobs. Retained so reconnects honour the same config. + config: BinanceConfig, +} + +/// Wire-format representation of an incoming Binance kline tick. Public so callers +/// can deserialize it themselves if they prefer. +#[derive(Debug, Clone, Deserialize)] +pub struct RawWsEnvelope { + /// Stream name, e.g. `"btcusdt@kline_1m"`. + pub stream: String, + pub data: RawKlinePayload, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RawKlinePayload { + #[serde(rename = "e")] + pub event_type: String, + #[serde(rename = "E")] + pub event_time: i64, + #[serde(rename = "s")] + pub symbol: String, + #[serde(rename = "k")] + pub kline: RawKline, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RawKline { + #[serde(rename = "t")] + pub open_time: i64, + #[serde(rename = "T")] + pub close_time: i64, + #[serde(rename = "s")] + pub symbol: String, + #[serde(rename = "i")] + pub interval: String, + #[serde(rename = "o")] + pub open: String, + #[serde(rename = "c")] + pub close: String, + #[serde(rename = "h")] + pub high: String, + #[serde(rename = "l")] + pub low: String, + #[serde(rename = "v")] + pub volume: String, + #[serde(rename = "x")] + pub is_closed: bool, +} + +impl BinanceKlineStream { + /// Open a raw combined-stream WebSocket for the given (already-lowercased) + /// symbols. + async fn open( + symbols: &[String], + interval: Interval, + config: &BinanceConfig, + ) -> Result>> { + let streams: Vec = symbols + .iter() + .map(|s| format!("{}@kline_{}", s, interval.as_str())) + .collect(); + let url = format!("{}/stream?streams={}", config.base_url, streams.join("/")); + let url = url::Url::parse(&url).map_err(|e| Error::Malformed(e.to_string()))?; + // tokio-tungstenite 0.29's WebSocketConfig is #[non_exhaustive], + // so the only way to set fields is via the builder-style methods on + // a fresh `default()` value. + let ws_config = WebSocketConfig::default() + .max_message_size(Some(config.max_message_size)) + .max_frame_size(Some(config.max_frame_size)); + let (socket, _) = + tokio_tungstenite::connect_async_with_config(url.as_str(), Some(ws_config), false) + .await?; + Ok(socket) + } + + /// Connect to Binance's combined-stream endpoint for one or more symbols. + /// + /// Symbols may be passed in either case; they are lowercased to match + /// Binance's stream-name conventions. A dropped or stalled connection is + /// re-established transparently by [`next_event`](Self::next_event). + pub async fn connect(symbols: &[String], interval: Interval) -> Result { + Self::connect_with_config(symbols, interval, BinanceConfig::default()).await + } + + /// Connect with a custom [`BinanceConfig`]. Useful for Binance Testnet + /// (`"wss://testnet.binance.vision"`) or for shrinking the reconnect + /// timing in integration tests. + pub async fn connect_with_config( + symbols: &[String], + interval: Interval, + config: BinanceConfig, + ) -> Result { + if symbols.is_empty() { + return Err(Error::Malformed( + "BinanceKlineStream requires at least one symbol".into(), + )); + } + let symbols: Vec = symbols.iter().map(|s| s.to_lowercase()).collect(); + let socket = Self::open(&symbols, interval, &config).await?; + Ok(Self { + socket, + symbols, + interval, + closed: false, + config, + }) + } + + /// Whether the caller has closed the stream. Once closed, every further + /// [`next_event`](Self::next_event) call yields `Ok(None)` immediately. + pub fn is_closed(&self) -> bool { + self.closed + } + + /// Re-establish a dropped connection with exponential backoff. Returns the + /// last error if every attempt fails. + async fn reconnect(&mut self) -> Result<()> { + let mut delay = self.config.initial_reconnect_delay; + let mut last_err = None; + for _ in 0..self.config.max_reconnect_attempts { + tokio::time::sleep(delay).await; + match Self::open(&self.symbols, self.interval, &self.config).await { + Ok(socket) => { + self.socket = socket; + return Ok(()); + } + Err(e) => { + last_err = Some(e); + delay = delay + .saturating_mul(2) + .min(self.config.max_reconnect_backoff); + } + } + } + Err(last_err.expect("max_reconnect_attempts is non-zero")) + } + + /// Receive the next kline event. A dropped, errored or stalled connection + /// is re-established transparently (exponential backoff, up to + /// [`BinanceConfig::max_reconnect_attempts`]); an exhausted reconnect + /// surfaces as `Err`. `Ok(None)` is returned only after the caller has + /// [`close`](Self::close)d the stream. + pub async fn next_event(&mut self) -> Result> { + if self.closed { + return Ok(None); + } + loop { + // A protocol error, a clean server close, or a read stall are all + // transient: reconnect with backoff and resume reading. + let Ok(Some(Ok(msg))) = + tokio::time::timeout(self.config.read_timeout, self.socket.next()).await + else { + self.reconnect().await?; + continue; + }; + match msg { + Message::Text(text) => { + if let Some(event) = Self::parse_frame(&text, self.interval)? { + return Ok(Some(event)); + } + // Non-kline frame (subscription ack / heartbeat / error): + // skip it and keep reading. + } + Message::Binary(bytes) => { + let text = String::from_utf8_lossy(&bytes); + if let Some(event) = Self::parse_frame(&text, self.interval)? { + return Ok(Some(event)); + } + } + Message::Ping(payload) => { + // Best-effort Pong reply. If the write fails the + // connection is already dead — the next read will + // surface the error and trigger reconnect through + // the timeout/err arm above. + let _ = self.socket.send(Message::Pong(payload)).await; + } + Message::Pong(_) | Message::Frame(_) => {} + Message::Close(_) => { + self.reconnect().await?; + } + } + } + } + + /// Parse one raw WebSocket text frame. + /// + /// Returns `Ok(Some(event))` for a kline frame, `Ok(None)` for any other + /// frame (subscription acknowledgements, error objects, heartbeats), and + /// `Err` only when a frame that *is* a kline fails to decode. + fn parse_frame(text: &str, interval: Interval) -> Result> { + let value: serde_json::Value = serde_json::from_str(text)?; + // Combined-stream kline frames carry `data.e == "kline"`. Everything + // else on the socket is control traffic that must not abort the feed. + let is_kline = value + .get("data") + .and_then(|d| d.get("e")) + .and_then(serde_json::Value::as_str) + == Some("kline"); + if !is_kline { + return Ok(None); + } + let envelope: RawWsEnvelope = serde_json::from_value(value)?; + Ok(Some(envelope.into_event(interval)?)) + } + + /// Close the underlying socket cleanly and mark the stream closed. After + /// this, [`next_event`](Self::next_event) yields `Ok(None)` and never + /// reconnects. + pub async fn close(&mut self) -> Result<()> { + self.closed = true; + self.socket.close(None).await?; + Ok(()) + } +} + +impl RawWsEnvelope { + fn into_event(self, interval: Interval) -> Result { + let k = self.data.kline; + let open: f64 = k + .open + .parse() + .map_err(|_| Error::Malformed(format!("bad open '{}'", k.open)))?; + let high: f64 = k + .high + .parse() + .map_err(|_| Error::Malformed(format!("bad high '{}'", k.high)))?; + let low: f64 = k + .low + .parse() + .map_err(|_| Error::Malformed(format!("bad low '{}'", k.low)))?; + let close: f64 = k + .close + .parse() + .map_err(|_| Error::Malformed(format!("bad close '{}'", k.close)))?; + let volume: f64 = k + .volume + .parse() + .map_err(|_| Error::Malformed(format!("bad volume '{}'", k.volume)))?; + let candle = Candle::new(open, high, low, close, volume, k.open_time)?; + Ok(KlineEvent { + symbol: self.data.symbol.to_lowercase(), + interval, + candle, + is_closed: k.is_closed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interval_as_str_covers_every_variant() { + // Wire-format strings are part of Binance's public protocol — pin + // every mapping so a typo here is caught immediately. + let pairs: &[(Interval, &str)] = &[ + (Interval::OneSecond, "1s"), + (Interval::OneMinute, "1m"), + (Interval::ThreeMinutes, "3m"), + (Interval::FiveMinutes, "5m"), + (Interval::FifteenMinutes, "15m"), + (Interval::ThirtyMinutes, "30m"), + (Interval::OneHour, "1h"), + (Interval::TwoHours, "2h"), + (Interval::FourHours, "4h"), + (Interval::SixHours, "6h"), + (Interval::EightHours, "8h"), + (Interval::TwelveHours, "12h"), + (Interval::OneDay, "1d"), + (Interval::ThreeDays, "3d"), + (Interval::OneWeek, "1w"), + (Interval::OneMonth, "1M"), + ]; + for (iv, expected) in pairs { + assert_eq!(iv.as_str(), *expected); + } + } + + #[test] + fn binance_config_default_matches_production_endpoint() { + let cfg = BinanceConfig::default(); + assert_eq!(cfg.base_url, "wss://stream.binance.com:9443"); + assert_eq!(cfg.read_timeout, Duration::from_secs(300)); + assert_eq!(cfg.initial_reconnect_delay, Duration::from_secs(1)); + assert_eq!(cfg.max_reconnect_backoff, Duration::from_secs(30)); + assert_eq!(cfg.max_reconnect_attempts, 6); + assert_eq!(cfg.max_message_size, 8 << 20); + assert_eq!(cfg.max_frame_size, 2 << 20); + } + + #[tokio::test] + async fn connect_rejects_an_empty_symbol_list() { + let err = BinanceKlineStream::connect(&[], Interval::OneMinute) + .await + .unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn parses_real_binance_payload() { + // Sample event format from Binance's public docs (truncated). + let json = r#"{ + "stream": "btcusdt@kline_1m", + "data": { + "e": "kline", + "E": 1700000000000, + "s": "BTCUSDT", + "k": { + "t": 1700000000000, + "T": 1700000059999, + "s": "BTCUSDT", + "i": "1m", + "f": 1, + "L": 100, + "o": "30000.0", + "c": "30050.0", + "h": "30100.0", + "l": "29950.0", + "v": "12.5", + "n": 50, + "x": false, + "q": "375000.0", + "V": "6.25", + "Q": "187500.0", + "B": "0" + } + } + }"#; + let env: RawWsEnvelope = serde_json::from_str(json).unwrap(); + let evt = env.into_event(Interval::OneMinute).unwrap(); + assert_eq!(evt.symbol, "btcusdt"); + assert_eq!(evt.candle.open, 30_000.0); + assert_eq!(evt.candle.close, 30_050.0); + assert!(!evt.is_closed); + assert_eq!(evt.interval, Interval::OneMinute); + } + + #[test] + fn rejects_non_parsable_numbers() { + let json = r#"{ + "stream": "btcusdt@kline_1m", + "data": { + "e": "kline", "E": 0, "s": "BTCUSDT", + "k": { + "t": 0, "T": 0, "s": "BTCUSDT", "i": "1m", + "f": 0, "L": 0, + "o": "not-a-number", "c": "0", "h": "0", "l": "0", + "v": "0", "n": 0, "x": false, "q": "0", "V": "0", "Q": "0", "B": "0" + } + } + }"#; + let env: RawWsEnvelope = serde_json::from_str(json).unwrap(); + let err = env.into_event(Interval::OneMinute).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn skips_non_kline_frames() { + // Subscription acknowledgement: skipped, never an error. + let ack = r#"{"result":null,"id":1}"#; + assert!(BinanceKlineStream::parse_frame(ack, Interval::OneMinute) + .unwrap() + .is_none()); + // Error object: also skipped. + let err = r#"{"error":{"code":2,"msg":"Invalid request"}}"#; + assert!(BinanceKlineStream::parse_frame(err, Interval::OneMinute) + .unwrap() + .is_none()); + } + + #[test] + fn parse_frame_decodes_a_kline() { + let json = r#"{ + "stream": "btcusdt@kline_1m", + "data": { + "e": "kline", "E": 1700000000000, "s": "BTCUSDT", + "k": { + "t": 1700000000000, "T": 1700000059999, "s": "BTCUSDT", "i": "1m", + "f": 1, "L": 100, "o": "30000.0", "c": "30050.0", "h": "30100.0", + "l": "29950.0", "v": "12.5", "n": 50, "x": true, + "q": "375000.0", "V": "6.25", "Q": "187500.0", "B": "0" + } + } + }"#; + let event = BinanceKlineStream::parse_frame(json, Interval::OneMinute) + .unwrap() + .expect("a kline frame yields an event"); + assert_eq!(event.symbol, "btcusdt"); + assert!(event.is_closed); + } + + // ==================================================================== + // Mock WebSocket server: drives the async / reconnect / control-frame + // paths against a `127.0.0.1` listener instead of the real Binance + // endpoint. Each test gets its own port (`bind("127.0.0.1:0")`). + // ==================================================================== + + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::net::TcpListener; + + /// A kline JSON frame matching Binance's combined-stream envelope. Always + /// reports `is_closed = true` so the test can assert on the flag. + fn sample_kline_text() -> String { + r#"{"stream":"btcusdt@kline_1m","data":{"e":"kline","E":1700000000000,"s":"BTCUSDT","k":{"t":1700000000000,"T":1700000059999,"s":"BTCUSDT","i":"1m","f":1,"L":100,"o":"30000.0","c":"30050.0","h":"30100.0","l":"29950.0","v":"12.5","n":50,"x":true,"q":"375000.0","V":"6.25","Q":"187500.0","B":"0"}}}"#.to_string() + } + + /// Test-tuned [`BinanceConfig`]: aim at the given mock and shrink every + /// timer so a failing reconnect loop completes in milliseconds. + fn test_config(base_url: String) -> BinanceConfig { + BinanceConfig { + base_url, + read_timeout: Duration::from_millis(200), + initial_reconnect_delay: Duration::from_millis(5), + max_reconnect_backoff: Duration::from_millis(10), + max_reconnect_attempts: 3, + ..BinanceConfig::default() + } + } + + /// Spawn a mock WS server that accepts one connection, drops the + /// listener (so any reconnect lands on a refused port), and then hands + /// the upgraded socket to `handler`. Every step `.unwrap()`s — a failure + /// here is a bug in the test scaffolding, not in the production code. + async fn one_shot_server(handler: F) -> String + where + F: FnOnce(WebSocketStream) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("ws://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + drop(listener); + let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + handler(ws).await; + }); + base_url + } + + /// Spawn a mock WS server that accepts exactly `n_accepts` connections + /// and invokes `handler` for each (with a zero-based index). Returns a + /// [`JoinHandle`](tokio::task::JoinHandle) the test can await so every + /// spawned handler is guaranteed to reach its closing brace before the + /// runtime is torn down. + async fn multi_shot_server( + n_accepts: u32, + handler: F, + ) -> (String, tokio::task::JoinHandle<()>) + where + F: Fn(u32, WebSocketStream) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("ws://{}", listener.local_addr().unwrap()); + let handler = Arc::new(handler); + let h = tokio::spawn(async move { + let mut joins = Vec::with_capacity(n_accepts as usize); + for index in 0..n_accepts { + let (stream, _) = listener.accept().await.unwrap(); + let handler = handler.clone(); + joins.push(tokio::spawn(async move { + let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + handler(index, ws).await; + })); + } + for j in joins { + j.await.unwrap(); + } + }); + (base_url, h) + } + + #[tokio::test] + async fn next_event_decodes_a_text_kline_frame() { + let kline = sample_kline_text(); + let base = one_shot_server(move |mut ws| async move { + let _ = ws.send(Message::Text(kline.into())).await; + while let Some(Ok(_)) = ws.next().await {} + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + assert!(!stream.is_closed()); + let event = stream + .next_event() + .await + .unwrap() + .expect("server pushes a kline"); + assert_eq!(event.symbol, "btcusdt"); + assert!(event.is_closed); + } + + #[tokio::test] + async fn next_event_decodes_a_binary_kline_frame() { + let kline = sample_kline_text(); + let base = one_shot_server(move |mut ws| async move { + let bytes: Vec = kline.into_bytes(); + let _ = ws.send(Message::Binary(bytes.into())).await; + while let Some(Ok(_)) = ws.next().await {} + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + let event = stream + .next_event() + .await + .unwrap() + .expect("server pushes a kline as Binary"); + assert_eq!(event.symbol, "btcusdt"); + } + + #[tokio::test] + async fn next_event_replies_to_a_ping_with_a_pong() { + let kline = sample_kline_text(); + let base = one_shot_server(move |mut ws| async move { + let _ = ws + .send(Message::Ping(b"binance-ping".as_slice().into())) + .await; + let _ = ws.send(Message::Text(kline.into())).await; + while let Some(Ok(_)) = ws.next().await {} + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + // If the client never replied to the Ping the server's drain would + // observe nothing — but for line coverage it's enough that the + // client received the Ping, sent a Pong, then read the next frame. + let event = stream + .next_event() + .await + .unwrap() + .expect("kline arrives right after the ping"); + assert_eq!(event.symbol, "btcusdt"); + } + + #[tokio::test] + async fn next_event_skips_inbound_pong_frames() { + let kline = sample_kline_text(); + let base = one_shot_server(move |mut ws| async move { + let _ = ws + .send(Message::Pong(b"unsolicited".as_slice().into())) + .await; + let _ = ws.send(Message::Text(kline.into())).await; + while let Some(Ok(_)) = ws.next().await {} + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + let event = stream + .next_event() + .await + .unwrap() + .expect("kline follows the ignored Pong"); + assert_eq!(event.symbol, "btcusdt"); + } + + #[tokio::test] + async fn next_event_reconnects_after_a_server_close_frame() { + let kline = sample_kline_text(); + let (base, server_done) = multi_shot_server(2, move |index, mut ws| { + let kline = kline.clone(); + async move { + let msg = if index == 0 { + // First connection: send a clean Close so the client + // exercises the Message::Close reconnect path. + Message::Close(None) + } else { + Message::Text(kline.into()) + }; + let _ = ws.send(msg).await; + } + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + let event = stream + .next_event() + .await + .unwrap() + .expect("reconnect succeeds and the second connection serves a kline"); + assert_eq!(event.symbol, "btcusdt"); + // Wait for every spawned handler to reach its final state. + tokio::time::timeout(Duration::from_secs(1), server_done) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn next_event_reconnects_after_a_read_timeout() { + let kline = sample_kline_text(); + let stall_token = Arc::new(AtomicU32::new(0)); + let stall_token_h = stall_token.clone(); + let (base, server_done) = multi_shot_server(2, move |index, mut ws| { + let kline = kline.clone(); + let stall_token = stall_token_h.clone(); + async move { + if index == 0 { + // First connection: never write anything. Outlast the + // client's 80 ms read_timeout but bounded so the + // handler still completes for the coverage check. + stall_token.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(250)).await; + } else { + let _ = ws.send(Message::Text(kline.into())).await; + } + } + }) + .await; + let cfg = BinanceConfig { + read_timeout: Duration::from_millis(80), + ..test_config(base) + }; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + cfg, + ) + .await + .unwrap(); + let event = stream + .next_event() + .await + .unwrap() + .expect("client times out, reconnects, and reads the kline"); + assert_eq!(event.symbol, "btcusdt"); + assert!(stall_token.load(Ordering::SeqCst) >= 1); + tokio::time::timeout(Duration::from_secs(1), server_done) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn next_event_yields_none_after_close() { + let base = one_shot_server(|mut ws| async move { + // Stay open until the client closes; this lets close() complete + // its handshake cleanly. + while let Some(Ok(_)) = ws.next().await {} + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + stream.close().await.unwrap(); + assert!(stream.is_closed()); + assert!(stream.next_event().await.unwrap().is_none()); + } + + #[tokio::test] + async fn next_event_surfaces_an_error_when_reconnect_attempts_are_exhausted() { + // After the first accept the listener is dropped (one_shot_server + // does this), so every reconnect attempt lands on a closed port. + let base = one_shot_server(|mut ws| async move { + let _ = ws.send(Message::Close(None)).await; + // Returning here also drops the socket, but the listener has + // already been released — the client's subsequent connects + // will be refused. + }) + .await; + let cfg = BinanceConfig { + max_reconnect_attempts: 2, + initial_reconnect_delay: Duration::from_millis(1), + max_reconnect_backoff: Duration::from_millis(2), + ..test_config(base) + }; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + cfg, + ) + .await + .unwrap(); + let err = stream + .next_event() + .await + .expect_err("reconnect attempts are exhausted"); + // Either a WS-layer error or a Malformed error from URL parsing — + // we only care that the call surfaced as Err rather than panicked. + let _ = err; + } + + #[tokio::test] + async fn next_event_skips_non_kline_frames_and_returns_the_next_kline() { + // Drives the loop through the Text- *and* Binary-arm "frame was + // not a kline, keep reading" fall-throughs before serving the + // actual kline. + let kline = sample_kline_text(); + let base = one_shot_server(move |mut ws| async move { + let _ = ws + .send(Message::Text(r#"{"result":null,"id":1}"#.into())) + .await; + let _ = ws + .send(Message::Binary(b"{\"id\":2}".to_vec().into())) + .await; + let _ = ws.send(Message::Text(kline.into())).await; + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + let event = stream + .next_event() + .await + .unwrap() + .expect("kline arrives after the two skipped control frames"); + assert_eq!(event.symbol, "btcusdt"); + } + + #[tokio::test] + async fn next_event_propagates_a_parse_error_from_a_malformed_kline() { + // A "kline" envelope whose open field is not a number — parse_frame + // identifies it as a kline, into_event then fails, and next_event + // bubbles the error rather than skipping the frame. + let bad = r#"{"stream":"btcusdt@kline_1m","data":{"e":"kline","E":0,"s":"BTCUSDT","k":{"t":0,"T":0,"s":"BTCUSDT","i":"1m","o":"not-a-number","c":"0","h":"0","l":"0","v":"0","x":false}}}"#.to_string(); + let base = one_shot_server(move |mut ws| async move { + let _ = ws.send(Message::Text(bad.into())).await; + while let Some(Ok(_)) = ws.next().await {} + }) + .await; + let mut stream = BinanceKlineStream::connect_with_config( + &["BTCUSDT".to_string()], + Interval::OneMinute, + test_config(base), + ) + .await + .unwrap(); + let err = stream.next_event().await.unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } +} diff --git a/crates/wickra-data/src/live/binance_rest.rs b/crates/wickra-data/src/live/binance_rest.rs new file mode 100644 index 0000000..d6256d1 --- /dev/null +++ b/crates/wickra-data/src/live/binance_rest.rs @@ -0,0 +1,322 @@ +//! Binance spot REST historical kline fetcher. +//! +//! Where [`super::binance`] streams *live* klines over a WebSocket, this module +//! pulls *historical* candles from Binance's public REST endpoint +//! (`GET /api/v3/klines`) with a single blocking request. It is the native, +//! dependency-free replacement for hand-rolled `urllib` / `jackson` / `jsonlite` +//! download helpers in every binding. +//! +//! Example (requires the `live-binance` feature): +//! +//! ```no_run +//! use wickra_data::live::binance::Interval; +//! use wickra_data::live::binance_rest::fetch_klines; +//! # fn run() -> wickra_data::Result<()> { +//! let candles = fetch_klines("BTCUSDT", Interval::OneHour, 500, None, None)?; +//! println!("got {} candles", candles.len()); +//! # Ok(()) } +//! ``` + +use std::fmt::Write as _; + +use serde::Deserialize; + +use super::binance::Interval; +use crate::error::{Error, Result}; +use wickra_core::Candle; + +/// Binance allows at most 1000 klines per REST request. +const MAX_LIMIT: u16 = 1000; + +/// Endpoint configuration for [`fetch_klines_with_config`]. The default points +/// at Binance's public production REST host; tests and Testnet users override +/// `base_url` to aim the request elsewhere. +#[derive(Debug, Clone)] +pub struct BinanceRestConfig { + /// REST host **without** path, e.g. `"https://api.binance.com"`. The + /// `/api/v3/klines` path and query string are appended internally. + pub base_url: String, +} + +impl Default for BinanceRestConfig { + fn default() -> Self { + Self { + base_url: "https://api.binance.com".to_string(), + } + } +} + +/// One row of Binance's `/api/v3/klines` response. The wire format is a fixed +/// 12-element JSON array of mixed types; only the first seven fields carry the +/// OHLCV candle, the rest are ignored. Deserializing positionally lets serde +/// reject a malformed shape before we touch the numbers. +#[derive(Debug, Deserialize)] +struct RawRestKline( + i64, // open time (ms) + String, // open + String, // high + String, // low + String, // close + String, // volume + serde::de::IgnoredAny, // close time + serde::de::IgnoredAny, // quote asset volume + serde::de::IgnoredAny, // number of trades + serde::de::IgnoredAny, // taker buy base volume + serde::de::IgnoredAny, // taker buy quote volume + serde::de::IgnoredAny, // unused +); + +/// Parse one of the five OHLCV string fields into an `f64`, tagging the field +/// name on failure so a bad payload is diagnosable. +fn parse_field(raw: &str, field: &str) -> Result { + raw.parse() + .map_err(|_| Error::Malformed(format!("bad {field} '{raw}'"))) +} + +impl RawRestKline { + fn into_candle(self) -> Result { + let open = parse_field(&self.1, "open")?; + let high = parse_field(&self.2, "high")?; + let low = parse_field(&self.3, "low")?; + let close = parse_field(&self.4, "close")?; + let volume = parse_field(&self.5, "volume")?; + Ok(Candle::new(open, high, low, close, volume, self.0)?) + } +} + +/// Fetch historical klines from Binance's production endpoint. +/// +/// `symbol` is upper-cased to match Binance's convention; `limit` must be in +/// `1..=1000`. `start_ms` / `end_ms` are optional inclusive Unix-millisecond +/// bounds (`None` lets Binance pick the most recent `limit` candles). Returns +/// the candles in ascending open-time order. +/// +/// # Errors +/// Returns [`Error::Malformed`] for an out-of-range `limit` or an unparseable +/// price field, [`Error::Http`] for a transport or non-2xx response, +/// [`Error::Json`] for a malformed body, and [`Error::Core`] when a row's OHLC +/// values violate candle invariants. +pub fn fetch_klines( + symbol: &str, + interval: Interval, + limit: u16, + start_ms: Option, + end_ms: Option, +) -> Result> { + fetch_klines_with_config( + symbol, + interval, + limit, + start_ms, + end_ms, + &BinanceRestConfig::default(), + ) +} + +/// Like [`fetch_klines`] but against a custom [`BinanceRestConfig`] (Testnet or +/// a local mock server). +/// +/// # Errors +/// See [`fetch_klines`]. +pub fn fetch_klines_with_config( + symbol: &str, + interval: Interval, + limit: u16, + start_ms: Option, + end_ms: Option, + config: &BinanceRestConfig, +) -> Result> { + if limit == 0 || limit > MAX_LIMIT { + return Err(Error::Malformed(format!( + "limit must be in 1..={MAX_LIMIT}, got {limit}" + ))); + } + let mut url = format!( + "{}/api/v3/klines?symbol={}&interval={}&limit={}", + config.base_url, + symbol.to_uppercase(), + interval.as_str(), + limit + ); + if let Some(start) = start_ms { + let _ = write!(url, "&startTime={start}"); + } + if let Some(end) = end_ms { + let _ = write!(url, "&endTime={end}"); + } + + // ureq 2.x does not auto-configure native-tls, so build an agent with an + // explicit native-tls connector (verifies against the OS trust store; no + // bundled CA roots). The connector is cheap and a one-shot fetch needs no + // pooling, so we build it per call. + let connector = native_tls::TlsConnector::new() + .map_err(|e| Error::Malformed(format!("native-tls init failed: {e}")))?; + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(connector)) + .build(); + let body = agent.get(&url).call().map_err(Box::new)?.into_string()?; + let rows: Vec = serde_json::from_str(&body)?; + rows.into_iter().map(RawRestKline::into_candle).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread::JoinHandle; + + /// A canonical single-row klines response (12-element array, OHLCV in the + /// first seven slots) — mirrors Binance's documented wire format. + fn sample_response() -> String { + r#"[[1700000000000,"30000.0","30100.0","29950.0","30050.0","12.5",1700000059999,"375000.0",50,"6.25","187500.0","0"]]"#.to_string() + } + + /// Spawn a one-shot mock HTTP server: accept a single connection, read the + /// request, and reply `200 OK` with `body`. Returns the base URL plus a + /// [`JoinHandle`] the test joins so the handler thread always reaches its + /// closing brace (every step `.unwrap()`s — a failure is a test bug). + fn mock_http(status_line: &'static str, body: String) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).unwrap(); + let response = format!( + "{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + (format!("http://{addr}"), handle) + } + + #[test] + fn fetch_parses_a_real_klines_response() { + let (base, handle) = mock_http("HTTP/1.1 200 OK", sample_response()); + let candles = fetch_klines_with_config( + "btcusdt", + Interval::OneHour, + 1, + Some(1_700_000_000_000), + Some(1_700_000_059_999), + &BinanceRestConfig { base_url: base }, + ) + .unwrap(); + handle.join().unwrap(); + assert_eq!(candles.len(), 1); + assert_eq!(candles[0].open, 30_000.0); + assert_eq!(candles[0].high, 30_100.0); + assert_eq!(candles[0].low, 29_950.0); + assert_eq!(candles[0].close, 30_050.0); + assert_eq!(candles[0].volume, 12.5); + assert_eq!(candles[0].timestamp, 1_700_000_000_000); + } + + #[test] + fn fetch_handles_an_empty_result() { + let (base, handle) = mock_http("HTTP/1.1 200 OK", "[]".to_string()); + let candles = fetch_klines_with_config( + "BTCUSDT", + Interval::OneMinute, + 10, + None, + None, + &BinanceRestConfig { base_url: base }, + ) + .unwrap(); + handle.join().unwrap(); + assert!(candles.is_empty()); + } + + #[test] + fn fetch_rejects_a_zero_limit() { + let err = fetch_klines("BTCUSDT", Interval::OneHour, 0, None, None).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn fetch_rejects_a_limit_above_the_cap() { + let err = + fetch_klines("BTCUSDT", Interval::OneHour, MAX_LIMIT + 1, None, None).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn fetch_surfaces_a_transport_error_for_an_unreachable_host() { + // Port 1 is privileged and unbound — the connection is refused. + let err = fetch_klines_with_config( + "BTCUSDT", + Interval::OneHour, + 1, + None, + None, + &BinanceRestConfig { + base_url: "http://127.0.0.1:1".to_string(), + }, + ) + .unwrap_err(); + assert!(matches!(err, Error::Http(_))); + } + + #[test] + fn fetch_surfaces_a_json_error_for_a_malformed_body() { + let (base, handle) = mock_http("HTTP/1.1 200 OK", "not json".to_string()); + let err = fetch_klines_with_config( + "BTCUSDT", + Interval::OneHour, + 1, + None, + None, + &BinanceRestConfig { base_url: base }, + ) + .unwrap_err(); + handle.join().unwrap(); + assert!(matches!(err, Error::Json(_))); + } + + #[test] + fn fetch_rejects_an_unparseable_price_field() { + let body = + r#"[[1700000000000,"not-a-number","0","0","0","0",0,"0",0,"0","0","0"]]"#.to_string(); + let (base, handle) = mock_http("HTTP/1.1 200 OK", body); + let err = fetch_klines_with_config( + "BTCUSDT", + Interval::OneHour, + 1, + None, + None, + &BinanceRestConfig { base_url: base }, + ) + .unwrap_err(); + handle.join().unwrap(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn fetch_rejects_a_row_that_violates_candle_invariants() { + // high (1) below low (100) — Candle::new rejects it. + let body = r#"[[1700000000000,"50","1","100","50","1",0,"0",0,"0","0","0"]]"#.to_string(); + let (base, handle) = mock_http("HTTP/1.1 200 OK", body); + let err = fetch_klines_with_config( + "BTCUSDT", + Interval::OneHour, + 1, + None, + None, + &BinanceRestConfig { base_url: base }, + ) + .unwrap_err(); + handle.join().unwrap(); + assert!(matches!(err, Error::Core(_))); + } + + #[test] + fn rest_config_default_targets_production() { + assert_eq!( + BinanceRestConfig::default().base_url, + "https://api.binance.com" + ); + } +} diff --git a/crates/wickra-data/src/resample.rs b/crates/wickra-data/src/resample.rs new file mode 100644 index 0000000..9472ba1 --- /dev/null +++ b/crates/wickra-data/src/resample.rs @@ -0,0 +1,224 @@ +//! Resample an existing candle stream from a finer timeframe to a coarser one. + +use crate::aggregator::Timeframe; +use crate::error::{Error, Result}; +use wickra_core::Candle; + +/// Roll a stream of candles up to a coarser timeframe. +/// +/// Used to derive 5m bars from a 1m feed, or 1h bars from 5m bars, without +/// touching the original tick stream. The output timeframe's bucket must be a +/// strict multiple of the input timeframe's bucket, but this is not enforced +/// — callers are responsible for picking sensible aggregations. +#[derive(Debug, Clone)] +pub struct Resampler { + timeframe: Timeframe, + open: Option, +} + +#[derive(Debug, Clone, Copy)] +struct RolledBar { + bucket_start: i64, + open: f64, + high: f64, + low: f64, + close: f64, + volume: f64, +} + +impl RolledBar { + fn from_candle(c: Candle, bucket_start: i64) -> Self { + Self { + bucket_start, + open: c.open, + high: c.high, + low: c.low, + close: c.close, + volume: c.volume, + } + } + + fn absorb(&mut self, c: Candle) { + if c.high > self.high { + self.high = c.high; + } + if c.low < self.low { + self.low = c.low; + } + self.close = c.close; + self.volume += c.volume; + } + + /// Finalise the rolled bar into a validated [`Candle`]. + /// + /// # Errors + /// Returns [`Error::Core`] if the accumulated `volume` is no longer finite. + /// `volume` is summed across every absorbed candle, so a long or large run + /// can drift it to `inf`; emitting such a candle would silently poison + /// every downstream indicator, so it is surfaced instead. The OHLC fields + /// are finite and correctly ordered by construction. + fn into_candle(self) -> Result { + Candle::new( + self.open, + self.high, + self.low, + self.close, + self.volume, + self.bucket_start, + ) + .map_err(Error::from) + } +} + +impl Resampler { + /// Build a resampler targeting the given output timeframe. + pub fn new(timeframe: Timeframe) -> Self { + Self { + timeframe, + open: None, + } + } + + /// Push a finer-grained candle. Returns the coarser candle that just closed, + /// if any. + /// + /// # Errors + /// Returns [`Error::Malformed`] if `candle.timestamp` falls into a bucket + /// strictly before the currently open bar — out-of-order candles are not + /// supported, matching [`crate::aggregator::TickAggregator::push`]. + pub fn push(&mut self, candle: Candle) -> Result> { + let bucket = self.timeframe.floor(candle.timestamp); + match self.open { + Some(mut bar) if bucket == bar.bucket_start => { + bar.absorb(candle); + self.open = Some(bar); + Ok(None) + } + Some(bar) if bucket > bar.bucket_start => { + let closed = bar.into_candle()?; + self.open = Some(RolledBar::from_candle(candle, bucket)); + Ok(Some(closed)) + } + Some(bar) => Err(Error::Malformed(format!( + "candle timestamp {} is older than the open bar start {}", + candle.timestamp, bar.bucket_start + ))), + None => { + self.open = Some(RolledBar::from_candle(candle, bucket)); + Ok(None) + } + } + } + + /// Flush the currently open coarser bar, if any. + /// + /// # Errors + /// Returns an error if the open bar's accumulated volume is non-finite + /// (see the internal `RolledBar::into_candle`). + pub fn flush(&mut self) -> Result> { + self.open.take().map(RolledBar::into_candle).transpose() + } +} + +/// Roll an entire iterator of candles into a `Vec` of coarser candles. The final +/// open bar (if any) is appended via [`Resampler::flush`]. +pub fn resample_all(timeframe: Timeframe, iter: I) -> Result> +where + I: IntoIterator>, +{ + let mut r = Resampler::new(timeframe); + let mut out = Vec::new(); + for c in iter { + let c = c?; + if let Some(closed) = r.push(c)? { + out.push(closed); + } + } + if let Some(last) = r.flush()? { + out.push(last); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(ts: i64, o: f64, h: f64, l: f64, cl: f64, v: f64) -> Candle { + Candle::new(o, h, l, cl, v, ts).unwrap() + } + + #[test] + fn resamples_1m_to_5m() { + let tf = Timeframe::new(5).unwrap(); + let one_m = vec![ + c(0, 10.0, 11.0, 9.0, 10.5, 10.0), + c(1, 10.5, 12.0, 10.0, 11.5, 12.0), + c(2, 11.5, 13.0, 11.0, 12.5, 15.0), + c(3, 12.5, 12.8, 11.5, 12.0, 8.0), + c(4, 12.0, 12.2, 11.0, 11.5, 6.0), + c(5, 11.5, 11.9, 11.0, 11.5, 4.0), + ]; + let rolled = resample_all(tf, one_m.into_iter().map(Ok)).unwrap(); + // First 5 candles share bucket 0 -> aggregate. Last candle opens bucket 5. + assert_eq!(rolled.len(), 2); + let a = rolled[0]; + assert_eq!(a.open, 10.0); + assert_eq!(a.close, 11.5); + assert_eq!(a.high, 13.0); + assert_eq!(a.low, 9.0); + assert!((a.volume - 51.0).abs() < 1e-12); + let b = rolled[1]; + assert_eq!(b.open, 11.5); + assert_eq!(b.timestamp, 5); + } + + #[test] + fn rejects_out_of_order_candle() { + let mut r = Resampler::new(Timeframe::new(5).unwrap()); + assert!(r.push(c(10, 10.0, 11.0, 9.0, 10.5, 1.0)).unwrap().is_none()); + // A candle in an earlier bucket than the open bar is rejected. + let err = r.push(c(2, 10.0, 11.0, 9.0, 10.5, 1.0)).unwrap_err(); + assert!(matches!(err, Error::Malformed(_))); + } + + #[test] + fn same_bucket_candles_aggregate() { + let mut r = Resampler::new(Timeframe::new(5).unwrap()); + assert!(r.push(c(0, 10.0, 11.0, 9.0, 10.5, 1.0)).unwrap().is_none()); + assert!(r.push(c(3, 10.5, 12.0, 10.0, 11.0, 1.0)).unwrap().is_none()); + let bar = r.flush().unwrap().unwrap(); + assert_eq!(bar.high, 12.0); + assert_eq!(bar.low, 9.0); + } + + #[test] + fn absorb_lowers_low_on_dipping_candle() { + // The first candle in the bucket sets low = 10.0; the second dips to + // 8.0 and must overwrite. Exercises the `c.low < self.low` branch in + // RolledBar::absorb that the other resampler tests never trigger + // because their follow-up candles always have a higher low. + let mut r = Resampler::new(Timeframe::new(5).unwrap()); + r.push(c(0, 10.0, 11.0, 10.0, 10.5, 1.0)).unwrap(); + r.push(c(1, 10.5, 11.5, 8.0, 9.0, 1.0)).unwrap(); + let bar = r.flush().unwrap().unwrap(); + assert_eq!(bar.low, 8.0); + assert_eq!(bar.high, 11.5); + } + + #[test] + fn flushes_a_non_finite_volume_as_an_error() { + let mut r = Resampler::new(Timeframe::new(5).unwrap()); + // Two near-max volumes in the same bucket sum to +inf. + assert!(r + .push(c(0, 10.0, 11.0, 9.0, 10.5, f64::MAX)) + .unwrap() + .is_none()); + assert!(r + .push(c(1, 10.0, 11.0, 9.0, 10.5, f64::MAX)) + .unwrap() + .is_none()); + let err = r.flush().unwrap_err(); + assert!(matches!(err, Error::Core(_))); + } +} diff --git a/crates/wickra/Cargo.toml b/crates/wickra/Cargo.toml new file mode 100644 index 0000000..953fa8a --- /dev/null +++ b/crates/wickra/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "wickra" +description = "Streaming-first technical analysis library: incremental indicators, drop-in TA-Lib replacement, multi-language." +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +documentation = "https://docs.wickra.org" + +# Render the docs on docs.rs with every feature enabled. +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true + +[dependencies] +wickra-core = { workspace = true } + +[features] +default = ["parallel"] +parallel = ["wickra-core/parallel"] + +[dev-dependencies] +approx = { workspace = true } +criterion = { workspace = true } +proptest = { workspace = true } +wickra-data = { path = "../wickra-data" } + +[[bench]] +name = "indicators" +harness = false + +[[bench]] +name = "data_layer" +harness = false diff --git a/crates/wickra/benches/data_layer.rs b/crates/wickra/benches/data_layer.rs new file mode 100644 index 0000000..0075cba --- /dev/null +++ b/crates/wickra/benches/data_layer.rs @@ -0,0 +1,119 @@ +//! Throughput microbenchmarks for the native data layer: CSV parsing, tick +//! aggregation, and resampling. +//! +//! Run with: +//! ```text +//! cargo bench -p wickra --bench data_layer +//! ``` +//! +//! These exercise `wickra-data` — the same native code every binding rides +//! through the FFI boundary characterised in `BENCHMARKS.md` §3. It is what +//! replaces `pandas` / `csv-parse` / manual tick bucketing / `pandas.resample` +//! in the nine non-Rust languages, so the numbers here are the upper bound a +//! binding can reach for "load a CSV, roll ticks into candles, resample a +//! series" without pulling in a single third-party package. +//! +//! The dataset is the checked-in `examples/data/btcusdt-1m.csv` (50 000 real +//! BTCUSDT one-minute candles). Regenerate it with +//! `cargo run -p wickra-examples --bin fetch_btcusdt`. + +use criterion::{criterion_group, criterion_main, Criterion, Throughput}; +use std::hint::black_box; +use wickra::{Candle, Tick}; +use wickra_data::{ + aggregator::{TickAggregator, Timeframe}, + csv::CandleReader, + resample::Resampler, +}; + +const DATASET: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/data/btcusdt-1m.csv" +); +const ONE_MINUTE_MS: i64 = 60_000; + +fn dataset_bytes() -> Vec { + std::fs::read(DATASET).unwrap_or_else(|e| { + panic!( + "could not read the benchmark dataset {DATASET}: {e}\n\ + generate it with `cargo run -p wickra-examples --bin fetch_btcusdt`" + ) + }) +} + +fn load_candles() -> Vec { + CandleReader::from_reader(dataset_bytes().as_slice()) + .unwrap() + .read_all() + .unwrap() +} + +/// CSV bytes -> `Vec`. Throughput is candles (rows) parsed per second. +fn bench_csv_read(c: &mut Criterion) { + let bytes = dataset_bytes(); + let rows = load_candles().len() as u64; + let mut group = c.benchmark_group("data_layer/csv_read"); + group.throughput(Throughput::Elements(rows)); + group.bench_function("btcusdt_1m", |b| { + b.iter(|| { + let candles = CandleReader::from_reader(black_box(bytes.as_slice())) + .unwrap() + .read_all() + .unwrap(); + black_box(candles.len()) + }); + }); + group.finish(); +} + +/// Ticks -> one-minute candles. Throughput is ticks aggregated per second. +fn bench_tick_aggregate(c: &mut Criterion) { + let ticks: Vec = load_candles() + .iter() + .map(|candle| Tick::new(candle.close, candle.volume, candle.timestamp).unwrap()) + .collect(); + let mut group = c.benchmark_group("data_layer/tick_aggregate"); + group.throughput(Throughput::Elements(ticks.len() as u64)); + group.bench_function("1m_buckets", |b| { + b.iter(|| { + let mut agg = TickAggregator::new(Timeframe::millis(ONE_MINUTE_MS).unwrap()); + let mut emitted = 0usize; + for tick in &ticks { + emitted += agg.push(black_box(*tick)).unwrap().len(); + } + black_box(emitted) + }); + }); + group.finish(); +} + +/// One-minute candles -> five-minute candles. Throughput is input candles per second. +fn bench_resample(c: &mut Criterion) { + let candles = load_candles(); + let mut group = c.benchmark_group("data_layer/resample"); + group.throughput(Throughput::Elements(candles.len() as u64)); + group.bench_function("1m_to_5m", |b| { + b.iter(|| { + let mut resampler = Resampler::new(Timeframe::millis(5 * ONE_MINUTE_MS).unwrap()); + let mut emitted = 0usize; + for candle in &candles { + if resampler.push(black_box(*candle)).unwrap().is_some() { + emitted += 1; + } + } + if resampler.flush().unwrap().is_some() { + emitted += 1; + } + black_box(emitted) + }); + }); + group.finish(); +} + +criterion_group!( + benches, + bench_csv_read, + bench_tick_aggregate, + bench_resample +); +criterion_main!(benches); diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs new file mode 100644 index 0000000..b108986 --- /dev/null +++ b/crates/wickra/benches/indicators.rs @@ -0,0 +1,455 @@ +//! Microbenchmarks for a curated subset of the indicator catalogue. +//! +//! Run with: +//! ```text +//! cargo bench -p wickra +//! ``` +//! +//! Each benchmark feeds real BTCUSDT 1-minute candles — read from the +//! checked-in dataset at the workspace `examples/data/btcusdt-1m.csv` — +//! through both the streaming (`update` loop) and batch APIs of an +//! indicator. Sizes cover small (1 000), medium (10 000), and large +//! (50 000) workloads, taken as prefixes of that dataset. +//! +//! ## Why curated rather than exhaustive +//! +//! The indicator catalogue has 214 entries; benching every single one +//! at three sizes inflates `cargo bench` to >10 minutes for diminishing +//! signal. The selection below picks the cheapest baseline and the +//! most-expensive representative in each family — a regression in any +//! of those is the meaningful signal; per-family redundancy benches +//! mostly produce noise. +//! +//! If you need a benchmark for a specific indicator that is not in this +//! list, add it locally and run `cargo bench -- ` to target just +//! that bench. +//! +//! Regenerate the dataset with: +//! ```text +//! cargo run -p wickra-examples --bin fetch_btcusdt +//! ``` + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::hint::black_box; +use wickra::{ + Adx, AnchoredRsi, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, + Candle, Cci, ClassicPivots, ConnorsRsi, DepthSlope, DerivativesTick, EffectiveSpread, Ema, + EmpiricalModeDecomposition, Engulfing, Frama, FundingRate, FundingRateZScore, + HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, KylesLambda, + Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, + Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar, + RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput, + TdSequential, TdSequentialOutput, TpoProfile, TpoProfileOutput, Trade, TradeImbalance, + TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, + VolumeProfile, VolumeProfileOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, + YangZhangVolatility, T3, +}; +use wickra_data::csv::CandleReader; + +/// Workload sizes, in candles. Each is taken as a prefix of the dataset. +const SIZES: &[usize] = &[1_000, 10_000, 50_000]; + +/// Load the checked-in BTCUSDT 1-minute candle dataset from the workspace +/// `examples/data/` directory. +fn load_candles() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/data/btcusdt-1m.csv" + ); + let mut reader = CandleReader::open(path).unwrap_or_else(|e| { + panic!( + "could not open the benchmark dataset {path}: {e}\n\ + generate it with `cargo run -p wickra-examples --bin fetch_btcusdt`" + ) + }); + reader + .read_all() + .expect("the benchmark dataset is valid OHLCV") +} + +fn bench_scalar(c: &mut Criterion, name: &str, prices: &[f64], make: F) +where + F: Fn() -> I, + I: Indicator + BatchExt, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(prices.len()); + let series = &prices[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| { + b.iter(|| { + let mut ind = make(); + for p in prices { + black_box(ind.update(*p)); + } + }); + }); + group.bench_with_input(BenchmarkId::new("batch", n), series, |b, prices| { + b.iter(|| { + let mut ind = make(); + black_box(ind.batch(prices)); + }); + }); + } + group.finish(); +} + +fn bench_candle_input(c: &mut Criterion, name: &str, candles: &[Candle], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(candles.len()); + let series = &candles[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, candles| { + b.iter(|| { + let mut ind = make(); + for c in candles { + black_box(ind.update(*c)); + } + }); + }); + } + group.finish(); +} + +fn bench_orderbook_input(c: &mut Criterion, name: &str, books: &[OrderBook], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(books.len()); + let series = &books[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, books| { + b.iter(|| { + let mut ind = make(); + for book in books { + black_box(ind.update(book.clone())); + } + }); + }); + } + group.finish(); +} + +fn bench_trade_input(c: &mut Criterion, name: &str, trades: &[Trade], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(trades.len()); + let series = &trades[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, trades| { + b.iter(|| { + let mut ind = make(); + for t in trades { + black_box(ind.update(*t)); + } + }); + }); + } + group.finish(); +} + +fn bench_tradequote_input(c: &mut Criterion, name: &str, quotes: &[TradeQuote], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(quotes.len()); + let series = "es[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, quotes| { + b.iter(|| { + let mut ind = make(); + for q in quotes { + black_box(ind.update(*q)); + } + }); + }); + } + group.finish(); +} + +fn bench_derivatives_input( + c: &mut Criterion, + name: &str, + ticks: &[DerivativesTick], + make: F, +) where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(ticks.len()); + let series = &ticks[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, ticks| { + b.iter(|| { + let mut ind = make(); + for tick in ticks { + black_box(ind.update(*tick)); + } + }); + }); + } + group.finish(); +} + +fn bench_scalar_multi(c: &mut Criterion, name: &str, prices: &[f64], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + let mut group = c.benchmark_group(name); + for &n in SIZES { + let n = n.min(prices.len()); + let series = &prices[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| { + b.iter(|| { + let mut ind = make(); + for p in prices { + black_box(ind.update(*p)); + } + }); + }); + } + group.finish(); +} + +#[allow(clippy::too_many_lines)] +fn benches(c: &mut Criterion) { + let candles = load_candles(); + let closes: Vec = candles.iter().map(|c| c.close).collect(); + + // === Family 01 — Moving Averages === + // Sma: cheapest baseline; Ema: recursive baseline; Frama / Jma / T3: adaptive / expensive. + bench_scalar(c, "sma", &closes, || Sma::new(14).unwrap()); + bench_scalar(c, "ema", &closes, || Ema::new(14).unwrap()); + bench_scalar(c, "frama", &closes, || Frama::new(16).unwrap()); + bench_scalar(c, "jma", &closes, || Jma::new(14, 0.0, 2).unwrap()); + bench_scalar(c, "t3", &closes, || T3::new(14, 0.7).unwrap()); + + // === Family 02 — Momentum Oscillators === + // Rsi: textbook baseline; ConnorsRsi: three-component composite. + bench_scalar(c, "rsi", &closes, || Rsi::new(14).unwrap()); + bench_scalar(c, "anchored_rsi", &closes, AnchoredRsi::new); + bench_candle_input(c, "cci", &candles, || Cci::new(20).unwrap()); + bench_scalar(c, "connors_rsi", &closes, ConnorsRsi::classic); + + // === Family 03 — Trend & Directional === + // Adx is multi-component (DI+/DI-/ADX); WaveTrend is the heaviest in this group. + bench_candle_input(c, "adx", &candles, || Adx::new(14).unwrap()); + bench_candle_input(c, "wave_trend", &candles, || WaveTrend::classic().unwrap()); + + // === Family 04 — Price Oscillators === + // Macd: multi-output baseline; Stc: deeply recursive (most expensive in family). + bench_scalar_multi::<_, _, MacdOutput>(c, "macd", &closes, MacdIndicator::classic); + bench_scalar(c, "ppo", &closes, || Ppo::new(12, 26).unwrap()); + bench_scalar(c, "stc", &closes, Stc::classic); + + // === Family 05 — Volatility & Bands === + // Atr: cheap baseline; Bollinger: stddev-heavy; YangZhang: most-expensive volatility metric. + bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap()); + bench_scalar_multi::<_, _, BollingerOutput>(c, "bollinger", &closes, || { + BollingerBands::new(20, 2.0).unwrap() + }); + bench_candle_input(c, "parkinson", &candles, || { + ParkinsonVolatility::new(20, 252).unwrap() + }); + bench_candle_input(c, "yang_zhang", &candles, || { + YangZhangVolatility::new(20, 252).unwrap() + }); + + // === Family 06 — Bands & Channels === + // TtmSqueeze: multi-indicator composite; VwapStdDevBands: volume-weighted. + bench_candle_input::<_, _, TtmSqueezeOutput>(c, "ttm_squeeze", &candles, || { + TtmSqueeze::new(20, 2.0, 1.5).unwrap() + }); + bench_candle_input::<_, _, VwapStdDevBandsOutput>(c, "vwap_stddev_bands", &candles, || { + VwapStdDevBands::new(2.0).unwrap() + }); + + // === Family 07 — Trailing Stops === + // Psar: textbook trailing stop; SuperTrend: ATR-anchored band. + bench_candle_input(c, "psar", &candles, || Psar::new(0.02, 0.02, 0.2).unwrap()); + bench_candle_input::<_, _, SuperTrendOutput>(c, "super_trend", &candles, || { + SuperTrend::new(10, 3.0).unwrap() + }); + + // === Family 08 — Volume === + // Obv: simplest volume cumul; Vwap: session cumul; RollingVwap: rolling window. + bench_candle_input(c, "obv", &candles, Obv::new); + bench_candle_input(c, "vwap", &candles, Vwap::new); + bench_candle_input(c, "rolling_vwap", &candles, || { + RollingVwap::new(20).unwrap() + }); + + // === Family 09 — Price Statistics === + // LinearRegression: OLS baseline; HurstExponent: R/S analysis (most expensive in family); + // Autocorrelation: lag-correlation. + bench_scalar(c, "linear_regression", &closes, || { + LinearRegression::new(14).unwrap() + }); + bench_scalar(c, "hurst_exponent", &closes, || { + HurstExponent::new(100, 4).unwrap() + }); + bench_scalar(c, "autocorrelation", &closes, || { + Autocorrelation::new(20, 1).unwrap() + }); + + // === Family 10 — Ehlers / Cycle (DSP) === + // Mama: paired adaptive MA (multi-output); HilbertDominantCycle: cycle estimation; + // EmpiricalModeDecomposition: heaviest DSP indicator in the catalogue. + bench_scalar_multi::<_, _, MamaOutput>(c, "mama", &closes, Mama::classic); + bench_scalar( + c, + "hilbert_dominant_cycle", + &closes, + HilbertDominantCycle::new, + ); + bench_scalar(c, "empirical_mode_decomposition", &closes, || { + EmpiricalModeDecomposition::new(20, 0.5).unwrap() + }); + + // === Family 11 — Pivots & Support/Resistance === + bench_candle_input(c, "classic_pivots", &candles, ClassicPivots::new); + + // === Family 12 — DeMark === + // TdSequential is the most complex in the family (state machine + countdown). + bench_candle_input::<_, _, TdSequentialOutput>( + c, + "td_sequential", + &candles, + TdSequential::classic, + ); + + // === Family 13 — Ichimoku & Charts === + bench_candle_input::<_, _, IchimokuOutput>(c, "ichimoku", &candles, Ichimoku::classic); + + // === Family 14 — Candlestick Patterns === + // Engulfing is two-bar so representative across the candlestick family. + bench_candle_input(c, "engulfing", &candles, Engulfing::new); + + // === Family 15 — Market Profile === + bench_candle_input::<_, _, ValueAreaOutput>(c, "value_area", &candles, || { + ValueArea::new(20, 50, 0.70).unwrap() + }); + bench_candle_input::<_, _, VolumeProfileOutput>(c, "volume_profile", &candles, || { + VolumeProfile::new(20, 50).unwrap() + }); + bench_candle_input::<_, _, TpoProfileOutput>(c, "tpo_profile", &candles, || { + TpoProfile::new(20, 50).unwrap() + }); + + // === Family 16 — Risk / Performance Metrics === + // Close-prices stand in for the equity curve / return stream; absolute + // numbers aren't meaningful here — what matters is the per-update cost. + bench_scalar(c, "sharpe_ratio", &closes, || { + SharpeRatio::new(20, 0.0).unwrap() + }); + bench_scalar(c, "max_drawdown", &closes, || MaxDrawdown::new(20).unwrap()); + bench_scalar(c, "calmar_ratio", &closes, || CalmarRatio::new(20).unwrap()); + bench_scalar(c, "value_at_risk", &closes, || { + ValueAtRisk::new(50, 0.95).unwrap() + }); + + // === Family — Microstructure === + // No order-book dataset ships with the repo, so synthesise a five-level + // book around each candle close. Benches the cheapest (top-of-book) and the + // most-expensive (full-depth sum) representatives of the family. + let books: Vec = candles + .iter() + .map(|candle| { + let mid = candle.close; + let tick = (mid * 0.0001).max(0.01); + let bids = (0..5u32) + .map(|i| Level::new_unchecked(mid - tick * f64::from(i + 1), 1.0 + f64::from(i))) + .collect(); + let asks = (0..5u32) + .map(|i| Level::new_unchecked(mid + tick * f64::from(i + 1), 1.0 + f64::from(i))) + .collect(); + OrderBook::new_unchecked(bids, asks) + }) + .collect(); + bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new); + bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new); + bench_orderbook_input(c, "microprice", &books, Microprice::new); + bench_orderbook_input(c, "depth_slope", &books, DepthSlope::new); + + // Synthesise a trade tape from candles: one trade per bar, sided by the + // candle's direction. SignedVolume is the cheapest; TradeImbalance carries + // a rolling window and is the most expensive. + let trades: Vec = candles + .iter() + .map(|candle| { + let side = if candle.close >= candle.open { + Side::Buy + } else { + Side::Sell + }; + Trade::new_unchecked(candle.close, candle.volume, side, candle.timestamp) + }) + .collect(); + bench_trade_input(c, "signed_volume", &trades, SignedVolume::new); + bench_trade_input(c, "trade_imbalance", &trades, || { + TradeImbalance::new(50).unwrap() + }); + + // Pair each synthetic trade with the candle close as the prevailing mid to + // exercise the price-impact family. EffectiveSpread is the stateless + // representative. + let quotes: Vec = trades + .iter() + .map(|trade| TradeQuote::new_unchecked(*trade, trade.price)) + .collect(); + bench_tradequote_input(c, "effective_spread", "es, EffectiveSpread::new); + bench_tradequote_input(c, "kyles_lambda", "es, || KylesLambda::new(50).unwrap()); + + // === Family — Derivatives === + // No derivatives feed ships with the repo, so synthesise a tick per candle: + // the close drives the mark price, funding tracks the candle's body, and + // open interest follows volume. FundingRate is the cheapest (passthrough); + // FundingRateZScore carries a rolling window and is the most expensive. + let ticks: Vec = candles + .iter() + .map(|candle| { + let funding = (candle.close - candle.open) / candle.open * 0.01; + DerivativesTick::new_unchecked( + funding, + candle.close, + candle.close * 0.999, + candle.close * 1.001, + candle.volume * 100.0, + candle.volume * 0.6, + candle.volume * 0.4, + candle.volume * 0.5, + candle.volume * 0.5, + 0.0, + 0.0, + candle.timestamp, + ) + }) + .collect(); + bench_derivatives_input(c, "funding_rate", &ticks, FundingRate::new); + bench_derivatives_input(c, "funding_rate_zscore", &ticks, || { + FundingRateZScore::new(50).unwrap() + }); +} + +criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); +criterion_main!(wickra_benches); diff --git a/crates/wickra/src/lib.rs b/crates/wickra/src/lib.rs new file mode 100644 index 0000000..94adfe8 --- /dev/null +++ b/crates/wickra/src/lib.rs @@ -0,0 +1,21 @@ +//! Wickra: streaming-first technical analysis. +//! +//! This crate is a thin re-export of [`wickra_core`] so downstream users can depend on +//! a single `wickra` package without thinking about the internal split. Every public +//! item lives in `wickra_core`; only the names re-exported here are part of the stable +//! public API. +//! +//! # Example +//! +//! ``` +//! use wickra::{Indicator, Sma}; +//! +//! let mut sma = Sma::new(3).unwrap(); +//! let prices = [1.0, 2.0, 3.0, 4.0, 5.0]; +//! let out: Vec> = prices.iter().map(|p| sma.update(*p)).collect(); +//! assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]); +//! ``` + +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub use wickra_core::*; diff --git a/crates/wickra/tests/example_data.rs b/crates/wickra/tests/example_data.rs new file mode 100644 index 0000000..67e5012 --- /dev/null +++ b/crates/wickra/tests/example_data.rs @@ -0,0 +1,68 @@ +//! Integration test: the checked-in BTCUSDT example datasets parse cleanly, +//! hold enough rows, and carry strictly increasing — and, for the fixed +//! timeframes, evenly spaced — timestamps. +//! +//! The datasets live in the workspace `examples/data/` directory and are +//! produced by the `fetch_btcusdt` example. Regenerate them with: +//! +//! ```text +//! cargo run -p wickra-examples --bin fetch_btcusdt +//! ``` + +use wickra_data::csv::CandleReader; + +/// `(file name, minimum row count, expected step in ms)`. The step is `None` +/// for the monthly file, whose buckets are 28–31 days and thus uneven. +const DATASETS: &[(&str, usize, Option)] = &[ + ("btcusdt-1m.csv", 50_000, Some(60_000)), + ("btcusdt-5m.csv", 10_000, Some(300_000)), + ("btcusdt-15m.csv", 10_000, Some(900_000)), + ("btcusdt-1h.csv", 10_000, Some(3_600_000)), + ("btcusdt-12h.csv", 5_000, Some(43_200_000)), + // 1d and 1month collect all the history Binance offers, which grows over + // time — assert a lower bound rather than an exact count. + ("btcusdt-1d.csv", 3_000, Some(86_400_000)), + ("btcusdt-1month.csv", 100, None), +]; + +fn dataset_path(file: &str) -> String { + format!("{}/../../examples/data/{file}", env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn every_dataset_parses_and_is_well_formed() { + for &(file, min_rows, step) in DATASETS { + let path = dataset_path(file); + let mut reader = + CandleReader::open(&path).unwrap_or_else(|e| panic!("{file}: cannot open {path}: {e}")); + // `read_all` validates every row through `Candle::new`, so a successful + // read already proves each OHLC tuple is finite and internally + // consistent (high >= low, etc.). + let candles = reader + .read_all() + .unwrap_or_else(|e| panic!("{file}: invalid OHLCV row: {e}")); + + assert!( + candles.len() >= min_rows, + "{file}: expected at least {min_rows} rows, got {}", + candles.len() + ); + + for pair in candles.windows(2) { + let (prev, next) = (pair[0], pair[1]); + assert!( + next.timestamp > prev.timestamp, + "{file}: timestamps must strictly increase, saw {} then {}", + prev.timestamp, + next.timestamp + ); + if let Some(step) = step { + assert_eq!( + next.timestamp - prev.timestamp, + step, + "{file}: a fixed timeframe must be evenly spaced by {step} ms" + ); + } + } + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..87627c5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# In-repo documentation + +This directory is reserved for in-repo docs that live alongside the source. +Currently it only holds this stub. + +For project orientation, read [`../README.md`](../README.md). For a compact, +family-grouped index of all 514 indicators with one-line summaries and links +to each implementation file, read [`../INDICATORS.md`](../INDICATORS.md). + +To regenerate `INDICATORS.md` after editing the indicator list: + +```bash +python scripts/gen_indicators_index.py +``` \ No newline at end of file diff --git a/scripts/gen_indicators_index.py b/scripts/gen_indicators_index.py new file mode 100644 index 0000000..0bc077e --- /dev/null +++ b/scripts/gen_indicators_index.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Generate INDICATORS.md — the compact entry file for AI agents. + +One row per indicator: public struct name + the file's `//!` one-line title ++ a link to the full Rust implementation. Family taxonomy is taken from +`FAMILIES` in `crates/wickra-core/src/indicators/mod.rs` (the single source +of truth, also enforced by an `assert_eq!(total, 514)` test in that file). + +Run from project root: + python scripts/gen_indicators_index.py + +ponytail: static snapshot. Rerun after adding or renaming indicators. +The `mod.rs::family_tests` total-count assertion keeps `FAMILIES` honest. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MOD_RS = ROOT / "crates" / "wickra-core" / "src" / "indicators" / "mod.rs" +INDIC_DIR = ROOT / "crates" / "wickra-core" / "src" / "indicators" +OUTPUT = ROOT / "INDICATORS.md" +REL_INDIC_DIR = INDIC_DIR.relative_to(ROOT).as_posix() + + +def parse_module_decl_map(mod_rs_text: str) -> dict[str, str]: + """{struct_name: file_stem} built from `mod ;` declarations. + + Files often expose both an `Output` struct and an `` struct; + we collect every `pub struct` per file so either name resolves to the + same file. `pattern_swing` is intentionally excluded (it is a `pub(crate)` + helper, not a public indicator). + """ + struct_to_snake: dict[str, str] = {} + in_families = False + for line in mod_rs_text.splitlines(): + if line.startswith("pub const FAMILIES"): + in_families = True + continue + if in_families: + continue + m = re.match( + r"^(?:pub(?:\(crate\))?\s+)?mod\s+([a-z][a-z0-9_]*)\s*;\s*$", + line.strip(), + ) + if not m: + continue + snake = m.group(1) + if snake in {"tests", "pattern_swing"}: + continue + path = INDIC_DIR / f"{snake}.rs" + if not path.exists(): + print(f"warn: module {snake} declared but file missing", file=sys.stderr) + continue + for fl in path.read_text(encoding="utf-8").splitlines(): + sm = re.match(r"^\s*pub\s+struct\s+([A-Z][A-Za-z0-9]*)\b", fl) + if sm: + struct_to_snake.setdefault(sm.group(1), snake) + return struct_to_snake + + +def parse_families(mod_rs_text: str) -> list[tuple[str, list[str]]]: + """[(family_name, [struct_name, ...]), ...] from FAMILIES array.""" + start = mod_rs_text.index("pub const FAMILIES") + line_end = mod_rs_text.index("\n", start) + line_text = mod_rs_text[start:line_end] + last_amp = line_text.rindex("&[") + body_start = start + last_amp + depth = 0 + i = body_start + while i < len(mod_rs_text): + ch = mod_rs_text[i] + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + break + i += 1 + body = mod_rs_text[body_start : i + 1] + families: list[tuple[str, list[str]]] = [] + for fam_match in re.finditer( + r'\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*&\[(.*?)\]\s*,?\s*\)', + body, + re.DOTALL, + ): + names = [m.group(1) for m in re.finditer(r'"([A-Za-z0-9_]+)"', fam_match.group(2))] + families.append((fam_match.group(1), names)) + return families + + +def first_title(path: Path) -> str: + """First non-empty `//!` line — the one-line intent.""" + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("//!") and line[3:].strip(): + return line[3:].strip() + return "" + + +def slug(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") + + +def main() -> int: + text = MOD_RS.read_text(encoding="utf-8") + struct_to_snake = parse_module_decl_map(text) + families = parse_families(text) + + total = sum(len(n) for _, n in families) + seen: set[str] = set() + for _, n in families: + for x in n: + if x in seen: + print(f"error: duplicate across families: {x}", file=sys.stderr) + return 2 + seen.add(x) + unresolved = [n for _, n in families if any(s not in struct_to_snake for s in n)] + if unresolved: + print( + f"error: struct names not in `mod` decls: {unresolved[:5]}", + file=sys.stderr, + ) + return 2 + + out: list[str] = [] + out.append("# Wickra Indicators") + out.append("") + out.append( + f"Compact index of all **{total}** indicators in `{REL_INDIC_DIR}/`, " + f"grouped by `FAMILIES` in `{REL_INDIC_DIR}/mod.rs`. Each row links " + f"to the full implementation file." + ) + out.append("") + out.append("> AI agents: skim family headers, then `Read` the linked `.rs` " + "file for the canonical formula. For QuantDinger, fetch the " + "live contract with `quantdinger_get_indicator_authoring_contract` " + "before writing code.") + out.append("") + out.append("## Families") + out.append("") + for family, names in families: + out.append(f"- [{family}](#{slug(family)}) ({len(names)})") + out.append("") + + for family, names in families: + out.append(f"## {family} ({len(names)})") + out.append("") + for struct_name in sorted(names, key=str.lower): + snake = struct_to_snake[struct_name] + title = first_title(INDIC_DIR / f"{snake}.rs").rstrip(".") + out.append( + f"- `{struct_name}` — {title} · " + f"[`{snake}.rs`]({REL_INDIC_DIR}/{snake}.rs)" + ) + out.append("") + + OUTPUT.write_text("\n".join(out) + "\n", encoding="utf-8") + print(f"wrote {OUTPUT.relative_to(ROOT)} ({total} indicators, " + f"{len(families)} families)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file