examples: fix and harmonize the strategy backtests across all languages (#324)

The strategy_* examples were only syntax-smoked in CI, never run, which hid two
classes of problem:

1. Python strategy_macd_adx / strategy_bollinger_squeeze passed three separate
   arguments to the candle indicators ADX/ATR, whose .update() takes a single
   candle — a TypeError at runtime — and read the ADX tuple at index 0 (plus_di)
   instead of 2 (adx). Both fixed.

2. The Go / C# / R / Java strategies defaulted to synthetic data and used a
   different (annualised) one-line summary, so they printed wildly different
   numbers from the Rust/Python/Node/C/WASM suite. Rewrite them to the shared
   per-trade backtest (load the bundled BTCUSDT CSV by default, same entry/exit
   logic, same print_summary output).

All nine runnable bindings now print byte-identical backtest summaries on the
same data (MACD+ADX 246 trades / -47.19%, RSI 37 / -17.84%, Bollinger 1 / -7.82%),
verified by diffing each language's output against the Python reference. WASM
shares the same logic and bundled dataset (browser-rendered).
This commit is contained in:
kingchenc
2026-06-17 17:56:22 +02:00
committed by GitHub
parent 2e07c07a40
commit 75eefbbd08
20 changed files with 886 additions and 196 deletions
+40
View File
@@ -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")
}
+61 -16
View File
@@ -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)
+46 -16
View File
@@ -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)
+39 -12
View File
@@ -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)