/* * SPDX-License-Identifier: MIT */ package ta4jexamples.backtesting; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.ta4j.core.*; import org.ta4j.core.backtest.BacktestExecutionResult; import org.ta4j.core.backtest.BacktestExecutor; import org.ta4j.core.backtest.ProgressCompletion; import org.ta4j.core.criteria.ExpectancyCriterion; import org.ta4j.core.criteria.pnl.NetProfitCriterion; import org.ta4j.core.indicators.NetMomentumIndicator; import org.ta4j.core.indicators.RSIIndicator; import org.ta4j.core.indicators.helpers.ClosePriceIndicator; import org.ta4j.core.num.Num; import org.ta4j.core.num.NumFactory; import org.ta4j.core.reports.TradingStatement; import org.ta4j.core.rules.CrossedDownIndicatorRule; import org.ta4j.core.rules.CrossedUpIndicatorRule; import org.ta4j.core.serialization.DurationTypeAdapter; import ta4jexamples.datasources.JsonFileBarSeriesDataSource; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.management.GarbageCollectorMXBean; import java.lang.management.ManagementFactory; import java.lang.management.MemoryUsage; import java.net.InetAddress; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.StringJoiner; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; /** * Performance tuning harness for backtesting large numbers of strategies. *
* This class provides a comprehensive tool for optimizing backtest performance * by systematically testing different parameter combinations and identifying * optimal settings for your hardware and dataset. It helps tune several * interrelated performance parameters: *
* The harness uses a non-trivial NetMomentumIndicator-based strategy workload * to make garbage collection (GC) and caching behavior visible. It * automatically detects non-linear performance degradation (e.g., excessive GC * overhead or slowdown beyond expected scaling) and recommends optimal * parameter combinations. *
*
* The harness supports four execution modes: *
*
*
{@code
* java BacktestPerformanceTuningHarness \
* --strategies 1000 \
* --barCount 2000 \
* --executionMode full
* }
* *
{@code
* java BacktestPerformanceTuningHarness \
* --tune \
* --tuneStrategyStart 2000 \
* --tuneStrategyStep 2000 \
* --tuneStrategyMax 20000 \
* --tuneBarCounts 500,1000,2000,full \
* --tuneMaxBarCountHints 0,512,1024,2048 \
* --executionMode topK \
* --topK 20
* }
*
* This will test strategy counts from 2000 to 20000 (in steps of 2000) across
* different bar counts and maximum bar count hints, then recommend the best
* configuration.
* *
{@code
* java BacktestPerformanceTuningHarness \
* --tuneHeaps 4g,8g,16g \
* --tuneStrategyStart 5000 \
* --tuneStrategyMax 50000 \
* --executionMode topK \
* --topK 20
* }
*
* This forks separate JVMs with 4GB, 8GB, and 16GB heaps, running a full tuning
* cycle in each.
* *
{@code
* java BacktestPerformanceTuningHarness \
* --strategies 10000 \
* --barCount 2000 \
* --maxBarCountHint 1024 \
* --executionMode topK \
* --topK 20 \
* --progress
* }
*
* The {@code --progress} flag enables progress logging with memory usage
* information.
* *
{@code
* java BacktestPerformanceTuningHarness \
* --throughputControl \
* --throughputOutputDir .agents/benchmarks/backtest-throughput/current \
* --matrixStrategyCounts 250,500,1000 \
* --matrixBarCounts 500,1000 \
* --matrixMaxBarCountHints 0 \
* --executionMode topK \
* --topK 10 \
* --parallelism 1
* }
* *
{@code --tune --tuneStrategyStart 1000 --tuneStrategyStep 5000 --tuneStrategyMax 50000}
*
* {@code --tune --tuneStrategyStart 8000 --tuneStrategyStep 1000 --tuneStrategyMax 15000}
*
* {@code --tune --tuneMaxBarCountHints 0,256,512,1024,2048,4096}
*
* {@code --tuneHeaps 2g,4g,8g,16g}
*
* *
* The harness outputs several types of information: *
*
* The harness generates strategies using a grid search over * NetMomentumIndicator parameters: *
*
* Run with {@code --help} to see all available options. Key options include: *
*
*
* Parses command-line arguments and executes the requested operation: *
* Example usage: * *
{@code
* // Single run
* java BacktestPerformanceTuningHarness --strategies 1000 --barCount 2000
*
* // Tuning mode
* java BacktestPerformanceTuningHarness --tune --tuneStrategyMax 20000
*
* // Cross-heap tuning
* java BacktestPerformanceTuningHarness --tuneHeaps 4g,8g,16g
* }
*
* @param args Command-line arguments (see {@code --help} for full list)
* @throws Exception If an error occurs during execution
*/
public static void main(String[] args) throws Exception {
HarnessCli cli = HarnessCli.parse(args);
if (cli.help) {
logUsage();
return;
}
if (!cli.tuneHeaps.isEmpty()) {
runTuneAcrossHeaps(cli);
return;
}
BarSeries baseSeries = loadSeries(cli.ohlcResourceFile);
Objects.requireNonNull(baseSeries, "Bar series was null");
if (cli.throughputControl) {
runThroughputControl(baseSeries, cli);
return;
}
if (cli.tune) {
warmupOnce(baseSeries);
runTuneInProcess(baseSeries, cli);
return;
}
RunOnceConfig runConfig = new RunOnceConfig(cli.strategyCount, cli.barCount, cli.maximumBarCountHint,
cli.executionMode, cli.topK, cli.progress);
RunOutcome runOutcome = runOnce(baseSeries, runConfig);
if (cli.topK > 0) {
logTopStrategies(runOutcome.result(), cli.topK);
}
}
static void runThroughputControl(BarSeries baseSeries, HarnessCli cli) throws IOException {
Objects.requireNonNull(baseSeries, "baseSeries must not be null");
Objects.requireNonNull(cli, "cli must not be null");
ThroughputControlPlan plan = ThroughputControlPlan.fromCli(cli, baseSeries.getBarCount());
Files.createDirectories(plan.outputDir());
HostTelemetry host = HostTelemetry.capture();
writeJson(plan.outputDir().resolve(THROUGHPUT_MANIFEST_FILE), plan.toManifest(host));
LOG.info("Throughput control plan: cells={}, parallelism={}, outputDir={}", plan.cells().size(),
plan.resolvedParallelism(), plan.outputDir());
long startedNanos = System.nanoTime();
ThroughputMatrixPerformanceTracker tracker = new ThroughputMatrixPerformanceTracker();
if (plan.resolvedParallelism() == 1) {
for (ThroughputMatrixCell cell : plan.cells()) {
tracker.record(runThroughputCell(baseSeries, plan, cell));
if (plan.gcBetweenRuns()) {
System.gc();
Thread.yield();
}
}
} else {
runThroughputCellsInParallel(baseSeries, plan, tracker);
}
long totalWallTimeMs = elapsedMillis(startedNanos);
JsonObject performance = tracker.toJson(totalWallTimeMs, plan, host);
writeJson(plan.outputDir().resolve(MATRIX_PERFORMANCE_FILE), performance);
writeJson(plan.outputDir().resolve(MATRIX_CELLS_FILE), tracker.cellsJson());
LOG.info(THROUGHPUT_RESULT_PREFIX + "{}", PRETTY_GSON.toJson(performance));
}
private static void runThroughputCellsInParallel(BarSeries baseSeries, ThroughputControlPlan plan,
ThroughputMatrixPerformanceTracker tracker) throws IOException {
ExecutorService executor = Executors.newFixedThreadPool(plan.resolvedParallelism());
try {
List* This method: *
* The best recommendation is determined by: *
* Only results with a non-null {@code lastLinear} (indicating successful linear
* performance) are considered.
*
* @param results List of variant tuning results to evaluate
* @return The best recommendation, or null if no valid results are found
*/
static RunResult selectBestRecommendation(List
* Non-linear behavior is detected when either:
*
* The normalized slowdown ratio is calculated as:
*
*
* This method is used during tuning to identify the point where increasing
* strategy count or bar count causes performance to degrade beyond expected
* linear scaling.
*
* @param previous The previous run result (baseline)
* @param current The current run result (to compare against baseline)
* @param thresholds The thresholds for detecting non-linear behavior
* @return true if non-linear behavior is detected, false otherwise
* @throws NullPointerException If any parameter is null
*/
static boolean isNonLinear(RunResult previous, RunResult current, Thresholds thresholds) {
Objects.requireNonNull(previous, "previous must not be null");
Objects.requireNonNull(current, "current must not be null");
Objects.requireNonNull(thresholds, "thresholds must not be null");
if (previous.workUnits() <= 0 || current.workUnits() <= 0) {
return false;
}
if (previous.runtimeStats().overallRuntime().isZero() || current.runtimeStats().overallRuntime().isZero()) {
return false;
}
double workRatio = current.workUnits() / (double) previous.workUnits();
double runtimeRatio = current.runtimeStats().overallRuntime().toNanos()
/ (double) previous.runtimeStats().overallRuntime().toNanos();
double normalizedSlowdown = runtimeRatio / workRatio;
double gcOverhead = current.gcOverhead();
boolean gcNonLinear = gcOverhead >= thresholds.gcOverheadThreshold();
boolean slowdownNonLinear = normalizedSlowdown >= thresholds.slowdownRatioThreshold();
if (gcNonLinear || slowdownNonLinear) {
LOG.info("Non-linear check: gcOverhead={} (threshold={}), slowdown={} (threshold={})",
formatPercent(gcOverhead), formatPercent(thresholds.gcOverheadThreshold()),
String.format(Locale.ROOT, "%.3f", normalizedSlowdown),
String.format(Locale.ROOT, "%.3f", thresholds.slowdownRatioThreshold()));
return true;
}
return false;
}
private static String formatPercent(double value) {
return String.format(Locale.ROOT, "%.2f%%", value * 100d);
}
private static long elapsedMillis(long startedNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
}
private static void writeJson(Path path, JsonObject object) throws IOException {
Files.createDirectories(path.getParent());
Files.writeString(path, PRETTY_GSON.toJson(object) + System.lineSeparator(), StandardCharsets.UTF_8);
}
static String shortSha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 8; i++) {
builder.append(String.format(Locale.ROOT, "%02x", hash[i]));
}
return builder.toString();
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
}
/**
* Slices a bar series to contain only the last N bars.
*
* If barCount is 0, negative, or greater than or equal to the available bars,
* the original series is returned unchanged. Otherwise, returns a sub-series
* containing the last barCount bars.
*
* This is useful for testing performance with different dataset sizes without
* loading multiple files.
*
* @param series The bar series to slice
* @param barCount The number of bars to keep (0 or negative = keep all)
* @return A sub-series containing the last barCount bars, or the original
* series if no slicing is needed
* @throws NullPointerException If series is null
*/
static BarSeries sliceToLastBars(BarSeries series, int barCount) {
Objects.requireNonNull(series, "series must not be null");
if (barCount <= 0) {
return series;
}
int availableBars = series.getBarCount();
if (barCount >= availableBars) {
return series;
}
int endExclusive = series.getEndIndex() + 1;
int startIndex = Math.max(0, endExclusive - barCount);
return series.getSubSeries(startIndex, endExclusive);
}
/**
* Applies a maximum bar count hint to a bar series for indicator caching
* optimization.
*
* The maximum bar count hint controls the size of the indicator cache window.
* When set, indicators will only cache values for the most recent N bars,
* reducing memory usage for large datasets.
*
* If maximumBarCountHint is 0 or negative, the original series is returned
* unchanged. If it matches the series' current maximum bar count, the original
* series is returned. Otherwise, returns a wrapper that overrides
* {@link BarSeries#getMaximumBarCount()}.
*
* This is useful for testing the impact of indicator caching on performance and
* memory usage.
*
* @param series The bar series to wrap
* @param maximumBarCountHint The maximum bar count hint (0 = disabled, use
* series default)
* @return A series with the maximum bar count hint applied, or the original
* series if no change is needed
* @throws NullPointerException If series is null
*/
static BarSeries applyMaximumBarCountHint(BarSeries series, int maximumBarCountHint) {
Objects.requireNonNull(series, "series must not be null");
if (maximumBarCountHint <= 0) {
return series;
}
if (maximumBarCountHint == series.getMaximumBarCount()) {
return series;
}
return new MaxBarCountHintSeries(series, maximumBarCountHint);
}
/**
* Creates a variety of strategies using NetMomentumIndicator with different
* parameter combinations for performance testing.
*
* Generates strategies by systematically varying:
*
* This generates approximately 10,416 unique strategy combinations. When fewer
* strategies are requested, the method samples from this grid. When more are
* requested, it repeats the grid with different repetition markers.
*
* Strategies use:
*
* Strategies that share the same RSI, Net Momentum timeframe, and decay factor
* reuse the same indicator graph. The underlying cached indicators are
* thread-safe, and sharing them keeps this benchmark focused on rule thresholds
* instead of repeatedly recomputing identical momentum series.
*
* @param series The bar series to use for indicator
* calculations
* @param requestedStrategyCount The number of strategies to create. Use -1 for
* full grid, or a positive number to sample that
* many strategies
* @return A list of strategies to test
* @throws NullPointerException If series is null
* @throws IllegalArgumentException If requestedStrategyCount is zero
*/
static List
* The strategy uses:
*
* The repetition parameter is used to create multiple strategies with the same
* parameters when more strategies are requested than the grid can provide. It's
* included in the strategy name for identification.
*
* @param series The bar series to use for indicator calculations
* @param rsiBarCount The number of bars to use for RSI calculation
* (must be positive)
* @param timeFrame The timeframe for NetMomentumIndicator (must be
* positive)
* @param oversoldThreshold The oversold threshold for entry signals
* @param overboughtThreshold The overbought threshold for exit signals
* @param decayFactor The decay factor for NetMomentumIndicator
* (typically 0.9 to 1.0)
* @param repetition The repetition number (0 for first occurrence,
* incremented for repeats)
* @return A new strategy with the specified parameters
* @throws NullPointerException If series is null
* @throws IllegalArgumentException If rsiBarCount or timeFrame is not positive
*/
static Strategy createStrategy(BarSeries series, int rsiBarCount, int timeFrame, int oversoldThreshold,
int overboughtThreshold, double decayFactor, int repetition) {
Objects.requireNonNull(series, "series cannot be null");
ClosePriceIndicator closePriceIndicator = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePriceIndicator, rsiBarCount);
NetMomentumIndicator netMomentumIndicator = NetMomentumIndicator.forRsiWithDecay(rsiIndicator, timeFrame,
decayFactor);
return createStrategy(netMomentumIndicator, rsiBarCount, timeFrame, oversoldThreshold, overboughtThreshold,
decayFactor, repetition);
}
private static Strategy createStrategy(NetMomentumIndicator netMomentumIndicator, int rsiBarCount, int timeFrame,
int oversoldThreshold, int overboughtThreshold, double decayFactor, int repetition) {
Objects.requireNonNull(netMomentumIndicator, "netMomentumIndicator cannot be null");
if (rsiBarCount <= 0) {
throw new IllegalArgumentException("rsiBarCount should be positive");
}
if (timeFrame <= 0) {
throw new IllegalArgumentException("timeFrame should be positive");
}
Rule entryRule = new CrossedUpIndicatorRule(netMomentumIndicator, oversoldThreshold);
Rule exitRule = new CrossedDownIndicatorRule(netMomentumIndicator, overboughtThreshold);
String suffix = repetition > 0 ? " (rep=" + repetition + ")" : "";
String strategyName = "Entry Crossed Up: {rsiBarCount=" + rsiBarCount + ", timeFrame=" + timeFrame
+ ", oversoldThreshold=" + oversoldThreshold + "}, Exit Crossed Down: {rsiBarCount=" + rsiBarCount
+ ", timeFrame=" + timeFrame + ", overboughtThreshold=" + overboughtThreshold + ", decayFactor="
+ decayFactor + "}" + suffix;
return new BaseStrategy(strategyName, entryRule, exitRule);
}
private static void logTopStrategies(BacktestExecutionResult result, int topK) {
AnalysisCriterion netProfitCriterion = new NetProfitCriterion();
AnalysisCriterion expectancyCriterion = new ExpectancyCriterion();
List
* Formats bytes using binary units (KiB, MiB, GiB, TiB) with 2 decimal places.
* Examples:
*
* This wrapper is used during performance tuning to test the impact of
* different maximum bar count hints on performance and memory usage. It
* delegates all BarSeries operations to the underlying series but overrides
* {@link #getMaximumBarCount()} to return the specified hint.
*
* The maximum bar count hint cannot be changed after construction
* (setMaximumBarCount throws UnsupportedOperationException) since this is a
* hint-only override for benchmarking purposes.
*/
final class MaxBarCountHintSeries implements BarSeries {
private static final long serialVersionUID = 4398573823756330718L;
private final BarSeries delegate;
private final int maximumBarCountHint;
MaxBarCountHintSeries(BarSeries delegate, int maximumBarCountHint) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
this.maximumBarCountHint = maximumBarCountHint;
}
@Override
public NumFactory numFactory() {
return delegate.numFactory();
}
@Override
public BarBuilder barBuilder() {
return delegate.barBuilder();
}
@Override
public String getName() {
return delegate.getName();
}
@Override
public Bar getBar(int i) {
return delegate.getBar(i);
}
@Override
public int getBarCount() {
return delegate.getBarCount();
}
@Override
public List
*
* {@code
* (runtimeRatio / workRatio)
* }
*
* where runtimeRatio is the ratio of runtimes and workRatio is the ratio of
* work units (strategies × bars). A value greater than 1.0 indicates that
* runtime increased faster than work, suggesting non-linear scaling.
*
*
*
*
*
*
*
*
*
* @param bytes The number of bytes to format
* @return A formatted string with appropriate unit (B, KiB, MiB, GiB, or TiB)
*/
static String formatBytes(long bytes) {
if (bytes < 1024) {
return bytes + " B";
}
double value = bytes;
String[] units = new String[] { "B", "KiB", "MiB", "GiB", "TiB" };
int unitIndex = 0;
while (value >= 1024d && unitIndex < units.length - 1) {
value /= 1024d;
unitIndex++;
}
return String.format(Locale.ROOT, "%.2f %s", value, units[unitIndex]);
}
}
/**
* Execution mode for backtest runs.
*
*
*/
enum ExecutionMode {
/** Execute all strategies and return full results. */
FULL_RESULT,
/** Execute all strategies but only keep top K results. */
KEEP_TOP_K
}
/**
* Configuration for a single backtest run.
*
* @param strategyCount Number of strategies to test (-1 for full grid)
* @param barCount Number of bars to use (0 or negative for full
* series)
* @param maximumBarCountHint Maximum bar count hint for indicator caching (0 to
* disable)
* @param executionMode Execution mode (FULL_RESULT or KEEP_TOP_K)
* @param topK Number of top strategies to keep when using
* KEEP_TOP_K mode
* @param progress Whether to enable progress logging with memory
* information
*/
record RunOnceConfig(int strategyCount, int barCount, int maximumBarCountHint, ExecutionMode executionMode, int topK,
boolean progress) {
}
/**
* Thresholds for detecting non-linear performance behavior.
*
* @param gcOverheadThreshold GC overhead threshold (0.0 to 1.0, e.g., 0.25 =
* 25% of runtime)
* @param slowdownRatioThreshold Normalized slowdown ratio threshold (e.g., 1.25
* = 25% slowdown)
*/
record Thresholds(double gcOverheadThreshold, double slowdownRatioThreshold) {
String describe() {
return String.format(Locale.ROOT, "{gcOverhead=%s, slowdownRatio>=%.3f}", formatPercent(gcOverheadThreshold),
slowdownRatioThreshold);
}
private static String formatPercent(double value) {
return String.format(Locale.ROOT, "%.2f%%", value * 100d);
}
}
record TunePlan(List