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:
@@ -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<Double> closedTrades, double finalEquity, List<Double> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Double> 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<Double> closedTrades = new ArrayList<>();
|
||||
double equity = 1.0;
|
||||
List<Double> equityCurve = new ArrayList<>();
|
||||
Deque<Double> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Double> returns = new ArrayList<>();
|
||||
int trades = 0;
|
||||
boolean inPosition = false;
|
||||
double entry = 0.0;
|
||||
double prevHistogram = Double.NaN;
|
||||
double entryPrice = 0.0;
|
||||
List<Double> closedTrades = new ArrayList<>();
|
||||
double equity = 1.0;
|
||||
List<Double> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Double> returns = new ArrayList<>();
|
||||
int trades = 0;
|
||||
boolean inPosition = false;
|
||||
double entry = 0.0;
|
||||
double entryPrice = 0.0;
|
||||
List<Double> closedTrades = new ArrayList<>();
|
||||
double equity = 1.0;
|
||||
List<Double> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user