2006 lines
86 KiB
Java
2006 lines
86 KiB
Java
/*
|
||
* 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.
|
||
* <p>
|
||
* 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:
|
||
* <ul>
|
||
* <li><b>Strategy count:</b> How many strategies to evaluate in a single
|
||
* backtest run</li>
|
||
* <li><b>Bar series size:</b> Number of bars to use (last-N bars from the
|
||
* dataset)</li>
|
||
* <li><b>Maximum bar count hint:</b> Indicator cache window size via
|
||
* {@link BarSeries#getMaximumBarCount()} to control memory usage</li>
|
||
* <li><b>JVM heap size:</b> Optional: fork child JVMs with different heap sizes
|
||
* to find optimal memory configuration</li>
|
||
* </ul>
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* <h2>Execution Modes</h2>
|
||
* <p>
|
||
* The harness supports four execution modes:
|
||
* <ol>
|
||
* <li><b>Run Once (default):</b> Execute a single backtest with specified
|
||
* parameters. Useful for quick performance checks or production runs with known
|
||
* optimal settings.</li>
|
||
* <li><b>Tune In-Process:</b> Run multiple backtests with varying parameters to
|
||
* find optimal settings. Tests different strategy counts, bar counts, and
|
||
* maximum bar count hints systematically.</li>
|
||
* <li><b>Tune Across Heaps:</b> Fork child JVMs with different heap sizes to
|
||
* test memory configuration impact. Each child JVM runs a full tuning
|
||
* cycle.</li>
|
||
* <li><b>Throughput Control:</b> Run a fixed strategy/bar/cache matrix and
|
||
* write {@code matrix_performance.json} with cells/min and hypotheses/min for
|
||
* reproducible before/after comparisons.</li>
|
||
* </ol>
|
||
* <p>
|
||
* <h2>Usage Examples</h2>
|
||
* <p>
|
||
* <h3>Example 1: Quick Performance Check</h3> Run a single backtest with 1000
|
||
* strategies on the last 2000 bars:
|
||
*
|
||
* <pre>{@code
|
||
* java BacktestPerformanceTuningHarness \
|
||
* --strategies 1000 \
|
||
* --barCount 2000 \
|
||
* --executionMode full
|
||
* }</pre>
|
||
* <p>
|
||
* <h3>Example 2: Find Optimal Settings</h3> Run a tuning cycle to find optimal
|
||
* parameters for your hardware:
|
||
*
|
||
* <pre>{@code
|
||
* java BacktestPerformanceTuningHarness \
|
||
* --tune \
|
||
* --tuneStrategyStart 2000 \
|
||
* --tuneStrategyStep 2000 \
|
||
* --tuneStrategyMax 20000 \
|
||
* --tuneBarCounts 500,1000,2000,full \
|
||
* --tuneMaxBarCountHints 0,512,1024,2048 \
|
||
* --executionMode topK \
|
||
* --topK 20
|
||
* }</pre>
|
||
*
|
||
* 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.
|
||
* <p>
|
||
* <h3>Example 3: Test Different Heap Sizes</h3> Test performance across
|
||
* different JVM heap sizes:
|
||
*
|
||
* <pre>{@code
|
||
* java BacktestPerformanceTuningHarness \
|
||
* --tuneHeaps 4g,8g,16g \
|
||
* --tuneStrategyStart 5000 \
|
||
* --tuneStrategyMax 50000 \
|
||
* --executionMode topK \
|
||
* --topK 20
|
||
* }</pre>
|
||
*
|
||
* This forks separate JVMs with 4GB, 8GB, and 16GB heaps, running a full tuning
|
||
* cycle in each.
|
||
* <p>
|
||
* <h3>Example 4: Production Run with Optimal Settings</h3> After tuning, use
|
||
* the recommended settings for a production run:
|
||
*
|
||
* <pre>{@code
|
||
* java BacktestPerformanceTuningHarness \
|
||
* --strategies 10000 \
|
||
* --barCount 2000 \
|
||
* --maxBarCountHint 1024 \
|
||
* --executionMode topK \
|
||
* --topK 20 \
|
||
* --progress
|
||
* }</pre>
|
||
*
|
||
* The {@code --progress} flag enables progress logging with memory usage
|
||
* information.
|
||
* <p>
|
||
* <h3>Example 5: Fixed Throughput Matrix</h3> Produce parseable throughput
|
||
* telemetry for a fixed matrix:
|
||
*
|
||
* <pre>{@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
|
||
* }</pre>
|
||
* <p>
|
||
* <h2>Performance Tuning Workflow</h2>
|
||
* <ol>
|
||
* <li><b>Initial Exploration:</b> Start with a broad tuning run to identify
|
||
* promising regions:
|
||
*
|
||
* <pre>{@code --tune --tuneStrategyStart 1000 --tuneStrategyStep 5000 --tuneStrategyMax 50000}</pre>
|
||
*
|
||
* </li>
|
||
* <li><b>Fine-Tuning:</b> Narrow down to the promising region with smaller
|
||
* steps:
|
||
*
|
||
* <pre>{@code --tune --tuneStrategyStart 8000 --tuneStrategyStep 1000 --tuneStrategyMax 15000}</pre>
|
||
*
|
||
* </li>
|
||
* <li><b>Memory Optimization:</b> Test different maximum bar count hints to
|
||
* balance memory and performance:
|
||
*
|
||
* <pre>{@code --tune --tuneMaxBarCountHints 0,256,512,1024,2048,4096}</pre>
|
||
*
|
||
* </li>
|
||
* <li><b>Heap Size Testing:</b> If memory is a concern, test different heap
|
||
* sizes:
|
||
*
|
||
* <pre>{@code --tuneHeaps 2g,4g,8g,16g}</pre>
|
||
*
|
||
* </li>
|
||
* </ol>
|
||
* <p>
|
||
* <h2>Understanding Results</h2>
|
||
* <p>
|
||
* The harness outputs several types of information:
|
||
* <ul>
|
||
* <li><b>HARNESS_RESULT:</b> JSON-formatted results for each run, including
|
||
* runtime statistics, GC overhead, heap usage, and work units (strategies ×
|
||
* bars)</li>
|
||
* <li><b>RECOMMENDED_SETTINGS:</b> Optimal parameter combinations based on
|
||
* linear performance behavior (before non-linear degradation is detected)</li>
|
||
* <li><b>Non-linear detection:</b> When performance degrades beyond expected
|
||
* scaling (excessive GC overhead or slowdown ratio), the harness flags this and
|
||
* recommends staying below that threshold</li>
|
||
* </ul>
|
||
* <p>
|
||
* <h2>Strategy Generation</h2>
|
||
* <p>
|
||
* The harness generates strategies using a grid search over
|
||
* NetMomentumIndicator parameters:
|
||
* <ul>
|
||
* <li>RSI bar count: 7 to 49 (increment: 7)</li>
|
||
* <li>Momentum timeframe: 100 to 400 (increment: 100)</li>
|
||
* <li>Oversold threshold: -2000 to 0 (increment: 250)</li>
|
||
* <li>Overbought threshold: 0 to 1500 (increment: 250)</li>
|
||
* <li>Decay factor: 0.9 to 1.0 (increment: 0.02)</li>
|
||
* </ul>
|
||
* This generates approximately 10,416 unique strategy combinations. When fewer
|
||
* strategies are requested, the harness samples from this grid. When more are
|
||
* requested, it repeats the grid with different repetition markers.
|
||
* <p>
|
||
* <h2>Command-Line Options</h2>
|
||
* <p>
|
||
* Run with {@code --help} to see all available options. Key options include:
|
||
* <ul>
|
||
* <li>{@code --dataset <file>}: OHLC data file (default:
|
||
* Coinbase-ETH-USD-PT1D-20160517_20251028.json)</li>
|
||
* <li>{@code --strategies <N>}: Number of strategies to test (default: full
|
||
* grid ~10,416)</li>
|
||
* <li>{@code --barCount <N>}: Number of bars to use (default: full series)</li>
|
||
* <li>{@code --maxBarCountHint <N>}: Maximum bar count hint for indicator
|
||
* caching (0 = disabled)</li>
|
||
* <li>{@code --executionMode full|topK}: Execution mode (default: full)</li>
|
||
* <li>{@code --topK <N>}: Number of top strategies to keep when using topK mode
|
||
* (default: 20)</li>
|
||
* <li>{@code --tune}: Enable tuning mode</li>
|
||
* <li>{@code --tuneStrategyStart <N>}: Starting strategy count for tuning
|
||
* (default: 2000)</li>
|
||
* <li>{@code --tuneStrategyStep <N>}: Strategy count increment for tuning
|
||
* (default: 2000)</li>
|
||
* <li>{@code --tuneStrategyMax <N>}: Maximum strategy count for tuning
|
||
* (default: 20000)</li>
|
||
* <li>{@code --tuneBarCounts <csv>}: Bar counts to test (default:
|
||
* 500,1000,2000,full)</li>
|
||
* <li>{@code --tuneMaxBarCountHints <csv>}: Maximum bar count hints to test
|
||
* (default: 0,512,1024,2048)</li>
|
||
* <li>{@code --nonlinearGcOverhead <0..1>}: GC overhead threshold for
|
||
* non-linear detection (default: 0.25)</li>
|
||
* <li>{@code --nonlinearSlowdownRatio <x>}: Slowdown ratio threshold for
|
||
* non-linear detection (default: 1.25)</li>
|
||
* <li>{@code --tuneHeaps <csv>}: Heap sizes to test (e.g., 4g,8g,16g)</li>
|
||
* <li>{@code --throughputControl}: Write fixed-matrix throughput artifacts</li>
|
||
* <li>{@code --throughputOutputDir}: Artifact directory for throughput control
|
||
* mode</li>
|
||
* <li>{@code --matrixStrategyCounts <csv>}: Strategy-count cells for throughput
|
||
* mode</li>
|
||
* <li>{@code --matrixBarCounts <csv>}: Bar-count cells for throughput mode;
|
||
* accepts {@code full}</li>
|
||
* <li>{@code --matrixMaxBarCountHints <csv>}: Maximum-bar-count hint cells for
|
||
* throughput mode</li>
|
||
* <li>{@code --parallelism <auto|N>}: Throughput matrix cell fan-out</li>
|
||
* <li>{@code --progress}: Enable progress logging with memory information</li>
|
||
* <li>{@code --gcBetweenRuns}: Force GC between tuning runs (default:
|
||
* true)</li>
|
||
* </ul>
|
||
* <p>
|
||
* <h2>Performance Notes</h2>
|
||
* <ul>
|
||
* <li>The default parameter ranges generate ~10,000+ strategies.
|
||
* BacktestExecutor automatically uses batch processing for large strategy
|
||
* counts (>1000) to prevent memory exhaustion.</li>
|
||
* <li>If execution is too slow, consider:
|
||
* <ol>
|
||
* <li>Increasing increment values to reduce grid density</li>
|
||
* <li>Narrowing MIN/MAX ranges based on preliminary results</li>
|
||
* <li>Using coarser increments for initial exploration, then fine-tuning
|
||
* promising regions</li>
|
||
* </ol>
|
||
* </li>
|
||
* <li>The harness performs a warm-up run before tuning to stabilize JVM
|
||
* performance metrics.</li>
|
||
* <li>Non-linear behavior detection helps identify when increasing strategy
|
||
* count or bar count causes performance to degrade beyond expected linear
|
||
* scaling.</li>
|
||
* </ul>
|
||
* <p>
|
||
* <h2>See Also</h2>
|
||
* <ul>
|
||
* <li>{@link BacktestExecutionResult#getTopStrategies(int, AnalysisCriterion...)}
|
||
* - Method for retrieving top-performing strategies</li>
|
||
* <li>{@link BacktestExecutor} - The underlying executor used for
|
||
* backtesting</li>
|
||
* <li>{@link BarSeries#getMaximumBarCount()} - Maximum bar count hint for
|
||
* indicator caching</li>
|
||
* </ul>
|
||
*/
|
||
public class BacktestPerformanceTuningHarness {
|
||
|
||
// PERFORMANCE NOTE: The current ranges generate ~10,000+ strategies.
|
||
// BacktestExecutor automatically uses batch processing for large strategy
|
||
// counts (>1000)
|
||
// to prevent memory exhaustion. If execution is still too slow, consider:
|
||
// 1. Increasing INCREMENT values to reduce grid density
|
||
// 2. Narrowing MIN/MAX ranges based on preliminary results
|
||
// 3. Using coarser increments for initial exploration, then fine-tuning
|
||
// promising regions
|
||
private static final int RSI_BARCOUNT_INCREMENT = 7;
|
||
private static final int RSI_BARCOUNT_MIN = 7;
|
||
private static final int RSI_BARCOUNT_MAX = 49;
|
||
|
||
private static final int MOMENTUM_TIMEFRAME_INCREMENT = 100;
|
||
private static final int MOMENTUM_TIMEFRAME_MIN = 100;
|
||
private static final int MOMENTUM_TIMEFRAME_MAX = 400;
|
||
|
||
private static final int OVERBOUGHT_THRESHOLD_INCREMENT = 250;
|
||
private static final int OVERBOUGHT_THRESHOLD_MIN = 0;
|
||
private static final int OVERBOUGHT_THRESHOLD_MAX = 1500;
|
||
|
||
private static final int OVERSOLD_THRESHOLD_INCREMENT = 250;
|
||
private static final int OVERSOLD_THRESHOLD_MIN = -2000;
|
||
private static final int OVERSOLD_THRESHOLD_MAX = 0;
|
||
|
||
private static final double DECAY_FACTOR_INCREMENT = 0.02;
|
||
private static final double DECAY_FACTOR_MIN = 0.9;
|
||
private static final double DECAY_FACTOR_MAX = 1;
|
||
|
||
private static final Logger LOG = LogManager.getLogger(BacktestPerformanceTuningHarness.class);
|
||
|
||
static final String DEFAULT_OHLC_RESOURCE_FILE = "Coinbase-ETH-USD-PT1D-20160517_20251028.json";
|
||
|
||
static final int DEFAULT_TOP_K = 20;
|
||
static final int DEFAULT_TUNE_STRATEGY_START = 2_000;
|
||
static final int DEFAULT_TUNE_STRATEGY_STEP = 2_000;
|
||
static final int DEFAULT_TUNE_STRATEGY_MAX = 20_000;
|
||
|
||
static final double DEFAULT_NONLINEAR_GC_OVERHEAD = 0.25d;
|
||
static final double DEFAULT_NONLINEAR_SLOWDOWN_RATIO = 1.25d;
|
||
|
||
static final List<Integer> DEFAULT_MATRIX_STRATEGY_COUNTS = List.of(250, 500, 1_000);
|
||
static final List<Integer> DEFAULT_MATRIX_BAR_COUNTS = List.of(500, 1_000);
|
||
static final List<Integer> DEFAULT_MATRIX_MAX_BAR_COUNT_HINTS = List.of(0);
|
||
static final String MATRIX_PERFORMANCE_FILE = "matrix_performance.json";
|
||
static final String MATRIX_CELLS_FILE = "matrix_cells.json";
|
||
static final String THROUGHPUT_MANIFEST_FILE = "throughput_manifest.json";
|
||
|
||
static final String HARNESS_RESULT_PREFIX = "HARNESS_RESULT: ";
|
||
static final String RECOMMENDED_SETTINGS_PREFIX = "RECOMMENDED_SETTINGS: ";
|
||
static final String THROUGHPUT_RESULT_PREFIX = "THROUGHPUT_RESULT: ";
|
||
|
||
static final Gson GSON = new GsonBuilder().registerTypeAdapter(Duration.class, new DurationTypeAdapter()).create();
|
||
static final Gson PRETTY_GSON = new GsonBuilder().registerTypeAdapter(Duration.class, new DurationTypeAdapter())
|
||
.setPrettyPrinting()
|
||
.create();
|
||
|
||
/**
|
||
* Main entry point for the performance tuning harness.
|
||
* <p>
|
||
* Parses command-line arguments and executes the requested operation:
|
||
* <ul>
|
||
* <li>If {@code --help} is specified, prints usage information and exits</li>
|
||
* <li>If {@code --tuneHeaps} is specified, forks child JVMs with different heap
|
||
* sizes and runs tuning in each</li>
|
||
* <li>If {@code --tune} is specified, runs an in-process tuning cycle to find
|
||
* optimal parameters</li>
|
||
* <li>Otherwise, runs a single backtest with the specified parameters</li>
|
||
* </ul>
|
||
* <p>
|
||
* Example usage:
|
||
*
|
||
* <pre>{@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
|
||
* }</pre>
|
||
*
|
||
* @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<Future<ThroughputCellResult>> futures = new ArrayList<>(plan.cells().size());
|
||
for (ThroughputMatrixCell cell : plan.cells()) {
|
||
futures.add(executor.submit(() -> runThroughputCell(baseSeries, plan, cell)));
|
||
}
|
||
for (Future<ThroughputCellResult> future : futures) {
|
||
try {
|
||
tracker.record(future.get());
|
||
} catch (InterruptedException ex) {
|
||
Thread.currentThread().interrupt();
|
||
throw new IOException("Interrupted while executing throughput matrix cells", ex);
|
||
} catch (ExecutionException ex) {
|
||
Throwable cause = ex.getCause() == null ? ex : ex.getCause();
|
||
if (cause instanceof IOException ioException) {
|
||
throw ioException;
|
||
}
|
||
throw new IOException("Throughput matrix cell failed", cause);
|
||
}
|
||
}
|
||
} finally {
|
||
executor.shutdownNow();
|
||
}
|
||
}
|
||
|
||
private static ThroughputCellResult runThroughputCell(BarSeries baseSeries, ThroughputControlPlan plan,
|
||
ThroughputMatrixCell cell) {
|
||
long startedNanos = System.nanoTime();
|
||
RunOutcome outcome = runOnce(baseSeries, cell.toRunOnceConfig(plan.progress()));
|
||
return new ThroughputCellResult(cell, outcome.runResult(), elapsedMillis(startedNanos));
|
||
}
|
||
|
||
private static void warmupOnce(BarSeries baseSeries) {
|
||
int warmupStrategies = Math.min(250, DEFAULT_TUNE_STRATEGY_START);
|
||
int warmupBars = Math.min(500, baseSeries.getBarCount());
|
||
|
||
RunOnceConfig warmupConfig = new RunOnceConfig(warmupStrategies, warmupBars, 0, ExecutionMode.KEEP_TOP_K, 1,
|
||
false);
|
||
LOG.info("Warm-up run (strategies={}, bars={})", warmupStrategies, warmupBars);
|
||
try {
|
||
runOnce(baseSeries, warmupConfig);
|
||
} catch (Exception ex) {
|
||
LOG.warn("Warm-up failed (continuing): {}", ex.getMessage());
|
||
}
|
||
System.gc();
|
||
Thread.yield();
|
||
}
|
||
|
||
/**
|
||
* Executes a single backtest run with the specified configuration.
|
||
* <p>
|
||
* This method:
|
||
* <ol>
|
||
* <li>Slices the base series to the requested bar count</li>
|
||
* <li>Applies the maximum bar count hint if specified</li>
|
||
* <li>Creates the requested number of strategies</li>
|
||
* <li>Executes the backtest with progress monitoring</li>
|
||
* <li>Captures performance metrics (GC, heap, runtime statistics)</li>
|
||
* <li>Logs results in JSON format with the {@code HARNESS_RESULT:} prefix</li>
|
||
* </ol>
|
||
*
|
||
* @param baseSeries The base bar series to use
|
||
* @param config Configuration for this run (strategy count, bar count,
|
||
* execution mode, etc.)
|
||
* @return A {@link RunOutcome} containing both the execution result and
|
||
* performance metrics
|
||
* @throws NullPointerException If baseSeries or config is null
|
||
*/
|
||
private static RunOutcome runOnce(BarSeries baseSeries, RunOnceConfig config) {
|
||
Objects.requireNonNull(baseSeries, "baseSeries must not be null");
|
||
Objects.requireNonNull(config, "config must not be null");
|
||
|
||
BarSeries series = sliceToLastBars(baseSeries, config.barCount());
|
||
series = applyMaximumBarCountHint(series, config.maximumBarCountHint());
|
||
|
||
long strategiesStart = System.nanoTime();
|
||
List<Strategy> strategies = createStrategies(series, config.strategyCount());
|
||
Duration strategiesBuildDuration = Duration.ofNanos(System.nanoTime() - strategiesStart);
|
||
|
||
int barCount = series.getEndIndex() - series.getBeginIndex() + 1;
|
||
long workUnits = (long) strategies.size() * (long) barCount;
|
||
|
||
LOG.info("Backtesting {} strategies (mode={}) on {} bars (maxBarCountHint={}, heapMax={})", strategies.size(),
|
||
config.executionMode(), barCount, series.getMaximumBarCount(),
|
||
formatBytes(Runtime.getRuntime().maxMemory()));
|
||
|
||
GcSnapshot gcBefore = GcSnapshot.capture();
|
||
HeapSnapshot heapBefore = HeapSnapshot.capture();
|
||
|
||
Consumer<Integer> progressCallback = config.progress()
|
||
? ProgressCompletion.loggingWithMemory(BacktestPerformanceTuningHarness.class)
|
||
: null;
|
||
BacktestExecutionResult result = executeBacktest(series, strategies, config.executionMode(), config.topK(),
|
||
progressCallback);
|
||
|
||
HeapSnapshot heapAfter = HeapSnapshot.capture();
|
||
GcSnapshot gcAfter = GcSnapshot.capture();
|
||
|
||
GcSnapshot gcDelta = gcAfter.delta(gcBefore);
|
||
|
||
BacktestRuntimeStats runtimeStats = BacktestRuntimeStats.from(result.runtimeReport());
|
||
|
||
RunResult runResult = new RunResult(config.executionMode(), strategies.size(), barCount,
|
||
config.maximumBarCountHint(), series.getMaximumBarCount(), config.barCount(), strategiesBuildDuration,
|
||
runtimeStats, workUnits, gcDelta, heapBefore, heapAfter,
|
||
series.numFactory().getClass().getSimpleName());
|
||
|
||
LOG.info("Backtest complete. runtimeReport={}", runtimeStats.runtimeReportJson());
|
||
LOG.info(HARNESS_RESULT_PREFIX + "{}", runResult.toJson());
|
||
|
||
return new RunOutcome(result, runResult);
|
||
}
|
||
|
||
private static BacktestExecutionResult executeBacktest(BarSeries series, List<Strategy> strategies,
|
||
ExecutionMode mode, int topK, Consumer<Integer> progressCallback) {
|
||
BacktestExecutor executor = new BacktestExecutor(series);
|
||
Num amount = series.numFactory().numOf(1);
|
||
|
||
if (mode == ExecutionMode.KEEP_TOP_K) {
|
||
int effectiveTopK = Math.max(1, topK);
|
||
AnalysisCriterion criterion = new NetProfitCriterion();
|
||
return executor.executeAndKeepTopK(strategies, amount, Trade.TradeType.BUY, criterion, effectiveTopK,
|
||
progressCallback);
|
||
}
|
||
|
||
return executor.executeWithRuntimeReport(strategies, amount, Trade.TradeType.BUY, progressCallback);
|
||
}
|
||
|
||
private static void runTuneInProcess(BarSeries baseSeries, HarnessCli cli) {
|
||
Thresholds thresholds = new Thresholds(cli.nonlinearGcOverheadThreshold, cli.nonlinearSlowdownRatioThreshold);
|
||
TunePlan plan = TunePlan.fromCli(cli, baseSeries.getBarCount());
|
||
|
||
LOG.info("Tuning plan: {}", plan.describe());
|
||
LOG.info("Non-linear thresholds: {}", thresholds.describe());
|
||
|
||
List<VariantTuningResult> variantResults = new ArrayList<>(plan.variants().size());
|
||
for (SeriesVariant variant : plan.variants()) {
|
||
BarSeries series = variant.apply(baseSeries);
|
||
series = applyMaximumBarCountHint(series, variant.maximumBarCountHint());
|
||
|
||
LOG.info("=== Series variant: {} ===", variant.describe(series));
|
||
|
||
RunResult lastLinear = null;
|
||
RunResult previous = null;
|
||
RunResult firstNonLinear = null;
|
||
for (int strategyCount : plan.strategyCounts()) {
|
||
RunOnceConfig runConfig = new RunOnceConfig(strategyCount, variant.barCount(),
|
||
variant.maximumBarCountHint(), plan.executionMode(), plan.topK(), plan.progress());
|
||
RunOutcome outcome = runOnce(series, runConfig);
|
||
RunResult current = outcome.runResult();
|
||
|
||
if (previous != null && isNonLinear(previous, current, thresholds)) {
|
||
firstNonLinear = current;
|
||
LOG.info("Non-linear behavior detected at strategies={} (previousLinearStrategies={})",
|
||
current.strategyCount(), lastLinear != null ? lastLinear.strategyCount() : null);
|
||
break;
|
||
}
|
||
|
||
lastLinear = current;
|
||
previous = current;
|
||
if (plan.gcBetweenRuns()) {
|
||
System.gc();
|
||
Thread.yield();
|
||
}
|
||
}
|
||
|
||
if (lastLinear == null) {
|
||
LOG.info("No linear runs recorded for {}", variant.describe(series));
|
||
} else {
|
||
LOG.info("Sweet spot (last linear run): {}", lastLinear.describeSweetSpot());
|
||
}
|
||
|
||
variantResults.add(new VariantTuningResult(variant, lastLinear, firstNonLinear));
|
||
}
|
||
|
||
logRecommendedSettings(cli, plan, thresholds, variantResults);
|
||
}
|
||
|
||
private static void logRecommendedSettings(HarnessCli cli, TunePlan plan, Thresholds thresholds,
|
||
List<VariantTuningResult> results) {
|
||
long heapMax = Runtime.getRuntime().maxMemory();
|
||
LOG.info("=== Recommended settings (heapMax={}, dataset={}) ===", formatBytes(heapMax), cli.ohlcResourceFile);
|
||
LOG.info("Non-linear definition: {}", thresholds.describe());
|
||
|
||
RunResult best = selectBestRecommendation(results);
|
||
if (best == null) {
|
||
LOG.info(RECOMMENDED_SETTINGS_PREFIX + "No recommendation available (no successful linear runs).");
|
||
return;
|
||
}
|
||
|
||
LOG.info(RECOMMENDED_SETTINGS_PREFIX + "BEST {}", best.describeSweetSpot());
|
||
LOG.info(RECOMMENDED_SETTINGS_PREFIX + "BEST CLI {}", buildRunOnceArgs(cli, plan, best));
|
||
|
||
for (VariantTuningResult result : results) {
|
||
if (result.lastLinear() == null) {
|
||
continue;
|
||
}
|
||
String label = result.variant().describeLabel();
|
||
String transition = result.firstNonLinear() == null ? "no non-linear detected up to max tested"
|
||
: "non-linear at strategies=" + result.firstNonLinear().strategyCount();
|
||
|
||
LOG.info(RECOMMENDED_SETTINGS_PREFIX + "{} strategies<={} ({}) | {}", label,
|
||
result.lastLinear().strategyCount(), transition, buildRunOnceArgs(cli, plan, result.lastLinear()));
|
||
}
|
||
|
||
LOG.info(RECOMMENDED_SETTINGS_PREFIX
|
||
+ "If you hit 'no non-linear detected', increase --tuneStrategyMax to probe further.");
|
||
}
|
||
|
||
/**
|
||
* Selects the best recommendation from a list of variant tuning results.
|
||
* <p>
|
||
* The best recommendation is determined by:
|
||
* <ol>
|
||
* <li>Highest work units (strategies × bars) - indicates most work done
|
||
* efficiently</li>
|
||
* <li>Highest strategy count (tie-breaker)</li>
|
||
* <li>Highest bar count (tie-breaker)</li>
|
||
* <li>Highest effective maximum bar count hint (tie-breaker)</li>
|
||
* </ol>
|
||
* <p>
|
||
* 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<VariantTuningResult> results) {
|
||
if (results == null || results.isEmpty()) {
|
||
return null;
|
||
}
|
||
return results.stream()
|
||
.map(VariantTuningResult::lastLinear)
|
||
.filter(Objects::nonNull)
|
||
.max(Comparator.comparingLong(RunResult::workUnits)
|
||
.thenComparingInt(RunResult::strategyCount)
|
||
.thenComparingInt(RunResult::barCount)
|
||
.thenComparingInt(RunResult::maximumBarCountHintEffective))
|
||
.orElse(null);
|
||
}
|
||
|
||
private static String buildRunOnceArgs(HarnessCli cli, TunePlan plan, RunResult recommendation) {
|
||
StringJoiner args = new StringJoiner(" ");
|
||
args.add("--dataset");
|
||
args.add(cli.ohlcResourceFile);
|
||
args.add("--strategies");
|
||
args.add(Integer.toString(recommendation.strategyCount()));
|
||
|
||
if (recommendation.barCountRequested() > 0) {
|
||
args.add("--barCount");
|
||
args.add(Integer.toString(recommendation.barCountRequested()));
|
||
}
|
||
if (recommendation.maximumBarCountHintRequested() > 0) {
|
||
args.add("--maxBarCountHint");
|
||
args.add(Integer.toString(recommendation.maximumBarCountHintRequested()));
|
||
}
|
||
|
||
args.add("--executionMode");
|
||
args.add(plan.executionMode() == ExecutionMode.KEEP_TOP_K ? "topK" : "full");
|
||
|
||
if (plan.executionMode() == ExecutionMode.KEEP_TOP_K) {
|
||
args.add("--topK");
|
||
args.add(Integer.toString(plan.topK()));
|
||
}
|
||
|
||
return args.toString();
|
||
}
|
||
|
||
private static void runTuneAcrossHeaps(HarnessCli cli) throws Exception {
|
||
List<String> childArgs = cli.toChildTuneArgs();
|
||
String javaExecutable = javaExecutablePath();
|
||
String classpath = System.getProperty("java.class.path");
|
||
|
||
for (String heap : cli.tuneHeaps) {
|
||
LOG.info("=== Forking tune run: heap={} ===", heap);
|
||
List<String> command = new ArrayList<>();
|
||
command.add(javaExecutable);
|
||
command.add("-Xms" + heap);
|
||
command.add("-Xmx" + heap);
|
||
command.add("-cp");
|
||
command.add(classpath);
|
||
command.add(BacktestPerformanceTuningHarness.class.getName());
|
||
command.addAll(childArgs);
|
||
|
||
ProcessBuilder builder = new ProcessBuilder(command);
|
||
builder.redirectErrorStream(true);
|
||
Process process = builder.start();
|
||
|
||
try (BufferedReader reader = new BufferedReader(
|
||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||
String line;
|
||
while ((line = reader.readLine()) != null) {
|
||
LOG.info(line);
|
||
}
|
||
}
|
||
|
||
int exitCode = process.waitFor();
|
||
if (exitCode != 0) {
|
||
throw new IllegalStateException("Child JVM exited with code=" + exitCode + " for heap=" + heap);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static String javaExecutablePath() {
|
||
String javaHome = System.getProperty("java.home");
|
||
String executable = isWindows() ? "java.exe" : "java";
|
||
return Path.of(javaHome, "bin", executable).toString();
|
||
}
|
||
|
||
private static boolean isWindows() {
|
||
String osName = System.getProperty("os.name");
|
||
return osName != null && osName.toLowerCase(Locale.ROOT).contains("win");
|
||
}
|
||
|
||
private static BarSeries loadSeries(String jsonOhlcResourceFile) {
|
||
try (InputStream resourceStream = BacktestPerformanceTuningHarness.class.getClassLoader()
|
||
.getResourceAsStream(jsonOhlcResourceFile)) {
|
||
if (resourceStream == null) {
|
||
LOG.error("Resource not found: {}", jsonOhlcResourceFile);
|
||
return null;
|
||
}
|
||
return JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resourceStream);
|
||
} catch (IOException ex) {
|
||
LOG.error("IOException while loading resource: {} - {}", jsonOhlcResourceFile, ex.getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determines if performance has degraded non-linearly between two runs.
|
||
* <p>
|
||
* Non-linear behavior is detected when either:
|
||
* <ul>
|
||
* <li>GC overhead exceeds the threshold (default: 25% of total runtime)</li>
|
||
* <li>Normalized slowdown ratio exceeds the threshold (default: 1.25x)</li>
|
||
* </ul>
|
||
* <p>
|
||
* The normalized slowdown ratio is calculated as:
|
||
*
|
||
* <pre>{@code
|
||
* (runtimeRatio / workRatio)
|
||
* }</pre>
|
||
*
|
||
* 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.
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* 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()}.
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* Generates strategies by systematically varying:
|
||
* <ul>
|
||
* <li>RSI bar count: 7 to 49 (increment: 7)</li>
|
||
* <li>Momentum timeframe: 100 to 400 (increment: 100)</li>
|
||
* <li>Oversold threshold: -2000 to 0 (increment: 250)</li>
|
||
* <li>Overbought threshold: 0 to 1500 (increment: 250)</li>
|
||
* <li>Decay factor: 0.9 to 1.0 (increment: 0.02)</li>
|
||
* </ul>
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* Strategies use:
|
||
* <ul>
|
||
* <li>Entry rule: CrossedUpIndicatorRule when NetMomentumIndicator crosses
|
||
* above oversold threshold</li>
|
||
* <li>Exit rule: CrossedDownIndicatorRule when NetMomentumIndicator crosses
|
||
* below overbought threshold</li>
|
||
* </ul>
|
||
* <p>
|
||
* 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<Strategy> createStrategies(BarSeries series, int requestedStrategyCount) {
|
||
Objects.requireNonNull(series, "series cannot be null");
|
||
|
||
int effectiveTarget;
|
||
if (requestedStrategyCount < 0) {
|
||
effectiveTarget = Integer.MAX_VALUE;
|
||
} else if (requestedStrategyCount == 0) {
|
||
throw new IllegalArgumentException("requestedStrategyCount must not be zero");
|
||
} else {
|
||
effectiveTarget = requestedStrategyCount;
|
||
}
|
||
|
||
List<Strategy> strategies = new ArrayList<>(requestedStrategyCount > 0 ? requestedStrategyCount : 10_416);
|
||
ClosePriceIndicator closePriceIndicator = new ClosePriceIndicator(series);
|
||
Map<Integer, RSIIndicator> rsiIndicators = new LinkedHashMap<>();
|
||
Map<String, NetMomentumIndicator> netMomentumIndicators = new LinkedHashMap<>();
|
||
int created = 0;
|
||
|
||
int repetition = 0;
|
||
while (created < effectiveTarget) {
|
||
boolean fullGrid = requestedStrategyCount < 0;
|
||
boolean addedAny = false;
|
||
|
||
for (int rsiBarCount = RSI_BARCOUNT_MIN; rsiBarCount <= RSI_BARCOUNT_MAX; rsiBarCount += RSI_BARCOUNT_INCREMENT) {
|
||
for (int timeFrame = MOMENTUM_TIMEFRAME_MIN; timeFrame <= MOMENTUM_TIMEFRAME_MAX; timeFrame += MOMENTUM_TIMEFRAME_INCREMENT) {
|
||
for (int oversoldThreshold = OVERSOLD_THRESHOLD_MIN; oversoldThreshold <= OVERSOLD_THRESHOLD_MAX; oversoldThreshold += OVERSOLD_THRESHOLD_INCREMENT) {
|
||
for (int overboughtThreshold = OVERBOUGHT_THRESHOLD_MIN; overboughtThreshold <= OVERBOUGHT_THRESHOLD_MAX; overboughtThreshold += OVERBOUGHT_THRESHOLD_INCREMENT) {
|
||
if (oversoldThreshold >= overboughtThreshold) {
|
||
continue;
|
||
}
|
||
for (double decayFactor = DECAY_FACTOR_MIN; decayFactor <= DECAY_FACTOR_MAX; decayFactor += DECAY_FACTOR_INCREMENT) {
|
||
try {
|
||
int currentRsiBarCount = rsiBarCount;
|
||
int currentTimeFrame = timeFrame;
|
||
double currentDecayFactor = decayFactor;
|
||
NetMomentumIndicator netMomentumIndicator = netMomentumIndicators
|
||
.computeIfAbsent(
|
||
netMomentumKey(currentRsiBarCount, currentTimeFrame,
|
||
currentDecayFactor),
|
||
ignored -> NetMomentumIndicator
|
||
.forRsiWithDecay(
|
||
rsiIndicators.computeIfAbsent(currentRsiBarCount,
|
||
key -> new RSIIndicator(closePriceIndicator,
|
||
key)),
|
||
currentTimeFrame, currentDecayFactor));
|
||
Strategy strategy = createStrategy(netMomentumIndicator, currentRsiBarCount,
|
||
currentTimeFrame, oversoldThreshold, overboughtThreshold,
|
||
currentDecayFactor, repetition);
|
||
strategies.add(strategy);
|
||
created++;
|
||
addedAny = true;
|
||
if (created >= effectiveTarget) {
|
||
return strategies;
|
||
}
|
||
} catch (Exception e) {
|
||
LOG.debug(
|
||
"Skipping invalid strategy combination: rsiBarCount={}, timeFrame={}, oversoldThreshold={}, overboughtThreshold={}, decayFactor={}: {}",
|
||
rsiBarCount, timeFrame, oversoldThreshold, overboughtThreshold, decayFactor,
|
||
e.getMessage());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (fullGrid) {
|
||
break;
|
||
}
|
||
if (!addedAny) {
|
||
break;
|
||
}
|
||
repetition++;
|
||
}
|
||
|
||
return strategies;
|
||
}
|
||
|
||
private static String netMomentumKey(int rsiBarCount, int timeFrame, double decayFactor) {
|
||
return rsiBarCount + "|" + timeFrame + "|" + String.format(Locale.ROOT, "%.8f", decayFactor);
|
||
}
|
||
|
||
/**
|
||
* Creates a single strategy using NetMomentumIndicator with the specified
|
||
* parameters.
|
||
* <p>
|
||
* The strategy uses:
|
||
* <ul>
|
||
* <li>RSI indicator with the specified bar count</li>
|
||
* <li>NetMomentumIndicator wrapping the RSI with the specified timeframe and
|
||
* decay factor</li>
|
||
* <li>Entry rule: Buy when NetMomentumIndicator crosses above the oversold
|
||
* threshold</li>
|
||
* <li>Exit rule: Sell when NetMomentumIndicator crosses below the overbought
|
||
* threshold</li>
|
||
* </ul>
|
||
* <p>
|
||
* 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<TradingStatement> topStrategies = result.getTopStrategies(topK, netProfitCriterion, expectancyCriterion);
|
||
LOG.debug("=== Top {} Strategies ===", topStrategies.size());
|
||
|
||
for (int i = 0; i < topStrategies.size(); i++) {
|
||
TradingStatement statement = topStrategies.get(i);
|
||
Strategy strategy = statement.getStrategy();
|
||
|
||
Num netProfit = statement.getCriterionScore(netProfitCriterion)
|
||
.orElseGet(() -> netProfitCriterion.calculate(result.barSeries(), statement.getTradingRecord()));
|
||
Num expectancy = statement.getCriterionScore(expectancyCriterion)
|
||
.orElseGet(() -> expectancyCriterion.calculate(result.barSeries(), statement.getTradingRecord()));
|
||
|
||
LOG.debug("{}. {}", (i + 1), strategy.getName());
|
||
LOG.debug(" Net Profit: {}", netProfit);
|
||
LOG.debug(" Expectancy: {}", expectancy);
|
||
LOG.debug(" Positions: {}", statement.getTradingRecord().getPositionCount());
|
||
}
|
||
}
|
||
|
||
private static void logUsage() {
|
||
StringJoiner usage = new StringJoiner(System.lineSeparator());
|
||
usage.add("BacktestPerformanceTuningHarness - performance harness");
|
||
usage.add("");
|
||
usage.add("Run once (default):");
|
||
usage.add(" --strategies <N> (default: full grid ~10,416)");
|
||
usage.add(" --barCount <N> (default: full series)");
|
||
usage.add(" --maxBarCountHint <N> (0 disables; default: 0)");
|
||
usage.add(" --executionMode full|topK (default: full)");
|
||
usage.add(" --topK <N> (default: 20)");
|
||
usage.add(" --progress (enable progress+memory logging)");
|
||
usage.add("");
|
||
usage.add("Throughput control matrix:");
|
||
usage.add(" --throughputControl");
|
||
usage.add(" --throughputOutputDir <dir>");
|
||
usage.add(" --matrixStrategyCounts <csv> (default: 250,500,1000)");
|
||
usage.add(" --matrixBarCounts <csv> (default: 500,1000; accepts full)");
|
||
usage.add(" --matrixMaxBarCountHints <csv> (default: 0)");
|
||
usage.add(" --parallelism <auto|N> (default: 1)");
|
||
usage.add("");
|
||
usage.add("Tune in current JVM:");
|
||
usage.add(" --tune");
|
||
usage.add(" --tuneStrategyStart <N> (default: " + DEFAULT_TUNE_STRATEGY_START + ")");
|
||
usage.add(" --tuneStrategyStep <N> (default: " + DEFAULT_TUNE_STRATEGY_STEP + ")");
|
||
usage.add(" --tuneStrategyMax <N> (default: " + DEFAULT_TUNE_STRATEGY_MAX + ")");
|
||
usage.add(" --tuneBarCounts <csv> (default: 500,1000,2000,full)");
|
||
usage.add(" --tuneMaxBarCountHints <csv> (default: 0,512,1024,2048)");
|
||
usage.add(" --nonlinearGcOverhead <0..1> (default: " + DEFAULT_NONLINEAR_GC_OVERHEAD + ")");
|
||
usage.add(" --nonlinearSlowdownRatio <x> (default: " + DEFAULT_NONLINEAR_SLOWDOWN_RATIO + ")");
|
||
usage.add(" --gcBetweenRuns (default: true)");
|
||
usage.add("");
|
||
usage.add("Tune across heaps (fork child JVM per heap):");
|
||
usage.add(" --tuneHeaps <csv> (e.g. 4g,8g,16g)");
|
||
LOG.info(System.lineSeparator() + usage);
|
||
}
|
||
|
||
/**
|
||
* Formats a byte count as a human-readable string with appropriate units.
|
||
* <p>
|
||
* Formats bytes using binary units (KiB, MiB, GiB, TiB) with 2 decimal places.
|
||
* Examples:
|
||
* <ul>
|
||
* <li>1023 bytes → "1023 B"</li>
|
||
* <li>1024 bytes → "1.00 KiB"</li>
|
||
* <li>1048576 bytes → "1.00 MiB"</li>
|
||
* </ul>
|
||
*
|
||
* @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.
|
||
* <ul>
|
||
* <li>{@link #FULL_RESULT}: Execute all strategies and return full results for
|
||
* all strategies</li>
|
||
* <li>{@link #KEEP_TOP_K}: Execute all strategies but only keep results for the
|
||
* top K performers (more memory-efficient for large strategy counts)</li>
|
||
* </ul>
|
||
*/
|
||
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<Integer> strategyCounts, List<SeriesVariant> variants, ExecutionMode executionMode, int topK,
|
||
boolean progress, boolean gcBetweenRuns) {
|
||
|
||
static TunePlan fromCli(HarnessCli cli, int fullBarCount) {
|
||
List<Integer> strategyCounts = cli.buildTuneStrategyCounts();
|
||
List<SeriesVariant> variants = cli.buildSeriesVariants(fullBarCount);
|
||
return new TunePlan(strategyCounts, variants, cli.executionMode, cli.topK, cli.progress, cli.gcBetweenRuns);
|
||
}
|
||
|
||
String describe() {
|
||
return String.format(Locale.ROOT, "{strategyCounts=%s, variants=%d, executionMode=%s, topK=%d}", strategyCounts,
|
||
variants.size(), executionMode, topK);
|
||
}
|
||
}
|
||
|
||
record SeriesVariant(int barCount, int maximumBarCountHint) {
|
||
|
||
BarSeries apply(BarSeries baseSeries) {
|
||
return BacktestPerformanceTuningHarness.sliceToLastBars(baseSeries, barCount);
|
||
}
|
||
|
||
String describeLabel() {
|
||
return String.format(Locale.ROOT, "barCount=%s, maxBarCountHint=%s",
|
||
barCount <= 0 ? "full" : Integer.toString(barCount),
|
||
maximumBarCountHint <= 0 ? "default" : Integer.toString(maximumBarCountHint));
|
||
}
|
||
|
||
String describe(BarSeries series) {
|
||
return String.format(Locale.ROOT, "{barCount=%s, maxBarCountHint=%s, effectiveBars=%d}",
|
||
barCount <= 0 ? "full" : Integer.toString(barCount),
|
||
maximumBarCountHint <= 0 ? "default" : Integer.toString(maximumBarCountHint),
|
||
series.getEndIndex() - series.getBeginIndex() + 1);
|
||
}
|
||
}
|
||
|
||
record VariantTuningResult(SeriesVariant variant, RunResult lastLinear, RunResult firstNonLinear) {
|
||
}
|
||
|
||
record BacktestRuntimeStats(Duration overallRuntime, Duration minStrategyRuntime, Duration maxStrategyRuntime,
|
||
Duration averageStrategyRuntime, Duration medianStrategyRuntime, String runtimeReportJson) {
|
||
|
||
static BacktestRuntimeStats from(org.ta4j.core.backtest.BacktestRuntimeReport report) {
|
||
return new BacktestRuntimeStats(report.overallRuntime(), report.minStrategyRuntime(),
|
||
report.maxStrategyRuntime(), report.averageStrategyRuntime(), report.medianStrategyRuntime(),
|
||
report.toString());
|
||
}
|
||
}
|
||
|
||
record RunOutcome(BacktestExecutionResult result, RunResult runResult) {
|
||
}
|
||
|
||
/**
|
||
* Host metadata captured with a stable hashed host identifier so benchmark
|
||
* artifacts can be shared without exposing local machine names.
|
||
*/
|
||
record HostTelemetry(String hostId, String osName, String osArch, String osVersion, int logicalProcessors,
|
||
long maxMemoryBytes, String javaVersion, String javaVmName) {
|
||
|
||
static HostTelemetry capture() {
|
||
return new HostTelemetry(detectHostId(), System.getProperty("os.name", "unknown"),
|
||
System.getProperty("os.arch", "unknown"), System.getProperty("os.version", "unknown"),
|
||
Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().maxMemory(),
|
||
System.getProperty("java.version", "unknown"), System.getProperty("java.vm.name", "unknown"));
|
||
}
|
||
|
||
private static String detectHostId() {
|
||
String hostname = detectHostname();
|
||
return "unknown".equals(hostname) ? hostname
|
||
: "sha256:" + BacktestPerformanceTuningHarness.shortSha256(hostname);
|
||
}
|
||
|
||
private static String detectHostname() {
|
||
try {
|
||
return InetAddress.getLocalHost().getHostName();
|
||
} catch (UnknownHostException ex) {
|
||
return "unknown";
|
||
}
|
||
}
|
||
}
|
||
|
||
record ThroughputMatrixCell(String cellId, int strategyCount, int barCount, int maximumBarCountHint,
|
||
ExecutionMode executionMode, int topK) {
|
||
|
||
RunOnceConfig toRunOnceConfig(boolean progress) {
|
||
return new RunOnceConfig(strategyCount, barCount, maximumBarCountHint, executionMode, topK, progress);
|
||
}
|
||
|
||
JsonObject toJson() {
|
||
return BacktestPerformanceTuningHarness.GSON.toJsonTree(this).getAsJsonObject();
|
||
}
|
||
}
|
||
|
||
record ThroughputCellResult(ThroughputMatrixCell cell, RunResult runResult, long wallTimeMs) {
|
||
|
||
JsonObject toJson() {
|
||
JsonObject object = new JsonObject();
|
||
object.add("cell", cell.toJson());
|
||
object.add("runResult", BacktestPerformanceTuningHarness.GSON.toJsonTree(runResult));
|
||
object.addProperty("wallTimeMs", wallTimeMs);
|
||
object.addProperty("strategyBuildWallTimeMs", runResult.strategyBuildDuration().toMillis());
|
||
object.addProperty("backtestRuntimeMs", runResult.runtimeStats().overallRuntime().toMillis());
|
||
return object;
|
||
}
|
||
}
|
||
|
||
record ThroughputControlPlan(String dataset, Path outputDir, List<ThroughputMatrixCell> cells, String parallelism,
|
||
int resolvedParallelism, ExecutionMode executionMode, int topK, boolean progress, boolean gcBetweenRuns,
|
||
String specFingerprint) {
|
||
|
||
static ThroughputControlPlan fromCli(HarnessCli cli, int fullBarCount) {
|
||
List<ThroughputMatrixCell> cells = cli.buildThroughputCells(fullBarCount);
|
||
int resolvedParallelism = resolveParallelism(cli.parallelism, cells.size());
|
||
StringJoiner fingerprintSource = new StringJoiner("|");
|
||
fingerprintSource.add(cli.ohlcResourceFile)
|
||
.add(cli.executionMode.name())
|
||
.add(Integer.toString(cli.topK))
|
||
.add(cli.parallelism)
|
||
.add(Integer.toString(resolvedParallelism))
|
||
.add(Boolean.toString(cli.progress))
|
||
.add(Boolean.toString(cli.gcBetweenRuns));
|
||
cells.forEach(cell -> fingerprintSource.add(cell.toString()));
|
||
Path outputDir = cli.throughputOutputDir == null ? Path.of(".agents", "benchmarks", "backtest-throughput",
|
||
"matrix-" + Instant.now().toString().replace(':', '-')) : cli.throughputOutputDir;
|
||
return new ThroughputControlPlan(cli.ohlcResourceFile, outputDir.toAbsolutePath().normalize(), cells,
|
||
cli.parallelism, resolvedParallelism, cli.executionMode, cli.topK, cli.progress, cli.gcBetweenRuns,
|
||
BacktestPerformanceTuningHarness.shortSha256(fingerprintSource.toString()));
|
||
}
|
||
|
||
JsonObject toManifest(HostTelemetry host) {
|
||
JsonObject object = new JsonObject();
|
||
object.addProperty("schemaVersion", 1);
|
||
object.addProperty("createdAt", Instant.now().toString());
|
||
object.addProperty("dataset", dataset);
|
||
object.addProperty("specFingerprint", specFingerprint);
|
||
object.addProperty("parallelism", parallelism);
|
||
object.addProperty("resolvedParallelism", resolvedParallelism);
|
||
object.addProperty("executionMode", executionMode.name());
|
||
object.addProperty("topK", topK);
|
||
object.addProperty("progress", progress);
|
||
object.addProperty("gcBetweenRuns", gcBetweenRuns);
|
||
object.add("host", BacktestPerformanceTuningHarness.GSON.toJsonTree(host));
|
||
JsonArray cellArray = new JsonArray();
|
||
cells.forEach(cell -> cellArray.add(cell.toJson()));
|
||
object.add("cells", cellArray);
|
||
return object;
|
||
}
|
||
|
||
private static int resolveParallelism(String rawParallelism, int cellCount) {
|
||
int cells = Math.max(1, cellCount);
|
||
String raw = rawParallelism == null || rawParallelism.isBlank() ? "1" : rawParallelism.trim();
|
||
if ("auto".equalsIgnoreCase(raw)) {
|
||
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||
int withHeadroom = Math.max(1, (int) Math.ceil(processors * 0.50d));
|
||
return Math.min(cells, withHeadroom);
|
||
}
|
||
int parsed = Integer.parseInt(raw);
|
||
if (parsed <= 0) {
|
||
throw new IllegalArgumentException("--parallelism must be positive or auto");
|
||
}
|
||
return Math.min(cells, parsed);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Aggregates additive throughput telemetry for fixed backtest matrix runs.
|
||
*/
|
||
final class ThroughputMatrixPerformanceTracker {
|
||
|
||
private final List<ThroughputCellResult> cells = new ArrayList<>();
|
||
|
||
synchronized void record(ThroughputCellResult cell) {
|
||
cells.add(Objects.requireNonNull(cell, "cell"));
|
||
}
|
||
|
||
synchronized JsonObject cellsJson() {
|
||
JsonObject root = new JsonObject();
|
||
JsonArray cellArray = new JsonArray();
|
||
for (ThroughputCellResult cell : cells) {
|
||
cellArray.add(cell.toJson());
|
||
}
|
||
root.add("cells", cellArray);
|
||
return root;
|
||
}
|
||
|
||
synchronized JsonObject toJson(long totalWallTimeMs, ThroughputControlPlan plan, HostTelemetry host) {
|
||
int cellCount = cells.size();
|
||
int hypothesisCount = 0;
|
||
long sumCellWallTimeMs = 0L;
|
||
long strategyBuildWallTimeMs = 0L;
|
||
long backtestRuntimeMs = 0L;
|
||
JsonArray cellArray = new JsonArray();
|
||
for (ThroughputCellResult cell : cells) {
|
||
RunResult runResult = cell.runResult();
|
||
hypothesisCount += runResult.strategyCount();
|
||
sumCellWallTimeMs += cell.wallTimeMs();
|
||
strategyBuildWallTimeMs += runResult.strategyBuildDuration().toMillis();
|
||
backtestRuntimeMs += runResult.runtimeStats().overallRuntime().toMillis();
|
||
cellArray.add(cell.toJson());
|
||
}
|
||
|
||
JsonObject root = new JsonObject();
|
||
root.addProperty("schemaVersion", 1);
|
||
root.addProperty("completedAt", Instant.now().toString());
|
||
root.addProperty("dataset", plan.dataset());
|
||
root.addProperty("specFingerprint", plan.specFingerprint());
|
||
root.addProperty("parallelism", plan.parallelism());
|
||
root.addProperty("resolvedParallelism", plan.resolvedParallelism());
|
||
root.addProperty("executionMode", plan.executionMode().name());
|
||
root.addProperty("topK", plan.topK());
|
||
root.addProperty("progress", plan.progress());
|
||
root.addProperty("gcBetweenRuns", plan.gcBetweenRuns());
|
||
root.addProperty("totalWallTimeMs", totalWallTimeMs);
|
||
root.addProperty("sumCellWallTimeMs", sumCellWallTimeMs);
|
||
root.addProperty("strategyBuildWallTimeMs", strategyBuildWallTimeMs);
|
||
root.addProperty("backtestRuntimeMs", backtestRuntimeMs);
|
||
root.addProperty("cellCount", cellCount);
|
||
root.addProperty("hypothesisKind", "strategy");
|
||
root.addProperty("hypothesisCount", hypothesisCount);
|
||
root.addProperty("cellsPerMinute", perMinute(cellCount, totalWallTimeMs));
|
||
root.addProperty("hypothesesPerMinute", perMinute(hypothesisCount, totalWallTimeMs));
|
||
root.add("host", BacktestPerformanceTuningHarness.GSON.toJsonTree(host));
|
||
JsonObject phases = new JsonObject();
|
||
JsonObject matrix = new JsonObject();
|
||
matrix.addProperty("cellCount", cellCount);
|
||
matrix.addProperty("hypothesisCount", hypothesisCount);
|
||
matrix.addProperty("sumCellWallTimeMs", sumCellWallTimeMs);
|
||
matrix.add("cells", cellArray);
|
||
phases.add("backtest", matrix);
|
||
root.add("phases", phases);
|
||
return root;
|
||
}
|
||
|
||
private static double perMinute(long count, long wallTimeMs) {
|
||
return wallTimeMs <= 0L ? count * 60_000.0d : count * 60_000.0d / wallTimeMs;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Results from a single backtest run, including performance metrics.
|
||
*
|
||
* @param executionMode The execution mode used
|
||
* @param strategyCount Number of strategies tested
|
||
* @param barCount Actual number of bars used
|
||
* @param maximumBarCountHintRequested The maximum bar count hint that was
|
||
* requested
|
||
* @param maximumBarCountHintEffective The effective maximum bar count hint
|
||
* applied
|
||
* @param barCountRequested The bar count that was requested (0 =
|
||
* full series)
|
||
* @param strategyBuildDuration Time taken to build all strategies
|
||
* @param runtimeStats Runtime statistics from the backtest
|
||
* execution
|
||
* @param workUnits Total work units (strategies × bars)
|
||
* @param gcDelta GC statistics delta (after - before)
|
||
* @param heapBefore Heap snapshot before execution
|
||
* @param heapAfter Heap snapshot after execution
|
||
* @param numFactory The NumFactory class name used
|
||
*/
|
||
record RunResult(ExecutionMode executionMode, int strategyCount, int barCount, int maximumBarCountHintRequested,
|
||
int maximumBarCountHintEffective, int barCountRequested, Duration strategyBuildDuration,
|
||
BacktestRuntimeStats runtimeStats, long workUnits, GcSnapshot gcDelta, HeapSnapshot heapBefore,
|
||
HeapSnapshot heapAfter, String numFactory) {
|
||
|
||
String toJson() {
|
||
return BacktestPerformanceTuningHarness.GSON.toJson(this);
|
||
}
|
||
|
||
double gcOverhead() {
|
||
if (runtimeStats.overallRuntime().isZero()) {
|
||
return 0d;
|
||
}
|
||
return gcDelta.collectionTime().toNanos() / (double) runtimeStats.overallRuntime().toNanos();
|
||
}
|
||
|
||
String describeSweetSpot() {
|
||
return String.format(Locale.ROOT,
|
||
"{strategies=%d, bars=%d, barCount=%s, maxBarCountHint=%s (effective=%s), heapMax=%s, overallRuntime=%s, gcOverhead=%s}",
|
||
strategyCount, barCount, barCountRequested <= 0 ? "full" : Integer.toString(barCountRequested),
|
||
maximumBarCountHintRequested <= 0 ? "default" : Integer.toString(maximumBarCountHintRequested),
|
||
Integer.toString(maximumBarCountHintEffective),
|
||
BacktestPerformanceTuningHarness.formatBytes(heapAfter.maxBytes()), runtimeStats.overallRuntime(),
|
||
String.format(Locale.ROOT, "%.2f%%", gcOverhead() * 100d));
|
||
}
|
||
}
|
||
|
||
record HeapSnapshot(long maxBytes, long committedBytes, long usedBytes) {
|
||
|
||
static HeapSnapshot capture() {
|
||
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
|
||
return new HeapSnapshot(Runtime.getRuntime().maxMemory(), heap.getCommitted(), heap.getUsed());
|
||
}
|
||
}
|
||
|
||
record GcSnapshot(long collections, Duration collectionTime) {
|
||
|
||
static GcSnapshot capture() {
|
||
long count = 0;
|
||
long timeMillis = 0;
|
||
for (GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) {
|
||
long beanCount = bean.getCollectionCount();
|
||
if (beanCount >= 0) {
|
||
count += beanCount;
|
||
}
|
||
long beanTime = bean.getCollectionTime();
|
||
if (beanTime >= 0) {
|
||
timeMillis += beanTime;
|
||
}
|
||
}
|
||
return new GcSnapshot(count, Duration.ofMillis(timeMillis));
|
||
}
|
||
|
||
GcSnapshot delta(GcSnapshot before) {
|
||
return new GcSnapshot(collections - before.collections, collectionTime.minus(before.collectionTime));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A wrapper around a BarSeries that overrides the maximum bar count hint for
|
||
* indicator caching.
|
||
* <p>
|
||
* 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.
|
||
* <p>
|
||
* 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<Bar> getBarData() {
|
||
return delegate.getBarData();
|
||
}
|
||
|
||
@Override
|
||
public int getBeginIndex() {
|
||
return delegate.getBeginIndex();
|
||
}
|
||
|
||
@Override
|
||
public int getEndIndex() {
|
||
return delegate.getEndIndex();
|
||
}
|
||
|
||
@Override
|
||
public int getMaximumBarCount() {
|
||
return maximumBarCountHint;
|
||
}
|
||
|
||
@Override
|
||
public void setMaximumBarCount(int maximumBarCount) {
|
||
throw new UnsupportedOperationException("Maximum bar count is a hint-only override for benchmarking");
|
||
}
|
||
|
||
@Override
|
||
public int getRemovedBarsCount() {
|
||
return delegate.getRemovedBarsCount();
|
||
}
|
||
|
||
@Override
|
||
public void addBar(Bar bar, boolean replace) {
|
||
delegate.addBar(bar, replace);
|
||
}
|
||
|
||
@Override
|
||
public void addTrade(Num tradeVolume, Num tradePrice) {
|
||
delegate.addTrade(tradeVolume, tradePrice);
|
||
}
|
||
|
||
@Override
|
||
public void addPrice(Num price) {
|
||
delegate.addPrice(price);
|
||
}
|
||
|
||
@Override
|
||
public BarSeries getSubSeries(int startIndex, int endIndex) {
|
||
return delegate.getSubSeries(startIndex, endIndex);
|
||
}
|
||
}
|
||
|
||
final class HarnessCli {
|
||
|
||
boolean help;
|
||
boolean tune;
|
||
boolean throughputControl;
|
||
boolean progress;
|
||
boolean gcBetweenRuns = true;
|
||
|
||
int topK = BacktestPerformanceTuningHarness.DEFAULT_TOP_K;
|
||
int barCount;
|
||
int strategyCount = -1;
|
||
int maximumBarCountHint;
|
||
|
||
int tuneStrategyStart = BacktestPerformanceTuningHarness.DEFAULT_TUNE_STRATEGY_START;
|
||
int tuneStrategyStep = BacktestPerformanceTuningHarness.DEFAULT_TUNE_STRATEGY_STEP;
|
||
int tuneStrategyMax = BacktestPerformanceTuningHarness.DEFAULT_TUNE_STRATEGY_MAX;
|
||
|
||
double nonlinearGcOverheadThreshold = BacktestPerformanceTuningHarness.DEFAULT_NONLINEAR_GC_OVERHEAD;
|
||
double nonlinearSlowdownRatioThreshold = BacktestPerformanceTuningHarness.DEFAULT_NONLINEAR_SLOWDOWN_RATIO;
|
||
|
||
String ohlcResourceFile = BacktestPerformanceTuningHarness.DEFAULT_OHLC_RESOURCE_FILE;
|
||
String parallelism = "1";
|
||
Path throughputOutputDir;
|
||
|
||
ExecutionMode executionMode = ExecutionMode.FULL_RESULT;
|
||
|
||
List<Integer> tuneBarCounts = List.of();
|
||
List<Integer> tuneMaxBarCountHints = List.of();
|
||
List<String> tuneHeaps = List.of();
|
||
List<Integer> matrixStrategyCounts = List.of();
|
||
List<Integer> matrixBarCounts = List.of();
|
||
List<Integer> matrixMaxBarCountHints = List.of();
|
||
|
||
static HarnessCli parse(String[] args) {
|
||
HarnessCli cli = new HarnessCli();
|
||
if (args == null || args.length == 0) {
|
||
return cli;
|
||
}
|
||
|
||
for (int i = 0; i < args.length; i++) {
|
||
String arg = args[i];
|
||
switch (arg) {
|
||
case "-h", "--help" -> cli.help = true;
|
||
case "--tune" -> cli.tune = true;
|
||
case "--throughputControl", "--throughput-control" -> cli.throughputControl = true;
|
||
case "--progress" -> cli.progress = true;
|
||
case "--gcBetweenRuns" -> cli.gcBetweenRuns = true;
|
||
case "--noGcBetweenRuns" -> cli.gcBetweenRuns = false;
|
||
case "--topK" -> cli.topK = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--bars", "--barCount" -> cli.barCount = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--strategies" -> cli.strategyCount = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--maxBarCountHint" -> cli.maximumBarCountHint = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--dataset" -> cli.ohlcResourceFile = requireValue(args, ++i, arg);
|
||
case "--executionMode" -> cli.executionMode = parseExecutionMode(requireValue(args, ++i, arg));
|
||
case "--parallelism" -> cli.parallelism = parseParallelism(requireValue(args, ++i, arg));
|
||
case "--throughputOutputDir", "--throughput-output-dir" ->
|
||
cli.throughputOutputDir = Path.of(requireValue(args, ++i, arg));
|
||
case "--matrixStrategyCounts", "--matrix-strategy-counts" ->
|
||
cli.matrixStrategyCounts = parseCsvPositiveInts(requireValue(args, ++i, arg), arg);
|
||
case "--matrixBarCounts", "--matrix-bar-counts" ->
|
||
cli.matrixBarCounts = parseCsvBarCounts(requireValue(args, ++i, arg), arg);
|
||
case "--matrixMaxBarCountHints", "--matrix-max-bar-count-hints" ->
|
||
cli.matrixMaxBarCountHints = parseCsvNonNegativeInts(requireValue(args, ++i, arg), arg);
|
||
case "--tuneStrategyStart" -> cli.tuneStrategyStart = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--tuneStrategyStep" -> cli.tuneStrategyStep = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--tuneStrategyMax" -> cli.tuneStrategyMax = Integer.parseInt(requireValue(args, ++i, arg));
|
||
case "--tuneBarCounts" -> cli.tuneBarCounts = parseCsvBarCounts(requireValue(args, ++i, arg), arg);
|
||
case "--tuneMaxBarCountHints" ->
|
||
cli.tuneMaxBarCountHints = parseCsvNonNegativeInts(requireValue(args, ++i, arg), arg);
|
||
case "--nonlinearGcOverhead" ->
|
||
cli.nonlinearGcOverheadThreshold = Double.parseDouble(requireValue(args, ++i, arg));
|
||
case "--nonlinearSlowdownRatio" ->
|
||
cli.nonlinearSlowdownRatioThreshold = Double.parseDouble(requireValue(args, ++i, arg));
|
||
case "--tuneHeaps" -> cli.tuneHeaps = parseCsvStrings(requireValue(args, ++i, arg));
|
||
default -> throw new IllegalArgumentException("Unknown argument: " + arg);
|
||
}
|
||
}
|
||
|
||
if (!cli.tuneHeaps.isEmpty()) {
|
||
cli.tune = true;
|
||
}
|
||
|
||
return cli;
|
||
}
|
||
|
||
List<String> toChildTuneArgs() {
|
||
List<String> args = new ArrayList<>();
|
||
args.add("--tune");
|
||
args.add("--dataset");
|
||
args.add(ohlcResourceFile);
|
||
args.add("--executionMode");
|
||
args.add(executionMode == ExecutionMode.KEEP_TOP_K ? "topK" : "full");
|
||
args.add("--topK");
|
||
args.add(Integer.toString(topK));
|
||
|
||
args.add("--tuneStrategyStart");
|
||
args.add(Integer.toString(tuneStrategyStart));
|
||
args.add("--tuneStrategyStep");
|
||
args.add(Integer.toString(tuneStrategyStep));
|
||
args.add("--tuneStrategyMax");
|
||
args.add(Integer.toString(tuneStrategyMax));
|
||
|
||
if (!tuneBarCounts.isEmpty()) {
|
||
args.add("--tuneBarCounts");
|
||
args.add(joinCsvInts(tuneBarCounts));
|
||
}
|
||
if (!tuneMaxBarCountHints.isEmpty()) {
|
||
args.add("--tuneMaxBarCountHints");
|
||
args.add(joinCsvInts(tuneMaxBarCountHints));
|
||
}
|
||
|
||
args.add("--nonlinearGcOverhead");
|
||
args.add(Double.toString(nonlinearGcOverheadThreshold));
|
||
args.add("--nonlinearSlowdownRatio");
|
||
args.add(Double.toString(nonlinearSlowdownRatioThreshold));
|
||
|
||
if (progress) {
|
||
args.add("--progress");
|
||
}
|
||
if (gcBetweenRuns) {
|
||
args.add("--gcBetweenRuns");
|
||
} else {
|
||
args.add("--noGcBetweenRuns");
|
||
}
|
||
|
||
return args;
|
||
}
|
||
|
||
List<Integer> buildTuneStrategyCounts() {
|
||
if (tuneStrategyStart <= 0 || tuneStrategyStep <= 0 || tuneStrategyMax <= 0) {
|
||
throw new IllegalArgumentException("Tune strategy counts must be positive");
|
||
}
|
||
if (tuneStrategyStart > tuneStrategyMax) {
|
||
throw new IllegalArgumentException("tuneStrategyStart must be <= tuneStrategyMax");
|
||
}
|
||
|
||
List<Integer> counts = new ArrayList<>();
|
||
for (int strategies = tuneStrategyStart; strategies <= tuneStrategyMax; strategies += tuneStrategyStep) {
|
||
counts.add(strategies);
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
List<SeriesVariant> buildSeriesVariants(int fullBarCount) {
|
||
List<SeriesVariant> variants = new ArrayList<>();
|
||
|
||
List<Integer> barCounts = tuneBarCounts.isEmpty() ? List.of(500, 1_000, 2_000, 0) : tuneBarCounts;
|
||
for (int barCount : barCounts) {
|
||
int normalized = barCount <= 0 ? 0 : Math.min(barCount, fullBarCount);
|
||
variants.add(new SeriesVariant(normalized, 0));
|
||
}
|
||
|
||
List<Integer> hints = tuneMaxBarCountHints.isEmpty() ? List.of(0, 512, 1_024, 2_048) : tuneMaxBarCountHints;
|
||
for (int hint : hints) {
|
||
if (hint < 0) {
|
||
continue;
|
||
}
|
||
variants.add(new SeriesVariant(0, hint));
|
||
}
|
||
|
||
return dedupeVariants(variants);
|
||
}
|
||
|
||
List<ThroughputMatrixCell> buildThroughputCells(int fullBarCount) {
|
||
List<Integer> strategyCounts = matrixStrategyCounts.isEmpty()
|
||
? BacktestPerformanceTuningHarness.DEFAULT_MATRIX_STRATEGY_COUNTS
|
||
: matrixStrategyCounts;
|
||
strategyCounts = dedupeIntegers(strategyCounts);
|
||
List<Integer> barCounts = matrixBarCounts.isEmpty() ? BacktestPerformanceTuningHarness.DEFAULT_MATRIX_BAR_COUNTS
|
||
: matrixBarCounts;
|
||
barCounts = dedupeIntegers(barCounts);
|
||
List<Integer> maximumBarCountHints = matrixMaxBarCountHints.isEmpty()
|
||
? BacktestPerformanceTuningHarness.DEFAULT_MATRIX_MAX_BAR_COUNT_HINTS
|
||
: matrixMaxBarCountHints;
|
||
maximumBarCountHints = dedupeIntegers(maximumBarCountHints);
|
||
|
||
List<ThroughputMatrixCell> cells = new ArrayList<>();
|
||
for (int strategyCount : strategyCounts) {
|
||
for (int rawBarCount : barCounts) {
|
||
int barCount = rawBarCount <= 0 ? 0 : Math.min(rawBarCount, fullBarCount);
|
||
for (int maximumBarCountHint : maximumBarCountHints) {
|
||
String cellId = "s" + strategyCount + "-b" + (barCount <= 0 ? "full" : barCount) + "-m"
|
||
+ maximumBarCountHint;
|
||
cells.add(new ThroughputMatrixCell(cellId, strategyCount, barCount, maximumBarCountHint,
|
||
executionMode, topK));
|
||
}
|
||
}
|
||
}
|
||
return cells;
|
||
}
|
||
|
||
private static List<Integer> dedupeIntegers(List<Integer> values) {
|
||
List<Integer> deduped = new ArrayList<>();
|
||
for (Integer candidate : values) {
|
||
if (!deduped.contains(candidate)) {
|
||
deduped.add(candidate);
|
||
}
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
private List<SeriesVariant> dedupeVariants(List<SeriesVariant> variants) {
|
||
List<SeriesVariant> deduped = new ArrayList<>();
|
||
for (SeriesVariant candidate : variants) {
|
||
boolean exists = false;
|
||
for (SeriesVariant existing : deduped) {
|
||
if (existing.barCount() == candidate.barCount()
|
||
&& existing.maximumBarCountHint() == candidate.maximumBarCountHint()) {
|
||
exists = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!exists) {
|
||
deduped.add(candidate);
|
||
}
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
private static ExecutionMode parseExecutionMode(String raw) {
|
||
String normalized = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
|
||
return switch (normalized) {
|
||
case "topk", "top_k", "keeptopk", "keep_top_k" -> ExecutionMode.KEEP_TOP_K;
|
||
case "full", "all", "full_result", "fullresult" -> ExecutionMode.FULL_RESULT;
|
||
default -> throw new IllegalArgumentException("Unknown executionMode: " + raw);
|
||
};
|
||
}
|
||
|
||
private static String requireValue(String[] args, int index, String flag) {
|
||
if (index >= args.length) {
|
||
throw new IllegalArgumentException("Missing value for " + flag);
|
||
}
|
||
return args[index];
|
||
}
|
||
|
||
private static List<String> parseCsvStrings(String value) {
|
||
if (value == null || value.isBlank()) {
|
||
return List.of();
|
||
}
|
||
return Arrays.stream(value.split(",")).map(String::trim).filter(part -> !part.isEmpty()).toList();
|
||
}
|
||
|
||
private static String parseParallelism(String value) {
|
||
String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||
if ("auto".equals(normalized)) {
|
||
return normalized;
|
||
}
|
||
int parsed = Integer.parseInt(normalized);
|
||
if (parsed <= 0) {
|
||
throw new IllegalArgumentException("--parallelism must be positive or auto");
|
||
}
|
||
return Integer.toString(parsed);
|
||
}
|
||
|
||
private static List<Integer> parseCsvBarCounts(String value, String flag) {
|
||
if (value == null || value.isBlank()) {
|
||
return List.of();
|
||
}
|
||
List<Integer> values = Arrays.stream(value.split(","))
|
||
.map(String::trim)
|
||
.filter(part -> !part.isEmpty())
|
||
.map(part -> parseBarCount(part, flag))
|
||
.toList();
|
||
return values;
|
||
}
|
||
|
||
private static int parseBarCount(String value, String flag) {
|
||
if ("full".equalsIgnoreCase(value)) {
|
||
return 0;
|
||
}
|
||
int parsed = Integer.parseInt(value);
|
||
if (parsed < 0) {
|
||
String source = flag == null ? "bar count" : flag;
|
||
throw new IllegalArgumentException(source + " values must be >= 0 or full");
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
private static List<Integer> parseCsvPositiveInts(String value, String flag) {
|
||
return parseCsvBoundedInts(value, flag, 1);
|
||
}
|
||
|
||
private static List<Integer> parseCsvNonNegativeInts(String value, String flag) {
|
||
return parseCsvBoundedInts(value, flag, 0);
|
||
}
|
||
|
||
private static List<Integer> parseCsvBoundedInts(String value, String flag, int minimum) {
|
||
if (value == null || value.isBlank()) {
|
||
return List.of();
|
||
}
|
||
List<Integer> values = Arrays.stream(value.split(","))
|
||
.map(String::trim)
|
||
.filter(part -> !part.isEmpty())
|
||
.map(Integer::parseInt)
|
||
.toList();
|
||
for (int parsed : values) {
|
||
if (parsed < minimum) {
|
||
throw new IllegalArgumentException(flag + " values must be >= " + minimum);
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
private static String joinCsvInts(List<Integer> values) {
|
||
return values.stream().map(Object::toString).reduce((left, right) -> left + "," + right).orElse("");
|
||
}
|
||
}
|