diff --git a/examples/csharp/_common/Backtest.cs b/examples/csharp/_common/Backtest.cs index fb11e761..514fbfb8 100644 --- a/examples/csharp/_common/Backtest.cs +++ b/examples/csharp/_common/Backtest.cs @@ -42,4 +42,75 @@ public static class Backtest Console.WriteLine( $"{name,-26} return={r.TotalReturnPct,8:F2}% sharpe={r.Sharpe,6:F2} maxDD={r.MaxDrawdownPct,6:F2}% trades={r.Trades}"); } + + /// + /// Prints the per-trade backtest summary shared verbatim with the Rust, + /// Python, Node, Go and C example suites (same labels, same numbers). + /// + public static void PrintSummary(string name, double firstPrice, double lastPrice, int bars, + IReadOnlyList closedTrades, double finalEquity, IReadOnlyList equityCurve) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + var buyHold = lastPrice / firstPrice; + var stratReturn = finalEquity - 1.0; + var bhReturn = buyHold - 1.0; + int wins = 0, losses = 0; + double best = 0.0, worst = 0.0; + for (var i = 0; i < closedTrades.Count; i++) + { + var r = closedTrades[i]; + if (r > 0) + { + wins++; + } + else if (r < 0) + { + losses++; + } + + if (i == 0 || r > best) + { + best = r; + } + + if (i == 0 || r < worst) + { + worst = r; + } + } + + var n = closedTrades.Count; + var mean = n > 0 ? closedTrades.Average() : 0.0; + var variance = n > 1 ? closedTrades.Sum(x => (x - mean) * (x - mean)) / (n - 1) : 0.0; + var sharpe = variance > 0 ? mean / Math.Sqrt(variance) : 0.0; + var peak = equityCurve.Count > 0 ? equityCurve[0] : 1.0; + var maxDd = 0.0; + foreach (var eq in equityCurve) + { + if (eq > peak) + { + peak = eq; + } + + var dd = (peak - eq) / peak; + if (dd > maxDd) + { + maxDd = dd; + } + } + + Console.WriteLine($"=== {name} ==="); + Console.WriteLine(string.Create(ci, $"{"Bars:",-23}{bars}")); + Console.WriteLine(string.Create(ci, $"{"Trades:",-23}{n} (W{wins} / L{losses})")); + Console.WriteLine(string.Create(ci, $"{"Strategy return:",-23}{stratReturn * 100:+0.00;-0.00}%")); + Console.WriteLine(string.Create(ci, $"{"Buy & Hold return:",-23}{bhReturn * 100:+0.00;-0.00}%")); + Console.WriteLine(string.Create(ci, $"{"Excess over BH:",-23}{(stratReturn - bhReturn) * 100:+0.00;-0.00}%")); + Console.WriteLine(string.Create(ci, $"{"Max drawdown:",-23}{maxDd * 100:0.00}%")); + Console.WriteLine(string.Create(ci, $"{"Per-trade Sharpe:",-23}{sharpe:0.00} (mean {mean:+0.0000;-0.0000}, stddev {Math.Sqrt(variance):0.0000})")); + Console.WriteLine(string.Create(ci, $"{"Best / worst trade:",-23}{best * 100:+0.00;-0.00}% / {worst * 100:+0.00;-0.00}%")); + Console.WriteLine(); + Console.WriteLine("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/csharp/_common/MarketData.cs b/examples/csharp/_common/MarketData.cs index 25517442..a6306bcd 100644 --- a/examples/csharp/_common/MarketData.cs +++ b/examples/csharp/_common/MarketData.cs @@ -58,4 +58,15 @@ public static class MarketData return bars; } + + /// + /// Loads one of the checked-in datasets under examples/data, resolved + /// relative to this source file so it works from any working directory. + /// + public static Bar[] BundledCandles(string filename, + [System.Runtime.CompilerServices.CallerFilePath] string self = "") + { + var dir = Path.GetDirectoryName(self)!; + return LoadOhlcvCsv(Path.Combine(dir, "..", "..", "data", filename)); + } } diff --git a/examples/csharp/strategy_bollinger_squeeze/Program.cs b/examples/csharp/strategy_bollinger_squeeze/Program.cs index 3763b4ba..56af51fa 100644 --- a/examples/csharp/strategy_bollinger_squeeze/Program.cs +++ b/examples/csharp/strategy_bollinger_squeeze/Program.cs @@ -1,46 +1,91 @@ using Wickra; using Wickra.Examples; -// Breakout: when Bollinger bandwidth is tight (a "squeeze") and price closes above the -// upper band, go long with an ATR(14) trailing stop. -var bars = args.Length > 0 ? MarketData.LoadOhlcvCsv(args[0]) : MarketData.SyntheticCandles(2000); +// Strategy example: Bollinger-squeeze breakout with an ATR(14) trailing stop. +// +// Enters long when Bollinger bandwidth makes a new SqueezeLookback low (a +// volatility squeeze) and price closes above the upper band; exits on an ATR(14) +// trailing stop or when the upper band falls back below the entry. 0.1% fees per +// trade. The C# counterpart of examples/python/strategy_bollinger_squeeze.py, +// printing the same summary. Uses the checked-in examples/data/btcusdt-1d.csv +// dataset (pass a CSV path to override). +const double Fee = 0.001; +const double AtrStopMult = 2.0; +const int SqueezeLookback = 180; + +var bars = args.Length > 0 ? MarketData.LoadOhlcvCsv(args[0]) : MarketData.BundledCandles("btcusdt-1d.csv"); using var bollinger = new BollingerBands(20, 2.0); using var atr = new Atr(14); -var returns = new List(); -var trades = 0; var inPosition = false; -var entry = 0.0; -var stop = 0.0; +var entryPrice = 0.0; +var stopLevel = 0.0; +var closedTrades = new List(); +var equity = 1.0; +var equityCurve = new List(); +var bwWindow = new Queue(); foreach (var b in bars) { var bands = bollinger.Update(b.Close); var atrValue = atr.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp); + var price = b.Close; + equityCurve.Add(inPosition ? equity * (price / entryPrice) : equity); + if (bands is not { } band || !double.IsFinite(atrValue)) { continue; } - var bandwidth = band.Middle != 0.0 ? (band.Upper - band.Lower) / band.Middle : double.MaxValue; - - if (!inPosition && bandwidth < 0.06 && b.Close > band.Upper) + if (Math.Abs(band.Middle) <= 1e-12) { - inPosition = true; - entry = b.Close; - stop = b.Close - 2.0 * atrValue; - trades++; + continue; } - else if (inPosition) + + var bandwidth = (band.Upper - band.Lower) / band.Middle; + bwWindow.Enqueue(bandwidth); + if (bwWindow.Count > SqueezeLookback) { - stop = Math.Max(stop, b.Close - 2.0 * atrValue); // trail the stop up - if (b.Close < stop) + bwWindow.Dequeue(); + } + + if (bwWindow.Count < SqueezeLookback) + { + continue; + } + + var minBw = bwWindow.Min(); + + if (inPosition) + { + if (price < stopLevel || band.Upper < entryPrice) { - returns.Add((b.Close - entry) / entry); + var tradeRet = price / entryPrice - 1.0; + closedTrades.Add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - Fee); inPosition = false; } } + else + { + var isNewLow = Math.Abs(bandwidth - minBw) < 1e-12; + if (isNewLow && price > band.Upper) + { + entryPrice = price; + stopLevel = price - AtrStopMult * atrValue; + equity *= 1.0 - Fee; + inPosition = true; + } + } } -Backtest.Print("Bollinger squeeze", Backtest.Summarize(returns, trades)); +if (inPosition) +{ + var tradeRet = bars[^1].Close / entryPrice - 1.0; + closedTrades.Add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - Fee); +} + +Backtest.PrintSummary("Bollinger Squeeze Breakout (1d, BTCUSDT)", + bars[0].Close, bars[^1].Close, bars.Length, closedTrades, equity, equityCurve); diff --git a/examples/csharp/strategy_macd_adx/Program.cs b/examples/csharp/strategy_macd_adx/Program.cs index 3e56624d..8364aeef 100644 --- a/examples/csharp/strategy_macd_adx/Program.cs +++ b/examples/csharp/strategy_macd_adx/Program.cs @@ -1,42 +1,66 @@ using Wickra; using Wickra.Examples; -// Trend follower: enter long on a MACD histogram cross up, but only when ADX(14) > 20 -// confirms a trend; exit when the histogram crosses back below zero. -var bars = args.Length > 0 ? MarketData.LoadOhlcvCsv(args[0]) : MarketData.SyntheticCandles(2000); +// Strategy example: MACD crossover with ADX trend-strength filter. +// +// Enters long on a MACD histogram cross up (the histogram turns positive) while +// ADX(14) > 20 (a directional market); exits on the opposite MACD crossover +// regardless of ADX. 0.1% fees per trade. The C# counterpart of +// examples/python/strategy_macd_adx.py, printing the same summary. Uses the +// checked-in examples/data/btcusdt-1h.csv dataset (pass a CSV path to override). +const double Fee = 0.001; +const double AdxFloor = 20.0; + +var bars = args.Length > 0 ? MarketData.LoadOhlcvCsv(args[0]) : MarketData.BundledCandles("btcusdt-1h.csv"); using var macd = new MacdIndicator(12, 26, 9); using var adx = new Adx(14); -var returns = new List(); -var trades = 0; var inPosition = false; -var entry = 0.0; -var prevHistogram = double.NaN; +var entryPrice = 0.0; +var closedTrades = new List(); +var equity = 1.0; +var equityCurve = new List(); +bool? prevSign = null; foreach (var b in bars) { var m = macd.Update(b.Close); var a = adx.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp); + var price = b.Close; + equityCurve.Add(inPosition ? equity * (price / entryPrice) : equity); + if (m is not { } macdValue || a is not { } adxValue) { continue; } - var trending = adxValue.Adx > 20.0; - if (!inPosition && trending && double.IsFinite(prevHistogram) && prevHistogram <= 0.0 && macdValue.Histogram > 0.0) + var histSign = macdValue.Histogram > 0.0; + var crossUp = prevSign == false && histSign; + var crossDown = prevSign == true && !histSign; + prevSign = histSign; + + if (!inPosition && crossUp && adxValue.Adx > AdxFloor) { + entryPrice = price; + equity *= 1.0 - Fee; inPosition = true; - entry = b.Close; - trades++; } - else if (inPosition && macdValue.Histogram < 0.0) + else if (inPosition && crossDown) { - returns.Add((b.Close - entry) / entry); + var tradeRet = price / entryPrice - 1.0; + closedTrades.Add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - Fee); inPosition = false; } - - prevHistogram = macdValue.Histogram; } -Backtest.Print("MACD + ADX trend", Backtest.Summarize(returns, trades)); +if (inPosition) +{ + var tradeRet = bars[^1].Close / entryPrice - 1.0; + closedTrades.Add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - Fee); +} + +Backtest.PrintSummary("MACD + ADX Trend Filter (1h, BTCUSDT)", + bars[0].Close, bars[^1].Close, bars.Length, closedTrades, equity, equityCurve); diff --git a/examples/csharp/strategy_rsi_mean_reversion/Program.cs b/examples/csharp/strategy_rsi_mean_reversion/Program.cs index 8e4a4304..e57d6492 100644 --- a/examples/csharp/strategy_rsi_mean_reversion/Program.cs +++ b/examples/csharp/strategy_rsi_mean_reversion/Program.cs @@ -1,34 +1,57 @@ using Wickra; using Wickra.Examples; -// Mean reversion: go long when RSI(14) drops below 30, exit when it recovers above 50. -var bars = args.Length > 0 ? MarketData.LoadOhlcvCsv(args[0]) : MarketData.SyntheticCandles(2000); +// Strategy example: RSI(14) mean-reversion. +// +// Go long when RSI(14) drops below 30 (oversold), exit when it recovers above +// 70 (overbought). 0.1% fees per trade. The C# counterpart of +// examples/python/strategy_rsi_mean_reversion.py, printing the same summary. +// Uses the checked-in examples/data/btcusdt-1h.csv dataset (pass a CSV path to override). +const double Fee = 0.001; +const double Oversold = 30.0; +const double Overbought = 70.0; + +var bars = args.Length > 0 ? MarketData.LoadOhlcvCsv(args[0]) : MarketData.BundledCandles("btcusdt-1h.csv"); using var rsi = new Rsi(14); -var returns = new List(); -var trades = 0; + var inPosition = false; -var entry = 0.0; +var entryPrice = 0.0; +var closedTrades = new List(); +var equity = 1.0; +var equityCurve = new List(); foreach (var b in bars) { var value = rsi.Update(b.Close); + var price = b.Close; + equityCurve.Add(inPosition ? equity * (price / entryPrice) : equity); if (!double.IsFinite(value)) { continue; } - if (!inPosition && value < 30.0) + if (!inPosition && value < Oversold) { + entryPrice = price; + equity *= 1.0 - Fee; inPosition = true; - entry = b.Close; - trades++; } - else if (inPosition && value > 50.0) + else if (inPosition && value > Overbought) { - returns.Add((b.Close - entry) / entry); + var tradeRet = price / entryPrice - 1.0; + closedTrades.Add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - Fee); inPosition = false; } } -Backtest.Print("RSI mean-reversion", Backtest.Summarize(returns, trades)); +if (inPosition) +{ + var tradeRet = bars[^1].Close / entryPrice - 1.0; + closedTrades.Add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - Fee); +} + +Backtest.PrintSummary("RSI Mean-Reversion (1h, BTCUSDT)", + bars[0].Close, bars[^1].Close, bars.Length, closedTrades, equity, equityCurve); diff --git a/examples/go/internal/market/market.go b/examples/go/internal/market/market.go index 7f39c225..7fed95b4 100644 --- a/examples/go/internal/market/market.go +++ b/examples/go/internal/market/market.go @@ -8,6 +8,8 @@ import ( "fmt" "math" "os" + "path/filepath" + "runtime" wickra "github.com/wickra-lib/wickra/bindings/go" ) @@ -139,3 +141,85 @@ func Print(name string, r EquityResult) { fmt.Printf("%-26s return=%8.2f%% sharpe=%6.2f maxDD=%6.2f%% trades=%d\n", name, r.TotalReturnPct, r.Sharpe, r.MaxDrawdownPct, r.Trades) } + +// BundledCandles loads one of the checked-in datasets under examples/data, +// resolved relative to this source file so it works from any working directory. +func BundledCandles(filename string) []Bar { + _, self, _, _ := runtime.Caller(0) + path := filepath.Join(filepath.Dir(self), "..", "..", "..", "data", filename) + bars, err := LoadOhlcvCsv(path) + if err != nil { + panic(err) + } + return bars +} + +// PrintSummary prints the per-trade backtest summary shared verbatim with the +// Rust, Python, Node and C example suites (same labels, same numbers). +func PrintSummary(name string, firstPrice, lastPrice float64, bars int, closedTrades []float64, finalEquity float64, equityCurve []float64) { + buyHold := lastPrice / firstPrice + stratReturn := finalEquity - 1.0 + bhReturn := buyHold - 1.0 + wins, losses := 0, 0 + best, worst := 0.0, 0.0 + for i, r := range closedTrades { + if r > 0 { + wins++ + } else if r < 0 { + losses++ + } + if i == 0 || r > best { + best = r + } + if i == 0 || r < worst { + worst = r + } + } + n := len(closedTrades) + mean := 0.0 + if n > 0 { + var sum float64 + for _, r := range closedTrades { + sum += r + } + mean = sum / float64(n) + } + variance := 0.0 + if n > 1 { + var ss float64 + for _, r := range closedTrades { + ss += (r - mean) * (r - mean) + } + variance = ss / float64(n-1) + } + sharpe := 0.0 + if variance > 0 { + sharpe = mean / math.Sqrt(variance) + } + peak, maxDD := 1.0, 0.0 + if len(equityCurve) > 0 { + peak = equityCurve[0] + } + for _, eq := range equityCurve { + if eq > peak { + peak = eq + } + if dd := (peak - eq) / peak; dd > maxDD { + maxDD = dd + } + } + + fmt.Printf("=== %s ===\n", name) + fmt.Printf("%-23s%d\n", "Bars:", bars) + fmt.Printf("%-23s%d (W%d / L%d)\n", "Trades:", n, wins, losses) + fmt.Printf("%-23s%+.2f%%\n", "Strategy return:", stratReturn*100) + fmt.Printf("%-23s%+.2f%%\n", "Buy & Hold return:", bhReturn*100) + fmt.Printf("%-23s%+.2f%%\n", "Excess over BH:", (stratReturn-bhReturn)*100) + fmt.Printf("%-23s%.2f%%\n", "Max drawdown:", maxDD*100) + fmt.Printf("%-23s%.2f (mean %+.4f, stddev %.4f)\n", "Per-trade Sharpe:", sharpe, mean, math.Sqrt(variance)) + fmt.Printf("%-23s%+.2f%% / %+.2f%%\n", "Best / worst trade:", best*100, worst*100) + fmt.Println() + fmt.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/go/strategy_bollinger_squeeze/main.go b/examples/go/strategy_bollinger_squeeze/main.go index f5b82990..dc4fa4e1 100644 --- a/examples/go/strategy_bollinger_squeeze/main.go +++ b/examples/go/strategy_bollinger_squeeze/main.go @@ -1,5 +1,13 @@ -// Breakout: when Bollinger bandwidth is tight (a "squeeze") and price closes above -// the upper band, go long with an ATR(14) trailing stop. +// Strategy example: Bollinger-squeeze breakout with an ATR(14) trailing stop. +// +// Enters long when Bollinger bandwidth makes a new SQUEEZE_LOOKBACK low (a +// volatility squeeze) and price closes above the upper band; exits on an ATR(14) +// trailing stop or when the upper band falls back below the entry. 0.1% fees per +// trade. The Go counterpart of examples/python/strategy_bollinger_squeeze.py, +// printing the same summary. +// +// Uses the checked-in examples/data/btcusdt-1d.csv dataset (daily bars give an +// interpretable ~6-month-low lookback); pass a CSV path to override. package main import ( @@ -11,47 +19,90 @@ import ( "github.com/wickra-lib/wickra/examples/go/internal/market" ) +const ( + fee = 0.001 + bbPeriod = 20 + bbK = 2.0 + atrPeriod = 14 + atrStopMult = 2.0 + squeezeLookback = 180 +) + func main() { bars := loadBars() - bollinger, _ := wickra.NewBollingerBands(20, 2.0) - defer bollinger.Close() - atr, _ := wickra.NewAtr(14) + bb, _ := wickra.NewBollingerBands(bbPeriod, bbK) + defer bb.Close() + atr, _ := wickra.NewAtr(atrPeriod) defer atr.Close() - var returns []float64 - trades := 0 inPosition := false - entry := 0.0 - stop := 0.0 + entryPrice := 0.0 + stopLevel := 0.0 + var closedTrades []float64 + equity := 1.0 + var equityCurve []float64 + var bwWindow []float64 for _, b := range bars { - band, okBand := bollinger.Update(b.Close) - atrValue := atr.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp) - if !okBand || math.IsNaN(atrValue) { + band, okBand := bb.Update(b.Close) + atrVal := atr.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp) + price := b.Close + mtm := equity + if inPosition { + mtm = equity * (price / entryPrice) + } + equityCurve = append(equityCurve, mtm) + + if !okBand || math.IsNaN(atrVal) { continue } - - bandwidth := math.MaxFloat64 - if band.Middle != 0.0 { - bandwidth = (band.Upper - band.Lower) / band.Middle + upper, middle, lower := band.Upper, band.Middle, band.Lower + if math.Abs(middle) <= 1e-12 { + continue + } + bandwidth := (upper - lower) / middle + bwWindow = append(bwWindow, bandwidth) + if len(bwWindow) > squeezeLookback { + bwWindow = bwWindow[len(bwWindow)-squeezeLookback:] + } + if len(bwWindow) < squeezeLookback { + continue + } + minBw := bwWindow[0] + for _, v := range bwWindow { + if v < minBw { + minBw = v + } } - if !inPosition && bandwidth < 0.06 && b.Close > band.Upper { - inPosition = true - entry = b.Close - stop = b.Close - 2.0*atrValue - trades++ - } else if inPosition { - stop = math.Max(stop, b.Close-2.0*atrValue) // trail the stop up - if b.Close < stop { - returns = append(returns, (b.Close-entry)/entry) + if inPosition { + if price < stopLevel || upper < entryPrice { + tradeRet := price/entryPrice - 1.0 + closedTrades = append(closedTrades, tradeRet) + equity *= (1.0 + tradeRet) * (1.0 - fee) inPosition = false } + } else { + isNewLow := math.Abs(bandwidth-minBw) < 1e-12 + if isNewLow && price > upper { + entryPrice = price + stopLevel = price - atrStopMult*atrVal + equity *= 1.0 - fee + inPosition = true + } } } - market.Print("Bollinger squeeze", market.Summarize(returns, trades, 252.0)) + if inPosition { + lastPrice := bars[len(bars)-1].Close + tradeRet := lastPrice/entryPrice - 1.0 + closedTrades = append(closedTrades, tradeRet) + equity *= (1.0 + tradeRet) * (1.0 - fee) + } + + market.PrintSummary("Bollinger Squeeze Breakout (1d, BTCUSDT)", + bars[0].Close, bars[len(bars)-1].Close, len(bars), closedTrades, equity, equityCurve) } func loadBars() []market.Bar { @@ -62,5 +113,5 @@ func loadBars() []market.Bar { } return bars } - return market.SyntheticCandles(2000) + return market.BundledCandles("btcusdt-1d.csv") } diff --git a/examples/go/strategy_macd_adx/main.go b/examples/go/strategy_macd_adx/main.go index a98cd418..38802f17 100644 --- a/examples/go/strategy_macd_adx/main.go +++ b/examples/go/strategy_macd_adx/main.go @@ -1,16 +1,28 @@ -// Trend follower: enter long on a MACD histogram cross up, but only when ADX(14) > 20 -// confirms a trend; exit when the histogram crosses back below zero. +// Strategy example: MACD crossover with ADX trend-strength filter. +// +// Enters long on a MACD histogram cross up (the histogram turns positive) while +// ADX(14) > 20 (a directional market); exits on the opposite MACD crossover +// regardless of ADX. 0.1% fees per trade. The Go counterpart of +// examples/python/strategy_macd_adx.py and the Rust strategy_macd_adx.rs, +// printing the same summary. +// +// Uses the checked-in examples/data/btcusdt-1h.csv dataset (pass a CSV path to +// override). package main import ( "log" - "math" "os" wickra "github.com/wickra-lib/wickra/bindings/go" "github.com/wickra-lib/wickra/examples/go/internal/market" ) +const ( + fee = 0.001 + adxFloor = 20.0 +) + func main() { bars := loadBars() @@ -19,32 +31,55 @@ func main() { adx, _ := wickra.NewAdx(14) defer adx.Close() - var returns []float64 - trades := 0 inPosition := false - entry := 0.0 - prevHistogram := math.NaN() + entryPrice := 0.0 + var closedTrades []float64 + equity := 1.0 + var equityCurve []float64 + havePrev := false + prevSign := false for _, b := range bars { m, okMacd := macd.Update(b.Close) a, okAdx := adx.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp) + price := b.Close + mtm := equity + if inPosition { + mtm = equity * (price / entryPrice) + } + equityCurve = append(equityCurve, mtm) + if !okMacd || !okAdx { continue } - trending := a.Adx > 20.0 - if !inPosition && trending && !math.IsNaN(prevHistogram) && prevHistogram <= 0.0 && m.Histogram > 0.0 { + histSign := m.Histogram > 0.0 + crossUp := havePrev && !prevSign && histSign + crossDown := havePrev && prevSign && !histSign + havePrev = true + prevSign = histSign + + if !inPosition && crossUp && a.Adx > adxFloor { + entryPrice = price + equity *= 1.0 - fee inPosition = true - entry = b.Close - trades++ - } else if inPosition && m.Histogram < 0.0 { - returns = append(returns, (b.Close-entry)/entry) + } else if inPosition && crossDown { + tradeRet := price/entryPrice - 1.0 + closedTrades = append(closedTrades, tradeRet) + equity *= (1.0 + tradeRet) * (1.0 - fee) inPosition = false } - prevHistogram = m.Histogram } - market.Print("MACD + ADX trend", market.Summarize(returns, trades, 252.0)) + if inPosition { + lastPrice := bars[len(bars)-1].Close + tradeRet := lastPrice/entryPrice - 1.0 + closedTrades = append(closedTrades, tradeRet) + equity *= (1.0 + tradeRet) * (1.0 - fee) + } + + market.PrintSummary("MACD + ADX Trend Filter (1h, BTCUSDT)", + bars[0].Close, bars[len(bars)-1].Close, len(bars), closedTrades, equity, equityCurve) } func loadBars() []market.Bar { @@ -55,5 +90,5 @@ func loadBars() []market.Bar { } return bars } - return market.SyntheticCandles(2000) + return market.BundledCandles("btcusdt-1h.csv") } diff --git a/examples/go/strategy_rsi_mean_reversion/main.go b/examples/go/strategy_rsi_mean_reversion/main.go index 3a02943a..3fd17a03 100644 --- a/examples/go/strategy_rsi_mean_reversion/main.go +++ b/examples/go/strategy_rsi_mean_reversion/main.go @@ -1,4 +1,11 @@ -// Mean reversion: go long when RSI(14) drops below 30, exit when it recovers above 50. +// Strategy example: RSI(14) mean-reversion. +// +// Go long when RSI(14) drops below 30 (oversold), exit when it recovers above +// 70 (overbought). 0.1% fees per trade. The Go counterpart of +// examples/python/strategy_rsi_mean_reversion.py, printing the same summary. +// +// Uses the checked-in examples/data/btcusdt-1h.csv dataset (pass a CSV path to +// override). package main import ( @@ -10,33 +17,57 @@ import ( "github.com/wickra-lib/wickra/examples/go/internal/market" ) +const ( + fee = 0.001 + oversold = 30.0 + overbought = 70.0 +) + func main() { bars := loadBars() rsi, _ := wickra.NewRsi(14) defer rsi.Close() - var returns []float64 - trades := 0 inPosition := false - entry := 0.0 + entryPrice := 0.0 + var closedTrades []float64 + equity := 1.0 + var equityCurve []float64 for _, b := range bars { value := rsi.Update(b.Close) + price := b.Close + mtm := equity + if inPosition { + mtm = equity * (price / entryPrice) + } + equityCurve = append(equityCurve, mtm) if math.IsNaN(value) { continue } - if !inPosition && value < 30.0 { + + if !inPosition && value < oversold { + entryPrice = price + equity *= 1.0 - fee inPosition = true - entry = b.Close - trades++ - } else if inPosition && value > 50.0 { - returns = append(returns, (b.Close-entry)/entry) + } else if inPosition && value > overbought { + tradeRet := price/entryPrice - 1.0 + closedTrades = append(closedTrades, tradeRet) + equity *= (1.0 + tradeRet) * (1.0 - fee) inPosition = false } } - market.Print("RSI mean-reversion", market.Summarize(returns, trades, 252.0)) + if inPosition { + lastPrice := bars[len(bars)-1].Close + tradeRet := lastPrice/entryPrice - 1.0 + closedTrades = append(closedTrades, tradeRet) + equity *= (1.0 + tradeRet) * (1.0 - fee) + } + + market.PrintSummary("RSI Mean-Reversion (1h, BTCUSDT)", + bars[0].Close, bars[len(bars)-1].Close, len(bars), closedTrades, equity, equityCurve) } func loadBars() []market.Bar { @@ -47,5 +78,5 @@ func loadBars() []market.Bar { } return bars } - return market.SyntheticCandles(2000) + return market.BundledCandles("btcusdt-1h.csv") } diff --git a/examples/java/src/main/java/org/wickra/examples/Equity.java b/examples/java/src/main/java/org/wickra/examples/Equity.java index cd4e8fe0..d5134734 100644 --- a/examples/java/src/main/java/org/wickra/examples/Equity.java +++ b/examples/java/src/main/java/org/wickra/examples/Equity.java @@ -1,6 +1,7 @@ package org.wickra.examples; import java.util.List; +import java.util.Locale; /** * Minimal long-only backtest helper: turn a stream of per-bar fractional returns @@ -60,4 +61,74 @@ public final class Equity { "%-26s return=%8.2f%% sharpe=%6.2f maxDD=%6.2f%% trades=%d%n", name, r.totalReturnPct(), r.sharpe(), r.maxDrawdownPct(), r.trades()); } + + /** + * Prints the per-trade backtest summary shared verbatim with the Rust, + * Python, Node, Go, C, C# and R example suites (same labels, same numbers). + */ + public static void printSummary(String name, double firstPrice, double lastPrice, int bars, + List closedTrades, double finalEquity, List equityCurve) { + double buyHold = lastPrice / firstPrice; + double stratReturn = finalEquity - 1.0; + double bhReturn = buyHold - 1.0; + int wins = 0; + int losses = 0; + double best = 0.0; + double worst = 0.0; + for (int i = 0; i < closedTrades.size(); i++) { + double r = closedTrades.get(i); + if (r > 0) { + wins++; + } else if (r < 0) { + losses++; + } + if (i == 0 || r > best) { + best = r; + } + if (i == 0 || r < worst) { + worst = r; + } + } + + int n = closedTrades.size(); + double mean = 0.0; + for (double r : closedTrades) { + mean += r; + } + mean = n > 0 ? mean / n : 0.0; + double variance = 0.0; + if (n > 1) { + for (double r : closedTrades) { + variance += (r - mean) * (r - mean); + } + variance /= n - 1; + } + double sharpe = variance > 0 ? mean / Math.sqrt(variance) : 0.0; + double peak = equityCurve.isEmpty() ? 1.0 : equityCurve.get(0); + double maxDd = 0.0; + for (double eq : equityCurve) { + if (eq > peak) { + peak = eq; + } + double dd = (peak - eq) / peak; + if (dd > maxDd) { + maxDd = dd; + } + } + + System.out.printf(Locale.ROOT, "=== %s ===%n", name); + System.out.printf(Locale.ROOT, "%-23s%d%n", "Bars:", bars); + System.out.printf(Locale.ROOT, "%-23s%d (W%d / L%d)%n", "Trades:", n, wins, losses); + System.out.printf(Locale.ROOT, "%-23s%+.2f%%%n", "Strategy return:", stratReturn * 100); + System.out.printf(Locale.ROOT, "%-23s%+.2f%%%n", "Buy & Hold return:", bhReturn * 100); + System.out.printf(Locale.ROOT, "%-23s%+.2f%%%n", "Excess over BH:", (stratReturn - bhReturn) * 100); + System.out.printf(Locale.ROOT, "%-23s%.2f%%%n", "Max drawdown:", maxDd * 100); + System.out.printf(Locale.ROOT, "%-23s%.2f (mean %+.4f, stddev %.4f)%n", + "Per-trade Sharpe:", sharpe, mean, Math.sqrt(variance)); + System.out.printf(Locale.ROOT, "%-23s%+.2f%% / %+.2f%%%n", "Best / worst trade:", best * 100, worst * 100); + System.out.println(); + System.out.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/java/src/main/java/org/wickra/examples/MarketData.java b/examples/java/src/main/java/org/wickra/examples/MarketData.java index 1b84e8bf..58953919 100644 --- a/examples/java/src/main/java/org/wickra/examples/MarketData.java +++ b/examples/java/src/main/java/org/wickra/examples/MarketData.java @@ -68,4 +68,12 @@ public final class MarketData { return bars; } } + + /** + * Loads one of the checked-in datasets under examples/data. The Java + * examples run from the examples/java directory, so ../data is examples/data. + */ + public static Bar[] bundledCandles(String filename) throws IOException { + return loadOhlcvCsv(Path.of("..", "data", filename).toString()); + } } diff --git a/examples/java/src/main/java/org/wickra/examples/StrategyBollingerSqueeze.java b/examples/java/src/main/java/org/wickra/examples/StrategyBollingerSqueeze.java index bed0caa7..3a7218bc 100644 --- a/examples/java/src/main/java/org/wickra/examples/StrategyBollingerSqueeze.java +++ b/examples/java/src/main/java/org/wickra/examples/StrategyBollingerSqueeze.java @@ -5,52 +5,96 @@ import org.wickra.BollingerBands; import org.wickra.BollingerOutput; import org.wickra.examples.MarketData.Bar; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; /** - * Breakout: when Bollinger bandwidth is tight (a "squeeze") and price closes above the - * upper band, go long with an ATR(14) trailing stop. + * Strategy example: Bollinger-squeeze breakout with an ATR(14) trailing stop. + * + *

Enters long when Bollinger bandwidth makes a new SQUEEZE_LOOKBACK low (a + * volatility squeeze) and price closes above the upper band; exits on an ATR(14) + * trailing stop or when the upper band falls back below the entry. 0.1% fees per + * trade. The Java counterpart of + * {@code examples/python/strategy_bollinger_squeeze.py}, printing the same + * summary. Uses the checked-in {@code examples/data/btcusdt-1d.csv} dataset + * (pass a CSV path to override). */ public final class StrategyBollingerSqueeze { + private static final double FEE = 0.001; + private static final double ATR_STOP_MULT = 2.0; + private static final int SQUEEZE_LOOKBACK = 180; + public static void main(String[] args) throws Exception { - Bar[] bars = args.length > 0 ? MarketData.loadOhlcvCsv(args[0]) : MarketData.syntheticCandles(2000); + Bar[] bars = args.length > 0 ? MarketData.loadOhlcvCsv(args[0]) : MarketData.bundledCandles("btcusdt-1d.csv"); try (BollingerBands bollinger = new BollingerBands(20, 2.0); Atr atr = new Atr(14)) { - List returns = new ArrayList<>(); - int trades = 0; boolean inPosition = false; - double entry = 0.0; - double stop = 0.0; + double entryPrice = 0.0; + double stopLevel = 0.0; + List closedTrades = new ArrayList<>(); + double equity = 1.0; + List equityCurve = new ArrayList<>(); + Deque bwWindow = new ArrayDeque<>(); for (Bar b : bars) { BollingerOutput band = bollinger.update(b.close()); double atrValue = atr.update(b.open(), b.high(), b.low(), b.close(), b.volume(), b.timestamp()); + double price = b.close(); + equityCurve.add(inPosition ? equity * (price / entryPrice) : equity); + if (band == null || !Double.isFinite(atrValue)) { continue; } + double middle = band.middle(); + if (Math.abs(middle) <= 1e-12) { + continue; + } + double upper = band.upper(); + double bandwidth = (upper - band.lower()) / middle; + bwWindow.addLast(bandwidth); + if (bwWindow.size() > SQUEEZE_LOOKBACK) { + bwWindow.removeFirst(); + } + if (bwWindow.size() < SQUEEZE_LOOKBACK) { + continue; + } + double minBw = Double.POSITIVE_INFINITY; + for (double v : bwWindow) { + if (v < minBw) { + minBw = v; + } + } - double bandwidth = band.middle() != 0.0 - ? (band.upper() - band.lower()) / band.middle() - : Double.MAX_VALUE; - - if (!inPosition && bandwidth < 0.06 && b.close() > band.upper()) { - inPosition = true; - entry = b.close(); - stop = b.close() - 2.0 * atrValue; - trades++; - } else if (inPosition) { - stop = Math.max(stop, b.close() - 2.0 * atrValue); // trail the stop up - if (b.close() < stop) { - returns.add((b.close() - entry) / entry); + if (inPosition) { + if (price < stopLevel || upper < entryPrice) { + double tradeRet = price / entryPrice - 1.0; + closedTrades.add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - FEE); inPosition = false; } + } else { + boolean isNewLow = Math.abs(bandwidth - minBw) < 1e-12; + if (isNewLow && price > upper) { + entryPrice = price; + stopLevel = price - ATR_STOP_MULT * atrValue; + equity *= 1.0 - FEE; + inPosition = true; + } } } - Equity.print("Bollinger squeeze", Equity.summarize(returns, trades)); + if (inPosition) { + double tradeRet = bars[bars.length - 1].close() / entryPrice - 1.0; + closedTrades.add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - FEE); + } + + Equity.printSummary("Bollinger Squeeze Breakout (1d, BTCUSDT)", + bars[0].close(), bars[bars.length - 1].close(), bars.length, closedTrades, equity, equityCurve); } } } diff --git a/examples/java/src/main/java/org/wickra/examples/StrategyMacdAdx.java b/examples/java/src/main/java/org/wickra/examples/StrategyMacdAdx.java index 43f6c50c..77d500cd 100644 --- a/examples/java/src/main/java/org/wickra/examples/StrategyMacdAdx.java +++ b/examples/java/src/main/java/org/wickra/examples/StrategyMacdAdx.java @@ -10,44 +10,69 @@ import java.util.ArrayList; import java.util.List; /** - * Trend follower: enter long on a MACD histogram cross up, but only when ADX(14) > 20 - * confirms a trend; exit when the histogram crosses back below zero. + * Strategy example: MACD crossover with ADX trend-strength filter. + * + *

Enters long on a MACD histogram cross up (the histogram turns positive) + * while ADX(14) > 20 (a directional market); exits on the opposite MACD + * crossover regardless of ADX. 0.1% fees per trade. The Java counterpart of + * {@code examples/python/strategy_macd_adx.py}, printing the same summary. Uses + * the checked-in {@code examples/data/btcusdt-1h.csv} dataset (pass a CSV path to + * override). */ public final class StrategyMacdAdx { + private static final double FEE = 0.001; + private static final double ADX_FLOOR = 20.0; + public static void main(String[] args) throws Exception { - Bar[] bars = args.length > 0 ? MarketData.loadOhlcvCsv(args[0]) : MarketData.syntheticCandles(2000); + Bar[] bars = args.length > 0 ? MarketData.loadOhlcvCsv(args[0]) : MarketData.bundledCandles("btcusdt-1h.csv"); try (MacdIndicator macd = new MacdIndicator(12, 26, 9); Adx adx = new Adx(14)) { - List returns = new ArrayList<>(); - int trades = 0; boolean inPosition = false; - double entry = 0.0; - double prevHistogram = Double.NaN; + double entryPrice = 0.0; + List closedTrades = new ArrayList<>(); + double equity = 1.0; + List equityCurve = new ArrayList<>(); + boolean havePrev = false; + boolean prevSign = false; for (Bar b : bars) { MacdOutput m = macd.update(b.close()); AdxOutput a = adx.update(b.open(), b.high(), b.low(), b.close(), b.volume(), b.timestamp()); + double price = b.close(); + equityCurve.add(inPosition ? equity * (price / entryPrice) : equity); + if (m == null || a == null) { continue; } - boolean trending = a.adx() > 20.0; - if (!inPosition && trending && Double.isFinite(prevHistogram) - && prevHistogram <= 0.0 && m.histogram() > 0.0) { + boolean histSign = m.histogram() > 0.0; + boolean crossUp = havePrev && !prevSign && histSign; + boolean crossDown = havePrev && prevSign && !histSign; + havePrev = true; + prevSign = histSign; + + if (!inPosition && crossUp && a.adx() > ADX_FLOOR) { + entryPrice = price; + equity *= 1.0 - FEE; inPosition = true; - entry = b.close(); - trades++; - } else if (inPosition && m.histogram() < 0.0) { - returns.add((b.close() - entry) / entry); + } else if (inPosition && crossDown) { + double tradeRet = price / entryPrice - 1.0; + closedTrades.add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - FEE); inPosition = false; } - - prevHistogram = m.histogram(); } - Equity.print("MACD + ADX trend", Equity.summarize(returns, trades)); + if (inPosition) { + double tradeRet = bars[bars.length - 1].close() / entryPrice - 1.0; + closedTrades.add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - FEE); + } + + Equity.printSummary("MACD + ADX Trend Filter (1h, BTCUSDT)", + bars[0].close(), bars[bars.length - 1].close(), bars.length, closedTrades, equity, equityCurve); } } } diff --git a/examples/java/src/main/java/org/wickra/examples/StrategyRsiMeanReversion.java b/examples/java/src/main/java/org/wickra/examples/StrategyRsiMeanReversion.java index c9c85d75..01deba0b 100644 --- a/examples/java/src/main/java/org/wickra/examples/StrategyRsiMeanReversion.java +++ b/examples/java/src/main/java/org/wickra/examples/StrategyRsiMeanReversion.java @@ -6,33 +6,58 @@ import org.wickra.examples.MarketData.Bar; import java.util.ArrayList; import java.util.List; -/** Mean reversion: go long when RSI(14) drops below 30, exit when it recovers above 50. */ +/** + * Strategy example: RSI(14) mean-reversion. + * + *

Go long when RSI(14) drops below 30 (oversold), exit when it recovers above + * 70 (overbought). 0.1% fees per trade. The Java counterpart of + * {@code examples/python/strategy_rsi_mean_reversion.py}, printing the same + * summary. Uses the checked-in {@code examples/data/btcusdt-1h.csv} dataset + * (pass a CSV path to override). + */ public final class StrategyRsiMeanReversion { + private static final double FEE = 0.001; + private static final double OVERSOLD = 30.0; + private static final double OVERBOUGHT = 70.0; + public static void main(String[] args) throws Exception { - Bar[] bars = args.length > 0 ? MarketData.loadOhlcvCsv(args[0]) : MarketData.syntheticCandles(2000); + Bar[] bars = args.length > 0 ? MarketData.loadOhlcvCsv(args[0]) : MarketData.bundledCandles("btcusdt-1h.csv"); try (Rsi rsi = new Rsi(14)) { - List returns = new ArrayList<>(); - int trades = 0; boolean inPosition = false; - double entry = 0.0; + double entryPrice = 0.0; + List closedTrades = new ArrayList<>(); + double equity = 1.0; + List equityCurve = new ArrayList<>(); for (Bar b : bars) { double value = rsi.update(b.close()); + double price = b.close(); + equityCurve.add(inPosition ? equity * (price / entryPrice) : equity); if (!Double.isFinite(value)) { continue; } - if (!inPosition && value < 30.0) { + + if (!inPosition && value < OVERSOLD) { + entryPrice = price; + equity *= 1.0 - FEE; inPosition = true; - entry = b.close(); - trades++; - } else if (inPosition && value > 50.0) { - returns.add((b.close() - entry) / entry); + } else if (inPosition && value > OVERBOUGHT) { + double tradeRet = price / entryPrice - 1.0; + closedTrades.add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - FEE); inPosition = false; } } - Equity.print("RSI mean-reversion", Equity.summarize(returns, trades)); + if (inPosition) { + double tradeRet = bars[bars.length - 1].close() / entryPrice - 1.0; + closedTrades.add(tradeRet); + equity *= (1.0 + tradeRet) * (1.0 - FEE); + } + + Equity.printSummary("RSI Mean-Reversion (1h, BTCUSDT)", + bars[0].close(), bars[bars.length - 1].close(), bars.length, closedTrades, equity, equityCurve); } } } diff --git a/examples/python/strategy_bollinger_squeeze.py b/examples/python/strategy_bollinger_squeeze.py index 8374a831..b976b7df 100644 --- a/examples/python/strategy_bollinger_squeeze.py +++ b/examples/python/strategy_bollinger_squeeze.py @@ -115,7 +115,7 @@ def main() -> None: for c in candles: bb_out = bb.update(c["close"]) - atr_val = atr.update(c["high"], c["low"], c["close"]) + atr_val = atr.update(c) price = c["close"] mtm = equity * (price / entry_price) if in_position else equity equity_curve.append(mtm) diff --git a/examples/python/strategy_macd_adx.py b/examples/python/strategy_macd_adx.py index 18185b12..4880a1a0 100644 --- a/examples/python/strategy_macd_adx.py +++ b/examples/python/strategy_macd_adx.py @@ -101,7 +101,7 @@ def main() -> None: for c in candles: macd_out = macd.update(c["close"]) - adx_out = adx.update(c["high"], c["low"], c["close"]) + adx_out = adx.update(c) price = c["close"] mtm = equity * (price / entry_price) if in_position else equity equity_curve.append(mtm) @@ -112,7 +112,7 @@ def main() -> None: # 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 + adx_value = adx_out[2] if isinstance(adx_out, tuple) else adx_out.adx hist_sign = histogram > 0.0 cross_up = prev_hist_sign is False and hist_sign diff --git a/examples/r/_common.R b/examples/r/_common.R index fd4805ee..af5f4190 100644 --- a/examples/r/_common.R +++ b/examples/r/_common.R @@ -49,3 +49,43 @@ print_equity <- function(name, r) { cat(sprintf("%-26s return=%8.2f%% sharpe=%6.2f maxDD=%6.2f%% trades=%d\n", name, r$total_return_pct, r$sharpe, r$max_dd_pct, r$trades)) } + +# Loads one of the checked-in datasets under examples/data (the R examples run +# from this directory, so ../data is examples/data). +bundled_candles <- function(filename) { + load_ohlcv_csv(file.path("..", "data", filename)) +} + +# Prints the per-trade backtest summary shared verbatim with the Rust, Python, +# Node, Go, C and C# example suites (same labels, same numbers). +print_summary <- function(name, first_price, last_price, bars, closed_trades, final_equity, equity_curve) { + buy_hold <- last_price / first_price + strat_return <- final_equity - 1 + bh_return <- buy_hold - 1 + n <- length(closed_trades) + wins <- sum(closed_trades > 0) + losses <- sum(closed_trades < 0) + best <- if (n > 0) max(closed_trades) else 0 + worst <- if (n > 0) min(closed_trades) else 0 + mean_r <- if (n > 0) mean(closed_trades) else 0 + var_r <- if (n > 1) stats::var(closed_trades) else 0 + sharpe <- if (var_r > 0) mean_r / sqrt(var_r) else 0 + peak <- if (length(equity_curve) > 0) equity_curve[1] else 1 + maxdd <- 0 + for (eq in equity_curve) { + if (eq > peak) peak <- eq + dd <- (peak - eq) / peak + if (dd > maxdd) maxdd <- dd + } + cat(sprintf("=== %s ===\n", name)) + cat(sprintf("%-23s%d\n", "Bars:", bars)) + cat(sprintf("%-23s%d (W%d / L%d)\n", "Trades:", n, wins, losses)) + cat(sprintf("%-23s%+.2f%%\n", "Strategy return:", strat_return * 100)) + cat(sprintf("%-23s%+.2f%%\n", "Buy & Hold return:", bh_return * 100)) + cat(sprintf("%-23s%+.2f%%\n", "Excess over BH:", (strat_return - bh_return) * 100)) + cat(sprintf("%-23s%.2f%%\n", "Max drawdown:", maxdd * 100)) + cat(sprintf("%-23s%.2f (mean %+.4f, stddev %.4f)\n", "Per-trade Sharpe:", sharpe, mean_r, sqrt(var_r))) + cat(sprintf("%-23s%+.2f%% / %+.2f%%\n", "Best / worst trade:", best * 100, worst * 100)) + cat("\n") + cat("NOTE: Educational example — fees, slippage, funding costs and tax effects are simplified or omitted. Past performance is not indicative of future results.\n") +} diff --git a/examples/r/strategy_bollinger_squeeze.R b/examples/r/strategy_bollinger_squeeze.R index f3742809..8e977118 100644 --- a/examples/r/strategy_bollinger_squeeze.R +++ b/examples/r/strategy_bollinger_squeeze.R @@ -1,24 +1,69 @@ -# Breakout: when Bollinger bandwidth is tight (a "squeeze") and price closes above -# the upper band, go long with an ATR(14) trailing stop. -library(wickra) +# Strategy example: Bollinger-squeeze breakout with an ATR(14) trailing stop. +# +# Enters long when Bollinger bandwidth makes a new SQUEEZE_LOOKBACK low (a +# volatility squeeze) and price closes above the upper band; exits on an ATR(14) +# trailing stop or when the upper band falls back below the entry. 0.1% fees per +# trade. The R counterpart of examples/python/strategy_bollinger_squeeze.py, +# printing the same summary. Uses the checked-in examples/data/btcusdt-1d.csv +# dataset (pass a CSV path to override). +suppressPackageStartupMessages(library(wickra)) source("_common.R") +FEE <- 0.001 +ATR_STOP_MULT <- 2.0 +SQUEEZE_LOOKBACK <- 180 + args <- commandArgs(trailingOnly = TRUE) -bars <- if (length(args) >= 1) load_ohlcv_csv(args[1]) else synthetic_candles(2000) +bars <- if (length(args) >= 1) load_ohlcv_csv(args[1]) else bundled_candles("btcusdt-1d.csv") + +opens <- bars$open; highs <- bars$high; lows <- bars$low +closes <- bars$close; vols <- bars$volume; ts <- bars$timestamp +n_bars <- length(closes) bb <- BollingerBands(20, 2.0); atr <- Atr(14) -returns <- numeric(0); trades <- 0L; in_pos <- FALSE; entry <- 0; stop <- 0 -for (i in seq_len(nrow(bars))) { - b <- bars[i, ] - band <- update(bb, b$close) - atr_value <- update(atr, b$open, b$high, b$low, b$close, b$volume, b$timestamp) +in_pos <- FALSE; entry_price <- 0; stop_level <- 0 +closed <- numeric(0); equity <- 1; equity_curve <- numeric(n_bars) +bw_window <- numeric(0) + +for (i in seq_len(n_bars)) { + band <- update(bb, closes[i]) + atr_value <- update(atr, opens[i], highs[i], lows[i], closes[i], vols[i], ts[i]) + price <- closes[i] + equity_curve[i] <- if (in_pos) equity * (price / entry_price) else equity if (is.na(band[["middle"]]) || !is.finite(atr_value)) next - bandwidth <- if (band[["middle"]] != 0) (band[["upper"]] - band[["lower"]]) / band[["middle"]] else .Machine$double.xmax - if (!in_pos && bandwidth < 0.06 && b$close > band[["upper"]]) { - in_pos <- TRUE; entry <- b$close; stop <- b$close - 2 * atr_value; trades <- trades + 1L - } else if (in_pos) { - stop <- max(stop, b$close - 2 * atr_value) - if (b$close < stop) { returns <- c(returns, (b$close - entry) / entry); in_pos <- FALSE } + + middle <- band[["middle"]] + if (abs(middle) <= 1e-12) next + upper <- band[["upper"]]; lower <- band[["lower"]] + bandwidth <- (upper - lower) / middle + bw_window <- c(bw_window, bandwidth) + if (length(bw_window) > SQUEEZE_LOOKBACK) { + bw_window <- bw_window[(length(bw_window) - SQUEEZE_LOOKBACK + 1):length(bw_window)] + } + if (length(bw_window) < SQUEEZE_LOOKBACK) next + min_bw <- min(bw_window) + + if (in_pos) { + if (price < stop_level || upper < entry_price) { + trade_ret <- price / entry_price - 1 + closed <- c(closed, trade_ret) + equity <- equity * (1 + trade_ret) * (1 - FEE) + in_pos <- FALSE + } + } else { + is_new_low <- abs(bandwidth - min_bw) < 1e-12 + if (is_new_low && price > upper) { + entry_price <- price; stop_level <- price - ATR_STOP_MULT * atr_value + equity <- equity * (1 - FEE); in_pos <- TRUE + } } } -print_equity("Bollinger squeeze", summarize_equity(returns, trades)) + +if (in_pos) { + trade_ret <- closes[n_bars] / entry_price - 1 + closed <- c(closed, trade_ret) + equity <- equity * (1 + trade_ret) * (1 - FEE) +} + +print_summary("Bollinger Squeeze Breakout (1d, BTCUSDT)", + closes[1], closes[n_bars], n_bars, closed, equity, equity_curve) diff --git a/examples/r/strategy_macd_adx.R b/examples/r/strategy_macd_adx.R index 5ab56949..fd8e6a55 100644 --- a/examples/r/strategy_macd_adx.R +++ b/examples/r/strategy_macd_adx.R @@ -1,24 +1,54 @@ -# Trend follower: enter long on a MACD histogram cross up, but only when ADX(14) > 20 -# confirms a trend; exit when the histogram crosses back below zero. -library(wickra) +# Strategy example: MACD crossover with ADX trend-strength filter. +# +# Enters long on a MACD histogram cross up (the histogram turns positive) while +# ADX(14) > 20 (a directional market); exits on the opposite MACD crossover +# regardless of ADX. 0.1% fees per trade. The R counterpart of +# examples/python/strategy_macd_adx.py, printing the same summary. Uses the +# checked-in examples/data/btcusdt-1h.csv dataset (pass a CSV path to override). +suppressPackageStartupMessages(library(wickra)) source("_common.R") +FEE <- 0.001 +ADX_FLOOR <- 20 + args <- commandArgs(trailingOnly = TRUE) -bars <- if (length(args) >= 1) load_ohlcv_csv(args[1]) else synthetic_candles(2000) +bars <- if (length(args) >= 1) load_ohlcv_csv(args[1]) else bundled_candles("btcusdt-1h.csv") + +opens <- bars$open; highs <- bars$high; lows <- bars$low +closes <- bars$close; vols <- bars$volume; ts <- bars$timestamp +n_bars <- length(closes) macd <- MacdIndicator(12, 26, 9); adx <- Adx(14) -returns <- numeric(0); trades <- 0L; in_pos <- FALSE; entry <- 0; prev_hist <- NA_real_ -for (i in seq_len(nrow(bars))) { - b <- bars[i, ] - m <- update(macd, b$close) - a <- update(adx, b$open, b$high, b$low, b$close, b$volume, b$timestamp) +in_pos <- FALSE; entry_price <- 0; closed <- numeric(0); equity <- 1 +equity_curve <- numeric(n_bars); have_prev <- FALSE; prev_sign <- FALSE + +for (i in seq_len(n_bars)) { + m <- update(macd, closes[i]) + a <- update(adx, opens[i], highs[i], lows[i], closes[i], vols[i], ts[i]) + price <- closes[i] + equity_curve[i] <- if (in_pos) equity * (price / entry_price) else equity if (is.na(m[["macd"]]) || is.na(a[["adx"]])) next - trending <- a[["adx"]] > 20 - if (!in_pos && trending && is.finite(prev_hist) && prev_hist <= 0 && m[["histogram"]] > 0) { - in_pos <- TRUE; entry <- b$close; trades <- trades + 1L - } else if (in_pos && m[["histogram"]] < 0) { - returns <- c(returns, (b$close - entry) / entry); in_pos <- FALSE + + hist_sign <- m[["histogram"]] > 0 + cross_up <- have_prev && !prev_sign && hist_sign + cross_down <- have_prev && prev_sign && !hist_sign + have_prev <- TRUE; prev_sign <- hist_sign + + if (!in_pos && cross_up && a[["adx"]] > ADX_FLOOR) { + entry_price <- price; equity <- equity * (1 - FEE); in_pos <- TRUE + } else if (in_pos && cross_down) { + trade_ret <- price / entry_price - 1 + closed <- c(closed, trade_ret) + equity <- equity * (1 + trade_ret) * (1 - FEE) + in_pos <- FALSE } - prev_hist <- m[["histogram"]] } -print_equity("MACD + ADX trend", summarize_equity(returns, trades)) + +if (in_pos) { + trade_ret <- closes[n_bars] / entry_price - 1 + closed <- c(closed, trade_ret) + equity <- equity * (1 + trade_ret) * (1 - FEE) +} + +print_summary("MACD + ADX Trend Filter (1h, BTCUSDT)", + closes[1], closes[n_bars], n_bars, closed, equity, equity_curve) diff --git a/examples/r/strategy_rsi_mean_reversion.R b/examples/r/strategy_rsi_mean_reversion.R index d0a944db..68032232 100644 --- a/examples/r/strategy_rsi_mean_reversion.R +++ b/examples/r/strategy_rsi_mean_reversion.R @@ -1,20 +1,47 @@ -# Mean reversion: go long when RSI(14) drops below 30, exit when it recovers above 50. -library(wickra) +# Strategy example: RSI(14) mean-reversion. +# +# Go long when RSI(14) drops below 30 (oversold), exit when it recovers above 70 +# (overbought). 0.1% fees per trade. The R counterpart of +# examples/python/strategy_rsi_mean_reversion.py, printing the same summary. Uses +# the checked-in examples/data/btcusdt-1h.csv dataset (pass a CSV path to override). +suppressPackageStartupMessages(library(wickra)) source("_common.R") +FEE <- 0.001 +OVERSOLD <- 30 +OVERBOUGHT <- 70 + args <- commandArgs(trailingOnly = TRUE) -bars <- if (length(args) >= 1) load_ohlcv_csv(args[1]) else synthetic_candles(2000) +bars <- if (length(args) >= 1) load_ohlcv_csv(args[1]) else bundled_candles("btcusdt-1h.csv") + +closes <- bars$close +n_bars <- length(closes) rsi <- Rsi(14) -returns <- numeric(0); trades <- 0L; in_pos <- FALSE; entry <- 0 -for (i in seq_len(nrow(bars))) { - cl <- bars$close[i] - value <- update(rsi, cl) +in_pos <- FALSE; entry_price <- 0; closed <- numeric(0); equity <- 1 +equity_curve <- numeric(n_bars) + +for (i in seq_len(n_bars)) { + value <- update(rsi, closes[i]) + price <- closes[i] + equity_curve[i] <- if (in_pos) equity * (price / entry_price) else equity if (!is.finite(value)) next - if (!in_pos && value < 30) { - in_pos <- TRUE; entry <- cl; trades <- trades + 1L - } else if (in_pos && value > 50) { - returns <- c(returns, (cl - entry) / entry); in_pos <- FALSE + + if (!in_pos && value < OVERSOLD) { + entry_price <- price; equity <- equity * (1 - FEE); in_pos <- TRUE + } else if (in_pos && value > OVERBOUGHT) { + trade_ret <- price / entry_price - 1 + closed <- c(closed, trade_ret) + equity <- equity * (1 + trade_ret) * (1 - FEE) + in_pos <- FALSE } } -print_equity("RSI mean-reversion", summarize_equity(returns, trades)) + +if (in_pos) { + trade_ret <- closes[n_bars] / entry_price - 1 + closed <- c(closed, trade_ret) + equity <- equity * (1 + trade_ret) * (1 - FEE) +} + +print_summary("RSI Mean-Reversion (1h, BTCUSDT)", + closes[1], closes[n_bars], n_bars, closed, equity, equity_curve)