diff --git a/examples/README.md b/examples/README.md index 2818e229..5c530673 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,9 @@ The Rust examples live in the `wickra-examples` workspace member crate. | `parallel_assets.rs` | Serial vs `BatchExt::batch_parallel` (rayon) over a synthetic panel, with speedup. | `cargo run --release -p wickra-examples --bin parallel_assets -- --assets 200 --bars 5000` | | `fetch_btcusdt.rs` | Download real BTCUSDT klines from the Binance REST API into `examples/data/`. | `cargo run -p wickra-examples --bin fetch_btcusdt` | | `live_binance.rs` | Stream live Binance klines through an indicator over a resilient WebSocket. | `cargo run -p wickra-examples --bin live_binance` | +| `strategy_rsi_mean_reversion.rs` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `cargo run --release -p wickra-examples --bin strategy_rsi_mean_reversion` | +| `strategy_macd_adx.rs` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `cargo run --release -p wickra-examples --bin strategy_macd_adx` | +| `strategy_bollinger_squeeze.rs` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `cargo run --release -p wickra-examples --bin strategy_bollinger_squeeze` | ## Python — `examples/python/` @@ -28,6 +31,9 @@ The Rust examples live in the `wickra-examples` workspace member crate. | `multi_timeframe.py` | Resample a 1-minute CSV to coarser timeframes and compare. | `python -m examples.python.multi_timeframe <1m.csv>` | | `parallel_assets.py` | Process many symbols in parallel — the Rust extension releases the GIL during batch computation. | `python -m examples.python.parallel_assets --assets 200 --bars 5000` | | `fetch_btcusdt.py` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (urllib + stdlib only). | `python -m examples.python.fetch_btcusdt` | +| `strategy_rsi_mean_reversion.py` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `python -m examples.python.strategy_rsi_mean_reversion` | +| `strategy_macd_adx.py` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `python -m examples.python.strategy_macd_adx` | +| `strategy_bollinger_squeeze.py` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `python -m examples.python.strategy_bollinger_squeeze` | `live_trading.py` additionally needs `pip install websockets`. diff --git a/examples/python/strategy_bollinger_squeeze.py b/examples/python/strategy_bollinger_squeeze.py new file mode 100644 index 00000000..6f95155c --- /dev/null +++ b/examples/python/strategy_bollinger_squeeze.py @@ -0,0 +1,178 @@ +"""Strategy example: Bollinger-Squeeze breakout with ATR-based stop. + +Enters long when the Bollinger Bandwidth has just printed a fresh +6-month low (the squeeze) and price closes above the upper band (the +release). Exits when price closes below entry minus 2 * ATR(14), or +when the upper band rolls back under the entry price. 0.1% fees per +trade. + +Educational example. NOT a live trading recommendation. + +Run with:: + + python -m examples.python.strategy_bollinger_squeeze + +Uses the checked-in ``examples/data/btcusdt-1d.csv`` dataset because +daily bars give an interpretable 6-month-low lookback (~180 bars). +""" + +from __future__ import annotations + +import csv +import math +from collections import deque +from pathlib import Path + +import wickra as ta + +FEE = 0.001 +BB_PERIOD = 20 +BB_K = 2.0 +ATR_PERIOD = 14 +ATR_STOP_MULT = 2.0 +SQUEEZE_LOOKBACK = 180 + + +def load_candles(path: Path) -> list[dict[str, float]]: + with path.open() as fh: + reader = csv.DictReader(fh) + return [ + { + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"]), + } + for r in reader + ] + + +def print_summary( + name: str, + first_price: float, + last_price: float, + bars: int, + closed_trades: list[float], + final_equity: float, + equity_curve: list[float], +) -> None: + buy_hold = last_price / first_price + strat_return = final_equity - 1.0 + bh_return = buy_hold - 1.0 + wins = sum(1 for r in closed_trades if r > 0) + losses = sum(1 for r in closed_trades if r < 0) + best = max(closed_trades) if closed_trades else 0.0 + worst = min(closed_trades) if closed_trades else 0.0 + n = len(closed_trades) + mean_ret = sum(closed_trades) / n if n else 0.0 + var_ret = ( + sum((r - mean_ret) ** 2 for r in closed_trades) / (n - 1) if n > 1 else 0.0 + ) + sharpe = mean_ret / math.sqrt(var_ret) if var_ret > 0 else 0.0 + peak = equity_curve[0] if equity_curve else 1.0 + max_dd = 0.0 + for eq in equity_curve: + if eq > peak: + peak = eq + dd = (peak - eq) / peak + if dd > max_dd: + max_dd = dd + print(f"=== {name} ===") + print(f"Bars: {bars}") + print(f"Trades: {n} (W{wins} / L{losses})") + print(f"Strategy return: {strat_return * 100:+.2f}%") + print(f"Buy & Hold return: {bh_return * 100:+.2f}%") + print(f"Excess over BH: {(strat_return - bh_return) * 100:+.2f}%") + print(f"Max drawdown: {max_dd * 100:.2f}%") + print( + f"Per-trade Sharpe: {sharpe:.2f} " + f"(mean {mean_ret:+.4f}, stddev {math.sqrt(var_ret):.4f})" + ) + print(f"Best / worst trade: {best * 100:+.2f}% / {worst * 100:+.2f}%") + print() + print( + "NOTE: Educational example — fees, slippage, funding costs and tax " + "effects are simplified or omitted. Past performance is not " + "indicative of future results." + ) + + +def main() -> None: + path = Path(__file__).resolve().parents[1] / "data" / "btcusdt-1d.csv" + candles = load_candles(path) + if len(candles) < SQUEEZE_LOOKBACK + BB_PERIOD: + raise SystemExit( + f"dataset has only {len(candles)} bars; need at least " + f"{SQUEEZE_LOOKBACK + BB_PERIOD}" + ) + + bb = ta.BollingerBands(BB_PERIOD, BB_K) + atr = ta.ATR(ATR_PERIOD) + bw_window: deque[float] = deque(maxlen=SQUEEZE_LOOKBACK) + + in_position = False + entry_price = 0.0 + stop_level = 0.0 + closed_trades: list[float] = [] + equity = 1.0 + equity_curve: list[float] = [] + + for c in candles: + bb_out = bb.update(c["close"]) + atr_val = atr.update(c["high"], c["low"], c["close"]) + price = c["close"] + mtm = equity * (price / entry_price) if in_position else equity + equity_curve.append(mtm) + + if bb_out is None or atr_val is None: + continue + + upper = bb_out[0] if isinstance(bb_out, tuple) else bb_out.upper + middle = bb_out[1] if isinstance(bb_out, tuple) else bb_out.middle + lower = bb_out[2] if isinstance(bb_out, tuple) else bb_out.lower + bandwidth = (upper - lower) / middle if abs(middle) > 1e-12 else float("nan") + + if math.isnan(bandwidth): + continue + bw_window.append(bandwidth) + if len(bw_window) < SQUEEZE_LOOKBACK: + continue + min_bw = min(bw_window) + + if in_position: + stop_hit = price < stop_level + upper_collapse = upper < entry_price + if stop_hit or upper_collapse: + trade_ret = price / entry_price - 1.0 + closed_trades.append(trade_ret) + equity *= (1.0 + trade_ret) * (1.0 - FEE) + in_position = False + else: + is_new_low = abs(bandwidth - min_bw) < 1e-12 + breakout = price > upper + if is_new_low and breakout: + entry_price = price + stop_level = price - ATR_STOP_MULT * atr_val + equity *= 1.0 - FEE + in_position = True + + if in_position: + last_price = candles[-1]["close"] + trade_ret = last_price / entry_price - 1.0 + closed_trades.append(trade_ret) + equity *= (1.0 + trade_ret) * (1.0 - FEE) + + print_summary( + "Bollinger Squeeze Breakout (1d, BTCUSDT)", + candles[0]["close"], + candles[-1]["close"], + len(candles), + closed_trades, + equity, + equity_curve, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/python/strategy_macd_adx.py b/examples/python/strategy_macd_adx.py new file mode 100644 index 00000000..4a119b4c --- /dev/null +++ b/examples/python/strategy_macd_adx.py @@ -0,0 +1,155 @@ +"""Strategy example: MACD crossover with ADX trend-strength filter. + +Long-only trend follower. Entries fire on a MACD-line-crosses-above- +signal-line event while ADX(14) > 20 (i.e. directional market). Exits +on the opposite MACD crossover regardless of ADX. 0.1% fees per trade. + +Educational example. NOT a live trading recommendation. + +Run with:: + + python -m examples.python.strategy_macd_adx + +Uses the checked-in ``examples/data/btcusdt-1h.csv`` dataset. +""" + +from __future__ import annotations + +import csv +import math +from pathlib import Path + +import wickra as ta + +FEE = 0.001 +ADX_FLOOR = 20.0 + + +def load_candles(path: Path) -> list[dict[str, float]]: + with path.open() as fh: + reader = csv.DictReader(fh) + return [ + { + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"]), + } + for r in reader + ] + + +def print_summary( + name: str, + first_price: float, + last_price: float, + bars: int, + closed_trades: list[float], + final_equity: float, + equity_curve: list[float], +) -> None: + buy_hold = last_price / first_price + strat_return = final_equity - 1.0 + bh_return = buy_hold - 1.0 + wins = sum(1 for r in closed_trades if r > 0) + losses = sum(1 for r in closed_trades if r < 0) + best = max(closed_trades) if closed_trades else 0.0 + worst = min(closed_trades) if closed_trades else 0.0 + n = len(closed_trades) + mean_ret = sum(closed_trades) / n if n else 0.0 + var_ret = ( + sum((r - mean_ret) ** 2 for r in closed_trades) / (n - 1) if n > 1 else 0.0 + ) + sharpe = mean_ret / math.sqrt(var_ret) if var_ret > 0 else 0.0 + peak = equity_curve[0] if equity_curve else 1.0 + max_dd = 0.0 + for eq in equity_curve: + if eq > peak: + peak = eq + dd = (peak - eq) / peak + if dd > max_dd: + max_dd = dd + print(f"=== {name} ===") + print(f"Bars: {bars}") + print(f"Trades: {n} (W{wins} / L{losses})") + print(f"Strategy return: {strat_return * 100:+.2f}%") + print(f"Buy & Hold return: {bh_return * 100:+.2f}%") + print(f"Excess over BH: {(strat_return - bh_return) * 100:+.2f}%") + print(f"Max drawdown: {max_dd * 100:.2f}%") + print( + f"Per-trade Sharpe: {sharpe:.2f} " + f"(mean {mean_ret:+.4f}, stddev {math.sqrt(var_ret):.4f})" + ) + print(f"Best / worst trade: {best * 100:+.2f}% / {worst * 100:+.2f}%") + print() + print( + "NOTE: Educational example — fees, slippage, funding costs and tax " + "effects are simplified or omitted. Past performance is not " + "indicative of future results." + ) + + +def main() -> None: + path = Path(__file__).resolve().parents[1] / "data" / "btcusdt-1h.csv" + candles = load_candles(path) + + macd = ta.MACD(12, 26, 9) + adx = ta.ADX(14) + + in_position = False + entry_price = 0.0 + closed_trades: list[float] = [] + equity = 1.0 + equity_curve: list[float] = [] + prev_hist_sign: bool | None = None + + for c in candles: + macd_out = macd.update(c["close"]) + adx_out = adx.update(c["high"], c["low"], c["close"]) + price = c["close"] + mtm = equity * (price / entry_price) if in_position else equity + equity_curve.append(mtm) + + if macd_out is None or adx_out is None: + continue + + # MACD output is a (macd, signal, histogram) tuple/object across + # bindings. The Python binding returns a namedtuple. + histogram = macd_out[2] if isinstance(macd_out, tuple) else macd_out.histogram + adx_value = adx_out[0] if isinstance(adx_out, tuple) else adx_out.adx + + hist_sign = histogram > 0.0 + cross_up = prev_hist_sign is False and hist_sign + cross_down = prev_hist_sign is True and not hist_sign + prev_hist_sign = hist_sign + + if not in_position and cross_up and adx_value > ADX_FLOOR: + entry_price = price + equity *= 1.0 - FEE + in_position = True + elif in_position and cross_down: + trade_ret = price / entry_price - 1.0 + closed_trades.append(trade_ret) + equity *= (1.0 + trade_ret) * (1.0 - FEE) + in_position = False + + if in_position: + last_price = candles[-1]["close"] + trade_ret = last_price / entry_price - 1.0 + closed_trades.append(trade_ret) + equity *= (1.0 + trade_ret) * (1.0 - FEE) + + print_summary( + "MACD + ADX Trend Filter (1h, BTCUSDT)", + candles[0]["close"], + candles[-1]["close"], + len(candles), + closed_trades, + equity, + equity_curve, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/python/strategy_rsi_mean_reversion.py b/examples/python/strategy_rsi_mean_reversion.py new file mode 100644 index 00000000..6bb0bb9a --- /dev/null +++ b/examples/python/strategy_rsi_mean_reversion.py @@ -0,0 +1,148 @@ +"""Strategy example: RSI mean-reversion on hourly BTCUSDT data. + +Goes long when RSI(14) crosses below 30 (oversold), exits when RSI +crosses above 70 (overbought). Position is binary (full-in / full-out), +fees are 0.1% per trade (Binance maker tier), no stop-loss. + +Educational example. NOT a recommended trading strategy in real markets. +The point is to show how Wickra streaming indicators wire up into a +complete signal -> fill -> PnL -> equity loop in a single file. + +Run with:: + + python -m examples.python.strategy_rsi_mean_reversion + +Uses the checked-in ``examples/data/btcusdt-1h.csv`` dataset. +""" + +from __future__ import annotations + +import csv +import math +from pathlib import Path + +import wickra as ta + +FEE = 0.001 +RSI_PERIOD = 14 +OVERSOLD = 30.0 +OVERBOUGHT = 70.0 + + +def load_candles(path: Path) -> list[dict[str, float]]: + with path.open() as fh: + reader = csv.DictReader(fh) + return [ + { + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"]), + } + for r in reader + ] + + +def print_summary( + name: str, + first_price: float, + last_price: float, + bars: int, + closed_trades: list[float], + final_equity: float, + equity_curve: list[float], +) -> None: + buy_hold = last_price / first_price + strat_return = final_equity - 1.0 + bh_return = buy_hold - 1.0 + wins = sum(1 for r in closed_trades if r > 0) + losses = sum(1 for r in closed_trades if r < 0) + best = max(closed_trades) if closed_trades else 0.0 + worst = min(closed_trades) if closed_trades else 0.0 + n = len(closed_trades) + mean_ret = sum(closed_trades) / n if n else 0.0 + var_ret = ( + sum((r - mean_ret) ** 2 for r in closed_trades) / (n - 1) if n > 1 else 0.0 + ) + sharpe = mean_ret / math.sqrt(var_ret) if var_ret > 0 else 0.0 + peak = equity_curve[0] if equity_curve else 1.0 + max_dd = 0.0 + for eq in equity_curve: + if eq > peak: + peak = eq + dd = (peak - eq) / peak + if dd > max_dd: + max_dd = dd + print(f"=== {name} ===") + print(f"Bars: {bars}") + print(f"Trades: {n} (W{wins} / L{losses})") + print(f"Strategy return: {strat_return * 100:+.2f}%") + print(f"Buy & Hold return: {bh_return * 100:+.2f}%") + print(f"Excess over BH: {(strat_return - bh_return) * 100:+.2f}%") + print(f"Max drawdown: {max_dd * 100:.2f}%") + print( + f"Per-trade Sharpe: {sharpe:.2f} " + f"(mean {mean_ret:+.4f}, stddev {math.sqrt(var_ret):.4f})" + ) + print(f"Best / worst trade: {best * 100:+.2f}% / {worst * 100:+.2f}%") + print() + print( + "NOTE: Educational example — fees, slippage, funding costs and tax " + "effects are simplified or omitted. Past performance is not " + "indicative of future results." + ) + + +def main() -> None: + path = Path(__file__).resolve().parents[1] / "data" / "btcusdt-1h.csv" + candles = load_candles(path) + if len(candles) < RSI_PERIOD * 4: + raise SystemExit(f"dataset too small: {len(candles)}") + + rsi = ta.RSI(RSI_PERIOD) + + in_position = False + entry_price = 0.0 + closed_trades: list[float] = [] + equity = 1.0 + equity_curve: list[float] = [] + + for c in candles: + rsi_val = rsi.update(c["close"]) + price = c["close"] + mtm = equity * (price / entry_price) if in_position else equity + equity_curve.append(mtm) + + if rsi_val is None: + continue + + if not in_position and rsi_val < OVERSOLD: + entry_price = price + equity *= 1.0 - FEE + in_position = True + elif in_position and rsi_val > OVERBOUGHT: + trade_ret = price / entry_price - 1.0 + closed_trades.append(trade_ret) + equity *= (1.0 + trade_ret) * (1.0 - FEE) + in_position = False + + if in_position: + last_price = candles[-1]["close"] + trade_ret = last_price / entry_price - 1.0 + closed_trades.append(trade_ret) + equity *= (1.0 + trade_ret) * (1.0 - FEE) + + print_summary( + "RSI Mean-Reversion (1h, BTCUSDT)", + candles[0]["close"], + candles[-1]["close"], + len(candles), + closed_trades, + equity, + equity_curve, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/rust/src/bin/strategy_bollinger_squeeze.rs b/examples/rust/src/bin/strategy_bollinger_squeeze.rs new file mode 100644 index 00000000..4dd64833 --- /dev/null +++ b/examples/rust/src/bin/strategy_bollinger_squeeze.rs @@ -0,0 +1,217 @@ +//! Strategy example: Bollinger-Squeeze breakout with ATR-based stop. +//! +//! Enters long when the Bollinger Bandwidth has just printed a fresh +//! 6-month low (the *squeeze*) and price closes above the upper band +//! (the *release*). Exits when price closes below the entry minus 2 * +//! ATR(14), or when the upper band starts trailing below the entry +//! price (the squeeze pattern has played out). 0.1% fees per trade. +//! +//! Educational example. **Not** a live trading recommendation. +//! +//! Build with: +//! ```text +//! cargo run --release -p wickra-examples --bin strategy_bollinger_squeeze +//! ``` +//! +//! Uses the checked-in `examples/data/btcusdt-1d.csv` dataset because +//! daily bars give an interpretable "6-month low" lookback (≈180 bars). + +use std::collections::VecDeque; + +use wickra::{Atr, BollingerBands, Indicator}; +use wickra_data::csv::CandleReader; + +const FEE: f64 = 0.001; +const BB_PERIOD: usize = 20; +const BB_K: f64 = 2.0; +const ATR_PERIOD: usize = 14; +const ATR_STOP_MULT: f64 = 2.0; +const SQUEEZE_LOOKBACK: usize = 180; // ≈ 6 months of daily bars + +fn main() -> Result<(), Box> { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/btcusdt-1d.csv"); + let mut reader = CandleReader::open(path)?; + let candles = reader.read_all()?; + if candles.len() < SQUEEZE_LOOKBACK + BB_PERIOD { + return Err(format!( + "dataset has only {} bars; need at least {}", + candles.len(), + SQUEEZE_LOOKBACK + BB_PERIOD + ) + .into()); + } + + let mut bb = BollingerBands::new(BB_PERIOD, BB_K)?; + let mut atr = Atr::new(ATR_PERIOD)?; + + let mut bw_window: VecDeque = VecDeque::with_capacity(SQUEEZE_LOOKBACK); + + let mut in_position = false; + let mut entry_price = 0.0_f64; + let mut stop_level = 0.0_f64; + let mut closed_trades: Vec = Vec::new(); + let mut equity = 1.0_f64; + let mut equity_curve: Vec = Vec::with_capacity(candles.len()); + + for candle in &candles { + let bb_out = bb.update(candle.close); + let atr_out = atr.update(*candle); + let price = candle.close; + + let mtm_equity = if in_position { + equity * (price / entry_price) + } else { + equity + }; + equity_curve.push(mtm_equity); + + let Some(b) = bb_out else { continue }; + let Some(a) = atr_out else { continue }; + + // Bandwidth = (upper - lower) / middle; track its rolling minimum + // over the squeeze lookback so we know what "tight" looks like + // in this regime. + let bandwidth = if b.middle.abs() > f64::EPSILON { + (b.upper - b.lower) / b.middle + } else { + f64::NAN + }; + if bandwidth.is_finite() { + if bw_window.len() == SQUEEZE_LOOKBACK { + bw_window.pop_front(); + } + bw_window.push_back(bandwidth); + } + + if bw_window.len() < SQUEEZE_LOOKBACK || !bandwidth.is_finite() { + continue; + } + let min_bw = bw_window.iter().copied().fold(f64::INFINITY, f64::min); + + if in_position { + // Exit: hit ATR-stop OR upper-band has rolled back under + // the entry (squeeze is exhausted). + let stop_hit = price < stop_level; + let upper_collapse = b.upper < entry_price; + if stop_hit || upper_collapse { + let trade_ret = price / entry_price - 1.0; + closed_trades.push(trade_ret); + equity *= (1.0 + trade_ret) * (1.0 - FEE); + in_position = false; + } + } else { + // Entry trigger: current bandwidth is the new 6-month low AND + // price has just punched above the upper band. + let is_new_low = (bandwidth - min_bw).abs() < 1e-12; + let breakout = price > b.upper; + if is_new_low && breakout { + entry_price = price; + stop_level = price - ATR_STOP_MULT * a; + equity *= 1.0 - FEE; + in_position = true; + } + } + } + + if in_position { + let last_price = candles.last().expect("non-empty above").close; + let trade_ret = last_price / entry_price - 1.0; + closed_trades.push(trade_ret); + equity *= (1.0 + trade_ret) * (1.0 - FEE); + } + + print_summary( + "Bollinger Squeeze Breakout (1d, BTCUSDT)", + candles.first().unwrap().close, + candles.last().unwrap().close, + candles.len(), + &closed_trades, + equity, + &equity_curve, + ); + + Ok(()) +} + +fn print_summary( + name: &str, + first_price: f64, + last_price: f64, + bars: usize, + closed_trades: &[f64], + final_equity: f64, + equity_curve: &[f64], +) { + let buy_hold = last_price / first_price; + let strat_return = final_equity - 1.0; + let bh_return = buy_hold - 1.0; + + let mut wins = 0usize; + let mut losses = 0usize; + let mut best = f64::NEG_INFINITY; + let mut worst = f64::INFINITY; + let mut sum_ret = 0.0_f64; + let mut sum_sq = 0.0_f64; + for &r in closed_trades { + if r > 0.0 { + wins += 1; + } else if r < 0.0 { + losses += 1; + } + best = best.max(r); + worst = worst.min(r); + sum_ret += r; + sum_sq += r * r; + } + let n = closed_trades.len() as f64; + let mean_ret = if n > 0.0 { sum_ret / n } else { 0.0 }; + let var_ret = if n > 1.0 { + (sum_sq - n * mean_ret * mean_ret) / (n - 1.0) + } else { + 0.0 + }; + let sharpe = if var_ret > 0.0 { + mean_ret / var_ret.sqrt() + } else { + 0.0 + }; + + let mut peak = equity_curve.first().copied().unwrap_or(1.0); + let mut max_dd = 0.0_f64; + for &eq in equity_curve { + peak = peak.max(eq); + let dd = (peak - eq) / peak; + if dd > max_dd { + max_dd = dd; + } + } + + println!("=== {name} ==="); + println!("Bars: {bars}"); + println!( + "Trades: {} (W{wins} / L{losses})", + closed_trades.len() + ); + println!("Strategy return: {:+.2}%", strat_return * 100.0); + println!("Buy & Hold return: {:+.2}%", bh_return * 100.0); + println!( + "Excess over BH: {:+.2}%", + (strat_return - bh_return) * 100.0 + ); + println!("Max drawdown: {:.2}%", max_dd * 100.0); + println!( + "Per-trade Sharpe: {sharpe:.2} (mean {:+.4}, stddev {:.4})", + mean_ret, + var_ret.sqrt() + ); + println!( + "Best / worst trade: {:+.2}% / {:+.2}%", + best * 100.0, + worst * 100.0 + ); + println!(); + println!( + "NOTE: Educational example — fees, slippage, funding costs and tax effects \ + are simplified or omitted. Past performance is not indicative of future results." + ); +} diff --git a/examples/rust/src/bin/strategy_macd_adx.rs b/examples/rust/src/bin/strategy_macd_adx.rs new file mode 100644 index 00000000..e4dacba7 --- /dev/null +++ b/examples/rust/src/bin/strategy_macd_adx.rs @@ -0,0 +1,183 @@ +//! Strategy example: MACD crossover with ADX trend-strength filter. +//! +//! Long-only trend follower. Entries fire on a MACD-line-crosses-above- +//! signal-line event while ADX(14) > 20 (i.e. a market with at least mild +//! directional strength). Exits on the opposite MACD crossover regardless +//! of ADX. 0.1% fees per trade. +//! +//! The ADX filter is the whole point of the strategy: pure MACD on +//! sideways markets chops in and out; gating entries on directional- +//! strength cuts the worst losing streak. +//! +//! Educational example. **Not** a live trading recommendation. +//! +//! Build with: +//! ```text +//! cargo run --release -p wickra-examples --bin strategy_macd_adx +//! ``` +//! +//! Uses the checked-in `examples/data/btcusdt-1h.csv` dataset. + +use wickra::{Adx, Indicator, MacdIndicator}; +use wickra_data::csv::CandleReader; + +const FEE: f64 = 0.001; +const ADX_FLOOR: f64 = 20.0; + +fn main() -> Result<(), Box> { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/btcusdt-1h.csv"); + let mut reader = CandleReader::open(path)?; + let candles = reader.read_all()?; + if candles.is_empty() { + return Err("CSV is empty".into()); + } + + let mut macd = MacdIndicator::classic(); + let mut adx = Adx::new(14)?; + + let mut in_position = false; + let mut entry_price = 0.0_f64; + let mut closed_trades: Vec = Vec::new(); + let mut equity = 1.0_f64; + let mut equity_curve: Vec = Vec::with_capacity(candles.len()); + + // Track the previous histogram sign to detect MACD-line crossovers. + let mut prev_hist_sign: Option = None; + + for candle in &candles { + let macd_out = macd.update(candle.close); + let adx_out = adx.update(*candle); + let price = candle.close; + + let mtm_equity = if in_position { + equity * (price / entry_price) + } else { + equity + }; + equity_curve.push(mtm_equity); + + let Some(m) = macd_out else { continue }; + let Some(a) = adx_out else { continue }; + + let hist_sign = m.histogram > 0.0; + let cross_up = prev_hist_sign == Some(false) && hist_sign; + let cross_down = prev_hist_sign == Some(true) && !hist_sign; + prev_hist_sign = Some(hist_sign); + + if !in_position && cross_up && a.adx > ADX_FLOOR { + // Enter long: directional regime with positive momentum. + entry_price = price; + equity *= 1.0 - FEE; + in_position = true; + } else if in_position && cross_down { + // Exit on opposite cross — ADX gating only the entries + // keeps us from being trapped in a long trade as a trend dies. + let trade_ret = price / entry_price - 1.0; + closed_trades.push(trade_ret); + equity *= (1.0 + trade_ret) * (1.0 - FEE); + in_position = false; + } + } + + if in_position { + let last_price = candles.last().expect("non-empty above").close; + let trade_ret = last_price / entry_price - 1.0; + closed_trades.push(trade_ret); + equity *= (1.0 + trade_ret) * (1.0 - FEE); + } + + print_summary( + "MACD + ADX Trend Filter (1h, BTCUSDT)", + candles.first().unwrap().close, + candles.last().unwrap().close, + candles.len(), + &closed_trades, + equity, + &equity_curve, + ); + + Ok(()) +} + +fn print_summary( + name: &str, + first_price: f64, + last_price: f64, + bars: usize, + closed_trades: &[f64], + final_equity: f64, + equity_curve: &[f64], +) { + let buy_hold = last_price / first_price; + let strat_return = final_equity - 1.0; + let bh_return = buy_hold - 1.0; + + let mut wins = 0usize; + let mut losses = 0usize; + let mut best = f64::NEG_INFINITY; + let mut worst = f64::INFINITY; + let mut sum_ret = 0.0_f64; + let mut sum_sq = 0.0_f64; + for &r in closed_trades { + if r > 0.0 { + wins += 1; + } else if r < 0.0 { + losses += 1; + } + best = best.max(r); + worst = worst.min(r); + sum_ret += r; + sum_sq += r * r; + } + let n = closed_trades.len() as f64; + let mean_ret = if n > 0.0 { sum_ret / n } else { 0.0 }; + let var_ret = if n > 1.0 { + (sum_sq - n * mean_ret * mean_ret) / (n - 1.0) + } else { + 0.0 + }; + let sharpe = if var_ret > 0.0 { + mean_ret / var_ret.sqrt() + } else { + 0.0 + }; + + let mut peak = equity_curve.first().copied().unwrap_or(1.0); + let mut max_dd = 0.0_f64; + for &eq in equity_curve { + peak = peak.max(eq); + let dd = (peak - eq) / peak; + if dd > max_dd { + max_dd = dd; + } + } + + println!("=== {name} ==="); + println!("Bars: {bars}"); + println!( + "Trades: {} (W{wins} / L{losses})", + closed_trades.len() + ); + println!("Strategy return: {:+.2}%", strat_return * 100.0); + println!("Buy & Hold return: {:+.2}%", bh_return * 100.0); + println!( + "Excess over BH: {:+.2}%", + (strat_return - bh_return) * 100.0 + ); + println!("Max drawdown: {:.2}%", max_dd * 100.0); + println!( + "Per-trade Sharpe: {sharpe:.2} (mean {:+.4}, stddev {:.4})", + mean_ret, + var_ret.sqrt() + ); + println!( + "Best / worst trade: {:+.2}% / {:+.2}%", + best * 100.0, + worst * 100.0 + ); + println!(); + println!( + "NOTE: Educational example — fees, slippage, funding costs and tax effects \ + are simplified or omitted. Past performance is not indicative of future results." + ); +} diff --git a/examples/rust/src/bin/strategy_rsi_mean_reversion.rs b/examples/rust/src/bin/strategy_rsi_mean_reversion.rs new file mode 100644 index 00000000..ea2372df --- /dev/null +++ b/examples/rust/src/bin/strategy_rsi_mean_reversion.rs @@ -0,0 +1,180 @@ +//! Strategy example: RSI mean-reversion on hourly BTCUSDT data. +//! +//! Goes long when RSI(14) crosses below 30 (oversold), exits when RSI +//! crosses above 70 (overbought). Position is binary (full-in / full-out), +//! fees are 0.1% per trade (Binance maker tier), no stop-loss. +//! +//! Educational example. **Not** a recommended trading strategy in real +//! markets — mean reversion on BTC has been historically losing over long +//! horizons. The point is to show how Wickra streaming indicators wire up +//! into a complete signal → fill → `PnL` → equity loop in a single file. +//! +//! Build with: +//! ```text +//! cargo run --release -p wickra-examples --bin strategy_rsi_mean_reversion +//! ``` +//! +//! Uses the checked-in `examples/data/btcusdt-1h.csv` dataset. + +use wickra::{Indicator, Rsi}; +use wickra_data::csv::CandleReader; + +const FEE: f64 = 0.001; // 0.1% per trade (Binance maker) +const RSI_PERIOD: usize = 14; +const OVERSOLD: f64 = 30.0; +const OVERBOUGHT: f64 = 70.0; + +fn main() -> Result<(), Box> { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/btcusdt-1h.csv"); + let mut reader = CandleReader::open(path)?; + let candles = reader.read_all()?; + if candles.len() < RSI_PERIOD * 4 { + return Err(format!("dataset too small: {}", candles.len()).into()); + } + + let mut rsi = Rsi::new(RSI_PERIOD)?; + + // Walk through bars, generate signals, track an equity curve. + let mut in_position = false; + let mut entry_price = 0.0_f64; + let mut closed_trades: Vec = Vec::new(); // per-trade returns + let mut equity = 1.0_f64; + let mut equity_curve: Vec = Vec::with_capacity(candles.len()); + + for candle in &candles { + let rsi_val = rsi.update(candle.close); + let price = candle.close; + + // Mark-to-market the open position so the equity curve moves + // bar-by-bar even between trades. + let mtm_equity = if in_position { + equity * (price / entry_price) + } else { + equity + }; + equity_curve.push(mtm_equity); + + let Some(r) = rsi_val else { continue }; + + if !in_position && r < OVERSOLD { + // Enter long. Pay entry fee out of equity. + entry_price = price; + equity *= 1.0 - FEE; + in_position = true; + } else if in_position && r > OVERBOUGHT { + // Exit long. Realise trade PnL, pay exit fee. + let trade_ret = price / entry_price - 1.0; + closed_trades.push(trade_ret); + equity *= (1.0 + trade_ret) * (1.0 - FEE); + in_position = false; + } + } + + // If we ended a still open trade, mark it closed at the last bar so + // metrics don't omit a half-trade. + if in_position { + let last_price = candles.last().expect("non-empty by guard above").close; + let trade_ret = last_price / entry_price - 1.0; + closed_trades.push(trade_ret); + equity *= (1.0 + trade_ret) * (1.0 - FEE); + } + + print_summary( + "RSI Mean-Reversion (1h, BTCUSDT)", + candles.first().unwrap().close, + candles.last().unwrap().close, + candles.len(), + &closed_trades, + equity, + &equity_curve, + ); + + Ok(()) +} + +/// Print a one-screen summary of an equity-curve plus per-trade list. +/// Kept inline (not factored out) so each strategy example stays a +/// single-file read. +fn print_summary( + name: &str, + first_price: f64, + last_price: f64, + bars: usize, + closed_trades: &[f64], + final_equity: f64, + equity_curve: &[f64], +) { + let buy_hold = last_price / first_price; + let strat_return = final_equity - 1.0; + let bh_return = buy_hold - 1.0; + + let mut wins = 0usize; + let mut losses = 0usize; + let mut best = f64::NEG_INFINITY; + let mut worst = f64::INFINITY; + let mut sum_ret = 0.0_f64; + let mut sum_sq = 0.0_f64; + for &r in closed_trades { + if r > 0.0 { + wins += 1; + } else if r < 0.0 { + losses += 1; + } + best = best.max(r); + worst = worst.min(r); + sum_ret += r; + sum_sq += r * r; + } + let n = closed_trades.len() as f64; + let mean_ret = if n > 0.0 { sum_ret / n } else { 0.0 }; + let var_ret = if n > 1.0 { + (sum_sq - n * mean_ret * mean_ret) / (n - 1.0) + } else { + 0.0 + }; + let sharpe = if var_ret > 0.0 { + mean_ret / var_ret.sqrt() + } else { + 0.0 + }; + + // Max-drawdown on the equity curve. + let mut peak = equity_curve.first().copied().unwrap_or(1.0); + let mut max_dd = 0.0_f64; + for &eq in equity_curve { + peak = peak.max(eq); + let dd = (peak - eq) / peak; + if dd > max_dd { + max_dd = dd; + } + } + + println!("=== {name} ==="); + println!("Bars: {bars}"); + println!( + "Trades: {} (W{wins} / L{losses})", + closed_trades.len() + ); + println!("Strategy return: {:+.2}%", strat_return * 100.0); + println!("Buy & Hold return: {:+.2}%", bh_return * 100.0); + println!( + "Excess over BH: {:+.2}%", + (strat_return - bh_return) * 100.0 + ); + println!("Max drawdown: {:.2}%", max_dd * 100.0); + println!( + "Per-trade Sharpe: {sharpe:.2} (mean {:+.4}, stddev {:.4})", + mean_ret, + var_ret.sqrt() + ); + println!( + "Best / worst trade: {:+.2}% / {:+.2}%", + best * 100.0, + worst * 100.0 + ); + println!(); + println!( + "NOTE: Educational example — fees, slippage, funding costs and tax effects \ + are simplified or omitted. Past performance is not indicative of future results." + ); +}