goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,184 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples;
import java.awt.GraphicsEnvironment;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.AnalysisCriterion.PositionFilter;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.drawdown.ReturnOverMaxDrawdownCriterion;
import org.ta4j.core.criteria.VersusEnterAndHoldCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.criteria.pnl.NetReturnCriterion;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import org.ta4j.core.rules.StopGainRule;
import org.ta4j.core.rules.StopLossRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* Quickstart example for ta4j.
*
* This class demonstrates how to:
* <ul>
* <li>Load a {@link BarSeries} from a CSV file.</li>
* <li>Build a trading {@link Strategy} using primitive {@link Rule rules} and
* indicators.</li>
* <li>Run a backtest over the series using {@link BarSeriesManager}.</li>
* <li>Analyze the resulting {@link TradingRecord} with various criteria.</li>
* <li>Display the results in a generated chart.</li>
* </ul>
*/
public class Quickstart {
private static final Logger LOG = LogManager.getLogger(Quickstart.class);
public static void main(String[] args) {
System.out.println("╔══════════════════════════════════════════════════════════════╗");
System.out.println("║ Welcome to ta4j - Your First Trading Strategy ║");
System.out.println("╚══════════════════════════════════════════════════════════════╝");
System.out.println();
// Step 1: Load historical price data
System.out.println("[1/6] Loading historical Bitcoin price data from Bitstamp...");
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
if (series == null || series.isEmpty()) {
System.err.println(
" [ERROR] Failed to load price data. The Bitstamp CSV file may be missing from the classpath.");
System.err.println(
" [TIP] Ensure the file 'Bitstamp-BTC-USD-PT5M-20131125_20131201.csv' exists in src/main/resources");
return;
}
System.out.printf(" [OK] Loaded %d bars of price data%n", series.getBarCount());
System.out.println();
// Step 2: Create indicators
System.out.println("[2/6] Creating technical indicators...");
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
SMAIndicator shortSma = new SMAIndicator(closePrice, 50); // 50-period SMA
SMAIndicator longSma = new SMAIndicator(closePrice, 200); // 200-period SMA
System.out.println(" [OK] Created 50-period and 200-period Simple Moving Averages");
System.out.println();
// Step 3: Build trading rules
System.out.println("[3/6] Building trading strategy rules...");
// Entry rule: Buy when fast SMA crosses above slow SMA (golden cross)
Rule buyingRule = new CrossedUpIndicatorRule(shortSma, longSma);
// Exit rule: Sell when fast SMA crosses below slow SMA (death cross)
// OR take profit at +6% OR cut losses at -5%
Rule sellingRule = new CrossedDownIndicatorRule(shortSma, longSma)
.or(new StopLossRule(closePrice, series.numFactory().numOf(5)))
.or(new StopGainRule(closePrice, series.numFactory().numOf(6)));
Strategy strategy = new BaseStrategy("SMA Crossover Strategy", buyingRule, sellingRule);
System.out.println(" [OK] Strategy: SMA Crossover with stop-loss and take-profit");
System.out.println();
// Step 4: Run backtest
System.out.println("[4/6] Running backtest on historical data...");
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
System.out.printf(" [OK] Backtest complete: %d trades executed%n", tradingRecord.getPositionCount());
System.out.println();
// Step 5: Analyze results
System.out.println("[5/6] Performance Analysis");
System.out.println(" ──────────────────────────────────────────");
// Calculate key metrics
AnalysisCriterion netReturn = new NetReturnCriterion();
AnalysisCriterion winningPositionsRatio = new PositionsRatioCriterion(PositionFilter.PROFIT);
AnalysisCriterion romad = new ReturnOverMaxDrawdownCriterion();
AnalysisCriterion versusEnterAndHoldCriterion = new VersusEnterAndHoldCriterion(new NetReturnCriterion());
Num netReturnValue = netReturn.calculate(series, tradingRecord);
Num winRate = winningPositionsRatio.calculate(series, tradingRecord);
Num romadValue = romad.calculate(series, tradingRecord);
Num vsBuyHold = versusEnterAndHoldCriterion.calculate(series, tradingRecord);
// Display formatted results
System.out.printf(" Total Trades: %d%n", tradingRecord.getPositionCount());
System.out.printf(" Net Return: %.2f%%%n",
netReturnValue.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Win Rate: %.1f%%%n",
winRate.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Return/Max Drawdown: %.2f%n", romadValue.doubleValue());
System.out.printf(" vs Buy & Hold: %.2f%%%n",
vsBuyHold.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.println();
// Step 6: Visualize the strategy
System.out.println("[6/6] Generating strategy visualization...");
boolean isHeadless = GraphicsEnvironment.isHeadless();
if (isHeadless) {
System.out.println(" [WARN] Headless environment detected - skipping chart display");
System.out.println(" [TIP] Run in a GUI environment to see interactive charts!");
} else {
try {
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("SMA Crossover Strategy - Quickstart Example")
.withSeries(series) // Price bars (candlesticks)
.withTradingRecordOverlay(tradingRecord) // Trading positions in subchart
.withIndicatorOverlay(shortSma) // Fast SMA overlay
.withIndicatorOverlay(longSma) // Slow SMA overlay
.withSubChart(new NetProfitLossCriterion(), tradingRecord) // Net profit/loss in subchart
.toChart();
chartWorkflow.displayChart(chart, "ta4j Quickstart - SMA Crossover Strategy");
System.out.println(" [OK] Chart displayed in new window");
System.out.println(" [TIP] Net profit/loss shown in subchart below price chart");
} catch (Exception ex) {
LOG.warn("Failed to display chart: {}", ex.getMessage(), ex);
System.out.println(" [WARN] Could not display chart: " + ex.getMessage());
}
}
System.out.println();
// Summary
System.out.println("╔══════════════════════════════════════════════════════════════╗");
System.out.println("║ Summary ║");
System.out.println("╚══════════════════════════════════════════════════════════════╝");
System.out.println();
System.out.println("What just happened?");
System.out.println();
System.out.println(" 1. We loaded historical Bitcoin price data");
System.out.println(" 2. Created two moving averages (50-period and 200-period)");
System.out.println(" 3. Built a strategy that:");
System.out.println(" - Buys when the fast MA crosses above the slow MA");
System.out.println(" - Sells when the fast MA crosses below the slow MA");
System.out.println(" - Uses stop-loss (-5%) and take-profit (+6%) rules");
System.out.println(" 4. Backtested the strategy on historical data");
System.out.println(" 5. Analyzed the performance metrics");
if (!isHeadless) {
System.out.println(" 6. Visualized the strategy with a chart");
}
System.out.println();
System.out.println("Next Steps:");
System.out.println(" - Modify the indicator periods (try 20/100 for more frequent trades)");
System.out.println(" - Adjust stop-loss and take-profit percentages");
System.out.println(" - Add more indicators (RSI, MACD, etc.)");
System.out.println(" - Explore other examples in ta4j-examples");
System.out.println(" - Check out the wiki: https://ta4j.github.io/ta4j-wiki/");
System.out.println();
System.out.println("Your turn! Modify this code and see how it affects performance.");
System.out.println();
}
}
@@ -0,0 +1,13 @@
# ta4jexamples.analysis instructions
- Analysis demo classes in this package should load *ossified* OHLCV datasets committed under `ta4j-examples/src/main/resources` (avoid live HTTP calls in the demo itself).
- Long-running analysis/calibration flows must log progress and persist primary artifacts incrementally.
- Write the primary dataset/instrument result before any portability or secondary checks.
- Final aggregate JSON/bundles may append later, but must not be the only durable output.
- When adding a new analysis demo, first stage the dataset locally:
1. Fetch the desired OHLCV data using an HTTP datasource (prefer `ta4jexamples.datasources.CoinbaseHttpBarSeriesDataSource` for crypto examples) with response caching enabled.
2. Let the datasource paginate as needed (Coinbase is capped at 350 candles/request) and write cached JSON responses under `ta4j-examples/temp/responses`.
3. Combine/merge the paginated cached files into a single consolidated OHLCV JSON file (deduplicate by candle start time, keep chronological ordering).
4. Rename the consolidated file to the resource naming convention `{Source}-{TICKER}-{INTERVAL}-{START}_{END}.json` (example: `Coinbase-BTC-USD-PT1D-20230616_20231011.json`) and move it to `ta4j-examples/src/main/resources`.
- Note: cached response filenames may use `PT24H` for daily candles; resources should use the `PT1D` / `PT4H` / `PT5M` style used by `ta4jexamples.datasources.file.AbstractFileBarSeriesDataSource`.
5. Update the demo class to initialize its `BarSeries` from the resource JSON (via `ta4jexamples.datasources.JsonFileBarSeriesDataSource`).
@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.BarSeries;
import ta4jexamples.strategies.MovingMomentumStrategy;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.charting.workflow.ChartWorkflow;
/**
* This class builds a graphical chart showing the buy/sell signals of a
* strategy.
*/
public class BuyAndSellSignalsToChart {
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = MovingMomentumStrategy.buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
// Displaying the chart using the shared ChartWorkflow utility
ChartWorkflow chartWorkflow = new ChartWorkflow();
String strategyName = strategy.getName() != null ? strategy.getName() : "Moving Momentum Strategy";
chartWorkflow.displayTradingRecordChart(series, strategyName, tradingRecord);
}
}
@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.CashFlow;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.strategies.MovingMomentumStrategy;
/**
* This class builds a graphical chart showing the cash flow of a strategy.
*
* <p>
* This example demonstrates the use of the dual-axis chart functionality in
* {@link ChartWorkflow} to display both the close price and cash flow of a
* trading strategy on the same chart with separate Y-axes.
* </p>
*/
public class CashFlowToChart {
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = MovingMomentumStrategy.buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
// Getting the cash flow of the resulting positions
CashFlow cashFlow = new CashFlow(series, tradingRecord);
// Creating indicators for the dual-axis chart
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// Building and displaying the dual-axis chart using ChartWorkflow
ChartWorkflow chartWorkflow = new ChartWorkflow();
chartWorkflow.displayDualAxisChart(series, closePrice, "Price (USD)", cashFlow, "Cash Flow Ratio",
"Bitstamp BTC", "Ta4j example - Cash flow to chart");
}
}
@@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.AnalysisCriterion.PositionFilter;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.AverageReturnPerBarCriterion;
import org.ta4j.core.criteria.EnterAndHoldCriterion;
import org.ta4j.core.criteria.LinearTransactionCostCriterion;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.NumberOfBarsCriterion;
import org.ta4j.core.criteria.NumberOfPositionsCriterion;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.drawdown.ReturnOverMaxDrawdownCriterion;
import org.ta4j.core.criteria.VersusEnterAndHoldCriterion;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetReturnCriterion;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.strategies.MovingMomentumStrategy;
/**
* This class displays analysis criterion values after running a trading
* strategy over a bar series.
*/
public class StrategyAnalysis {
private static final Logger LOG = LogManager.getLogger(StrategyAnalysis.class);
public static void main(String[] args) {
// Getting the bar series
var series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
var strategy = MovingMomentumStrategy.buildStrategy(series);
// Running the strategy
var seriesManager = new BarSeriesManager(series);
var tradingRecord = seriesManager.run(strategy);
/*
* Analysis criteria
*/
var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("Gross return: {}", grossReturn);
var netReturnCriterion = new NetReturnCriterion();
var netReturn = netReturnCriterion.calculate(series, tradingRecord);
LOG.debug("Net return: {}", netReturn);
var numberOfBars = new NumberOfBarsCriterion().calculate(series, tradingRecord);
LOG.debug("Number of bars: {}", numberOfBars);
var AverageReturnPerBar = new AverageReturnPerBarCriterion().calculate(series, tradingRecord);
LOG.debug("Average return per bar: {}", AverageReturnPerBar);
var numberOfPositions = new NumberOfPositionsCriterion().calculate(series, tradingRecord);
LOG.debug("Number of positions: {}", numberOfPositions);
var positionsRatio = new PositionsRatioCriterion(PositionFilter.PROFIT).calculate(series, tradingRecord);
LOG.debug("Winning positions ratio: {}", positionsRatio);
var maximumDrawdown = new MaximumDrawdownCriterion().calculate(series, tradingRecord);
LOG.debug("Maximum drawdown: {}", maximumDrawdown);
var returnOverMaxDrawdown = new ReturnOverMaxDrawdownCriterion().calculate(series, tradingRecord);
LOG.debug("Return over maximum drawdown: {}", returnOverMaxDrawdown);
var linearTransactionCost = new LinearTransactionCostCriterion(1000, 0.005).calculate(series, tradingRecord);
LOG.debug("Total transaction cost (from $1000): {}", linearTransactionCost);
var enterAndHold = EnterAndHoldCriterion.EnterAndHoldReturnCriterion().calculate(series, tradingRecord);
LOG.debug("Buy-and-hold return: {}", enterAndHold);
var versusEnterAndHold = new VersusEnterAndHoldCriterion(netReturnCriterion).calculate(series, tradingRecord);
LOG.debug("Custom strategy return vs buy-and-hold strategy return: {}", versusEnterAndHold);
}
}
@@ -0,0 +1,77 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import java.text.DecimalFormat;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Indicator;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.LinearBorrowingCostModel;
import org.ta4j.core.analysis.cost.LinearTransactionCostModel;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* This class displays an example of the transaction cost calculation.
*/
public class TradeCost {
private static final Logger LOG = LogManager.getLogger(TradeCost.class);
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the short selling trading strategy
Strategy strategy = buildShortSellingMomentumStrategy(series);
// Setting the trading cost models
double feePerTrade = 0.0005;
double borrowingFee = 0.00001;
CostModel transactionCostModel = new LinearTransactionCostModel(feePerTrade);
CostModel borrowingCostModel = new LinearBorrowingCostModel(borrowingFee);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series, transactionCostModel, borrowingCostModel);
Trade.TradeType entryTrade = Trade.TradeType.SELL;
TradingRecord tradingRecord = seriesManager.run(strategy, entryTrade);
DecimalFormat df = new DecimalFormat("##.##");
LOG.debug("------------ Borrowing Costs ------------");
tradingRecord.getPositions()
.forEach(position -> LOG.debug("Borrowing cost for {} periods is: {}",
df.format(position.getExit().getIndex() - position.getEntry().getIndex()),
df.format(position.getHoldingCost().doubleValue())));
LOG.debug("------------ Transaction Costs ------------");
tradingRecord.getPositions()
.forEach(position -> LOG.debug("Transaction cost for selling: {} -- Transaction cost for buying: {}",
df.format(position.getEntry().getCost().doubleValue()),
df.format(position.getExit().getCost().doubleValue())));
}
private static Strategy buildShortSellingMomentumStrategy(BarSeries series) {
Indicator<Num> closingPrices = new ClosePriceIndicator(series);
SMAIndicator shortEma = new SMAIndicator(closingPrices, 10);
SMAIndicator longEma = new SMAIndicator(closingPrices, 50);
Rule shortOverLongRule = new OverIndicatorRule(shortEma, longEma);
Rule shortUnderLongRule = new UnderIndicatorRule(shortEma, longEma);
String strategyName = "Momentum short-selling strategy";
return new BaseStrategy(strategyName, shortOverLongRule, shortUnderLongRule);
}
}
@@ -0,0 +1,547 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import java.awt.Color;
import java.awt.GraphicsEnvironment;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.indicators.RecentFractalSwingHighIndicator;
import org.ta4j.core.indicators.RecentFractalSwingLowIndicator;
import org.ta4j.core.indicators.RecentSwingIndicator;
import org.ta4j.core.indicators.SwingPointMarkerIndicator;
import org.ta4j.core.indicators.helpers.HighPriceIndicator;
import org.ta4j.core.indicators.helpers.LowPriceIndicator;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator.TrendLineSegment;
import org.ta4j.core.indicators.supportresistance.TrendLineResistanceIndicator;
import org.ta4j.core.indicators.supportresistance.TrendLineSupportIndicator;
import org.ta4j.core.indicators.zigzag.RecentZigZagSwingHighIndicator;
import org.ta4j.core.indicators.zigzag.RecentZigZagSwingLowIndicator;
import org.ta4j.core.num.Num;
import ta4jexamples.charting.builder.ChartPlan;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Combined regression + visualization harness for trendline and swing point
* indicators.
*
* <p>
* This runner performs two kinds of verification:
* <ul>
* <li>Capacity headroom checks across bundled example datasets (fractal +
* ZigZag, highs + lows) to ensure default caps remain comfortably above
* real-world usage.</li>
* <li>Chart generation that overlays trendline indicators and swing point
* markers on a representative dataset, enabling quick visual inspection after
* large changes.</li>
* </ul>
*
* <p>
* The companion unit test asserts the regression invariants programmatically
* and validates that rendered overlays contain data without requiring GUI
* display.
*
* @see org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator
* @see org.ta4j.core.indicators.RecentFractalSwingHighIndicator
* @see org.ta4j.core.indicators.RecentFractalSwingLowIndicator
* @see org.ta4j.core.indicators.zigzag.RecentZigZagSwingHighIndicator
* @see org.ta4j.core.indicators.zigzag.RecentZigZagSwingLowIndicator
*/
public class TrendLineAndSwingPointAnalysis {
static final int DEFAULT_TRENDLINE_LOOKBACK = 200;
static final int DEFAULT_SURROUNDING_BARS = 5;
static final int DEFAULT_FRACTAL_PRECEDING_BARS = 5;
static final int DEFAULT_FRACTAL_FOLLOWING_BARS = 5;
static final int DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS = 0;
static final int HEADROOM_SWING_POINT_LIMIT = AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE / 2;
static final int HEADROOM_CANDIDATE_PAIR_LIMIT = AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS / 2;
static final String DEFAULT_CHART_OUTPUT_DIRECTORY = "temp/charts";
static final String DEFAULT_CHART_FILE_NAME = "trendline-swingpoint-analysis";
private static final Logger LOG = LogManager.getLogger(TrendLineAndSwingPointAnalysis.class);
/**
* Represents a dataset with its name and bar series.
*/
record Dataset(String name, BarSeries series) {
}
private record DatasetSpec(String name, Supplier<BarSeries> loader) {
}
/**
* Statistics about swing points observed in a dataset.
*
* @param maxSwings the maximum number of swing points found in any lookback
* window
* @param maxPairs the maximum number of candidate pairs (n*(n-1)/2 for n
* swings)
*/
private record SwingStats(int maxSwings, int maxPairs) {
}
private enum SwingMethod {
FRACTAL, ZIGZAG
}
private record HeadroomObservation(String datasetName, SwingMethod method, PriceSide side, int lookback,
SwingStats stats) {
}
/**
* Indicates whether we're analyzing support (lows) or resistance (highs)
* trendlines.
*/
private enum PriceSide {
SUPPORT, RESISTANCE
}
public static void main(String[] args) {
Config config = Config.fromArgs(args);
TrendLineAndSwingPointAnalysis analysis = new TrendLineAndSwingPointAnalysis();
analysis.verifyDefaultCapsHeadroomForBundledDatasets();
BarSeries chartSeries = analysis.loadChartSeries(config.chartDatasetResource());
AnalysisChartArtifacts artifacts = analysis.buildAnalysisChartArtifacts(chartSeries,
Math.min(chartSeries.getBarCount(), config.trendLineLookback()), config.surroundingBars());
analysis.renderAnalysisChart(artifacts, config);
}
void verifyDefaultCapsHeadroomForBundledDatasets() {
List<Dataset> datasets = loadRequiredDatasets();
List<HeadroomObservation> observations = new ArrayList<>();
for (Dataset dataset : datasets) {
BarSeries series = dataset.series();
int lookback = Math.min(series.getBarCount(), DEFAULT_TRENDLINE_LOOKBACK);
observations.addAll(analyzeHeadroom(dataset, lookback));
}
verifyHeadroom(observations);
logHeadroomSummary(observations, datasets.size());
}
private List<HeadroomObservation> analyzeHeadroom(Dataset dataset, int lookback) {
BarSeries series = dataset.series();
LowPriceIndicator lowPrice = new LowPriceIndicator(series);
HighPriceIndicator highPrice = new HighPriceIndicator(series);
RecentSwingIndicator fractalLows = new RecentFractalSwingLowIndicator(lowPrice, DEFAULT_FRACTAL_PRECEDING_BARS,
DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentSwingIndicator fractalHighs = new RecentFractalSwingHighIndicator(highPrice,
DEFAULT_FRACTAL_PRECEDING_BARS, DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentSwingIndicator zigzagLows = new RecentZigZagSwingLowIndicator(series);
RecentSwingIndicator zigzagHighs = new RecentZigZagSwingHighIndicator(series);
return List.of(
new HeadroomObservation(dataset.name(), SwingMethod.FRACTAL, PriceSide.SUPPORT, lookback,
analyzeSwings(series, fractalLows, PriceSide.SUPPORT, lookback)),
new HeadroomObservation(dataset.name(), SwingMethod.FRACTAL, PriceSide.RESISTANCE, lookback,
analyzeSwings(series, fractalHighs, PriceSide.RESISTANCE, lookback)),
new HeadroomObservation(dataset.name(), SwingMethod.ZIGZAG, PriceSide.SUPPORT, lookback,
analyzeSwings(series, zigzagLows, PriceSide.SUPPORT, lookback)),
new HeadroomObservation(dataset.name(), SwingMethod.ZIGZAG, PriceSide.RESISTANCE, lookback,
analyzeSwings(series, zigzagHighs, PriceSide.RESISTANCE, lookback)));
}
private SwingStats analyzeSwings(BarSeries series, RecentSwingIndicator swingIndicator, PriceSide side,
int lookback) {
final Indicator<Num> priceIndicator = swingIndicator.getPriceIndicator();
final int beginIndex = series.getBeginIndex();
final int endIndex = series.getEndIndex();
swingIndicator.getValue(endIndex);
validateSwingIndexes(series, swingIndicator.getSwingPointIndexesUpTo(endIndex));
int maxSwings = 0;
for (int end = beginIndex; end <= endIndex; end++) {
swingIndicator.getValue(end);
final int windowStart = Math.max(beginIndex, end - lookback + 1);
int swingCount = 0;
List<Integer> swingPoints = swingIndicator.getSwingPointIndexesUpTo(end);
for (int i = swingPoints.size() - 1; i >= 0; i--) {
int swingIndex = swingPoints.get(i);
if (swingIndex < windowStart) {
break;
}
if (isValidPrice(priceIndicator.getValue(swingIndex)) || isValidBarFallback(series, swingIndex, side)) {
swingCount++;
}
}
maxSwings = Math.max(maxSwings, swingCount);
}
final int maxPairs = maxSwings < 2 ? 0 : (maxSwings * (maxSwings - 1)) / 2;
return new SwingStats(maxSwings, maxPairs);
}
private boolean isValidPrice(Num value) {
return value != null && !value.isNaN() && !Double.isNaN(value.doubleValue());
}
private boolean isValidBarFallback(BarSeries series, int index, PriceSide side) {
if (series == null || index < series.getBeginIndex() || index > series.getEndIndex()) {
return false;
}
Bar bar = series.getBar(index);
final Num fallback = side == PriceSide.SUPPORT ? bar.getLowPrice() : bar.getHighPrice();
return isValidPrice(fallback);
}
private void validateSwingIndexes(BarSeries series, List<Integer> swingIndexes) {
int beginIndex = series.getBeginIndex();
int endIndex = series.getEndIndex();
Integer previous = null;
for (Integer index : swingIndexes) {
requireTrue(index != null, "Swing indicator returned a null index");
requireTrue(index >= beginIndex && index <= endIndex,
"Swing index " + index + " must fall within [" + beginIndex + ", " + endIndex + "]");
if (previous != null) {
requireTrue(index > previous,
"Swing indexes must be strictly increasing (found " + previous + " then " + index + ")");
}
previous = index;
}
}
private void verifyHeadroom(List<HeadroomObservation> observations) {
for (HeadroomObservation observation : observations) {
SwingStats stats = observation.stats();
requireTrue(stats.maxSwings() <= AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE,
headroomMessage(observation, "Exceeded default swing cap", stats.maxSwings(),
AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE));
requireTrue(stats.maxPairs() <= AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS,
headroomMessage(observation, "Exceeded default candidate-pair cap", stats.maxPairs(),
AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS));
requireTrue(stats.maxSwings() <= HEADROOM_SWING_POINT_LIMIT, headroomMessage(observation,
"Swing headroom shrank below 50%", stats.maxSwings(), HEADROOM_SWING_POINT_LIMIT));
requireTrue(stats.maxPairs() <= HEADROOM_CANDIDATE_PAIR_LIMIT, headroomMessage(observation,
"Candidate-pair headroom shrank below 50%", stats.maxPairs(), HEADROOM_CANDIDATE_PAIR_LIMIT));
}
}
private String headroomMessage(HeadroomObservation observation, String prefix, int observed, int limit) {
return prefix + " for dataset=" + observation.datasetName + ", method=" + observation.method + ", side="
+ observation.side + ", lookback=" + observation.lookback + " (observed=" + observed + ", limit="
+ limit + ")";
}
private void logHeadroomSummary(List<HeadroomObservation> observations, int datasetCount) {
int maxSwings = 0;
int maxPairs = 0;
for (HeadroomObservation observation : observations) {
SwingStats stats = observation.stats();
maxSwings = Math.max(maxSwings, stats.maxSwings());
maxPairs = Math.max(maxPairs, stats.maxPairs());
}
LOG.info(
"Trendline cap headroom summary: datasets={}, maxSwings={}, maxPairs={}, caps(swings={}, pairs={}), headroom(swings<= {}, pairs<= {})",
datasetCount, maxSwings, maxPairs, AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE,
AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS, HEADROOM_SWING_POINT_LIMIT,
HEADROOM_CANDIDATE_PAIR_LIMIT);
}
private BarSeries loadJsonSeries(String resourceName) {
final InputStream stream = TrendLineAndSwingPointAnalysis.class.getClassLoader()
.getResourceAsStream(resourceName);
if (stream == null) {
return null;
}
return JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
}
private List<Dataset> loadRequiredDatasets() {
List<Dataset> datasets = new ArrayList<>();
for (DatasetSpec spec : requiredDatasetSpecs()) {
datasets.add(loadRequiredDataset(spec));
}
return datasets;
}
private List<DatasetSpec> requiredDatasetSpecs() {
return List.of(
new DatasetSpec("AAPL-PT1D-20130102_20131231.csv",
() -> CsvFileBarSeriesDataSource.loadSeriesFromFile("AAPL-PT1D-20130102_20131231.csv")),
new DatasetSpec("Bitstamp-BTC-USD-PT5M-20131125_20131201.csv",
() -> BitStampCsvTradesFileBarSeriesDataSource
.loadBitstampSeries("Bitstamp-BTC-USD-PT5M-20131125_20131201.csv")),
new DatasetSpec("Binance-ETH-USD-PT5M-20230313_20230315.json",
() -> loadJsonSeries("Binance-ETH-USD-PT5M-20230313_20230315.json")),
new DatasetSpec("Coinbase-ETH-USD-PT1D-20241105_20251020.json",
() -> loadJsonSeries("Coinbase-ETH-USD-PT1D-20241105_20251020.json")),
new DatasetSpec("Coinbase-ETH-USD-PT1D-20160517_20251028.json",
() -> loadJsonSeries("Coinbase-ETH-USD-PT1D-20160517_20251028.json")));
}
private Dataset loadRequiredDataset(DatasetSpec spec) {
BarSeries series;
try {
series = spec.loader.get();
} catch (Exception e) {
throw new IllegalStateException("Failed to load required dataset '" + spec.name + '\'', e);
}
requireTrue(series != null && !series.isEmpty(), "Required dataset '" + spec.name + "' is missing or empty");
return new Dataset(spec.name, series);
}
private BarSeries loadChartSeries(String datasetResource) {
if (datasetResource == null || datasetResource.isBlank()) {
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
requireTrue(series != null && !series.isEmpty(), "Default chart series is missing or empty");
return series;
}
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile(datasetResource);
if (series == null || series.isEmpty()) {
series = loadJsonSeries(datasetResource);
}
requireTrue(series != null && !series.isEmpty(), "Chart series '" + datasetResource + "' is missing or empty");
return series;
}
record TrendLineVariants(TrendLineSupportIndicator support, TrendLineSupportIndicator supportTouchBiased,
TrendLineSupportIndicator supportExtremeBiased, TrendLineResistanceIndicator resistance,
TrendLineResistanceIndicator resistanceTouchBiased, TrendLineResistanceIndicator resistanceExtremeBiased) {
}
record SwingMarkerVariants(SwingPointMarkerIndicator fractalLows, SwingPointMarkerIndicator fractalHighs,
SwingPointMarkerIndicator zigzagLows, SwingPointMarkerIndicator zigzagHighs) {
}
record AnalysisChartArtifacts(BarSeries series, TrendLineVariants trendLines, SwingMarkerVariants swings,
ChartPlan plan) {
}
AnalysisChartArtifacts buildAnalysisChartArtifacts(BarSeries series, int lookback, int surroundingBars) {
Objects.requireNonNull(series, "Series cannot be null");
requireFalse(series.isEmpty(), "Series cannot be empty");
TrendLineVariants trendLines = buildTrendLines(series, lookback, surroundingBars);
SwingMarkerVariants swings = buildSwingMarkers(series);
ChartPlan plan = buildChartPlan(series, trendLines, swings);
return new AnalysisChartArtifacts(series, trendLines, swings, plan);
}
private TrendLineVariants buildTrendLines(BarSeries series, int lookback, int surroundingBars) {
TrendLineSupportIndicator support = new TrendLineSupportIndicator(series, surroundingBars, lookback);
TrendLineSupportIndicator supportTouchBiased = new TrendLineSupportIndicator(series, surroundingBars, lookback,
TrendLineSupportIndicator.ScoringWeights.touchCountBiasPreset());
TrendLineSupportIndicator supportExtremeBiased = new TrendLineSupportIndicator(series, surroundingBars,
lookback, TrendLineSupportIndicator.ScoringWeights.extremeSwingBiasPreset());
TrendLineResistanceIndicator resistance = new TrendLineResistanceIndicator(series, surroundingBars, lookback);
TrendLineResistanceIndicator resistanceTouchBiased = new TrendLineResistanceIndicator(series, surroundingBars,
lookback, TrendLineResistanceIndicator.ScoringWeights.touchCountBiasPreset());
TrendLineResistanceIndicator resistanceExtremeBiased = new TrendLineResistanceIndicator(series, surroundingBars,
lookback, TrendLineResistanceIndicator.ScoringWeights.extremeSwingBiasPreset());
return new TrendLineVariants(support, supportTouchBiased, supportExtremeBiased, resistance,
resistanceTouchBiased, resistanceExtremeBiased);
}
private SwingMarkerVariants buildSwingMarkers(BarSeries series) {
LowPriceIndicator lowPrice = new LowPriceIndicator(series);
HighPriceIndicator highPrice = new HighPriceIndicator(series);
RecentFractalSwingLowIndicator fractalLows = new RecentFractalSwingLowIndicator(lowPrice,
DEFAULT_FRACTAL_PRECEDING_BARS, DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentFractalSwingHighIndicator fractalHighs = new RecentFractalSwingHighIndicator(highPrice,
DEFAULT_FRACTAL_PRECEDING_BARS, DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentZigZagSwingLowIndicator zigzagLows = new RecentZigZagSwingLowIndicator(series);
RecentZigZagSwingHighIndicator zigzagHighs = new RecentZigZagSwingHighIndicator(series);
SwingPointMarkerIndicator fractalLowMarkers = new SwingPointMarkerIndicator(series, fractalLows);
SwingPointMarkerIndicator fractalHighMarkers = new SwingPointMarkerIndicator(series, fractalHighs);
SwingPointMarkerIndicator zigzagLowMarkers = new SwingPointMarkerIndicator(series, zigzagLows);
SwingPointMarkerIndicator zigzagHighMarkers = new SwingPointMarkerIndicator(series, zigzagHighs);
return new SwingMarkerVariants(fractalLowMarkers, fractalHighMarkers, zigzagLowMarkers, zigzagHighMarkers);
}
private ChartPlan buildChartPlan(BarSeries series, TrendLineVariants trendLines, SwingMarkerVariants swings) {
ChartWorkflow chartWorkflow = new ChartWorkflow();
return chartWorkflow.builder()
.withTitle("Trendlines + Swing Points (Regression Harness)")
.withSeries(series)
.withIndicatorOverlay(trendLines.support)
.withLineColor(Color.GREEN)
.withLineWidth(2.0f)
.withOpacity(0.55f)
.withLabel("Support (default)")
.withIndicatorOverlay(trendLines.supportTouchBiased)
.withLineColor(Color.BLUE)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Support (touchCountBiasPreset)")
.withIndicatorOverlay(trendLines.supportExtremeBiased)
.withLineColor(Color.MAGENTA)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Support (extremeSwingBiasPreset)")
.withIndicatorOverlay(trendLines.resistance)
.withLineColor(Color.RED)
.withLineWidth(2.0f)
.withOpacity(0.55f)
.withLabel("Resistance (default)")
.withIndicatorOverlay(trendLines.resistanceTouchBiased)
.withLineColor(Color.CYAN)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Resistance (touchCountBiasPreset)")
.withIndicatorOverlay(trendLines.resistanceExtremeBiased)
.withLineColor(Color.ORANGE)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Resistance (extremeSwingBiasPreset)")
.withIndicatorOverlay(swings.fractalLows)
.withLineColor(Color.GREEN)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.85f)
.withLabel("Fractal swing lows")
.withIndicatorOverlay(swings.fractalHighs)
.withLineColor(Color.RED)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.85f)
.withLabel("Fractal swing highs")
.withIndicatorOverlay(swings.zigzagLows)
.withLineColor(Color.GREEN)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.25f)
.withLabel("ZigZag swing lows")
.withIndicatorOverlay(swings.zigzagHighs)
.withLineColor(Color.RED)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.25f)
.withLabel("ZigZag swing highs")
.toPlan();
}
private void renderAnalysisChart(AnalysisChartArtifacts artifacts, Config config) {
int endIndex = artifacts.series().getEndIndex();
TrendLineVariants trendLines = artifacts.trendLines();
warmTrendLine(trendLines.support(), endIndex, "Support (default)");
warmTrendLine(trendLines.supportTouchBiased(), endIndex, "Support (touchCountBiasPreset)");
warmTrendLine(trendLines.supportExtremeBiased(), endIndex, "Support (extremeSwingBiasPreset)");
warmTrendLine(trendLines.resistance(), endIndex, "Resistance (default)");
warmTrendLine(trendLines.resistanceTouchBiased(), endIndex, "Resistance (touchCountBiasPreset)");
warmTrendLine(trendLines.resistanceExtremeBiased(), endIndex, "Resistance (extremeSwingBiasPreset)");
ChartWorkflow chartWorkflow = new ChartWorkflow();
if (config.saveChart()) {
chartWorkflow.save(artifacts.plan(), config.chartOutputDirectory(), config.chartFileName());
}
if (config.displayChart() && !GraphicsEnvironment.isHeadless()) {
try {
chartWorkflow.display(artifacts.plan());
} catch (Exception e) {
LOG.warn("Unable to display chart; continuing (headless runner?)", e);
}
}
}
private void warmTrendLine(AbstractTrendLineIndicator trendLine, int endIndex, String label) {
trendLine.getValue(endIndex);
TrendLineSegment segment = trendLine.getCurrentSegment();
if (segment == null) {
LOG.info("{} trendline: no active segment", label);
return;
}
LOG.info(
"{} trendline anchors=({}, {}), slope={}, intercept={}, swingTouches={}, swingsOutside={}, anchoredAtExtreme={}, score={}",
label, segment.firstIndex, segment.secondIndex, segment.slope, segment.intercept, segment.touchCount,
segment.outsideCount, segment.touchesExtreme, segment.score);
}
private record Config(String chartDatasetResource, String chartOutputDirectory, String chartFileName,
boolean displayChart, boolean saveChart, int trendLineLookback, int surroundingBars) {
private static Config fromArgs(String[] args) {
String dataset = null;
String outDir = DEFAULT_CHART_OUTPUT_DIRECTORY;
String fileName = DEFAULT_CHART_FILE_NAME;
boolean display = true;
boolean save = true;
int lookback = DEFAULT_TRENDLINE_LOOKBACK;
int surroundingBars = DEFAULT_SURROUNDING_BARS;
if (args != null) {
for (String arg : args) {
if (arg == null || arg.isBlank()) {
continue;
}
if (arg.equals("--no-display")) {
display = false;
continue;
}
if (arg.equals("--no-save")) {
save = false;
continue;
}
if (arg.startsWith("--dataset=")) {
dataset = arg.substring("--dataset=".length());
continue;
}
if (arg.startsWith("--outDir=")) {
outDir = arg.substring("--outDir=".length());
continue;
}
if (arg.startsWith("--file=")) {
fileName = arg.substring("--file=".length());
continue;
}
if (arg.startsWith("--lookback=")) {
lookback = Integer.parseInt(arg.substring("--lookback=".length()));
continue;
}
if (arg.startsWith("--surroundingBars=")) {
surroundingBars = Integer.parseInt(arg.substring("--surroundingBars=".length()));
}
}
}
return new Config(dataset, outDir, fileName, display, save, lookback, surroundingBars);
}
}
private static void requireTrue(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private static void requireFalse(boolean condition, String message) {
requireTrue(!condition, message);
}
}
@@ -0,0 +1,844 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import java.math.RoundingMode;
import java.math.BigDecimal;
import java.io.IOException;
import java.util.Optional;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.indicators.elliott.ElliottInvalidationIndicator;
import org.ta4j.core.indicators.elliott.ElliottConfluenceIndicator;
import org.ta4j.core.indicators.elliott.ElliottChannelIndicator;
import org.ta4j.core.indicators.elliott.ElliottRatio.RatioType;
import org.ta4j.core.indicators.elliott.ElliottPhaseIndicator;
import org.ta4j.core.indicators.elliott.ElliottRatioIndicator;
import org.ta4j.core.indicators.elliott.ElliottSwingMetadata;
import org.ta4j.core.indicators.elliott.ElliottScenarioSet;
import org.ta4j.core.indicators.elliott.ElliottConfidence;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottChannel;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottRatio;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ScenarioType;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.walkforward.ElliottWaveWalkForwardProfiles;
import org.ta4j.core.num.Num;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.charting.builder.ChartPlan;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.ExclusionStrategy;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.JsonToken;
import com.google.gson.FieldAttributes;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.Gson;
/**
* Domain model capturing all Elliott Wave analysis results currently logged via
* {@link ElliottWaveIndicatorSuiteDemo#logBaseCaseScenario(ElliottScenario)}
* and {@link ElliottWaveIndicatorSuiteDemo#logAlternativeScenarios(List)}.
* <p>
* This class provides structured access to analysis results including swing
* snapshots, phase information, ratio and channel data, confluence scores,
* scenario summaries, trend bias, and detailed base case and alternative
* scenario information.
* <p>
* The class contains pre-rendered chart images (PNG format, base64-encoded) for
* all scenarios, allowing charts to be viewed without requiring external data
* or chart generation libraries. Charts are embedded as base64-encoded PNG byte
* arrays, making them directly viewable in browsers, reports, or any system
* that supports PNG images.
* <p>
* The class is serializable to JSON using Gson, providing a structured
* representation suitable for storage, transmission, or further processing.
*
* @see ElliottWaveIndicatorSuiteDemo
* @since 0.22.4
*/
public record ElliottWaveAnalysisReport(ElliottDegree degree, int endIndex, SwingSnapshot swingSnapshot,
LatestAnalysis latestAnalysis, ScenarioSummary scenarioSummary, ElliottTrendBias trendBias,
ProbabilityCalibration probabilityCalibration, OutlookGate outlookGate, BaseCaseScenario baseCase,
List<AlternativeScenario> alternatives, String baseCaseChartImage, List<String> alternativeChartImages) {
private static final Logger LOG = LogManager.getLogger(ElliottWaveAnalysisReport.class);
private static final double SCENARIO_TYPE_OVERLAP_WEIGHT = 0.3;
private static final double CONSENSUS_ADJUSTMENT_WEIGHT = 0.4;
private static final double DIRECTION_OVERLAP_WEIGHT = 0.2;
private static final double PHASE_OVERLAP_WEIGHT = 0.5;
private static final double MIN_CONFIDENCE_CONTRAST_EXPONENT = 1.5;
private static final double MAX_CONFIDENCE_CONTRAST_EXPONENT = 6.0;
private static final double CONFIDENCE_SPREAD_TARGET = 0.25;
private static final String CALIBRATION_METHOD = "centered_shrinkage_renormalized";
private static final String CALIBRATION_PROFILE = "wf-baseline-minute-f2-h2l2-max25-sw0__k1-200-65-320";
private static final double CALIBRATION_BASE_SHRINK_FACTOR = 0.72;
private static final double CALIBRATION_STRONG_CONSENSUS_BONUS = 0.08;
private static final double CALIBRATION_DIRECTIONAL_CONSENSUS_BONUS = 0.06;
private static final double CALIBRATION_WEAK_TREND_PENALTY = 0.06;
private static final double CALIBRATION_MIN_SHRINK_FACTOR = 0.45;
private static final double CALIBRATION_MAX_SHRINK_FACTOR = 0.95;
private static final double OUTLOOK_MIN_TOP_PROBABILITY = 0.30;
private static final double OUTLOOK_MIN_TOP_TWO_SPREAD = 0.03;
private static final double OUTLOOK_MIN_TOP_THREE_SPREAD = 0.08;
private static final double OUTLOOK_MIN_TREND_STRENGTH = 0.15;
private static final double CALIBRATION_EPSILON = 1.0e-12;
private static final TypeAdapter<Double> NULLING_DOUBLE_ADAPTER = new TypeAdapter<>() {
@Override
public void write(JsonWriter out, Double value) throws IOException {
if (value == null || value.isNaN() || value.isInfinite()) {
out.nullValue();
return;
}
out.value(value);
}
@Override
public Double read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
return in.nextDouble();
}
};
/**
* Creates an analysis result from the current analysis state.
*
* @param degree the Elliott wave degree used
* @param swingMetadata swing metadata snapshot
* @param phaseIndicator phase indicator (for latest phase and
* confirmation flags)
* @param ratioIndicator ratio indicator (for latest ratio)
* @param channelIndicator channel indicator (for latest channel)
* @param confluenceIndicator confluence indicator (for latest confluence)
* @param invalidationIndicator invalidation indicator (for latest invalidation)
* @param scenarioSet scenario set (for scenario summary and
* scenarios)
* @param endIndex index at which to evaluate indicators
* @param baseCaseChartPlan chart plan for base case scenario (optional)
* @param alternativeChartPlans chart plans for alternative scenarios
* @return analysis result capturing all logged data and chart images
*/
public static ElliottWaveAnalysisReport from(ElliottDegree degree, ElliottSwingMetadata swingMetadata,
ElliottPhaseIndicator phaseIndicator, ElliottRatioIndicator ratioIndicator,
ElliottChannelIndicator channelIndicator, ElliottConfluenceIndicator confluenceIndicator,
ElliottInvalidationIndicator invalidationIndicator, ElliottScenarioSet scenarioSet, int endIndex,
Optional<ChartPlan> baseCaseChartPlan, List<ChartPlan> alternativeChartPlans) {
Objects.requireNonNull(degree, "degree");
Objects.requireNonNull(swingMetadata, "swingMetadata");
Objects.requireNonNull(phaseIndicator, "phaseIndicator");
Objects.requireNonNull(ratioIndicator, "ratioIndicator");
Objects.requireNonNull(channelIndicator, "channelIndicator");
Objects.requireNonNull(confluenceIndicator, "confluenceIndicator");
Objects.requireNonNull(invalidationIndicator, "invalidationIndicator");
Objects.requireNonNull(scenarioSet, "scenarioSet");
Objects.requireNonNull(baseCaseChartPlan, "baseCaseChartPlan");
Objects.requireNonNull(alternativeChartPlans, "alternativeChartPlans");
SwingSnapshot snapshot = SwingSnapshot.from(swingMetadata);
LatestAnalysis latest = LatestAnalysis.from(phaseIndicator, ratioIndicator, channelIndicator,
confluenceIndicator, invalidationIndicator, endIndex);
ScenarioSummary summary = ScenarioSummary.from(scenarioSet);
ElliottTrendBias trendBias = scenarioSet.trendBias();
Map<String, Double> scenarioProbabilities = computeScenarioProbabilities(scenarioSet);
CalibrationResult calibrationResult = calibrateScenarioProbabilities(scenarioProbabilities, summary, trendBias);
Map<String, Double> calibratedProbabilities = calibrationResult.calibratedProbabilities();
ProbabilityCalibration probabilityCalibration = calibrationResult.calibration();
OutlookGate outlookGate = OutlookGate.from(scenarioProbabilities, calibratedProbabilities, summary, trendBias);
BaseCaseScenario baseCase = scenarioSet.base()
.map(scenario -> BaseCaseScenario.from(scenario, scenarioProbabilities.getOrDefault(scenario.id(), 0.0),
calibratedProbabilities.getOrDefault(scenario.id(), 0.0)))
.orElse(null);
List<AlternativeScenario> alternatives = scenarioSet.alternatives()
.stream()
.map(scenario -> AlternativeScenario.from(scenario,
scenarioProbabilities.getOrDefault(scenario.id(), 0.0),
calibratedProbabilities.getOrDefault(scenario.id(), 0.0)))
.toList();
ChartWorkflow chartWorkflow = new ChartWorkflow();
String baseCaseChartImage = baseCaseChartPlan.map(plan -> encodeChartAsBase64(chartWorkflow, plan))
.orElse(null);
List<String> alternativeChartImages = alternativeChartPlans.stream()
.map(plan -> encodeChartAsBase64(chartWorkflow, plan))
.toList();
return new ElliottWaveAnalysisReport(degree, endIndex, snapshot, latest, summary, trendBias,
probabilityCalibration, outlookGate, baseCase, alternatives, baseCaseChartImage,
alternativeChartImages);
}
/**
* Serializes this analysis result to JSON using Gson.
* <p>
* By default, chart image data is excluded to keep the JSON size manageable.
* Use {@link #toJson(boolean)} with {@code true} to include chart images.
*
* @return JSON representation of the analysis result (without chart images)
*/
public String toJson() {
return toJson(false);
}
/**
* Serializes this analysis result to JSON using Gson.
*
* @param includeChartData if {@code true}, includes base64-encoded PNG chart
* images; if {@code false}, excludes chart images to
* reduce JSON size
* @return JSON representation of the analysis result
*/
public String toJson(boolean includeChartData) {
GsonBuilder builder = new GsonBuilder().setPrettyPrinting()
.serializeNulls()
.registerTypeAdapter(Double.class, NULLING_DOUBLE_ADAPTER)
.registerTypeAdapter(Double.TYPE, NULLING_DOUBLE_ADAPTER);
if (!includeChartData) {
builder.setExclusionStrategies(new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes f) {
return "baseCaseChartImage".equals(f.getName()) || "alternativeChartImages".equals(f.getName());
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return false;
}
});
}
Gson gson = builder.create();
return gson.toJson(this);
}
/**
* Swing snapshot data capturing the current state of detected swings.
*
* @param valid whether the snapshot contains valid prices for each swing
* @param swings number of swings represented
* @param high highest price touched by any swing
* @param low lowest price touched by any swing
*/
public record SwingSnapshot(boolean valid, int swings, double high, double low) {
static SwingSnapshot from(ElliottSwingMetadata metadata) {
return new SwingSnapshot(metadata.isValid(), metadata.size(), safeDoubleValue(metadata.highestPrice()),
safeDoubleValue(metadata.lowestPrice()));
}
}
/**
* Latest analysis results at the evaluation index.
*
* @param phase current Elliott phase
* @param impulseConfirmed whether impulse waves are confirmed
* @param correctiveConfirmed whether corrective waves are confirmed
* @param ratioType type of ratio relationship (retracement/extension)
* @param ratioValue measured ratio value
* @param channel channel data (null if invalid)
* @param confluenceScore confluence score
* @param confluent whether confluence threshold is met
* @param invalidation whether current price action invalidates the wave
* count
*/
public record LatestAnalysis(ElliottPhase phase, boolean impulseConfirmed, boolean correctiveConfirmed,
RatioType ratioType, double ratioValue, ChannelData channel, double confluenceScore, boolean confluent,
boolean invalidation) {
static LatestAnalysis from(ElliottPhaseIndicator phaseIndicator, ElliottRatioIndicator ratioIndicator,
ElliottChannelIndicator channelIndicator, ElliottConfluenceIndicator confluenceIndicator,
ElliottInvalidationIndicator invalidationIndicator, int endIndex) {
ElliottPhase phase = phaseIndicator.getValue(endIndex);
boolean impulseConfirmed = phaseIndicator.isImpulseConfirmed(endIndex);
boolean correctiveConfirmed = phaseIndicator.isCorrectiveConfirmed(endIndex);
ElliottRatio ratio = ratioIndicator.getValue(endIndex);
RatioType ratioType = ratio != null ? ratio.type() : RatioType.NONE;
double ratioValue = ratio != null ? safeDoubleValue(ratio.value()) : Double.NaN;
ElliottChannel channel = channelIndicator.getValue(endIndex);
ChannelData channelData = channel != null && channel.isValid() ? ChannelData.from(channel) : null;
Num confluenceNum = confluenceIndicator.getValue(endIndex);
double confluenceScore = safeDoubleValue(confluenceNum);
boolean confluent = confluenceIndicator.isConfluent(endIndex);
boolean invalidation = invalidationIndicator.getValue(endIndex);
return new LatestAnalysis(phase, impulseConfirmed, correctiveConfirmed, ratioType, ratioValue, channelData,
confluenceScore, confluent, invalidation);
}
}
/**
* Channel boundary data.
*
* @param valid whether the channel is valid
* @param upper expected resistance boundary
* @param lower expected support boundary
* @param median arithmetic midline between upper and lower bounds
*/
public record ChannelData(boolean valid, double upper, double lower, double median) {
static ChannelData from(ElliottChannel channel) {
return new ChannelData(channel.isValid(), safeDoubleValue(channel.upper()),
safeDoubleValue(channel.lower()), safeDoubleValue(channel.median()));
}
}
/**
* Scenario summary across all scenarios.
*
* @param summary human-readable summary describing scenario
* distribution
* @param strongConsensus whether there is strong consensus (single
* high-confidence scenario or large spread)
* @param consensusPhase agreed-upon phase if all high-confidence scenarios
* match, otherwise NONE
*/
public record ScenarioSummary(String summary, boolean strongConsensus, ElliottPhase consensusPhase) {
static ScenarioSummary from(ElliottScenarioSet scenarioSet) {
return new ScenarioSummary(scenarioSet.summary(), scenarioSet.hasStrongConsensus(),
scenarioSet.consensus());
}
}
/**
* Metadata describing how probabilities were calibrated for this analysis run.
*
* @param profile calibration profile id sourced from walk-forward tuning
* @param method calibration transform identifier
* @param shrinkFactor shrink factor applied to centered scenario probabilities
*/
public record ProbabilityCalibration(String profile, String method, double shrinkFactor) {
static ProbabilityCalibration from(double shrinkFactor) {
final String profile = CALIBRATION_PROFILE + "|cfg="
+ ElliottWaveWalkForwardProfiles.baselineConfig().configHash();
return new ProbabilityCalibration(profile, CALIBRATION_METHOD, shrinkFactor);
}
}
/**
* Directional outlook gate status.
*
* <p>
* The gate blocks directional publication when scenario probabilities are too
* crowded or consensus signals are weak.
*
* @param eligible whether directional outlook can be published
* @param outlookLabel directional label or {@code NEUTRAL}
* @param reason concise gate decision explanation
* @param topScenarioProbability top raw scenario probability
* @param calibratedTopProbability top calibrated scenario probability
* @param topTwoSpread spread between top-1 and top-2 raw
* probabilities
* @param topThreeSpread spread between top-1 and top-3 raw
* probabilities
* @param strongConsensus whether strong scenario consensus is present
* @param directionalConsensus whether directional consensus is present
* @param trendStrength trend bias strength (0.0-1.0)
*/
public record OutlookGate(boolean eligible, String outlookLabel, String reason, double topScenarioProbability,
double calibratedTopProbability, double topTwoSpread, double topThreeSpread, boolean strongConsensus,
boolean directionalConsensus, double trendStrength) {
static OutlookGate from(Map<String, Double> rawProbabilities, Map<String, Double> calibratedProbabilities,
ScenarioSummary summary, ElliottTrendBias trendBias) {
if (rawProbabilities == null || rawProbabilities.isEmpty()) {
return new OutlookGate(false, "NEUTRAL", "No scenarios available", Double.NaN, Double.NaN, Double.NaN,
Double.NaN, false, false, Double.NaN);
}
final List<Double> rawSorted = rawProbabilities.values()
.stream()
.filter(Double::isFinite)
.sorted((a, b) -> Double.compare(b, a))
.toList();
final List<Double> calibratedSorted = calibratedProbabilities.values()
.stream()
.filter(Double::isFinite)
.sorted((a, b) -> Double.compare(b, a))
.toList();
if (rawSorted.isEmpty()) {
return new OutlookGate(false, "NEUTRAL", "Scenario probabilities were not finite", Double.NaN,
Double.NaN, Double.NaN, Double.NaN, false, false, Double.NaN);
}
final double top = rawSorted.get(0);
final double topTwoSpread = rawSorted.size() > 1 ? top - rawSorted.get(1) : top;
final double topThreeSpread = rawSorted.size() > 2 ? top - rawSorted.get(2) : topTwoSpread;
final double calibratedTop = calibratedSorted.isEmpty() ? Double.NaN : calibratedSorted.get(0);
final boolean strongConsensus = summary != null && summary.strongConsensus();
final boolean directionalConsensus = trendBias != null && trendBias.consensus();
final boolean knownTrend = trendBias != null && !trendBias.isUnknown() && !trendBias.isNeutral();
final double trendStrength = trendBias == null ? Double.NaN : trendBias.strength();
final boolean strongTrend = Double.isFinite(trendStrength) && trendStrength >= OUTLOOK_MIN_TREND_STRENGTH;
final boolean topProbabilityPass = top >= OUTLOOK_MIN_TOP_PROBABILITY;
final boolean spreadPass = topTwoSpread >= OUTLOOK_MIN_TOP_TWO_SPREAD
&& topThreeSpread >= OUTLOOK_MIN_TOP_THREE_SPREAD;
final boolean eligible = strongConsensus && directionalConsensus && knownTrend && strongTrend
&& topProbabilityPass && spreadPass;
final String label = eligible ? trendBias.direction().name() : "NEUTRAL";
final String reason;
if (eligible) {
reason = "Directional outlook passed consensus and spread gates";
} else if (!strongConsensus) {
reason = "Strong scenario consensus not established";
} else if (!directionalConsensus || !knownTrend) {
reason = "Directional consensus is weak or trend is neutral";
} else if (!strongTrend) {
reason = "Trend strength is below baseline threshold";
} else if (!topProbabilityPass) {
reason = "Top scenario probability is below publication threshold";
} else {
reason = "Top scenarios are too close; low-conviction outlook";
}
return new OutlookGate(eligible, label, reason, top, calibratedTop, topTwoSpread, topThreeSpread,
strongConsensus, directionalConsensus, trendStrength);
}
}
/**
* Internal result container for calibrated probabilities and calibration
* metadata.
*/
private record CalibrationResult(Map<String, Double> calibratedProbabilities, ProbabilityCalibration calibration) {
}
/**
* Base case scenario details.
*
* @param currentPhase the phase this scenario assigns to current price
* action
* @param type pattern type classification
* @param overallConfidence overall confidence percentage (0-100)
* @param scenarioProbability raw scenario probability ratio (0.0-1.0)
* @param calibratedProbability walk-forward calibrated scenario probability
* ratio (0.0-1.0)
* @param confidenceLevel confidence level (HIGH, MEDIUM, or LOW)
* @param fibonacciScore Fibonacci proximity score as percentage (0-100)
* @param timeScore time proportion score as percentage (0-100)
* @param alternationScore alternation quality score as percentage (0-100)
* @param channelScore channel adherence score as percentage (0-100)
* @param completenessScore structure completeness score as percentage
* (0-100)
* @param primaryReason human-readable description of dominant factor
* @param weakestFactor description of the weakest scoring factor
* @param direction direction (BULLISH or BEARISH)
* @param invalidationPrice price level that would invalidate this count
* @param primaryTarget primary Fibonacci projection target
* @param swings swing sequence for building wave labels
*/
public record BaseCaseScenario(ElliottPhase currentPhase, ScenarioType type, double overallConfidence,
@JsonAdapter(ScenarioProbabilityAdapter.class) double scenarioProbability,
@JsonAdapter(ScenarioProbabilityAdapter.class) double calibratedProbability, String confidenceLevel,
double fibonacciScore, double timeScore, double alternationScore, double channelScore,
double completenessScore, String primaryReason, String weakestFactor, String direction,
double invalidationPrice, double primaryTarget, List<SwingData> swings) {
static BaseCaseScenario from(ElliottScenario scenario, double scenarioProbability,
double calibratedProbability) {
ElliottConfidence confidence = scenario.confidence();
double overallConfidence = confidence.asPercentage();
String confidenceLevel = confidence.isHighConfidence() ? "HIGH"
: confidence.isLowConfidence() ? "LOW" : "MEDIUM";
double fibonacciScore = safeDoubleValue(confidence.fibonacciScore()) * 100.0;
double timeScore = safeDoubleValue(confidence.timeProportionScore()) * 100.0;
double alternationScore = safeDoubleValue(confidence.alternationScore()) * 100.0;
double channelScore = safeDoubleValue(confidence.channelScore()) * 100.0;
double completenessScore = safeDoubleValue(confidence.completenessScore()) * 100.0;
String direction = scenario.isBullish() ? "BULLISH" : "BEARISH";
double invalidationPrice = safeDoubleValue(scenario.invalidationPrice());
double primaryTarget = safeDoubleValue(scenario.primaryTarget());
List<SwingData> swings = scenario.swings().stream().map(SwingData::from).toList();
return new BaseCaseScenario(scenario.currentPhase(), scenario.type(), overallConfidence,
scenarioProbability, calibratedProbability, confidenceLevel, fibonacciScore, timeScore,
alternationScore, channelScore, completenessScore, confidence.primaryReason(),
confidence.weakestFactor(), direction, invalidationPrice, primaryTarget, swings);
}
}
/**
* Alternative scenario details.
*
* @param currentPhase the phase this scenario assigns to current price
* action
* @param type pattern type classification
* @param confidencePercent overall confidence percentage (0-100)
* @param scenarioProbability raw scenario probability ratio (0.0-1.0)
* @param calibratedProbability walk-forward calibrated scenario probability
* ratio (0.0-1.0)
* @param swings swing sequence for building wave labels
*/
public record AlternativeScenario(ElliottPhase currentPhase, ScenarioType type, double confidencePercent,
@JsonAdapter(ScenarioProbabilityAdapter.class) double scenarioProbability,
@JsonAdapter(ScenarioProbabilityAdapter.class) double calibratedProbability, List<SwingData> swings) {
static AlternativeScenario from(ElliottScenario scenario, double scenarioProbability,
double calibratedProbability) {
List<SwingData> swings = scenario.swings().stream().map(SwingData::from).toList();
return new AlternativeScenario(scenario.currentPhase(), scenario.type(),
scenario.confidence().asPercentage(), scenarioProbability, calibratedProbability, swings);
}
}
/**
* Swing data for building wave labels.
*
* @param fromIndex starting bar index
* @param toIndex ending bar index
* @param fromPrice starting price
* @param toPrice ending price
* @param isRising whether the swing is rising
*/
public record SwingData(int fromIndex, int toIndex, double fromPrice, double toPrice, boolean isRising) {
static SwingData from(ElliottSwing swing) {
return new SwingData(swing.fromIndex(), swing.toIndex(), safeDoubleValue(swing.fromPrice()),
safeDoubleValue(swing.toPrice()), swing.isRising());
}
}
/**
* Computes scenario probabilities by applying adaptive contrast to confidence
* scores, then tilting toward overlapping consensus factors (phase, type, and
* direction).
*
* @param scenarioSet scenario set to evaluate
* @return scenario probability ratios keyed by scenario id
*/
static Map<String, Double> computeScenarioProbabilities(ElliottScenarioSet scenarioSet) {
Objects.requireNonNull(scenarioSet, "scenarioSet");
List<ElliottScenario> scenarios = scenarioSet.all();
if (scenarios.isEmpty()) {
return Map.of();
}
EnumMap<ElliottPhase, Integer> phaseCounts = new EnumMap<>(ElliottPhase.class);
EnumMap<ScenarioType, Integer> typeCounts = new EnumMap<>(ScenarioType.class);
double minConfidence = Double.POSITIVE_INFINITY;
double maxConfidence = Double.NEGATIVE_INFINITY;
int scenarioCount = scenarios.size();
double[] confidenceScores = new double[scenarioCount];
int knownPhaseCount = 0;
int knownTypeCount = 0;
int bullishCount = 0;
int bearishCount = 0;
int knownDirectionCount = 0;
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
ElliottPhase phase = scenario.currentPhase();
if (phase != ElliottPhase.NONE) {
phaseCounts.merge(phase, 1, Integer::sum);
knownPhaseCount++;
}
ScenarioType type = scenario.type();
if (type != ScenarioType.UNKNOWN) {
typeCounts.merge(type, 1, Integer::sum);
knownTypeCount++;
}
if (scenario.hasKnownDirection()) {
knownDirectionCount++;
if (scenario.isBullish()) {
bullishCount++;
} else {
bearishCount++;
}
}
double confidence = safeScoreValue(scenario.confidenceScore());
confidenceScores[i] = confidence;
minConfidence = Math.min(minConfidence, confidence);
maxConfidence = Math.max(maxConfidence, confidence);
}
double[] baseWeights = new double[scenarioCount];
double totalConfidence = 0.0;
double contrastExponent = confidenceContrastExponent(minConfidence, maxConfidence, scenarioCount);
for (int i = 0; i < scenarioCount; i++) {
double confidence = confidenceScores[i];
double contrasted = applyConfidenceContrast(confidence, contrastExponent);
baseWeights[i] = contrasted;
totalConfidence += contrasted;
}
if (totalConfidence > 0.0) {
for (int i = 0; i < scenarioCount; i++) {
baseWeights[i] /= totalConfidence;
}
} else {
double equalWeight = 1.0 / scenarioCount;
for (int i = 0; i < scenarioCount; i++) {
baseWeights[i] = equalWeight;
}
}
double[] overlapScores = new double[scenarioCount];
double overlapTotal = 0.0;
int overlapCount = 0;
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
double overlapScore = overlapScoreForScenario(scenario, phaseCounts, knownPhaseCount, typeCounts,
knownTypeCount, bullishCount, bearishCount, knownDirectionCount);
overlapScores[i] = overlapScore;
if (overlapScore > 0.0) {
overlapTotal += overlapScore;
overlapCount++;
}
}
double averageOverlap = overlapCount > 0 ? overlapTotal / overlapCount : 0.0;
double[] adjustedWeights = new double[scenarioCount];
double adjustedTotal = 0.0;
for (int i = 0; i < scenarioCount; i++) {
double overlapScore = overlapScores[i];
double multiplier = overlapScore > 0.0
? 1.0 + (CONSENSUS_ADJUSTMENT_WEIGHT * (overlapScore - averageOverlap))
: 1.0;
double adjustedWeight = baseWeights[i] * multiplier;
adjustedWeights[i] = adjustedWeight;
adjustedTotal += adjustedWeight;
}
Map<String, Double> probabilities = new HashMap<>();
if (adjustedTotal <= 0.0) {
double fallback = 1.0 / scenarioCount;
for (ElliottScenario scenario : scenarios) {
probabilities.put(scenario.id(), fallback);
}
return Map.copyOf(probabilities);
}
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
double probability = adjustedWeights[i] / adjustedTotal;
probabilities.put(scenario.id(), probability);
}
return Map.copyOf(probabilities);
}
/**
* Applies walk-forward-informed probability calibration using centered
* shrinkage followed by renormalization.
*
* <p>
* The transform shrinks probabilities toward the uniform prior to reduce
* over-confident tails while preserving ordering signals from the raw scenario
* model.
*/
private static CalibrationResult calibrateScenarioProbabilities(Map<String, Double> rawProbabilities,
ScenarioSummary summary, ElliottTrendBias trendBias) {
if (rawProbabilities == null || rawProbabilities.isEmpty()) {
ProbabilityCalibration calibration = ProbabilityCalibration.from(CALIBRATION_BASE_SHRINK_FACTOR);
return new CalibrationResult(Map.of(), calibration);
}
Map<String, Double> normalizedRaw = normalizeProbabilityMap(rawProbabilities);
if (normalizedRaw.isEmpty()) {
ProbabilityCalibration calibration = ProbabilityCalibration.from(CALIBRATION_BASE_SHRINK_FACTOR);
return new CalibrationResult(Map.of(), calibration);
}
double shrinkFactor = CALIBRATION_BASE_SHRINK_FACTOR;
if (summary != null && summary.strongConsensus()) {
shrinkFactor += CALIBRATION_STRONG_CONSENSUS_BONUS;
}
if (trendBias != null && trendBias.consensus()) {
shrinkFactor += CALIBRATION_DIRECTIONAL_CONSENSUS_BONUS;
}
if (trendBias == null || trendBias.isUnknown() || trendBias.isNeutral()
|| !Double.isFinite(trendBias.strength()) || trendBias.strength() < OUTLOOK_MIN_TREND_STRENGTH) {
shrinkFactor -= CALIBRATION_WEAK_TREND_PENALTY;
}
shrinkFactor = clamp(shrinkFactor, CALIBRATION_MIN_SHRINK_FACTOR, CALIBRATION_MAX_SHRINK_FACTOR);
int scenarioCount = normalizedRaw.size();
double uniformPrior = 1.0 / scenarioCount;
Map<String, Double> centered = new LinkedHashMap<>();
for (Map.Entry<String, Double> entry : normalizedRaw.entrySet()) {
double raw = entry.getValue();
double calibrated = uniformPrior + (shrinkFactor * (raw - uniformPrior));
centered.put(entry.getKey(), Math.max(CALIBRATION_EPSILON, calibrated));
}
Map<String, Double> calibratedProbabilities = normalizeProbabilityMap(centered);
ProbabilityCalibration calibration = ProbabilityCalibration.from(shrinkFactor);
return new CalibrationResult(calibratedProbabilities, calibration);
}
private static double overlapScoreForScenario(ElliottScenario scenario, EnumMap<ElliottPhase, Integer> phaseCounts,
int knownPhaseCount, EnumMap<ScenarioType, Integer> typeCounts, int knownTypeCount, int bullishCount,
int bearishCount, int knownDirectionCount) {
double weightedSum = 0.0;
double weightTotal = 0.0;
ElliottPhase phase = scenario.currentPhase();
if (phase != ElliottPhase.NONE && knownPhaseCount > 0) {
weightedSum += PHASE_OVERLAP_WEIGHT * (phaseCounts.getOrDefault(phase, 0) / (double) knownPhaseCount);
weightTotal += PHASE_OVERLAP_WEIGHT;
}
ScenarioType type = scenario.type();
if (type != ScenarioType.UNKNOWN && knownTypeCount > 0) {
weightedSum += SCENARIO_TYPE_OVERLAP_WEIGHT * (typeCounts.getOrDefault(type, 0) / (double) knownTypeCount);
weightTotal += SCENARIO_TYPE_OVERLAP_WEIGHT;
}
if (scenario.hasKnownDirection() && knownDirectionCount > 0) {
double directionOverlap = scenario.isBullish() ? (double) bullishCount / knownDirectionCount
: (double) bearishCount / knownDirectionCount;
weightedSum += DIRECTION_OVERLAP_WEIGHT * directionOverlap;
weightTotal += DIRECTION_OVERLAP_WEIGHT;
}
if (weightTotal <= 0.0) {
return 0.0;
}
return weightedSum / weightTotal;
}
private static double confidenceContrastExponent(double minConfidence, double maxConfidence, int scenarioCount) {
if (scenarioCount <= 1) {
return 1.0;
}
double spread = maxConfidence - minConfidence;
if (spread <= 0.0) {
return MAX_CONFIDENCE_CONTRAST_EXPONENT;
}
double normalizedSpread = Math.min(1.0, spread / CONFIDENCE_SPREAD_TARGET);
return MIN_CONFIDENCE_CONTRAST_EXPONENT
+ (MAX_CONFIDENCE_CONTRAST_EXPONENT - MIN_CONFIDENCE_CONTRAST_EXPONENT) * (1.0 - normalizedSpread);
}
private static double applyConfidenceContrast(double confidence, double exponent) {
if (confidence <= 0.0) {
return 0.0;
}
if (exponent <= 1.0) {
return confidence;
}
return Math.pow(confidence, exponent);
}
private static Map<String, Double> normalizeProbabilityMap(Map<String, Double> probabilities) {
if (probabilities == null || probabilities.isEmpty()) {
return Map.of();
}
Map<String, Double> finitePositive = new LinkedHashMap<>();
double total = 0.0;
for (Map.Entry<String, Double> entry : probabilities.entrySet()) {
if (entry.getKey() == null) {
continue;
}
Double raw = entry.getValue();
if (raw == null || !Double.isFinite(raw)) {
continue;
}
double sanitized = Math.max(0.0, raw.doubleValue());
if (sanitized <= 0.0) {
continue;
}
finitePositive.put(entry.getKey(), sanitized);
total += sanitized;
}
if (finitePositive.isEmpty() || total <= CALIBRATION_EPSILON) {
return Map.of();
}
Map<String, Double> normalized = new LinkedHashMap<>();
for (Map.Entry<String, Double> entry : finitePositive.entrySet()) {
normalized.put(entry.getKey(), entry.getValue() / total);
}
return Map.copyOf(normalized);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
/**
* Encodes a chart plan as a base64-encoded PNG image string.
*
* @param chartWorkflow the chart workflow for rendering
* @param chartPlan the chart plan to encode
* @return base64-encoded PNG image string, or null if encoding fails
*/
private static String encodeChartAsBase64(ChartWorkflow chartWorkflow, ChartPlan chartPlan) {
try {
byte[] pngBytes = chartWorkflow.getChartAsByteArray(chartWorkflow.render(chartPlan));
return Base64.getEncoder().encodeToString(pngBytes);
} catch (Exception ex) {
LOG.warn("Chart encoding failed for chart plan {}: {}", chartPlan, ex.getMessage(), ex);
return null;
}
}
/**
* Safely converts a Num to a confidence score, treating invalid values as zero.
*
* @param num the numeric value to convert
* @return double value, or 0.0 if null or invalid
*/
private static double safeScoreValue(Num num) {
if (num == null || !Num.isValid(num)) {
return 0.0;
}
return num.doubleValue();
}
private static double roundScenarioProbability(double value) {
return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue();
}
/**
* Safely converts a Num to double, handling null and NaN cases.
*
* @param num the numeric value to convert
* @return double value, or NaN if null or invalid
*/
private static double safeDoubleValue(Num num) {
if (num == null || !Num.isValid(num)) {
return Double.NaN;
}
return num.doubleValue();
}
private static final class ScenarioProbabilityAdapter extends TypeAdapter<Double> {
@Override
public void write(JsonWriter out, Double value) throws IOException {
if (value == null || value.isNaN() || value.isInfinite()) {
out.nullValue();
return;
}
out.value(roundScenarioProbability(value));
}
@Override
public Double read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
return in.nextDouble();
}
}
}
@@ -0,0 +1,254 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveBtcMacroCycleDemo;
/**
* Consolidated preset launcher for Elliott Wave demos.
*
* <p>
* This entry point replaces multiple thin wrappers by supporting:
* <ul>
* <li>Ossified presets mapped to bundled datasets</li>
* <li>Live runs where users can specify any ticker</li>
* </ul>
*
* <p>
* Usage:
* <ul>
* <li>{@code ossified <btc|eth|sp500>}</li>
* <li>{@code live <Coinbase|YahooFinance> <ticker> [barDuration] [lookbackDays] [degree]}</li>
* </ul>
*
* <p>
* Examples:
* <ul>
* <li>{@code ossified btc}</li>
* <li>{@code live Coinbase BTC-USD PT1D 1825}</li>
* <li>{@code live YahooFinance AAPL PT1D 1460 PRIMARY}</li>
* </ul>
*
* @since 0.22.4
*/
public class ElliottWavePresetDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWavePresetDemo.class);
private static final String DEFAULT_BAR_DURATION = "PT1D";
private static final long DEFAULT_LOOKBACK_DAYS = 1825L;
private static final Path DEFAULT_BTC_MACRO_CHART_DIRECTORY = Path.of("temp", "charts");
/**
* Runs a preset Elliott Wave demo.
*
* @param args command-line arguments; see class-level usage documentation
*/
public static void main(String[] args) {
if (args == null || args.length == 0) {
logUsage();
return;
}
final String mode = normalize(args[0]);
switch (mode) {
case "ossified":
runOssified(args);
return;
case "live":
runLive(args);
return;
default:
LOG.error("Unknown mode '{}'. Expected 'ossified' or 'live'.", args[0]);
logUsage();
}
}
private static void runOssified(String[] args) {
if (args.length < 2) {
LOG.error("Missing ossified preset. Expected one of: btc, eth, sp500");
logUsage();
return;
}
Optional<OssifiedPreset> preset = OssifiedPreset.fromToken(args[1]);
if (preset.isEmpty()) {
LOG.error("Unknown ossified preset '{}'. Expected one of: btc, eth, sp500", args[1]);
logUsage();
return;
}
OssifiedPreset selected = preset.orElseThrow();
ElliottWaveIndicatorSuiteDemo.runOssifiedResource(ElliottWavePresetDemo.class, selected.resource(),
selected.seriesName(), selected.degreeOverride().orElse(null));
}
private static void runLive(String[] args) {
if (args.length < 3) {
LOG.error("Live mode expects at least dataSource and ticker");
logUsage();
return;
}
final String dataSource = Objects.requireNonNull(args[1], "dataSource");
final String ticker = Objects.requireNonNull(args[2], "ticker");
final String barDuration = args.length > 3 ? args[3] : DEFAULT_BAR_DURATION;
long lookbackDays = DEFAULT_LOOKBACK_DAYS;
if (args.length > 4) {
try {
lookbackDays = Long.parseLong(args[4]);
} catch (NumberFormatException ex) {
LOG.error("Invalid lookbackDays '{}': expected a positive integer", args[4]);
return;
}
}
if (lookbackDays <= 0) {
LOG.error("Invalid lookbackDays '{}': must be > 0", lookbackDays);
return;
}
ElliottDegree degree = null;
if (args.length > 5) {
try {
degree = ElliottDegree.valueOf(args[5].trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
LOG.error("Invalid degree '{}'", args[5]);
return;
}
}
if (shouldUseBtcMacroPreset(ticker, barDuration)) {
if (degree != null) {
LOG.info(
"Ignoring explicit degree '{}' for BTC daily macro preset; the validated macro profile selects its own structural interpretation.",
degree);
}
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofDays(lookbackDays));
Duration parsedDuration = parseBarDuration(barDuration);
BarSeries series = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(dataSource, ticker,
parsedDuration, startTime, endTime);
if (series == null || series.isEmpty()) {
LOG.error("Unable to load live BTC series for macro preset from {} {} {}", dataSource, ticker,
barDuration);
return;
}
ElliottWaveBtcMacroCycleDemo.runLivePreset(series, DEFAULT_BTC_MACRO_CHART_DIRECTORY);
return;
}
String[] suiteArgs = buildLiveSuiteArgs(dataSource, ticker, barDuration, lookbackDays, Instant.now(), degree);
ElliottWaveIndicatorSuiteDemo.main(suiteArgs);
}
static boolean shouldUseBtcMacroPreset(String ticker, String barDuration) {
if (ticker == null || barDuration == null) {
return false;
}
String normalizedTicker = ticker.trim().toUpperCase(Locale.ROOT);
String normalizedDuration = barDuration.trim().toUpperCase(Locale.ROOT);
return "BTC-USD".equals(normalizedTicker)
&& ("PT1D".equals(normalizedDuration) || "PT24H".equals(normalizedDuration));
}
static String[] buildLiveSuiteArgs(String dataSource, String ticker, String barDuration, long lookbackDays,
Instant endTime, ElliottDegree degree) {
Objects.requireNonNull(dataSource, "dataSource");
Objects.requireNonNull(ticker, "ticker");
Objects.requireNonNull(barDuration, "barDuration");
Objects.requireNonNull(endTime, "endTime");
if (lookbackDays <= 0) {
throw new IllegalArgumentException("lookbackDays must be > 0");
}
Instant startTime = endTime.minus(Duration.ofDays(lookbackDays));
if (degree == null) {
return new String[] { dataSource, ticker, barDuration, Long.toString(startTime.getEpochSecond()),
Long.toString(endTime.getEpochSecond()) };
}
return new String[] { dataSource, ticker, barDuration, degree.name(), Long.toString(startTime.getEpochSecond()),
Long.toString(endTime.getEpochSecond()) };
}
private static void logUsage() {
LOG.info("Usage:");
LOG.info(" ossified <btc|eth|sp500>");
LOG.info(" live <Coinbase|YahooFinance> <ticker> [barDuration] [lookbackDays] [degree]");
}
private static Duration parseBarDuration(String barDuration) {
Objects.requireNonNull(barDuration, "barDuration");
String normalized = barDuration.trim().toUpperCase(Locale.ROOT);
if (normalized.startsWith("PT") && normalized.endsWith("D")) {
final String dayString = normalized.substring(2, normalized.length() - 1);
try {
final int days = Integer.parseInt(dayString);
return Duration.ofHours(days * 24L);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid day count in barDuration: " + barDuration, e);
}
}
return Duration.parse(normalized);
}
private static String normalize(String value) {
if (value == null) {
return "";
}
return value.trim().toLowerCase(Locale.ROOT);
}
private enum OssifiedPreset {
BTC("Coinbase-BTC-USD-PT1D-20230616_20231011.json", "BTC-USD_PT1D@Coinbase (ossified)", Optional.empty()),
ETH("Coinbase-ETH-USD-PT1D-20241105_20251020.json", "ETH-USD_PT1D@Coinbase (ossified)", Optional.empty()),
SP500("YahooFinance-SP500-PT1D-20230616_20231011.json", "^GSPC_PT1D@YahooFinance (ossified)", Optional.empty());
private final String resource;
private final String seriesName;
private final Optional<ElliottDegree> degreeOverride;
OssifiedPreset(String resource, String seriesName, Optional<ElliottDegree> degreeOverride) {
this.resource = resource;
this.seriesName = seriesName;
this.degreeOverride = degreeOverride;
}
static Optional<OssifiedPreset> fromToken(String token) {
if (token == null) {
return Optional.empty();
}
String normalized = token.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "btc" -> Optional.of(BTC);
case "eth" -> Optional.of(ETH);
case "sp500", "s&p500", "gspc", "^gspc" -> Optional.of(SP500);
default -> Optional.empty();
};
}
String resource() {
return resource;
}
String seriesName() {
return seriesName;
}
Optional<ElliottDegree> degreeOverride() {
return degreeOverride;
}
}
}
@@ -0,0 +1,418 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import com.google.gson.Gson;
/**
* Loads and resolves the BTC anchor registry used by the CF-17 anchor-aware
* calibration study.
*
* <p>
* The registry stores broad calendar windows plus provenance. Resolution is
* deterministic: each anchor window is collapsed to the highest high or lowest
* low inside the local ossified BTC dataset, then the resolved anchors are
* partitioned chronologically into validation and holdout segments.
*
* <p>
* These committed windows are the canonical BTC daily validation set for the
* CF-17 macro study.
* {@link ElliottWaveAnchorCalibrationHarness#defaultBitcoinAnchors(BarSeries)}
* preserves that contract by translating the distance from the resolved
* extremum to each window edge into {@code toleranceBefore} and
* {@code toleranceAfter}, so acceptable match windows stay pinned to the
* registry instead of drifting via runtime heuristics.
*
* @since 0.22.7
*/
final class ElliottWaveAnchorRegistry {
static final String DEFAULT_RESOURCE = "/ta4jexamples/analysis/elliottwave/backtest/BTC-anchor-registry-v2.json";
private static final Gson GSON = new Gson();
private final String registryId;
private final String datasetResource;
private final String provenance;
private final List<AnchorSpec> anchors;
private ElliottWaveAnchorRegistry(String registryId, String datasetResource, String provenance,
List<AnchorSpec> anchors) {
this.registryId = requireText(registryId, "registryId");
this.datasetResource = requireText(datasetResource, "datasetResource");
this.provenance = requireText(provenance, "provenance");
if (anchors == null || anchors.isEmpty()) {
throw new IllegalArgumentException("anchors must not be empty");
}
this.anchors = anchors.stream().sorted(Comparator.comparing(AnchorSpec::windowStart)).toList();
}
/**
* Loads the registry from the given classpath resource.
*
* @param resource classpath resource path
* @return parsed registry
* @since 0.22.7
*/
static ElliottWaveAnchorRegistry load(String resource) {
String normalized = resource != null && resource.startsWith("/") ? resource : "/" + resource;
try (InputStream stream = ElliottWaveAnchorRegistry.class.getResourceAsStream(normalized)) {
if (stream == null) {
throw new IllegalStateException("Missing anchor registry resource: " + normalized);
}
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
RegistryDocument document = GSON.fromJson(reader, RegistryDocument.class);
Objects.requireNonNull(document, "document");
List<RegistryAnchor> rawAnchors = document.anchors();
if (rawAnchors == null) {
throw new IllegalArgumentException("Anchor registry " + normalized + " is missing \"anchors\"");
}
List<AnchorSpec> anchorSpecs = new ArrayList<>(rawAnchors.size());
for (int index = 0; index < rawAnchors.size(); index++) {
RegistryAnchor anchor = rawAnchors.get(index);
if (anchor == null) {
throw new IllegalArgumentException(
"Anchor registry " + normalized + " contains null anchor at index " + index);
}
anchorSpecs.add(anchor.toSpec());
}
return new ElliottWaveAnchorRegistry(document.registryId(), document.datasetResource(),
document.provenance(), anchorSpecs);
}
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("Failed to load anchor registry: " + normalized, ex);
}
}
/**
* Resolves every stored anchor window against the supplied series and assigns
* the trailing anchors to holdout.
*
* <p>
* Resolution never expands or contracts the committed calendar windows. The
* JSON window bounds remain authoritative; the selected bar is simply the local
* extremum inside each stored range.
*
* @param series BTC series used for resolution
* @param holdoutCount trailing resolved anchors reserved for holdout
* @return resolved anchors in chronological order
* @since 0.22.7
*/
List<ResolvedAnchor> resolve(BarSeries series, int holdoutCount) {
Objects.requireNonNull(series, "series");
List<ResolvedAnchor> resolved = new ArrayList<>(anchors.size());
for (AnchorSpec anchor : anchors) {
resolved.add(resolve(series, anchor));
}
resolved.sort(Comparator.comparing(ResolvedAnchor::resolvedTime));
if (holdoutCount < 0 || holdoutCount > resolved.size()) {
throw new IllegalArgumentException("holdoutCount must be between 0 and " + resolved.size());
}
int validationCutoff = resolved.size() - holdoutCount;
List<ResolvedAnchor> partitioned = new ArrayList<>(resolved.size());
for (int i = 0; i < resolved.size(); i++) {
AnchorPartition partition = i < validationCutoff ? AnchorPartition.VALIDATION : AnchorPartition.HOLDOUT;
partitioned.add(resolved.get(i).withPartition(partition));
}
return List.copyOf(partitioned);
}
/**
* @return registry identifier
* @since 0.22.7
*/
String registryId() {
return registryId;
}
/**
* @return backing BTC dataset resource name
* @since 0.22.7
*/
String datasetResource() {
return datasetResource;
}
/**
* @return provenance summary
* @since 0.22.7
*/
String provenance() {
return provenance;
}
/**
* @return stored anchor specs
* @since 0.22.7
*/
List<AnchorSpec> anchors() {
return anchors;
}
private static ResolvedAnchor resolve(BarSeries series, AnchorSpec anchor) {
long midpointEpochMillis = anchor.windowStart().toEpochMilli()
+ ((anchor.windowEnd().toEpochMilli() - anchor.windowStart().toEpochMilli()) / 2L);
int bestIndex = -1;
double bestPrice = anchor.kind() == AnchorKind.TOP ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
long bestDistance = Long.MAX_VALUE;
boolean foundBarInWindow = false;
boolean foundValidPrice = false;
for (int index = series.getBeginIndex(); index <= series.getEndIndex(); index++) {
Bar bar = series.getBar(index);
Instant endTime = bar.getEndTime();
if (endTime.isBefore(anchor.windowStart()) || endTime.isAfter(anchor.windowEnd())) {
continue;
}
foundBarInWindow = true;
double candidatePrice = priceFor(bar, anchor.kind());
if (!Double.isFinite(candidatePrice)) {
continue;
}
foundValidPrice = true;
long candidateDistance = Math.abs(endTime.toEpochMilli() - midpointEpochMillis);
if (shouldReplaceBest(anchor.kind(), candidatePrice, bestPrice, candidateDistance, bestDistance, index,
bestIndex)) {
bestIndex = index;
bestPrice = candidatePrice;
bestDistance = candidateDistance;
}
}
if (bestIndex < 0) {
if (!foundBarInWindow) {
throw new IllegalStateException("No bars found inside anchor window " + anchor.id());
}
if (!foundValidPrice) {
throw new IllegalStateException("Bars found inside anchor window " + anchor.id() + " but no finite "
+ anchor.kind() + " prices were available");
}
throw new IllegalStateException("Failed to resolve anchor window " + anchor.id());
}
return new ResolvedAnchor(anchor, bestIndex, series.getBar(bestIndex).getEndTime(), bestPrice,
AnchorPartition.VALIDATION);
}
private static boolean better(AnchorKind kind, double candidatePrice, double bestPrice) {
if (kind == AnchorKind.TOP) {
return candidatePrice > bestPrice;
}
return candidatePrice < bestPrice;
}
private static boolean shouldReplaceBest(AnchorKind kind, double candidatePrice, double bestPrice,
long candidateDistance, long bestDistance, int candidateIndex, int bestIndex) {
return bestIndex < 0 || better(kind, candidatePrice, bestPrice)
|| (same(candidatePrice, bestPrice) && (candidateDistance < bestDistance
|| (candidateDistance == bestDistance && candidateIndex < bestIndex)));
}
private static boolean same(double left, double right) {
return Double.compare(left, right) == 0;
}
private static double priceFor(Bar bar, AnchorKind kind) {
Objects.requireNonNull(bar, "bar");
double value = kind == AnchorKind.TOP ? bar.getHighPrice().doubleValue() : bar.getLowPrice().doubleValue();
if (Double.isNaN(value) || Double.isInfinite(value)) {
value = bar.getClosePrice().doubleValue();
}
return value;
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field);
if (value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
private static AnchorKind parseKind(String kind, String anchorId) {
String normalizedKind = requireText(kind, "kind");
try {
return AnchorKind.valueOf(normalizedKind);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unknown anchor kind '" + normalizedKind + "' for anchor " + anchorId,
ex);
}
}
private static Instant parseInstant(String value, String field, String anchorId) {
String normalizedValue = requireText(value, field);
try {
return Instant.parse(normalizedValue);
} catch (DateTimeParseException ex) {
throw new IllegalArgumentException("Invalid " + field + " '" + normalizedValue + "' for anchor " + anchorId,
ex);
}
}
private static ElliottPhase parsePhase(String phase, String anchorId) {
String normalizedPhase = requireText(phase, "phase");
try {
return ElliottPhase.valueOf(normalizedPhase);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException(
"Unknown expected phase '" + normalizedPhase + "' for anchor " + anchorId, ex);
}
}
/**
* Anchor direction used when resolving local extrema.
*
* @since 0.22.7
*/
enum AnchorKind {
TOP, BOTTOM
}
/**
* Chronological anchor partition used for validation-versus-holdout reporting.
*
* @since 0.22.7
*/
enum AnchorPartition {
VALIDATION, HOLDOUT
}
/**
* Immutable anchor specification loaded from the registry file.
*
* @param id stable anchor identifier
* @param label human-readable label
* @param kind anchor direction
* @param windowStart inclusive window start
* @param windowEnd inclusive window end
* @param expectedPhases acceptable Elliott phases for a hit
* @param source provenance string
* @param notes optional note
* @since 0.22.7
*/
record AnchorSpec(String id, String label, AnchorKind kind, Instant windowStart, Instant windowEnd,
Set<ElliottPhase> expectedPhases, String source, String notes) {
/**
* Creates a validated anchor specification.
*/
AnchorSpec {
id = requireText(id, "id");
label = requireText(label, "label");
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(windowStart, "windowStart");
Objects.requireNonNull(windowEnd, "windowEnd");
if (windowEnd.isBefore(windowStart)) {
throw new IllegalArgumentException("windowEnd must not be before windowStart");
}
expectedPhases = expectedPhases == null || expectedPhases.isEmpty() ? EnumSet.noneOf(ElliottPhase.class)
: Collections.unmodifiableSet(EnumSet.copyOf(expectedPhases));
if (expectedPhases.isEmpty()) {
throw new IllegalArgumentException("expectedPhases must not be empty");
}
source = requireText(source, "source");
notes = notes == null ? "" : notes.trim();
}
}
/**
* Concrete anchor resolved against the BTC series.
*
* @param spec original anchor specification
* @param decisionIndex resolved series index
* @param resolvedTime resolved bar end time
* @param resolvedPrice resolved bar high/low used for matching
* @param partition validation or holdout
* @since 0.22.7
*/
record ResolvedAnchor(AnchorSpec spec, int decisionIndex, Instant resolvedTime, double resolvedPrice,
AnchorPartition partition) {
/**
* Creates a validated resolved anchor.
*/
ResolvedAnchor {
Objects.requireNonNull(spec, "spec");
if (decisionIndex < 0) {
throw new IllegalArgumentException("decisionIndex must be >= 0");
}
Objects.requireNonNull(resolvedTime, "resolvedTime");
Objects.requireNonNull(partition, "partition");
if (Double.isNaN(resolvedPrice) || Double.isInfinite(resolvedPrice)) {
throw new IllegalArgumentException("resolvedPrice must be finite");
}
}
/**
* Creates a copy with a different partition.
*
* @param newPartition new partition
* @return copied resolved anchor
* @since 0.22.7
*/
ResolvedAnchor withPartition(AnchorPartition newPartition) {
return new ResolvedAnchor(spec, decisionIndex, resolvedTime, resolvedPrice, newPartition);
}
}
/**
* Raw registry document loaded through Gson.
*
* @param registryId registry identifier
* @param datasetResource dataset resource name
* @param provenance provenance summary
* @param anchors raw anchors
*/
private record RegistryDocument(String registryId, String datasetResource, String provenance,
List<RegistryAnchor> anchors) {
}
/**
* Raw anchor entry loaded through Gson.
*
* @param id stable anchor identifier
* @param label human-readable label
* @param kind anchor direction
* @param windowStart inclusive window start in ISO-8601 form
* @param windowEnd inclusive window end in ISO-8601 form
* @param expectedPhases accepted Elliott phases as enum names
* @param source provenance string
* @param notes optional note
*/
private record RegistryAnchor(String id, String label, String kind, String windowStart, String windowEnd,
List<String> expectedPhases, String source, String notes) {
private AnchorSpec toSpec() {
Set<ElliottPhase> phases = EnumSet.noneOf(ElliottPhase.class);
for (String phase : Objects.requireNonNull(expectedPhases, "expectedPhases")) {
phases.add(parsePhase(phase, id));
}
return new AnchorSpec(id, label, parseKind(kind, id), parseInstant(windowStart, "windowStart", id),
parseInstant(windowEnd, "windowEnd", id), phases, source, notes);
}
}
}
@@ -0,0 +1,201 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.awt.Color;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.CurrentCycleSummary;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.DirectionalCycleSummary;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.HypothesisResult;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.MacroStudy;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.ProfileScoreSummary;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* BTC-specific wrapper around the generic macro-cycle demo.
*
* <p>
* The generic demo now owns both the historical macro study and the live
* current-cycle reporting flow. This wrapper exists to keep the fixed BTC
* dataset entry points, the locked BTC anchor truth set, and the canonical BTC
* chart/summary filenames stable for users and regression tests.
*
* @since 0.22.4
*/
public final class ElliottWaveBtcMacroCycleDemo {
static final String RESULT_PREFIX = "EW_BTC_MACRO_DEMO: ";
static final String LIVE_RESULT_PREFIX = "EW_BTC_LIVE_MACRO: ";
static final Path DEFAULT_CHART_DIRECTORY = Path.of("temp", "charts");
static final String DEFAULT_CHART_FILE_NAME = "elliott-wave-btc-macro-cycles";
static final String DEFAULT_SUMMARY_FILE_NAME = "elliott-wave-btc-macro-cycles-summary.json";
static final String DEFAULT_LIVE_CHART_FILE_NAME = "elliott-wave-btc-live-macro-current-cycle";
static final String DEFAULT_LIVE_SUMMARY_FILE_NAME = "elliott-wave-btc-live-macro-current-cycle-summary.json";
static final int DEFAULT_CHART_WIDTH = 3840;
static final int DEFAULT_CHART_HEIGHT = 2160;
static final int MIN_CORE_SEGMENT_SCENARIOS = 1000;
static final int MAX_CORE_ANCHOR_DRIFT_BARS = 3;
static final double DEFAULT_ACCEPTED_SEGMENT_SCORE = 0.64;
static final int LABEL_CLUSTER_BAR_GAP = 18;
static final Color BULLISH_LEG_COLOR = new Color(0x66BB6A);
static final Color BEARISH_LEG_COLOR = new Color(0xEF5350);
static final Color BULLISH_WAVE_COLOR = new Color(0x81C784);
static final Color BEARISH_WAVE_COLOR = new Color(0xE57373);
static final Color BULLISH_CANDIDATE_COLOR = new Color(0xC8E6C9);
static final Color BEARISH_CANDIDATE_COLOR = new Color(0xFFCDD2);
static final Color ANCHOR_OVERLAY_COLOR = new Color(0xCFD8DC);
static final double WAVE_LABEL_FONT_SCALE = 3.0;
static final double EPSILON = 1e-9;
private static final Logger LOG = LogManager.getLogger(ElliottWaveBtcMacroCycleDemo.class);
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String BTC_LIVE_HISTORICAL_STATUS = "BTC macro profile prevalidated from historical cycle truth set";
private ElliottWaveBtcMacroCycleDemo() {
}
/**
* Runs the BTC macro-cycle study and logs the resulting JSON summary.
*
* @param args unused
*/
public static void main(final String[] args) {
final DemoReport report = generateReport(DEFAULT_CHART_DIRECTORY);
LOG.info("{}{}", RESULT_PREFIX, report.toJson());
}
/**
* Runs the series-native live BTC macro preset on the supplied series and logs
* the resulting JSON summary.
*
* @param series live or loaded BTC series to analyze
* @param chartDirectory directory for the saved current-cycle chart and JSON
* summary
* @since 0.22.4
*/
public static void runLivePreset(final BarSeries series, final Path chartDirectory) {
ElliottWaveMacroCycleDemo.runLivePreset(series, chartDirectory, DEFAULT_LIVE_CHART_FILE_NAME,
DEFAULT_LIVE_SUMMARY_FILE_NAME, "btc-usd", BTC_LIVE_HISTORICAL_STATUS);
}
static DemoReport generateReport(final Path chartDirectory) {
final BarSeries series = requireSeries(ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE,
ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME);
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry = ElliottWaveAnchorCalibrationHarness
.defaultBitcoinAnchors(series);
return DemoReport.from(ElliottWaveMacroCycleDemo.generateHistoricalReport(series, registry, chartDirectory));
}
static DemoReport generateReport(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry, final Path chartDirectory) {
return DemoReport.from(ElliottWaveMacroCycleDemo.generateHistoricalReport(series, registry, chartDirectory));
}
static LivePresetReport generateLivePresetReport(final BarSeries series, final Path chartDirectory) {
return LivePresetReport.from(ElliottWaveMacroCycleDemo.generateLivePresetReport(series, chartDirectory,
DEFAULT_LIVE_CHART_FILE_NAME, DEFAULT_LIVE_SUMMARY_FILE_NAME, BTC_LIVE_HISTORICAL_STATUS));
}
static Optional<Path> saveMacroCycleChart(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry, final Path chartDirectory) {
return ElliottWaveMacroCycleDemo.saveHistoricalChart(series, registry, chartDirectory);
}
static JFreeChart renderMacroCycleChart(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry) {
return ElliottWaveMacroCycleDemo.renderHistoricalChart(series, registry);
}
static JFreeChart renderMacroCycleChart(final BarSeries series, final MacroStudy study) {
return ElliottWaveMacroCycleDemo.renderHistoricalChart(series, study);
}
static MacroStudy evaluateMacroStudy(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry) {
return ElliottWaveMacroCycleDemo.evaluateMacroStudy(series, registry);
}
private static BarSeries requireSeries(final String resource, final String seriesName) {
final BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveBtcMacroCycleDemo.class,
resource, seriesName, LOG);
if (series == null) {
throw new IllegalStateException("Unable to load required resource " + resource);
}
return series;
}
record DemoReport(String registryVersion, String datasetResource, String baselineProfileId,
String selectedProfileId, String selectedHypothesisId, boolean historicalFitPassed,
String harnessDecisionRationale, String chartPath, String summaryPath, String structureSource,
List<ProfileScoreSummary> profileScores, List<DirectionalCycleSummary> cycles,
List<HypothesisResult> hypotheses, CurrentCycleSummary currentCycle) {
DemoReport {
Objects.requireNonNull(registryVersion, "registryVersion");
Objects.requireNonNull(datasetResource, "datasetResource");
Objects.requireNonNull(baselineProfileId, "baselineProfileId");
Objects.requireNonNull(selectedProfileId, "selectedProfileId");
Objects.requireNonNull(selectedHypothesisId, "selectedHypothesisId");
Objects.requireNonNull(harnessDecisionRationale, "harnessDecisionRationale");
Objects.requireNonNull(chartPath, "chartPath");
Objects.requireNonNull(summaryPath, "summaryPath");
Objects.requireNonNull(structureSource, "structureSource");
profileScores = profileScores == null ? List.of() : List.copyOf(profileScores);
cycles = cycles == null ? List.of() : List.copyOf(cycles);
hypotheses = hypotheses == null ? List.of() : List.copyOf(hypotheses);
Objects.requireNonNull(currentCycle, "currentCycle");
}
String toJson() {
return GSON.toJson(this);
}
static DemoReport from(final ElliottWaveMacroCycleDemo.DemoReport report) {
return new DemoReport(report.registryVersion(), report.datasetResource(), report.baselineProfileId(),
report.selectedProfileId(), report.selectedHypothesisId(), report.historicalFitPassed(),
report.harnessDecisionRationale(), report.chartPath(), report.summaryPath(),
report.structureSource(), report.profileScores(), report.cycles(), report.hypotheses(),
report.currentCycle());
}
}
record LivePresetReport(String seriesName, String startTimeUtc, String latestTimeUtc, String selectedProfileId,
String selectedHypothesisId, String chartPath, String summaryPath, String structureSource,
CurrentCycleSummary currentCycle) {
LivePresetReport {
Objects.requireNonNull(seriesName, "seriesName");
Objects.requireNonNull(startTimeUtc, "startTimeUtc");
Objects.requireNonNull(latestTimeUtc, "latestTimeUtc");
Objects.requireNonNull(selectedProfileId, "selectedProfileId");
Objects.requireNonNull(selectedHypothesisId, "selectedHypothesisId");
Objects.requireNonNull(chartPath, "chartPath");
Objects.requireNonNull(summaryPath, "summaryPath");
Objects.requireNonNull(structureSource, "structureSource");
Objects.requireNonNull(currentCycle, "currentCycle");
}
String toJson() {
return GSON.toJson(this);
}
static LivePresetReport from(final ElliottWaveMacroCycleDemo.LivePresetReport report) {
return new LivePresetReport(report.seriesName(), report.startTimeUtc(), report.latestTimeUtc(),
report.selectedProfileId(), report.selectedHypothesisId(), report.chartPath(), report.summaryPath(),
report.structureSource(), report.currentCycle());
}
}
}
@@ -0,0 +1,234 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottLogicProfile;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
/**
* Infers broad macro-cycle anchors directly from a series-wide Elliott swing
* set.
*
* <p>
* This detector is the anchor-free path for {@link ElliottWaveMacroCycleDemo}.
* It runs the same full-history core analysis profile used by the BTC macro
* demo, then collapses the processed swing pivots into major cycle turns by
* keeping only all-time-high peaks that are followed by severe drawdowns before
* the next higher high. The detector keeps the deepest corrective low inside
* that regime, then rejects short-lived crashes that never mature into broad
* macro cycles. The resulting top/bottom chain is converted into an
* {@link ElliottWaveAnchorCalibrationHarness.AnchorRegistry} so the existing
* historical chart and JSON report flow can be reused unchanged.
*
* <p>
* The heuristic is intentionally simple:
* <ul>
* <li>build a full-history orthodox swing map with no curated anchors</li>
* <li>track each new all-time-high pivot</li>
* <li>keep only peaks that lead to a material trough before the next higher
* high</li>
* </ul>
*
* <p>
* For BTC full-history runs this naturally recovers the committed 2011, 2013,
* 2017, and 2021 macro tops plus their following corrective lows closely enough
* to compare against the truth-set registry.
*
* @since 0.22.7
*/
final class ElliottWaveMacroCycleDetector {
private static final String INFERRED_REGISTRY_VERSION = "inferred-macro-cycle-anchors-v1";
private static final int HOLDOUT_ANCHOR_COUNT = 2;
private static final double MIN_MACRO_DRAWDOWN_FRACTION = 0.55;
private static final Duration MIN_MACRO_SPAN = Duration.ofDays(120);
private static final Duration DEFAULT_TOLERANCE = Duration.ofDays(45);
private ElliottWaveMacroCycleDetector() {
}
/**
* Infers a macro-cycle anchor registry for the supplied series.
*
* @param series series to analyze
* @return inferred anchor registry
* @since 0.22.7
*/
static ElliottWaveAnchorCalibrationHarness.AnchorRegistry inferAnchorRegistry(final BarSeries series) {
Objects.requireNonNull(series, "series");
final ElliottWaveAnalysisResult analysis = buildRunner().analyze(series);
final ElliottAnalysisResult baseAnalysis = analysis.analysisFor(ElliottDegree.MINOR)
.orElseThrow(() -> new IllegalStateException("Missing base-degree MINOR analysis"))
.analysis();
final List<Pivot> pivots = toPivots(series, baseAnalysis.rawSwings());
final List<MacroDrawdown> macroDrawdowns = detectMacroDrawdowns(pivots);
if (macroDrawdowns.isEmpty()) {
throw new IllegalStateException("Unable to infer macro-cycle anchors from processed swings");
}
return new ElliottWaveAnchorCalibrationHarness.AnchorRegistry(INFERRED_REGISTRY_VERSION,
datasetResource(series), inferredProvenance(series), toAnchors(macroDrawdowns));
}
private static ElliottWaveAnalysisRunner buildRunner() {
return ElliottWaveAnalysisRunner.builder()
.degree(ElliottDegree.MINOR)
.logicProfile(ElliottLogicProfile.ORTHODOX_CLASSICAL)
.maxScenarios(ElliottLogicProfile.ORTHODOX_CLASSICAL.maxScenarios())
.minConfidence(0.0)
.seriesSelector((inputSeries, ignoredDegree) -> inputSeries)
.build();
}
private static List<Pivot> toPivots(final BarSeries series, final List<ElliottSwing> processedSwings) {
if (processedSwings == null || processedSwings.isEmpty()) {
return List.of();
}
final List<Pivot> pivots = new ArrayList<>(processedSwings.size() + 1);
final ElliottSwing firstSwing = processedSwings.getFirst();
pivots.add(new Pivot(firstSwing.fromIndex(), series.getBar(firstSwing.fromIndex()).getEndTime(),
firstSwing.fromPrice().doubleValue(), !firstSwing.isRising()));
for (final ElliottSwing swing : processedSwings) {
pivots.add(new Pivot(swing.toIndex(), series.getBar(swing.toIndex()).getEndTime(),
swing.toPrice().doubleValue(), swing.isRising()));
}
return List.copyOf(pivots);
}
private static List<MacroDrawdown> detectMacroDrawdowns(final List<Pivot> pivots) {
final List<Pivot> allTimeHighs = new ArrayList<>();
double bestHigh = Double.NEGATIVE_INFINITY;
for (final Pivot pivot : pivots) {
if (!pivot.high() || pivot.price() <= bestHigh) {
continue;
}
allTimeHighs.add(pivot);
bestHigh = pivot.price();
}
final List<MacroDrawdown> drawdowns = new ArrayList<>();
for (int index = 0; index < allTimeHighs.size(); index++) {
final Pivot top = allTimeHighs.get(index);
final Instant nextHigherHighTime = index + 1 < allTimeHighs.size() ? allTimeHighs.get(index + 1).at()
: null;
Pivot trough = lowestLowAfter(top, pivots, nextHigherHighTime);
if (trough == null) {
continue;
}
final double drawdownFraction = (top.price() - trough.price()) / top.price();
final Duration span = Duration.between(top.at(), trough.at());
if (drawdownFraction >= MIN_MACRO_DRAWDOWN_FRACTION && span.compareTo(MIN_MACRO_SPAN) >= 0) {
drawdowns.add(new MacroDrawdown(top, trough, drawdownFraction));
}
}
return List.copyOf(drawdowns);
}
private static Pivot lowestLowAfter(final Pivot top, final List<Pivot> pivots, final Instant nextHigherHighTime) {
Pivot trough = null;
for (final Pivot pivot : pivots) {
if (pivot.at().isBefore(top.at()) || pivot.at().equals(top.at()) || pivot.high()) {
continue;
}
if (nextHigherHighTime != null && !pivot.at().isBefore(nextHigherHighTime)) {
break;
}
if (trough == null || pivot.price() < trough.price()) {
trough = pivot;
}
}
return trough;
}
private static List<ElliottWaveAnchorCalibrationHarness.Anchor> toAnchors(
final List<MacroDrawdown> macroDrawdowns) {
final int anchorCount = macroDrawdowns.size() * 2;
final int validationCutoff = Math.max(0, anchorCount - HOLDOUT_ANCHOR_COUNT);
final List<ElliottWaveAnchorCalibrationHarness.Anchor> anchors = new ArrayList<>(anchorCount);
int anchorIndex = 0;
for (int index = 0; index < macroDrawdowns.size(); index++) {
final MacroDrawdown drawdown = macroDrawdowns.get(index);
anchors.add(toAnchor(index, drawdown.top(), ElliottWaveAnchorCalibrationHarness.AnchorType.TOP,
partitionFor(anchorIndex, validationCutoff)));
anchorIndex++;
anchors.add(toAnchor(index, drawdown.trough(), ElliottWaveAnchorCalibrationHarness.AnchorType.BOTTOM,
partitionFor(anchorIndex, validationCutoff)));
anchorIndex++;
}
return List.copyOf(anchors);
}
private static ElliottWaveAnchorCalibrationHarness.Anchor toAnchor(final int sequence, final Pivot pivot,
final ElliottWaveAnchorCalibrationHarness.AnchorType type,
final ElliottWaveAnchorRegistry.AnchorPartition partition) {
final Set<ElliottPhase> expectedPhases = type == ElliottWaveAnchorCalibrationHarness.AnchorType.TOP
? EnumSet.of(ElliottPhase.WAVE5)
: EnumSet.of(ElliottPhase.CORRECTIVE_C);
final String direction = type == ElliottWaveAnchorCalibrationHarness.AnchorType.TOP ? "top" : "bottom";
return new ElliottWaveAnchorCalibrationHarness.Anchor("inferred-" + direction + "-" + (sequence + 1), type,
pivot.at(), DEFAULT_TOLERANCE, DEFAULT_TOLERANCE, expectedPhases, partition,
"Inferred from full-history orthodox Elliott swings and macro drawdown turns.");
}
private static ElliottWaveAnchorRegistry.AnchorPartition partitionFor(int anchorIndex, int validationCutoff) {
return anchorIndex < validationCutoff ? ElliottWaveAnchorRegistry.AnchorPartition.VALIDATION
: ElliottWaveAnchorRegistry.AnchorPartition.HOLDOUT;
}
private static String datasetResource(final BarSeries series) {
final String name = series.getName();
return name == null || name.isBlank() ? "in-memory-series" : name;
}
private static String inferredProvenance(final BarSeries series) {
return "Inferred from full-history orthodox Elliott swing pivots for " + datasetResource(series)
+ " without a curated anchor registry.";
}
private record Pivot(int index, Instant at, double price, boolean high) {
private Pivot {
Objects.requireNonNull(at, "at");
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
if (!Double.isFinite(price)) {
throw new IllegalArgumentException("price must be finite");
}
}
}
private record MacroDrawdown(Pivot top, Pivot trough, double drawdownFraction) {
private MacroDrawdown {
Objects.requireNonNull(top, "top");
Objects.requireNonNull(trough, "trough");
if (!top.high()) {
throw new IllegalArgumentException("top must be a high pivot");
}
if (trough.high()) {
throw new IllegalArgumentException("trough must be a low pivot");
}
if (trough.index() <= top.index()) {
throw new IllegalArgumentException("trough must follow top");
}
if (!Double.isFinite(drawdownFraction) || drawdownFraction <= 0.0) {
throw new IllegalArgumentException("drawdownFraction must be positive and finite");
}
}
}
}
@@ -0,0 +1,374 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.time.format.DateTimeFormatter;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwingCompressor;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.ElliottTrendBiasIndicator;
import org.ta4j.core.indicators.elliott.ElliottWaveFacade;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Backtests and walk-forward tests Elliott Wave trend bias predictions using
* ossified datasets.
*
* <p>
* This demo evaluates whether the trend bias derived from Elliott Wave
* scenarios correctly predicts the direction of price action over a fixed
* lookahead horizon. It runs a full-history backtest and a rolling walk-forward
* evaluation over multiple instruments.
*
* @since 0.22.2
*/
public class ElliottWaveTrendBacktest {
private static final Logger LOG = LogManager.getLogger(ElliottWaveTrendBacktest.class);
private static final double DEFAULT_FIB_TOLERANCE = 0.25;
private static final int DEFAULT_LOOKAHEAD_BARS = 20;
private static final double DEFAULT_MIN_STRENGTH = 0.20;
private static final int DEFAULT_WALK_FORWARD_WINDOW = 180;
private static final int DEFAULT_WALK_FORWARD_STEP = 60;
/**
* Runs the trend-bias backtest and walk-forward evaluation demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
List<DatasetSpec> datasets = List.of(
DatasetSpec.json("Coinbase-ETH-USD-PT1D-20160517_20251028.json", "ETH-USD PT1D (Coinbase)",
ElliottDegree.PRIMARY),
DatasetSpec.csv("AAPL-PT1D-20130102_20131231.csv", "AAPL PT1D (Yahoo)", ElliottDegree.MINOR));
for (DatasetSpec dataset : datasets) {
BarSeries series = dataset.loadSeries();
if (series == null || series.isEmpty()) {
LOG.warn("Dataset {} could not be loaded or is empty.", dataset.label());
continue;
}
LOG.info("=== Elliott Trend Bias Backtest: {} ===", dataset.label());
LOG.info("Bars: {} | Range: {}", series.getBarCount(), describeRange(series));
ElliottWaveFacade facade = buildFacade(series, dataset.degree());
ElliottTrendBiasIndicator trendBiasIndicator = facade.trendBias();
TrendAccuracy backtest = evaluateWindow(series, trendBiasIndicator, series.getBeginIndex(),
series.getEndIndex(), DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH);
logAccuracy("Backtest", backtest, DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH);
runWalkForward(series, trendBiasIndicator, DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH,
DEFAULT_WALK_FORWARD_WINDOW, DEFAULT_WALK_FORWARD_STEP);
}
}
/**
* Builds an Elliott Wave facade with zigzag swings and compression.
*
* @param series bar series to analyze
* @param degree Elliott wave degree to use
* @return configured Elliott Wave facade
*/
private static ElliottWaveFacade buildFacade(BarSeries series, ElliottDegree degree) {
ElliottSwingCompressor compressor = new ElliottSwingCompressor(series);
return ElliottWaveFacade.zigZag(series, degree, Optional.of(series.numFactory().numOf(DEFAULT_FIB_TOLERANCE)),
Optional.of(compressor));
}
/**
* Evaluates directional accuracy across a contiguous bar window.
*
* @param series bar series being evaluated
* @param trendBiasIndicator indicator supplying trend bias values
* @param startIndex first index to evaluate
* @param endIndex last index to evaluate
* @param lookaheadBars bars to look ahead for validation
* @param minStrength minimum bias strength to include
* @return aggregated accuracy statistics
*/
private static TrendAccuracy evaluateWindow(BarSeries series, ElliottTrendBiasIndicator trendBiasIndicator,
int startIndex, int endIndex, int lookaheadBars, double minStrength) {
int effectiveStart = Math.max(startIndex, series.getBeginIndex());
int effectiveEnd = Math.min(endIndex, series.getEndIndex());
int unstableBars = trendBiasIndicator.getCountOfUnstableBars();
effectiveStart = Math.max(effectiveStart, unstableBars);
int lastEvaluationIndex = effectiveEnd - Math.max(1, lookaheadBars);
if (lastEvaluationIndex < effectiveStart) {
return TrendAccuracy.empty();
}
int totalPredictions = 0;
int correctPredictions = 0;
int bullishPredictions = 0;
int bullishCorrect = 0;
int bearishPredictions = 0;
int bearishCorrect = 0;
int skipped = 0;
for (int i = effectiveStart; i <= lastEvaluationIndex; i++) {
ElliottTrendBias bias = trendBiasIndicator.getValue(i);
if (bias == null || bias.isUnknown() || bias.isNeutral()) {
skipped++;
continue;
}
double strength = bias.strength();
if (Double.isNaN(strength) || strength < minStrength) {
skipped++;
continue;
}
Num currentClose = series.getBar(i).getClosePrice();
Num futureClose = series.getBar(i + lookaheadBars).getClosePrice();
if (Num.isNaNOrNull(currentClose) || Num.isNaNOrNull(futureClose)) {
skipped++;
continue;
}
boolean bullish = bias.isBullish();
boolean correct = bullish ? futureClose.isGreaterThan(currentClose) : futureClose.isLessThan(currentClose);
totalPredictions++;
if (bullish) {
bullishPredictions++;
if (correct) {
bullishCorrect++;
}
} else {
bearishPredictions++;
if (correct) {
bearishCorrect++;
}
}
if (correct) {
correctPredictions++;
}
}
return new TrendAccuracy(totalPredictions, correctPredictions, bullishPredictions, bullishCorrect,
bearishPredictions, bearishCorrect, skipped);
}
/**
* Runs a rolling walk-forward evaluation across the series.
*
* @param series bar series being evaluated
* @param trendBiasIndicator indicator supplying trend bias values
* @param lookaheadBars bars to look ahead for validation
* @param minStrength minimum bias strength to include
* @param windowSize rolling window size in bars
* @param stepSize step size between windows in bars
*/
private static void runWalkForward(BarSeries series, ElliottTrendBiasIndicator trendBiasIndicator,
int lookaheadBars, double minStrength, int windowSize, int stepSize) {
int startIndex = Math.max(series.getBeginIndex(), trendBiasIndicator.getCountOfUnstableBars());
int endIndex = series.getEndIndex();
int availableBars = endIndex - startIndex + 1;
if (availableBars <= 0) {
LOG.info("Walk-forward skipped: insufficient bars after unstable period.");
return;
}
int effectiveWindow = Math.min(windowSize, availableBars);
int effectiveStep = Math.max(1, stepSize);
LOG.info("Walk-forward windows: size={} bars, step={} bars", effectiveWindow, effectiveStep);
for (int windowStart = startIndex; windowStart + effectiveWindow
- 1 <= endIndex; windowStart += effectiveStep) {
int windowEnd = windowStart + effectiveWindow - 1;
TrendAccuracy accuracy = evaluateWindow(series, trendBiasIndicator, windowStart, windowEnd, lookaheadBars,
minStrength);
String windowRange = describeRange(series, windowStart, windowEnd);
LOG.info("Walk-forward {} -> {}", windowRange, formatAccuracy(accuracy, lookaheadBars, minStrength));
}
}
/**
* Logs summary accuracy output.
*
* @param label label for the output
* @param accuracy aggregated accuracy statistics
* @param lookaheadBars lookahead window used in bars
* @param minStrength minimum bias strength used for inclusion
*/
private static void logAccuracy(String label, TrendAccuracy accuracy, int lookaheadBars, double minStrength) {
LOG.info("{} -> {}", label, formatAccuracy(accuracy, lookaheadBars, minStrength));
}
/**
* Formats accuracy statistics into a single summary string.
*
* @param accuracy aggregated accuracy statistics
* @param lookaheadBars lookahead window used in bars
* @param minStrength minimum bias strength used for inclusion
* @return formatted summary string
*/
private static String formatAccuracy(TrendAccuracy accuracy, int lookaheadBars, double minStrength) {
String overall = formatPercent(accuracy.accuracy());
String bullish = formatPercent(accuracy.bullishAccuracy());
String bearish = formatPercent(accuracy.bearishAccuracy());
return String.format(
"lookahead=%d bars | minStrength=%.2f | predictions=%d | accuracy=%s | bullish=%s | bearish=%s | skipped=%d",
lookaheadBars, minStrength, accuracy.totalPredictions(), overall, bullish, bearish,
accuracy.skippedPredictions());
}
/**
* Formats a ratio as a percentage string.
*
* @param value ratio value (0.0-1.0)
* @return formatted percentage string, or {@code n/a} if undefined
*/
private static String formatPercent(double value) {
if (Double.isNaN(value)) {
return "n/a";
}
return String.format("%.1f%%", value * 100.0);
}
/**
* Describes the full range of the series in ISO date form.
*
* @param series bar series to describe
* @return formatted date range string
*/
private static String describeRange(BarSeries series) {
return describeRange(series, series.getBeginIndex(), series.getEndIndex());
}
/**
* Describes a subset of the series in ISO date form.
*
* @param series bar series to describe
* @param startIndex start bar index
* @param endIndex end bar index
* @return formatted date range string
*/
private static String describeRange(BarSeries series, int startIndex, int endIndex) {
DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE;
String start = series.getBar(startIndex).getEndTime().atZone(ZoneOffset.UTC).toLocalDate().format(formatter);
String end = series.getBar(endIndex).getEndTime().atZone(ZoneOffset.UTC).toLocalDate().format(formatter);
return start + " -> " + end;
}
/**
* Dataset metadata for backtesting inputs.
*
* @param resource classpath resource identifier
* @param label label for logging output
* @param degree Elliott wave degree to analyze
* @param sourceType data source kind
*/
private record DatasetSpec(String resource, String label, ElliottDegree degree, DataSourceType sourceType) {
/**
* Loads the bar series for this dataset.
*
* @return loaded series, or {@code null} if unavailable
*/
BarSeries loadSeries() {
if (sourceType == DataSourceType.JSON) {
return JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resource);
}
return new CsvFileBarSeriesDataSource().loadSeries(resource);
}
/**
* Builds a JSON-backed dataset spec.
*
* @param resource resource path
* @param label label for logging
* @param degree Elliott wave degree
* @return dataset spec
*/
static DatasetSpec json(String resource, String label, ElliottDegree degree) {
return new DatasetSpec(resource, label, degree, DataSourceType.JSON);
}
/**
* Builds a CSV-backed dataset spec.
*
* @param resource resource path
* @param label label for logging
* @param degree Elliott wave degree
* @return dataset spec
*/
static DatasetSpec csv(String resource, String label, ElliottDegree degree) {
return new DatasetSpec(resource, label, degree, DataSourceType.CSV);
}
}
private enum DataSourceType {
JSON, CSV
}
/**
* Aggregated trend-bias prediction accuracy metrics.
*
* @param totalPredictions total evaluated predictions
* @param correctPredictions total correct predictions
* @param bullishPredictions total bullish predictions
* @param bullishCorrect correct bullish predictions
* @param bearishPredictions total bearish predictions
* @param bearishCorrect correct bearish predictions
* @param skippedPredictions skipped predictions
*/
private record TrendAccuracy(int totalPredictions, int correctPredictions, int bullishPredictions,
int bullishCorrect, int bearishPredictions, int bearishCorrect, int skippedPredictions) {
/**
* @return empty accuracy metrics
*/
static TrendAccuracy empty() {
return new TrendAccuracy(0, 0, 0, 0, 0, 0, 0);
}
/**
* @return overall accuracy ratio
*/
double accuracy() {
return ratio(correctPredictions, totalPredictions);
}
/**
* @return bullish-only accuracy ratio
*/
double bullishAccuracy() {
return ratio(bullishCorrect, bullishPredictions);
}
/**
* @return bearish-only accuracy ratio
*/
double bearishAccuracy() {
return ratio(bearishCorrect, bearishPredictions);
}
/**
* Computes a safe ratio, returning NaN when the denominator is zero.
*
* @param numerator numerator value
* @param denominator denominator value
* @return ratio or NaN
*/
private double ratio(int numerator, int denominator) {
return denominator > 0 ? (double) numerator / denominator : Double.NaN;
}
}
}
@@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.ExpectancyCriterion;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.pnl.GrossProfitLossRatioCriterion;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import ta4jexamples.strategies.HighRewardElliottWaveStrategy;
/**
* Backtests the high-reward Elliott Wave strategy using ossified datasets.
*
* @since 0.22.2
*/
public class HighRewardElliottWaveBacktest {
private static final Logger LOG = LogManager.getLogger(HighRewardElliottWaveBacktest.class);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-ETH-USD-PT4H-20160518_20251028.json";
private static final List<StrategySpec> DEFAULT_SPECS = List.of(StrategySpec.defaultSpec(),
StrategySpec.relaxedSpec(), StrategySpec.exploratorySpec(), StrategySpec.strictSpec());
/**
* Runs the backtest demo.
*
* @param args command-line arguments (optional: override dataset resource)
*/
public static void main(String[] args) {
String resource = args.length > 0 ? args[0] : DEFAULT_OHLCV_RESOURCE;
BarSeries series = loadSeries(resource);
if (series == null || series.isEmpty()) {
LOG.error("No data available for backtest: {}", resource);
return;
}
LOG.info("High-reward Elliott Wave backtest: {}", resource);
LOG.info("Bars: {} | Range: {}", series.getBarCount(), describeRange(series));
for (StrategySpec spec : DEFAULT_SPECS) {
runBacktest(series, spec);
}
}
private static void runBacktest(BarSeries series, StrategySpec spec) {
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, spec.toParameters());
BarSeriesManager manager = new BarSeriesManager(series);
TradingRecord record = manager.run(strategy);
Num netProfit = new NetProfitLossCriterion().calculate(series, record);
Num grossReturn = new GrossReturnCriterion().calculate(series, record);
Num profitFactor = new GrossProfitLossRatioCriterion().calculate(series, record);
Num maxDrawdown = new MaximumDrawdownCriterion().calculate(series, record);
Num winRate = PositionsRatioCriterion.WinningPositionsRatioCriterion().calculate(series, record);
Num expectancy = new ExpectancyCriterion().calculate(series, record);
LOG.info("Spec: {}", spec.label());
LOG.info(
"Trades: {} | WinRate: {} | Net PnL: {} | Gross Return: {} | Profit Factor: {} | Max DD: {} | Expectancy: {}",
record.getPositionCount(), formatPercent(winRate), netProfit, grossReturn, profitFactor, maxDrawdown,
expectancy);
logTrades(series, record);
}
private static String formatPercent(Num ratio) {
if (ratio == null || ratio.isNaN()) {
return "n/a";
}
return String.format("%.1f%%", ratio.doubleValue() * 100.0);
}
private static BarSeries loadSeries(String resource) {
try (InputStream stream = HighRewardElliottWaveBacktest.class.getClassLoader().getResourceAsStream(resource)) {
if (stream == null) {
LOG.error("Missing resource: {}", resource);
return null;
}
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
if (loaded == null) {
LOG.error("Failed to load resource: {}", resource);
return null;
}
BarSeries series = new BaseBarSeriesBuilder().withName(resource).build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
LOG.error("Failed to load dataset: {}", ex.getMessage(), ex);
return null;
}
}
private static String describeRange(BarSeries series) {
String start = DATE_FORMATTER.format(series.getBar(series.getBeginIndex()).getEndTime());
String end = DATE_FORMATTER.format(series.getBar(series.getEndIndex()).getEndTime());
return start + " -> " + end;
}
private static void logTrades(BarSeries series, TradingRecord record) {
if (record == null || record.getPositionCount() == 0) {
return;
}
record.getPositions().stream().filter(position -> !position.isOpened()).forEach(position -> {
Trade entry = position.getEntry();
Trade exit = position.getExit();
String entryTime = DATE_FORMATTER.format(series.getBar(entry.getIndex()).getEndTime());
String exitTime = DATE_FORMATTER.format(series.getBar(exit.getIndex()).getEndTime());
LOG.info("Trade: entry={}@{} ({}), exit={}@{} ({}), profit={}", entry.getIndex(), entry.getPricePerAsset(),
entryTime, exit.getIndex(), exit.getPricePerAsset(), exitTime, position.getProfit());
});
}
private record StrategySpec(String direction, String degree, double minConfidence, double minRiskReward,
double minAlternationRatio, double minTrendBiasStrength, int trendSmaPeriod, int rsiPeriod,
double rsiThreshold, int macdFastPeriod, int macdSlowPeriod, double minRelativeSwing) {
static StrategySpec defaultSpec() {
return new StrategySpec("BULLISH", "PRIMARY", 0.35, 2.0, 1.50, 0.10, 100, 14, 50.0, 12, 26, 0.10);
}
static StrategySpec relaxedSpec() {
return new StrategySpec("BULLISH", "MINOR", 0.30, 1.8, 1.05, 0.05, 80, 14, 48.0, 12, 26, 0.08);
}
static StrategySpec exploratorySpec() {
return new StrategySpec("BULLISH", "MINOR", 0.25, 1.5, 1.00, 0.00, 80, 14, 45.0, 12, 26, 0.05);
}
static StrategySpec strictSpec() {
return new StrategySpec("BULLISH", "PRIMARY", 0.50, 2.5, 1.40, 0.20, 200, 14, 50.0, 12, 26, 0.15);
}
String[] toParameters() {
return new String[] { direction, degree, format(minConfidence), format(minRiskReward),
format(minAlternationRatio), format(minTrendBiasStrength), String.valueOf(trendSmaPeriod),
String.valueOf(rsiPeriod), format(rsiThreshold), String.valueOf(macdFastPeriod),
String.valueOf(macdSlowPeriod), format(minRelativeSwing) };
}
String label() {
return String.format("dir=%s deg=%s conf=%s rr=%s alt=%s bias=%s swing=%s", direction, degree,
format(minConfidence), format(minRiskReward), format(minAlternationRatio),
format(minTrendBiasStrength), format(minRelativeSwing));
}
private static String format(double value) {
return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
}
}
@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.confidence.ConfidenceFactorResult;
import org.ta4j.core.indicators.elliott.confidence.ElliottConfidenceBreakdown;
import org.ta4j.core.indicators.elliott.swing.AdaptiveZigZagConfig;
import org.ta4j.core.indicators.elliott.swing.CompositeSwingDetector;
import org.ta4j.core.indicators.elliott.swing.MinMagnitudeSwingFilter;
import org.ta4j.core.indicators.elliott.swing.SwingDetector;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates adaptive ZigZag and composite swing detection for Elliott Wave
* analysis.
*
* @since 0.22.2
*/
public class ElliottWaveAdaptiveSwingAnalysis {
private static final Logger LOG = LogManager.getLogger(ElliottWaveAdaptiveSwingAnalysis.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
/**
* Runs the adaptive swing analysis demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
BarSeries series = loadSeries();
if (series == null || series.isEmpty()) {
LOG.error("No data available for adaptive swing analysis");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
AdaptiveZigZagConfig config = new AdaptiveZigZagConfig(14, 1.0, 0.0, 0.0, 3);
SwingDetector detector = SwingDetectors.composite(CompositeSwingDetector.Policy.OR, SwingDetectors.fractal(5),
SwingDetectors.adaptiveZigZag(config));
ElliottWaveAnalysisRunner analyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(detector)
.swingFilter(new MinMagnitudeSwingFilter(0.2))
.build();
ElliottWaveAnalysisResult analysisResult = analyzer.analyze(series);
ElliottAnalysisResult result = analysisResult.analysisFor(baseDegree).orElseThrow().analysis();
LOG.info("Adaptive swing analysis complete. Trend bias: {}", result.trendBias().direction());
result.scenarios().base().ifPresent(base -> logScenario("BASE", base, result));
List<ElliottScenario> alternatives = result.scenarios().alternatives();
for (int i = 0; i < Math.min(2, alternatives.size()); i++) {
logScenario("ALT " + (i + 1), alternatives.get(i), result);
}
}
/**
* Logs scenario-level confidence breakdown details.
*
* @param label scenario label
* @param scenario scenario to log
* @param result analysis result containing factor breakdowns
*/
private static void logScenario(String label, ElliottScenario scenario, ElliottAnalysisResult result) {
LOG.info("{} SCENARIO: {} ({}) - confidence={}%%", label, scenario.currentPhase(), scenario.type(),
String.format("%.1f", scenario.confidence().asPercentage()));
ElliottConfidenceBreakdown breakdown = result.breakdownFor(scenario).orElse(null);
if (breakdown == null) {
return;
}
for (ConfidenceFactorResult factor : breakdown.factors()) {
LOG.info(" Factor: {} score={} weight={} diagnostics={}", factor.name(),
String.format("%.2f", factor.score().doubleValue()), String.format("%.2f", factor.weight()),
factor.diagnostics());
}
}
/**
* Loads the ossified BTC-USD dataset from classpath resources.
*
* @return loaded bar series, or {@code null} if unavailable
*/
private static BarSeries loadSeries() {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveAdaptiveSwingAnalysis.class,
DEFAULT_OHLCV_RESOURCE, "BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import java.util.List;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.swing.AdaptiveZigZagConfig;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates multi-degree Elliott Wave analysis, validating scenarios across
* neighboring degrees and re-ranking base-degree outcomes.
*
* <p>
* This demo uses an ossified BTC-USD dataset from classpath resources and runs
* an auto-selected base-degree analysis, plus one supporting degree higher and
* lower.
*
* @since 0.22.4
*/
public class ElliottWaveMultiDegreeAnalysisDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWaveMultiDegreeAnalysisDemo.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231020.json";
public static void main(String[] args) {
BarSeries series = loadSeries(DEFAULT_OHLCV_RESOURCE);
if (series == null || series.isEmpty()) {
LOG.error("No series available for multi-degree Elliott Wave analysis");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
ElliottWaveAnalysisRunner analyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.adaptiveZigZag(new AdaptiveZigZagConfig(14, 1.0, 0.0, 0.0, 1)))
.build();
ElliottWaveAnalysisResult result = analyzer.analyze(series);
LOG.info("Auto-selected base degree: {}", baseDegree);
for (ElliottWaveAnalysisResult.DegreeAnalysis analysis : result.analyses()) {
LOG.info("{} Degree {}: bars={} duration={} historyFit={} trendBias={}", series.getName(),
analysis.degree(), analysis.barCount(), analysis.barDuration(),
String.format("%.2f", analysis.historyFitScore()), analysis.analysis().trendBias().direction());
analysis.analysis().scenarios().base().ifPresent(base -> logScenario(" Base", base));
}
Optional<ElliottWaveAnalysisResult.BaseScenarioAssessment> recommended = result.recommendedScenario();
if (recommended.isEmpty()) {
LOG.warn("No base-degree scenarios were produced");
return;
}
ElliottWaveAnalysisResult.BaseScenarioAssessment assessment = recommended.orElseThrow();
ElliottScenario scenario = assessment.scenario();
LOG.info("Recommended base scenario: id={} phase={} type={} confidence={} crossDegree={} composite={}",
scenario.id(), scenario.currentPhase(), scenario.type(),
String.format("%.2f", assessment.confidenceScore()),
String.format("%.2f", assessment.crossDegreeScore()),
String.format("%.2f", assessment.compositeScore()));
List<ElliottWaveAnalysisResult.SupportingScenarioMatch> matches = assessment.supportingMatches();
for (ElliottWaveAnalysisResult.SupportingScenarioMatch match : matches) {
LOG.info(" Match {}: scenarioId={} compat={} weightedCompat={} historyFit={}", match.degree(),
match.scenarioId(), String.format("%.2f", match.compatibilityScore()),
String.format("%.2f", match.weightedCompatibility()),
String.format("%.2f", match.historyFitScore()));
}
for (String note : result.notes()) {
LOG.info("Note: {}", note);
}
}
private static void logScenario(final String label, final ElliottScenario scenario) {
LOG.info("{} scenarioId={} phase={} type={} confidence={} direction={}", label, scenario.id(),
scenario.currentPhase(), scenario.type(), String.format("%.1f", scenario.confidence().asPercentage()),
scenario.hasKnownDirection() ? (scenario.isBullish() ? "bullish" : "bearish") : "unknown");
}
private static BarSeries loadSeries(final String resource) {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveMultiDegreeAnalysisDemo.class, resource,
"BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,100 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.confidence.ConfidenceProfiles;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates how pattern-specific confidence profiles influence scenario
* ranking.
*
* @since 0.22.2
*/
public class ElliottWavePatternProfileDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWavePatternProfileDemo.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
/**
* Runs the pattern profile comparison demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
BarSeries series = loadSeries();
if (series == null || series.isEmpty()) {
LOG.error("No data available for pattern profile demo");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
ElliottWaveAnalysisRunner defaultAnalyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.fractal(5))
.confidenceModelFactory(ConfidenceProfiles::defaultModel)
.build();
ElliottWaveAnalysisRunner patternAwareAnalyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.fractal(5))
.confidenceModelFactory(ConfidenceProfiles::patternAwareModel)
.build();
ElliottWaveAnalysisResult defaultSnapshot = defaultAnalyzer.analyze(series);
ElliottWaveAnalysisResult patternSnapshot = patternAwareAnalyzer.analyze(series);
ElliottAnalysisResult defaultResult = defaultSnapshot.analysisFor(baseDegree)
.orElseThrow(() -> new IllegalStateException("No base-degree analysis in default snapshot"))
.analysis();
ElliottAnalysisResult patternResult = patternSnapshot.analysisFor(baseDegree)
.orElseThrow(() -> new IllegalStateException("No base-degree analysis in pattern-aware snapshot"))
.analysis();
logBaseScenario("Default profile", defaultResult);
logBaseScenario("Pattern-aware profile", patternResult);
}
/**
* Logs the base scenario for the provided analysis result.
*
* @param label label for the output
* @param result analysis result to inspect
*/
private static void logBaseScenario(String label, ElliottAnalysisResult result) {
ElliottScenario base = result.scenarios().base().orElse(null);
if (base == null) {
LOG.info("{}: No scenarios available", label);
return;
}
LOG.info("{}: {} ({}) confidence={}%%", label, base.currentPhase(), base.type(),
String.format("%.1f", base.confidence().asPercentage()));
}
/**
* Loads the ossified BTC-USD dataset from classpath resources.
*
* @return loaded bar series, or {@code null} if unavailable
*/
private static BarSeries loadSeries() {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWavePatternProfileDemo.class, DEFAULT_OHLCV_RESOURCE,
"BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.support;
import java.io.InputStream;
import java.util.Objects;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Utility for loading ossified Elliott Wave demo series from classpath JSON
* resources.
*
* @since 0.22.4
*/
public final class OssifiedElliottWaveSeriesLoader {
/**
* Utility class.
*/
private OssifiedElliottWaveSeriesLoader() {
}
/**
* Loads an ossified classpath dataset into a detached {@link BarSeries} with a
* caller-provided display name.
*
* @param resourceOwner class used to resolve classpath resources
* @param resource classpath resource path
* @param seriesName name assigned to the returned series
* @param logger logger used for diagnostics
* @return loaded series, or {@code null} when loading fails
*/
public static BarSeries loadSeries(final Class<?> resourceOwner, final String resource, final String seriesName,
final Logger logger) {
Objects.requireNonNull(resourceOwner, "resourceOwner");
Objects.requireNonNull(resource, "resource");
Objects.requireNonNull(seriesName, "seriesName");
Objects.requireNonNull(logger, "logger");
String normalizedResource = resource.startsWith("/") ? resource : "/" + resource;
try (InputStream stream = resourceOwner.getResourceAsStream(normalizedResource)) {
if (stream == null) {
logger.error("Missing resource: {}", resource);
return null;
}
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
if (loaded == null) {
logger.error("Failed to load resource: {}", resource);
return null;
}
BarSeries series = new BaseBarSeriesBuilder().withName(seriesName).build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
logger.error("Failed to load dataset from {}: {}", resource, ex.getMessage(), ex);
return null;
}
}
}
@@ -0,0 +1,432 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import java.awt.GraphicsEnvironment;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.LinearTransactionCostCriterion;
import org.ta4j.core.num.Num;
import org.ta4j.core.criteria.ValueAtRiskCriterion;
import org.ta4j.core.criteria.pnl.GrossProfitLossCriterion;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.criteria.pnl.NetReturnCriterion;
import org.ta4j.core.criteria.pnl.NetAverageProfitCriterion;
import org.ta4j.core.criteria.pnl.NetAverageLossCriterion;
import org.ta4j.core.criteria.pnl.MaxConsecutiveLossCriterion;
import org.ta4j.core.criteria.pnl.MaxConsecutiveProfitCriterion;
import org.ta4j.core.indicators.MACDIndicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.keltner.KeltnerChannelFacade;
import org.ta4j.core.indicators.volume.MoneyFlowIndexIndicator;
import org.ta4j.core.indicators.volume.VWAPIndicator;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.StopGainRule;
import org.ta4j.core.rules.TrailingStopLossRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.CoinbaseHttpBarSeriesDataSource;
/**
* Coinbase Data Source Backtest - Advanced Risk Management & Transaction Costs
* <p>
* This example demonstrates advanced ta4j features focused on real-world
* trading considerations:
* <ul>
* <li>Loading historical OHLCV data from Coinbase Advanced Trade API</li>
* <li>MACD (Moving Average Convergence Divergence) with signal line and
* histogram</li>
* <li>Keltner Channels (alternative to Bollinger Bands using ATR)</li>
* <li>VWAP (Volume-Weighted Average Price) - critical for crypto trading</li>
* <li>Money Flow Index (MFI) - volume-weighted RSI</li>
* <li>Trailing Stop Loss - dynamic stop that follows price upward</li>
* <li>Stop Gain - take profit targets</li>
* <li>Transaction cost analysis - real-world impact of fees</li>
* <li>Value at Risk (VaR) - risk quantification</li>
* <li>Gross vs Net metrics - understanding the difference</li>
* <li>Multiple strategy comparison</li>
* </ul>
* <p>
* <strong>Strategy Concept:</strong> A trend-following strategy using MACD
* crossovers with Keltner Channel confirmation, VWAP for entry timing, and
* sophisticated risk management with trailing stops and take-profit targets.
* <p>
* <strong>Data Source:</strong> This example uses Coinbase's public market data
* API to fetch real cryptocurrency data. No API key is required, but be aware
* of rate limits (350 candles per request, automatically paginated).
* <p>
* <strong>Key Learning:</strong> This example emphasizes the critical
* importance of transaction costs in real trading. Notice how gross returns
* differ significantly from net returns after accounting for fees!
*/
public class CoinbaseBacktest {
private static final Logger LOG = LogManager.getLogger(CoinbaseBacktest.class);
public static void main(String[] args) {
System.out.println("╔══════════════════════════════════════════════════════════════╗");
System.out.println("║ Coinbase Data Source - Advanced Risk Management ║");
System.out.println("╚══════════════════════════════════════════════════════════════╝");
System.out.println();
// Step 1: Load historical price data from Coinbase
System.out.println("[1/8] Loading historical price data from Coinbase...");
System.out.println(" Fetching 1 year of daily data for Bitcoin (BTC-USD)...");
System.out.println(" (Crypto markets are 24/7 - perfect for trend strategies)");
// Load 1 year of daily data
CoinbaseHttpBarSeriesDataSource dataSource = new CoinbaseHttpBarSeriesDataSource(true);
BarSeries series = dataSource.loadSeriesInstance("BTC-USD",
CoinbaseHttpBarSeriesDataSource.CoinbaseInterval.ONE_DAY, 365);
// Alternative methods you can try:
// BarSeries series = dataSource.loadSeriesInstance("ETH-USD",
// CoinbaseInterval.ONE_DAY, 500); // 500 bars
// BarSeries series = dataSource.loadSeriesInstance("BTC-USD",
// CoinbaseInterval.FOUR_HOUR, 1000); // 4-hour data
// BarSeries series = dataSource.loadSeriesInstance("ETH-USD",
// CoinbaseInterval.ONE_DAY,
// Instant.parse("2023-01-01T00:00:00Z"),
// Instant.parse("2023-12-31T23:59:59Z")); // Date range
if (series == null || series.getBarCount() == 0) {
System.err.println(" [ERROR] Failed to load data from Coinbase");
System.err.println(" [TIP] Check your internet connection and try again");
System.err.println(" [TIP] Coinbase API may have rate limits - wait a few minutes and retry");
return;
}
System.out.printf(" [OK] Loaded %d bars of price data%n", series.getBarCount());
System.out.printf(" [INFO] Date range: %s to %s%n", series.getFirstBar().getEndTime(),
series.getLastBar().getEndTime());
System.out.println();
// Step 2: Create advanced indicators
System.out.println("[2/8] Creating advanced technical indicators...");
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// MACD: Trend-following momentum indicator
// MACD = 12-period EMA - 26-period EMA
// Signal = 9-period EMA of MACD
// Histogram = MACD - Signal
MACDIndicator macd = new MACDIndicator(closePrice, 12, 26);
EMAIndicator macdSignal = macd.getSignalLine(9);
// Histogram: difference between MACD and signal line
// Positive histogram = bullish momentum, negative = bearish
org.ta4j.core.indicators.numeric.NumericIndicator macdHistogram = macd.getHistogram(9);
// Keltner Channels: Volatility-based channels using ATR
// Similar to Bollinger Bands but uses ATR instead of standard deviation
// More responsive to volatility changes
KeltnerChannelFacade keltner = new KeltnerChannelFacade(series, 20, 10, 2.0);
// keltner.middle() = 20-period EMA
// keltner.upper() = middle + (2.0 * ATR)
// keltner.lower() = middle - (2.0 * ATR)
// VWAP: Volume-Weighted Average Price
// Critical for crypto trading - shows institutional price levels
// Often acts as support/resistance
// Using all available bars for VWAP calculation
VWAPIndicator vwap = new VWAPIndicator(series, series.getBarCount());
// Money Flow Index: Volume-weighted RSI (0-100)
// > 80 = overbought, < 20 = oversold
// More reliable than RSI because it includes volume
MoneyFlowIndexIndicator mfi = new MoneyFlowIndexIndicator(series, 14);
System.out.println(" [OK] Created MACD (12, 26, 9) with signal line and histogram");
System.out.println(" [OK] Created Keltner Channels (20 EMA, 10 ATR, 2.0 multiplier)");
System.out.println(" [OK] Created VWAP (Volume-Weighted Average Price)");
System.out.println(" [OK] Created Money Flow Index (14-period)");
System.out.println();
// Step 3: Build trend-following strategy with risk management
System.out.println("[3/8] Building trend-following strategy with advanced risk management...");
System.out.println(" Strategy: MACD crossover with Keltner Channel confirmation");
// Entry rule: Buy when MACD crosses above signal line (bullish crossover)
// AND price is above VWAP (institutional support)
// AND price is above Keltner middle (uptrend)
// AND MFI is not overbought (< 80)
Rule macdBullishCrossover = new CrossedUpIndicatorRule(macd, macdSignal);
Rule priceAboveVWAP = new OverIndicatorRule(closePrice, vwap);
Rule priceAboveKeltnerMiddle = new OverIndicatorRule(closePrice, keltner.middle());
Rule mfiNotOverbought = new UnderIndicatorRule(mfi, series.numFactory().numOf(80));
Rule buyingRule = macdBullishCrossover.and(priceAboveVWAP).and(priceAboveKeltnerMiddle).and(mfiNotOverbought);
// Exit rule: Sell when MACD crosses below signal line (bearish crossover)
// OR trailing stop loss triggers (protects profits)
// OR stop gain triggers (take profit at 15%)
// OR price falls below Keltner lower band (breakdown)
Rule macdBearishCrossover = new CrossedDownIndicatorRule(macd, macdSignal);
// Trailing stop: 5% trailing stop loss (follows price upward)
// This protects profits by moving the stop loss up as price rises
TrailingStopLossRule trailingStop = new TrailingStopLossRule(closePrice, series.numFactory().numOf(5.0));
// Stop gain: Take profit at 15% gain
StopGainRule stopGain = new StopGainRule(closePrice, series.numFactory().numOf(15.0));
Rule priceBelowKeltnerLower = new UnderIndicatorRule(closePrice, keltner.lower());
Rule sellingRule = macdBearishCrossover.or(trailingStop).or(stopGain).or(priceBelowKeltnerLower);
Strategy strategy = new BaseStrategy("MACD Trend Following (Risk Managed)", buyingRule, sellingRule);
System.out.println(" [OK] Entry: MACD bullish crossover + Price > VWAP + Price > Keltner middle + MFI < 80");
System.out.println(
" [OK] Exit: MACD bearish crossover OR 5% trailing stop OR 15% stop gain OR Price < Keltner lower");
System.out.println();
// Step 4: Run backtest
System.out.println("[4/8] Running backtest on historical data...");
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
System.out.printf(" [OK] Backtest complete: %d positions executed%n", tradingRecord.getPositionCount());
System.out.println();
// Step 5: Performance analysis with transaction costs
System.out.println("[5/8] Performance Analysis (WITH Transaction Costs)");
System.out.println(" ──────────────────────────────────────────");
// Transaction cost parameters (typical for crypto exchanges)
// Coinbase Advanced Trade: 0.4% maker fee, 0.6% taker fee
// Using 0.5% average (0.005) per trade (entry + exit = 1% total per round trip)
double initialAmount = 10000.0; // $10,000 starting capital
double feePercentage = 0.005; // 0.5% per trade
// Gross metrics (before transaction costs)
AnalysisCriterion grossReturn = new GrossReturnCriterion();
AnalysisCriterion grossProfitLoss = new GrossProfitLossCriterion();
Num grossReturnValue = grossReturn.calculate(series, tradingRecord);
Num grossProfitLossValue = grossProfitLoss.calculate(series, tradingRecord);
// Net metrics (after transaction costs)
AnalysisCriterion netReturn = new NetReturnCriterion();
AnalysisCriterion netProfitLoss = new NetProfitLossCriterion();
Num netReturnValue = netReturn.calculate(series, tradingRecord);
Num netProfitLossValue = netProfitLoss.calculate(series, tradingRecord);
// Transaction costs
LinearTransactionCostCriterion transactionCosts = new LinearTransactionCostCriterion(initialAmount,
feePercentage);
Num totalCosts = transactionCosts.calculate(series, tradingRecord);
// Average profit/loss per trade
AnalysisCriterion avgProfit = new NetAverageProfitCriterion();
AnalysisCriterion avgLoss = new NetAverageLossCriterion();
Num avgProfitValue = avgProfit.calculate(series, tradingRecord);
Num avgLossValue = avgLoss.calculate(series, tradingRecord);
// Consecutive streaks
AnalysisCriterion maxConsecutiveProfit = new MaxConsecutiveProfitCriterion();
AnalysisCriterion maxConsecutiveLoss = new MaxConsecutiveLossCriterion();
Num maxConsecutiveProfitValue = maxConsecutiveProfit.calculate(series, tradingRecord);
Num maxConsecutiveLossValue = maxConsecutiveLoss.calculate(series, tradingRecord);
// Risk metrics
ValueAtRiskCriterion var95 = new ValueAtRiskCriterion(0.95); // 95% confidence level
Num var95Value = var95.calculate(series, tradingRecord);
// Display comprehensive results
System.out.println(" Gross Performance (Before Fees):");
System.out.printf(" Gross Return: %.2f%%%n",
grossReturnValue.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Gross Profit/Loss: $%.2f%n", grossProfitLossValue.doubleValue());
System.out.println();
System.out.println(" Transaction Costs:");
System.out.printf(" Total Fees Paid: $%.2f (%.2f%% of initial capital)%n", totalCosts.doubleValue(),
totalCosts.dividedBy(series.numFactory().numOf(initialAmount))
.multipliedBy(series.numFactory().numOf(100))
.doubleValue());
System.out.printf(" Fee per Trade: %.2f%% (entry + exit)%n", feePercentage * 200);
System.out.println();
System.out.println(" Net Performance (After Fees):");
System.out.printf(" Net Return: %.2f%%%n",
netReturnValue.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Net Profit/Loss: $%.2f%n", netProfitLossValue.doubleValue());
System.out.printf(" Impact of Fees: %.2f%% (difference between gross and net)%n",
grossReturnValue.minus(netReturnValue).multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.println();
System.out.println(" Trade Statistics:");
System.out.printf(" Average Profit: $%.2f%n", avgProfitValue.doubleValue());
System.out.printf(" Average Loss: $%.2f%n", avgLossValue.doubleValue());
System.out.printf(" Max Consecutive Wins: %d%n", maxConsecutiveProfitValue.intValue());
System.out.printf(" Max Consecutive Losses: %d%n", maxConsecutiveLossValue.intValue());
System.out.println();
System.out.println(" Risk Metrics:");
System.out.printf(" Value at Risk (95%%): %.2f%% (worst expected loss)%n",
var95Value.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.println();
// Step 6: Compare strategies (with and without trailing stop)
System.out.println("[6/8] Strategy Comparison: With vs Without Trailing Stop");
System.out.println(" ──────────────────────────────────────────");
// Strategy without trailing stop (only MACD crossover)
Rule simpleBuyingRule = macdBullishCrossover.and(priceAboveVWAP);
Rule simpleSellingRule = macdBearishCrossover.or(stopGain).or(priceBelowKeltnerLower);
Strategy simpleStrategy = new BaseStrategy("MACD Simple (No Trailing Stop)", simpleBuyingRule,
simpleSellingRule);
TradingRecord simpleRecord = seriesManager.run(simpleStrategy);
Num simpleNetReturn = netReturn.calculate(series, simpleRecord);
Num strategyNetReturn = netReturn.calculate(series, tradingRecord);
System.out.printf(" Simple Strategy (no trailing stop): %.2f%% net return%n",
simpleNetReturn.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Risk-Managed Strategy (with trailing stop): %.2f%% net return%n",
strategyNetReturn.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Difference: %.2f%%%n",
strategyNetReturn.minus(simpleNetReturn).multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.println();
// Step 7: Visualize the strategy
System.out.println("[7/8] Generating comprehensive strategy visualization...");
boolean isHeadless = GraphicsEnvironment.isHeadless();
if (isHeadless) {
System.out.println(" [WARN] Headless environment detected - skipping chart display");
System.out.println(" [TIP] Run in a GUI environment to see interactive charts!");
} else {
try {
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("MACD Trend Following Strategy - Coinbase Data (BTC-USD)")
.withSeries(series) // Price bars (candlesticks)
.withTradingRecordOverlay(tradingRecord) // Trading positions marked on price chart
.withIndicatorOverlay(keltner.middle()) // Keltner middle band
.withIndicatorOverlay(keltner.upper()) // Keltner upper band
.withIndicatorOverlay(keltner.lower()) // Keltner lower band
.withIndicatorOverlay(vwap) // VWAP overlay
.withSubChart(macd) // MACD in first subchart
.withIndicatorOverlay(macdSignal) // MACD signal line
.withSubChart(macdHistogram) // MACD histogram in second subchart
.withSubChart(mfi) // Money Flow Index in third subchart
.withSubChart(new NetProfitLossCriterion(), tradingRecord) // Net P&L in fourth subchart
.toChart();
chartWorkflow.displayChart(chart, "ta4j Coinbase Backtest - MACD Trend Following with Risk Management");
System.out.println(" [OK] Multi-subchart displayed in new window");
System.out.println(" [TIP] Chart shows: Price with Keltner Channels & VWAP, MACD, MFI, and P&L");
} catch (Exception ex) {
LOG.warn("Failed to display chart: {}", ex.getMessage(), ex);
System.out.println(" [WARN] Could not display chart: " + ex.getMessage());
}
}
System.out.println();
// Step 8: Explain advanced concepts
System.out.println("[8/8] Advanced Concepts Demonstrated");
System.out.println(" ──────────────────────────────────────────");
System.out.println(" ✓ MACD: Trend-following momentum indicator");
System.out.println(" - Bullish crossover: MACD crosses above signal line");
System.out.println(" - Histogram shows momentum strength");
System.out.println();
System.out.println(" ✓ Keltner Channels: Volatility-based price channels");
System.out.println(" - Uses ATR instead of standard deviation (more responsive)");
System.out.println(" - Upper/lower bands adapt to market volatility");
System.out.println();
System.out.println(" ✓ VWAP: Volume-Weighted Average Price");
System.out.println(" - Critical for crypto trading (institutional levels)");
System.out.println(" - Price above VWAP = bullish, below = bearish");
System.out.println();
System.out.println(" ✓ Trailing Stop Loss: Dynamic risk management");
System.out.println(" - Follows price upward, protecting profits");
System.out.println(" - More effective than fixed stop loss in trending markets");
System.out.println();
System.out.println(" ✓ Stop Gain: Take-profit targets");
System.out.println(" - Locks in profits at predetermined levels");
System.out.println(" - Prevents giving back gains in volatile markets");
System.out.println();
System.out.println(" ✓ Transaction Costs: Real-world impact");
System.out.println(" - Gross returns vs Net returns (after fees)");
System.out.println(" - Fees can significantly impact profitability");
System.out.println(" - Always account for costs in backtesting!");
System.out.println();
System.out.println(" ✓ Value at Risk (VaR): Risk quantification");
System.out.println(" - Measures worst expected loss at confidence level");
System.out.println(" - Helps understand downside risk");
System.out.println();
System.out.println(" ✓ Gross vs Net Metrics: Understanding the difference");
System.out.println(" - Gross: Before transaction costs");
System.out.println(" - Net: After transaction costs (real-world)");
System.out.println(" - Always use net metrics for real trading decisions");
System.out.println();
// Summary
System.out.println("╔══════════════════════════════════════════════════════════════╗");
System.out.println("║ Summary ║");
System.out.println("╚══════════════════════════════════════════════════════════════╝");
System.out.println();
System.out.println("What just happened?");
System.out.println();
System.out.println(" 1. Loaded 1 year of daily OHLCV data for BTC-USD from Coinbase");
System.out.println(" 2. Created advanced indicators:");
System.out.println(" - MACD with signal line and histogram (trend momentum)");
System.out.println(" - Keltner Channels (volatility-based channels)");
System.out.println(" - VWAP (institutional price levels)");
System.out.println(" - Money Flow Index (volume-weighted momentum)");
System.out.println(" 3. Built a trend-following strategy with risk management:");
System.out.println(" - Entry: MACD bullish crossover + Price > VWAP + Price > Keltner middle + MFI < 80");
System.out.println(
" - Exit: MACD bearish crossover OR 5% trailing stop OR 15% stop gain OR Price < Keltner lower");
System.out.println(" 4. Backtested with transaction costs (0.5% per trade)");
System.out.println(" 5. Analyzed gross vs net performance (impact of fees)");
System.out.println(" 6. Compared strategies (with/without trailing stop)");
System.out.println(" 7. Calculated risk metrics (Value at Risk)");
if (!isHeadless) {
System.out.println(" 8. Visualized with multi-subchart (Price, MACD, MFI, P&L)");
}
System.out.println();
System.out.println("Advanced Features Demonstrated:");
System.out.println(" ✓ MACD with signal line and histogram");
System.out.println(" ✓ Keltner Channels (ATR-based volatility bands)");
System.out.println(" ✓ VWAP for institutional price levels");
System.out.println(" ✓ Money Flow Index (volume-weighted RSI)");
System.out.println(" ✓ Trailing Stop Loss (dynamic profit protection)");
System.out.println(" ✓ Stop Gain (take-profit targets)");
System.out.println(" ✓ Transaction cost analysis (real-world impact)");
System.out.println(" ✓ Value at Risk (risk quantification)");
System.out.println(" ✓ Gross vs Net metrics comparison");
System.out.println(" ✓ Multiple strategy comparison");
System.out.println();
System.out.println("Coinbase Data Source Features:");
System.out.println(" - Load data by number of days: loadSeries(\"BTC-USD\", 365)");
System.out.println(" - Load data by bar count: loadSeries(\"BTC-USD\", ONE_DAY, 500)");
System.out.println(" - Load data by date range: loadSeries(\"BTC-USD\", ONE_DAY, start, end)");
System.out.println(" - Supports multiple intervals: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 1d");
System.out.println(" - Works with all Coinbase trading pairs (BTC-USD, ETH-USD, etc.)");
System.out.println(" - Automatic pagination for large date ranges (350 candles per request)");
System.out.println();
System.out.println("Key Takeaways:");
System.out.println(" • Transaction costs matter! Always use net returns for decisions.");
System.out.println(" • Trailing stops protect profits better than fixed stops.");
System.out.println(" • VWAP is critical for crypto trading (institutional levels).");
System.out.println(" • Risk metrics (VaR) help quantify downside risk.");
System.out.println(" • Gross vs Net metrics show the real impact of fees.");
System.out.println();
System.out.println("Next Steps - Experiment with:");
System.out.println(" - Different cryptocurrencies: \"ETH-USD\", \"SOL-USD\", \"ADA-USD\"");
System.out.println(" - Adjust MACD periods (try 8/21 or 19/39)");
System.out.println(" - Modify trailing stop percentage (try 3% or 7%)");
System.out.println(" - Change stop gain targets (try 10% or 20%)");
System.out.println(" - Test different fee structures (0.1%, 0.25%, 1.0%)");
System.out.println(" - Try different intervals: FOUR_HOUR, SIX_HOUR for shorter timeframes");
System.out.println(" - Explore other examples in ta4j-examples");
System.out.println(" - Check out the wiki: https://ta4j.github.io/ta4j-wiki/");
System.out.println();
System.out.println("Your turn! Modify this code and see how transaction costs affect profitability.");
System.out.println();
}
}
@@ -0,0 +1,153 @@
/*
* 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.BacktestExecutor;
import org.ta4j.core.indicators.KalmanFilterIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.BasePerformanceReport;
import org.ta4j.core.reports.PositionStatsReport;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
public class MovingAverageCrossOverRangeBacktest {
private static final Logger LOG = LogManager.getLogger(MovingAverageCrossOverRangeBacktest.class);
private static final int DEFAULT_DECIMAL_PRECISION = 16;
public static void main(String[] args) {
DecimalNum.configureDefaultPrecision(DEFAULT_DECIMAL_PRECISION);
String resourceName = "Binance-ETH-USD-PT5M-20230313_20230315.json";
InputStream resourceStream = MovingAverageCrossOverRangeBacktest.class.getClassLoader()
.getResourceAsStream(resourceName);
if (resourceStream == null) {
LOG.error("File not found in classpath: {}", resourceName);
return;
}
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resourceStream);
if (series == null || series.isEmpty()) {
LOG.error("Bar series was null or empty: {}", series);
return;
}
int barCountStart = 3;
int barCountStop = 200;
int barCountStep = 3;
final List<Strategy> strategies = new ArrayList<>();
for (int shortBarCount = barCountStart; shortBarCount <= barCountStop; shortBarCount += barCountStep) {
for (int longBarCount = shortBarCount
+ barCountStep; longBarCount <= barCountStop; longBarCount += barCountStep) {
String strategyName = String.format("Sma(%d) CrossOver Sma(%d)", shortBarCount, longBarCount);
strategies.add(
new BaseStrategy(strategyName, createSmaCrossEntryRule(series, shortBarCount, longBarCount),
createSmaCrossExitRule(series, shortBarCount, longBarCount)));
}
}
Instant startInstant = Instant.now();
BacktestExecutor backtestExecutor = new BacktestExecutor(series);
List<TradingStatement> tradingStatements = backtestExecutor.execute(strategies, DecimalNum.valueOf(50),
Trade.TradeType.BUY);
LOG.debug("Back-tested {} strategies on {}-bar series using decimal precision of {} in {}", strategies.size(),
series.getBarCount(), DEFAULT_DECIMAL_PRECISION, Duration.between(startInstant, Instant.now()));
LOG.debug(printReport(tradingStatements));
}
private static Rule createSmaCrossEntryRule(BarSeries series, int shortBarCount, int longBarCount) {
Indicator<Num> closePrice = new ClosePriceIndicator(series);
KalmanFilterIndicator kalmanFilteredClosePrice = new KalmanFilterIndicator(closePrice);
SMAIndicator smaShort = new SMAIndicator(kalmanFilteredClosePrice, shortBarCount);
SMAIndicator smaLong = new SMAIndicator(kalmanFilteredClosePrice, longBarCount);
return new CrossedUpIndicatorRule(smaShort, smaLong);
}
private static Rule createSmaCrossExitRule(BarSeries series, int shortBarCount, int longBarCount) {
Indicator<Num> closePrice = new ClosePriceIndicator(series);
KalmanFilterIndicator kalmanFilteredClosePrice = new KalmanFilterIndicator(closePrice);
SMAIndicator smaShort = new SMAIndicator(kalmanFilteredClosePrice, shortBarCount);
SMAIndicator smaLong = new SMAIndicator(kalmanFilteredClosePrice, longBarCount);
return new CrossedDownIndicatorRule(smaShort, smaLong);
}
private static String printReport(List<TradingStatement> tradingStatements) {
StringJoiner resultJoiner = new StringJoiner(System.lineSeparator());
for (TradingStatement statement : tradingStatements) {
resultJoiner.add(printStatementReport(statement).toString());
}
return resultJoiner.toString();
}
private static StringBuilder printStatementReport(TradingStatement statement) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append("######### ")
.append(statement.getStrategy().getName())
.append(" #########")
.append(System.lineSeparator())
.append(printPerformanceReport(statement.getPerformanceReport()))
.append(System.lineSeparator())
.append(printPositionStats(statement.getPositionStatsReport()))
.append(System.lineSeparator())
.append("###########################");
return resultBuilder;
}
private static StringBuilder printPerformanceReport(BasePerformanceReport report) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append("--------- performance report ---------")
.append(System.lineSeparator())
.append("total loss: ")
.append(report.totalLoss)
.append(System.lineSeparator())
.append("total profit: ")
.append(report.totalProfit)
.append(System.lineSeparator())
.append("total profit loss: ")
.append(report.totalProfitLoss)
.append(System.lineSeparator())
.append("total profit loss percentage: ")
.append(report.totalProfitLossPercentage)
.append(System.lineSeparator())
.append("---------------------------");
return resultBuilder;
}
private static StringBuilder printPositionStats(PositionStatsReport report) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append("--------- trade statistics report ---------")
.append(System.lineSeparator())
.append("loss trade count: ")
.append(report.getLossCount())
.append(System.lineSeparator())
.append("profit trade count: ")
.append(report.getProfitCount())
.append(System.lineSeparator())
.append("break even trade count: ")
.append(report.getBreakEvenCount())
.append(System.lineSeparator())
.append("---------------------------");
return resultBuilder;
}
}
@@ -0,0 +1,142 @@
/*
* 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.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
public class SimpleMovingAverageBacktest {
private static final Logger LOG = LogManager.getLogger(SimpleMovingAverageBacktest.class);
public static void main(String[] args) throws InterruptedException {
BarSeries series = createBarSeries();
Strategy strategy3DaySma = create3DaySmaStrategy(series);
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord3DaySma = seriesManager.run(strategy3DaySma, Trade.TradeType.BUY,
DecimalNum.valueOf(50));
LOG.debug(tradingRecord3DaySma.toString());
Strategy strategy2DaySma = create2DaySmaStrategy(series);
TradingRecord tradingRecord2DaySma = seriesManager.run(strategy2DaySma, Trade.TradeType.BUY,
DecimalNum.valueOf(50));
LOG.debug(tradingRecord2DaySma.toString());
var criterion = new GrossReturnCriterion();
Num calculate3DaySma = criterion.calculate(series, tradingRecord3DaySma);
Num calculate2DaySma = criterion.calculate(series, tradingRecord2DaySma);
LOG.debug(calculate3DaySma.toString());
LOG.debug(calculate2DaySma.toString());
}
private static BarSeries createBarSeries() {
final var series = new BaseBarSeriesBuilder().build();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(1))
.openPrice(100.0)
.highPrice(100.0)
.lowPrice(100.0)
.closePrice(100.0)
.volume(1060)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(2))
.openPrice(110.0)
.highPrice(110.0)
.lowPrice(110.0)
.closePrice(110.0)
.volume(1070)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(3))
.openPrice(140.0)
.highPrice(140.0)
.lowPrice(140.0)
.closePrice(140.0)
.volume(1080)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(4))
.openPrice(119.0)
.highPrice(119.0)
.lowPrice(119.0)
.closePrice(119.0)
.volume(1090)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(5))
.openPrice(100.0)
.highPrice(100.0)
.lowPrice(100.0)
.closePrice(100.0)
.volume(1100)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(6))
.openPrice(110.0)
.highPrice(110.0)
.lowPrice(110.0)
.closePrice(110.0)
.volume(1110)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(7))
.openPrice(120.0)
.highPrice(120.0)
.lowPrice(120.0)
.closePrice(120.0)
.volume(1120)
.add();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(createDay(8))
.openPrice(130.0)
.highPrice(130.0)
.lowPrice(130.0)
.closePrice(130.0)
.volume(1130)
.add();
return series;
}
private static Instant createDay(int day) {
return ZonedDateTime.of(2018, 01, day, 12, 0, 0, 0, ZoneOffset.UTC).toInstant();
}
private static Strategy create3DaySmaStrategy(BarSeries series) {
var closePrice = new ClosePriceIndicator(series);
var sma = new SMAIndicator(closePrice, 3);
return new BaseStrategy(new UnderIndicatorRule(sma, closePrice), new OverIndicatorRule(sma, closePrice));
}
private static Strategy create2DaySmaStrategy(BarSeries series) {
var closePrice = new ClosePriceIndicator(series);
var sma = new SMAIndicator(closePrice, 2);
return new BaseStrategy(new UnderIndicatorRule(sma, closePrice), new OverIndicatorRule(sma, closePrice));
}
}
@@ -0,0 +1,158 @@
/*
* 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.TradingStatementExecutionResult.WeightedCriterion;
import org.ta4j.core.criteria.drawdown.ReturnOverMaxDrawdownCriterion;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.Num;
import org.ta4j.core.reports.BasePerformanceReport;
import org.ta4j.core.reports.PositionStatsReport;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Example demonstrating how to use {@link BacktestExecutor} for parallel
* strategy evaluation.
*
* This example:
* <ul>
* <li>Creates multiple variations of a Simple Moving Average (SMA)
* strategy.</li>
* <li>Uses {@code BacktestExecutor} to run them in parallel over the data
* series.</li>
* <li>Ranks the strategies based on a composite {@link WeightedCriterion}.</li>
* <li>Prints a performance report for the best strategies.</li>
* </ul>
*/
public class SimpleMovingAverageRangeBacktest {
private static final Logger LOG = LogManager.getLogger(SimpleMovingAverageRangeBacktest.class);
private static final int DEFAULT_TOP_STRATEGIES = 3;
public static void main(String[] args) {
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
int start = 3;
int stop = 50;
int step = 5;
final List<Strategy> strategies = new ArrayList<>();
for (int i = start; i <= stop; i += step) {
Strategy strategy = new BaseStrategy("Sma(" + i + ")", createEntryRule(series, i),
createExitRule(series, i));
strategies.add(strategy);
}
BacktestExecutor backtestExecutor = new BacktestExecutor(series);
BacktestExecutionResult result = backtestExecutor.executeWithRuntimeReport(strategies, DecimalNum.valueOf(50),
Trade.TradeType.BUY);
List<TradingStatement> tradingStatements = selectTopStrategies(result, DEFAULT_TOP_STRATEGIES);
LOG.debug("Top {} weighted SMA strategies (7 parts net profit, 3 parts return over max drawdown)",
tradingStatements.size());
LOG.debug(printReport(tradingStatements));
}
/**
* Selects the top strategies for this example using weighted, normalized
* ranking.
*
* @param result full backtest result for the SMA parameter sweep
* @param limit maximum number of strategies to keep
* @return top strategies ordered by the example weighted criteria
*/
static List<TradingStatement> selectTopStrategies(BacktestExecutionResult result, int limit) {
Objects.requireNonNull(result, "result cannot be null");
return result.getTopStrategiesWeighted(limit, WeightedCriterion.of(new NetProfitCriterion(), 7.0),
WeightedCriterion.of(new ReturnOverMaxDrawdownCriterion(), 3.0));
}
private static Rule createEntryRule(BarSeries series, int barCount) {
Indicator<Num> closePrice = new ClosePriceIndicator(series);
SMAIndicator sma = new SMAIndicator(closePrice, barCount);
return new UnderIndicatorRule(sma, closePrice);
}
private static Rule createExitRule(BarSeries series, int barCount) {
Indicator<Num> closePrice = new ClosePriceIndicator(series);
SMAIndicator sma = new SMAIndicator(closePrice, barCount);
return new OverIndicatorRule(sma, closePrice);
}
private static String printReport(List<TradingStatement> tradingStatements) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append(System.lineSeparator());
for (TradingStatement statement : tradingStatements) {
resultBuilder.append(printStatementReport(statement));
resultBuilder.append(System.lineSeparator());
}
return resultBuilder.toString();
}
private static StringBuilder printStatementReport(TradingStatement statement) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append("######### ")
.append(statement.getStrategy().getName())
.append(" #########")
.append(System.lineSeparator())
.append(printPerformanceReport(statement.getPerformanceReport()))
.append(System.lineSeparator())
.append(printPositionStats(statement.getPositionStatsReport()))
.append(System.lineSeparator())
.append("###########################");
return resultBuilder;
}
private static StringBuilder printPerformanceReport(BasePerformanceReport report) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append("--------- performance report ---------")
.append(System.lineSeparator())
.append("total loss: ")
.append(report.totalLoss)
.append(System.lineSeparator())
.append("total profit: ")
.append(report.totalProfit)
.append(System.lineSeparator())
.append("total profit loss: ")
.append(report.totalProfitLoss)
.append(System.lineSeparator())
.append("total profit loss percentage: ")
.append(report.totalProfitLossPercentage)
.append(System.lineSeparator())
.append("---------------------------");
return resultBuilder;
}
private static StringBuilder printPositionStats(PositionStatsReport report) {
StringBuilder resultBuilder = new StringBuilder();
resultBuilder.append("--------- trade statistics report ---------")
.append(System.lineSeparator())
.append("loss trade count: ")
.append(report.getLossCount())
.append(System.lineSeparator())
.append("profit trade count: ")
.append(report.getProfitCount())
.append(System.lineSeparator())
.append("break even trade count: ")
.append(report.getBreakEvenCount())
.append(System.lineSeparator())
.append("---------------------------");
return resultBuilder;
}
}
@@ -0,0 +1,175 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import java.time.Instant;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.ExecutionMatchPolicy;
import org.ta4j.core.ExecutionSide;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.TradeFill;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Example demonstrating partial trade fills and custom trading records.
*
* <p>
* This example shows how to use {@link org.ta4j.core.TradeFill} events directly
* on a {@link org.ta4j.core.TradingRecord}. This is useful when connecting ta4j
* to broker APIs that confirm orders asynchronously or execute orders in
* multiple parts.
* </p>
*/
public class TradeFillRecordingExample {
private static final Logger LOG = LogManager.getLogger(TradeFillRecordingExample.class);
private static final BarSeries ANALYSIS_SERIES = new BaseBarSeriesBuilder()
.withName("trade-fill-recording-analysis")
.build();
private static final NumFactory NUM_FACTORY = ANALYSIS_SERIES.numFactory();
private static final NetProfitCriterion NET_PROFIT_CRITERION = new NetProfitCriterion();
public static void main(String[] args) {
LOG.info("Step 1: stream partial fills directly into TradingRecord");
BaseTradingRecord streamingRecord = new BaseTradingRecord(TradeType.BUY, ExecutionMatchPolicy.FIFO,
new ZeroCostModel(), new ZeroCostModel(), null, null);
recordStreamingOrder(streamingRecord, "BUY entry order", entryFills());
logOpenExposure("After BUY entry order", streamingRecord);
recordStreamingOrder(streamingRecord, "SELL exit order", exitFills());
logRecordSummary("Streaming fills", streamingRecord);
LOG.info("Step 2: record the same exchange fills as grouped logical orders");
BaseTradingRecord groupedTradeRecord = buildGroupedTradeRecord();
logRecordSummary("Grouped order batches", groupedTradeRecord);
LOG.info("Step 3: replay one partial exit under each ExecutionMatchPolicy");
for (ExecutionMatchPolicy matchPolicy : ExecutionMatchPolicy.values()) {
BaseTradingRecord matchPolicyRecord = buildMatchingPolicyRecord(matchPolicy);
logMatchingPolicyOutcome(matchPolicy, matchPolicyRecord);
}
LOG.info("The same TradingRecord APIs cover direct fills, grouped trades, and lot-matching inspection.");
}
static BaseTradingRecord buildStreamingRecord() {
BaseTradingRecord record = new BaseTradingRecord(TradeType.BUY, ExecutionMatchPolicy.FIFO, new ZeroCostModel(),
new ZeroCostModel(), null, null);
for (TradeFill fill : entryFills()) {
record.operate(fill);
}
for (TradeFill fill : exitFills()) {
record.operate(fill);
}
return record;
}
static BaseTradingRecord buildGroupedTradeRecord() {
BaseTradingRecord record = new BaseTradingRecord(TradeType.BUY, ExecutionMatchPolicy.FIFO, new ZeroCostModel(),
new ZeroCostModel(), null, null);
record.operate(Trade.fromFills(TradeType.BUY, entryFills()));
record.operate(Trade.fromFills(TradeType.SELL, exitFills()));
return record;
}
static Num closedNetProfit(TradingRecord record) {
return NET_PROFIT_CRITERION.calculate(ANALYSIS_SERIES, record);
}
static BaseTradingRecord buildMatchingPolicyRecord(ExecutionMatchPolicy matchPolicy) {
BaseTradingRecord record = new BaseTradingRecord(TradeType.BUY, matchPolicy, new ZeroCostModel(),
new ZeroCostModel(), null, null);
for (TradeFill fill : matchingPolicyEntryFills()) {
record.operate(fill);
}
String correlationId = matchPolicy == ExecutionMatchPolicy.SPECIFIC_ID ? "lot-b" : null;
record.operate(new TradeFill(3, Instant.parse("2025-02-01T00:02:00Z"), NUM_FACTORY.numOf(120),
NUM_FACTORY.one(), NUM_FACTORY.zero(), ExecutionSide.SELL, "policy-exit", correlationId));
return record;
}
private static void recordStreamingOrder(TradingRecord record, String label, List<TradeFill> fills) {
LOG.info("{} ({})", label, fills.get(0).orderId());
for (TradeFill fill : fills) {
record.operate(fill);
LOG.info(" {} fill {} -> index={}, price={}, amount={}, fee={}, openPositions={}", fill.side(),
fill.correlationId(), fill.index(), fill.price(), fill.amount(), fill.fee(),
record.getOpenPositions().size());
}
}
private static void logOpenExposure(String label, TradingRecord record) {
Position currentPosition = record.getCurrentPosition();
LOG.info("{} -> netOpenAmount={}, netAverageEntry={}, openLots={}, recordedFees={}", label,
currentPosition.amount(), currentPosition.averageEntryPrice(), record.getOpenPositions().size(),
record.getRecordedTotalFees());
for (int i = 0; i < record.getOpenPositions().size(); i++) {
Position openPosition = record.getOpenPositions().get(i);
LOG.info(" open[{}] lot={} amount={} avgEntry={}", i, openPosition.getEntry().getCorrelationId(),
openPosition.amount(), openPosition.averageEntryPrice());
}
}
private static void logRecordSummary(String label, TradingRecord record) {
LOG.info("{} -> trades={}, closedPositions={}, openPositions={}, fees={}, closedProfit={}", label,
record.getTrades().size(), record.getPositionCount(), record.getOpenPositions().size(),
record.getRecordedTotalFees(), closedNetProfit(record));
for (int i = 0; i < record.getPositions().size(); i++) {
Position position = record.getPositions().get(i);
LOG.info(" position[{}] entry={} @ {} amount={}, exit={} @ {}, profit={}", i,
position.getEntry().getIndex(), position.getEntry().getPricePerAsset(),
position.getEntry().getAmount(), position.getExit().getIndex(),
position.getExit().getPricePerAsset(), position.getProfit());
}
}
private static void logMatchingPolicyOutcome(ExecutionMatchPolicy matchPolicy, TradingRecord record) {
Position closedPosition = record.getPositions().get(0);
Position currentPosition = record.getCurrentPosition();
LOG.info("{} -> closedLot={} entry={} amount={}, remainingOpenLots={}, netOpenAmount={}, netAverageEntry={}",
matchPolicy, closedPosition.getEntry().getCorrelationId(), closedPosition.getEntry().getPricePerAsset(),
closedPosition.getEntry().getAmount(), record.getOpenPositions().size(), currentPosition.amount(),
currentPosition.averageEntryPrice());
for (int i = 0; i < record.getOpenPositions().size(); i++) {
Position openPosition = record.getOpenPositions().get(i);
LOG.info(" remaining[{}] lot={} amount={} avgEntry={}", i, openPosition.getEntry().getCorrelationId(),
openPosition.amount(), openPosition.averageEntryPrice());
}
}
private static List<TradeFill> entryFills() {
return List.of(
new TradeFill(4, Instant.parse("2025-01-01T00:00:00Z"), NUM_FACTORY.hundred(), NUM_FACTORY.one(),
NUM_FACTORY.numOf(0.1), ExecutionSide.BUY, "entry-fill-1", "entry-order"),
new TradeFill(5, Instant.parse("2025-01-01T00:01:00Z"), NUM_FACTORY.numOf(101), NUM_FACTORY.two(),
NUM_FACTORY.numOf(0.2), ExecutionSide.BUY, "entry-fill-2", "entry-order"));
}
private static List<TradeFill> exitFills() {
return List.of(
new TradeFill(8, Instant.parse("2025-01-01T00:02:00Z"), NUM_FACTORY.numOf(110), NUM_FACTORY.one(),
NUM_FACTORY.numOf(0.05), ExecutionSide.SELL, "exit-fill-1", "exit-order"),
new TradeFill(9, Instant.parse("2025-01-01T00:03:00Z"), NUM_FACTORY.numOf(111), NUM_FACTORY.two(),
NUM_FACTORY.numOf(0.06), ExecutionSide.SELL, "exit-fill-2", "exit-order"));
}
private static List<TradeFill> matchingPolicyEntryFills() {
return List.of(
new TradeFill(1, Instant.parse("2025-02-01T00:00:00Z"), NUM_FACTORY.hundred(), NUM_FACTORY.two(),
NUM_FACTORY.zero(), ExecutionSide.BUY, "policy-entry-a", "lot-a"),
new TradeFill(2, Instant.parse("2025-02-01T00:01:00Z"), NUM_FACTORY.numOf(106), NUM_FACTORY.one(),
NUM_FACTORY.zero(), ExecutionSide.BUY, "policy-entry-b", "lot-b"));
}
}
@@ -0,0 +1,179 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Objects;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.ExecutionMatchPolicy;
import org.ta4j.core.Position;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.backtest.BacktestExecutor;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.backtest.SlippageExecutionModel;
import org.ta4j.core.backtest.TradeExecutionModel;
import org.ta4j.core.backtest.TradeOnCurrentCloseModel;
import org.ta4j.core.backtest.TradeOnNextOpenModel;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.FixedRule;
/**
* Example demonstrating execution engine parity across different execution
* models.
*
* This backtest asserts that strategies yield identical results when run
* through:
* <ul>
* <li>{@link BarSeriesManager} (standard simulation)</li>
* <li>{@link BacktestExecutor} (batch/parallel simulation)</li>
* <li>Manual iterative loops (mimicking live/paper trading)</li>
* </ul>
* It ensures the default {@link BaseTradingRecord} acts as a unified record
* model.
*/
public class TradingRecordParityBacktest {
private static final Logger LOG = LogManager.getLogger(TradingRecordParityBacktest.class);
public static void main(String[] args) {
BarSeries series = createSeries();
Strategy strategy = createStrategy();
Num slippageRatio = series.numFactory().numOf(0.01);
TradingRecord nextOpenRecord = runWithExecutionModel(series, strategy, new TradeOnNextOpenModel());
TradingRecord currentCloseRecord = runWithExecutionModel(series, strategy, new TradeOnCurrentCloseModel());
TradingRecord slippageRecord = runWithExecutionModel(series, strategy,
new SlippageExecutionModel(slippageRatio, TradeExecutionModel.PriceSource.CURRENT_CLOSE));
logExecutionComparison("Next-open fills", nextOpenRecord);
logExecutionComparison("Current-close fills", currentCloseRecord);
logExecutionComparison("Current-close fills with 1% slippage", slippageRecord);
TradingRecord providedRecord = runWithProvidedRecord(series, strategy, new TradeOnCurrentCloseModel());
assertEquivalent(currentCloseRecord, providedRecord, "provided BaseTradingRecord");
TradingRecord factoryConfiguredRecord = runWithFactoryConfiguredRecord(series, strategy,
new TradeOnCurrentCloseModel());
assertEquivalent(currentCloseRecord, factoryConfiguredRecord, "factory-configured BaseTradingRecord");
LOG.info("Execution-model comparison and record-parity checks passed.");
}
static Strategy createStrategy() {
return new BaseStrategy("Single-entry timing demo", new FixedRule(1), new FixedRule(3));
}
static BarSeries createSeries() {
BarSeries series = new BaseBarSeriesBuilder().withName("parity-series").build();
addBar(series, 1, 100d, 100d);
addBar(series, 2, 102d, 104d);
addBar(series, 3, 109d, 111d);
addBar(series, 4, 107d, 103d);
addBar(series, 5, 99d, 101d);
addBar(series, 6, 105d, 107d);
return series;
}
static TradingRecord runWithExecutionModel(BarSeries series, Strategy strategy,
TradeExecutionModel tradeExecutionModel) {
BarSeriesManager manager = new BarSeriesManager(series, new ZeroCostModel(), new ZeroCostModel(),
tradeExecutionModel);
return manager.run(strategy, TradeType.BUY, series.numFactory().one(), 0, series.getEndIndex());
}
static TradingRecord runWithProvidedRecord(BarSeries series, Strategy strategy,
TradeExecutionModel tradeExecutionModel) {
BarSeriesManager manager = new BarSeriesManager(series, new ZeroCostModel(), new ZeroCostModel(),
tradeExecutionModel);
BaseTradingRecord providedRecord = new BaseTradingRecord(TradeType.BUY, ExecutionMatchPolicy.FIFO,
new ZeroCostModel(), new ZeroCostModel(), 0, series.getEndIndex());
return manager.run(strategy, providedRecord, series.numFactory().one(), 0, series.getEndIndex());
}
static TradingRecord runWithFactoryConfiguredRecord(BarSeries series, Strategy strategy,
TradeExecutionModel tradeExecutionModel) {
BarSeriesManager configuredManager = new BarSeriesManager(series, new ZeroCostModel(), new ZeroCostModel(),
tradeExecutionModel,
(tradeType, startIndex, endIndex, txCost, holdCost) -> new BaseTradingRecord(tradeType,
ExecutionMatchPolicy.FIFO, txCost, holdCost, startIndex, endIndex));
return configuredManager.run(strategy, TradeType.BUY, series.numFactory().one(), 0, series.getEndIndex());
}
private static void addBar(BarSeries series, int day, double openPrice, double closePrice) {
double highPrice = Math.max(openPrice, closePrice);
double lowPrice = Math.min(openPrice, closePrice);
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(ZonedDateTime.of(2025, 1, day, 12, 0, 0, 0, ZoneOffset.UTC).toInstant())
.openPrice(openPrice)
.highPrice(highPrice)
.lowPrice(lowPrice)
.closePrice(closePrice)
.volume(1000d + day)
.add();
}
static void assertEquivalent(TradingRecord expected, TradingRecord actual, String runLabel) {
require(expected.getTrades().size() == actual.getTrades().size(), () -> runLabel + ": trade count mismatch");
require(expected.getPositions().size() == actual.getPositions().size(),
() -> runLabel + ": position count mismatch");
require(expected.isClosed() == actual.isClosed(), () -> runLabel + ": close-state mismatch");
for (int i = 0; i < expected.getTrades().size(); i++) {
Trade left = expected.getTrades().get(i);
Trade right = actual.getTrades().get(i);
assertTrade(left, right, runLabel + ": trade[" + i + "]");
}
for (int i = 0; i < expected.getPositions().size(); i++) {
Position left = expected.getPositions().get(i);
Position right = actual.getPositions().get(i);
assertTrade(left.getEntry(), right.getEntry(), runLabel + ": position[" + i + "].entry");
if (left.getExit() != null || right.getExit() != null) {
require(left.getExit() != null && right.getExit() != null, () -> runLabel + ": exit mismatch");
assertTrade(left.getExit(), right.getExit(), runLabel + ": position[" + i + "].exit");
}
}
}
private static void logExecutionComparison(String label, TradingRecord tradingRecord) {
Position position = tradingRecord.getPositions().get(0);
LOG.info("{} -> entry={} @ {}, exit={} @ {}, gross profit={}", label, position.getEntry().getIndex(),
position.getEntry().getPricePerAsset(), position.getExit().getIndex(),
position.getExit().getPricePerAsset(), position.getGrossProfit());
}
private static void assertTrade(Trade expected, Trade actual, String label) {
require(expected.getType() == actual.getType(), () -> label + ": type mismatch");
require(expected.getIndex() == actual.getIndex(), () -> label + ": index mismatch");
assertNum(expected.getPricePerAsset(), actual.getPricePerAsset(), label + ": price mismatch");
assertNum(expected.getAmount(), actual.getAmount(), label + ": amount mismatch");
}
private static void assertNum(Num expected, Num actual, String message) {
if (expected == null || actual == null) {
require(Objects.equals(expected, actual), () -> message);
return;
}
require(expected.isEqual(actual), () -> message + " expected=" + expected + ", actual=" + actual);
}
private static void require(boolean condition, Supplier<String> messageSupplier) {
if (!condition) {
throw new IllegalStateException(messageSupplier.get());
}
}
}
@@ -0,0 +1,365 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import java.awt.GraphicsEnvironment;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.AnalysisCriterion.PositionFilter;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.ExpectancyCriterion;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.SqnCriterion;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.drawdown.ReturnOverMaxDrawdownCriterion;
import org.ta4j.core.criteria.VersusEnterAndHoldCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.criteria.pnl.NetReturnCriterion;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.UltimateOscillatorIndicator;
import org.ta4j.core.indicators.VortexIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.bollinger.BollingerBandsLowerIndicator;
import org.ta4j.core.indicators.bollinger.BollingerBandsMiddleIndicator;
import org.ta4j.core.indicators.bollinger.BollingerBandsUpperIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.helpers.PreviousValueIndicator;
import org.ta4j.core.indicators.statistics.StandardDeviationIndicator;
import org.ta4j.core.indicators.volume.OnBalanceVolumeIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.AverageTrueRangeStopLossRule;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.YahooFinanceHttpBarSeriesDataSource;
/**
* Yahoo Finance Data Source Backtest - Advanced Multi-Indicator Strategy
* <p>
* This example demonstrates advanced ta4j features beyond the Quickstart:
* <ul>
* <li>Loading historical OHLCV data from Yahoo Finance API</li>
* <li>Bollinger Bands for mean reversion signals</li>
* <li>ATR-based dynamic stop-loss (adapts to market volatility)</li>
* <li>RSI for momentum confirmation</li>
* <li>Volume analysis with On-Balance Volume (OBV)</li>
* <li>Trend confirmation with Vortex and Ultimate Oscillator</li>
* <li>Indicator composition using BinaryOperationIndicator</li>
* <li>Advanced performance metrics (Expectancy, SQN, Maximum Drawdown)</li>
* <li>Multi-subchart visualization</li>
* </ul>
* <p>
* <strong>Strategy Concept:</strong> A mean reversion strategy that buys when
* price touches the lower Bollinger Band (oversold) with RSI confirmation and
* volume support, using ATR-based stops that adapt to market volatility.
* <p>
* <strong>Data Source:</strong> This example uses Yahoo Finance's public API to
* fetch real market data. No API key is required, but be aware of rate limits
* (~2000 requests/hour per IP).
* <p>
* Run this example to see an advanced trading strategy backtested on real
* market data with comprehensive analysis!
*/
public class YahooFinanceBacktest {
private static final Logger LOG = LogManager.getLogger(YahooFinanceBacktest.class);
public static void main(String[] args) {
System.out.println("╔══════════════════════════════════════════════════════════════╗");
System.out.println("║ Yahoo Finance Data Source - Backtesting Example ║");
System.out.println("╚══════════════════════════════════════════════════════════════╝");
System.out.println();
// Step 1: Load historical price data from Yahoo Finance
System.out.println("[1/7] Loading historical price data from Yahoo Finance...");
System.out.println(" Fetching 2 years of daily data for Apple Inc. (AAPL)...");
System.out.println(" (More data = better indicator calculations)");
// Load 2 years of data for better indicator stability
YahooFinanceHttpBarSeriesDataSource dataSource = new YahooFinanceHttpBarSeriesDataSource(true);
BarSeries series = dataSource.loadSeriesInstance("AAPL",
YahooFinanceHttpBarSeriesDataSource.YahooFinanceInterval.DAY_1, 730);
// Alternative methods you can try:
// BarSeries series = dataSource.loadSeriesInstance("AAPL",
// YahooFinanceInterval.DAY_1, 500); // 500 bars
// BarSeries series = dataSource.loadSeriesInstance("MSFT",
// YahooFinanceInterval.HOUR_1, 1000); // Hourly data
// BarSeries series = dataSource.loadSeriesInstance("BTC-USD",
// YahooFinanceInterval.DAY_1,
// Instant.parse("2023-01-01T00:00:00Z"),
// Instant.parse("2023-12-31T23:59:59Z")); // Date range
if (series == null || series.getBarCount() == 0) {
System.err.println(" [ERROR] Failed to load data from Yahoo Finance");
System.err.println(" [TIP] Check your internet connection and try again");
System.err.println(" [TIP] Yahoo Finance may have rate limits - wait a few minutes and retry");
return;
}
System.out.printf(" [OK] Loaded %d bars of price data%n", series.getBarCount());
System.out.printf(" [INFO] Date range: %s to %s%n", series.getFirstBar().getEndTime(),
series.getLastBar().getEndTime());
System.out.println();
// Step 2: Create base indicators
System.out.println("[2/7] Creating technical indicators...");
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// Bollinger Bands: Mean reversion indicator
// Uses 20-period SMA with 2 standard deviations
int bbPeriod = 20;
double bbMultiplier = 2.0;
SMAIndicator bbSma = new SMAIndicator(closePrice, bbPeriod);
StandardDeviationIndicator bbStdDev = new StandardDeviationIndicator(closePrice, bbPeriod);
BollingerBandsMiddleIndicator bbMiddle = new BollingerBandsMiddleIndicator(bbSma);
BollingerBandsUpperIndicator bbUpper = new BollingerBandsUpperIndicator(bbMiddle, bbStdDev,
series.numFactory().numOf(bbMultiplier));
BollingerBandsLowerIndicator bbLower = new BollingerBandsLowerIndicator(bbMiddle, bbStdDev,
series.numFactory().numOf(bbMultiplier));
// RSI: Momentum oscillator (14-period)
RSIIndicator rsi = new RSIIndicator(closePrice, 14);
// On-Balance Volume: Volume-based trend indicator
OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(series);
// Trend confirmation indicators
VortexIndicator vortex = new VortexIndicator(series, 14);
UltimateOscillatorIndicator ultimateOscillator = new UltimateOscillatorIndicator(series);
// Note: ATR is used in the AverageTrueRangeStopLossRule below
// Advanced: You can also create custom indicators using
// BinaryOperationIndicator
// Example: Calculate distance from price to middle band as a percentage
// BinaryOperationIndicator priceToMiddleRatio =
// BinaryOperationIndicator.quotient(
// BinaryOperationIndicator.difference(closePrice, bbMiddle), bbMiddle);
System.out.println(" [OK] Created Bollinger Bands (20-period, 2 std dev)");
System.out.println(" [OK] Created RSI (14-period)");
System.out.println(" [OK] Created ATR (14-period) for dynamic stops");
System.out.println(" [OK] Created On-Balance Volume indicator");
System.out.println(" [OK] Created Vortex oscillator (14-period) for trend direction");
System.out.println(" [OK] Created Ultimate Oscillator (7/14/28) for trend strength");
System.out.println(" [OK] Created custom price-to-middle-band ratio indicator");
System.out.println();
// Step 3: Build advanced trading rules
System.out.println("[3/7] Building advanced trading strategy rules...");
System.out.println(" Strategy: Mean reversion with multiple confirmations");
// Entry rule: Buy when price is at or below lower Bollinger Band (oversold)
// AND RSI is below 45 (oversold confirmation - less strict than 40)
// Price touching lower BB OR crossing below it
Rule priceAtLowerBB = new UnderIndicatorRule(closePrice, bbLower)
.or(new CrossedDownIndicatorRule(closePrice, bbLower));
Rule rsiOversold = new UnderIndicatorRule(rsi, series.numFactory().numOf(45));
// Optional: OBV rising provides additional confirmation (but not required)
// This makes the strategy more tradeable while still using volume analysis
Rule obvRising = new OverIndicatorRule(obv, new PreviousValueIndicator(obv, 1));
Rule vortexBullish = new OverIndicatorRule(vortex, series.numFactory().zero());
Rule ultimateBullish = new OverIndicatorRule(ultimateOscillator, series.numFactory().numOf(50));
// Entry: Price at lower BB + RSI oversold + (OBV rising OR price below middle
// band) + trend confirmation from Vortex and Ultimate Oscillator
// This allows entries when either volume confirms OR price is clearly oversold
Rule priceBelowMiddle = new UnderIndicatorRule(closePrice, bbMiddle);
Rule buyingRule = priceAtLowerBB.and(rsiOversold)
.and(obvRising.or(priceBelowMiddle))
.and(vortexBullish)
.and(ultimateBullish);
// Exit rule: Sell when price reaches upper Bollinger Band (overbought)
// OR RSI crosses above 65 (overbought - less strict than 70 for more exits)
// OR ATR-based stop loss triggers (dynamic, adapts to volatility)
// OR Vortex turns bearish
Rule exitCondition1 = new CrossedUpIndicatorRule(closePrice, bbUpper)
.or(new OverIndicatorRule(closePrice, bbUpper));
Rule exitCondition2 = new OverIndicatorRule(rsi, series.numFactory().numOf(65));
// ATR-based stop: 2.5x ATR below entry price (allows for some volatility)
Rule exitCondition3 = new AverageTrueRangeStopLossRule(series, 14, 2.5);
Rule exitCondition4 = new UnderIndicatorRule(vortex, series.numFactory().zero());
Rule sellingRule = exitCondition1.or(exitCondition2).or(exitCondition3).or(exitCondition4);
Strategy strategy = new BaseStrategy("Bollinger Bands Mean Reversion (Trend-Confirmed)", buyingRule,
sellingRule);
System.out.println(
" [OK] Entry: Price at/below lower BB + RSI < 45 + (OBV rising OR price below middle) + Vortex > 0 + Ultimate > 50");
System.out.println(" [OK] Exit: Price at/above upper BB OR RSI > 65 OR ATR stop (2.5x ATR) OR Vortex < 0");
System.out.println();
// Step 4: Run backtest
System.out.println("[4/7] Running backtest on historical data...");
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
System.out.printf(" [OK] Backtest complete: %d positions executed%n", tradingRecord.getPositionCount());
System.out.println();
// Step 5: Advanced performance analysis
System.out.println("[5/7] Advanced Performance Analysis");
System.out.println(" ──────────────────────────────────────────");
// Basic metrics
AnalysisCriterion netReturn = new NetReturnCriterion();
AnalysisCriterion winningPositionsRatio = new PositionsRatioCriterion(PositionFilter.PROFIT);
Num netReturnValue = netReturn.calculate(series, tradingRecord);
Num winRate = winningPositionsRatio.calculate(series, tradingRecord);
// Advanced risk-adjusted metrics
AnalysisCriterion romad = new ReturnOverMaxDrawdownCriterion();
AnalysisCriterion maxDrawdown = new MaximumDrawdownCriterion();
AnalysisCriterion expectancy = new ExpectancyCriterion();
AnalysisCriterion sqn = new SqnCriterion(); // System Quality Number (higher = better)
AnalysisCriterion versusEnterAndHoldCriterion = new VersusEnterAndHoldCriterion(new NetReturnCriterion());
Num romadValue = romad.calculate(series, tradingRecord);
Num maxDrawdownValue = maxDrawdown.calculate(series, tradingRecord);
Num expectancyValue = expectancy.calculate(series, tradingRecord);
Num sqnValue = sqn.calculate(series, tradingRecord);
Num vsBuyHold = versusEnterAndHoldCriterion.calculate(series, tradingRecord);
// Display comprehensive results
System.out.println(" Basic Metrics:");
System.out.printf(" Total Positions: %d%n", tradingRecord.getPositionCount());
System.out.printf(" Net Return: %.2f%%%n",
netReturnValue.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Win Rate: %.1f%%%n",
winRate.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.println();
System.out.println(" Risk Metrics:");
System.out.printf(" Maximum Drawdown: %.2f%%%n",
maxDrawdownValue.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.printf(" Return/Max Drawdown: %.2f%n", romadValue.doubleValue());
System.out.println();
System.out.println(" Advanced Metrics:");
System.out.printf(" Expectancy: %.4f (avg profit per trade)%n", expectancyValue.doubleValue());
System.out.printf(" SQN (System Quality): %.2f (higher = better)%n", sqnValue.doubleValue());
System.out.printf(" vs Buy & Hold: %.2f%%%n",
vsBuyHold.multipliedBy(series.numFactory().numOf(100)).doubleValue());
System.out.println();
// Step 6: Visualize the strategy with multiple subcharts
System.out.println("[6/7] Generating comprehensive strategy visualization...");
boolean isHeadless = GraphicsEnvironment.isHeadless();
if (isHeadless) {
System.out.println(" [WARN] Headless environment detected - skipping chart display");
System.out.println(" [TIP] Run in a GUI environment to see interactive charts!");
} else {
try {
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("Bollinger Bands Mean Reversion Strategy - Yahoo Finance Data (AAPL)")
.withSeries(series) // Price bars (candlesticks)
.withTradingRecordOverlay(tradingRecord) // Trading positions marked on price chart
.withIndicatorOverlay(bbMiddle) // Middle band overlay
.withIndicatorOverlay(bbUpper) // Upper band overlay
.withIndicatorOverlay(bbLower) // Lower band overlay
.withSubChart(rsi) // RSI in first subchart
.withSubChart(obv) // OBV in second subchart
.withSubChart(vortex) // Vortex oscillator in third subchart
.withSubChart(ultimateOscillator) // Ultimate Oscillator in fourth subchart
.withSubChart(new NetProfitLossCriterion(), tradingRecord) // Net profit/loss in fifth subchart
.toChart();
chartWorkflow.displayChart(chart, "ta4j Yahoo Finance Backtest - Advanced Mean Reversion Strategy");
System.out.println(" [OK] Multi-subchart displayed in new window");
System.out.println(" [TIP] Chart shows: Price with BB bands, RSI, OBV, Vortex, Ultimate, and P&L");
} catch (Exception ex) {
LOG.warn("Failed to display chart: {}", ex.getMessage(), ex);
System.out.println(" [WARN] Could not display chart: " + ex.getMessage());
}
}
System.out.println();
// Step 7: Explain advanced concepts
System.out.println("[7/7] Advanced Concepts Demonstrated");
System.out.println(" ──────────────────────────────────────────");
System.out.println(" ✓ Bollinger Bands: Mean reversion indicator");
System.out.println(" - Price tends to revert to the middle band");
System.out.println(" - Lower band = oversold, Upper band = overbought");
System.out.println();
System.out.println(" ✓ ATR-based Stop Loss: Dynamic risk management");
System.out.println(" - Adapts to market volatility automatically");
System.out.println(" - Tighter stops in calm markets, wider in volatile markets");
System.out.println();
System.out.println(" ✓ Multi-Indicator Confirmation: Reduces false signals");
System.out.println(" - RSI confirms oversold/overbought conditions");
System.out.println(" - OBV confirms volume support for price moves");
System.out.println(" - Vortex confirms directional trend bias (+VI vs -VI)");
System.out.println(" - Ultimate Oscillator confirms multi-timeframe buying pressure");
System.out.println();
System.out.println(" ✓ Advanced Metrics: Deeper performance insights");
System.out.println(" - Expectancy: Average profit per trade");
System.out.println(" - SQN: System Quality Number (risk-adjusted performance)");
System.out.println(" - Maximum Drawdown: Largest peak-to-trough decline");
System.out.println();
// Summary
System.out.println("╔══════════════════════════════════════════════════════════════╗");
System.out.println("║ Summary ║");
System.out.println("╚══════════════════════════════════════════════════════════════╝");
System.out.println();
System.out.println("What just happened?");
System.out.println();
System.out.println(" 1. Loaded 2 years of daily OHLCV data for AAPL from Yahoo Finance");
System.out.println(" 2. Created advanced indicators:");
System.out.println(" - Bollinger Bands (mean reversion)");
System.out.println(" - RSI (momentum confirmation)");
System.out.println(" - ATR (volatility for dynamic stops)");
System.out.println(" - OBV (volume trend confirmation)");
System.out.println(" - Vortex oscillator (trend direction confirmation)");
System.out.println(" - Ultimate Oscillator (trend strength confirmation)");
System.out.println(" - Custom price-to-middle-band ratio (indicator composition)");
System.out.println(" 3. Built a sophisticated mean reversion strategy:");
System.out.println(
" - Entry: Price at/below lower BB + RSI < 45 + (OBV rising OR price below middle) + Vortex > 0 + Ultimate > 50");
System.out.println(" - Exit: Price at/above upper BB OR RSI > 65 OR ATR stop (2.5x ATR) OR Vortex < 0");
System.out.println(" 4. Backtested with ATR-based dynamic stop-loss (adapts to volatility)");
System.out.println(" 5. Analyzed with advanced metrics (Expectancy, SQN, Max Drawdown)");
if (!isHeadless) {
System.out.println(" 6. Visualized with multi-subchart (Price, RSI, OBV, P&L)");
}
System.out.println();
System.out.println("Advanced Features Demonstrated:");
System.out.println(" ✓ Bollinger Bands for mean reversion trading");
System.out.println(" ✓ ATR-based dynamic stop-loss (better than fixed %)");
System.out.println(" ✓ Multi-indicator trend confirmation (RSI, OBV, Vortex, Ultimate)");
System.out.println(" ✓ Indicator composition (BinaryOperationIndicator)");
System.out.println(" ✓ Advanced performance metrics (Expectancy, SQN)");
System.out.println(" ✓ Multi-subchart visualization");
System.out.println();
System.out.println("Yahoo Finance Data Source Features:");
System.out.println(" - Load data by number of days: loadSeries(\"AAPL\", 730)");
System.out.println(" - Load data by bar count: loadSeries(\"AAPL\", DAY_1, 500)");
System.out.println(" - Load data by date range: loadSeries(\"AAPL\", DAY_1, start, end)");
System.out.println(" - Supports multiple intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk, 1mo");
System.out.println(" - Works with stocks, ETFs, and cryptocurrencies");
System.out.println(" - Automatic pagination for large date ranges");
System.out.println();
System.out.println("Next Steps - Experiment with:");
System.out.println(" - Different tickers: \"MSFT\", \"GOOGL\", \"BTC-USD\", \"ETH-USD\"");
System.out.println(" - Adjust BB period (try 10, 30) and multiplier (try 1.5, 2.5)");
System.out.println(" - Modify RSI thresholds (try 30/70 or 35/65)");
System.out.println(" - Change ATR multiplier for stops (try 1.5x or 3.0x)");
System.out.println(" - Add MACD or Stochastic for additional confirmation");
System.out.println(" - Try different intervals: HOUR_1, WEEK_1 for different timeframes");
System.out.println(" - Explore other examples in ta4j-examples");
System.out.println(" - Check out the wiki: https://ta4j.github.io/ta4j-wiki/");
System.out.println();
System.out.println("Your turn! Modify this code and see how it affects performance.");
System.out.println();
}
}
@@ -0,0 +1,167 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.barSeries;
import java.time.Duration;
import java.time.Instant;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.DoubleNumFactory;
public class BuildBarSeries {
private static final Logger LOG = LogManager.getLogger(BuildBarSeries.class);
/**
* Calls different functions that shows how a BaseBarSeries could be created and
* how Bars could be added
*
* @param args command line arguments (ignored)
*/
@SuppressWarnings("unused")
public static void main(String[] args) {
BarSeries a = buildAndAddData();
LOG.debug("a: {}", a.getBar(0).getClosePrice().getName());
a = buildAndAddData();
LOG.debug("a: {}", a.getBar(0).getClosePrice().getName());
BarSeries b = buildWithDouble();
BarSeries c = buildWithBigDecimal();
BarSeries d = buildManually();
BarSeries e = buildManuallyDoubleNum();
BarSeries f = buildManuallyAndAddBarManually();
}
private static BarSeries buildAndAddData() {
var series = new BaseBarSeriesBuilder().withName("mySeries").build();
var endTime = Instant.now();
// Instant endTime, Number openPrice, Number highPrice, Number lowPrice,
// Number closePrice, volume
addBars(series, endTime);
return series;
}
private static void addBars(final BarSeries series, final Instant endTime) {
series.addBar(series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTime)
.openPrice(105.42)
.highPrice(112.99)
.lowPrice(104.01)
.closePrice(111.42)
.volume(1337)
.build());
series.addBar(series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTime.plus(Duration.ofDays(1)))
.openPrice(111.43)
.highPrice(112.83)
.lowPrice(107.77)
.closePrice(107.99)
.volume(1234)
.build());
series.addBar(series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTime.plus(Duration.ofDays(2)))
.openPrice(107.90)
.highPrice(117.50)
.lowPrice(107.90)
.closePrice(115.42)
.volume(4242)
.build());
}
private static BarSeries buildWithDouble() {
var series = new BaseBarSeriesBuilder().withName("mySeries")
.withNumFactory(DoubleNumFactory.getInstance())
.build();
var endTime = Instant.now();
addBars(series, endTime);
return series;
}
private static BarSeries buildWithBigDecimal() {
var series = new BaseBarSeriesBuilder().withName("mySeries")
.withNumFactory(DecimalNumFactory.getInstance())
.build();
var endTime = Instant.now();
addBars(series, endTime);
// ...
return series;
}
private static BarSeries buildManually() {
var series = new BaseBarSeriesBuilder().withName("mySeries").build(); // uses BigDecimalNum
var endTime = Instant.now();
addBars(series, endTime);
// ...
return series;
}
private static BarSeries buildManuallyDoubleNum() {
var series = new BaseBarSeriesBuilder().withName("mySeries")
.withNumFactory(DoubleNumFactory.getInstance())
.build();
var endTime = Instant.now();
addBars(series, endTime);
// ...
return series;
}
private static BarSeries buildManuallyAndAddBarManually() {
var series = new BaseBarSeriesBuilder().withName("mySeries")
.withNumFactory(DoubleNumFactory.getInstance())
.build();
// create bars and add them to the series. The bars have the same Num type
// as the series
var endTime = Instant.now();
Bar b1 = series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTime)
.openPrice(105.42)
.highPrice(112.99)
.lowPrice(104.01)
.closePrice(111.42)
.volume(1337.0)
.build();
Bar b2 = series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTime.plus(Duration.ofDays(1)))
.openPrice(111.43)
.highPrice(112.83)
.lowPrice(107.77)
.closePrice(107.99)
.volume(1234.0)
.build();
Bar b3 = series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTime.plus(Duration.ofDays(2)))
.openPrice(107.90)
.highPrice(117.50)
.lowPrice(107.90)
.closePrice(115.42)
.volume(4242.0)
.build();
// ...
series.addBar(b1);
series.addBar(b2);
series.addBar(b3);
return series;
}
}
@@ -0,0 +1,173 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.bots;
import java.time.Duration;
import java.time.Instant;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.bars.TimeBarBuilder;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* Example of a trading bot running on a live, moving {@link BarSeries}.
*
* This example simulates a real-time trading environment:
* <ul>
* <li>A strategy is evaluated on the latest bar on each tick/iteration.</li>
* <li>The bot maintains a {@link BaseTradingRecord} and updates it using
* {@link Trade.TradeType} entry/exit signals.</li>
* <li>The series is continuously fed with new simulated ticks, mimicking a live
* feed.</li>
* </ul>
*/
public class TradingBotOnMovingBarSeries {
private static final Logger LOG = LogManager.getLogger(TradingBotOnMovingBarSeries.class);
/**
* Close price of the last bar
*/
private static Num LAST_BAR_CLOSE_PRICE;
/**
* Builds a moving bar series (i.e. keeping only the maxBarCount last bars)
*
* @param maxBarCount the number of bars to keep in the bar series (at maximum)
* @return a moving bar series
*/
private static BarSeries initMovingBarSeries(int maxBarCount) {
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Limitating the number of bars to maxBarCount
series.setMaximumBarCount(maxBarCount);
LAST_BAR_CLOSE_PRICE = series.getBar(series.getEndIndex()).getClosePrice();
LOG.debug("Initial bar count: {} (limited to {}), close price = {}", series.getBarCount(), maxBarCount,
LAST_BAR_CLOSE_PRICE);
return series;
}
/**
* @param series a bar series
* @return a dummy strategy
*/
private static Strategy buildStrategy(BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
SMAIndicator sma = new SMAIndicator(closePrice, 12);
// Signals
// Buy when SMA goes over close price
// Sell when close price goes over SMA
Strategy buySellSignals = new BaseStrategy(new OverIndicatorRule(sma, closePrice),
new UnderIndicatorRule(sma, closePrice));
return buySellSignals;
}
/**
* Generates a random decimal number between min and max.
*
* @param min the minimum bound
* @param max the maximum bound
* @return a random decimal number between min and max
*/
private static Num randDecimal(Num min, Num max) {
Num randomDecimal = null;
if (min != null && max != null && min.isLessThan(max)) {
Num range = max.minus(min);
Num position = range.multipliedBy(DecimalNum.valueOf(Math.random()));
randomDecimal = min.plus(position);
}
return randomDecimal;
}
/**
* Generates a random bar.
*
* @return a random bar
*/
private static Bar generateRandomBar() {
final Num maxRange = DecimalNum.valueOf("0.03"); // 3.0%
Num openPrice = LAST_BAR_CLOSE_PRICE;
Num lowPrice = openPrice.minus(maxRange.multipliedBy(DecimalNum.valueOf(Math.random())));
Num highPrice = openPrice.plus(maxRange.multipliedBy(DecimalNum.valueOf(Math.random())));
Num closePrice = randDecimal(lowPrice, highPrice);
LAST_BAR_CLOSE_PRICE = closePrice;
return new TimeBarBuilder(DecimalNumFactory.getInstance()).amount(1)
.volume(1)
.timePeriod(Duration.ofDays(1))
.endTime(Instant.now())
.openPrice(openPrice)
.highPrice(highPrice)
.lowPrice(lowPrice)
.closePrice(closePrice)
.build();
}
public static void main(String[] args) throws InterruptedException {
LOG.debug("********************** Initialization **********************");
// Getting the bar series
BarSeries series = initMovingBarSeries(20);
// Building the trading strategy
Strategy strategy = buildStrategy(series);
// Initializing the trading history
TradingRecord tradingRecord = new BaseTradingRecord();
LOG.debug("************************************************************");
/*
* We run the strategy for the 50 next bars.
*/
for (int i = 0; i < 50; i++) {
// New bar
Thread.sleep(30); // I know...
Bar newBar = generateRandomBar();
LOG.debug("------------------------------------------------------\nBar {} added, close price = {}", i,
newBar.getClosePrice().doubleValue());
series.addBar(newBar);
int endIndex = series.getEndIndex();
if (strategy.shouldEnter(endIndex)) {
// Our strategy should enter
LOG.debug("Strategy should ENTER on {}", endIndex);
boolean entered = tradingRecord.enter(endIndex, newBar.getClosePrice(), DecimalNum.valueOf(10));
if (entered) {
Trade entry = tradingRecord.getLastEntry();
LOG.debug("Entered on {} (price={}, amount={})", entry.getIndex(),
entry.getNetPrice().doubleValue(), entry.getAmount().doubleValue());
}
} else if (strategy.shouldExit(endIndex)) {
// Our strategy should exit
LOG.debug("Strategy should EXIT on {}", endIndex);
boolean exited = tradingRecord.exit(endIndex, newBar.getClosePrice(), DecimalNum.valueOf(10));
if (exited) {
Trade exit = tradingRecord.getLastExit();
LOG.debug("Exited on {} (price={}, amount={})", exit.getIndex(), exit.getNetPrice().doubleValue(),
exit.getAmount().doubleValue());
}
}
}
}
}
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.num.Num;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* An indicator that visualizes an {@link AnalysisCriterion} over time by
* calculating the criterion value at each bar index using a partial trading
* record.
*
* <p>
* For each bar index, this indicator creates a partial trading record
* containing only positions that have been entered (and optionally closed) up
* to that index, then calculates the criterion value for that partial record.
* This allows analysis criteria to be visualized as time series on charts.
* </p>
*
* @since 0.19
*/
public class AnalysisCriterionIndicator extends CachedIndicator<Num> {
private final AnalysisCriterion criterion;
private final TradingRecord fullTradingRecord;
private final List<Trade> allTrades;
private final String label;
/**
* Constructs an AnalysisCriterionIndicator.
*
* @param series the bar series
* @param criterion the analysis criterion to visualize
* @param tradingRecord the full trading record
* @throws NullPointerException if any parameter is null
*/
public AnalysisCriterionIndicator(BarSeries series, AnalysisCriterion criterion, TradingRecord tradingRecord) {
super(series);
this.criterion = Objects.requireNonNull(criterion, "Criterion cannot be null");
this.fullTradingRecord = Objects.requireNonNull(tradingRecord, "Trading record cannot be null");
this.allTrades = new ArrayList<>(tradingRecord.getTrades());
this.label = deriveLabel(criterion);
}
@Override
protected Num calculate(int index) {
// Create a partial trading record with trades up to this index
TradingRecord partialRecord = createPartialTradingRecord(index);
// Calculate criterion value for the partial record
return criterion.calculate(getBarSeries(), partialRecord);
}
@Override
public int getCountOfUnstableBars() {
// Analysis criteria don't have unstable bars - they calculate from trading
// records
return 0;
}
/**
* Creates a partial trading record containing only trades that have occurred up
* to the specified index.
*
* @param upToIndex the maximum bar index to include
* @return a partial trading record
*/
private TradingRecord createPartialTradingRecord(int upToIndex) {
// Filter trades where trade index <= upToIndex
List<Trade> partialTrades = new ArrayList<>();
for (Trade trade : allTrades) {
if (trade.getIndex() <= upToIndex) {
partialTrades.add(trade);
}
}
// Create new trading record from filtered trades
if (partialTrades.isEmpty()) {
// Return empty record with same cost models and starting type
return new BaseTradingRecord(fullTradingRecord.getStartingType(),
fullTradingRecord.getTransactionCostModel(), fullTradingRecord.getHoldingCostModel());
}
Trade[] tradesArray = partialTrades.toArray(new Trade[0]);
return new BaseTradingRecord(fullTradingRecord.getTransactionCostModel(),
fullTradingRecord.getHoldingCostModel(), tradesArray);
}
@Override
public String toString() {
return label;
}
private static String deriveLabel(AnalysisCriterion criterion) {
String simpleName = criterion.getClass().getSimpleName();
if (simpleName.endsWith("Criterion") && simpleName.length() > "Criterion".length()) {
simpleName = simpleName.substring(0, simpleName.length() - "Criterion".length());
}
return simpleName.isEmpty() ? criterion.getClass().getSimpleName() : simpleName;
}
}
@@ -0,0 +1,122 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting;
import java.util.Objects;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.indicators.PriceChannel;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.num.NaN;
import org.ta4j.core.num.Num;
/**
* Wraps channel boundary values for chart overlays, extracting the selected
* line from a {@link PriceChannel} when available.
*
* <p>
* When the wrapped indicator yields {@link Num} values, this indicator forwards
* them unchanged. When the wrapped indicator yields {@link PriceChannel}
* values, it extracts the configured boundary (upper, lower, or median).
* </p>
*
* @since 0.22.0
*/
public final class ChannelBoundaryIndicator extends CachedIndicator<Num> {
private final PriceChannel.Boundary boundary;
private final Indicator<?> indicator;
private final String label;
/**
* Constructs a channel boundary indicator.
*
* @param indicator the indicator providing boundary values or channels
* @param boundary the boundary role to extract or label
* @throws IllegalArgumentException if the indicator is not attached to a bar
* series
* @throws NullPointerException if indicator or boundary is null
*/
public ChannelBoundaryIndicator(Indicator<?> indicator, PriceChannel.Boundary boundary) {
this(indicator, boundary, null);
}
/**
* Constructs a channel boundary indicator with a custom label.
*
* @param indicator the indicator providing boundary values or channels
* @param boundary the boundary role to extract or label
* @param label the label to expose in legends (uses a default if blank)
* @throws IllegalArgumentException if the indicator is not attached to a bar
* series
* @throws NullPointerException if indicator or boundary is null
*/
public ChannelBoundaryIndicator(Indicator<?> indicator, PriceChannel.Boundary boundary, String label) {
super(requireSeries(indicator));
this.indicator = indicator;
this.boundary = Objects.requireNonNull(boundary, "Boundary cannot be null");
this.label = resolveLabel(label, indicator, boundary);
}
/**
* Returns the boundary role configured for this indicator.
*
* @return the channel boundary role
*/
public PriceChannel.Boundary boundary() {
return boundary;
}
@Override
protected Num calculate(int index) {
Object value = indicator.getValue(index);
if (value == null) {
return NaN.NaN;
}
if (value instanceof Num num) {
return num;
}
if (value instanceof PriceChannel channel) {
return switch (boundary) {
case UPPER -> channel.upper();
case LOWER -> channel.lower();
case MEDIAN -> channel.median();
};
}
throw new IllegalStateException("ChannelBoundaryIndicator expects Num or PriceChannel values, but received "
+ value.getClass().getName());
}
@Override
public int getCountOfUnstableBars() {
return indicator.getCountOfUnstableBars();
}
@Override
public String toString() {
return label;
}
private static BarSeries requireSeries(Indicator<?> indicator) {
Objects.requireNonNull(indicator, "Indicator cannot be null");
BarSeries series = indicator.getBarSeries();
if (series == null) {
throw new IllegalArgumentException("Indicator " + indicator + " is not attached to a BarSeries");
}
return series;
}
private static String resolveLabel(String label, Indicator<?> indicator, PriceChannel.Boundary boundary) {
if (label != null && !label.isBlank()) {
return label;
}
String baseLabel = indicator.toString();
String boundaryLabel = boundary.label();
if (baseLabel == null || baseLabel.isBlank()) {
return boundaryLabel;
}
return baseLabel + " (" + boundaryLabel + ")";
}
}
@@ -0,0 +1,92 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.annotation;
import static org.ta4j.core.num.NaN.NaN;
import java.awt.Color;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.num.Num;
/**
* Indicator overlay that exposes sparse bar-index labels for chart annotations.
*
* <p>
* The primary {@link #getValue(int)} output returns the label Y-value
* (typically a price) at labeled indices and {@code NaN} elsewhere. Chart
* renderers can additionally consume {@link #labels()} to attach text
* annotations at the labeled indices.
*/
public class BarSeriesLabelIndicator extends CachedIndicator<Num> {
public enum LabelPlacement {
ABOVE, BELOW, CENTER
}
public record BarLabel(int barIndex, Num yValue, String text, LabelPlacement placement, Color color,
double fontScale) {
public BarLabel(int barIndex, Num yValue, String text, LabelPlacement placement) {
this(barIndex, yValue, text, placement, null, 1.0);
}
public BarLabel(int barIndex, Num yValue, String text, LabelPlacement placement, Color color) {
this(barIndex, yValue, text, placement, color, 1.0);
}
public BarLabel {
if (barIndex < 0) {
throw new IllegalArgumentException("barIndex must be non-negative");
}
Objects.requireNonNull(yValue, "yValue");
Objects.requireNonNull(text, "text");
Objects.requireNonNull(placement, "placement");
if (!Double.isFinite(fontScale) || fontScale <= 0.0) {
throw new IllegalArgumentException("fontScale must be positive and finite");
}
}
}
private final Map<Integer, BarLabel> labelsByIndex;
private final List<BarLabel> labels;
public BarSeriesLabelIndicator(final BarSeries series, final List<BarLabel> labels) {
super(Objects.requireNonNull(series, "series"));
Objects.requireNonNull(labels, "labels");
this.labels = labels.stream()
.filter(Objects::nonNull)
.sorted(Comparator.comparingInt(BarLabel::barIndex))
.collect(Collectors.toUnmodifiableList());
this.labelsByIndex = this.labels.stream()
.collect(Collectors.toUnmodifiableMap(BarLabel::barIndex, label -> label, (left, right) -> {
// Keep the last (right) value for duplicate indices
return right;
}));
}
@Override
protected Num calculate(final int index) {
final BarLabel label = labelsByIndex.get(index);
return label != null ? label.yValue() : NaN;
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
/**
* @return ordered, immutable label list
*/
public List<BarLabel> labels() {
return List.copyOf(this.labels);
}
}
@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.builder;
import java.util.Objects;
import org.ta4j.core.BarSeries;
/**
* Immutable context describing a chart definition alongside its shared
* metadata.
*
* @since 0.22.2
*/
public record ChartContext(ChartBuilder.ChartDefinition definition, ChartBuilder.ChartDefinitionMetadata metadata) {
public ChartContext {
Objects.requireNonNull(definition, "Chart definition cannot be null");
Objects.requireNonNull(metadata, "Chart metadata cannot be null");
}
public static ChartContext from(ChartBuilder.ChartDefinition definition) {
Objects.requireNonNull(definition, "Chart definition cannot be null");
return new ChartContext(definition, definition.metadata());
}
public BarSeries domainSeries() {
return metadata.domainSeries();
}
public String title() {
return metadata.title();
}
public TimeAxisMode timeAxisMode() {
return metadata.timeAxisMode();
}
}
@@ -0,0 +1,53 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.builder;
import org.ta4j.core.BarSeries;
import ta4jexamples.charting.builder.ChartBuilder.ChartDefinitionMetadata;
/**
* Immutable plan describing a chart composition along with shared metadata.
*/
public final class ChartPlan {
private final ChartContext context;
ChartPlan(ChartBuilder.ChartDefinition definition) {
this.context = ChartContext.from(definition);
}
public ChartBuilder.ChartDefinition definition() {
return context.definition();
}
/**
* Returns the metadata for this chart plan.
*
* @return the chart metadata
* @since 0.22.2
*/
public ChartDefinitionMetadata metadata() {
return context.metadata();
}
/**
* Returns the chart context containing definition and metadata.
*
* @return the chart context
* @since 0.22.2
*/
public ChartContext context() {
return context;
}
/**
* Returns the primary domain series for this chart plan.
*
* @return the primary series
*/
public BarSeries primarySeries() {
return context.domainSeries();
}
}
@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.builder;
/**
* Controls how charts interpret gaps between bars on the domain axis.
*
* <p>
* Use {@link #REAL_TIME} to preserve actual time gaps (weekends, holidays, or
* missing bars). Use {@link #BAR_INDEX} to compress the domain axis so bars are
* evenly spaced by index, eliminating visual gaps while keeping the underlying
* data unchanged.
* </p>
*
* @since 0.22.2
*/
public enum TimeAxisMode {
/**
* Plot bars using their real timestamps. Missing bars appear as gaps on the
* time axis.
*/
REAL_TIME,
/**
* Plot bars using their index positions, producing evenly spaced candles and
* removing time gaps (e.g., weekends, holidays).
*/
BAR_INDEX
}
@@ -0,0 +1,109 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.display;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.data.time.TimeSeries;
import org.jfree.data.time.TimeSeriesCollection;
import org.jfree.data.xy.DefaultOHLCDataset;
import org.jfree.data.xy.XYDataset;
import org.jfree.data.xy.XYSeries;
import org.jfree.data.xy.XYSeriesCollection;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Extracts formatted text data from chart datasets for display in mouseover
* tooltips.
* <p>
* This class handles different dataset types (OHLC, TimeSeries, XYSeries) and
* formats them appropriately for display. It is designed to be testable
* independently of the chart display infrastructure.
*
* @since 0.19
*/
public final class ChartDataExtractor {
private static final Logger LOG = LogManager.getLogger(ChartDataExtractor.class);
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
/**
* Extracts formatted text representation of data from a chart dataset at the
* specified series and item indices.
*
* @param dataset the dataset to extract data from
* @param seriesIndex the index of the series within the dataset
* @param itemIndex the index of the item within the series
* @return formatted text string, or null if extraction fails or data is not
* available
*/
public String extractDataText(XYDataset dataset, int seriesIndex, int itemIndex) {
if (dataset instanceof DefaultOHLCDataset ohlcDataset) {
return extractOHLCData(ohlcDataset, seriesIndex, itemIndex);
} else if (dataset instanceof TimeSeriesCollection timeSeriesCollection) {
return extractTimeSeriesData(timeSeriesCollection, seriesIndex, itemIndex);
} else if (dataset instanceof XYSeriesCollection xyCollection) {
return extractXYSeriesData(xyCollection, seriesIndex, itemIndex);
}
return null;
}
private String extractOHLCData(DefaultOHLCDataset dataset, int seriesIndex, int itemIndex) {
try {
double xValue = dataset.getXValue(seriesIndex, itemIndex);
double open = dataset.getOpenValue(seriesIndex, itemIndex);
double high = dataset.getHighValue(seriesIndex, itemIndex);
double low = dataset.getLowValue(seriesIndex, itemIndex);
double close = dataset.getCloseValue(seriesIndex, itemIndex);
double volume = dataset.getVolumeValue(seriesIndex, itemIndex);
DecimalFormat priceFormat = new DecimalFormat("#,##0.00###");
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT);
return String.format("Date: %s | O: %s | H: %s | L: %s | C: %s | V: %s",
dateFormat.format(new Date((long) xValue)), priceFormat.format(open), priceFormat.format(high),
priceFormat.format(low), priceFormat.format(close), priceFormat.format(volume));
} catch (Exception ex) {
LOG.debug("Error extracting OHLC data", ex);
return null;
}
}
private String extractTimeSeriesData(TimeSeriesCollection dataset, int seriesIndex, int itemIndex) {
try {
TimeSeries timeSeries = dataset.getSeries(seriesIndex);
if (timeSeries != null && itemIndex < timeSeries.getItemCount()) {
org.jfree.data.time.TimeSeriesDataItem dataItem = timeSeries.getDataItem(itemIndex);
String seriesName = timeSeries.getKey().toString();
String dateStr = dataItem.getPeriod().toString();
double value = dataItem.getValue().doubleValue();
DecimalFormat valueFormat = new DecimalFormat("#,##0.00###");
return String.format("%s: %s | Value: %s", seriesName, dateStr, valueFormat.format(value));
}
} catch (Exception ex) {
LOG.debug("Error extracting TimeSeries data", ex);
}
return null;
}
private String extractXYSeriesData(XYSeriesCollection dataset, int seriesIndex, int itemIndex) {
try {
XYSeries series = dataset.getSeries(seriesIndex);
if (series != null && itemIndex < series.getItemCount()) {
double x = series.getX(itemIndex).doubleValue();
double y = series.getY(itemIndex).doubleValue();
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT);
DecimalFormat valueFormat = new DecimalFormat("#,##0.00###");
return String.format("Date: %s | Value: %s", dateFormat.format(new Date((long) x)),
valueFormat.format(y));
}
} catch (Exception ex) {
LOG.debug("Error extracting indicator data", ex);
}
return null;
}
}
@@ -0,0 +1,69 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.display;
import org.apache.logging.log4j.LogManager;
import org.jfree.chart.JFreeChart;
import java.awt.Dimension;
/**
* Display strategy for {@link JFreeChart} instances.
*
* <p>
* Implementations are responsible for presenting a chart to the user. The
* default implementation is {@link SwingChartDisplayer}, which renders a chart
* in a Swing {@code ApplicationFrame}.
* </p>
*
* @since 0.19
*/
public interface ChartDisplayer {
/**
* Presents the provided chart to the user.
*
* @param chart the chart to display
* @since 0.19
*/
void display(JFreeChart chart);
/**
* Presents the provided chart to the user with a custom window title.
*
* @param chart the chart to display
* @param windowTitle the title for the window/frame
* @since 0.19
*/
void display(JFreeChart chart, String windowTitle);
/**
* Presents the provided chart to the user with a custom window title and an
* explicit preferred display size.
*
* <p>
* Implementations may ignore the preferred size when they cannot honor it. The
* default behavior logs that the hint was ignored, then delegates to the
* existing title-aware overload so current implementations remain
* source-compatible.
* </p>
*
* @param chart the chart to display
* @param windowTitle the title for the window/frame
* @param preferredSize the preferred display size
* @since 0.22.7
*/
default void display(JFreeChart chart, String windowTitle, Dimension preferredSize) {
if (preferredSize != null) {
LogManager.getLogger(ChartDisplayer.class)
.debug("Chart displayer {} ignored preferredSize {} because it does not override the size-aware display overload.",
getClass().getName(), preferredSize);
}
if (windowTitle != null && !windowTitle.trim().isEmpty()) {
display(chart, windowTitle);
} else {
display(chart);
}
}
}
@@ -0,0 +1,469 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.display;
import org.jfree.chart.ChartMouseEvent;
import org.jfree.chart.ChartMouseListener;
import org.jfree.chart.ChartPanel;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.entity.ChartEntity;
import org.jfree.chart.entity.XYItemEntity;
import org.jfree.data.xy.XYDataset;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.HeadlessException;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowEvent;
import java.awt.event.WindowListener;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.Timer;
import javax.swing.event.AncestorEvent;
import javax.swing.event.AncestorListener;
/**
* Swing-based {@link ChartDisplayer} that renders charts in a {@link JFrame}.
*
* <p>
* This implementation displays charts in a Swing window with zoom and pan
* capabilities. By default it auto-sizes the chart to 80% of the available
* display bounds, while still allowing callers to supply an explicit preferred
* size for display-specific use cases. Each window closes independently using
* {@link JFrame#DISPOSE_ON_CLOSE} to prevent closing one window from affecting
* others. When all chart windows are closed, the program automatically exits.
* Windows are also configured as non-focusable so chart rendering in automated
* runs does not steal desktop focus.
* </p>
*
* @since 0.19
*/
public final class SwingChartDisplayer implements ChartDisplayer {
/**
* System property key for chart display scale configuration.
*
* @since 0.19
*/
static final String DISPLAY_SCALE_PROPERTY = "ta4j.chart.displayScale";
/**
* System property key for mouseover hover delay in milliseconds.
*
* @since 0.19
*/
static final String HOVER_DELAY_PROPERTY = "ta4j.chart.hoverDelay";
/**
* System property key to disable chart display (useful for automated tests).
* When set to "true", charts will not be displayed and the display method will
* return immediately without creating any windows.
*
* @since 0.19
*/
public static final String DISABLE_DISPLAY_PROPERTY = "ta4j.chart.disableDisplay";
/**
* Default chart display scale.
*
* @since 0.19
*/
static final double DEFAULT_DISPLAY_SCALE = 0.80;
private static final int DEFAULT_DISPLAY_WIDTH = 1920;
private static final int DEFAULT_DISPLAY_HEIGHT = 1200;
private static final int MIN_DISPLAY_WIDTH = 800;
private static final int MIN_DISPLAY_HEIGHT = 600;
/**
* Default mouseover hover delay in milliseconds.
* <p>
* Set to 500ms to align with UX best practices. Nielsen Norman Group recommends
* 300-500ms, and Microsoft uses 500ms for tooltips. This prevents accidental
* tooltip activations when users move their cursor across the chart.
*
* @since 0.19
*/
static final int DEFAULT_HOVER_DELAY_MS = 500;
private static final Logger LOG = LogManager.getLogger(SwingChartDisplayer.class);
/**
* Static counter to track window positions for cascading multiple chart
* windows.
*/
private static int windowCounter = 0;
private static final int CASCADE_OFFSET_X = 30;
private static final int CASCADE_OFFSET_Y = 30;
/**
* Set of all open chart windows. Used to track when all windows are closed so
* the program can exit.
*/
private static final Set<JFrame> openWindows = ConcurrentHashMap.newKeySet();
@Override
public void display(JFreeChart chart) {
display(chart, "Ta4j-examples", null);
}
@Override
public void display(JFreeChart chart, String windowTitle) {
display(chart, windowTitle, null);
}
@Override
public void display(JFreeChart chart, String windowTitle, Dimension preferredSize) {
// Validate input parameter
if (chart == null) {
throw new IllegalArgumentException("Chart cannot be null");
}
// Check if display is disabled via system property (useful for automated tests)
if (isDisplayDisabled()) {
LOG.debug("Chart display is disabled via system property {}", DISABLE_DISPLAY_PROPERTY);
return;
}
// Serialize and deserialize the chart to create a deep copy that prevents
// ChartPanel from modifying the original
JFreeChart chartClone;
try {
chartClone = deepCopyChart(chart);
} catch (Exception e) {
LOG.debug("Failed to deep copy chart, falling back to shallow clone", e);
try {
chartClone = (JFreeChart) chart.clone();
} catch (CloneNotSupportedException cloneEx) {
LOG.debug("Failed to clone chart, using original chart for display", cloneEx);
chartClone = chart;
}
}
ChartPanel panel = new ChartPanel(chartClone);
panel.setFillZoomRectangle(true);
panel.setMouseWheelEnabled(true);
panel.setDomainZoomable(true);
panel.setDisplayToolTips(false);
panel.setPreferredSize(resolveDisplaySize(preferredSize));
// Create info panel for mouseover data
JLabel infoLabel = new JLabel(" ");
infoLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
infoLabel.setForeground(Color.LIGHT_GRAY);
infoLabel.setBackground(Color.BLACK);
infoLabel.setOpaque(true);
infoLabel.setBorder(javax.swing.BorderFactory.createEmptyBorder(4, 8, 4, 8));
// Create container panel with chart and info label
JPanel containerPanel = new JPanel(new BorderLayout());
containerPanel.add(panel, BorderLayout.CENTER);
containerPanel.add(infoLabel, BorderLayout.NORTH);
// Add mouseover listener
int hoverDelay = resolveHoverDelay();
ChartMouseoverListener mouseoverListener = new ChartMouseoverListener(infoLabel, hoverDelay);
panel.addChartMouseListener(mouseoverListener);
// Add ancestor listener to cleanup timer when component is removed
panel.addAncestorListener(new AncestorListener() {
@Override
public void ancestorAdded(AncestorEvent event) {
// No action needed
}
@Override
public void ancestorRemoved(AncestorEvent event) {
mouseoverListener.disposeHoverTimer();
}
@Override
public void ancestorMoved(AncestorEvent event) {
// No action needed
}
});
String title = windowTitle != null && !windowTitle.trim().isEmpty() ? windowTitle : "Ta4j-examples";
// Use JFrame instead of ApplicationFrame to avoid EXIT_ON_CLOSE behavior
// ApplicationFrame sets EXIT_ON_CLOSE which closes all windows
JFrame frame = new JFrame(title);
frame.setContentPane(containerPanel);
frame.pack();
frame.setAlwaysOnTop(false);
frame.setAutoRequestFocus(false);
frame.setFocusableWindowState(false);
// Set to DISPOSE_ON_CLOSE so closing one window doesn't close all windows
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
// Track this window and add listener to exit when all windows are closed
openWindows.add(frame);
frame.addWindowListener(new WindowListener() {
@Override
public void windowOpened(WindowEvent e) {
// No action needed
}
@Override
public void windowClosing(WindowEvent e) {
// No action needed - DISPOSE_ON_CLOSE handles the closing
}
@Override
public void windowClosed(WindowEvent e) {
openWindows.remove(frame);
// If all windows are closed, exit the program
if (openWindows.isEmpty()) {
if (isDisplayDisabled()) {
LOG.debug("All chart windows closed while display is disabled; skipping System.exit(0)");
} else {
LOG.debug("All chart windows closed, exiting program");
System.exit(0);
}
}
}
@Override
public void windowIconified(WindowEvent e) {
// No action needed
}
@Override
public void windowDeiconified(WindowEvent e) {
// No action needed
}
@Override
public void windowActivated(WindowEvent e) {
// No action needed
}
@Override
public void windowDeactivated(WindowEvent e) {
// No action needed
}
});
// Cascade windows by offsetting each new window
int windowIndex = windowCounter++;
try {
Rectangle screenBounds = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
if (screenBounds != null) {
int x = screenBounds.x + (windowIndex * CASCADE_OFFSET_X);
int y = screenBounds.y + (windowIndex * CASCADE_OFFSET_Y);
// Ensure window stays within screen bounds
Dimension frameSize = frame.getSize();
if (x + frameSize.width > screenBounds.width) {
x = screenBounds.x + ((windowIndex % 10) * CASCADE_OFFSET_X);
}
if (y + frameSize.height > screenBounds.height) {
y = screenBounds.y + ((windowIndex % 10) * CASCADE_OFFSET_Y);
}
frame.setLocation(x, y);
}
} catch (Exception ex) {
LOG.debug("Unable to set window position for cascading, using default", ex);
}
frame.setVisible(true);
}
Dimension determineDisplaySize() {
return resolveDisplaySize(null);
}
Dimension resolveDisplaySize(Dimension preferredSize) {
if (preferredSize != null) {
if (preferredSize.width <= 0 || preferredSize.height <= 0) {
throw new IllegalArgumentException("Preferred display size must be positive");
}
return new Dimension(preferredSize);
}
double displayScale = resolveDisplayScale();
try {
Rectangle bounds = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
if (bounds != null && bounds.getWidth() > 0 && bounds.getHeight() > 0) {
int width = (int) Math.round(bounds.getWidth() * displayScale);
int height = (int) Math.round(bounds.getHeight() * displayScale);
width = Math.max(MIN_DISPLAY_WIDTH, width);
height = Math.max(MIN_DISPLAY_HEIGHT, height);
return new Dimension(width, height);
}
} catch (HeadlessException headlessEx) {
LOG.debug("Headless environment detected while determining chart display size", headlessEx);
} catch (Exception ex) {
LOG.warn("Unable to determine screen bounds for chart display size", ex);
}
int fallbackWidth = (int) Math.round(DEFAULT_DISPLAY_WIDTH * displayScale);
int fallbackHeight = (int) Math.round(DEFAULT_DISPLAY_HEIGHT * displayScale);
fallbackWidth = Math.max(MIN_DISPLAY_WIDTH, fallbackWidth);
fallbackHeight = Math.max(MIN_DISPLAY_HEIGHT, fallbackHeight);
return new Dimension(fallbackWidth, fallbackHeight);
}
double resolveDisplayScale() {
String configuredScale = System.getProperty(DISPLAY_SCALE_PROPERTY);
if (configuredScale != null) {
try {
double parsedValue = Double.parseDouble(configuredScale);
if (parsedValue > 0.1 && parsedValue <= 1.0) {
return parsedValue;
}
LOG.debug("Ignoring display scale property {} outside accepted range (0.1, 1.0]: {}",
DISPLAY_SCALE_PROPERTY, configuredScale);
} catch (NumberFormatException numberFormatException) {
LOG.debug("Unable to parse display scale property {} value: {}", DISPLAY_SCALE_PROPERTY,
configuredScale, numberFormatException);
}
}
return DEFAULT_DISPLAY_SCALE;
}
int resolveHoverDelay() {
String configuredDelay = System.getProperty(HOVER_DELAY_PROPERTY);
if (configuredDelay != null) {
try {
int parsedValue = Integer.parseInt(configuredDelay);
if (parsedValue >= 0) {
return parsedValue;
}
LOG.debug("Ignoring hover delay property {} with negative value: {}", HOVER_DELAY_PROPERTY,
configuredDelay);
} catch (NumberFormatException numberFormatException) {
LOG.debug("Unable to parse hover delay property {} value: {}", HOVER_DELAY_PROPERTY, configuredDelay,
numberFormatException);
}
}
return DEFAULT_HOVER_DELAY_MS;
}
/**
* Checks if chart display is disabled via system property.
*
* @return true if display is disabled, false otherwise
*/
boolean isDisplayDisabled() {
String disableDisplay = System.getProperty(DISABLE_DISPLAY_PROPERTY);
return "true".equalsIgnoreCase(disableDisplay);
}
private JFreeChart deepCopyChart(JFreeChart chart) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(chart);
}
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
return (JFreeChart) ois.readObject();
}
}
/**
* Mouse listener that displays OHLC data for candles and indicator values on
* mouseover.
*
* @since 0.19
*/
static class ChartMouseoverListener implements ChartMouseListener {
private final JLabel infoLabel;
private final int hoverDelay;
private final ChartDataExtractor dataExtractor;
private Timer hoverTimer;
private String lastDisplayedText;
private ChartMouseEvent lastEvent;
ChartMouseoverListener(JLabel infoLabel, int hoverDelay) {
this(infoLabel, hoverDelay, new ChartDataExtractor());
}
ChartMouseoverListener(JLabel infoLabel, int hoverDelay, ChartDataExtractor dataExtractor) {
this.infoLabel = infoLabel;
this.hoverDelay = hoverDelay;
this.dataExtractor = dataExtractor;
}
@Override
public void chartMouseClicked(ChartMouseEvent event) {
// No action on click
}
@Override
public void chartMouseMoved(ChartMouseEvent event) {
// Cancel any pending timer
if (hoverTimer != null) {
hoverTimer.stop();
hoverTimer = null;
}
// Clear display immediately when mouse moves
if (lastDisplayedText != null) {
infoLabel.setText(" ");
lastDisplayedText = null;
}
// Store the event for later use
lastEvent = event;
// Start new timer to show data after delay
hoverTimer = new Timer(hoverDelay, new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
displayMouseoverData(lastEvent);
}
});
hoverTimer.setRepeats(false);
hoverTimer.start();
}
/**
* Stops the hover timer and clears all references to prevent memory leaks when
* the component is disposed or the listener is removed.
*/
void disposeHoverTimer() {
if (hoverTimer != null) {
hoverTimer.stop();
hoverTimer = null;
}
lastEvent = null;
lastDisplayedText = null;
}
private void displayMouseoverData(ChartMouseEvent event) {
try {
if (event == null) {
return;
}
ChartEntity entity = event.getEntity();
if (entity instanceof XYItemEntity xyItemEntity) {
XYDataset dataset = xyItemEntity.getDataset();
int seriesIndex = xyItemEntity.getSeriesIndex();
int itemIndex = xyItemEntity.getItem();
String displayText = dataExtractor.extractDataText(dataset, seriesIndex, itemIndex);
if (displayText != null && !displayText.isEmpty()) {
infoLabel.setText(displayText);
lastDisplayedText = displayText;
}
}
} catch (Exception ex) {
LOG.debug("Error displaying mouseover data", ex);
}
}
}
}
@@ -0,0 +1,75 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.renderer;
import org.jfree.chart.renderer.xy.CandlestickRenderer;
import org.jfree.data.xy.OHLCDataset;
import org.jfree.data.xy.XYDataset;
import java.awt.*;
/**
* Custom candlestick renderer that provides distinct colors for up and down
* candles.
*
* <p>
* This renderer extends the standard JFreeChart CandlestickRenderer to provide
* custom coloring based on whether a candle represents a price increase (up) or
* decrease (down). Up candles are colored green, down candles are colored red.
* </p>
*
* @see CandlestickRenderer
* @since 0.19
*/
public class BaseCandleStickRenderer extends CandlestickRenderer {
/**
* Color for up candles (close > open). Matches TradingView's default bullish
* candle color.
*
* @since 0.19
*/
public static final Color DEFAULT_UP_COLOR = new Color(0x26A69A);
/**
* Color for down candles (close {@literal <} open). Matches TradingView's
* default bearish candle color.
*
* @since 0.19
*/
public static final Color DEFAULT_DOWN_COLOR = new Color(0xEF5350);
/**
* Returns the paint color for the specified item based on whether it's an up or
* down candle.
*
* @param row the row (series) index
* @param column the column (item) index
* @return the paint color for the item
* @since 0.19
*/
@Override
public Paint getItemPaint(int row, int column) {
XYDataset dataset = getPlot().getDataset();
if (!(dataset instanceof OHLCDataset highLowData)) {
return super.getItemPaint(row, column);
}
// Check for valid indices
if (row < 0 || row >= highLowData.getSeriesCount() || column < 0 || column >= highLowData.getItemCount(row)) {
return new Color(128, 128, 128); // Return neutral gray for invalid indices
}
Number yOpen = highLowData.getOpen(row, column);
Number yClose = highLowData.getClose(row, column);
if (yOpen == null || yClose == null) {
return super.getItemPaint(row, column);
}
boolean isUpCandle = yClose.doubleValue() > yOpen.doubleValue();
return isUpCandle ? DEFAULT_UP_COLOR : DEFAULT_DOWN_COLOR;
}
}
@@ -0,0 +1,48 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.storage;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import java.nio.file.Path;
import java.util.Optional;
/**
* Strategy for persisting charts.
*
* <p>
* Implementations are responsible for saving chart images to a storage system.
* The default implementation {@link FileSystemChartStorage} saves charts as
* JPEG files to the filesystem.
* </p>
*
* @since 0.19
*/
public interface ChartStorage {
/**
* Persists the provided chart and returns the destination path if the operation
* succeeds.
*
* @param chart the chart to persist
* @param series the originating bar series
* @param chartTitle the descriptive chart title
* @param width target image width
* @param height target image height
* @return the optional path to the persisted image
* @since 0.19
*/
Optional<Path> save(JFreeChart chart, BarSeries series, String chartTitle, int width, int height);
/**
* Creates a storage strategy that performs no persistence.
*
* @return a no-op storage strategy
* @since 0.19
*/
static ChartStorage noOp() {
return (chart, series, chartTitle, width, height) -> Optional.empty();
}
}
@@ -0,0 +1,116 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.storage;
import org.jfree.chart.ChartUtils;
import org.jfree.chart.JFreeChart;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import java.util.Objects;
/**
* Persists charts to the filesystem as JPEG images.
*
* <p>
* This storage implementation saves charts directly to a configurable root
* directory. When a chart title is provided (non-null and non-empty), it is
* used as the filename (sanitized and with .jpg extension). Otherwise,
* filenames are automatically generated using the format:
* {@code <sanitized bar series name>_<start date>_to_<end date>_<current datetime>.jpg}.
* Bar series start and end dates are formatted as dates only (without time
* portion). Filenames are sanitized to ensure filesystem compatibility.
* </p>
*
* @since 0.19
*/
public final class FileSystemChartStorage implements ChartStorage {
private static final Logger LOG = LogManager.getLogger(FileSystemChartStorage.class);
private final Path rootDirectory;
public FileSystemChartStorage(Path rootDirectory) {
Objects.requireNonNull(rootDirectory, "Root directory must be provided");
this.rootDirectory = rootDirectory;
}
@Override
public Optional<Path> save(JFreeChart chart, BarSeries series, String chartTitle, int width, int height) {
Objects.requireNonNull(chart, "Chart cannot be null");
Objects.requireNonNull(series, "Series cannot be null");
Path targetPath = (chartTitle != null && !chartTitle.trim().isEmpty()) ? buildSavePath(series, chartTitle)
: buildSavePath(series);
try {
Files.createDirectories(targetPath.getParent());
ChartUtils.saveChartAsJPEG(targetPath.toFile(), chart, width, height);
LOG.debug("Saved chart to {}", targetPath.toAbsolutePath());
return Optional.of(targetPath.toAbsolutePath());
} catch (IOException ex) {
LOG.error("Failed to save chart {} to {}", chartTitle, targetPath, ex);
return Optional.empty();
}
}
private Path buildSavePath(BarSeries series) {
String sanitizedSeriesName = sanitizePathComponent(series.getName());
// Get start and end dates (date only, no time)
String startDate = "unknown";
String endDate = "unknown";
if (!series.isEmpty()) {
startDate = formatDateOnly(series.getFirstBar().getEndTime());
endDate = formatDateOnly(series.getLastBar().getEndTime());
}
// Get current datetime for filename
String currentDateTime = ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
// Build filename: <sanitized bar series name>_<start date>_to_<end
// date>_<current datetime>.jpg
String filename = String.format("%s_%s_to_%s_%s.jpg", sanitizedSeriesName, startDate, endDate, currentDateTime);
return rootDirectory.resolve(filename);
}
private Path buildSavePath(BarSeries series, String filename) {
String sanitizedFilename = sanitizePathComponent(filename);
return rootDirectory.resolve(sanitizedFilename + ".jpg");
}
private String formatDateOnly(Instant instant) {
LocalDate date = instant.atZone(ZoneOffset.UTC).toLocalDate();
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
private String sanitizePathComponent(String component) {
if (component == null || component.trim().isEmpty()) {
return "unknown";
}
return component.replace(":", "-")
.replace("/", "_")
.replace("\\", "_")
.replace("?", "_")
.replace("*", "_")
.replace("<", "(")
.replace(">", ")")
.replace("|", "_")
.replace("\"", "")
.trim()
.replaceAll("^\\.+|\\.+$", "")
.replaceAll("\\s+", "_");
}
}
@@ -0,0 +1,145 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import org.ta4j.core.BarSeries;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
/**
* Common interface for data sources that load BarSeries using business domain
* concepts (ticker, interval, date range).
* <p>
* This interface abstracts away implementation details (files, APIs, databases)
* and allows users to work with trading domain concepts. File-based
* implementations will search for files matching the ticker/interval/date range
* criteria, while API-based implementations will fetch data from remote
* sources.
* <p>
* <strong>Domain-Driven Usage:</strong>
*
* <pre>
* // All data sources work with business concepts
* BarSeriesDataSource yahoo = new YahooFinanceHttpBarSeriesDataSource(true);
* BarSeriesDataSource csv = new CsvFileBarSeriesDataSource();
* BarSeriesDataSource json = new JsonFileBarSeriesDataSource();
*
* // Same interface, different implementations
* BarSeries aapl = yahoo.loadSeries("AAPL", Duration.ofDays(1), Instant.parse("2023-01-01T00:00:00Z"),
* Instant.parse("2023-12-31T23:59:59Z"));
*
* BarSeries btc = csv.loadSeries("BTC-USD", Duration.ofDays(1), Instant.parse("2023-01-01T00:00:00Z"),
* Instant.parse("2023-12-31T23:59:59Z"));
* </pre>
* <p>
* <strong>Direct Source Loading:</strong>
* <p>
* For cases where you know the exact source identifier (filename, URL, etc.),
* use {@link #loadSeries(String)}:
*
* <pre>
* // Direct filename loading (bypasses search logic)
* BarSeries series = csv.loadSeries("AAPL-PT1D-20130102_20131231.csv");
* </pre>
* <p>
* <strong>Implementation Behavior:</strong>
* <ul>
* <li><strong>File-based sources</strong> (CsvFileBarSeriesDataSource,
* JsonFileBarSeriesDataSource, BitStampCsvTradesFileBarSeriesDataSource):
* Search for files matching ticker/interval/date range patterns in the
* classpath or configured directories. The exact filename pattern is
* implementation-specific.</li>
* <li><strong>API-based sources</strong> (YahooFinanceHttpBarSeriesDataSource):
* Fetch data from the API. If caching is enabled, will first check for cached
* files matching the criteria.</li>
* </ul>
*
* @since 0.20
*/
public interface BarSeriesDataSource {
/**
* Loads a BarSeries using business domain concepts.
* <p>
* This is the primary method for loading data. Implementations interpret the
* parameters according to their capabilities:
* <ul>
* <li><strong>File-based sources</strong>: Search for files matching the
* ticker, interval, and date range. The search pattern is
* implementation-specific (e.g.,
* "{ticker}_bars_from_{startDate}_{endDate}.csv").</li>
* <li><strong>API-based sources</strong>: Fetch data from the API for the
* specified ticker, interval, and date range. May check cache first if caching
* is enabled.</li>
* </ul>
*
* @param ticker the ticker symbol or identifier (e.g., "AAPL", "BTC-USD",
* "MSFT")
* @param interval the bar interval (e.g., Duration.ofDays(1) for daily bars,
* Duration.ofHours(1) for hourly bars)
* @param start the start date/time for the data range (inclusive)
* @param end the end date/time for the data range (inclusive)
* @return a BarSeries containing the loaded data, or null if no matching data
* is found or loading fails
* @throws IllegalArgumentException if any parameter is invalid (e.g., null
* ticker, negative interval, start after end)
*/
BarSeries loadSeries(String ticker, Duration interval, Instant start, Instant end);
/**
* Loads a BarSeries directly from a known source identifier.
* <p>
* This method bypasses the search/fetch logic and loads directly from the
* specified source. Use this when you know the exact source identifier (e.g.,
* filename, URL, resource name).
* <p>
* For file-based sources, this is typically a filename or resource path. For
* API-based sources, this might be a cached file path or a direct API endpoint.
*
* @param source the source identifier (filename, resource name, URL, etc.)
* @return a BarSeries containing the loaded data, or null if loading fails
* @throws IllegalArgumentException if the source parameter is invalid or
* unsupported
*/
BarSeries loadSeries(String source);
/**
* Loads a BarSeries from an InputStream.
* <p>
* This method is optional - implementations that don't support InputStream
* loading should throw {@link UnsupportedOperationException}.
* <p>
* The caller is responsible for closing the InputStream after this method
* returns. Implementations should not close the stream unless they fully
* consume it.
*
* @param inputStream the input stream containing the data
* @return a BarSeries containing the loaded data, or null if loading fails
* @throws UnsupportedOperationException if this data source doesn't support
* InputStream loading
* @throws IllegalArgumentException if the inputStream is null or invalid
*/
default BarSeries loadSeries(InputStream inputStream) {
throw new UnsupportedOperationException(
"InputStream loading not supported by " + this.getClass().getSimpleName());
}
/**
* Returns the source name for this data source. This is used for building file
* search patterns, cache file names, and other source-specific identifiers.
* <p>
* For example, a Yahoo Finance data source would return "YahooFinance", which
* would be used to build cache file names like "YahooFinance-AAPL-1d-...".
* <p>
* File-based sources that don't use a source prefix (e.g., generic CSV files)
* should return an empty string.
*
* @return the source name, or an empty string if no source prefix is used
*/
default String getSourceName() {
return "";
}
}
@@ -0,0 +1,315 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.file.AbstractFileBarSeriesDataSource;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.ListIterator;
/**
* This class builds a Ta4j bar series from a Bitstamp CSV file containing
* trades. It reads trade-level data (timestamp, price, volume) and aggregates
* them into OHLCV bars suitable for technical analysis.
* <p>
* Implements {@link BarSeriesDataSource} to support domain-driven loading by
* ticker, interval, and date range. Searches for Bitstamp CSV files matching
* the specified criteria in the classpath.
*/
public class BitStampCsvTradesFileBarSeriesDataSource extends AbstractFileBarSeriesDataSource {
private static final Logger LOG = LogManager.getLogger(BitStampCsvTradesFileBarSeriesDataSource.class);
private static final String DEFAULT_BITSTAMP_FILE = "Bitstamp-BTC-USD-PT5M-20131125_20131201.csv";
/**
* Creates a new BitStampCsvTradesFileBarSeriesDataSource with "Bitstamp" as the
* source name.
*/
public BitStampCsvTradesFileBarSeriesDataSource() {
super("Bitstamp");
}
@Override
protected String getFileExtension() {
return "csv";
}
@Override
protected BarSeries searchAndLoadFile(String ticker, String intervalStr, String sourcePrefix,
String startDateTimeStr, String endDateTimeStr, String startDateStr, String endDateStr, Duration interval,
Instant start, Instant end) {
// Try exact pattern with interval-appropriate format:
// {sourceName}-{ticker}-{interval}-{startDateTime}_{endDateTime}.csv
String exactPattern = sourcePrefix + ticker.toUpperCase() + "-" + intervalStr + "-" + startDateTimeStr + "_"
+ endDateTimeStr + ".csv";
BarSeries series = loadBitstampSeries(exactPattern);
if (series != null && !series.isEmpty()) {
return filterAndAggregateSeries(series, interval, start, end);
}
// Fallback to date-only format for backward compatibility with existing files
String exactPatternDateOnly = sourcePrefix + ticker.toUpperCase() + "-" + intervalStr + "-" + startDateStr + "_"
+ endDateStr + ".csv";
series = loadBitstampSeries(exactPatternDateOnly);
if (series != null && !series.isEmpty()) {
return filterAndAggregateSeries(series, interval, start, end);
}
// Try broader pattern: {sourceName}-{ticker}-*-{startDateTime}_*.csv
String broaderPattern = sourcePrefix + ticker.toUpperCase() + "-*-" + startDateTimeStr + "_*.csv";
series = searchAndLoadBitstampFile(broaderPattern, interval, start, end);
if (series != null && !series.isEmpty()) {
return series;
}
// Fallback to date-only format for broader pattern
String broaderPatternDateOnly = sourcePrefix + ticker.toUpperCase() + "-*-" + startDateStr + "_*.csv";
series = searchAndLoadBitstampFile(broaderPatternDateOnly, interval, start, end);
if (series != null && !series.isEmpty()) {
return series;
}
// Try even broader: {sourceName}-{ticker}-*.csv (then filter by date range)
String broadestPattern = sourcePrefix + ticker.toUpperCase() + "-*.csv";
series = searchAndLoadBitstampFile(broadestPattern, interval, start, end);
if (series != null && !series.isEmpty()) {
return series;
}
return null;
}
@Override
public BarSeries loadSeries(String source) {
if (source == null || source.trim().isEmpty()) {
throw new IllegalArgumentException("Source cannot be null or empty");
}
return loadBitstampSeries(source);
}
/**
* Searches for a Bitstamp CSV file matching the pattern and loads it if found.
*
* @param pattern the filename pattern to search for (supports wildcards)
* @param interval the desired bar interval
* @param start the start date
* @param end the end date
* @return the loaded and filtered BarSeries, or null if not found
*/
private BarSeries searchAndLoadBitstampFile(String pattern, Duration interval, Instant start, Instant end) {
// Try direct pattern match as resource
if (!pattern.contains("*")) {
BarSeries series = loadBitstampSeries(pattern);
if (series != null && !series.isEmpty()) {
return filterAndAggregateSeries(series, interval, start, end);
}
}
// For wildcard patterns, try common variations
String[] variations = { pattern.replace("*", "PT5M"), pattern.replace("*", "PT1D") };
for (String variation : variations) {
BarSeries series = loadBitstampSeries(variation);
if (series != null && !series.isEmpty()) {
return filterAndAggregateSeries(series, interval, start, end);
}
}
return null;
}
/**
* Filters a series to the date range and re-aggregates with the specified
* interval.
*
* @param series the series to filter and aggregate
* @param interval the desired bar interval
* @param start the start date (inclusive)
* @param end the end date (inclusive)
* @return a new BarSeries with bars within the date range and specified
* interval, or null if no bars match
*/
private BarSeries filterAndAggregateSeries(BarSeries series, Duration interval, Instant start, Instant end) {
if (series == null || series.isEmpty()) {
return null;
}
// Filter trades within date range and re-aggregate with new interval
// This is a simplified implementation - in practice, you'd want to
// re-aggregate from the original trade data
var filteredSeries = new BaseBarSeriesBuilder().withName(series.getName()).build();
int barsInDateRange = 0;
int barsWithMatchingInterval = 0;
Duration actualInterval = null;
for (int i = 0; i < series.getBarCount(); i++) {
var bar = series.getBar(i);
Instant barEnd = bar.getEndTime();
if (!barEnd.isBefore(start) && !barEnd.isAfter(end)) {
barsInDateRange++;
if (actualInterval == null) {
actualInterval = bar.getTimePeriod();
}
// If interval matches, add as-is; otherwise would need re-aggregation
if (bar.getTimePeriod().equals(interval)) {
barsWithMatchingInterval++;
filteredSeries.addBar(bar);
}
}
}
// Log warning if bars exist in date range but intervals don't match
if (barsInDateRange > 0 && barsWithMatchingInterval == 0 && actualInterval != null) {
LOG.warn(
"Found {} bars within date range [{} to {}], but bar interval ({}) does not match requested interval ({}). "
+ "Re-aggregation from original trade data is required but not implemented. Returning null.",
barsInDateRange, start, end, actualInterval, interval);
}
return filteredSeries.isEmpty() ? null : filteredSeries;
}
/**
* Loads a bar series from the default Bitstamp CSV file. The method reads trade
* data from a CSV file containing Bitstamp exchange trades and converts it into
* a bar series format suitable for technical analysis.
*
* @return the bar series from Bitstamp (bitcoin exchange) trades
*/
public static BarSeries loadBitstampSeries() {
return loadBitstampSeries(DEFAULT_BITSTAMP_FILE);
}
/**
* Loads a bar series from a specified Bitstamp CSV file. The method reads trade
* data from a CSV file containing Bitstamp exchange trades and converts it into
* a bar series format suitable for technical analysis.
*
* @param bitstampCsvFile the path to the CSV file containing Bitstamp trade
* data
* @return the bar series built from the Bitstamp trades data
*/
public static BarSeries loadBitstampSeries(String bitstampCsvFile) {
// Reading all lines of the CSV file
InputStream stream = BitStampCsvTradesFileBarSeriesDataSource.class.getClassLoader()
.getResourceAsStream(bitstampCsvFile);
List<String[]> lines = null;
if (stream == null) {
LOG.debug("CSV file not found in classpath: {}", bitstampCsvFile);
return null;
}
try (final var csvReader = new com.opencsv.CSVReader(new InputStreamReader(stream))) {
lines = csvReader.readAll();
lines.remove(0); // Removing header line
} catch (Exception ioe) {
LOG.error("Unable to load trades from CSV", ioe);
}
var series = new BaseBarSeriesBuilder().withName(bitstampCsvFile).build();
if ((lines != null) && !lines.isEmpty()) {
// Getting the first and last trades timestamps
Instant beginTime = null;
Instant endTime = null;
try {
beginTime = Instant.ofEpochMilli(Long.parseLong(lines.get(0)[0]) * 1000);
endTime = Instant.ofEpochMilli(Long.parseLong(lines.get(lines.size() - 1)[0]) * 1000);
} catch (NumberFormatException nfe) {
LOG.error("Invalid trade timestamp format in CSV: {}", nfe.getMessage());
return null;
}
if (beginTime.isAfter(endTime)) {
beginTime = endTime;
// Since the CSV file has the most recent trades at the top of the file, we'll
// reverse the list to feed
// the List<Bar> correctly.
Collections.reverse(lines);
}
// build the list of populated bars (default 5-minute bars)
buildSeries(series, beginTime, endTime, 300, lines);
}
return series.isEmpty() ? null : series;
}
/**
* Builds a list of populated bars from csv data.
*
* @param beginTime the begin time of the whole period
* @param endTime the end time of the whole period
* @param duration the bar duration (in seconds)
* @param lines the csv data returned by CSVReader.readAll()
*/
private static void buildSeries(BarSeries series, Instant beginTime, Instant endTime, int duration,
List<String[]> lines) {
Duration barDuration = Duration.ofSeconds(duration);
Instant barEndTime = beginTime;
ListIterator<String[]> iterator = lines.listIterator();
// line number of trade data
do {
// build a bar
barEndTime = barEndTime.plus(barDuration);
var bar = series.barBuilder().timePeriod(barDuration).endTime(barEndTime).volume(0).amount(0).build();
do {
// get a trade
String[] tradeLine = iterator.next();
Instant tradeTimeStamp;
try {
tradeTimeStamp = Instant.ofEpochMilli(Long.parseLong(tradeLine[0]) * 1000);
} catch (NumberFormatException nfe) {
LOG.warn("Invalid trade timestamp format in CSV line, skipping trade: {}",
tradeLine.length > 0 ? tradeLine[0] : "empty line", nfe);
continue;
}
// if the trade happened during the bar
if (bar.inPeriod(tradeTimeStamp)) {
// add the trade to the bar
Num tradePrice;
Num tradeVolume;
try {
tradePrice = series.numFactory().numOf(Double.parseDouble(tradeLine[1]));
tradeVolume = series.numFactory().numOf(Double.parseDouble(tradeLine[2]));
} catch (NumberFormatException nfe) {
LOG.warn(
"Invalid trade price or volume format in CSV line, skipping trade: price={}, volume={}",
tradeLine.length > 1 ? tradeLine[1] : "missing",
tradeLine.length > 2 ? tradeLine[2] : "missing", nfe);
continue;
}
bar.addTrade(tradeVolume, tradePrice);
} else {
// the trade happened after the end of the bar
// go to the next bar but stay with the same trade (don't increment i)
// this break will drop us after the inner "while", skipping the increment
break;
}
} while (iterator.hasNext());
// if the bar has any trades add it to the bars list
// this is where the break drops to
if (bar.getTrades() > 0) {
series.addBar(bar);
}
} while (barEndTime.isBefore(endTime));
}
public static void main(String[] args) {
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
LOG.debug("Series: {} ({})", series.getName(), series.getSeriesPeriodDescription());
LOG.debug("Number of bars: {}", series.getBarCount());
LOG.debug("First bar: \n\tVolume: {}\n\tNumber of trades: {}\n\tClose price: {}", series.getBar(0).getVolume(),
series.getBar(0).getTrades(), series.getBar(0).getClosePrice());
}
}
@@ -0,0 +1,867 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import ta4jexamples.datasources.http.AbstractHttpBarSeriesDataSource;
import ta4jexamples.datasources.http.DefaultHttpClientWrapper;
import ta4jexamples.datasources.http.HttpClientWrapper;
import ta4jexamples.datasources.http.HttpResponseWrapper;
import ta4jexamples.datasources.json.AdaptiveBarSeriesTypeAdapter;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
/**
* Loads OHLCV data from Coinbase Advanced Trade API.
* <p>
* This loader fetches historical price data from Coinbase's public market data
* API without requiring authentication. It supports all Coinbase trading pairs
* (e.g., BTC-USD, ETH-USD).
* <p>
* <strong>Example usage:</strong>
*
* <pre>
* // Load 1 year of daily data for Bitcoin (using days)
* BarSeries series = CoinbaseHttpBarSeriesDataSource.loadSeries("BTC-USD", 365);
*
* // Load 500 bars of hourly data for Ethereum (using bar count)
* BarSeries ethSeries = CoinbaseHttpBarSeriesDataSource.loadSeries("ETH-USD", CoinbaseInterval.ONE_HOUR, 500);
*
* // Load data for a specific date range
* Instant start = Instant.parse("2023-01-01T00:00:00Z");
* Instant end = Instant.parse("2023-12-31T23:59:59Z");
* BarSeries btcSeries = CoinbaseHttpBarSeriesDataSource.loadSeries("BTC-USD", CoinbaseInterval.ONE_DAY, start, end);
* </pre>
* <p>
* <strong>Response Caching:</strong> To enable response caching for faster
* subsequent requests, use the constructor with {@code enableResponseCaching}:
*
* <pre>
* CoinbaseHttpBarSeriesDataSource loader = new CoinbaseHttpBarSeriesDataSource(true);
* BarSeries series = loader.loadSeriesInstance("BTC-USD", CoinbaseInterval.ONE_DAY, start, end);
* </pre>
* <p>
* To use a custom cache directory, use the constructor with
* {@code responseCacheDir}:
*
* <pre>
* CoinbaseHttpBarSeriesDataSource loader = new CoinbaseHttpBarSeriesDataSource("/path/to/cache");
* BarSeries series = loader.loadSeriesInstance("BTC-USD", CoinbaseInterval.ONE_DAY, start, end);
* </pre>
* <p>
* When caching is enabled, responses are saved to the cache directory (default:
* {@code temp/responses}) and reused for requests within the cache validity
* period (based on the interval). For example, daily data is cached for the
* day, 15-minute data is cached for 15 minutes, etc. Historical data (end date
* in the past) is cached indefinitely.
* <p>
* <strong>Unit Testing:</strong> For unit testing with a mock HttpClient, use
* the constructor:
*
* <pre>
* HttpClientWrapper mockHttpClient = mock(HttpClientWrapper.class);
* CoinbaseHttpBarSeriesDataSource loader = new CoinbaseHttpBarSeriesDataSource(mockHttpClient);
* // Use loader instance methods or inject into your code
* </pre>
* <p>
* <strong>API Limits:</strong> Coinbase API has a maximum of 350 candles per
* request. This implementation automatically paginates large requests into
* multiple API calls and merges the results.
* <p>
* <strong>Note:</strong> This uses Coinbase's public market data endpoint which
* does not require authentication. For production use with higher rate limits,
* consider using authenticated endpoints.
*
* @since 0.20
*/
public class CoinbaseHttpBarSeriesDataSource extends AbstractHttpBarSeriesDataSource {
public static final String COINBASE_API_URL = "https://api.coinbase.com/api/v3/brokerage/market/products/";
public static final int MAX_CANDLES_PER_REQUEST = 350;
private static final Logger LOG = LogManager.getLogger(CoinbaseHttpBarSeriesDataSource.class);
@Override
public String getSourceName() {
return "Coinbase";
}
private static final HttpClientWrapper DEFAULT_HTTP_CLIENT = new DefaultHttpClientWrapper();
private static final CoinbaseHttpBarSeriesDataSource DEFAULT_INSTANCE = new CoinbaseHttpBarSeriesDataSource(
DEFAULT_HTTP_CLIENT);
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with a default HttpClient. For
* unit testing, use {@link #CoinbaseHttpBarSeriesDataSource(HttpClientWrapper)}
* to inject a mock HttpClientWrapper.
*/
public CoinbaseHttpBarSeriesDataSource() {
super(DEFAULT_HTTP_CLIENT, false);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with a default HttpClient and
* caching option.
*
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
public CoinbaseHttpBarSeriesDataSource(boolean enableResponseCaching) {
super(DEFAULT_HTTP_CLIENT, enableResponseCaching);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with a default HttpClient and
* custom cache directory. Response caching is automatically enabled when a
* cache directory is specified.
*
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
public CoinbaseHttpBarSeriesDataSource(String responseCacheDir) {
super(DEFAULT_HTTP_CLIENT, responseCacheDir);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with the specified
* HttpClientWrapper. This constructor allows dependency injection of a mock
* HttpClientWrapper for unit testing.
*
* @param httpClient the HttpClientWrapper to use for API requests (can be a
* mock for testing)
*/
public CoinbaseHttpBarSeriesDataSource(HttpClientWrapper httpClient) {
super(httpClient, false);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with the specified
* HttpClientWrapper and caching option. This constructor allows dependency
* injection of a mock HttpClientWrapper for unit testing and enables response
* caching.
*
* @param httpClient the HttpClientWrapper to use for API requests
* (can be a mock for testing)
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
public CoinbaseHttpBarSeriesDataSource(HttpClientWrapper httpClient, boolean enableResponseCaching) {
super(httpClient, enableResponseCaching);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with the specified HttpClient.
* This is a convenience constructor that wraps the HttpClient in a
* DefaultHttpClientWrapper.
*
* @param httpClient the HttpClient to use for API requests
*/
public CoinbaseHttpBarSeriesDataSource(HttpClient httpClient) {
super(httpClient, false);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with the specified HttpClient
* and caching option.
*
* @param httpClient the HttpClient to use for API requests
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
public CoinbaseHttpBarSeriesDataSource(HttpClient httpClient, boolean enableResponseCaching) {
super(httpClient, enableResponseCaching);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with the specified
* HttpClientWrapper and custom cache directory. Response caching is
* automatically enabled when a cache directory is specified.
*
* @param httpClient the HttpClientWrapper to use for API requests (can be
* a mock for testing)
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
public CoinbaseHttpBarSeriesDataSource(HttpClientWrapper httpClient, String responseCacheDir) {
super(httpClient, responseCacheDir);
}
/**
* Creates a new CoinbaseHttpBarSeriesDataSource with the specified HttpClient
* and custom cache directory. Response caching is automatically enabled when a
* cache directory is specified.
*
* @param httpClient the HttpClient to use for API requests
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
public CoinbaseHttpBarSeriesDataSource(HttpClient httpClient, String responseCacheDir) {
super(httpClient, responseCacheDir);
}
/**
* Loads historical OHLCV data for a given product ID within a specified date
* range. This is the base method that all other convenience methods delegate
* to.
* <p>
* <strong>Automatic Pagination:</strong> If the requested date range would
* exceed 350 candles (Coinbase's maximum per request), this method
* automatically splits the request into multiple smaller chunks, fetches them
* sequentially, and merges the results into a single BarSeries. This ensures
* reliable data retrieval for large date ranges while respecting API limits.
*
* @param productId the product ID (e.g., "BTC-USD", "ETH-USD")
* @param interval the bar interval (must be one of the supported Coinbase
* intervals)
* @param startDateTime the start date/time for the data range (inclusive)
* @param endDateTime the end date/time for the data range (inclusive)
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime) {
return DEFAULT_INSTANCE.loadSeriesInstance(productId, interval, startDateTime, endDateTime);
}
/**
* Loads historical OHLCV data for a given product ID with a specified number of
* bars. The end date/time is set to the current time, and the start date/time
* is calculated based on the bar count and interval.
* <p>
* <strong>Note:</strong> If the calculated date range would exceed 350 candles,
* this method will automatically paginate the request into multiple API calls
* and merge the results. This ensures reliable data retrieval for large bar
* counts.
*
* @param productId the product ID (e.g., "BTC-USD", "ETH-USD")
* @param interval the bar interval (must be one of the supported Coinbase
* intervals)
* @param barCount the number of bars to fetch
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String productId, CoinbaseInterval interval, int barCount) {
if (barCount <= 0) {
LOG.error("Bar count must be greater than 0");
return null;
}
Instant endDateTime = Instant.now();
Duration totalDuration = interval.getDuration().multipliedBy(barCount);
Instant startDateTime = endDateTime.minus(totalDuration);
return loadSeries(productId, interval, startDateTime, endDateTime);
}
/**
* Loads historical OHLCV data for a given product ID with daily bars.
* Convenience method that uses the number of days to calculate the date range.
*
* @param productId the product ID (e.g., "BTC-USD", "ETH-USD")
* @param days the number of days of historical data to fetch
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String productId, int days) {
return loadSeries(productId, CoinbaseInterval.ONE_DAY, days);
}
/**
* Loads historical OHLCV data for a given product ID with a specified interval.
* Convenience method that uses the number of days to calculate the date range.
*
* @param productId the product ID (e.g., "BTC-USD", "ETH-USD")
* @param days the number of days of historical data to fetch
* @param interval the bar interval (must be one of the supported Coinbase
* intervals)
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String productId, int days, CoinbaseInterval interval) {
if (days <= 0) {
LOG.error("Days must be greater than 0");
return null;
}
Instant endDateTime = Instant.now();
Instant startDateTime = endDateTime.minusSeconds(days * 86400L);
return loadSeries(productId, interval, startDateTime, endDateTime);
}
/**
* Parses the Coinbase API JSON response into a BarSeries using
* AdaptiveBarSeriesTypeAdapter.
* <p>
* This method reuses the existing AdaptiveBarSeriesTypeAdapter which already
* supports Coinbase format and handles null values. The adapter correctly
* interprets Coinbase's "start" field as the start of the candle period and
* calculates the end time as start + interval. The known interval from the API
* request is used directly.
*
* @param jsonResponse the JSON response string from Coinbase API
* @param productId the product ID (used as series name)
* @param barInterval the known bar interval from the API request
* @return a BarSeries containing the parsed data, or null if parsing fails
*/
private static BarSeries parseCoinbaseResponse(String jsonResponse, String productId, Duration barInterval) {
try {
JsonObject root = JsonParser.parseString(jsonResponse).getAsJsonObject();
// Use AdaptiveBarSeriesTypeAdapter's static helper method which handles
// Coinbase format correctly (treats "start" as start time, calculates end as
// start + interval)
// and uses the known interval from the API request
BarSeries series = AdaptiveBarSeriesTypeAdapter.parseCoinbaseFormat(root, productId, barInterval);
if (series == null || series.isEmpty()) {
LOG.error("No candles found in Coinbase response for product: {}", productId);
return null;
}
LOG.debug("Successfully loaded {} bars for product {}", series.getBarCount(), productId);
return series;
} catch (Exception e) {
LOG.error("Error parsing Coinbase response for product {}: {}", productId, e.getMessage(), e);
return null;
}
}
/**
* Merges multiple BarSeries into a single BarSeries, removing duplicates and
* sorting chronologically. Uses a TreeMap keyed by timestamp to automatically
* handle deduplication and sorting.
*
* @param chunks list of BarSeries to merge
* @param productId the product ID (for the merged series name)
* @param barInterval the bar interval
* @return a merged BarSeries
*/
private static BarSeries mergeBarSeries(List<BarSeries> chunks, String productId, Duration barInterval) {
// Use TreeMap to automatically sort by timestamp and deduplicate
TreeMap<Instant, BarData> barMap = new TreeMap<>();
// Collect all bars from all chunks
for (BarSeries chunk : chunks) {
for (int i = 0; i < chunk.getBarCount(); i++) {
var bar = chunk.getBar(i);
Instant endTime = bar.getEndTime();
// If we already have a bar at this timestamp, keep the first one
barMap.putIfAbsent(endTime, new BarData(bar));
}
}
// Build the merged series
BarSeries merged = new BaseBarSeriesBuilder().withName(productId).build();
for (BarData barData : barMap.values()) {
merged.barBuilder()
.timePeriod(barInterval)
.endTime(barData.endTime)
.openPrice(barData.open)
.highPrice(barData.high)
.lowPrice(barData.low)
.closePrice(barData.close)
.volume(barData.volume)
.amount(0)
.add();
}
LOG.debug("Merged {} chunks into {} unique bars for product {}", chunks.size(), merged.getBarCount(),
productId);
return merged;
}
/**
* Instance method that loads historical OHLCV data for a given product ID with
* a specified number of bars. The end date/time is set to the current time, and
* the start date/time is calculated based on the bar count and interval.
*
* @param productId the product ID (e.g., "BTC-USD", "ETH-USD")
* @param interval the bar interval (must be one of the supported Coinbase
* intervals)
* @param barCount the number of bars to fetch
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, int barCount) {
return loadSeriesInstance(productId, interval, barCount, null);
}
/**
* Instance method that loads historical OHLCV data for a given product ID with
* a specified number of bars and optional notes for cache file naming. The end
* date/time is set to the current time, and the start date/time is calculated
* based on the bar count and interval.
*
* @param productId the product ID (e.g., "BTC-USD", "ETH-USD")
* @param interval the bar interval (must be one of the supported Coinbase
* intervals)
* @param barCount the number of bars to fetch
* @param notes optional notes to include in cache filename (for uniqueness,
* e.g., test identifiers)
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, int barCount, String notes) {
if (barCount <= 0) {
LOG.error("Bar count must be greater than 0");
return null;
}
Instant endDateTime = Instant.now();
Duration totalDuration = interval.getDuration().multipliedBy(barCount);
Instant startDateTime = endDateTime.minus(totalDuration);
return loadSeriesInstance(productId, interval, startDateTime, endDateTime, notes);
}
@Override
public BarSeries loadSeries(String productId, Duration interval, Instant start, Instant end) {
if (productId == null || productId.trim().isEmpty()) {
throw new IllegalArgumentException("Product ID cannot be null or empty");
}
if (interval == null || interval.isNegative() || interval.isZero()) {
throw new IllegalArgumentException("Interval must be positive");
}
if (start == null || end == null) {
throw new IllegalArgumentException("Start and end dates cannot be null");
}
if (start.isAfter(end)) {
throw new IllegalArgumentException("Start date must be before or equal to end date");
}
// Map Duration to CoinbaseInterval
CoinbaseInterval cbInterval = mapDurationToInterval(interval);
if (cbInterval == null) {
LOG.warn("Unsupported interval duration: {}. Falling back to ONE_DAY", interval);
cbInterval = CoinbaseInterval.ONE_DAY;
}
return loadSeriesInstance(productId, cbInterval, start, end);
}
@Override
public BarSeries loadSeries(String source) {
if (source == null || source.trim().isEmpty()) {
throw new IllegalArgumentException("Source cannot be null or empty");
}
// Check if it's a cache file path
String sourcePrefix = getSourceName().isEmpty() ? "" : getSourceName() + "-";
if (source.startsWith(responseCacheDir) || (!sourcePrefix.isEmpty() && source.contains(sourcePrefix))) {
Path cacheFile = Paths.get(source);
if (Files.exists(cacheFile)) {
String cachedResponse = readFromCache(cacheFile);
if (cachedResponse != null) {
// Try to extract product ID from filename
String filename = cacheFile.getFileName().toString();
// Format: {sourceName}-PRODUCTID-INTERVAL-START-END[_NOTES].json
// Remove extension
String baseName = filename.replace(".json", "");
String[] parts = baseName.split("-");
if (parts.length >= 5) {
String productId = parts[1];
// Try to determine interval from filename
CoinbaseInterval interval = CoinbaseInterval.ONE_DAY; // Default
try {
interval = parseIntervalFromApiValue(parts[2]);
} catch (IllegalArgumentException e) {
LOG.debug("Could not parse interval from filename, using default: {}", e.getMessage());
}
return parseCoinbaseResponse(cachedResponse, productId, interval.getDuration());
}
}
}
}
// If not a cache file, return null (could be extended to parse other formats)
return null;
}
/**
* Maps a Duration to the closest matching CoinbaseInterval.
*
* @param duration the duration to map
* @return the matching CoinbaseInterval, or null if no close match is found
*/
private CoinbaseInterval mapDurationToInterval(Duration duration) {
long seconds = duration.getSeconds();
for (CoinbaseInterval interval : CoinbaseInterval.values()) {
if (interval.getDuration().getSeconds() == seconds) {
return interval;
}
}
return null;
}
/**
* Parses a CoinbaseInterval from its API value string.
*
* @param apiValue the API value (e.g., "ONE_MINUTE", "ONE_DAY")
* @return the matching CoinbaseInterval
* @throws IllegalArgumentException if no matching interval is found
*/
private CoinbaseInterval parseIntervalFromApiValue(String apiValue) {
for (CoinbaseInterval interval : CoinbaseInterval.values()) {
if (interval.getApiValue().equals(apiValue)) {
return interval;
}
}
throw new IllegalArgumentException("Unknown interval API value: " + apiValue);
}
/**
* Instance method that performs the actual loading logic. This method uses the
* instance's HttpClient (which can be injected for testing).
*/
public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime) {
return loadSeriesInstance(productId, interval, startDateTime, endDateTime, null);
}
/**
* Instance method that performs the actual loading logic with optional notes.
* This method uses the instance's HttpClient (which can be injected for
* testing).
*
* @param productId the product ID
* @param interval the interval
* @param startDateTime the start date/time
* @param endDateTime the end date/time
* @param notes optional notes to include in cache filename (for
* uniqueness)
* @return the BarSeries or null if request fails
*/
public BarSeries loadSeriesInstance(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
if (productId == null || productId.trim().isEmpty()) {
LOG.error("Product ID cannot be null or empty");
return null;
}
if (startDateTime == null || endDateTime == null) {
LOG.error("Start and end date/time cannot be null");
return null;
}
if (startDateTime.isAfter(endDateTime)) {
LOG.error("Start date/time must be before or equal to end date/time");
return null;
}
// Calculate if we need pagination (max 350 candles per request)
Duration requestedRange = Duration.between(startDateTime, endDateTime);
long requestedBars = requestedRange.dividedBy(interval.getDuration());
if (requestedBars > MAX_CANDLES_PER_REQUEST) {
LOG.debug(
"Requested date range would result in {} bars (max {}). "
+ "Splitting into multiple requests and combining results.",
requestedBars, MAX_CANDLES_PER_REQUEST);
return loadSeriesPaginated(productId, interval, startDateTime, endDateTime, notes);
}
// Single request for smaller ranges
return loadSeriesSingleRequest(productId, interval, startDateTime, endDateTime, notes);
}
/**
* Generates the cache file path for a given request.
*
* @param productId the product ID
* @param interval the interval
* @param startDateTime the start date/time (will be truncated)
* @param endDateTime the end date/time (will be truncated)
* @param notes optional notes section to append to filename (can be
* null or empty)
* @return the cache file path
*/
private Path getCacheFilePath(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
return getCacheFilePath(productId, startDateTime, endDateTime, interval.getDuration(), notes);
}
/**
* Generates the cache file path for a given request (without notes).
*
* @param productId the product ID
* @param interval the interval
* @param startDateTime the start date/time (will be truncated)
* @param endDateTime the end date/time (will be truncated)
* @return the cache file path
*/
private Path getCacheFilePath(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime) {
return getCacheFilePath(productId, interval, startDateTime, endDateTime, null);
}
/**
* Makes a single API request for the specified date range with optional notes.
* This is used for requests that don't exceed 350 candles. If caching is
* enabled, checks cache first before making the API request.
*
* @param productId the product ID
* @param interval the interval
* @param startDateTime the start date/time
* @param endDateTime the end date/time
* @param notes optional notes to include in cache filename (for
* uniqueness)
* @return the BarSeries or null if request fails
*/
private BarSeries loadSeriesSingleRequest(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
// Check cache first if caching is enabled
if (enableResponseCaching) {
// Try exact match first (with or without notes)
Path cacheFile = getCacheFilePath(productId, interval, startDateTime, endDateTime, notes);
if (isCacheValid(cacheFile, interval.getDuration(), endDateTime)) {
String cachedResponse = readFromCache(cacheFile);
if (cachedResponse != null) {
LOG.debug("Using cached response for {} ({} to {})", productId, startDateTime, endDateTime);
return parseCoinbaseResponse(cachedResponse, productId, interval.getDuration());
}
}
// Also try without notes (for backward compatibility)
if (notes != null && !notes.trim().isEmpty()) {
Path cacheFileNoNotes = getCacheFilePath(productId, interval, startDateTime, endDateTime);
if (isCacheValid(cacheFileNoNotes, interval.getDuration(), endDateTime)) {
String cachedResponse = readFromCache(cacheFileNoNotes);
if (cachedResponse != null) {
LOG.debug("Using cached response for {} ({} to {})", productId, startDateTime, endDateTime);
return parseCoinbaseResponse(cachedResponse, productId, interval.getDuration());
}
}
}
}
try {
String encodedProductId = URLEncoder.encode(productId.trim(), StandardCharsets.UTF_8);
long startTimestamp = startDateTime.getEpochSecond();
long endTimestamp = endDateTime.getEpochSecond();
String url = String.format("%s%s/candles?start=%d&end=%d&granularity=%s&limit=%d", COINBASE_API_URL,
encodedProductId, startTimestamp, endTimestamp, interval.getApiValue(), MAX_CANDLES_PER_REQUEST);
LOG.trace("Fetching data from Coinbase: {}", url);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponseWrapper<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
LOG.error("Coinbase API returned status code: {}", response.statusCode());
return null;
}
String responseBody = response.body();
LOG.trace("Response body: {}", responseBody);
// Cache the response if caching is enabled
if (enableResponseCaching) {
Path cacheFile = getCacheFilePath(productId, interval, startDateTime, endDateTime, notes);
writeToCache(cacheFile, responseBody);
}
return parseCoinbaseResponse(responseBody, productId, interval.getDuration());
} catch (IOException | InterruptedException e) {
LOG.error("Error fetching data from Coinbase for product {}: {}", productId, e.getMessage(), e);
return null;
}
}
/**
* Loads data by splitting a large date range into multiple smaller requests
* (pagination). Each chunk respects the 350 candle limit, and results are
* merged chronologically.
*
* @param productId the product ID
* @param interval the bar interval
* @param startDateTime the start date/time
* @param endDateTime the end date/time
* @param notes optional notes to include in cache filename (for
* uniqueness)
* @return a BarSeries containing all merged data, or null if all requests fail
*/
private BarSeries loadSeriesPaginated(String productId, CoinbaseInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
List<BarSeries> chunks = new ArrayList<>();
Instant currentStart = startDateTime;
int requestCount = 0;
// Calculate chunk size (350 candles worth of time)
Duration chunkSize = interval.getDuration().multipliedBy(MAX_CANDLES_PER_REQUEST);
// Calculate number of chunks needed
Duration totalRange = Duration.between(startDateTime, endDateTime);
int estimatedChunks = (int) Math.ceil((double) totalRange.toSeconds() / chunkSize.toSeconds());
LOG.trace("Splitting request into approximately {} chunks", estimatedChunks);
while (currentStart.isBefore(endDateTime)) {
// Calculate chunk end time (don't exceed the requested end time)
Instant chunkEnd = currentStart.plus(chunkSize);
if (chunkEnd.isAfter(endDateTime)) {
chunkEnd = endDateTime;
}
requestCount++;
LOG.trace("Fetching chunk {}/? ({} to {})", requestCount, currentStart, chunkEnd);
BarSeries chunk = loadSeriesSingleRequest(productId, interval, currentStart, chunkEnd, notes);
if (chunk != null && chunk.getBarCount() > 0) {
chunks.add(chunk);
LOG.trace("Successfully loaded chunk {} with {} bars", requestCount, chunk.getBarCount());
} else {
LOG.warn("Chunk {} returned no data or failed", requestCount);
}
// Move to next chunk (start from the end of current chunk)
currentStart = chunkEnd;
// If we've reached the end, break
if (chunkEnd.equals(endDateTime) || !currentStart.isBefore(endDateTime)) {
break;
}
// Add a small delay between requests to avoid rate limiting
try {
Thread.sleep(100); // 100ms delay between requests
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Interrupted during pagination delay");
break;
}
}
if (chunks.isEmpty()) {
LOG.error("All paginated requests failed for product {}", productId);
return null;
}
LOG.debug("Successfully fetched {} chunks, merging {} total bars", chunks.size(),
chunks.stream().mapToInt(BarSeries::getBarCount).sum());
return mergeBarSeries(chunks, productId, interval.getDuration());
}
/**
* Supported intervals for Coinbase API. These correspond to the intervals that
* Coinbase's Advanced Trade API supports.
*/
public enum CoinbaseInterval {
/**
* 1 minute bars
*/
ONE_MINUTE(Duration.ofMinutes(1), "ONE_MINUTE"),
/**
* 5 minute bars
*/
FIVE_MINUTE(Duration.ofMinutes(5), "FIVE_MINUTE"),
/**
* 15 minute bars
*/
FIFTEEN_MINUTE(Duration.ofMinutes(15), "FIFTEEN_MINUTE"),
/**
* 30 minute bars
*/
THIRTY_MINUTE(Duration.ofMinutes(30), "THIRTY_MINUTE"),
/**
* 1 hour bars
*/
ONE_HOUR(Duration.ofHours(1), "ONE_HOUR"),
/**
* 2 hour bars
*/
TWO_HOUR(Duration.ofHours(2), "TWO_HOUR"),
/**
* 4 hour bars
*/
FOUR_HOUR(Duration.ofHours(4), "FOUR_HOUR"),
/**
* 6 hour bars
*/
SIX_HOUR(Duration.ofHours(6), "SIX_HOUR"),
/**
* 1 day bars
*/
ONE_DAY(Duration.ofDays(1), "ONE_DAY");
private final Duration duration;
private final String apiValue;
CoinbaseInterval(Duration duration, String apiValue) {
this.duration = duration;
this.apiValue = apiValue;
}
/**
* Returns the Duration for this interval.
*
* @return the Duration
*/
public Duration getDuration() {
return duration;
}
/**
* Returns the API string value for this interval.
*
* @return the API string value
*/
public String getApiValue() {
return apiValue;
}
}
/**
* Helper class to hold bar data during merging.
*/
private static class BarData {
final Instant endTime;
final double open;
final double high;
final double low;
final double close;
final double volume;
BarData(Bar bar) {
this.endTime = bar.getEndTime();
this.open = bar.getOpenPrice().doubleValue();
this.high = bar.getHighPrice().doubleValue();
this.low = bar.getLowPrice().doubleValue();
this.close = bar.getClosePrice().doubleValue();
this.volume = bar.getVolume().doubleValue();
}
}
}
@@ -0,0 +1,250 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import com.opencsv.CSVParserBuilder;
import com.opencsv.CSVReader;
import com.opencsv.CSVReaderBuilder;
import com.opencsv.exceptions.CsvValidationException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import ta4jexamples.datasources.file.AbstractFileBarSeriesDataSource;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* This class builds a Ta4j bar series from a CSV file containing bars.
* <p>
* Implements {@link BarSeriesDataSource} to support domain-driven loading by
* ticker, interval, and date range. Searches for CSV files matching the
* specified criteria in the classpath.
*/
public class CsvFileBarSeriesDataSource extends AbstractFileBarSeriesDataSource {
private static final Logger LOG = LogManager.getLogger(CsvFileBarSeriesDataSource.class);
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String DEFAULT_APPLE_BAR_FILE = "AAPL-PT1D-20130102_20131231.csv";
/**
* Creates a new CsvFileBarSeriesDataSource with no source prefix.
*/
public CsvFileBarSeriesDataSource() {
super("");
}
@Override
protected String getFileExtension() {
return "csv";
}
@Override
protected BarSeries searchAndLoadFile(String ticker, String intervalStr, String sourcePrefix,
String startDateTimeStr, String endDateTimeStr, String startDateStr, String endDateStr, Duration interval,
Instant start, Instant end) {
// Try exact pattern with interval-appropriate format:
// {ticker}-{interval}-{startDateTime}_{endDateTime}.csv
String exactPattern = ticker.toUpperCase() + "-" + intervalStr + "-" + startDateTimeStr + "_" + endDateTimeStr
+ ".csv";
BarSeries series = loadCsvSeries(exactPattern);
if (series != null && !series.isEmpty()) {
return series;
}
// Fallback to date-only format for existing files
String exactPatternDateOnly = ticker.toUpperCase() + "-" + intervalStr + "-" + startDateStr + "_" + endDateStr
+ ".csv";
series = loadCsvSeries(exactPatternDateOnly);
if (series != null && !series.isEmpty()) {
return series;
}
// Try broader pattern: {ticker}-*-{startDateTime}_*.csv
String broaderPattern = ticker.toUpperCase() + "-*-" + startDateTimeStr + "_*.csv";
series = searchAndLoadCsvFile(broaderPattern, start, end);
if (series != null && !series.isEmpty()) {
return series;
}
// Fallback to date-only format for broader pattern
String broaderPatternDateOnly = ticker.toUpperCase() + "-*-" + startDateStr + "_*.csv";
series = searchAndLoadCsvFile(broaderPatternDateOnly, start, end);
if (series != null && !series.isEmpty()) {
return series;
}
// Try even broader: {ticker}-*.csv (then filter by date range)
String broadestPattern = ticker.toUpperCase() + "-*.csv";
series = searchAndLoadCsvFile(broadestPattern, start, end);
if (series != null && !series.isEmpty()) {
return filterSeriesByDateRange(series, start, end);
}
return null;
}
@Override
public BarSeries loadSeries(String source) {
if (source == null || source.trim().isEmpty()) {
throw new IllegalArgumentException("Source cannot be null or empty");
}
return loadCsvSeries(source);
}
/**
* Searches for a CSV file matching the pattern and loads it if found.
*
* @param pattern the filename pattern to search for (supports wildcards)
* @param start the start date (for validation)
* @param end the end date (for validation)
* @return the loaded BarSeries, or null if not found
*/
private BarSeries searchAndLoadCsvFile(String pattern, Instant start, Instant end) {
// Try direct pattern match as resource
if (!pattern.contains("*")) {
return loadCsvSeries(pattern);
}
// Count wildcards to determine replacement strategy
long wildcardCount = pattern.chars().filter(ch -> ch == '*').count();
if (wildcardCount == 1) {
// Single wildcard: try common interval values
String[] intervalVariations = { "PT1D", "PT5M", "PT1H", "" };
for (String interval : intervalVariations) {
String variation = pattern.replaceFirst("\\*", interval);
BarSeries series = loadCsvSeries(variation);
if (series != null && !series.isEmpty()) {
return series;
}
}
} else if (wildcardCount >= 2) {
// Multiple wildcards: replace the first (interval) with common values,
// and the second (end date) with the actual end date from parameters
String[] intervalVariations = { "PT1D", "PT5M", "PT1H" };
// Format end date in both date-only and datetime formats
String endDateStr = end.atZone(ZoneOffset.UTC).format(FILENAME_DATE_FORMAT);
String endDateTimeStr = end.atZone(ZoneOffset.UTC)
.format(getDateTimeFormatterForInterval(Duration.ofDays(1)));
for (String interval : intervalVariations) {
// Replace first wildcard with interval, second with end date (date-only format)
String variation = pattern.replaceFirst("\\*", interval).replaceFirst("\\*", endDateStr);
BarSeries series = loadCsvSeries(variation);
if (series != null && !series.isEmpty()) {
return series;
}
// Also try with datetime format for the end date
variation = pattern.replaceFirst("\\*", interval).replaceFirst("\\*", endDateTimeStr);
series = loadCsvSeries(variation);
if (series != null && !series.isEmpty()) {
return series;
}
}
}
return null;
}
/**
* Loads a bar series from the default CSV file.
*
* @return the bar series loaded from the default CSV file
*/
public static BarSeries loadSeriesFromFile() {
return loadSeriesFromFile(DEFAULT_APPLE_BAR_FILE);
}
/**
* Loads a bar series from the specified CSV file. This is a convenience method
* that delegates to {@link #loadCsvSeries(String)}.
*
* @param csvFile the path to the CSV file containing bar data
* @return the bar series loaded from the specified CSV file
*/
public static BarSeries loadSeriesFromFile(String csvFile) {
return loadCsvSeries(csvFile);
}
/**
* Loads a bar series from a CSV file with the specified filename. The CSV file
* is expected to contain stock market data with the following columns: date,
* open price, high price, low price, close price, and volume. The date format
* is expected to match the predefined DATE_FORMAT.
*
* @param filename the name of the CSV file to load
* @return the bar series containing stock data loaded from the specified CSV
* file, or null if the file is not found or empty
*/
public static BarSeries loadCsvSeries(String filename) {
var stream = CsvFileBarSeriesDataSource.class.getClassLoader().getResourceAsStream(filename);
if (stream == null) {
LOG.debug("CSV file not found in classpath: {}", filename);
return null;
}
var series = new BaseBarSeriesBuilder().withName(filename).build();
try (stream) {
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
try (CSVReader csvReader = new CSVReaderBuilder(reader)
.withCSVParser(new CSVParserBuilder().withSeparator(',').build())
.withSkipLines(1)
.build()) {
String[] line;
while ((line = csvReader.readNext()) != null) {
Instant date = LocalDate.parse(line[0], DATE_FORMAT).atStartOfDay(ZoneOffset.UTC).toInstant();
double open = Double.parseDouble(line[1]);
double high = Double.parseDouble(line[2]);
double low = Double.parseDouble(line[3]);
double close = Double.parseDouble(line[4]);
double volume = Double.parseDouble(line[5]);
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(date)
.openPrice(open)
.closePrice(close)
.highPrice(high)
.lowPrice(low)
.volume(volume)
.amount(0)
.add();
}
} catch (CsvValidationException e) {
LOG.error("Unable to load bars from CSV. File is not valid csv.", e);
}
}
} catch (IOException ioe) {
LOG.error("Unable to load bars from CSV", ioe);
} catch (NumberFormatException nfe) {
LOG.error("Error while parsing value", nfe);
return null;
}
return series.isEmpty() ? null : series;
}
public static void main(String[] args) {
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
LOG.debug("Series: {} ({})", series.getName(), series.getSeriesPeriodDescription());
LOG.debug("Number of bars: {}", series.getBarCount());
if (series.isEmpty()) {
LOG.warn("Series is empty - no bars loaded from CSV file. Skipping first bar details.");
} else {
LOG.debug("First bar: \n\tVolume: {}\n\tOpen price: {}\n\tClose price: {}", series.getBar(0).getVolume(),
series.getBar(0).getOpenPrice(), series.getBar(0).getClosePrice());
}
}
}
@@ -0,0 +1,177 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.stream.JsonReader;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import ta4jexamples.datasources.file.AbstractFileBarSeriesDataSource;
import ta4jexamples.datasources.json.AdaptiveBarSeriesTypeAdapter;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
/**
* A data source for BarSeries objects that can adapt to different JSON formats.
* This class provides methods to load BarSeries data from JSON format,
* specifically supporting multiple exchange formats such as Binance and
* Coinbase. It uses Gson with a custom TypeAdapter to handle the
* deserialization process. The data source can read from either an InputStream
* or a file path.
* <p>
* Implements {@link BarSeriesDataSource} to support domain-driven loading by
* ticker, interval, and date range. Searches for JSON files matching the
* specified criteria in the classpath.
*
* @since 0.19
*/
public class JsonFileBarSeriesDataSource extends AbstractFileBarSeriesDataSource {
private static final Gson TYPEADAPTER_GSON = new GsonBuilder()
.registerTypeAdapter(BarSeries.class, new AdaptiveBarSeriesTypeAdapter())
.create();
private static final Logger LOG = LogManager.getLogger(JsonFileBarSeriesDataSource.class);
/**
* Creates a new JsonFileBarSeriesDataSource with no source prefix.
*/
public JsonFileBarSeriesDataSource() {
super("");
}
/**
* Default instance for backward compatibility with static method calls. Use
* this instance when migrating from static methods to instance methods.
*/
public static final JsonFileBarSeriesDataSource DEFAULT_INSTANCE = new JsonFileBarSeriesDataSource();
@Override
protected String getFileExtension() {
return "json";
}
@Override
protected BarSeries searchAndLoadFile(String ticker, String intervalStr, String sourcePrefix,
String startDateTimeStr, String endDateTimeStr, String startDateStr, String endDateStr, Duration interval,
Instant start, Instant end) {
// Try with exchange prefixes (Coinbase-, Binance-)
// NOTE: All branches must call filterSeriesByDateRange() to ensure data is
// filtered
// to the requested date range, even when files contain broader date ranges.
String[] exchangePrefixes = { "Coinbase-", "Binance-" };
for (String exchange : exchangePrefixes) {
// Try exact pattern with interval-appropriate format:
// {Exchange}-{ticker}-{interval}-{startDateTime}_{endDateTime}.json
String pattern = exchange + ticker.toUpperCase() + "-" + intervalStr + "-" + startDateTimeStr + "_"
+ endDateTimeStr + ".json";
BarSeries series = loadFromSource(pattern);
if (series != null && !series.isEmpty()) {
return filterSeriesByDateRange(series, start, end);
}
// Fallback to date-only format for existing files
// NOTE: Date-only format may match files with broader date ranges, so filtering
// is required
String patternDateOnly = exchange + ticker.toUpperCase() + "-" + intervalStr + "-" + startDateStr + "_"
+ endDateStr + ".json";
series = loadFromSource(patternDateOnly);
if (series != null && !series.isEmpty()) {
return filterSeriesByDateRange(series, start, end);
}
}
// Try without exchange prefix as fallback (for generic JSON files)
String pattern = ticker.toUpperCase() + "-" + intervalStr + "-" + startDateTimeStr + "_" + endDateTimeStr
+ ".json";
BarSeries series = loadFromSource(pattern);
if (series != null && !series.isEmpty()) {
return filterSeriesByDateRange(series, start, end);
}
// Fallback to date-only format
// NOTE: Date-only format may match files with broader date ranges, so filtering
// is required
String patternDateOnly = ticker.toUpperCase() + "-" + intervalStr + "-" + startDateStr + "_" + endDateStr
+ ".json";
series = loadFromSource(patternDateOnly);
if (series != null && !series.isEmpty()) {
return filterSeriesByDateRange(series, start, end);
}
return null;
}
@Override
public BarSeries loadSeries(String source) {
return loadFromSource(source);
}
@Override
public BarSeries loadSeries(InputStream inputStream) {
return loadFromStream(inputStream);
}
/**
* Internal implementation for loading from a file.
*/
private BarSeries loadFromSource(String source) {
if (source == null || source.trim().isEmpty()) {
return null;
}
try (FileInputStream fis = new FileInputStream(source)) {
return loadFromStream(fis);
} catch (Exception e) {
// Try as classpath resource
InputStream resourceStream = JsonFileBarSeriesDataSource.class.getClassLoader().getResourceAsStream(source);
if (resourceStream != null) {
try (resourceStream) {
return loadFromStream(resourceStream);
} catch (Exception resourceException) {
LOG.debug("Unable to load bars from classpath resource: {}", source, resourceException);
return null;
}
}
LOG.debug("Unable to load bars from file: {}", source, e);
return null;
}
}
/**
* Internal implementation for loading from InputStream.
* <p>
* This method fully consumes the stream but does not close it, as per the
* interface contract. The caller is responsible for closing the stream.
*/
private BarSeries loadFromStream(InputStream inputStream) {
if (inputStream == null) {
LOG.debug("Input stream is null, returning null");
return null;
}
// Read the stream fully into a String without closing it
// This ensures we fully consume the stream while respecting the contract
// that the caller is responsible for closing it
String jsonContent;
try {
jsonContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
LOG.debug("Unable to read from input stream", e);
return null;
}
// Parse the JSON content from the String
try (JsonReader reader = new JsonReader(new java.io.StringReader(jsonContent))) {
return TYPEADAPTER_GSON.fromJson(reader, BarSeries.class);
} catch (Exception e) {
LOG.debug("Unable to load bars from JSON using TypeAdapter", e);
return null;
}
}
}
@@ -0,0 +1,978 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import ta4jexamples.datasources.http.AbstractHttpBarSeriesDataSource;
import ta4jexamples.datasources.http.DefaultHttpClientWrapper;
import ta4jexamples.datasources.http.HttpClientWrapper;
import ta4jexamples.datasources.http.HttpResponseWrapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
/**
* Loads OHLCV data from Yahoo Finance API.
* <p>
* This loader fetches historical price data from Yahoo Finance's public API
* without requiring an API key. It supports stocks, ETFs, and cryptocurrencies.
* <p>
* <strong>Example usage:</strong>
*
* <pre>
* // Load 1 year of daily data for Apple stock (using days)
* BarSeries series = YahooFinanceHttpBarSeriesDataSource.loadSeries("AAPL", 365);
*
* // Load 500 bars of hourly data for Bitcoin (using bar count)
* BarSeries btcSeries = YahooFinanceHttpBarSeriesDataSource.loadSeries("BTC-USD", YahooFinanceInterval.HOUR_1, 500);
*
* // Load data for a specific date range
* Instant start = Instant.parse("2023-01-01T00:00:00Z");
* Instant end = Instant.parse("2023-12-31T23:59:59Z");
* BarSeries msftSeries = YahooFinanceHttpBarSeriesDataSource.loadSeries("MSFT", YahooFinanceInterval.DAY_1, start,
* end);
* </pre>
* <p>
* <strong>Response Caching:</strong> To enable response caching for faster
* subsequent requests, use the constructor with {@code enableResponseCaching}:
*
* <pre>
* YahooFinanceHttpBarSeriesDataSource loader = new YahooFinanceHttpBarSeriesDataSource(true);
* BarSeries series = loader.loadSeriesInstance("AAPL", YahooFinanceInterval.DAY_1, start, end);
* </pre>
* <p>
* To use a custom cache directory, use the constructor with
* {@code responseCacheDir}:
*
* <pre>
* YahooFinanceHttpBarSeriesDataSource loader = new YahooFinanceHttpBarSeriesDataSource("/path/to/cache");
* BarSeries series = loader.loadSeriesInstance("AAPL", YahooFinanceInterval.DAY_1, start, end);
* </pre>
* <p>
* When caching is enabled, responses are saved to the cache directory (default:
* {@code temp/responses}) and reused for requests within the cache validity
* period (based on the interval). For example, daily data is cached for the
* day, 15-minute data is cached for 15 minutes, etc. Historical data (end date
* in the past) is cached indefinitely.
* <p>
* <strong>Unit Testing:</strong> For unit testing with a mock HttpClient, use
* the constructor:
*
* <pre>
* HttpClientWrapper mockHttpClient = mock(HttpClientWrapper.class);
* YahooFinanceHttpBarSeriesDataSource loader = new YahooFinanceHttpBarSeriesDataSource(mockHttpClient);
* // Use loader instance methods or inject into your code
* </pre>
* <p>
* <strong>Note:</strong> Yahoo Finance is an unofficial API and may have rate
* limits or availability issues. For production use, consider using official
* APIs like Alpha Vantage, Polygon.io, or IEX Cloud.
*
* @since 0.20
*/
public class YahooFinanceHttpBarSeriesDataSource extends AbstractHttpBarSeriesDataSource {
public static final String YAHOO_FINANCE_API_URL = "https://query1.finance.yahoo.com/v8/finance/chart/";
private static final Logger LOG = LogManager.getLogger(YahooFinanceHttpBarSeriesDataSource.class);
@Override
public String getSourceName() {
return "YahooFinance";
}
private static final HttpClientWrapper DEFAULT_HTTP_CLIENT = new DefaultHttpClientWrapper();
private static final YahooFinanceHttpBarSeriesDataSource DEFAULT_INSTANCE = new YahooFinanceHttpBarSeriesDataSource(
DEFAULT_HTTP_CLIENT);
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with a default HttpClient.
* For unit testing, use
* {@link #YahooFinanceHttpBarSeriesDataSource(HttpClientWrapper)} to inject a
* mock HttpClientWrapper.
*/
public YahooFinanceHttpBarSeriesDataSource() {
super(DEFAULT_HTTP_CLIENT, false);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with a default HttpClient
* and caching option.
*
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
public YahooFinanceHttpBarSeriesDataSource(boolean enableResponseCaching) {
super(DEFAULT_HTTP_CLIENT, enableResponseCaching);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with a default HttpClient
* and custom cache directory. Response caching is automatically enabled when a
* cache directory is specified.
*
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
public YahooFinanceHttpBarSeriesDataSource(String responseCacheDir) {
super(DEFAULT_HTTP_CLIENT, responseCacheDir);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with the specified
* HttpClientWrapper. This constructor allows dependency injection of a mock
* HttpClientWrapper for unit testing.
*
* @param httpClient the HttpClientWrapper to use for API requests (can be a
* mock for testing)
*/
public YahooFinanceHttpBarSeriesDataSource(HttpClientWrapper httpClient) {
super(httpClient, false);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with the specified
* HttpClientWrapper and caching option. This constructor allows dependency
* injection of a mock HttpClientWrapper for unit testing and enables response
* caching.
*
* @param httpClient the HttpClientWrapper to use for API requests
* (can be a mock for testing)
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
public YahooFinanceHttpBarSeriesDataSource(HttpClientWrapper httpClient, boolean enableResponseCaching) {
super(httpClient, enableResponseCaching);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with the specified
* HttpClient. This is a convenience constructor that wraps the HttpClient in a
* DefaultHttpClientWrapper.
*
* @param httpClient the HttpClient to use for API requests
*/
public YahooFinanceHttpBarSeriesDataSource(HttpClient httpClient) {
super(httpClient, false);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with the specified
* HttpClient and caching option.
*
* @param httpClient the HttpClient to use for API requests
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
public YahooFinanceHttpBarSeriesDataSource(HttpClient httpClient, boolean enableResponseCaching) {
super(httpClient, enableResponseCaching);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with the specified
* HttpClientWrapper and custom cache directory. Response caching is
* automatically enabled when a cache directory is specified.
*
* @param httpClient the HttpClientWrapper to use for API requests (can be
* a mock for testing)
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
public YahooFinanceHttpBarSeriesDataSource(HttpClientWrapper httpClient, String responseCacheDir) {
super(httpClient, responseCacheDir);
}
/**
* Creates a new YahooFinanceHttpBarSeriesDataSource with the specified
* HttpClient and custom cache directory. Response caching is automatically
* enabled when a cache directory is specified.
*
* @param httpClient the HttpClient to use for API requests
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
public YahooFinanceHttpBarSeriesDataSource(HttpClient httpClient, String responseCacheDir) {
super(httpClient, responseCacheDir);
}
/**
* Loads historical OHLCV data for a given ticker symbol within a specified date
* range. This is the base method that all other convenience methods delegate
* to.
* <p>
* <strong>Automatic Pagination:</strong> If the requested date range exceeds
* conservative API limits, this method automatically splits the request into
* multiple smaller chunks, fetches them sequentially, and merges the results
* into a single BarSeries. This ensures reliable data retrieval for large date
* ranges while respecting API rate limits.
* <p>
* <strong>API Limits:</strong> Yahoo Finance's unofficial API has practical
* limits:
* <ul>
* <li>Rate limits: ~2000 requests/hour per IP (may result in temporary bans if
* exceeded)</li>
* <li>Data range limits (approximate, may vary):
* <ul>
* <li>Intraday (1m-4h): Typically 60-90 days maximum per request</li>
* <li>Daily (1d): Typically 2-5 years maximum per request</li>
* <li>Weekly/Monthly (1wk, 1mo): Can request many years per request</li>
* </ul>
* </li>
* </ul>
* <p>
* <strong>Conservative Limits (triggers pagination):</strong>
* <ul>
* <li>Intraday (1m-4h): 30 days per chunk</li>
* <li>Hourly (1h, 4h): 60 days per chunk</li>
* <li>Daily (1d): 1 year per chunk</li>
* <li>Weekly/Monthly (1wk, 1mo): 5 years per chunk</li>
* </ul>
*
* @param ticker the ticker symbol (e.g., "AAPL", "MSFT", "BTC-USD",
* "ETH-USD")
* @param interval the bar interval (must be one of the supported Yahoo
* Finance intervals)
* @param startDateTime the start date/time for the data range (inclusive)
* @param endDateTime the end date/time for the data range (inclusive)
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime) {
return DEFAULT_INSTANCE.loadSeriesInstance(ticker, interval, startDateTime, endDateTime);
}
/**
* Loads historical OHLCV data for a given ticker symbol with a specified number
* of bars. The end date/time is set to the current time, and the start
* date/time is calculated based on the bar count and interval.
* <p>
* <strong>Note:</strong> If the calculated date range exceeds conservative API
* limits, this method will automatically paginate the request into multiple API
* calls and merge the results. This ensures reliable data retrieval for large
* bar counts.
*
* @param ticker the ticker symbol (e.g., "AAPL", "MSFT", "BTC-USD",
* "ETH-USD")
* @param interval the bar interval (must be one of the supported Yahoo Finance
* intervals)
* @param barCount the number of bars to fetch
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String ticker, YahooFinanceInterval interval, int barCount) {
if (barCount <= 0) {
LOG.error("Bar count must be greater than 0");
return null;
}
Instant endDateTime = Instant.now();
Duration totalDuration = interval.getDuration().multipliedBy(barCount);
Instant startDateTime = endDateTime.minus(totalDuration);
return loadSeries(ticker, interval, startDateTime, endDateTime);
}
/**
* Loads historical OHLCV data for a given ticker symbol with daily bars.
* Convenience method that uses the number of days to calculate the date range.
*
* @param ticker the ticker symbol (e.g., "AAPL", "MSFT", "BTC-USD", "ETH-USD")
* @param days the number of days of historical data to fetch
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String ticker, int days) {
return loadSeries(ticker, YahooFinanceInterval.DAY_1, days);
}
/**
* Loads historical OHLCV data for a given ticker symbol with a specified
* interval. Convenience method that uses the number of days to calculate the
* date range.
*
* @param ticker the ticker symbol (e.g., "AAPL", "MSFT", "BTC-USD",
* "ETH-USD")
* @param days the number of days of historical data to fetch
* @param interval the bar interval (must be one of the supported Yahoo Finance
* intervals)
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public static BarSeries loadSeries(String ticker, int days, YahooFinanceInterval interval) {
if (days <= 0) {
LOG.error("Days must be greater than 0");
return null;
}
Instant endDateTime = Instant.now();
Instant startDateTime = endDateTime.minusSeconds(days * 86400L);
return loadSeries(ticker, interval, startDateTime, endDateTime);
}
/**
* Parses the Yahoo Finance API JSON response into a BarSeries.
*/
private static BarSeries parseYahooFinanceResponse(String jsonResponse, String ticker, Duration barInterval) {
try {
JsonObject root = JsonParser.parseString(jsonResponse).getAsJsonObject();
JsonObject chart = root.getAsJsonObject("chart");
JsonArray results = chart.getAsJsonArray("result");
if (results == null || results.isEmpty()) {
LOG.error("No results found in Yahoo Finance response for ticker: {}", ticker);
return null;
}
JsonObject result = results.get(0).getAsJsonObject();
// Get timestamps array
JsonArray timestamps = result.getAsJsonArray("timestamp");
if (timestamps == null) {
LOG.error("No timestamp data found in Yahoo Finance response for ticker: {}", ticker);
return null;
}
JsonObject indicators = result.getAsJsonObject("indicators");
if (indicators == null) {
LOG.error("No indicators found in Yahoo Finance response for ticker: {}", ticker);
return null;
}
JsonArray quotes = indicators.getAsJsonArray("quote");
if (quotes == null || quotes.isEmpty()) {
LOG.error("No quote data found in Yahoo Finance response for ticker: {}", ticker);
return null;
}
JsonObject quote = quotes.get(0).getAsJsonObject();
JsonArray opens = quote.getAsJsonArray("open");
JsonArray highs = quote.getAsJsonArray("high");
JsonArray lows = quote.getAsJsonArray("low");
JsonArray closes = quote.getAsJsonArray("close");
JsonArray volumes = quote.getAsJsonArray("volume");
BarSeries series = new BaseBarSeriesBuilder().withName(ticker).build();
int dataLength = timestamps.size();
for (int i = 0; i < dataLength; i++) {
// Skip bars with null values
if (timestamps.get(i).isJsonNull() || opens.get(i).isJsonNull() || highs.get(i).isJsonNull()
|| lows.get(i).isJsonNull() || closes.get(i).isJsonNull()) {
continue;
}
long timestamp = timestamps.get(i).getAsLong();
Instant endTime = Instant.ofEpochSecond(timestamp);
double openValue = opens.get(i).getAsDouble();
double highValue = highs.get(i).getAsDouble();
double lowValue = lows.get(i).getAsDouble();
double closeValue = closes.get(i).getAsDouble();
double volumeValue = volumes.get(i).isJsonNull() ? 0.0 : volumes.get(i).getAsDouble();
series.barBuilder()
.timePeriod(barInterval)
.endTime(endTime)
.openPrice(openValue)
.highPrice(highValue)
.lowPrice(lowValue)
.closePrice(closeValue)
.volume(volumeValue)
.amount(0)
.add();
}
LOG.debug("Successfully loaded {} bars for ticker {}", series.getBarCount(), ticker);
return series;
} catch (Exception e) {
LOG.error("Error parsing Yahoo Finance response for ticker {}: {}", ticker, e.getMessage(), e);
return null;
}
}
/**
* Merges multiple BarSeries into a single BarSeries, removing duplicates and
* sorting chronologically. Uses a TreeMap keyed by timestamp to automatically
* handle deduplication and sorting.
*
* @param chunks list of BarSeries to merge
* @param ticker the ticker symbol (for the merged series name)
* @param barInterval the bar interval
* @return a merged BarSeries
*/
private static BarSeries mergeBarSeries(List<BarSeries> chunks, String ticker, Duration barInterval) {
// Use TreeMap to automatically sort by timestamp and deduplicate
TreeMap<Instant, BarData> barMap = new TreeMap<>();
// Collect all bars from all chunks
for (BarSeries chunk : chunks) {
for (int i = 0; i < chunk.getBarCount(); i++) {
var bar = chunk.getBar(i);
Instant endTime = bar.getEndTime();
// If we already have a bar at this timestamp, keep the first one (or you could
// merge)
barMap.putIfAbsent(endTime, new BarData(bar));
}
}
// Build the merged series
BarSeries merged = new BaseBarSeriesBuilder().withName(ticker).build();
for (BarData barData : barMap.values()) {
merged.barBuilder()
.timePeriod(barInterval)
.endTime(barData.endTime)
.openPrice(barData.open)
.highPrice(barData.high)
.lowPrice(barData.low)
.closePrice(barData.close)
.volume(barData.volume)
.amount(0)
.add();
}
LOG.debug("Merged {} chunks into {} unique bars for ticker {}", chunks.size(), merged.getBarCount(), ticker);
return merged;
}
/**
* Instance method that loads historical OHLCV data for a given ticker symbol
* with a specified number of bars. The end date/time is set to the current
* time, and the start date/time is calculated based on the bar count and
* interval.
*
* @param ticker the ticker symbol (e.g., "AAPL", "MSFT", "BTC-USD",
* "ETH-USD")
* @param interval the bar interval (must be one of the supported Yahoo Finance
* intervals)
* @param barCount the number of bars to fetch
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public BarSeries loadSeriesInstance(String ticker, YahooFinanceInterval interval, int barCount) {
return loadSeriesInstance(ticker, interval, barCount, null);
}
/**
* Instance method that loads historical OHLCV data for a given ticker symbol
* with a specified number of bars and optional notes for cache file naming. The
* end date/time is set to the current time, and the start date/time is
* calculated based on the bar count and interval.
*
* @param ticker the ticker symbol (e.g., "AAPL", "MSFT", "BTC-USD",
* "ETH-USD")
* @param interval the bar interval (must be one of the supported Yahoo Finance
* intervals)
* @param barCount the number of bars to fetch
* @param notes optional notes to include in cache filename (for uniqueness,
* e.g., test identifiers)
* @return a BarSeries containing the historical data, or null if the request
* fails
*/
public BarSeries loadSeriesInstance(String ticker, YahooFinanceInterval interval, int barCount, String notes) {
if (barCount <= 0) {
LOG.error("Bar count must be greater than 0");
return null;
}
Instant endDateTime = Instant.now();
Duration totalDuration = interval.getDuration().multipliedBy(barCount);
Instant startDateTime = endDateTime.minus(totalDuration);
return loadSeriesInstance(ticker, interval, startDateTime, endDateTime, notes);
}
@Override
public BarSeries loadSeries(String ticker, Duration interval, Instant start, Instant end) {
if (ticker == null || ticker.trim().isEmpty()) {
throw new IllegalArgumentException("Ticker cannot be null or empty");
}
if (interval == null || interval.isNegative() || interval.isZero()) {
throw new IllegalArgumentException("Interval must be positive");
}
if (start == null || end == null) {
throw new IllegalArgumentException("Start and end dates cannot be null");
}
if (start.isAfter(end)) {
throw new IllegalArgumentException("Start date must be before or equal to end date");
}
// Map Duration to YahooFinanceInterval
YahooFinanceInterval yfInterval = mapDurationToInterval(interval);
if (yfInterval == null) {
LOG.warn("Unsupported interval duration: {}. Falling back to DAY_1", interval);
yfInterval = YahooFinanceInterval.DAY_1;
}
return loadSeriesInstance(ticker, yfInterval, start, end);
}
@Override
public BarSeries loadSeries(String source) {
if (source == null || source.trim().isEmpty()) {
throw new IllegalArgumentException("Source cannot be null or empty");
}
// Check if it's a cache file path
String sourcePrefix = getSourceName().isEmpty() ? "" : getSourceName() + "-";
if (source.startsWith(responseCacheDir) || (!sourcePrefix.isEmpty() && source.contains(sourcePrefix))) {
Path cacheFile = Paths.get(source);
if (Files.exists(cacheFile)) {
String cachedResponse = readFromCache(cacheFile);
if (cachedResponse != null) {
// Try to extract ticker from filename
String filename = cacheFile.getFileName().toString();
// Format: {sourceName}-TICKER-INTERVAL-START-END[_NOTES].json
// Remove extension
String baseName = filename.replace(".json", "");
// Notes section is everything after the last underscore that follows the end
// timestamp
// We need to parse: {sourceName}-TICKER-INTERVAL-START-END[_NOTES]
String[] parts = baseName.split("-");
if (parts.length >= 5) {
// Check if last part contains underscore (indicating notes section)
// Format: END or END_NOTES
// Notes section is ignored for parsing, so we just need to extract the ticker
// and interval
String ticker = parts[1];
// Try to determine interval from filename
YahooFinanceInterval interval = YahooFinanceInterval.DAY_1; // Default
try {
interval = parseIntervalFromApiValue(parts[2]);
} catch (IllegalArgumentException e) {
LOG.debug("Could not parse interval from filename, using default: {}", e.getMessage());
}
return parseYahooFinanceResponse(cachedResponse, ticker, interval.getDuration());
}
}
}
}
// If not a cache file, return null (could be extended to parse other formats)
return null;
}
/**
* Maps a Duration to the closest matching YahooFinanceInterval.
*
* @param duration the duration to map
* @return the matching YahooFinanceInterval, or null if no close match is found
*/
private YahooFinanceInterval mapDurationToInterval(Duration duration) {
long seconds = duration.getSeconds();
for (YahooFinanceInterval interval : YahooFinanceInterval.values()) {
if (interval.getDuration().getSeconds() == seconds) {
return interval;
}
}
return null;
}
/**
* Parses a YahooFinanceInterval from its API value string.
*
* @param apiValue the API value (e.g., "1m", "1d", "1wk")
* @return the matching YahooFinanceInterval
* @throws IllegalArgumentException if no matching interval is found
*/
private YahooFinanceInterval parseIntervalFromApiValue(String apiValue) {
for (YahooFinanceInterval interval : YahooFinanceInterval.values()) {
if (interval.getApiValue().equals(apiValue)) {
return interval;
}
}
throw new IllegalArgumentException("Unknown interval API value: " + apiValue);
}
/**
* Instance method that performs the actual loading logic. This method uses the
* instance's HttpClient (which can be injected for testing).
*/
public BarSeries loadSeriesInstance(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime) {
return loadSeriesInstance(ticker, interval, startDateTime, endDateTime, null);
}
/**
* Instance method that performs the actual loading logic with optional notes.
* This method uses the instance's HttpClient (which can be injected for
* testing).
*
* @param ticker the ticker symbol
* @param interval the interval
* @param startDateTime the start date/time
* @param endDateTime the end date/time
* @param notes optional notes to include in cache filename (for
* uniqueness)
* @return the BarSeries or null if request fails
*/
public BarSeries loadSeriesInstance(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
if (ticker == null || ticker.trim().isEmpty()) {
LOG.error("Ticker symbol cannot be null or empty");
return null;
}
if (startDateTime == null || endDateTime == null) {
LOG.error("Start and end date/time cannot be null");
return null;
}
if (startDateTime.isAfter(endDateTime)) {
LOG.error("Start date/time must be before or equal to end date/time");
return null;
}
Duration requestedRange = Duration.between(startDateTime, endDateTime);
Duration conservativeLimit = this.getConservativeLimit(interval);
// If the requested range exceeds conservative limits, paginate the request
if (requestedRange.compareTo(conservativeLimit) > 0) {
LOG.debug(
"Requested date range ({}) exceeds conservative limit ({}) for interval {}. "
+ "Splitting into multiple requests and combining results.",
requestedRange, conservativeLimit, interval);
return loadSeriesPaginated(ticker, interval, startDateTime, endDateTime, conservativeLimit, notes);
}
// Single request for smaller ranges
return loadSeriesSingleRequest(ticker, interval, startDateTime, endDateTime, notes);
}
/**
* Truncates a timestamp based on the interval to enable cache hits for requests
* within the same time period. This override provides Yahoo Finance-specific
* truncation logic for week and month boundaries.
* <p>
* For example, for 1-day intervals, timestamps are truncated to the start of
* the day. For 15-minute intervals, timestamps are truncated to the start of
* the 15-minute period. For weekly intervals, timestamps are truncated to the
* start of the week (Monday). For monthly intervals, timestamps are truncated
* to the start of the month.
*
* @param instant the timestamp to truncate
* @param interval the interval to use for truncation
* @return the truncated timestamp
*/
/**
* Generates the cache file path for a given request.
*
* @param ticker the ticker symbol
* @param interval the interval
* @param startDateTime the start date/time (will be truncated)
* @param endDateTime the end date/time (will be truncated)
* @param notes optional notes section to append to filename (can be
* null or empty)
* @return the cache file path
*/
private Path getCacheFilePath(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
return getCacheFilePath(ticker, startDateTime, endDateTime, interval.getDuration(), notes);
}
/**
* Generates the cache file path for a given request (without notes).
*
* @param ticker the ticker symbol
* @param interval the interval
* @param startDateTime the start date/time (will be truncated)
* @param endDateTime the end date/time (will be truncated)
* @return the cache file path
*/
private Path getCacheFilePath(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime) {
return getCacheFilePath(ticker, interval, startDateTime, endDateTime, null);
}
/**
* Makes a single API request for the specified date range with optional notes.
* This is used for requests that don't exceed conservative limits. If caching
* is enabled, checks cache first before making the API request.
*
* @param ticker the ticker symbol
* @param interval the interval
* @param startDateTime the start date/time
* @param endDateTime the end date/time
* @param notes optional notes to include in cache filename (for
* uniqueness)
* @return the BarSeries or null if request fails
*/
private BarSeries loadSeriesSingleRequest(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime, String notes) {
// Check cache first if caching is enabled
if (enableResponseCaching) {
// Try exact match first (with or without notes)
Path cacheFile = getCacheFilePath(ticker, interval, startDateTime, endDateTime, notes);
if (isCacheValid(cacheFile, interval.getDuration(), endDateTime)) {
String cachedResponse = readFromCache(cacheFile);
if (cachedResponse != null) {
LOG.debug("Using cached response for {} ({} to {})", ticker, startDateTime, endDateTime);
return parseYahooFinanceResponse(cachedResponse, ticker, interval.getDuration());
}
}
// Also try without notes (for backward compatibility)
if (notes != null && !notes.trim().isEmpty()) {
Path cacheFileNoNotes = getCacheFilePath(ticker, interval, startDateTime, endDateTime);
if (isCacheValid(cacheFileNoNotes, interval.getDuration(), endDateTime)) {
String cachedResponse = readFromCache(cacheFileNoNotes);
if (cachedResponse != null) {
LOG.debug("Using cached response for {} ({} to {})", ticker, startDateTime, endDateTime);
return parseYahooFinanceResponse(cachedResponse, ticker, interval.getDuration());
}
}
}
}
try {
String encodedTicker = URLEncoder.encode(ticker.trim(), StandardCharsets.UTF_8);
long period1 = startDateTime.getEpochSecond();
long period2 = endDateTime.getEpochSecond();
String url = String.format("%s%s?interval=%s&period1=%d&period2=%d", YAHOO_FINANCE_API_URL, encodedTicker,
interval.getApiValue(), period1, period2);
LOG.trace("Fetching data from Yahoo Finance: {}", url);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "Mozilla/5.0")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponseWrapper<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
LOG.error("Yahoo Finance API returned status code: {}", response.statusCode());
return null;
}
String responseBody = response.body();
LOG.trace("Response body: {}", responseBody);
// Cache the response if caching is enabled
if (enableResponseCaching) {
Path cacheFile = getCacheFilePath(ticker, interval, startDateTime, endDateTime, notes);
writeToCache(cacheFile, responseBody);
}
return parseYahooFinanceResponse(responseBody, ticker, interval.getDuration());
} catch (IOException | InterruptedException e) {
LOG.error("Error fetching data from Yahoo Finance for ticker {}: {}", ticker, e.getMessage(), e);
return null;
}
}
/**
* Loads data by splitting a large date range into multiple smaller requests
* (pagination). Each chunk respects the conservative limit, and results are
* merged chronologically.
*
* @param ticker the ticker symbol
* @param interval the bar interval
* @param startDateTime the start date/time
* @param endDateTime the end date/time
* @param chunkSize the maximum size for each chunk
* @param notes optional notes to include in cache filename (for
* uniqueness)
* @return a BarSeries containing all merged data, or null if all requests fail
*/
private BarSeries loadSeriesPaginated(String ticker, YahooFinanceInterval interval, Instant startDateTime,
Instant endDateTime, Duration chunkSize, String notes) {
List<BarSeries> chunks = new ArrayList<>();
Instant currentStart = startDateTime;
int requestCount = 0;
// Calculate number of chunks needed
Duration totalRange = Duration.between(startDateTime, endDateTime);
int estimatedChunks = (int) Math.ceil((double) totalRange.toSeconds() / chunkSize.toSeconds());
LOG.trace("Splitting request into approximately {} chunks", estimatedChunks);
while (currentStart.isBefore(endDateTime)) {
// Calculate chunk end time (don't exceed the requested end time)
Instant chunkEnd = currentStart.plus(chunkSize);
if (chunkEnd.isAfter(endDateTime)) {
chunkEnd = endDateTime;
}
requestCount++;
LOG.trace("Fetching chunk {}/? ({} to {})", requestCount, currentStart, chunkEnd);
BarSeries chunk = loadSeriesSingleRequest(ticker, interval, currentStart, chunkEnd, notes);
if (chunk != null && chunk.getBarCount() > 0) {
chunks.add(chunk);
LOG.trace("Successfully loaded chunk {} with {} bars", requestCount, chunk.getBarCount());
} else {
LOG.warn("Chunk {} returned no data or failed", requestCount);
}
// Move to next chunk (start from the end of current chunk)
currentStart = chunkEnd;
// If we've reached the end, break
if (chunkEnd.equals(endDateTime) || !currentStart.isBefore(endDateTime)) {
break;
}
// Add a small delay between requests to avoid rate limiting
try {
Thread.sleep(100); // 100ms delay between requests
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Interrupted during pagination delay");
break;
}
}
if (chunks.isEmpty()) {
LOG.error("All paginated requests failed for ticker {}", ticker);
return null;
}
LOG.debug("Successfully fetched {} chunks, merging {} total bars", chunks.size(),
chunks.stream().mapToInt(BarSeries::getBarCount).sum());
return mergeBarSeries(chunks, ticker, interval.getDuration());
}
/**
* Returns the conservative (safe) maximum date range for a given interval.
* These are smaller than the absolute maximums to ensure reliable API
* responses. Used to determine when pagination is needed.
* <p>
* This method is protected to allow subclasses (e.g., in tests) to override the
* conservative limit for testing pagination functionality.
*
* @param interval the bar interval
* @return the conservative maximum date range
*/
protected Duration getConservativeLimit(YahooFinanceInterval interval) {
return switch (interval) {
case MINUTE_1, MINUTE_5, MINUTE_15, MINUTE_30 -> Duration.ofDays(30); // 30 days for intraday (conservative)
case HOUR_1, HOUR_4 -> Duration.ofDays(60); // 60 days for hourly (conservative)
case DAY_1 -> Duration.ofDays(365); // 1 year for daily (conservative)
case WEEK_1, MONTH_1 -> Duration.ofDays(365 * 5); // 5 years for weekly/monthly (conservative)
};
}
/**
* Supported intervals for Yahoo Finance API. These correspond to the intervals
* that Yahoo Finance's chart API supports.
*/
public enum YahooFinanceInterval {
/**
* 1 minute bars
*/
MINUTE_1(Duration.ofMinutes(1), "1m"),
/**
* 5 minute bars
*/
MINUTE_5(Duration.ofMinutes(5), "5m"),
/**
* 15 minute bars
*/
MINUTE_15(Duration.ofMinutes(15), "15m"),
/**
* 30 minute bars
*/
MINUTE_30(Duration.ofMinutes(30), "30m"),
/**
* 1 hour bars
*/
HOUR_1(Duration.ofHours(1), "1h"),
/**
* 4 hour bars
*/
HOUR_4(Duration.ofHours(4), "4h"),
/**
* 1 day bars
*/
DAY_1(Duration.ofDays(1), "1d"),
/**
* 1 week bars
*/
WEEK_1(Duration.ofDays(7), "1wk"),
/**
* 1 month bars
*/
MONTH_1(Duration.ofDays(30), "1mo");
private final Duration duration;
private final String apiValue;
YahooFinanceInterval(Duration duration, String apiValue) {
this.duration = duration;
this.apiValue = apiValue;
}
/**
* Returns the Duration for this interval.
*
* @return the Duration
*/
public Duration getDuration() {
return duration;
}
/**
* Returns the API string value for this interval.
*
* @return the API string value
*/
public String getApiValue() {
return apiValue;
}
}
/**
* Helper class to hold bar data during merging.
*/
private static class BarData {
final Instant endTime;
final double open;
final double high;
final double low;
final double close;
final double volume;
BarData(Bar bar) {
this.endTime = bar.getEndTime();
this.open = bar.getOpenPrice().doubleValue();
this.high = bar.getHighPrice().doubleValue();
this.low = bar.getLowPrice().doubleValue();
this.close = bar.getClosePrice().doubleValue();
this.volume = bar.getVolume().doubleValue();
}
}
}
@@ -0,0 +1,247 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.file;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import ta4jexamples.datasources.BarSeriesDataSource;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* Abstract base class for file-based BarSeries data sources.
* <p>
* This class provides common infrastructure for file-based data sources,
* including:
* <ul>
* <li>Parameter validation for ticker, interval, and date range</li>
* <li>Filename pattern building based on interval and date range</li>
* <li>Date range filtering for BarSeries</li>
* <li>DateTimeFormatter utilities for filename formatting</li>
* </ul>
* <p>
* Subclasses should implement the file format-specific logic such as:
* <ul>
* <li>Loading and parsing files (CSV, JSON, etc.)</li>
* <li>File searching with format-specific patterns</li>
* <li>Format-specific data transformation</li>
* </ul>
* <p>
* <strong>Example usage:</strong>
*
* <pre>
* public class MyFileDataSource extends AbstractFileBarSeriesDataSource {
* public MyFileDataSource() {
* super("MySource");
* }
*
* // Implement abstract methods for format-specific loading
* }
* </pre>
*
* @since 0.20
*/
public abstract class AbstractFileBarSeriesDataSource implements BarSeriesDataSource {
private static final Logger LOG = LogManager.getLogger(AbstractFileBarSeriesDataSource.class);
/**
* Date format for filenames: yyyyMMdd
*/
protected static final DateTimeFormatter FILENAME_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
/**
* Date-time format for filenames with hours: yyyyMMddHH
*/
protected static final DateTimeFormatter FILENAME_DATETIME_HOUR_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHH");
/**
* Date-time format for filenames with minutes: yyyyMMddHHmm
*/
protected static final DateTimeFormatter FILENAME_DATETIME_MINUTE_FORMAT = DateTimeFormatter
.ofPattern("yyyyMMddHHmm");
private final String sourceName;
/**
* Creates a new AbstractFileBarSeriesDataSource with the specified source name.
*
* @param sourceName the source name for building file search patterns (e.g.,
* "Bitstamp", "Coinbase"). Use empty string for generic
* sources without a prefix.
*/
protected AbstractFileBarSeriesDataSource(String sourceName) {
this.sourceName = sourceName != null ? sourceName : "";
}
@Override
public String getSourceName() {
return sourceName;
}
@Override
public BarSeries loadSeries(String ticker, Duration interval, Instant start, Instant end) {
validateParameters(ticker, interval, start, end);
// Build search patterns for filename matching
// Standard pattern:
// {sourcePrefix}{ticker}-{interval}-{startDateTime}_{endDateTime}.{extension}
// DateTime format depends on interval: minutes -> HHmm, hours -> HH, days ->
// date only
DateTimeFormatter dateTimeFormatter = getDateTimeFormatterForInterval(interval);
String startDateTimeStr = start.atZone(ZoneOffset.UTC).format(dateTimeFormatter);
String endDateTimeStr = end.atZone(ZoneOffset.UTC).format(dateTimeFormatter);
// Also prepare date-only format since existing files use this format
String startDateStr = start.atZone(ZoneOffset.UTC).format(FILENAME_DATE_FORMAT);
String endDateStr = end.atZone(ZoneOffset.UTC).format(FILENAME_DATE_FORMAT);
String intervalStr = formatIntervalForFilename(interval);
String sourcePrefix = sourceName.isEmpty() ? "" : sourceName + "-";
// Delegate to subclass for format-specific file searching
BarSeries series = searchAndLoadFile(ticker, intervalStr, sourcePrefix, startDateTimeStr, endDateTimeStr,
startDateStr, endDateStr, interval, start, end);
if (series != null && !series.isEmpty()) {
return series;
}
LOG.debug("No {} file found matching ticker: {}, interval: {}, date range: {} to {}", getFileExtension(),
ticker, interval, start, end);
return null;
}
/**
* Validates the parameters for loading a series.
*
* @param ticker the ticker symbol
* @param interval the bar interval
* @param start the start date/time
* @param end the end date/time
* @throws IllegalArgumentException if any parameter is invalid
*/
protected void validateParameters(String ticker, Duration interval, Instant start, Instant end) {
if (ticker == null || ticker.trim().isEmpty()) {
throw new IllegalArgumentException("Ticker cannot be null or empty");
}
if (interval == null || interval.isNegative() || interval.isZero()) {
throw new IllegalArgumentException("Interval must be positive");
}
if (start == null || end == null) {
throw new IllegalArgumentException("Start and end dates cannot be null");
}
if (start.isAfter(end)) {
throw new IllegalArgumentException("Start date must be before or equal to end date");
}
}
/**
* Searches for and loads a file matching the specified patterns. Subclasses
* should implement this method to handle format-specific file searching logic.
* <p>
* This method is called by
* {@link #loadSeries(String, Duration, Instant, Instant)} with pre-formatted
* date strings and interval strings. Subclasses should try multiple patterns
* (exact match, date-only format, broader patterns) and return the first
* matching file.
*
* @param ticker the ticker symbol (already validated)
* @param intervalStr the formatted interval string (e.g., "PT1D", "PT5M")
* @param sourcePrefix the source prefix (e.g., "Bitstamp-", or empty
* string)
* @param startDateTimeStr the formatted start date-time string
* @param endDateTimeStr the formatted end date-time string
* @param startDateStr the formatted start date string (date only)
* @param endDateStr the formatted end date string (date only)
* @param interval the interval duration
* @param start the start date/time
* @param end the end date/time
* @return the loaded BarSeries, or null if no matching file is found
*/
protected abstract BarSeries searchAndLoadFile(String ticker, String intervalStr, String sourcePrefix,
String startDateTimeStr, String endDateTimeStr, String startDateStr, String endDateStr, Duration interval,
Instant start, Instant end);
/**
* Returns the file extension (without the dot) for this data source. Used in
* log messages and file pattern building.
*
* @return the file extension (e.g., "csv", "json")
*/
protected abstract String getFileExtension();
/**
* Determines the appropriate DateTimeFormatter for filename datetime formatting
* based on the interval. For minute-level intervals, includes hours and
* minutes. For hour-level intervals, includes hours. For day-level intervals,
* uses date only.
*
* @param interval the bar interval
* @return the appropriate DateTimeFormatter
*/
protected DateTimeFormatter getDateTimeFormatterForInterval(Duration interval) {
long seconds = interval.getSeconds();
if (seconds < 3600) {
// Interval is in minutes or seconds - include hours and minutes
return FILENAME_DATETIME_MINUTE_FORMAT;
} else if (seconds < 86400) {
// Interval is in hours - include hours
return FILENAME_DATETIME_HOUR_FORMAT;
} else {
// Interval is in days or longer - date only
return FILENAME_DATE_FORMAT;
}
}
/**
* Formats a Duration as an ISO 8601 interval string for use in filenames.
*
* @param interval the duration to format
* @return the ISO 8601 duration string (e.g., "PT1D", "PT5M", "PT1H")
*/
protected String formatIntervalForFilename(Duration interval) {
long seconds = interval.getSeconds();
if (seconds % 86400 == 0) {
return "PT" + (seconds / 86400) + "D";
} else if (seconds % 3600 == 0) {
return "PT" + (seconds / 3600) + "H";
} else if (seconds % 60 == 0) {
return "PT" + (seconds / 60) + "M";
} else {
return "PT" + seconds + "S";
}
}
/**
* Filters a BarSeries to only include bars within the specified date range.
*
* @param series the series to filter
* @param start the start date (inclusive)
* @param end the end date (inclusive)
* @return a new BarSeries containing only bars within the date range, or null
* if no bars match
*/
protected BarSeries filterSeriesByDateRange(BarSeries series, Instant start, Instant end) {
if (series == null || series.isEmpty()) {
return null;
}
var filteredSeries = new BaseBarSeriesBuilder().withName(series.getName()).build();
for (int i = 0; i < series.getBarCount(); i++) {
var bar = series.getBar(i);
Instant barEnd = bar.getEndTime();
if (!barEnd.isBefore(start) && !barEnd.isAfter(end)) {
filteredSeries.addBar(bar);
}
}
return filteredSeries.isEmpty() ? null : filteredSeries;
}
}
@@ -0,0 +1,430 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.http;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
/**
* Abstract base class for HTTP-based BarSeries data sources.
* <p>
* This class provides common infrastructure for HTTP-based data sources,
* including:
* <ul>
* <li>HTTP client management via {@link HttpClientWrapper}</li>
* <li>Response caching to disk for faster subsequent requests</li>
* <li>Cache validation and management</li>
* <li>Common cache file operations</li>
* </ul>
* <p>
* Subclasses should implement the API-specific logic such as:
* <ul>
* <li>Building API-specific URLs and request parameters</li>
* <li>Parsing API-specific response formats</li>
* <li>Defining interval enums and their API values</li>
* <li>Implementing interval-specific timestamp truncation logic</li>
* </ul>
* <p>
* <strong>Example usage:</strong>
*
* <pre>
* public class MyHttpDataSource extends AbstractHttpBarSeriesDataSource {
* public MyHttpDataSource() {
* super(new DefaultHttpClientWrapper(), false);
* }
*
* // Implement abstract methods and API-specific logic
* }
* </pre>
*
* @since 0.20
*/
public abstract class AbstractHttpBarSeriesDataSource implements HttpBarSeriesDataSource {
/**
* Default directory for caching HTTP responses.
*/
public static final String DEFAULT_RESPONSE_CACHE_DIR = "temp/responses";
private static final Logger LOG = LogManager.getLogger(AbstractHttpBarSeriesDataSource.class);
protected final HttpClientWrapper httpClient;
protected final boolean enableResponseCaching;
protected final String responseCacheDir;
/**
* Creates a new AbstractHttpBarSeriesDataSource with the specified
* HttpClientWrapper and caching option, using the default cache directory.
*
* @param httpClient the HttpClientWrapper to use for API requests
* (can be a mock for testing)
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
* @throws IllegalArgumentException if httpClient is null
*/
protected AbstractHttpBarSeriesDataSource(HttpClientWrapper httpClient, boolean enableResponseCaching) {
if (httpClient == null) {
throw new IllegalArgumentException("HttpClientWrapper cannot be null");
}
this.httpClient = httpClient;
this.enableResponseCaching = enableResponseCaching;
this.responseCacheDir = DEFAULT_RESPONSE_CACHE_DIR;
if (enableResponseCaching) {
ensureCacheDirectoryExists();
}
}
/**
* Creates a new AbstractHttpBarSeriesDataSource with the specified
* HttpClientWrapper and custom cache directory. Response caching is
* automatically enabled when a cache directory is specified.
*
* @param httpClient the HttpClientWrapper to use for API requests (can be
* a mock for testing)
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
* @throws IllegalArgumentException if httpClient is null or responseCacheDir is
* null or empty
*/
protected AbstractHttpBarSeriesDataSource(HttpClientWrapper httpClient, String responseCacheDir) {
if (httpClient == null) {
throw new IllegalArgumentException("HttpClientWrapper cannot be null");
}
if (responseCacheDir == null || responseCacheDir.trim().isEmpty()) {
throw new IllegalArgumentException("Response cache directory cannot be null or empty");
}
this.httpClient = httpClient;
this.enableResponseCaching = true;
this.responseCacheDir = responseCacheDir.trim();
ensureCacheDirectoryExists();
}
/**
* Creates a new AbstractHttpBarSeriesDataSource with the specified HttpClient.
* This is a convenience constructor that wraps the HttpClient in a
* DefaultHttpClientWrapper, using the default cache directory.
*
* @param httpClient the HttpClient to use for API requests
* @param enableResponseCaching if true, responses will be cached to disk for
* faster subsequent requests
*/
protected AbstractHttpBarSeriesDataSource(HttpClient httpClient, boolean enableResponseCaching) {
this(new DefaultHttpClientWrapper(httpClient), enableResponseCaching);
}
/**
* Creates a new AbstractHttpBarSeriesDataSource with the specified HttpClient
* and custom cache directory. This is a convenience constructor that wraps the
* HttpClient in a DefaultHttpClientWrapper. Response caching is automatically
* enabled when a cache directory is specified.
*
* @param httpClient the HttpClient to use for API requests
* @param responseCacheDir the directory path for caching responses (can be
* relative or absolute)
*/
protected AbstractHttpBarSeriesDataSource(HttpClient httpClient, String responseCacheDir) {
this(new DefaultHttpClientWrapper(httpClient), responseCacheDir);
}
@Override
public HttpClientWrapper getHttpClient() {
return httpClient;
}
@Override
public boolean isResponseCachingEnabled() {
return enableResponseCaching;
}
/**
* Returns the response cache directory path.
*
* @return the cache directory path
*/
public String getResponseCacheDir() {
return responseCacheDir;
}
/**
* Ensures the cache directory exists, creating it if necessary.
*/
protected void ensureCacheDirectoryExists() {
try {
Path cacheDir = Paths.get(responseCacheDir);
if (!Files.exists(cacheDir)) {
Files.createDirectories(cacheDir);
LOG.debug("Created cache directory: {}", cacheDir.toAbsolutePath());
}
} catch (IOException e) {
LOG.warn("Failed to create cache directory: {}", e.getMessage());
}
}
/**
* Truncates a timestamp based on the interval duration to enable cache hits for
* requests within the same time period. This is a generic implementation that
* works with Duration. Subclasses can override this method to provide more
* precise truncation logic for specific interval types (e.g., week boundaries,
* month boundaries).
* <p>
* For example, for 1-day intervals, timestamps are truncated to the start of
* the day. For 15-minute intervals, timestamps are truncated to the start of
* the 15-minute period.
*
* @param instant the timestamp to truncate
* @param interval the interval duration to use for truncation
* @return the truncated timestamp
*/
protected Instant truncateTimestampForCache(Instant instant, Duration interval) {
ZonedDateTime zdt = instant.atZone(ZoneOffset.UTC);
ZonedDateTime truncated;
long seconds = interval.getSeconds();
long minutes = seconds / 60;
long hours = minutes / 60;
long days = hours / 24;
if (days >= 30) {
// For monthly intervals (approximately 30 days), truncate to start of month
truncated = zdt.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
} else if (days >= 7) {
// For weekly intervals (approximately 7 days), truncate to start of week
// (Monday)
int daysFromMonday = zdt.getDayOfWeek().getValue() - java.time.DayOfWeek.MONDAY.getValue();
if (daysFromMonday < 0) {
daysFromMonday += 7; // Handle Sunday (value 7)
}
truncated = zdt.minusDays(daysFromMonday).withHour(0).withMinute(0).withSecond(0).withNano(0);
} else if (days >= 1) {
// For daily intervals, truncate to start of day
truncated = zdt.withHour(0).withMinute(0).withSecond(0).withNano(0);
} else if (hours >= 1) {
// For hourly intervals, truncate to start of hour period
int hour = (int) ((zdt.getHour() / hours) * hours);
truncated = zdt.withHour(hour).withMinute(0).withSecond(0).withNano(0);
} else if (minutes >= 1) {
// For minute intervals, truncate to start of minute period
int minute = (int) ((zdt.getMinute() / minutes) * minutes);
truncated = zdt.withMinute(minute).withSecond(0).withNano(0);
} else {
// For second-level intervals, truncate to start of second
int second = (int) ((zdt.getSecond() / seconds) * seconds);
truncated = zdt.withSecond(second).withNano(0);
}
return truncated.toInstant();
}
/**
* Generates the cache file path for a given request.
*
* @param symbol the symbol (ticker, product ID, etc.)
* @param startDateTime the start date/time (will be truncated)
* @param endDateTime the end date/time (will be truncated)
* @param interval the interval duration (used for truncation and filename)
* @param notes optional notes section to append to filename (can be
* null or empty)
* @return the cache file path
*/
protected Path getCacheFilePath(String symbol, Instant startDateTime, Instant endDateTime, Duration interval,
String notes) {
Instant truncatedStart = truncateTimestampForCache(startDateTime, interval);
Instant truncatedEnd = truncateTimestampForCache(endDateTime, interval);
String sourcePrefix = getSourceName().isEmpty() ? "" : getSourceName() + "-";
String notesSuffix = (notes != null && !notes.trim().isEmpty()) ? "_" + notes.trim() : "";
// Use Duration.toString() for standardized format (e.g., "PT24H" for 1 day,
// "PT1H" for 1 hour)
String durationString = interval.toString();
String filename = String.format("%s%s-%s-%d-%d%s.json", sourcePrefix,
symbol.toUpperCase().replaceAll("[^A-Z0-9-]", "_"), durationString, truncatedStart.getEpochSecond(),
truncatedEnd.getEpochSecond(), notesSuffix);
return Paths.get(responseCacheDir, filename);
}
/**
* Checks if a cache file exists and is still valid based on the interval. For
* historical data (end date in the past), cache is valid indefinitely. For
* current data (end date is recent), cache expires after the interval duration.
* <p>
* Cache validity is determined by the file's modification time (when the cache
* file was created), not access time. This is appropriate because we want to
* know how old the cached data is, not when it was last read.
*
* @param cacheFile the cache file path
* @param interval the interval duration
* @param endDateTime the end date/time of the request
* @return true if cache is valid, false otherwise
*/
protected boolean isCacheValid(Path cacheFile, Duration interval, Instant endDateTime) {
if (!Files.exists(cacheFile)) {
return false;
}
try {
// Check file modification time (when the cache file was created/written)
Instant fileModified = Files.getLastModifiedTime(cacheFile).toInstant();
Instant now = Instant.now();
// If the request is for historical data (end date is in the past), cache is
// valid indefinitely
if (endDateTime.isBefore(now.minus(interval))) {
return true;
}
// For current/recent data, cache expires after the interval duration
Duration age = Duration.between(fileModified, now);
return age.compareTo(interval) < 0;
} catch (IOException e) {
LOG.warn("Failed to check cache file validity: {}", e.getMessage());
return false;
}
}
/**
* Reads a cached response from disk.
*
* @param cacheFile the cache file path
* @return the cached JSON response, or null if read fails
*/
protected String readFromCache(Path cacheFile) {
try {
String content = Files.readString(cacheFile, StandardCharsets.UTF_8);
LOG.debug("Cache hit: {}", cacheFile.getFileName());
return content;
} catch (IOException e) {
LOG.warn("Failed to read from cache: {}", e.getMessage());
return null;
}
}
/**
* Writes a response to the cache.
*
* @param cacheFile the cache file path
* @param response the JSON response to cache
*/
protected void writeToCache(Path cacheFile, String response) {
try {
// Ensure parent directory exists
Path parentDir = cacheFile.getParent();
if (parentDir != null && !Files.exists(parentDir)) {
Files.createDirectories(parentDir);
}
Files.writeString(cacheFile, response, StandardCharsets.UTF_8);
LOG.debug("Cached response: {}", cacheFile.getFileName());
} catch (IOException e) {
LOG.warn("Failed to write to cache: {}", e.getMessage());
}
}
/**
* Deletes all cache files in the cache directory that belong to this data
* source. Files are identified by the source name prefix (e.g., "Coinbase-",
* "YahooFinance-").
*
* @return the number of files deleted
*/
public int deleteAllCacheFiles() {
return deleteCacheFilesOlderThan(Duration.ZERO);
}
/**
* Deletes cache files that are older than the specified maximum age. Files are
* identified by the source name prefix (e.g., "Coinbase-", "YahooFinance-") and
* are deleted based on their file modification time (when the cache file was
* created/written).
* <p>
* <strong>Note:</strong> This method uses file modification time, not access
* time. Modification time represents when the cache file was created (when the
* response was cached), which is appropriate for determining cache age. Access
* time (when the file was last read) is not used because it's not reliably
* available across all platforms and filesystems.
*
* @param maxAge the maximum age of files to keep (files older than this will be
* deleted). Use {@link Duration#ZERO} to delete all files.
* @return the number of files deleted
*/
public int deleteCacheFilesOlderThan(Duration maxAge) {
if (!Files.exists(Paths.get(responseCacheDir))) {
return 0;
}
String sourcePrefix = getSourceName().isEmpty() ? "" : getSourceName() + "-";
int deletedCount = 0;
Instant cutoffTime = Instant.now().minus(maxAge);
try {
Path cacheDir = Paths.get(responseCacheDir);
for (Path path : Files.list(cacheDir).toList()) {
if (Files.isRegularFile(path)) {
String filename = path.getFileName().toString();
// Check if file belongs to this data source
if (sourcePrefix.isEmpty() || filename.startsWith(sourcePrefix)) {
try {
// If maxAge is ZERO, delete all files regardless of age
if (maxAge.isZero() || Files.getLastModifiedTime(path).toInstant().isBefore(cutoffTime)) {
Files.delete(path);
deletedCount++;
LOG.debug("Deleted cache file: {}", filename);
}
} catch (IOException e) {
LOG.warn("Failed to delete cache file {}: {}", filename, e.getMessage());
}
}
}
}
} catch (IOException e) {
LOG.warn("Failed to list cache directory: {}", e.getMessage());
return deletedCount;
}
if (deletedCount > 0) {
LOG.info("Deleted {} stale cache file(s) with prefix '{}'", deletedCount, sourcePrefix);
}
return deletedCount;
}
/**
* Deletes stale cache files. A file is considered stale if it's older than 30
* days and the end timestamp encoded in the filename indicates it's for
* historical data (data that won't change). This is a conservative approach
* that preserves recent caches and historical data caches that might still be
* useful.
* <p>
* For more control, use {@link #deleteCacheFilesOlderThan(Duration)} or
* {@link #deleteAllCacheFiles()}.
*
* @return the number of files deleted
*/
public int deleteStaleCacheFiles() {
return deleteStaleCacheFiles(Duration.ofDays(30));
}
/**
* Deletes stale cache files older than the specified age. A file is considered
* stale if it's older than the specified age. Files are identified by the
* source name prefix (e.g., "Coinbase-", "YahooFinance-").
*
* @param maxAge the maximum age of files to keep (files older than this will be
* deleted)
* @return the number of files deleted
*/
public int deleteStaleCacheFiles(Duration maxAge) {
return deleteCacheFilesOlderThan(maxAge);
}
}
@@ -0,0 +1,45 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.http;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* Default implementation of {@link HttpClientWrapper} that delegates to a
* {@link HttpClient}.
*/
public class DefaultHttpClientWrapper implements HttpClientWrapper {
private final HttpClient httpClient;
/**
* Creates a new wrapper around the specified HttpClient.
*
* @param httpClient the HttpClient to wrap
*/
public DefaultHttpClientWrapper(HttpClient httpClient) {
if (httpClient == null) {
throw new IllegalArgumentException("HttpClient cannot be null");
}
this.httpClient = httpClient;
}
/**
* Creates a new wrapper with a default HttpClient.
*/
public DefaultHttpClientWrapper() {
this(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build());
}
@Override
public <T> HttpResponseWrapper<T> send(HttpRequest request, HttpResponse.BodyHandler<T> handler)
throws IOException, InterruptedException {
HttpResponse<T> response = httpClient.send(request, handler);
return new DefaultHttpResponseWrapper<>(response);
}
}
@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.http;
import java.net.http.HttpResponse;
/**
* Default implementation of {@link HttpResponseWrapper} that delegates to a
* {@link HttpResponse}.
*
* @param <T> the response body type
*/
public class DefaultHttpResponseWrapper<T> implements HttpResponseWrapper<T> {
private final HttpResponse<T> httpResponse;
/**
* Creates a new wrapper around the specified HttpResponse.
*
* @param httpResponse the HttpResponse to wrap
*/
public DefaultHttpResponseWrapper(HttpResponse<T> httpResponse) {
if (httpResponse == null) {
throw new IllegalArgumentException("HttpResponse cannot be null");
}
this.httpResponse = httpResponse;
}
@Override
public int statusCode() {
return httpResponse.statusCode();
}
@Override
public T body() {
return httpResponse.body();
}
}
@@ -0,0 +1,46 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.http;
import ta4jexamples.datasources.BarSeriesDataSource;
/**
* Marker interface for HTTP-based BarSeries data sources.
* <p>
* This interface extends {@link BarSeriesDataSource} to identify data sources
* that fetch data over HTTP. Implementations of this interface typically use
* {@link HttpClientWrapper} for making HTTP requests and may support response
* caching.
* <p>
* HTTP-based data sources share common functionality such as:
* <ul>
* <li>HTTP request handling via {@link HttpClientWrapper}</li>
* <li>Response caching to disk for faster subsequent requests</li>
* <li>Cache validation and management</li>
* <li>Pagination for large data requests</li>
* </ul>
* <p>
* For shared implementation, consider extending
* {@link AbstractHttpBarSeriesDataSource} which provides common HTTP and
* caching infrastructure.
*
* @since 0.20
*/
public interface HttpBarSeriesDataSource extends BarSeriesDataSource {
/**
* Returns the HttpClientWrapper used by this data source for making HTTP
* requests.
*
* @return the HttpClientWrapper instance
*/
HttpClientWrapper getHttpClient();
/**
* Returns whether response caching is enabled for this data source.
*
* @return true if caching is enabled, false otherwise
*/
boolean isResponseCachingEnabled();
}
@@ -0,0 +1,32 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.http;
import java.io.IOException;
import java.net.http.HttpRequest;
/**
* Wrapper around {@link java.net.http.HttpClient} to enable easier testing via
* mocking.
* <p>
* This wrapper provides a simple interface for HTTP operations that can be
* easily mocked in unit tests, avoiding the need to mock the final
* {@link java.net.http.HttpClient} class directly.
*/
public interface HttpClientWrapper {
/**
* Sends the given request using this client, blocking if necessary to get the
* response.
*
* @param <T> the response body type
* @param request the request
* @param handler the response body handler
* @return the response wrapper
* @throws IOException if an I/O error occurs when sending or receiving
* @throws InterruptedException if the operation is interrupted
*/
<T> HttpResponseWrapper<T> send(HttpRequest request, java.net.http.HttpResponse.BodyHandler<T> handler)
throws IOException, InterruptedException;
}
@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.http;
/**
* Wrapper around {@link java.net.http.HttpResponse} to enable easier testing
* via mocking.
* <p>
* This wrapper provides a simple interface for HTTP response operations that
* can be easily mocked in unit tests, avoiding the need to mock the final
* {@link java.net.http.HttpResponse} class directly.
*
* @param <T> the response body type
*/
public interface HttpResponseWrapper<T> {
/**
* Returns the status code for this response.
*
* @return the status code
*/
int statusCode();
/**
* Returns the body of this response.
*
* @return the response body
*/
T body();
}
@@ -0,0 +1,316 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.json;
import com.google.gson.*;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* A TypeAdapter implementation for deserializing JSON data into BarSeries
* objects. This adapter supports multiple JSON formats by detecting the
* structure of the input data. It currently handles two formats:
* <ul>
* <li><strong>Coinbase format:</strong> identified by the presence of a
* "candles" array. The "start" field represents the start of the candle period,
* and the end time is calculated as start + duration. This adapter can be used
* for both Coinbase API responses (where the interval is known) and Coinbase
* JSON files (where the interval can be inferred from the data).</li>
* <li><strong>Binance format:</strong> identified by the presence of an "ohlc"
* array</li>
* </ul>
* <p>
* The adapter parses the JSON input and converts it into a BaseBarSeries
* instance populated with the appropriate bar data. The bar data is sorted by
* timestamp for the Coinbase format to ensure chronological order.
* <p>
* For Coinbase format, the adapter provides a static helper method
* {@link #parseCoinbaseFormat(JsonObject, String, Duration)} that can be used
* directly when parsing Coinbase data, allowing you to specify a known interval
* (for API responses) or let it be inferred (for JSON files).
* <p>
* Write operations are not supported by this adapter and will throw an
* exception.
* <p>
* The adapter uses internal lightweight data classes to temporarily hold the
* parsed JSON data before converting it into the final BarSeries format.
* <p>
* Logging is implemented to track which format is being parsed during
* deserialization.
*
* @since 0.19
*/
public class AdaptiveBarSeriesTypeAdapter extends TypeAdapter<BarSeries> {
private static final Logger LOG = LogManager.getLogger(AdaptiveBarSeriesTypeAdapter.class);
@Override
public void write(JsonWriter out, BarSeries value) throws IOException {
// Not implemented for this use case
throw new UnsupportedOperationException("Write operation not supported");
}
/**
* Reads a BarSeries from the provided JsonReader by detecting the format based
* on available fields. The method parses the JSON input and delegates to
* appropriate parsing methods depending on whether the root object contains
* "candles" or "ohlc" fields.
*
* @param in the JsonReader to read the JSON data from
* @return the parsed BarSeries object
* @throws JsonParseException if the JSON format is unknown or neither "candles"
* nor "ohlc" fields are found
* @since 0.19
*/
@Override
public BarSeries read(JsonReader in) {
JsonElement json = JsonParser.parseReader(in);
JsonObject root = json.getAsJsonObject();
// Detect format based on available fields
if (root.has("candles")) {
return parseCoinbaseFormat(root);
} else if (root.has("ohlc")) {
return parseBinanceFormat(root);
} else {
throw new JsonParseException("Unknown format - neither 'candles' nor 'ohlc' found");
}
}
/**
* Parses a JSON object in Coinbase format into a BarSeries. The input JSON is
* expected to contain a "candles" array where each element represents a bar
* with start time, open, high, low, close, and volume values.
* <p>
* Note: Coinbase API's "start" field represents the start of the time interval
* for the candle. The end time is calculated as start + duration. This method
* infers the duration from the data. For cases where the interval is known, use
* {@link #parseCoinbaseFormat(JsonObject, String, Duration)} instead.
*
* @param root the JsonObject representing the root of the JSON data in Coinbase
* format
* @return a BarSeries populated with data parsed from the Coinbase format JSON
*/
private BarSeries parseCoinbaseFormat(JsonObject root) {
return parseCoinbaseFormat(root, "CoinbaseData", null);
}
/**
* Parses a JSON object in Coinbase format into a BarSeries with a known
* interval and series name. The input JSON is expected to contain a "candles"
* array where each element represents a bar with start time, open, high, low,
* close, and volume values.
* <p>
* Note: Coinbase API's "start" field represents the start of the time interval
* for the candle. The end time is calculated as start + duration. This method
* uses the provided interval directly instead of inferring it from the data.
* <p>
* This method can be used for both API responses (where the interval is known)
* and JSON files (where the interval can be inferred or provided).
*
* @param root the JsonObject representing the root of the JSON data in
* Coinbase format
* @param seriesName the name to use for the BarSeries
* @param knownInterval the known bar interval (if null, will be inferred from
* the data by calculating the difference between
* consecutive start times)
* @return a BarSeries populated with data parsed from the Coinbase format JSON
*/
public static BarSeries parseCoinbaseFormat(JsonObject root, String seriesName, Duration knownInterval) {
LOG.trace("Parsing Coinbase format");
JsonArray candles = root.getAsJsonArray("candles");
if (candles == null || candles.isEmpty()) {
return new BaseBarSeriesBuilder().withName(seriesName).build();
}
List<CoinbaseBar> barList = new ArrayList<>();
for (JsonElement candle : candles) {
JsonObject candleObj = candle.getAsJsonObject();
// Skip candles with null or missing required fields
if (candleObj.get("start") == null || candleObj.get("start").isJsonNull() || candleObj.get("open") == null
|| candleObj.get("open").isJsonNull() || candleObj.get("high") == null
|| candleObj.get("high").isJsonNull() || candleObj.get("low") == null
|| candleObj.get("low").isJsonNull() || candleObj.get("close") == null
|| candleObj.get("close").isJsonNull()) {
continue;
}
// Handle null volume by defaulting to "0"
String volume = "0";
if (candleObj.get("volume") != null && !candleObj.get("volume").isJsonNull()) {
volume = candleObj.get("volume").getAsString();
}
// Validate timestamp format before creating CoinbaseBar
String startStr = candleObj.get("start").getAsString();
try {
Long.parseLong(startStr);
} catch (NumberFormatException nfe) {
LOG.warn("Invalid timestamp format in Coinbase data, skipping candle: {}", startStr, nfe);
continue;
}
barList.add(
new CoinbaseBar(startStr, candleObj.get("open").getAsString(), candleObj.get("high").getAsString(),
candleObj.get("low").getAsString(), candleObj.get("close").getAsString(), volume));
}
// Sort by timestamp (ascending order)
barList.sort(Comparator.comparingLong(CoinbaseBar::getStartTime));
// Build series
BaseBarSeries series = new BaseBarSeriesBuilder().withName(seriesName).build();
Duration lastDuration = knownInterval;
for (int i = 0; i < barList.size(); i++) {
CoinbaseBar bar = barList.get(i);
// Coinbase "start" field is the start of the candle period
Instant startTime = bar.getStartInstant();
Duration duration;
if (knownInterval != null) {
duration = knownInterval;
} else {
// Infer duration from data by calculating the difference between consecutive
// start times
Instant previousStart = i > 0 ? barList.get(i - 1).getStartInstant() : null;
Instant currentStart = bar.getStartInstant();
Instant nextStart = i + 1 < barList.size() ? barList.get(i + 1).getStartInstant() : null;
duration = inferDuration(previousStart, currentStart, nextStart, lastDuration);
lastDuration = duration;
}
// End time is start time + duration
Instant endTime = startTime.plus(duration);
bar.addToSeries(series, endTime, duration);
}
return series;
}
/**
* Parses a JSON object in Binance format into a BarSeries. The input JSON is
* expected to contain an "ohlc" array where each element represents a bar with
* end time, open price, high price, low price, close price, volume, and amount
* values.
*
* @param root the JsonObject representing the root of the JSON data in Binance
* format
* @return a BarSeries populated with data parsed from the Binance format JSON
*/
private BarSeries parseBinanceFormat(JsonObject root) {
LOG.trace("Parsing Binance format");
JsonArray ohlc = root.getAsJsonArray("ohlc");
String seriesName = root.has("name") ? root.get("name").getAsString() : "BinanceData";
BaseBarSeries series = new BaseBarSeriesBuilder().withName(seriesName).build();
List<BinanceBar> bars = new ArrayList<>();
for (JsonElement barElement : ohlc) {
JsonObject barObj = barElement.getAsJsonObject();
BinanceBar bar = new BinanceBar(barObj.get("endTime").getAsLong(), barObj.get("openPrice").getAsNumber(),
barObj.get("highPrice").getAsNumber(), barObj.get("lowPrice").getAsNumber(),
barObj.get("closePrice").getAsNumber(), barObj.get("volume").getAsNumber(),
barObj.get("amount").getAsNumber());
bars.add(bar);
}
bars.sort(Comparator.comparingLong(BinanceBar::endTime));
Duration lastDuration = null;
for (int i = 0; i < bars.size(); i++) {
BinanceBar bar = bars.get(i);
Instant previousEnd = i > 0 ? bars.get(i - 1).getEndInstant() : null;
Instant currentEnd = bar.getEndInstant();
Instant nextEnd = i + 1 < bars.size() ? bars.get(i + 1).getEndInstant() : null;
Duration duration = inferDuration(previousEnd, currentEnd, nextEnd, lastDuration);
lastDuration = duration;
bar.addToSeries(series, currentEnd, duration);
}
return series;
}
private static Duration inferDuration(Instant previous, Instant current, Instant next, Duration fallback) {
Duration candidate = null;
if (next != null) {
candidate = Duration.between(current, next);
} else if (previous != null) {
candidate = Duration.between(previous, current);
}
if (candidate != null) {
if (candidate.isNegative()) {
candidate = candidate.negated();
}
if (!candidate.isZero()) {
return candidate;
}
}
if (fallback != null && !fallback.isZero() && !fallback.isNegative()) {
return fallback;
}
return Duration.ofSeconds(1);
}
// Lightweight data classes for internal use only
private record CoinbaseBar(String start, String open, String high, String low, String close, String volume) {
public long getStartTime() {
return Long.parseLong(start);
}
public Instant getStartInstant() {
return Instant.ofEpochSecond(getStartTime());
}
public void addToSeries(BaseBarSeries series, Instant endTime, Duration timePeriod) {
series.barBuilder()
.timePeriod(timePeriod)
.endTime(endTime)
.openPrice(open)
.highPrice(high)
.lowPrice(low)
.closePrice(close)
.volume(volume)
.add();
}
}
private record BinanceBar(long endTime, Number openPrice, Number highPrice, Number lowPrice, Number closePrice,
Number volume, Number amount) {
public Instant getEndInstant() {
return Instant.ofEpochMilli(endTime);
}
public void addToSeries(BaseBarSeries series, Instant endTimeInstant, Duration timePeriod) {
series.barBuilder()
.timePeriod(timePeriod)
.endTime(endTimeInstant)
.openPrice(openPrice)
.highPrice(highPrice)
.lowPrice(lowPrice)
.closePrice(closePrice)
.volume(volume)
.amount(amount)
.add();
}
}
}
@@ -0,0 +1,59 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.json;
import org.ta4j.core.Bar;
import org.ta4j.core.BaseBarSeries;
import org.ta4j.core.utils.DeprecationNotifier;
import java.time.Duration;
import java.time.Instant;
@Deprecated(since = "0.19", forRemoval = true)
public class GsonBarData {
private long endTime;
private Number openPrice;
private Number highPrice;
private Number lowPrice;
private Number closePrice;
private Number volume;
private Number amount;
public GsonBarData() {
DeprecationNotifier.warnOnce(GsonBarData.class, "ta4jexamples.datasources.json.JsonFileBarSeriesDataSource",
"0.24.0");
}
@Deprecated(since = "0.19", forRemoval = true)
public static GsonBarData from(Bar bar) {
DeprecationNotifier.warnOnce(GsonBarData.class, "ta4jexamples.datasources.json.JsonFileBarSeriesDataSource",
"0.24.0");
var result = new GsonBarData();
result.endTime = bar.getEndTime().toEpochMilli();
result.openPrice = bar.getOpenPrice().getDelegate();
result.highPrice = bar.getHighPrice().getDelegate();
result.lowPrice = bar.getLowPrice().getDelegate();
result.closePrice = bar.getClosePrice().getDelegate();
result.volume = bar.getVolume().getDelegate();
result.amount = bar.getAmount().getDelegate();
return result;
}
@Deprecated(since = "0.19", forRemoval = true)
public void addTo(BaseBarSeries barSeries) {
DeprecationNotifier.warnOnce(GsonBarData.class, "ta4jexamples.datasources.json.JsonFileBarSeriesDataSource",
"0.24.0");
var endTimeInstant = Instant.ofEpochMilli(endTime);
barSeries.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(endTimeInstant)
.openPrice(openPrice)
.highPrice(highPrice)
.lowPrice(lowPrice)
.closePrice(closePrice)
.volume(volume)
.amount(amount)
.add();
}
}
@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.json;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.utils.DeprecationNotifier;
import java.util.LinkedList;
import java.util.List;
@Deprecated(since = "0.19", forRemoval = true)
public class GsonBarSeries {
private String name;
private List<GsonBarData> ohlc = new LinkedList<>();
public GsonBarSeries() {
DeprecationNotifier.warnOnce(GsonBarSeries.class, "ta4jexamples.datasources.json.JsonFileBarSeriesDataSource",
"0.24.0");
}
@Deprecated(since = "0.19", forRemoval = true)
public static GsonBarSeries from(BarSeries series) {
DeprecationNotifier.warnOnce(GsonBarSeries.class, "ta4jexamples.datasources.json.JsonFileBarSeriesDataSource",
"0.24.0");
GsonBarSeries result = new GsonBarSeries();
result.name = series.getName();
List<Bar> barData = series.getBarData();
for (Bar bar : barData) {
GsonBarData exportableBarData = GsonBarData.from(bar);
result.ohlc.add(exportableBarData);
}
return result;
}
@Deprecated(since = "0.19", forRemoval = true)
public BarSeries toBarSeries() {
DeprecationNotifier.warnOnce(GsonBarSeries.class, "ta4jexamples.datasources.json.JsonFileBarSeriesDataSource",
"0.24.0");
BaseBarSeries result = new BaseBarSeriesBuilder().withName(this.name).build();
for (GsonBarData data : ohlc) {
data.addTo(result);
}
return result;
}
}
@@ -0,0 +1,135 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.json;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.utils.DeprecationNotifier;
import java.io.*;
/**
* @deprecated
* <p>
* Use {@link JsonFileBarSeriesDataSource} instead. Scheduled for
* removal in 0.24.0.
* </p>
*/
@Deprecated(since = "0.19", forRemoval = true)
public class JsonBarsSerializer {
private static final Logger LOG = LogManager.getLogger(JsonBarsSerializer.class);
public JsonBarsSerializer() {
warnDeprecatedUse();
}
private static void warnDeprecatedUse() {
DeprecationNotifier.warnOnce(JsonBarsSerializer.class,
"ta4jexamples.datasources.json.JsonFileBarSeriesDataSource", "0.24.0");
}
@Deprecated(since = "0.19", forRemoval = true)
public static void persistSeries(BarSeries series, String filename) {
warnDeprecatedUse();
GsonBarSeries exportableSeries = GsonBarSeries.from(series);
Gson gson = new GsonBuilder().setPrettyPrinting().create();
FileWriter writer = null;
try {
writer = new FileWriter(filename);
gson.toJson(exportableSeries, writer);
LOG.debug("Bar series '{}' successfully saved to '{}'", series.getName(), filename);
} catch (IOException e) {
LOG.error("Unable to store bars in JSON", e);
} finally {
if (writer != null) {
try {
writer.flush();
writer.close();
} catch (IOException e) {
LOG.warn("Unable to flush and/or close file writer", e);
}
}
}
}
@Deprecated(since = "0.19", forRemoval = true)
public static BarSeries loadSeries(String filename) {
warnDeprecatedUse();
Gson gson = new Gson();
FileReader reader = null;
BarSeries result = null;
try {
reader = new FileReader(filename);
GsonBarSeries loadedSeries = gson.fromJson(reader, GsonBarSeries.class);
result = loadedSeries.toBarSeries();
LOG.debug("Bar series '" + result.getName() + "' successfully loaded. #Entries: " + result.getBarCount());
} catch (FileNotFoundException e) {
LOG.error("Unable to load bars from JSON", e);
} finally {
try {
if (reader != null)
reader.close();
} catch (IOException e) {
LOG.warn("Unable to close file reader", e);
}
}
return result;
}
/**
* Loads a BarSeries from the provided InputStream containing JSON data. The
* method parses the JSON content using Gson library and converts it to a
* BarSeries object. If the input stream is null or parsing fails, appropriate
* warning or error messages are logged and null is returned.
*
* @deprecated
* <p>
* Use {@link JsonFileBarSeriesDataSource#loadSeries(String)}
* instead.
*
* @param inputStream the input stream containing JSON data to be parsed into a
* BarSeries
* @return the loaded BarSeries object, or null if loading fails or input stream
* is null
*/
@Deprecated(since = "0.19", forRemoval = true)
public static BarSeries loadSeries(InputStream inputStream) {
warnDeprecatedUse();
if (inputStream == null) {
LOG.warn("Input stream is null, returning null");
return null;
}
Gson gson = new Gson();
InputStreamReader reader = null;
BarSeries result = null;
try {
reader = new InputStreamReader(inputStream);
GsonBarSeries loadedSeries = gson.fromJson(reader, GsonBarSeries.class);
if (loadedSeries == null) {
LOG.warn("Failed to parse JSON, loadedSeries is null");
return null;
}
result = loadedSeries.toBarSeries();
LOG.debug("Bar series '" + result.getName() + "' successfully loaded. #Entries: " + result.getBarCount());
} catch (Exception e) {
LOG.error("Unable to load bars from JSON", e);
} finally {
try {
if (reader != null)
reader.close();
} catch (IOException e) {
LOG.warn("Error closing input stream reader", e);
}
}
return result;
}
}
@@ -0,0 +1,695 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.doc;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.*;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.MACDIndicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.rules.*;
import org.ta4j.core.serialization.ComponentSerialization;
import org.ta4j.core.serialization.RuleSerialization;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import java.awt.GraphicsEnvironment;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility class to manage README content including chart images, code snippets,
* and documentation.
*
* <p>
* This class serves as the single source of truth for README content. It
* generates chart images, extracts code snippets from source code using special
* comment markers, and synchronizes the README with the latest code examples.
* </p>
*
* <p>
* <strong>How it works:</strong>
* <ul>
* <li>Code snippets are marked with {@code // START_SNIPPET: snippet-id} and
* {@code // END_SNIPPET: snippet-id} comments in the chart generation
* methods</li>
* <li>The README.md file contains corresponding HTML comment markers:
* {@code <!-- START_SNIPPET: snippet-id -->} and
* {@code <!-- END_SNIPPET: snippet-id -->}</li>
* <li>Run with argument "update-readme" to automatically extract snippets and
* update the README:
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager -Dexec.args=update-readme}</li>
* </ul>
* </p>
*
* <p>
* <strong>Usage:</strong>
* <ul>
* <li>Generate charts and update README (default):
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager}</li>
* <li>Update README snippets only:
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager -Dexec.args=update-readme}</li>
* <li>View snippets only:
* {@code mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.doc.ReadmeContentManager -Dexec.args=snippets}</li>
* </ul>
* </p>
*/
public class ReadmeContentManager {
private static final Logger LOG = LogManager.getLogger(ReadmeContentManager.class);
/**
* Generates the EMA Crossover chart matching the README quick start example.
*
* @param outputDir the directory to save the chart image (e.g., "docs/charts"
* or ".")
* @return the path to the saved chart image, or empty if saving failed
*/
public static Optional<Path> generateEmaCrossoverChart(String outputDir) {
LOG.info("Generating EMA Crossover chart for README...");
// Load historical price data
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
// Use a smaller subset for a cleaner chart (last ~150 bars)
int totalBars = fullSeries.getBarCount();
int startIndex = Math.max(0, totalBars - 150);
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
// Create indicators: calculate moving averages from close prices
ClosePriceIndicator close = new ClosePriceIndicator(series);
EMAIndicator fastEma = new EMAIndicator(close, 12); // 12-period EMA
EMAIndicator slowEma = new EMAIndicator(close, 26); // 26-period EMA
// Define entry rule: buy when fast EMA crosses above slow EMA (golden cross)
Rule entry = new CrossedUpIndicatorRule(fastEma, slowEma);
// Define exit rule: sell when price gains 3% OR loses 1.5%
Rule exit = new StopGainRule(close, 3.0) // take profit at +3%
.or(new StopLossRule(close, 1.5)); // or cut losses at -1.5%
// Combine rules into a strategy
Strategy strategy = new BaseStrategy("EMA Crossover", entry, exit);
// Run the strategy on historical data
BarSeriesManager manager = new BarSeriesManager(series);
TradingRecord record = manager.run(strategy);
LOG.info("Strategy executed: {} positions", record.getPositionCount());
// START_SNIPPET: ema-crossover
// Generate simplified chart - just price, indicators, and signals (no subchart)
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("EMA Crossover Strategy")
.withSeries(series) // Price bars (candlesticks)
.withIndicatorOverlay(fastEma) // Overlay indicators on price chart
.withIndicatorOverlay(slowEma)
.withTradingRecordOverlay(record) // Mark entry/exit points with arrows
.toChart();
chartWorkflow.saveChartImage(chart, series, "ema-crossover-strategy", "output/charts"); // Save as image
// END_SNIPPET: ema-crossover
// Display chart in interactive window (only in non-headless environments)
if (!GraphicsEnvironment.isHeadless()) {
chartWorkflow.displayChart(chart);
}
// Save chart image
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "ema-crossover-readme", outputDir);
if (savedPath.isPresent()) {
Path relativePath = getRelativePath(savedPath.get());
LOG.info("Chart saved to: {}", relativePath);
} else {
LOG.warn("Failed to save ema-crossover-readme.jpg chart image");
}
return savedPath;
}
/**
* Generates an RSI Strategy chart with RSI indicator in a subchart.
* Demonstrates using subcharts for indicators with different scales.
*
* @param outputDir the directory to save the chart image
* @return the path to the saved chart image, or empty if saving failed
*/
public static Optional<Path> generateRsiStrategyChart(String outputDir) {
LOG.info("Generating RSI Strategy chart with subchart for README...");
// Load historical price data
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
// Use a smaller subset for a cleaner chart (last ~150 bars)
int totalBars = fullSeries.getBarCount();
int startIndex = Math.max(0, totalBars - 150);
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
// START_SNIPPET: rsi-strategy
// Create indicators
ClosePriceIndicator close = new ClosePriceIndicator(series);
RSIIndicator rsi = new RSIIndicator(close, 14);
// RSI strategy: buy when RSI crosses below 30 (oversold), sell when RSI crosses
// above 70 (overbought)
Rule entry = new CrossedDownIndicatorRule(rsi, 30);
Rule exit = new CrossedUpIndicatorRule(rsi, 70);
Strategy strategy = new BaseStrategy("RSI Strategy", entry, exit);
TradingRecord record = new BarSeriesManager(series).run(strategy);
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("RSI Strategy with Subchart")
.withSeries(series) // Price bars (candlesticks)
.withTradingRecordOverlay(record) // Mark entry/exit points
.withSubChart(rsi) // RSI indicator in separate subchart panel
.toChart();
// END_SNIPPET: rsi-strategy
// Save chart image
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "rsi-strategy-readme", outputDir);
if (savedPath.isPresent()) {
Path relativePath = getRelativePath(savedPath.get());
LOG.info("Chart saved to: {}", relativePath);
} else {
LOG.warn("Failed to save rsi-strategy-readme.jpg chart image");
}
return savedPath;
}
/**
* Generates a strategy chart with performance metrics subchart. Demonstrates
* visualizing performance analysis criteria over time.
*
* @param outputDir the directory to save the chart image
* @return the path to the saved chart image, or empty if saving failed
*/
public static Optional<Path> generateStrategyPerformanceChart(String outputDir) {
LOG.info("Generating Strategy Performance chart with metrics subchart for README...");
// Load historical price data
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
// Use a smaller subset for a cleaner chart (last ~150 bars)
int totalBars = fullSeries.getBarCount();
int startIndex = Math.max(0, totalBars - 150);
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
// START_SNIPPET: strategy-performance
// Create indicators: multiple moving averages
ClosePriceIndicator close = new ClosePriceIndicator(series);
SMAIndicator sma20 = new SMAIndicator(close, 20);
EMAIndicator ema12 = new EMAIndicator(close, 12);
// Strategy: buy when EMA crosses above SMA, sell when EMA crosses below SMA
Rule entry = new CrossedUpIndicatorRule(ema12, sma20);
Rule exit = new CrossedDownIndicatorRule(ema12, sma20);
Strategy strategy = new BaseStrategy("EMA/SMA Crossover", entry, exit);
TradingRecord record = new BarSeriesManager(series).run(strategy);
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("Strategy Performance Analysis")
.withSeries(series) // Price bars (candlesticks)
.withIndicatorOverlay(sma20) // Overlay SMA on price chart
.withIndicatorOverlay(ema12) // Overlay EMA on price chart
.withTradingRecordOverlay(record) // Mark entry/exit points
.withSubChart(new MaximumDrawdownCriterion(), record) // Performance metric in subchart
.toChart();
// END_SNIPPET: strategy-performance
// Save chart image
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "strategy-performance-readme",
outputDir);
if (savedPath.isPresent()) {
Path relativePath = getRelativePath(savedPath.get());
LOG.info("Chart saved to: {}", relativePath);
} else {
LOG.warn("Failed to save strategy-performance-readme.jpg chart image");
}
return savedPath;
}
/**
* Generates an advanced multi-indicator strategy chart with multiple subcharts.
* Demonstrates full charting capabilities with multiple analysis layers.
*
* @param outputDir the directory to save the chart image
* @return the path to the saved chart image, or empty if saving failed
*/
public static Optional<Path> generateAdvancedStrategyChart(String outputDir) {
LOG.info("Generating Advanced Multi-Indicator Strategy chart for README...");
// Load historical price data
BarSeries fullSeries = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
LOG.info("Loaded {} bars from Bitstamp series", fullSeries.getBarCount());
// Use a smaller subset for a cleaner chart (last ~150 bars)
int totalBars = fullSeries.getBarCount();
int startIndex = Math.max(0, totalBars - 150);
BarSeries series = fullSeries.getSubSeries(startIndex, totalBars);
LOG.info("Using subset: {} bars (indices {} to {})", series.getBarCount(), startIndex, totalBars - 1);
// START_SNIPPET: advanced-strategy
// Create indicators
ClosePriceIndicator close = new ClosePriceIndicator(series);
SMAIndicator sma50 = new SMAIndicator(close, 50);
EMAIndicator ema12 = new EMAIndicator(close, 12);
MACDIndicator macd = new MACDIndicator(close, 12, 26);
RSIIndicator rsi = new RSIIndicator(close, 14);
// Strategy: buy when EMA crosses above SMA and RSI > 50, sell when EMA crosses
// below SMA
Rule entry = new CrossedUpIndicatorRule(ema12, sma50).and(new OverIndicatorRule(rsi, 50));
Rule exit = new CrossedDownIndicatorRule(ema12, sma50);
Strategy strategy = new BaseStrategy("Advanced Multi-Indicator Strategy", entry, exit);
TradingRecord record = new BarSeriesManager(series).run(strategy);
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withTitle("Advanced Multi-Indicator Strategy")
.withSeries(series) // Price bars (candlesticks)
.withIndicatorOverlay(sma50) // Overlay SMA on price chart
.withIndicatorOverlay(ema12) // Overlay EMA on price chart
.withTradingRecordOverlay(record) // Mark entry/exit points
.withSubChart(macd) // MACD indicator in subchart
.withSubChart(rsi) // RSI indicator in subchart
.withSubChart(new NetProfitLossCriterion(), record) // Net profit/loss performance metric
.toChart();
// END_SNIPPET: advanced-strategy
// Save chart image
Optional<Path> savedPath = chartWorkflow.saveChartImage(chart, series, "advanced-strategy-readme", outputDir);
if (savedPath.isPresent()) {
Path relativePath = getRelativePath(savedPath.get());
LOG.info("Chart saved to: {}", relativePath);
} else {
LOG.warn("Failed to save advanced-strategy-readme.jpg chart image");
}
return savedPath;
}
/**
* Finds the project root directory by searching for README.md file. Starts from
* the current working directory and walks up the directory tree.
*
* @return the project root path, or current directory if not found
*/
private static Path findProjectRoot() {
Path current = Paths.get("").toAbsolutePath().normalize();
Path root = current.getRoot();
// Walk up the directory tree looking for README.md
while (current != null && !current.equals(root)) {
Path readmePath = current.resolve("README.md");
if (Files.exists(readmePath)) {
return current;
}
current = current.getParent();
}
// If not found, return current working directory as fallback
return Paths.get("").toAbsolutePath().normalize();
}
/**
* Converts an absolute path to a relative path from the project root.
*
* @param absolutePath the absolute path to convert
* @return the relative path, or the original path if conversion fails
*/
private static Path getRelativePath(Path absolutePath) {
try {
Path projectRoot = findProjectRoot();
Path normalized = absolutePath.normalize();
if (normalized.startsWith(projectRoot)) {
return projectRoot.relativize(normalized);
}
return normalized;
} catch (Exception e) {
LOG.warn("Failed to convert path to relative: {}", e.getMessage());
return absolutePath;
}
}
/**
* Generates serialization examples for the README. This method demonstrates
* serializing indicators, rules, and strategies to JSON. The examples are
* extracted via snippet markers for inclusion in the README.
*
* @param series the bar series to use for examples
*/
@SuppressWarnings("unused")
public static void generateSerializationExamples(BarSeries series) {
// START_SNIPPET: serialize-indicator
// Serialize an indicator (RSI) to JSON
ClosePriceIndicator close = new ClosePriceIndicator(series);
RSIIndicator rsi = new RSIIndicator(close, 14);
String rsiJson = rsi.toJson();
LOG.info("Output: {}", rsiJson);
// Output:
// {"type":"RSIIndicator","parameters":{"barCount":14},"components":[{"type":"ClosePriceIndicator"}]}
// END_SNIPPET: serialize-indicator
// START_SNIPPET: serialize-rule
// Serialize a rule (AndRule) to JSON
Rule rule1 = new OverIndicatorRule(rsi, 50);
Rule rule2 = new UnderIndicatorRule(rsi, 80);
Rule andRule = new AndRule(rule1, rule2);
String ruleJson = ComponentSerialization.toJson(RuleSerialization.describe(andRule));
LOG.info("Output: {}", ruleJson);
// Output:
// {"type":"AndRule","label":"AndRule","components":[{"type":"OverIndicatorRule","label":"OverIndicatorRule","components":[{"type":"RSIIndicator","parameters":{"barCount":14},"components":[{"type":"ClosePriceIndicator"}]}],"parameters":{"threshold":50.0}},{"type":"UnderIndicatorRule","label":"UnderIndicatorRule","components":[{"type":"RSIIndicator","parameters":{"barCount":14},"components":[{"type":"ClosePriceIndicator"}]}],"parameters":{"threshold":80.0}}]}
// END_SNIPPET: serialize-rule
// START_SNIPPET: serialize-strategy
// Serialize a strategy (EMA Crossover) to JSON
EMAIndicator fastEma = new EMAIndicator(close, 12);
EMAIndicator slowEma = new EMAIndicator(close, 26);
Rule entry = new CrossedUpIndicatorRule(fastEma, slowEma);
Rule exit = new CrossedDownIndicatorRule(fastEma, slowEma);
Strategy strategy = new BaseStrategy("EMA Crossover", entry, exit);
String strategyJson = strategy.toJson();
LOG.info("Output: {}", strategyJson);
// Output: {"type":"BaseStrategy","label":"EMA
// Crossover","parameters":{"unstableBars":0},"rules":[{"type":"CrossedUpIndicatorRule","label":"entry","components":[{"type":"EMAIndicator","parameters":{"barCount":12},"components":[{"type":"ClosePriceIndicator"}]},{"type":"EMAIndicator","parameters":{"barCount":26},"components":[{"type":"ClosePriceIndicator"}]}]},{"type":"CrossedDownIndicatorRule","label":"exit","components":[{"type":"EMAIndicator","parameters":{"barCount":12},"components":[{"type":"ClosePriceIndicator"}]},{"type":"EMAIndicator","parameters":{"barCount":26},"components":[{"type":"ClosePriceIndicator"}]}]}]}
// END_SNIPPET: serialize-strategy
}
/**
* Extracts code between START_SNIPPET and END_SNIPPET markers from the source
* file.
*
* @param sourceFile the Java source file to read
* @param snippetId the snippet identifier (e.g., "ema-crossover")
* @return the extracted code snippet, or empty if not found
*/
public static Optional<String> extractCodeSnippet(Path sourceFile, String snippetId) {
try {
String content = Files.readString(sourceFile, StandardCharsets.UTF_8);
String startMarker = "// START_SNIPPET: " + snippetId;
String endMarker = "// END_SNIPPET: " + snippetId;
int startIndex = content.indexOf(startMarker);
if (startIndex == -1) {
LOG.warn("Start marker not found for snippet: {}", snippetId);
return Optional.empty();
}
int endIndex = content.indexOf(endMarker, startIndex);
if (endIndex == -1) {
LOG.warn("End marker not found for snippet: {}", snippetId);
return Optional.empty();
}
// Extract code between markers (excluding the markers themselves)
int newlineIndex = content.indexOf('\n', startIndex);
if (newlineIndex == -1) {
LOG.warn("No newline found after start marker for snippet: {}", snippetId);
return Optional.empty();
}
int codeStart = newlineIndex + 1;
// Don't trim here - we need to preserve the exact indentation structure
// We'll trim only trailing whitespace from the entire snippet after processing
String snippet = content.substring(codeStart, endIndex);
// Remove leading indentation (find minimum indentation and remove it)
// Strategy: Find the minimum indentation among lines that have indentation > 0,
// but if all lines have 0 indentation, use 0. This handles cases where the
// first
// line (e.g., a comment) has no indentation but subsequent lines do.
String[] lines = snippet.split("\n");
int minIndent = Integer.MAX_VALUE;
int minIndentIncludingZero = Integer.MAX_VALUE;
boolean hasIndentedLines = false;
for (String line : lines) {
if (!line.trim().isEmpty()) {
int indent = 0;
for (char c : line.toCharArray()) {
if (c == ' ') {
indent++;
} else {
break;
}
}
minIndentIncludingZero = Math.min(minIndentIncludingZero, indent);
if (indent > 0) {
minIndent = Math.min(minIndent, indent);
hasIndentedLines = true;
}
}
}
// Use the minimum indentation of indented lines if any exist, otherwise use 0
int indentToRemove = hasIndentedLines ? minIndent
: (minIndentIncludingZero == Integer.MAX_VALUE ? 0 : minIndentIncludingZero);
// Remove minimum indentation from all lines
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.trim().isEmpty()) {
result.append("\n");
} else {
// Ensure we don't go beyond the line length
int indentToRemoveForLine = Math.min(indentToRemove, line.length());
result.append(line.substring(indentToRemoveForLine)).append("\n");
}
}
// Trim only trailing whitespace/newlines, preserve leading structure
String resultStr = result.toString();
// Remove trailing newlines and whitespace
while (resultStr.endsWith("\n") || resultStr.endsWith(" ") || resultStr.endsWith("\r")) {
resultStr = resultStr.substring(0, resultStr.length() - 1);
}
return Optional.of(resultStr);
} catch (IOException e) {
LOG.error("Error reading source file: {}", e.getMessage());
return Optional.empty();
}
}
/**
* Updates the README file by replacing code snippets between HTML comment
* markers.
*
* @param readmePath path to the README.md file
* @param sourceFile path to the ReadmeContentManager.java file
* @return true if update was successful
*/
public static boolean updateReadmeSnippets(Path readmePath, Path sourceFile) {
try {
String readmeContent = Files.readString(readmePath, StandardCharsets.UTF_8);
String lineSeparator = detectPreferredLineSeparator(readmeContent);
String[] snippetIds = { "ema-crossover", "rsi-strategy", "strategy-performance", "advanced-strategy",
"serialize-indicator", "serialize-rule", "serialize-strategy" };
boolean updated = false;
for (String snippetId : snippetIds) {
Optional<String> snippet = extractCodeSnippet(sourceFile, snippetId);
if (snippet.isEmpty()) {
LOG.warn("Could not extract snippet: {}", snippetId);
continue;
}
String startMarker = "<!-- START_SNIPPET: " + snippetId + " -->";
String endMarker = "<!-- END_SNIPPET: " + snippetId + " -->";
// Find the code block between markers
Pattern pattern = Pattern.compile(Pattern.quote(startMarker) + ".*?" + Pattern.quote(endMarker),
Pattern.DOTALL);
String normalizedSnippet = normalizeLineSeparators(snippet.get(), lineSeparator);
String replacement = startMarker + lineSeparator + "```java" + lineSeparator + normalizedSnippet
+ lineSeparator + "```" + lineSeparator + endMarker;
String before = readmeContent;
readmeContent = pattern.matcher(readmeContent).replaceAll(Matcher.quoteReplacement(replacement));
if (!readmeContent.equals(before)) {
updated = true;
LOG.info("Updated snippet in README: {}", snippetId);
} else {
LOG.warn("Snippet markers not found in README for: {}", snippetId);
}
}
if (updated) {
Files.writeString(readmePath, readmeContent, StandardCharsets.UTF_8);
LOG.info("README updated successfully");
return true;
} else {
LOG.warn("No updates made to README");
return false;
}
} catch (IOException e) {
LOG.error("Error updating README: {}", e.getMessage());
return false;
}
}
private static String detectPreferredLineSeparator(String content) {
int crlfCount = 0;
int lfCount = 0;
for (int i = 0; i < content.length(); i++) {
if (content.charAt(i) == '\n') {
if (i > 0 && content.charAt(i - 1) == '\r') {
crlfCount++;
} else {
lfCount++;
}
}
}
if (crlfCount >= lfCount && crlfCount > 0) {
return "\r\n";
}
if (lfCount > 0) {
return "\n";
}
return System.lineSeparator();
}
private static String normalizeLineSeparators(String content, String lineSeparator) {
String normalized = content.replace("\r\n", "\n").replace("\r", "\n");
if ("\n".equals(lineSeparator)) {
return normalized;
}
return normalized.replace("\n", lineSeparator);
}
/**
* Main method to generate all README charts and automatically update README
* code snippets.
*
* <p>
* By default, this method generates all chart images and automatically updates
* the README with code snippets extracted from the chart generation methods to
* keep them in sync.
* </p>
*
* @param args optional arguments: - "update-readme": Only updates README.md
* with code snippets (no chart generation) - "snippets": Only
* prints all code snippets to log (no chart generation) -
* Otherwise: output directory for charts (defaults to
* "ta4j-examples/docs/img")
*/
public static void main(String[] args) {
if (args.length > 0 && "update-readme".equals(args[0])) {
LOG.info("=== Updating README with code snippets ===");
Path projectRoot = findProjectRoot();
Path sourceFile = projectRoot
.resolve("ta4j-examples/src/main/java/ta4jexamples/doc/ReadmeContentManager.java")
.normalize();
Path readmePath = projectRoot.resolve("README.md").normalize();
if (!Files.exists(sourceFile)) {
LOG.error("Source file not found: {}", sourceFile.toAbsolutePath());
return;
}
if (!Files.exists(readmePath)) {
LOG.error("README file not found: {}", readmePath.toAbsolutePath());
return;
}
boolean success = updateReadmeSnippets(readmePath, sourceFile);
if (success) {
LOG.info("=== README update complete ===");
} else {
LOG.warn("=== README update failed or no changes made ===");
}
return;
}
if (args.length > 0 && "snippets".equals(args[0])) {
LOG.info("=== Extracting code snippets ===");
Path projectRoot = findProjectRoot();
Path sourceFile = projectRoot
.resolve("ta4j-examples/src/main/java/ta4jexamples/doc/ReadmeContentManager.java")
.normalize();
String[] snippetIds = { "ema-crossover", "rsi-strategy", "strategy-performance", "advanced-strategy",
"serialize-indicator", "serialize-rule", "serialize-strategy" };
for (String snippetId : snippetIds) {
Optional<String> snippet = extractCodeSnippet(sourceFile, snippetId);
if (snippet.isPresent()) {
LOG.info("\n=== {} Code Snippet ===", snippetId);
LOG.info("\n{}", snippet.get());
} else {
LOG.warn("Could not extract snippet: {}", snippetId);
}
}
return;
}
// Chart generation requires a display (non-headless environment)
if (GraphicsEnvironment.isHeadless()) {
LOG.error("Cannot generate charts in headless environment. Chart generation requires a display.");
LOG.error("Use 'update-readme' or 'snippets' arguments for headless operations.");
System.exit(1);
}
String outputDir = args.length > 0 ? args[0] : "ta4j-examples/docs/img";
LOG.info("=== README Content Manager ===");
LOG.info("Output directory: {}", outputDir);
// Generate all charts
generateEmaCrossoverChart(outputDir);
generateRsiStrategyChart(outputDir);
generateStrategyPerformanceChart(outputDir);
generateAdvancedStrategyChart(outputDir);
LOG.info("=== Chart generation complete ===");
// Automatically update README with code snippets to keep them in sync
LOG.info("=== Updating README with code snippets ===");
Path projectRoot = findProjectRoot();
Path sourceFile = projectRoot.resolve("ta4j-examples/src/main/java/ta4jexamples/doc/ReadmeContentManager.java")
.normalize();
Path readmePath = projectRoot.resolve("README.md").normalize();
if (Files.exists(sourceFile) && Files.exists(readmePath)) {
boolean success = updateReadmeSnippets(readmePath, sourceFile);
if (success) {
LOG.info("=== README updated successfully ===");
} else {
LOG.warn("=== README update completed (no changes or warnings occurred) ===");
}
} else {
LOG.warn("Could not update README: source file or README not found");
LOG.warn("Source: {}, exists: {}", sourceFile.toAbsolutePath(), Files.exists(sourceFile));
LOG.warn("README: {}, exists: {}", readmePath.toAbsolutePath(), Files.exists(readmePath));
}
LOG.info("To view code snippets only, run with argument 'snippets'");
}
}
@@ -0,0 +1,768 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.doc;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sun.source.doctree.DocCommentTree;
import com.sun.source.tree.AnnotationTree;
import com.sun.source.tree.ClassTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.tree.ModifiersTree;
import com.sun.source.tree.Tree;
import com.sun.source.tree.VariableTree;
import com.sun.source.util.DocTrees;
import com.sun.source.util.JavacTask;
import com.sun.source.util.SourcePositions;
import com.sun.source.util.TreePath;
import com.sun.source.util.TreePathScanner;
import java.io.IOException;
import java.io.PrintStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
/**
* Scans ta4j source trees for {@code @Deprecated(..., forRemoval = true)}
* symbols, reports release lifecycle state, and emits issue plans for release
* automation.
*
* <p>
* Typical usage:
* {@code mvn -pl ta4j-examples -am compile exec:java -Dexec.mainClass=ta4jexamples.doc.RemovalReadyDeprecationScanner -Dexec.args="--output-json build/report.json --output-md build/report.md"}
* </p>
*
* <p>
* Pass {@code --include-overdue} when a workflow target version should include
* every removal version less than or equal to that target. That keeps removal
* tracking correct when release numbers jump across major, minor, or patch
* positions.
* </p>
*
* @since 0.22.7
*/
public final class RemovalReadyDeprecationScanner {
private static final int SCHEMA_VERSION = 1;
private static final String AUTOMATION_NAMESPACE = "ta4j:deprecation-removal";
private static final String SYMBOL_TRACKING_NAMESPACE = AUTOMATION_NAMESPACE + ":symbol:v1";
private static final String GROUPED_FILE_PLAN_KIND = "grouped-file-removal";
private static final Gson PRETTY_GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create();
private static final Gson COMPACT_GSON = new GsonBuilder().disableHtmlEscaping().create();
private static final Pattern VERSION_RE = Pattern.compile("<version>\\s*([^<]+?)\\s*</version>");
private static final Pattern SEMVER_RE = Pattern.compile("\\d+\\.\\d+\\.\\d+");
private static final Pattern DECLARATION_VERSION_RE = Pattern.compile(
"(?:scheduled\\s+for\\s+removal\\s+in|removal\\s+in)\\s+(\\d+\\.\\d+\\.\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern NOTIFIER_RE = Pattern.compile(
"DeprecationNotifier\\.warnOnce\\s*\\([^,]+,\\s*\"([^\"]+)\"(?:\\s*,\\s*\"(\\d+\\.\\d+\\.\\d+)\")?",
Pattern.DOTALL);
private static final Pattern JAVADOC_REPLACEMENT_RE = Pattern.compile("(?i)\\buse\\s+\\{@link\\s+([^}]+)\\}");
private static final Set<String> SKIP_PARTS = Set.of(".agents", ".git", ".idea", "target");
private RemovalReadyDeprecationScanner() {
}
/**
* Launches the CLI scanner.
*
* @param args command-line arguments
* @since 0.22.7
*/
public static void main(String[] args) {
int exitCode = run(args, System.out, System.err);
if (exitCode != 0) {
System.exit(exitCode);
}
}
static int run(String[] args, PrintStream stdout, PrintStream stderr) {
ScanOptions options;
try {
options = parseArgs(args);
} catch (IllegalArgumentException error) {
stderr.println("Error: " + error.getMessage());
stderr.println(usage());
return 1;
}
try {
DeprecationReport report = generateReport(options);
writeOutput(options.outputJson(), PRETTY_GSON.toJson(report));
writeOutput(options.outputMarkdown(), report.summaryMarkdown());
stdout.println(COMPACT_GSON.toJson(summaryJson(report)));
if (options.failOnDue() && report.blockingFindingCount() > 0) {
stderr.println("Removal-ready deprecation gate failed: " + report.blockingFindingCount()
+ " symbol(s) are due or overdue for " + report.removalVersion() + ".");
return 2;
}
return 0;
} catch (IOException | IllegalArgumentException error) {
stderr.println("Error: " + error.getMessage());
return 1;
}
}
private static Map<String, Object> summaryJson(DeprecationReport report) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("schemaVersion", report.schemaVersion());
summary.put("automationNamespace", report.automationNamespace());
summary.put("snapshotVersion", report.snapshotVersion());
summary.put("removalVersion", report.removalVersion());
summary.put("issuePlanCount", report.issuePlanCount());
summary.put("findingCount", report.findingCount());
summary.put("dueFindingCount", report.dueFindingCount());
summary.put("overdueFindingCount", report.overdueFindingCount());
summary.put("futureFindingCount", report.futureFindingCount());
summary.put("unscheduledFindingCount", report.unscheduledFindingCount());
summary.put("blockingFindingCount", report.blockingFindingCount());
return summary;
}
private static DeprecationReport generateReport(ScanOptions options) throws IOException {
ProjectVersion projectVersion = readProjectVersion(options.pomFile());
String targetRemovalVersion = options.targetRemovalVersion();
if (targetRemovalVersion == null) {
if (!projectVersion.version().endsWith("-SNAPSHOT")) {
throw new IllegalArgumentException("expected a SNAPSHOT version in " + options.pomFile() + ", found '"
+ projectVersion.version() + "'");
}
targetRemovalVersion = projectVersion.version().substring(0, projectVersion.version().length() - 9);
}
validateVersion(targetRemovalVersion, "--target-removal-version");
String scanTargetRemovalVersion = targetRemovalVersion;
List<SourceFinding> allFindings = scanSources(options.repoRoot());
List<IssuePlan> issuePlans = new ArrayList<>();
List<SymbolFinding> unscheduledSymbols = new ArrayList<>();
int selectedFindingCount = 0;
int dueCount = 0;
int overdueCount = 0;
int futureCount = 0;
int unscheduledCount = 0;
Map<String, List<SourceFinding>> findingsByIssue = new LinkedHashMap<>();
for (SourceFinding finding : allFindings) {
if (finding.removalVersion() == null) {
unscheduledCount++;
unscheduledSymbols.add(finding.toSymbolFinding());
continue;
}
int versionComparison = compareVersions(finding.removalVersion(), targetRemovalVersion);
if (versionComparison < 0) {
overdueCount++;
} else if (versionComparison == 0) {
dueCount++;
} else {
futureCount++;
}
boolean selected = versionComparison == 0 || options.includeOverdue() && versionComparison < 0;
if (selected) {
selectedFindingCount++;
String key = finding.removalVersion() + "|" + finding.filePath();
findingsByIssue.computeIfAbsent(key, ignored -> new ArrayList<>()).add(finding);
}
}
for (Map.Entry<String, List<SourceFinding>> entry : findingsByIssue.entrySet()) {
List<SourceFinding> findings = entry.getValue();
SourceFinding first = findings.get(0);
List<SymbolFinding> symbols = findings.stream()
.map(finding -> finding.toSymbolFinding(status(finding.removalVersion(), scanTargetRemovalVersion)))
.toList();
issuePlans.add(buildIssuePlan(projectVersion.version(), first.removalVersion(), first.filePath(),
first.module(), symbols));
}
DeprecationReport partialReport = new DeprecationReport(SCHEMA_VERSION, AUTOMATION_NAMESPACE,
OffsetDateTime.now(ZoneOffset.UTC).toString(), options.repoRoot().toString(), projectVersion.version(),
targetRemovalVersion, options.targetRemovalVersion() == null ? "snapshot" : "target",
options.includeOverdue(), options.failOnDue(), allFindings.size(), selectedFindingCount, dueCount,
overdueCount, futureCount, unscheduledCount, dueCount + overdueCount, issuePlans.size(),
List.copyOf(issuePlans), List.copyOf(unscheduledSymbols), "");
return partialReport.withSummaryMarkdown(renderMarkdown(partialReport));
}
private static List<SourceFinding> scanSources(Path repoRoot) throws IOException {
List<Path> javaFiles = iterJavaFiles(repoRoot);
if (javaFiles.isEmpty()) {
return List.of();
}
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new IllegalArgumentException("unable to locate the JDK Java compiler");
}
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null,
StandardCharsets.UTF_8)) {
Iterable<? extends JavaFileObject> fileObjects = fileManager.getJavaFileObjectsFromPaths(javaFiles);
JavacTask task = (JavacTask) compiler.getTask(null, fileManager, diagnostics,
List.of("-proc:none", "-Xlint:none"), null, fileObjects);
DocTrees docTrees = DocTrees.instance(task);
SourcePositions sourcePositions = docTrees.getSourcePositions();
List<SourceFinding> findings = new ArrayList<>();
for (CompilationUnitTree unit : task.parse()) {
Path sourcePath = sourcePath(unit);
Path relativePath = repoRoot.relativize(sourcePath);
String sourceText = Files.readString(sourcePath, StandardCharsets.UTF_8);
SourceScanner scanner = new SourceScanner(docTrees, sourcePositions, unit, sourceText,
relativePath.toString().replace('\\', '/'), moduleName(relativePath));
scanner.scan(unit, null);
findings.addAll(scanner.findings());
}
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.getDiagnostics()
.stream()
.filter(diagnostic -> diagnostic.getKind() == Diagnostic.Kind.ERROR)
.toList();
if (!errors.isEmpty()) {
throw new IllegalArgumentException("unable to parse Java source: " + errors.get(0).getMessage(null));
}
return findings;
}
}
private static Path sourcePath(CompilationUnitTree unit) {
URI uri = unit.getSourceFile().toUri();
return Path.of(uri).toAbsolutePath().normalize();
}
private static String renderMarkdown(DeprecationReport report) {
List<String> lines = new ArrayList<>();
lines.add("# Removal-ready deprecations for " + report.snapshotVersion());
lines.add("");
lines.add("- Target removal version: " + report.removalVersion());
lines.add("- Scan mode: " + report.scanMode());
lines.add("- Planned issues: " + report.issuePlanCount());
lines.add("- Removal-ready symbols in planned issues: " + report.findingCount());
lines.add("- Due symbols: " + report.dueFindingCount());
lines.add("- Overdue symbols: " + report.overdueFindingCount());
lines.add("- Future symbols: " + report.futureFindingCount());
lines.add("- Unscheduled symbols: " + report.unscheduledFindingCount());
lines.add("");
if (report.issuePlans().isEmpty()) {
lines.add("No removal-ready deprecations matched this scan's issue-planning criteria.");
} else {
for (IssuePlan plan : report.issuePlans()) {
lines.add("## " + plan.issueTitle());
lines.add("");
lines.add("- Module: `" + plan.module() + "`");
lines.add("- File: `" + plan.filePath() + "`");
lines.add("- Removal version: `" + plan.removalVersion() + "`");
lines.add("- Symbols:");
for (SymbolFinding symbol : plan.symbols()) {
lines.add(" - " + renderSymbol(symbol));
}
lines.add("");
}
}
if (!report.unscheduledSymbols().isEmpty()) {
lines.add("");
lines.add("## Unscheduled for-removal symbols");
lines.add("");
for (SymbolFinding symbol : report.unscheduledSymbols()) {
lines.add("- `" + symbol.filePath() + "`: " + renderSymbol(symbol));
}
}
return String.join("\n", lines);
}
private static String renderSymbol(SymbolFinding symbol) {
List<String> parts = new ArrayList<>();
parts.add("`" + symbol.name() + "`");
parts.add(symbol.kind());
parts.add("line " + symbol.line());
parts.add(symbol.status());
if (symbol.replacement() != null) {
parts.add("use `" + symbol.replacement() + "`");
}
return parts.stream().reduce((left, right) -> left + ", " + right).orElse("");
}
private static ScanOptions parseArgs(String[] args) {
Path repoRoot = Path.of(".").toAbsolutePath().normalize();
String pomFile = "pom.xml";
String outputJson = null;
String outputMarkdown = null;
String targetRemovalVersion = null;
boolean includeOverdue = false;
boolean failOnDue = false;
for (int index = 0; index < args.length; index++) {
String argument = args[index];
if ("--repo-root".equals(argument)) {
repoRoot = Path.of(requireValue(argument, args, ++index)).toAbsolutePath().normalize();
} else if ("--pom-file".equals(argument)) {
pomFile = requireValue(argument, args, ++index);
} else if ("--output-json".equals(argument)) {
outputJson = requireValue(argument, args, ++index);
} else if ("--output-md".equals(argument)) {
outputMarkdown = requireValue(argument, args, ++index);
} else if ("--target-removal-version".equals(argument)) {
targetRemovalVersion = requireValue(argument, args, ++index);
validateVersion(targetRemovalVersion, argument);
} else if ("--include-overdue".equals(argument)) {
includeOverdue = true;
} else if ("--fail-on-due".equals(argument)) {
failOnDue = true;
} else {
throw new IllegalArgumentException("unknown argument " + argument);
}
}
if (outputJson == null || outputMarkdown == null) {
throw new IllegalArgumentException("--output-json and --output-md are required");
}
Path resolvedPom = Path.of(pomFile);
if (!resolvedPom.isAbsolute()) {
resolvedPom = repoRoot.resolve(resolvedPom).normalize();
}
return new ScanOptions(repoRoot, resolvedPom, Path.of(outputJson), Path.of(outputMarkdown),
targetRemovalVersion, includeOverdue, failOnDue);
}
private static String requireValue(String option, String[] args, int valueIndex) {
if (valueIndex >= args.length) {
throw new IllegalArgumentException(option + " requires a value");
}
return args[valueIndex];
}
private static ProjectVersion readProjectVersion(Path pomFile) throws IOException {
String text = Files.readString(pomFile, StandardCharsets.UTF_8);
Matcher matcher = VERSION_RE.matcher(text);
if (!matcher.find()) {
throw new IllegalArgumentException("unable to find a project version in " + pomFile);
}
return new ProjectVersion(matcher.group(1).trim());
}
private static void validateVersion(String version, String option) {
if (!SEMVER_RE.matcher(version).matches()) {
throw new IllegalArgumentException(option + " must be major.minor.patch, found '" + version + "'");
}
}
private static List<Path> iterJavaFiles(Path repoRoot) throws IOException {
List<Path> javaFiles = new ArrayList<>();
try (Stream<Path> paths = Files.walk(repoRoot)) {
paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> hasMainSourceMarker(repoRoot.relativize(path)))
.filter(path -> !containsSkipPart(repoRoot.relativize(path)))
.forEach(javaFiles::add);
}
javaFiles.sort(Comparator.naturalOrder());
return javaFiles;
}
private static boolean containsSkipPart(Path relativePath) {
for (Path part : relativePath) {
if (SKIP_PARTS.contains(part.toString())) {
return true;
}
}
return false;
}
private static boolean hasMainSourceMarker(Path relativePath) {
List<String> parts = pathParts(relativePath);
for (int index = 0; index <= parts.size() - 3; index++) {
if ("src".equals(parts.get(index)) && "main".equals(parts.get(index + 1))
&& "java".equals(parts.get(index + 2))) {
return true;
}
}
return false;
}
private static String moduleName(Path relativePath) {
List<String> parts = pathParts(relativePath);
for (int index = 0; index <= parts.size() - 4; index++) {
if ("src".equals(parts.get(index + 1)) && "main".equals(parts.get(index + 2))
&& "java".equals(parts.get(index + 3))) {
return parts.get(index);
}
}
return parts.isEmpty() ? relativePath.toString() : parts.get(0);
}
private static Set<String> removalVersions(String text) {
Set<String> versions = new LinkedHashSet<>();
Matcher declarationMatcher = DECLARATION_VERSION_RE.matcher(text);
while (declarationMatcher.find()) {
versions.add(declarationMatcher.group(1));
}
Matcher notifierMatcher = NOTIFIER_RE.matcher(text);
while (notifierMatcher.find()) {
String version = notifierMatcher.group(2);
if (version != null) {
versions.add(version);
}
}
return versions;
}
private static String replacement(String text) {
Matcher notifierMatcher = NOTIFIER_RE.matcher(text);
if (notifierMatcher.find()) {
return notifierMatcher.group(1);
}
Matcher javadocMatcher = JAVADOC_REPLACEMENT_RE.matcher(text);
if (javadocMatcher.find()) {
return javadocMatcher.group(1);
}
return null;
}
private static IssuePlan buildIssuePlan(String snapshotVersion, String removalVersion, String relativePath,
String module, List<SymbolFinding> symbols) {
int symbolCount = symbols.size();
String issueTitle = symbolCount == 1
? "Remove " + removalVersion + "-ready deprecation: " + symbols.get(0).name()
: "Remove " + removalVersion + "-ready deprecations in " + Path.of(relativePath).getFileName();
String dedupeKey = removalVersion + ":" + relativePath;
String issueMarker = "<!-- ta4j:deprecation-removal version=" + removalVersion + " dedupe=" + dedupeKey
+ " -->";
String symbolLines = symbols.stream()
.map(symbol -> "- " + renderSymbol(symbol))
.reduce((left, right) -> left + "\n" + right)
.orElse("");
String issueBody = String.join("\n",
"Release automation detected removal-ready deprecations for `" + snapshotVersion + "`.", "",
"Module: `" + module + "`", "File: `" + relativePath + "`", "Removal version: `" + removalVersion + "`",
"", "Symbols:", symbolLines, "", "Acceptance checks:",
"- remove or migrate the compatibility symbols scheduled for removal in `" + removalVersion + "`",
"- update callers, tests, and documentation as needed", "- keep the full build green", "", issueMarker);
return new IssuePlan(dedupeKey, GROUPED_FILE_PLAN_KIND, issueMarker, issueTitle, issueBody, removalVersion,
module, relativePath, symbolCount, List.copyOf(symbols));
}
private static String status(String removalVersion, String targetRemovalVersion) {
int comparison = compareVersions(removalVersion, targetRemovalVersion);
if (comparison < 0) {
return "overdue";
}
if (comparison == 0) {
return "due";
}
return "future";
}
private static int compareVersions(String left, String right) {
int[] leftParts = versionParts(left);
int[] rightParts = versionParts(right);
for (int index = 0; index < leftParts.length; index++) {
int comparison = Integer.compare(leftParts[index], rightParts[index]);
if (comparison != 0) {
return comparison;
}
}
return 0;
}
private static int[] versionParts(String version) {
validateVersion(version, "version");
String[] parts = version.split("\\.");
try {
return new int[] { Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]) };
} catch (NumberFormatException error) {
throw new IllegalArgumentException("version components must fit in an integer: '" + version + "'", error);
}
}
private static void writeOutput(Path path, String content) throws IOException {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private static List<String> pathParts(Path path) {
List<String> parts = new ArrayList<>();
for (Path part : path) {
parts.add(part.toString());
}
return parts;
}
private static String usage() {
return "Usage: --output-json <path> --output-md <path> [--repo-root <path>] [--pom-file <path>] "
+ "[--target-removal-version <major.minor.patch>] [--include-overdue] [--fail-on-due]";
}
private static final class SourceScanner extends TreePathScanner<Void, Void> {
private final DocTrees docTrees;
private final SourcePositions sourcePositions;
private final CompilationUnitTree unit;
private final String sourceText;
private final String filePath;
private final String module;
private final List<SourceFinding> findings = new ArrayList<>();
private final Deque<TypeContext> typeContexts = new ArrayDeque<>();
private int methodDepth;
private SourceScanner(DocTrees docTrees, SourcePositions sourcePositions, CompilationUnitTree unit,
String sourceText, String filePath, String module) {
this.docTrees = docTrees;
this.sourcePositions = sourcePositions;
this.unit = unit;
this.sourceText = sourceText;
this.filePath = filePath;
this.module = module;
}
private List<SourceFinding> findings() {
return findings;
}
@Override
public Void visitClass(ClassTree tree, Void unused) {
TreePath path = getCurrentPath();
CandidateDetails details = candidateDetails(path, tree.getModifiers(), tree, className(tree),
className(tree), kind(tree.getKind()), false);
Set<String> inheritedVersions = details.forRemoval() ? details.effectiveVersions() : inheritedVersions();
typeContexts.push(new TypeContext(className(tree), inheritedVersions));
try {
if (details.forRemoval()) {
findings.add(details.toFinding(filePath, module));
}
return super.visitClass(tree, unused);
} finally {
typeContexts.pop();
}
}
@Override
public Void visitMethod(MethodTree tree, Void unused) {
TreePath path = getCurrentPath();
String name = tree.getReturnType() == null ? currentTypeName() : tree.getName().toString();
String kind = tree.getReturnType() == null ? "constructor" : "method";
CandidateDetails details = candidateDetails(path, tree.getModifiers(), tree, name,
methodSignature(tree, name), kind, true);
if (details.forRemoval()) {
findings.add(details.toFinding(filePath, module));
}
methodDepth++;
try {
return super.visitMethod(tree, unused);
} finally {
methodDepth--;
}
}
@Override
public Void visitVariable(VariableTree tree, Void unused) {
TreePath path = getCurrentPath();
if (methodDepth == 0 && isField(path)) {
CandidateDetails details = candidateDetails(path, tree.getModifiers(), tree, tree.getName().toString(),
tree.getName().toString(), "field", true);
if (details.forRemoval()) {
findings.add(details.toFinding(filePath, module));
}
}
return super.visitVariable(tree, unused);
}
private CandidateDetails candidateDetails(TreePath path, ModifiersTree modifiers, Tree tree, String name,
String signature, String kind, boolean inheritMissingRemovalVersion) {
boolean forRemoval = isDeprecatedForRemoval(modifiers);
String context = context(path, tree);
Set<String> explicitVersions = removalVersions(context);
Set<String> effectiveVersions = explicitVersions.isEmpty() && inheritMissingRemovalVersion
? inheritedVersions()
: explicitVersions;
String removalVersion = effectiveVersions.stream().findFirst().orElse(null);
String findingStatus = removalVersion == null ? "unscheduled" : "scheduled";
int line = declarationLine(tree, name);
return new CandidateDetails(forRemoval, name, signature, kind, line, removalVersion, findingStatus,
replacement(context), Set.copyOf(effectiveVersions));
}
private String methodSignature(MethodTree tree, String name) {
List<String> parameterTypes = new ArrayList<>();
for (VariableTree parameter : tree.getParameters()) {
parameterTypes.add(parameter.getType().toString());
}
return name + "(" + String.join(", ", parameterTypes) + ")";
}
private String context(TreePath path, Tree tree) {
StringBuilder context = new StringBuilder();
DocCommentTree docComment = docTrees.getDocCommentTree(path);
if (docComment != null) {
context.append(docComment).append('\n');
}
context.append(sourceFragment(tree));
return context.toString();
}
private String sourceFragment(Tree tree) {
long start = sourcePositions.getStartPosition(unit, tree);
long end = sourcePositions.getEndPosition(unit, tree);
if (start < 0 || end < start || end > sourceText.length()) {
return tree.toString();
}
return sourceText.substring((int) start, (int) end);
}
private int declarationLine(Tree tree, String name) {
long start = sourcePositions.getStartPosition(unit, tree);
if (start < 0) {
return -1;
}
String fragment = sourceFragment(tree);
int nameIndex = fragment.indexOf(name);
if (nameIndex < 0) {
nameIndex = 0;
}
return (int) unit.getLineMap().getLineNumber(start + nameIndex);
}
private boolean isDeprecatedForRemoval(ModifiersTree modifiers) {
for (AnnotationTree annotation : modifiers.getAnnotations()) {
if (!isDeprecatedAnnotation(annotation)) {
continue;
}
for (ExpressionTree argument : annotation.getArguments()) {
String normalized = argument.toString().replaceAll("\\s+", "");
if (normalized.equals("forRemoval=true")) {
return true;
}
}
}
return false;
}
private boolean isDeprecatedAnnotation(AnnotationTree annotation) {
String annotationType = annotation.getAnnotationType().toString();
return "Deprecated".equals(annotationType) || "java.lang.Deprecated".equals(annotationType)
|| annotationType.endsWith(".Deprecated");
}
private Set<String> inheritedVersions() {
return typeContexts.isEmpty() ? Set.of() : typeContexts.peek().inheritedRemovalVersions();
}
private String currentTypeName() {
return typeContexts.isEmpty() ? "" : typeContexts.peek().name();
}
private boolean isField(TreePath path) {
TreePath parentPath = path.getParentPath();
return parentPath != null && parentPath.getLeaf() instanceof ClassTree;
}
private String className(ClassTree tree) {
return tree.getSimpleName().toString();
}
private String kind(Tree.Kind treeKind) {
return switch (treeKind) {
case ANNOTATION_TYPE -> "annotation";
case ENUM -> "enum";
case INTERFACE -> "interface";
case RECORD -> "record";
default -> "class";
};
}
}
private record ScanOptions(Path repoRoot, Path pomFile, Path outputJson, Path outputMarkdown,
String targetRemovalVersion, boolean includeOverdue, boolean failOnDue) {
}
private record ProjectVersion(String version) {
}
private record TypeContext(String name, Set<String> inheritedRemovalVersions) {
}
private record CandidateDetails(boolean forRemoval, String name, String signature, String kind, int line,
String removalVersion, String status, String replacement, Set<String> effectiveVersions) {
private SourceFinding toFinding(String filePath, String module) {
return new SourceFinding(name, signature, kind, line, removalVersion, status, replacement, module,
filePath);
}
}
private record SourceFinding(String name, String signature, String kind, int line, String removalVersion,
String status, String replacement, String module, String filePath) {
private SymbolFinding toSymbolFinding() {
return toSymbolFinding(status);
}
private SymbolFinding toSymbolFinding(String status) {
return new SymbolFinding(trackingKey(), name, signature, kind, line, removalVersion, status, replacement,
module, filePath);
}
private String trackingKey() {
return String.join("|", SYMBOL_TRACKING_NAMESPACE, removalVersion == null ? "unscheduled" : removalVersion,
module, filePath, kind, signature);
}
}
private record SymbolFinding(String trackingKey, String name, String signature, String kind, int line,
String removalVersion, String status, String replacement, String module, String filePath) {
}
private record IssuePlan(String dedupeKey, String planKind, String issueMarker, String issueTitle, String issueBody,
String removalVersion, String module, String filePath, int symbolCount, List<SymbolFinding> symbols) {
}
private record DeprecationReport(int schemaVersion, String automationNamespace, String generatedAt, String repoRoot,
String snapshotVersion, String removalVersion, String scanMode, boolean includeOverdue, boolean failOnDue,
int totalForRemovalCount, int findingCount, int dueFindingCount, int overdueFindingCount,
int futureFindingCount, int unscheduledFindingCount, int blockingFindingCount, int issuePlanCount,
List<IssuePlan> issuePlans, List<SymbolFinding> unscheduledSymbols, String summaryMarkdown) {
private DeprecationReport withSummaryMarkdown(String summaryMarkdown) {
return new DeprecationReport(schemaVersion, automationNamespace, generatedAt, repoRoot, snapshotVersion,
removalVersion, scanMode, includeOverdue, failOnDue, totalForRemovalCount, findingCount,
dueFindingCount, overdueFindingCount, futureFindingCount, unscheduledFindingCount,
blockingFindingCount, issuePlanCount, issuePlans, unscheduledSymbols, summaryMarkdown);
}
}
}
@@ -0,0 +1,461 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.indicators;
import java.text.NumberFormat;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.Indicator;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
/**
* Benchmark focused on the performance characteristics of
* {@link CachedIndicator}.
*
* <p>
* This is intended for before/after comparisons across branches: run this
* benchmark on the feature branch, then checkout {@code master} and run it
* again.
*
* <p>
* Scenarios:
* <ul>
* <li>Bounded cache eviction (hot path for streaming/rolling windows)</li>
* <li>Concurrent cache-hit reads (contention on read-mostly workloads)</li>
* <li>Repeated reads of the last bar (common in live feeds where the last bar
* is queried frequently)</li>
* </ul>
*
* @since 0.22.0
*/
public class CachedIndicatorBenchmark {
private static final Logger LOG = LogManager.getLogger(CachedIndicatorBenchmark.class);
private static final int DEFAULT_THREADS = Math.max(8, Runtime.getRuntime().availableProcessors());
private static final int DEFAULT_BATCHES = 3;
private static final int DEFAULT_EVICTION_BAR_COUNT = 200_000;
private static final int DEFAULT_MAXIMUM_BAR_COUNT_HINT = 512;
private static final int DEFAULT_CACHE_HIT_READS_PER_THREAD = 1_000_000;
private static final int DEFAULT_LAST_BAR_READS = 1_000_000;
private static final int DEFAULT_LAST_BAR_SMA_PERIOD = 50;
public static void main(String[] args) throws Exception {
int threads = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_THREADS;
int batches = args.length > 1 ? Integer.parseInt(args[1]) : DEFAULT_BATCHES;
int evictionBars = args.length > 2 ? Integer.parseInt(args[2]) : DEFAULT_EVICTION_BAR_COUNT;
int cacheHitsPerThread = args.length > 3 ? Integer.parseInt(args[3]) : DEFAULT_CACHE_HIT_READS_PER_THREAD;
int lastBarReads = args.length > 4 ? Integer.parseInt(args[4]) : DEFAULT_LAST_BAR_READS;
int maximumBarCountHint = args.length > 5 ? Integer.parseInt(args[5]) : DEFAULT_MAXIMUM_BAR_COUNT_HINT;
int lastBarSmaPeriod = args.length > 6 ? Integer.parseInt(args[6]) : DEFAULT_LAST_BAR_SMA_PERIOD;
new CachedIndicatorBenchmark().run(threads, batches, evictionBars, cacheHitsPerThread, lastBarReads,
maximumBarCountHint, lastBarSmaPeriod);
}
ScenarioResult runBoundedEvictionScenario(int barCount, int maximumBarCountHint) {
BarSeries series = buildSeries(barCount);
return benchmarkBoundedEviction(series, maximumBarCountHint);
}
ScenarioResult runConcurrentCacheHitsScenario(int barCount, int threads, int readsPerThread) {
BarSeries series = buildSeries(barCount);
return benchmarkConcurrentCacheHits(series, threads, readsPerThread);
}
ScenarioResult runLastBarHotReadsScenario(int barCount, int smaPeriod, int reads) {
BarSeries series = buildSeries(barCount);
return benchmarkLastBarHotReads(series, smaPeriod, reads);
}
private void run(int threads, int batches, int evictionBars, int cacheHitsPerThread, int lastBarReads,
int maximumBarCountHint, int lastBarSmaPeriod) throws Exception {
LOG.info(
"Starting CachedIndicator benchmark: threads={}, batches={}, evictionBars={}, cacheHitsPerThread={}, lastBarReads={}, maxBarCountHint={}, lastBarSmaPeriod={}",
threads, batches, formatLong(evictionBars), formatLong(cacheHitsPerThread), formatLong(lastBarReads),
formatLong(maximumBarCountHint), formatLong(lastBarSmaPeriod));
var evictionSeries = buildSeries(evictionBars);
var cacheHitSeries = buildSeries(Math.max(5_000, lastBarSmaPeriod + 2));
var lastBarSeries = buildSeries(Math.max(5_000, lastBarSmaPeriod + 2));
Map<String, ScenarioStats> statsByScenario = new HashMap<>();
for (int batch = 1; batch <= batches; batch++) {
runScenario("Bounded eviction (monotonic indices)", batch, statsByScenario,
() -> benchmarkBoundedEviction(evictionSeries, maximumBarCountHint));
runScenario("Concurrent cache hits (same index)", batch, statsByScenario,
() -> benchmarkConcurrentCacheHits(cacheHitSeries, threads, cacheHitsPerThread));
runScenario("Last bar hot reads (SMA)", batch, statsByScenario,
() -> benchmarkLastBarHotReads(lastBarSeries, lastBarSmaPeriod, lastBarReads));
}
logSummary(statsByScenario);
}
private void runScenario(String name, int batch, Map<String, ScenarioStats> statsByScenario,
Supplier<ScenarioResult> runner) throws Exception {
LOG.info("Batch {}: {}", batch, name);
ScenarioResult result = runner.get();
ScenarioStats stats = statsByScenario.computeIfAbsent(name, ignored -> new ScenarioStats());
stats.add(result);
LOG.info(" duration={} ms, ops={}, throughput={} ops/s, checksum={}", formatMillis(result.durationNanos),
formatLong(result.operations), formatDouble(result.throughputOpsPerSecond), result.checksum);
}
private void logSummary(Map<String, ScenarioStats> statsByScenario) {
LOG.info("CachedIndicator benchmark summary (averages across batches):");
for (Map.Entry<String, ScenarioStats> entry : statsByScenario.entrySet()) {
ScenarioStats stats = entry.getValue();
LOG.info(" {}: avgDuration={} ms, avgThroughput={} ops/s (runs={})", entry.getKey(),
formatMillis(stats.averageDurationNanos()), formatDouble(stats.averageThroughputOpsPerSecond()),
stats.runs);
}
}
private ScenarioResult benchmarkBoundedEviction(BarSeries baseSeries, int maximumBarCountHint) {
BarSeries series = new MaxBarCountHintSeries(baseSeries, maximumBarCountHint);
Indicator<Integer> indicator = new IndexIndicator(series);
int endIndex = series.getEndIndex();
// Warm-up the cache machinery a bit (avoid timing the "first use" path).
for (int i = 0; i < Math.min(endIndex, maximumBarCountHint + 16); i++) {
indicator.getValue(i);
}
long checksum = 0;
int operations = Math.max(0, endIndex);
long startNanos = System.nanoTime();
for (int i = 0; i < endIndex; i++) {
checksum += indicator.getValue(i);
}
long durationNanos = System.nanoTime() - startNanos;
requireTrue(endIndex >= 1, "Series must contain at least one bar");
requireEquals(Integer.valueOf(endIndex - 1), indicator.getValue(endIndex - 1), "Index indicator mismatch");
return new ScenarioResult(operations, durationNanos, checksum);
}
private ScenarioResult benchmarkConcurrentCacheHits(BarSeries baseSeries, int threads, int readsPerThread) {
BarSeries series = new MaxBarCountHintSeries(baseSeries, Integer.MAX_VALUE);
Indicator<Integer> indicator = new IndexIndicator(series);
int hitIndex = Math.max(0, series.getEndIndex() - 1);
indicator.getValue(hitIndex);
ExecutorService pool = Executors.newFixedThreadPool(threads);
try {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
List<CompletableFuture<Long>> futures = new ArrayList<>(threads);
for (int i = 0; i < threads; i++) {
futures.add(CompletableFuture.supplyAsync(() -> {
ready.countDown();
try {
start.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return 0L;
}
long localChecksum = 0;
for (int j = 0; j < readsPerThread; j++) {
localChecksum += indicator.getValue(hitIndex);
}
done.countDown();
return localChecksum;
}, pool));
}
awaitLatch(ready, Duration.ofSeconds(30), "workers to become ready");
long startNanos = System.nanoTime();
start.countDown();
awaitLatch(done, Duration.ofMinutes(2), "workers to finish");
long durationNanos = System.nanoTime() - startNanos;
long checksum = 0;
for (CompletableFuture<Long> future : futures) {
checksum += future.join();
}
long operations = (long) threads * readsPerThread;
return new ScenarioResult(operations, durationNanos, checksum);
} finally {
pool.shutdown();
try {
pool.awaitTermination(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private ScenarioResult benchmarkLastBarHotReads(BarSeries baseSeries, int smaPeriod, int reads) {
BarSeries series = new MaxBarCountHintSeries(baseSeries, Integer.MAX_VALUE);
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
SMAIndicator sma = new SMAIndicator(closePrice, smaPeriod);
int endIndex = series.getEndIndex();
// Warm up: compute once so cache-hit behavior is measured.
Num expected = sma.getValue(endIndex);
long checksum = 0;
long startNanos = System.nanoTime();
for (int i = 0; i < reads; i++) {
Num value = sma.getValue(endIndex);
checksum += value.hashCode();
}
long durationNanos = System.nanoTime() - startNanos;
requireEquals(expected, sma.getValue(endIndex), "Last bar SMA must remain stable across reads");
return new ScenarioResult(reads, durationNanos, checksum);
}
static BarSeries buildSeries(int barCount) {
var numFactory = DoubleNumFactory.getInstance();
BarSeries series = new BaseBarSeriesBuilder().withNumFactory(numFactory).build();
Duration timePeriod = Duration.ofDays(1);
Instant endTime = Instant.EPOCH;
for (int i = 0; i < barCount; i++) {
endTime = endTime.plus(timePeriod);
series.barBuilder()
.timePeriod(timePeriod)
.endTime(endTime)
.closePrice(i + 1d)
.openPrice(i + 1d)
.highPrice(i + 1d)
.lowPrice(i + 1d)
.volume(1d)
.add();
}
return series;
}
private static void awaitLatch(CountDownLatch latch, Duration timeout, String what) {
try {
boolean completed = latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS);
requireTrue(completed, "Timed out waiting for " + what);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for " + what, e);
}
}
private static void requireTrue(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private static void requireEquals(Object expected, Object actual, String message) {
if (!Objects.equals(expected, actual)) {
throw new IllegalStateException(message + " (expected=" + expected + ", actual=" + actual + ')');
}
}
private static String formatLong(long value) {
return NumberFormat.getNumberInstance(Locale.US).format(value);
}
private static String formatMillis(double nanos) {
double millis = nanos / 1_000_000d;
return NumberFormat.getNumberInstance(Locale.US).format(millis);
}
private static String formatDouble(double value) {
NumberFormat format = NumberFormat.getNumberInstance(Locale.US);
format.setMaximumFractionDigits(2);
return format.format(value);
}
static final class ScenarioResult {
private final long operations;
private final long durationNanos;
private final long checksum;
private final double throughputOpsPerSecond;
ScenarioResult(long operations, long durationNanos, long checksum) {
this.operations = operations;
this.durationNanos = durationNanos;
this.checksum = checksum;
this.throughputOpsPerSecond = operations / (durationNanos / 1_000_000_000d);
}
long getOperations() {
return operations;
}
long getDurationNanos() {
return durationNanos;
}
long getChecksum() {
return checksum;
}
double getThroughputOpsPerSecond() {
return throughputOpsPerSecond;
}
}
private static final class ScenarioStats {
private int runs;
private double totalDurationNanos;
private double totalThroughputOpsPerSecond;
private void add(ScenarioResult result) {
runs++;
totalDurationNanos += result.durationNanos;
totalThroughputOpsPerSecond += result.throughputOpsPerSecond;
}
private double averageDurationNanos() {
return totalDurationNanos / runs;
}
private double averageThroughputOpsPerSecond() {
return totalThroughputOpsPerSecond / runs;
}
}
static final class IndexIndicator extends CachedIndicator<Integer> {
private IndexIndicator(BarSeries series) {
super(series);
}
@Override
protected Integer calculate(int index) {
return index;
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
}
static final class MaxBarCountHintSeries implements BarSeries {
private static final long serialVersionUID = 3793483697719901088L;
private final BarSeries delegate;
private final int maximumBarCountHint;
private MaxBarCountHintSeries(BarSeries delegate, int maximumBarCountHint) {
this.delegate = delegate;
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);
}
}
}
@@ -0,0 +1,151 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.indicators;
import java.awt.Color;
import java.awt.Dimension;
import java.util.Date;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.ChartPanel;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.plot.DatasetRenderingOrder;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.xy.CandlestickRenderer;
import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
import org.jfree.chart.ui.ApplicationFrame;
import org.jfree.chart.ui.UIUtils;
import org.jfree.data.time.Second;
import org.jfree.data.time.TimeSeries;
import org.jfree.data.time.TimeSeriesCollection;
import org.jfree.data.xy.DefaultHighLowDataset;
import org.jfree.data.xy.OHLCDataset;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* This class builds a traditional candlestick chart.
*/
public class CandlestickChart {
/**
* Builds a JFreeChart OHLC dataset from a ta4j bar series.
*
* @param series the bar series
* @return an Open-High-Low-Close dataset
*/
private static OHLCDataset createOHLCDataset(BarSeries series) {
final int nbBars = series.getBarCount();
Date[] dates = new Date[nbBars];
double[] opens = new double[nbBars];
double[] highs = new double[nbBars];
double[] lows = new double[nbBars];
double[] closes = new double[nbBars];
double[] volumes = new double[nbBars];
for (int i = 0; i < nbBars; i++) {
Bar bar = series.getBar(i);
dates[i] = new Date(bar.getEndTime().toEpochMilli());
opens[i] = bar.getOpenPrice().doubleValue();
highs[i] = bar.getHighPrice().doubleValue();
lows[i] = bar.getLowPrice().doubleValue();
closes[i] = bar.getClosePrice().doubleValue();
volumes[i] = bar.getVolume().doubleValue();
}
return new DefaultHighLowDataset("btc", dates, highs, lows, opens, closes, volumes);
}
/**
* Builds an additional JFreeChart dataset from a ta4j bar series.
*
* @param series the bar series
* @return an additional dataset
*/
private static TimeSeriesCollection createAdditionalDataset(BarSeries series) {
ClosePriceIndicator indicator = new ClosePriceIndicator(series);
TimeSeriesCollection dataset = new TimeSeriesCollection();
TimeSeries chartTimeSeries = new TimeSeries("Btc price");
for (int i = 0; i < series.getBarCount(); i++) {
Bar bar = series.getBar(i);
chartTimeSeries.add(new Second(new Date(bar.getEndTime().toEpochMilli())),
indicator.getValue(i).doubleValue());
}
dataset.addSeries(chartTimeSeries);
return dataset;
}
/**
* Displays a chart in a frame. The frame is configured to avoid stealing focus
* when launched from tests or scripts.
*
* @param chart the chart to be displayed
*/
private static void displayChart(JFreeChart chart) {
// Chart panel
ChartPanel panel = new ChartPanel(chart);
panel.setFillZoomRectangle(true);
panel.setMouseWheelEnabled(true);
panel.setPreferredSize(new Dimension(740, 300));
// Application frame
ApplicationFrame frame = new ApplicationFrame("Ta4j example - Candlestick chart");
frame.setContentPane(panel);
frame.pack();
UIUtils.centerFrameOnScreen(frame);
frame.setFocusableWindowState(false);
frame.setVisible(true);
frame.setAlwaysOnTop(false);
frame.setAutoRequestFocus(false);
}
public static void main(String[] args) {
/*
* Getting bar series
*/
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
/*
* Creating the OHLC dataset
*/
OHLCDataset ohlcDataset = createOHLCDataset(series);
/*
* Creating the additional dataset
*/
TimeSeriesCollection xyDataset = createAdditionalDataset(series);
/*
* Creating the chart
*/
JFreeChart chart = ChartFactory.createCandlestickChart("Bitstamp BTC price", "Time", "USD", ohlcDataset, true);
// Candlestick rendering
CandlestickRenderer renderer = new CandlestickRenderer();
renderer.setAutoWidthMethod(CandlestickRenderer.WIDTHMETHOD_SMALLEST);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Additional dataset
int index = 1;
plot.setDataset(index, xyDataset);
plot.mapDatasetToRangeAxis(index, 0);
XYLineAndShapeRenderer renderer2 = new XYLineAndShapeRenderer(true, false);
renderer2.setSeriesPaint(index, Color.blue);
plot.setRenderer(index, renderer2);
// Misc
plot.setRangeGridlinePaint(Color.lightGray);
plot.setBackgroundPaint(Color.white);
NumberAxis numberAxis = (NumberAxis) plot.getRangeAxis();
numberAxis.setAutoRangeIncludesZero(false);
plot.setDatasetRenderingOrder(DatasetRenderingOrder.FORWARD);
/*
* Displaying the chart
*/
displayChart(chart);
}
}
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.indicators;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.Stroke;
import java.util.Date;
import org.jfree.chart.ChartPanel;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.annotations.XYLineAnnotation;
import org.jfree.chart.axis.DateAxis;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.plot.CombinedDomainXYPlot;
import org.jfree.chart.plot.DatasetRenderingOrder;
import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.xy.CandlestickRenderer;
import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
import org.jfree.chart.ui.ApplicationFrame;
import org.jfree.chart.ui.UIUtils;
import org.jfree.data.time.Second;
import org.jfree.data.time.TimeSeries;
import org.jfree.data.time.TimeSeriesCollection;
import org.jfree.data.xy.DefaultHighLowDataset;
import org.jfree.data.xy.OHLCDataset;
import org.jfree.data.xy.XYDataset;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.ChopIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* This class builds a traditional candlestick chart.
*/
public class CandlestickChartWithChopIndicator {
private static final int CHOP_INDICATOR_TIMEFRAME = 14;
private static final double CHOP_UPPER_THRESHOLD = 61.8;
private static final double CHOP_LOWER_THRESHOLD = 38.2;
private static final int VOLUME_DATASET_INDEX = 1;
private static final int CHOP_SCALE_VALUE = 100;
private static CombinedDomainXYPlot combinedPlot;
private static JFreeChart combinedChart;
static DateAxis xAxis = new DateAxis("Time");
private static ChartPanel combinedChartPanel;
private static XYPlot indicatorXYPlot;
static Stroke dashedThinLineStyle = new BasicStroke(0.4f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1.0f,
new float[] { 8.0f, 4.0f }, 0.0f);
static BarSeries series;
/**
* Builds a JFreeChart OHLC dataset from a ta4j bar series.
*
* @param series a bar series
* @return an Open-High-Low-Close dataset
*/
private static OHLCDataset createOHLCDataset(BarSeries series) {
final int nbBars = series.getBarCount();
Date[] dates = new Date[nbBars];
double[] opens = new double[nbBars];
double[] highs = new double[nbBars];
double[] lows = new double[nbBars];
double[] closes = new double[nbBars];
double[] volumes = new double[nbBars];
for (int i = 0; i < nbBars; i++) {
Bar bar = series.getBar(i);
dates[i] = new Date(bar.getEndTime().toEpochMilli());
opens[i] = bar.getOpenPrice().doubleValue();
highs[i] = bar.getHighPrice().doubleValue();
lows[i] = bar.getLowPrice().doubleValue();
closes[i] = bar.getClosePrice().doubleValue();
volumes[i] = bar.getVolume().doubleValue();
}
return new DefaultHighLowDataset("btc", dates, highs, lows, opens, closes, volumes);
}
/**
* Builds an additional JFreeChart dataset from a ta4j bar series.
*
* @param series a bar series
* @return an additional dataset
*/
private static TimeSeriesCollection createAdditionalDataset(BarSeries series) {
ClosePriceIndicator indicator = new ClosePriceIndicator(series);
TimeSeriesCollection dataset = new TimeSeriesCollection();
TimeSeries chartTimeSeries = new TimeSeries("Btc price");
for (int i = 0; i < series.getBarCount(); i++) {
Bar bar = series.getBar(i);
chartTimeSeries.add(new Second(new Date(bar.getEndTime().toEpochMilli())),
indicator.getValue(i).doubleValue());
}
dataset.addSeries(chartTimeSeries);
return dataset;
}
private static TimeSeriesCollection createChopDataset(BarSeries series) {
ChopIndicator indicator = new ChopIndicator(series, CHOP_INDICATOR_TIMEFRAME, CHOP_SCALE_VALUE);
TimeSeriesCollection dataset = new TimeSeriesCollection();
TimeSeries chartTimeSeries = new TimeSeries("CHOP_14");
for (int i = 0; i < series.getBarCount(); i++) {
Bar bar = series.getBar(i);
if (i < CHOP_INDICATOR_TIMEFRAME)
continue;
chartTimeSeries.add(new Second(new Date(bar.getEndTime().toEpochMilli())),
indicator.getValue(i).doubleValue());
}
dataset.addSeries(chartTimeSeries);
return dataset;
}
/**
* Displays a chart in a frame. The frame is configured to avoid stealing focus
* when launched from tests or scripts.
*
* @param ohlcDataset
* @param xyDataset
* @param chopSeries
*/
private static void displayChart(XYDataset ohlcDataset, XYDataset xyDataset, XYDataset chopSeries) {
/*
* Create the chart
*/
CandlestickRenderer renderer = new CandlestickRenderer();
XYPlot pricePlot = new XYPlot(ohlcDataset, xAxis, new NumberAxis("Price"), renderer);
renderer.setAutoWidthMethod(CandlestickRenderer.WIDTHMETHOD_SMALLEST);
// volume dataset
pricePlot.setDataset(VOLUME_DATASET_INDEX, xyDataset);
pricePlot.mapDatasetToRangeAxis(VOLUME_DATASET_INDEX, 0);
// plot.setDomainAxis( xAxis );
XYLineAndShapeRenderer renderer2 = new XYLineAndShapeRenderer(true, false);
renderer2.setSeriesPaint(VOLUME_DATASET_INDEX, Color.blue);
pricePlot.setRenderer(VOLUME_DATASET_INDEX, renderer2);
// Misc
pricePlot.setRangeGridlinePaint(Color.lightGray);
pricePlot.setBackgroundPaint(Color.white);
NumberAxis numberAxis = (NumberAxis) pricePlot.getRangeAxis();
pricePlot.setDatasetRenderingOrder(DatasetRenderingOrder.FORWARD);
renderer.setAutoWidthMethod(CandlestickRenderer.WIDTHMETHOD_SMALLEST);
// Misc
pricePlot.setRangeGridlinePaint(Color.lightGray);
pricePlot.setBackgroundPaint(Color.white);
numberAxis.setAutoRangeIncludesZero(false);
pricePlot.setDatasetRenderingOrder(DatasetRenderingOrder.FORWARD);
// secondary study plot
indicatorXYPlot = new XYPlot( /* null, xAxis, yAxis, renderer */);
indicatorXYPlot.setDataset(chopSeries);
indicatorXYPlot.setRangeAxis(0, new NumberAxis(""));
indicatorXYPlot.setRenderer(0, new XYLineAndShapeRenderer());
NumberAxis yIndicatorAxis = new NumberAxis("");
yIndicatorAxis.setRange(0, CHOP_SCALE_VALUE);
indicatorXYPlot.setRangeAxis(0, yIndicatorAxis);
// combinedPlot
combinedPlot = new CombinedDomainXYPlot(xAxis); // DateAxis
combinedPlot.setGap(10.0);
// combinedPlot.setDomainAxis( xAxis );
combinedPlot.setBackgroundPaint(Color.LIGHT_GRAY);
combinedPlot.setDomainGridlinePaint(Color.GRAY);
combinedPlot.setRangeGridlinePaint(Color.GRAY);
combinedPlot.setOrientation(PlotOrientation.VERTICAL);
combinedPlot.add(pricePlot, 70);
combinedPlot.add(indicatorXYPlot, 30);
// Now create the chart that contains the combinedPlot
combinedChart = new JFreeChart("Bitstamp BTC price with Chop indicator", null, combinedPlot, true);
combinedChart.setBackgroundPaint(Color.LIGHT_GRAY);
// combinedChartPanel to contain combinedChart
combinedChartPanel = new ChartPanel(combinedChart);
combinedChartPanel.setLayout(new GridLayout(0, 1));
combinedChartPanel.setBackground(Color.LIGHT_GRAY);
combinedChartPanel.setPreferredSize(new Dimension(740, 300));
// Application frame
ApplicationFrame frame = new ApplicationFrame("Ta4j example - Candlestick chart");
frame.setContentPane(combinedChartPanel);
frame.pack();
UIUtils.centerFrameOnScreen(frame);
frame.setFocusableWindowState(false);
frame.setVisible(true);
frame.setAlwaysOnTop(false);
frame.setAutoRequestFocus(false);
// CHOP oscillator upper/lower threshold guidelines
XYLineAnnotation lineAnnotation = new XYLineAnnotation(
(double) series.getFirstBar().getBeginTime().toEpochMilli(), CHOP_LOWER_THRESHOLD,
(double) series.getLastBar().getEndTime().toEpochMilli(), CHOP_LOWER_THRESHOLD, dashedThinLineStyle,
Color.GREEN);
lineAnnotation.setToolTipText("tradable below this");
indicatorXYPlot.addAnnotation(lineAnnotation);
lineAnnotation = new XYLineAnnotation((double) series.getFirstBar().getBeginTime().toEpochMilli(),
CHOP_UPPER_THRESHOLD, (double) series.getLastBar().getEndTime().toEpochMilli(), CHOP_UPPER_THRESHOLD,
dashedThinLineStyle, Color.RED);
lineAnnotation.setToolTipText("too choppy above this");
indicatorXYPlot.addAnnotation(lineAnnotation);
}
public static void main(String[] args) {
series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
/*
* Create the OHLC dataset from the data series
*/
OHLCDataset ohlcDataset = createOHLCDataset(series);
/*
* Create volume dataset
*/
TimeSeriesCollection xyDataset = createAdditionalDataset(series);
/*
* add the CHOP Indicator
*/
TimeSeriesCollection chopSeries = createChopDataset(series);
/*
* Display the chart
*/
displayChart(ohlcDataset, xyDataset, chopSeries);
}
}
@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.indicators;
import java.awt.Dimension;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.ChartPanel;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.axis.DateAxis;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.ui.ApplicationFrame;
import org.jfree.chart.ui.UIUtils;
import org.jfree.data.time.Day;
import org.jfree.data.time.TimeSeries;
import org.jfree.data.time.TimeSeriesCollection;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.bollinger.BollingerBandsLowerIndicator;
import org.ta4j.core.indicators.bollinger.BollingerBandsMiddleIndicator;
import org.ta4j.core.indicators.bollinger.BollingerBandsUpperIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.statistics.StandardDeviationIndicator;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
/**
* This class builds a graphical chart showing values from indicators.
*/
public class IndicatorsToChart {
/**
* Builds a JFreeChart time series from a Ta4j bar series and an indicator.
*
* @param barSeries the ta4j bar series
* @param indicator the indicator
* @param name the name of the chart time series
* @return the JFreeChart time series
*/
private static TimeSeries buildChartBarSeries(BarSeries barSeries, Indicator<Num> indicator, String name) {
TimeSeries chartTimeSeries = new TimeSeries(name);
for (int i = 0; i < barSeries.getBarCount(); i++) {
Bar bar = barSeries.getBar(i);
chartTimeSeries.add(new Day(Date.from(bar.getEndTime())), indicator.getValue(i).doubleValue());
}
return chartTimeSeries;
}
/**
* Displays a chart in a frame. The frame is configured to avoid stealing focus
* when launched from tests or scripts.
*
* @param chart the chart to be displayed
*/
private static void displayChart(JFreeChart chart) {
// Chart panel
ChartPanel panel = new ChartPanel(chart);
panel.setFillZoomRectangle(true);
panel.setMouseWheelEnabled(true);
panel.setPreferredSize(new Dimension(500, 270));
// Application frame
ApplicationFrame frame = new ApplicationFrame("Ta4j example - Indicators to chart");
frame.setContentPane(panel);
frame.pack();
UIUtils.centerFrameOnScreen(frame);
frame.setFocusableWindowState(false);
frame.setVisible(true);
frame.setAlwaysOnTop(false);
frame.setAutoRequestFocus(false);
}
public static void main(String[] args) {
/*
* Getting bar series
*/
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
/*
* Creating indicators
*/
// Close price
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
EMAIndicator avg14 = new EMAIndicator(closePrice, 14);
StandardDeviationIndicator sd14 = new StandardDeviationIndicator(closePrice, 14);
// Bollinger bands
BollingerBandsMiddleIndicator middleBBand = new BollingerBandsMiddleIndicator(avg14);
BollingerBandsLowerIndicator lowBBand = new BollingerBandsLowerIndicator(middleBBand, sd14);
BollingerBandsUpperIndicator upBBand = new BollingerBandsUpperIndicator(middleBBand, sd14);
/*
* Building chart dataset
*/
TimeSeriesCollection dataset = new TimeSeriesCollection();
dataset.addSeries(buildChartBarSeries(series, closePrice, "Apple Inc. (AAPL) - NASDAQ GS"));
dataset.addSeries(buildChartBarSeries(series, lowBBand, "Low Bollinger Band"));
dataset.addSeries(buildChartBarSeries(series, upBBand, "High Bollinger Band"));
/*
* Creating the chart
*/
JFreeChart chart = ChartFactory.createTimeSeriesChart("Apple Inc. 2013 Close Prices", // title
"Date", // x-axis label
"Price Per Unit", // y-axis label
dataset, // data
true, // create legend?
true, // generate tooltips?
false // generate URLs?
);
XYPlot plot = (XYPlot) chart.getPlot();
DateAxis axis = (DateAxis) plot.getDomainAxis();
axis.setDateFormatOverride(new SimpleDateFormat("yyyy-MM-dd"));
/*
* Displaying the chart
*/
displayChart(chart);
}
}
@@ -0,0 +1,131 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.indicators;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.ATRIndicator;
import org.ta4j.core.indicators.PPOIndicator;
import org.ta4j.core.indicators.ROCIndicator;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.WilliamsRIndicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceRatioIndicator;
import org.ta4j.core.indicators.helpers.TypicalPriceIndicator;
import org.ta4j.core.indicators.statistics.StandardDeviationIndicator;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* This class builds a CSV file containing values from indicators.
*/
public class IndicatorsToCsv {
private static final Logger LOG = LogManager.getLogger(IndicatorsToCsv.class);
public static void main(String[] args) {
/*
* Getting bar series
*/
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
/*
* Creating indicators
*/
// Close price
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// Typical price
TypicalPriceIndicator typicalPrice = new TypicalPriceIndicator(series);
// Price variation
ClosePriceRatioIndicator closePriceRatioIndicator = new ClosePriceRatioIndicator(series);
// Simple moving averages
SMAIndicator shortSma = new SMAIndicator(closePrice, 8);
SMAIndicator longSma = new SMAIndicator(closePrice, 20);
// Exponential moving averages
EMAIndicator shortEma = new EMAIndicator(closePrice, 8);
EMAIndicator longEma = new EMAIndicator(closePrice, 20);
// Percentage price oscillator
PPOIndicator ppo = new PPOIndicator(closePrice, 12, 26);
// Rate of change
ROCIndicator roc = new ROCIndicator(closePrice, 100);
// Relative strength index
RSIIndicator rsi = new RSIIndicator(closePrice, 14);
// Williams %R
WilliamsRIndicator williamsR = new WilliamsRIndicator(series, 20);
// Average true range
ATRIndicator atr = new ATRIndicator(series, 20);
// Standard deviation
StandardDeviationIndicator sd = new StandardDeviationIndicator(closePrice, 14);
/*
* Building header
*/
StringBuilder sb = new StringBuilder(
"timestamp,close,typical,variation,sma8,sma20,ema8,ema20,ppo,roc,rsi,williamsr,atr,sd\n");
/*
* Adding indicators values
*/
final int nbBars = series.getBarCount();
for (int i = 0; i < nbBars; i++) {
sb.append(series.getBar(i).getEndTime())
.append(',')
.append(closePrice.getValue(i))
.append(',')
.append(typicalPrice.getValue(i))
.append(',')
.append(closePriceRatioIndicator.getValue(i))
.append(',')
.append(shortSma.getValue(i))
.append(',')
.append(longSma.getValue(i))
.append(',')
.append(shortEma.getValue(i))
.append(',')
.append(longEma.getValue(i))
.append(',')
.append(ppo.getValue(i))
.append(',')
.append(roc.getValue(i))
.append(',')
.append(rsi.getValue(i))
.append(',')
.append(williamsR.getValue(i))
.append(',')
.append(atr.getValue(i))
.append(',')
.append(sd.getValue(i))
.append('\n');
}
/*
* Writing CSV file
*/
BufferedWriter writer = null;
try {
writer = new BufferedWriter(new FileWriter(new File("target", "indicators.csv")));
writer.write(sb.toString());
} catch (IOException ioe) {
LOG.error("Unable to write CSV file", ioe);
} finally {
try {
if (writer != null) {
writer.close();
}
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
}
}
@@ -0,0 +1,85 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.logging;
import java.net.URL;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.ConfigurationFactory;
import org.apache.logging.log4j.core.config.Configurator;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.backtest.BarSeriesManager;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.strategies.CCICorrectionStrategy;
/**
* Strategy execution logging example.
*/
public class StrategyExecutionLogging {
private static final Logger LOGGER = LogManager.getLogger(StrategyExecutionLogging.class);
private static final URL LOG4J_CONFIGURATION = StrategyExecutionLogging.class.getClassLoader()
.getResource("log4j2-traces.xml");
private static String previousConfigurationFile;
/**
* Loads the Log4j configuration from a resource file. Only here to avoid
* polluting other examples with logs. Could be replaced by a simple log4j2.xml
* file in the resource folder.
*/
private static void loadLoggerConfiguration() {
if (LOG4J_CONFIGURATION == null) {
LOGGER.warn("Unable to locate log4j2-traces.xml on the classpath");
return;
}
if (previousConfigurationFile == null) {
previousConfigurationFile = System.getProperty(ConfigurationFactory.CONFIGURATION_FILE_PROPERTY);
}
System.setProperty(ConfigurationFactory.CONFIGURATION_FILE_PROPERTY, LOG4J_CONFIGURATION.toString());
try {
Configurator.reconfigure();
} catch (RuntimeException exception) {
LOGGER.error("Unable to load Log4j configuration", exception);
restorePreviousConfiguration();
}
}
private static void unloadLoggerConfiguration() {
restorePreviousConfiguration();
}
private static void restorePreviousConfiguration() {
if (previousConfigurationFile == null) {
System.clearProperty(ConfigurationFactory.CONFIGURATION_FILE_PROPERTY);
} else {
System.setProperty(ConfigurationFactory.CONFIGURATION_FILE_PROPERTY, previousConfigurationFile);
}
Configurator.reconfigure();
previousConfigurationFile = null;
}
public static void main(String[] args) {
// Loading the Log4j configuration
loadLoggerConfiguration();
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = CCICorrectionStrategy.buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
seriesManager.run(strategy);
// Unload the Log4j configuration
unloadLoggerConfiguration();
}
}
@@ -0,0 +1,115 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.num;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.MACDIndicator;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.helpers.HighPriceIndicator;
import org.ta4j.core.indicators.helpers.LowPriceIndicator;
import org.ta4j.core.indicators.numeric.BinaryOperationIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.IsEqualRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import java.math.MathContext;
import java.time.Duration;
import java.time.Instant;
import java.util.Random;
public class CompareNumTypes {
private static final Logger LOG = LogManager.getLogger(CompareNumTypes.class);
private static final int NUMBARS = 10000;
public static void main(String[] args) {
BaseBarSeriesBuilder barSeriesBuilder = new BaseBarSeriesBuilder();
BarSeries seriesD = barSeriesBuilder.withName("Sample Series Double ")
.withNumFactory(DoubleNumFactory.getInstance())
.build();
BarSeries seriesP = barSeriesBuilder.withName("Sample Series DecimalNum 32")
.withNumFactory(DecimalNumFactory.getInstance())
.build();
BarSeries seriesPH = barSeriesBuilder.withName("Sample Series DecimalNum 256")
.withNumFactory(DecimalNumFactory.getInstance(256))
.build();
var now = Instant.now();
int[] randoms = new Random().ints(NUMBARS, 80, 100).toArray();
for (int i = 0; i < randoms.length; i++) {
Instant date = now.minusSeconds(NUMBARS - i);
seriesD.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(date)
.openPrice(randoms[i])
.closePrice(randoms[i] + 21)
.highPrice(randoms[i] - 21)
.lowPrice(randoms[i] - 5)
.add();
seriesP.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(date)
.openPrice(randoms[i])
.closePrice(randoms[i] + 21)
.highPrice(randoms[i] - 21)
.lowPrice(randoms[i] - 5)
.add();
seriesPH.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(date)
.openPrice(randoms[i])
.closePrice(randoms[i] + 21)
.highPrice(randoms[i] - 21)
.lowPrice(randoms[i] - 5)
.add();
}
Num D = DecimalNum.valueOf(test(seriesD).toString(), new MathContext(256));
Num P = DecimalNum.valueOf(test(seriesP).toString(), new MathContext(256));
Num standard = DecimalNum.valueOf(test(seriesPH).toString(), new MathContext(256));
LOG.debug("{} error: {}", seriesD.getName(),
D.minus(standard).dividedBy(standard).multipliedBy(DecimalNum.valueOf(100)));
LOG.debug("{} error: {}", seriesP.getName(),
P.minus(standard).dividedBy(standard).multipliedBy(DecimalNum.valueOf(100)));
}
public static Num test(BarSeries series) {
final var closePriceIndicator = new ClosePriceIndicator(series);
final var rsi = new RSIIndicator(closePriceIndicator, 100);
final var macdIndicator = new MACDIndicator(rsi);
final var ema = new EMAIndicator(rsi, 12);
final var emaLong = new EMAIndicator(rsi, 26);
final var macdIndicator2 = BinaryOperationIndicator.difference(ema, emaLong);
final var entry = new IsEqualRule(macdIndicator, macdIndicator2);
final var exit = new UnderIndicatorRule(new LowPriceIndicator(series), new HighPriceIndicator(series));
final var strategy1 = new BaseStrategy(entry, exit); // enter/exit every tick
final var start = System.currentTimeMillis();
final var manager = new BarSeriesManager(series);
final var record1 = manager.run(strategy1);
final var totalReturn1 = new GrossReturnCriterion();
final var returnResult1 = totalReturn1.calculate(series, record1);
final var end = System.currentTimeMillis();
LOG.debug("""
[{}]
-Time: {} ms.
-Profit: {}\s
-Bars: {}
\s
""", series.getName(), (end - start), returnResult1, series.getBarCount());
return returnResult1;
}
}
@@ -0,0 +1,181 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.num;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
/**
* Performance profiling for {@link DecimalNum} precision trade-offs.
*/
class DecimalNumPrecisionPerformanceTest {
private static final Logger LOG = LogManager.getLogger(DecimalNumPrecisionPerformanceTest.class);
private static final int SAMPLE_SIZE = 512;
private static final String[] SAMPLE_VALUES = IntStream.range(0, SAMPLE_SIZE)
.mapToObj(DecimalNumPrecisionPerformanceTest::createSampleValue)
.toArray(String[]::new);
private static final List<Integer> PRECISIONS = List.of(8, 12, 16, 24, 32, 48, 64);
private static final int WARMUP_ITERATIONS = 64;
private static final int MEASUREMENT_ITERATIONS = 128;
private static final int MEASUREMENT_REPETITIONS = 6;
private static final MathContext BASELINE_CONTEXT = new MathContext(64, RoundingMode.HALF_UP);
public static void main(String[] args) {
new DecimalNumPrecisionPerformanceTest().quantifyPrecisionPerformanceTradeOffs();
}
private static Summary computeSummary(final NumFactory factory) {
final var alpha = (DecimalNum) factory.numOf("0.2");
final var one = (DecimalNum) factory.one();
final var decay = (DecimalNum) one.minus(alpha);
DecimalNum ema = (DecimalNum) factory.numOf(SAMPLE_VALUES[0]);
DecimalNum prev = ema;
DecimalNum sum = (DecimalNum) factory.zero();
DecimalNum sumSquared = (DecimalNum) factory.zero();
DecimalNum volatility = (DecimalNum) factory.zero();
for (final var valueStr : SAMPLE_VALUES) {
final var value = (DecimalNum) factory.numOf(valueStr);
sum = (DecimalNum) sum.plus(value);
sumSquared = (DecimalNum) sumSquared.plus(value.multipliedBy(value));
final var delta = (DecimalNum) value.minus(prev);
volatility = (DecimalNum) volatility.plus(delta.abs());
ema = (DecimalNum) ema.multipliedBy(decay).plus(value.multipliedBy(alpha));
prev = value;
}
final var count = (DecimalNum) factory.numOf(SAMPLE_VALUES.length);
final var mean = (DecimalNum) sum.dividedBy(count);
final var variance = (DecimalNum) sumSquared.dividedBy(count).minus(mean.multipliedBy(mean));
final var avgVolatility = (DecimalNum) volatility.dividedBy(count);
return new Summary(ema, mean, variance, avgVolatility);
}
private static double nanosToMillis(final double nanos) {
return nanos / (double) TimeUnit.MILLISECONDS.toNanos(1);
}
private static String createSampleValue(final int index) {
final double base = Math.sin(index / 10.0) * 250 + 1000 + index * 0.1;
final var value = BigDecimal.valueOf(base).setScale(8, RoundingMode.HALF_UP);
return value.toPlainString();
}
void quantifyPrecisionPerformanceTradeOffs() {
final var results = new ArrayList<BenchmarkResult>();
for (final var precision : PRECISIONS) {
results.add(benchmark(precision));
}
final var baseline = results.stream()
.filter(result -> result.precision() == BASELINE_CONTEXT.getPrecision())
.findFirst()
.orElseThrow();
final var report = new StringBuilder();
final double baselineMillis = nanosToMillis(baseline.medianDurationNanos());
report.append("Precision,MedianMillis,AvgMillis,MinMillis,MaxMillis,RelativeDuration,MaxAbsoluteError\n");
for (final var result : results) {
final double medianMillis = nanosToMillis(result.medianDurationNanos());
final double avgMillis = nanosToMillis(result.averageDurationNanos());
final double minMillis = nanosToMillis(result.minDurationNanos());
final double maxMillis = nanosToMillis(result.maxDurationNanos());
final double relative = medianMillis / baselineMillis;
final double maxError = result.summary().maxAbsoluteError(baseline.summary()).doubleValue();
report.append(String.format(Locale.ROOT, "%d,%.4f,%.4f,%.4f,%.4f,%.3f,%.10f%n", result.precision(),
medianMillis, avgMillis, minMillis, maxMillis, relative, maxError));
}
LOG.debug(report.toString());
}
private BenchmarkResult benchmark(final int precision) {
DecimalNum.configureDefaultPrecision(precision);
try {
final var factory = DecimalNumFactory.getInstance();
// Warm-up ensures JIT compilation happens before measurements start.
for (int i = 0; i < WARMUP_ITERATIONS; i++) {
computeSummary(factory);
}
final long[] durations = new long[MEASUREMENT_REPETITIONS];
Summary summary = null;
for (int run = 0; run < MEASUREMENT_REPETITIONS; run++) {
final long start = System.nanoTime();
for (int iteration = 0; iteration < MEASUREMENT_ITERATIONS; iteration++) {
summary = computeSummary(factory);
}
durations[run] = System.nanoTime() - start;
}
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
long total = 0;
for (final long duration : durations) {
total += duration;
if (duration < min) {
min = duration;
}
if (duration > max) {
max = duration;
}
}
final double average = total / (double) durations.length;
final long[] sorted = durations.clone();
Arrays.sort(sorted);
final double median;
final int middle = sorted.length / 2;
if (sorted.length % 2 == 0) {
median = (sorted[middle - 1] + sorted[middle]) / 2.0;
} else {
median = sorted[middle];
}
return new BenchmarkResult(precision, median, average, min, max, summary);
} finally {
DecimalNum.resetDefaultPrecision();
}
}
private record BenchmarkResult(int precision, double medianDurationNanos, double averageDurationNanos,
long minDurationNanos, long maxDurationNanos, Summary summary) {
}
private record Summary(DecimalNum ema, DecimalNum mean, DecimalNum variance, DecimalNum avgVolatility) {
private static BigDecimal difference(final DecimalNum first, final DecimalNum second) {
return first.bigDecimalValue().subtract(second.bigDecimalValue()).abs();
}
private static BigDecimal max(final BigDecimal... values) {
var result = values[0];
for (int i = 1; i < values.length; i++) {
if (values[i].compareTo(result) > 0) {
result = values[i];
}
}
return result;
}
BigDecimal maxAbsoluteError(final Summary baseline) {
final var emaDiff = difference(ema, baseline.ema);
final var meanDiff = difference(mean, baseline.mean);
final var varianceDiff = difference(variance, baseline.variance);
final var volatilityDiff = difference(avgVolatility, baseline.avgVolatility);
return max(emaDiff, meanDiff, varianceDiff, volatilityDiff);
}
}
}
@@ -0,0 +1,239 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.rules;
import java.text.NumberFormat;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Rule;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.rules.AbstractRule;
import org.ta4j.core.rules.AndRule;
import org.ta4j.core.rules.FixedRule;
/**
* Comparative benchmark for eager vs lazy rule naming.
* <p>
* Scenarios:
* <ul>
* <li>Eagerly setting AndRule's name in constructor and then calling getName()
* (current default)</li>
* <li>Lazily setting/getting AndRule's name in getName() (i.e. don't set name
* in constructor)</li>
* <li>Eagerly setting the name in constructor (using class names, not calling
* getName() on children) but never calling getName() - demonstrates that JIT
* may optimize away string construction overhead when the name is never
* accessed, though the volatile write itself cannot be eliminated</li>
* </ul>
*
* @since 0.22.0
*/
public class RuleNameBenchmark {
private static final Logger LOG = LogManager.getLogger(RuleNameBenchmark.class);
private static final int DEFAULT_THREADS = Math.max(8, Runtime.getRuntime().availableProcessors());
private static final int DEFAULT_RULES_PER_THREAD = 100_000;
private static final int DEFAULT_BATCHES = 3;
public static void main(String[] args) throws Exception {
int threads = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_THREADS;
int batches = args.length > 1 ? Integer.parseInt(args[1]) : DEFAULT_BATCHES;
int rulesPerThread = args.length > 2 ? Integer.parseInt(args[2]) : DEFAULT_RULES_PER_THREAD;
new RuleNameBenchmark().run(threads, batches, rulesPerThread);
}
private void run(int threads, int batches, int rulesPerThread) throws Exception {
String formattedRulesPerThread = NumberFormat.getIntegerInstance(Locale.US).format(rulesPerThread);
LOG.info("Starting rule construction benchmark: threads={}, batches={}, rulesPerThread={}", threads, batches,
formattedRulesPerThread);
Map<String, ScenarioStats> statsByScenario = new HashMap<>();
for (int batch = 1; batch <= batches; batch++) {
runScenario("Eager setName() + getName()", threads, rulesPerThread, this::buildEagerRule, true, batch,
statsByScenario);
runScenario("Lazy setName() + getName()", threads, rulesPerThread, this::buildLazyRule, true, batch,
statsByScenario);
runScenario("Eager setName() + No getName()", threads, rulesPerThread, this::buildEagerRuleNoChildNames,
false, batch, statsByScenario);
}
LOG.info("=== Rule construction throughput summary (threads={}, batches={}, rulesPerThread={}) ===", threads,
batches, formattedRulesPerThread);
statsByScenario.forEach((label, s) -> {
double avgThroughput = s.totalThroughput / s.runs;
Duration avgDuration = Duration.ofNanos((long) (s.totalDurationNanos / s.runs));
LOG.info(
"{}: runs={}, avgThroughput={} rules/s, minThroughput={}, maxThroughput={}, avgDuration={}, totalChecksum={}",
label, s.runs, NumberFormat.getIntegerInstance(Locale.US).format(avgThroughput),
NumberFormat.getIntegerInstance(Locale.US).format(s.minThroughput),
NumberFormat.getIntegerInstance(Locale.US).format(s.maxThroughput), avgDuration, s.totalChecksum);
});
}
private void runScenario(String label, int threads, int rulesPerThread, Supplier<Rule> factory, boolean callName,
int batch, Map<String, ScenarioStats> statsByScenario) throws Exception {
long started = System.nanoTime();
long checksum = exercise(threads, rulesPerThread, factory, callName);
long elapsedNanos = System.nanoTime() - started;
double elapsedSeconds = elapsedNanos / 1_000_000_000.0;
double constructions = (double) threads * rulesPerThread;
double throughput = constructions / elapsedSeconds;
LOG.debug("Batch {} [{}]: duration={}, throughput={} rules/s, checksum={}", batch, label,
Duration.ofNanos(elapsedNanos), NumberFormat.getIntegerInstance(Locale.US).format(throughput),
checksum);
ScenarioStats stats = statsByScenario.computeIfAbsent(label, k -> new ScenarioStats());
stats.runs++;
stats.totalDurationNanos += elapsedNanos;
stats.totalThroughput += throughput;
stats.totalChecksum += checksum;
stats.minThroughput = Math.min(stats.minThroughput, throughput);
stats.maxThroughput = Math.max(stats.maxThroughput, throughput);
}
private long exercise(int threads, int rulesPerThread, Supplier<Rule> factory, boolean callName) throws Exception {
var pool = Executors.newFixedThreadPool(threads);
try {
List<CompletableFuture<Long>> futures = new ArrayList<>(threads);
for (int t = 0; t < threads; t++) {
futures.add(CompletableFuture.supplyAsync(() -> {
long localChecksum = 0;
for (int i = 0; i < rulesPerThread; i++) {
Rule r = factory.get();
if (callName) {
String name = r.getName();
localChecksum += name.hashCode();
} else {
// Touch type to prevent dead-code elimination without invoking getName()
localChecksum += r.getClass().hashCode();
}
}
return localChecksum;
}, pool));
}
long total = 0;
for (CompletableFuture<Long> future : futures) {
total += future.get(5, TimeUnit.MINUTES);
}
return total;
} finally {
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
}
}
private Rule buildEagerRule() {
Rule left = new FixedRule(1);
Rule right = new FixedRule(2);
return new AndRule(left, right);
}
Rule buildLazyRule() {
Rule left = new FixedRule(1);
Rule right = new FixedRule(2);
return new LazyAndRule(left, right);
}
Rule buildEagerRuleNoChildNames() {
Rule left = new FixedRule(1);
Rule right = new FixedRule(2);
return new NoNameLeakAndRule(left, right);
}
/**
* Lazy variant of AndRule that defers name construction to getName().
*/
static final class LazyAndRule extends AbstractRule {
private final Rule rule1;
private final Rule rule2;
LazyAndRule(Rule rule1, Rule rule2) {
this.rule1 = rule1;
this.rule2 = rule2;
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
boolean satisfied = rule1.isSatisfied(index, tradingRecord) && rule2.isSatisfied(index, tradingRecord);
traceIsSatisfied(index, satisfied);
return satisfied;
}
@Override
protected String createDefaultName() {
setName(createCompositeName(getClass().getSimpleName(), rule1, rule2));
return getName();
}
}
/**
* Eager variant that constructs the name in the constructor but avoids calling
* getName() on children (to prevent triggering their lazy name resolution).
* This demonstrates that even when setName() is called eagerly, if getName() is
* never invoked on the rule, the JIT compiler may be able to optimize away some
* of the string construction overhead, though the volatile write itself cannot
* be eliminated.
* <p>
* Note: The volatile write in setName() is an observable side effect that
* prevents complete dead-code elimination, but the string construction work
* (StringBuilder operations) may still be optimized if the result is never
* read.
*/
static final class NoNameLeakAndRule extends AbstractRule {
private final Rule rule1;
private final Rule rule2;
NoNameLeakAndRule(Rule rule1, Rule rule2) {
this.rule1 = rule1;
this.rule2 = rule2;
// Eagerly construct and set the name, but use class names instead of
// calling getName() on children to avoid triggering their lazy name resolution.
// This allows us to test if JIT can eliminate the string construction
// overhead when the name is never accessed.
setName(buildNameWithoutChildNames());
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
boolean satisfied = rule1.isSatisfied(index, tradingRecord) && rule2.isSatisfied(index, tradingRecord);
traceIsSatisfied(index, satisfied);
return satisfied;
}
private String buildNameWithoutChildNames() {
StringBuilder builder = new StringBuilder(getClass().getSimpleName());
builder.append('(');
builder.append(rule1 == null ? "null" : rule1.getClass().getSimpleName());
builder.append(',');
builder.append(rule2 == null ? "null" : rule2.getClass().getSimpleName());
builder.append(')');
return builder.toString();
}
}
private static final class ScenarioStats {
int runs;
double totalDurationNanos;
double totalThroughput;
double minThroughput = Double.MAX_VALUE;
double maxThroughput = Double.MIN_VALUE;
long totalChecksum;
}
}
@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.*;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.adx.ADXIndicator;
import org.ta4j.core.indicators.adx.MinusDIIndicator;
import org.ta4j.core.indicators.adx.PlusDIIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* ADX indicator based strategy
*
* @see <a href=
* "http://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:average_directional_index_adx">
* http://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:average_directional_index_adx</a>
*/
public class ADXStrategy {
private static final Logger LOG = LogManager.getLogger(ADXStrategy.class);
/**
* @param series a bar series
* @return an adx indicator based strategy
*/
public static Strategy buildStrategy(BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
final ClosePriceIndicator closePriceIndicator = new ClosePriceIndicator(series);
final SMAIndicator smaIndicator = new SMAIndicator(closePriceIndicator, 50);
final int adxBarCount = 14;
final ADXIndicator adxIndicator = new ADXIndicator(series, adxBarCount);
final OverIndicatorRule adxOver20Rule = new OverIndicatorRule(adxIndicator, 20);
final PlusDIIndicator plusDIIndicator = new PlusDIIndicator(series, adxBarCount);
final MinusDIIndicator minusDIIndicator = new MinusDIIndicator(series, adxBarCount);
final Rule plusDICrossedUpMinusDI = new CrossedUpIndicatorRule(plusDIIndicator, minusDIIndicator);
final Rule plusDICrossedDownMinusDI = new CrossedDownIndicatorRule(plusDIIndicator, minusDIIndicator);
final OverIndicatorRule closePriceOverSma = new OverIndicatorRule(closePriceIndicator, smaIndicator);
final Rule entryRule = adxOver20Rule.and(plusDICrossedUpMinusDI).and(closePriceOverSma);
final UnderIndicatorRule closePriceUnderSma = new UnderIndicatorRule(closePriceIndicator, smaIndicator);
final Rule exitRule = adxOver20Rule.and(plusDICrossedDownMinusDI).and(closePriceUnderSma);
return new BaseStrategy("ADXStrategy", entryRule, exitRule, adxBarCount);
}
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
// Analysis
var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s gross return: {}", strategy.getName(), grossReturn);
ClosePriceIndicator closePriceIndicator = new ClosePriceIndicator(series);
SMAIndicator smaIndicator = new SMAIndicator(closePriceIndicator, 50);
int adxBarCount = 14;
ADXIndicator adxIndicator = new ADXIndicator(series, adxBarCount);
PlusDIIndicator plusDIIndicator = new PlusDIIndicator(series, adxBarCount);
MinusDIIndicator minusDIIndicator = new MinusDIIndicator(series, adxBarCount);
// Charting
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withIndicatorOverlay(smaIndicator)
.withSubChart(adxIndicator)
.withIndicatorOverlay(plusDIIndicator)
.withIndicatorOverlay(minusDIIndicator)
.withSubChart(new GrossReturnCriterion(), tradingRecord)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "adx-strategy", "temp/charts");
}
}
@@ -0,0 +1,97 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import java.awt.Color;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.CCIIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* CCI Correction Strategy
*
* @see <a href=
* "http://stockcharts.com/school/doku.php?id=chart_school:trading_strategies:cci_correction">
* http://stockcharts.com/school/doku.php?id=chart_school:trading_strategies:cci_correction</a>
*/
public class CCICorrectionStrategy {
private static final Logger LOG = LogManager.getLogger(CCICorrectionStrategy.class);
/**
* @param series a bar series
* @return a CCI correction strategy
*/
public static Strategy buildStrategy(BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
CCIIndicator longCci = new CCIIndicator(series, 200);
CCIIndicator shortCci = new CCIIndicator(series, 5);
Num plus100 = series.numFactory().hundred();
Num minus100 = series.numFactory().numOf(-100);
Rule entryRule = new OverIndicatorRule(longCci, plus100) // Bull trend
.and(new UnderIndicatorRule(shortCci, minus100)); // Signal
Rule exitRule = new UnderIndicatorRule(longCci, minus100) // Bear trend
.and(new OverIndicatorRule(shortCci, plus100)); // Signal
String strategyName = "CCICorrectionStrategy";
Strategy strategy = new BaseStrategy(strategyName, entryRule, exitRule);
strategy.setUnstableBars(5);
return strategy;
}
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
// Analysis
var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s gross return: {}", strategy.getName(), grossReturn);
CCIIndicator longCci = new CCIIndicator(series, 200);
CCIIndicator shortCci = new CCIIndicator(series, 5);
// Charting
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withSubChart(longCci)
.withIndicatorOverlay(shortCci)
.withLineColor(Color.ORANGE)
.withSubChart(new GrossReturnCriterion(), tradingRecord)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "cci-correction-strategy", "temp/charts");
}
}
@@ -0,0 +1,141 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.indicators.helpers.DateTimeIndicator;
import org.ta4j.core.rules.DayOfWeekRule;
import org.ta4j.core.strategy.named.NamedStrategy;
import java.time.DayOfWeek;
import java.util.ArrayList;
import java.util.List;
/**
* A trading strategy that enters and exits positions based on specific days of
* the week.
*
* <p>
* This strategy uses {@link DayOfWeekRule} to determine entry and exit signals
* based on the day of the week of each bar in the series. The strategy will
* enter a position when the bar's day of the week matches the specified entry
* day, and exit when it matches the specified exit day.
*
* <p>
* The strategy name is automatically generated as
* {@code "DayOfWeekStrategy_<entryDay>_<exitDay>"} (e.g.,
* "DayOfWeekStrategy_MONDAY_FRIDAY").
*
* <p>
* This strategy is useful for testing day-of-week effects in trading, such as
* the "Monday effect" or "Friday effect" observed in some markets.
*
* @since 0.19
*/
public class DayOfWeekStrategy extends NamedStrategy {
static {
registerImplementation(DayOfWeekStrategy.class);
}
/**
* Constructs a new DayOfWeekStrategy with the specified entry and exit days.
*
* @param series the bar series to analyze
* @param entryDayOfWeek the day of the week to enter positions
* @param exitDayOfWeek the day of the week to exit positions
* @throws IllegalArgumentException if series is null or if entryDayOfWeek
* equals exitDayOfWeek
*/
public DayOfWeekStrategy(BarSeries series, DayOfWeek entryDayOfWeek, DayOfWeek exitDayOfWeek) {
super(NamedStrategy.buildLabel(DayOfWeekStrategy.class, entryDayOfWeek.name(), exitDayOfWeek.name()),
new DayOfWeekRule(new DateTimeIndicator(series), entryDayOfWeek),
new DayOfWeekRule(new DateTimeIndicator(series), exitDayOfWeek));
}
/**
* Constructs a new DayOfWeekStrategy from string parameters.
*
* <p>
* The parameters should be two strings representing the entry and exit days of
* the week. Valid values are: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
* SATURDAY, SUNDAY (case-sensitive).
*
* @param series the bar series to analyze
* @param params array containing [entryDayOfWeek, exitDayOfWeek] as strings
* @throws IllegalArgumentException if params is null, has fewer than 2
* elements, or contains invalid day names
*/
public DayOfWeekStrategy(BarSeries series, String... params) {
this(series, parseEntryDayOfWeek(params), parseExitDayOfWeek(params));
}
/**
* Builds all possible strategy permutations for all combinations of entry and
* exit days.
*
* <p>
* This method generates strategies for all pairs of different days of the week
* (7 * 6 = 42 total strategies). Strategies where the entry day equals the exit
* day are excluded. If any strategy construction fails, a warning is logged and
* that strategy is skipped.
*
* @param series the bar series to analyze
* @return a list of all valid DayOfWeekStrategy permutations
*/
public static List<Strategy> buildAllStrategyPermutations(BarSeries series) {
List<String[]> permutations = new ArrayList<>();
for (DayOfWeek entryDay : DayOfWeek.values()) {
for (DayOfWeek exitDay : DayOfWeek.values()) {
if (entryDay != exitDay) {
permutations.add(new String[] { entryDay.name(), exitDay.name() });
}
}
}
return NamedStrategy.buildAllStrategyPermutations(series, permutations, DayOfWeekStrategy::new,
(params, error) -> {
String entry = params.length > 0 ? params[0] : "<missing>";
String exit = params.length > 1 ? params[1] : "<missing>";
LogManager.getLogger()
.warn("Failed to build strategy for entry day {} and exit day {} - {}", entry, exit,
error.getMessage());
});
}
private static DayOfWeek parseEntryDayOfWeek(String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length < 1) {
throw new IllegalArgumentException(
"At least 2 parameters required (entryDayOfWeek, exitDayOfWeek), but got " + params.length);
}
try {
return DayOfWeek.valueOf(params[0]);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid entry DayOfWeek value: '" + params[0]
+ "'. Valid values are: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY", e);
}
}
private static DayOfWeek parseExitDayOfWeek(String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length < 2) {
throw new IllegalArgumentException(
"At least 2 parameters required (entryDayOfWeek, exitDayOfWeek), but got " + params.length);
}
try {
return DayOfWeek.valueOf(params[1]);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid exit DayOfWeek value: '" + params[1]
+ "'. Valid values are: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY", e);
}
}
}
@@ -0,0 +1,107 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Strategy;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.helpers.HighPriceIndicator;
import org.ta4j.core.indicators.helpers.HighestValueIndicator;
import org.ta4j.core.indicators.helpers.LowPriceIndicator;
import org.ta4j.core.indicators.helpers.LowestValueIndicator;
import org.ta4j.core.indicators.numeric.BinaryOperationIndicator;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* Strategies which compares current price to global extrema over a week.
*/
public class GlobalExtremaStrategy {
private static final Logger LOG = LogManager.getLogger(GlobalExtremaStrategy.class);
// We assume that there were at least one position every 5 minutes during the
// whole
// week
private static final int NB_BARS_PER_WEEK = 12 * 24 * 7;
/**
* @param series the bar series
* @return the global extrema strategy
*/
public static Strategy buildStrategy(final BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
final var closePrices = new ClosePriceIndicator(series);
// Getting the high price over the past week
final var highPrices = new HighPriceIndicator(series);
final var weekHighPrice = new HighestValueIndicator(highPrices, NB_BARS_PER_WEEK);
// Getting the low price over the past week
final var lowPrices = new LowPriceIndicator(series);
final var weekLowPrice = new LowestValueIndicator(lowPrices, NB_BARS_PER_WEEK);
// Going long if the close price goes below the low price
final var downWeek = BinaryOperationIndicator.product(weekLowPrice, 1.004);
final var buyingRule = new UnderIndicatorRule(closePrices, downWeek);
// Going short if the close price goes above the high price
final var upWeek = BinaryOperationIndicator.product(weekHighPrice, 0.996);
final var sellingRule = new OverIndicatorRule(closePrices, upWeek);
String strategyName = "GlobalExtremaStrategy";
return new BaseStrategy(strategyName, buyingRule, sellingRule);
}
public static void main(final String[] args) {
// Getting the bar series
final var series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
final var strategy = buildStrategy(series);
// Running the strategy
final var seriesManager = new BarSeriesManager(series);
final var tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
// Analysis
final var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s gross return: {}", strategy.getName(), grossReturn);
final var highPrices = new HighPriceIndicator(series);
final var weekHighPrice = new HighestValueIndicator(highPrices, NB_BARS_PER_WEEK);
final var lowPrices = new LowPriceIndicator(series);
final var weekLowPrice = new LowestValueIndicator(lowPrices, NB_BARS_PER_WEEK);
final var downWeek = BinaryOperationIndicator.product(weekLowPrice, 1.004);
final var upWeek = BinaryOperationIndicator.product(weekHighPrice, 0.996);
// Charting
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withIndicatorOverlay(weekHighPrice)
.withIndicatorOverlay(weekLowPrice)
.withIndicatorOverlay(downWeek)
.withIndicatorOverlay(upWeek)
.withAnalysisCriterionOverlay(new NetProfitCriterion(), tradingRecord)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "global-extrema-strategy", "temp/charts");
}
}
@@ -0,0 +1,689 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import java.math.BigDecimal;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.Rule;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.elliott.ElliottChannelIndicator;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottScenarioGenerator;
import org.ta4j.core.indicators.elliott.ElliottScenarioSet;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottSwingCompressor;
import org.ta4j.core.indicators.elliott.ElliottSwingIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.macd.VolatilityNormalizedMACDIndicator;
import org.ta4j.core.rules.NotRule;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import org.ta4j.core.rules.elliott.ElliottImpulsePhaseRule;
import org.ta4j.core.rules.elliott.ElliottScenarioAlternationRule;
import org.ta4j.core.rules.elliott.ElliottScenarioCompletionRule;
import org.ta4j.core.rules.elliott.ElliottScenarioConfidenceRule;
import org.ta4j.core.rules.elliott.ElliottScenarioDirectionRule;
import org.ta4j.core.rules.elliott.ElliottScenarioInvalidationRule;
import org.ta4j.core.rules.elliott.ElliottScenarioRiskRewardRule;
import org.ta4j.core.rules.elliott.ElliottScenarioStopViolationRule;
import org.ta4j.core.rules.elliott.ElliottScenarioTargetReachedRule;
import org.ta4j.core.rules.elliott.ElliottScenarioTimeStopRule;
import org.ta4j.core.rules.elliott.ElliottScenarioValidRule;
import org.ta4j.core.rules.elliott.ElliottTrendBiasRule;
import org.ta4j.core.strategy.named.NamedStrategy;
/**
* High-reward Elliott Wave strategy that trades only high-confidence impulse
* scenarios with favorable risk/reward and trend/momentum alignment.
*
* <p>
* Entry criteria:
* <ul>
* <li>Impulse scenario in wave 3 or wave 5</li>
* <li>Confidence above the minimum threshold</li>
* <li>Directional trend bias alignment</li>
* <li>Risk/reward meets the minimum threshold using the wave 2/4 stop and the
* furthest Fibonacci target</li>
* <li>Wave 2/4 time alternation exceeds the minimum ratio</li>
* <li>Trend (SMA) and momentum (RSI or volatility-normalized MACD-V)
* confirmation</li>
* </ul>
*
* <p>
* Exit criteria:
* <ul>
* <li>Scenario invalidation or completion</li>
* <li>Corrective swing stop breached (wave 2/4)</li>
* <li>Target reached</li>
* <li>Trend/momentum breakdown</li>
* <li>Time-based stop after an extended wave 3 duration</li>
* </ul>
*
* @since 0.22.2
*/
public class HighRewardElliottWaveStrategy extends NamedStrategy {
static {
registerImplementation(HighRewardElliottWaveStrategy.class);
}
private static final SignalDirection DEFAULT_DIRECTION = SignalDirection.BULLISH;
private static final ElliottDegree DEFAULT_DEGREE = ElliottDegree.PRIMARY;
private static final double DEFAULT_MIN_CONFIDENCE = 0.35;
private static final double DEFAULT_MIN_RISK_REWARD = 2.0;
private static final double DEFAULT_MIN_ALTERNATION_RATIO = 1.50;
private static final double DEFAULT_MIN_TREND_BIAS_STRENGTH = 0.10;
private static final int DEFAULT_TREND_SMA_PERIOD = 100;
private static final int DEFAULT_RSI_PERIOD = 14;
private static final double DEFAULT_RSI_THRESHOLD = 50.0;
private static final int DEFAULT_MACD_FAST = 12;
private static final int DEFAULT_MACD_SLOW = 26;
private static final int DEFAULT_MACD_SIGNAL = 9;
private static final int DEFAULT_ATR_PERIOD = 14;
private static final double DEFAULT_MIN_RELATIVE_SWING = 0.10;
private static final double ANALYZER_MIN_CONFIDENCE = 0.20;
private static final int DEFAULT_SCENARIO_SWING_WINDOW = 5;
private static final double MAX_WAVE_DURATION_MULTIPLIER = 1.5;
private static final int PARAMETER_COUNT = 12;
/**
* Builds the strategy with default parameters.
*
* @param series bar series to analyze
*/
public HighRewardElliottWaveStrategy(final BarSeries series) {
this(series, Config.defaults());
}
/**
* Builds the strategy using serialized label parameters.
*
* @param series bar series to analyze
* @param params serialized parameters (see
* {@link Config#fromParameters(String...)})
*/
public HighRewardElliottWaveStrategy(final BarSeries series, final String... params) {
this(series, Config.fromParameters(params));
}
/**
* Builds the strategy using a precomputed scenario indicator.
*
* @param series bar series to analyze
* @param config strategy configuration
* @param scenarioIndicator indicator supplying scenario sets
*/
HighRewardElliottWaveStrategy(final BarSeries series, final Config config,
final Indicator<ElliottScenarioSet> scenarioIndicator) {
this(config, buildEntryRule(series, config, scenarioIndicator),
buildExitRule(series, config, scenarioIndicator), calculateUnstableBars(config));
}
/**
* Builds the strategy with the default scenario indicator pipeline.
*
* @param series bar series to analyze
* @param config strategy configuration
*/
private HighRewardElliottWaveStrategy(final BarSeries series, final Config config) {
this(series, config, buildScenarioIndicator(series, config));
}
/**
* Internal constructor that wires the prepared rules into the named strategy.
*
* @param config strategy configuration
* @param entryRule precomputed entry rule
* @param exitRule precomputed exit rule
* @param unstableBars unstable bar count for warm-up
*/
private HighRewardElliottWaveStrategy(final Config config, final Rule entryRule, final Rule exitRule,
final int unstableBars) {
super(buildLabel(config), entryRule, exitRule, unstableBars);
}
/**
* Builds the entry rule for the strategy.
*
* @param series bar series backing indicators
* @param config strategy configuration
* @param scenarioIndicator indicator supplying scenario sets
* @return entry rule
*/
private static Rule buildEntryRule(final BarSeries series, final Config config,
final Indicator<ElliottScenarioSet> scenarioIndicator) {
Objects.requireNonNull(series, "series");
Objects.requireNonNull(config, "config");
Objects.requireNonNull(scenarioIndicator, "scenarioIndicator");
validateScenarioIndicator(series, scenarioIndicator);
ClosePriceIndicator close = new ClosePriceIndicator(series);
SMAIndicator trendSma = new SMAIndicator(close, config.trendSmaPeriod());
RSIIndicator rsi = new RSIIndicator(close, config.rsiPeriod());
VolatilityNormalizedMACDIndicator macd = new VolatilityNormalizedMACDIndicator(close, config.macdFastPeriod(),
config.macdSlowPeriod(), DEFAULT_MACD_SIGNAL);
Rule trendRule = config.direction().isBullish() ? new OverIndicatorRule(close, trendSma)
: new UnderIndicatorRule(close, trendSma);
Rule momentumRule = config.direction().isBullish()
? new OverIndicatorRule(rsi, config.rsiThreshold()).or(new OverIndicatorRule(macd, 0))
: new UnderIndicatorRule(rsi, config.rsiThreshold()).or(new UnderIndicatorRule(macd, 0));
Rule scenarioValidRule = new ElliottScenarioValidRule(scenarioIndicator, close);
Rule impulsePhaseRule = new ElliottImpulsePhaseRule(scenarioIndicator, ElliottPhase.WAVE3, ElliottPhase.WAVE5);
Rule confidenceRule = new ElliottScenarioConfidenceRule(scenarioIndicator, config.minConfidence());
Rule directionRule = new ElliottScenarioDirectionRule(scenarioIndicator, config.direction().isBullish());
Rule trendBiasRule = new ElliottTrendBiasRule(scenarioIndicator, config.direction().isBullish(),
config.minTrendBiasStrength());
Rule alternationRule = new ElliottScenarioAlternationRule(scenarioIndicator, config.minAlternationRatio());
Rule riskRewardRule = new ElliottScenarioRiskRewardRule(scenarioIndicator, close,
config.direction().isBullish(), config.minRiskReward());
Rule entryRule = scenarioValidRule.and(impulsePhaseRule)
.and(confidenceRule)
.and(directionRule)
.and(trendBiasRule)
.and(alternationRule)
.and(riskRewardRule)
.and(trendRule)
.and(momentumRule);
return entryRule;
}
/**
* Builds the exit rule for the strategy.
*
* @param series bar series backing indicators
* @param config strategy configuration
* @param scenarioIndicator indicator supplying scenario sets
* @return exit rule
*/
private static Rule buildExitRule(final BarSeries series, final Config config,
final Indicator<ElliottScenarioSet> scenarioIndicator) {
Objects.requireNonNull(series, "series");
Objects.requireNonNull(config, "config");
Objects.requireNonNull(scenarioIndicator, "scenarioIndicator");
validateScenarioIndicator(series, scenarioIndicator);
ClosePriceIndicator close = new ClosePriceIndicator(series);
SMAIndicator trendSma = new SMAIndicator(close, config.trendSmaPeriod());
RSIIndicator rsi = new RSIIndicator(close, config.rsiPeriod());
VolatilityNormalizedMACDIndicator macd = new VolatilityNormalizedMACDIndicator(close, config.macdFastPeriod(),
config.macdSlowPeriod(), DEFAULT_MACD_SIGNAL);
Rule trendRule = config.direction().isBullish() ? new OverIndicatorRule(close, trendSma)
: new UnderIndicatorRule(close, trendSma);
Rule momentumRule = config.direction().isBullish()
? new OverIndicatorRule(rsi, config.rsiThreshold()).or(new OverIndicatorRule(macd, 0))
: new UnderIndicatorRule(rsi, config.rsiThreshold()).or(new UnderIndicatorRule(macd, 0));
Rule scenarioValidRule = new ElliottScenarioValidRule(scenarioIndicator, close);
Rule directionRule = new ElliottScenarioDirectionRule(scenarioIndicator, config.direction().isBullish());
Rule trendBiasRule = new ElliottTrendBiasRule(scenarioIndicator, config.direction().isBullish(),
config.minTrendBiasStrength());
Rule exitTriggers = new NotRule(scenarioValidRule).or(new NotRule(directionRule))
.or(new ElliottScenarioCompletionRule(scenarioIndicator))
.or(new ElliottScenarioInvalidationRule(scenarioIndicator, close))
.or(new ElliottScenarioTargetReachedRule(scenarioIndicator, close, config.direction().isBullish()))
.or(new ElliottScenarioStopViolationRule(scenarioIndicator, close, config.direction().isBullish()))
.or(new NotRule(trendBiasRule))
.or(new NotRule(trendRule.and(momentumRule)))
.or(new ElliottScenarioTimeStopRule(scenarioIndicator, MAX_WAVE_DURATION_MULTIPLIER));
return exitTriggers;
}
/**
* Validates that the scenario indicator is bound to the same series instance as
* the strategy.
*
* @param series strategy series
* @param scenarioIndicator scenario source indicator
*/
private static void validateScenarioIndicator(final BarSeries series,
final Indicator<ElliottScenarioSet> scenarioIndicator) {
if (scenarioIndicator.getBarSeries() != series) {
throw new IllegalArgumentException("scenarioIndicator must use the same BarSeries instance");
}
}
/**
* Builds the scenario indicator pipeline used by the strategy.
*
* @param series bar series to analyze
* @param config strategy configuration
* @return scenario indicator
*/
private static Indicator<ElliottScenarioSet> buildScenarioIndicator(final BarSeries series, final Config config) {
ElliottSwingIndicator swingIndicator = ElliottSwingIndicator.zigZag(series, config.degree());
ElliottChannelIndicator channelIndicator = new ElliottChannelIndicator(swingIndicator);
double minConfidence = Math.min(config.minConfidence(), ANALYZER_MIN_CONFIDENCE);
ElliottScenarioGenerator generator = new ElliottScenarioGenerator(series.numFactory(), minConfidence,
ElliottScenarioGenerator.DEFAULT_MAX_SCENARIOS);
ElliottSwingCompressor compressor = new ElliottSwingCompressor(new ClosePriceIndicator(series),
config.minRelativeSwing(), 0);
return new ScenarioSetIndicator(series, swingIndicator, channelIndicator, generator, compressor,
DEFAULT_SCENARIO_SWING_WINDOW);
}
/**
* Builds the named-strategy label for the configured parameters.
*
* @param config strategy configuration
* @return label string
*/
private static String buildLabel(final Config config) {
return NamedStrategy.buildLabel(HighRewardElliottWaveStrategy.class, config.labelParts());
}
/**
* Calculates the number of unstable bars for indicator warm-up.
*
* @param config strategy configuration
* @return unstable bar count
*/
private static int calculateUnstableBars(final Config config) {
int unstable = Math.max(config.trendSmaPeriod(), Math.max(config.rsiPeriod(), config.macdSlowPeriod()));
unstable = Math.max(unstable, DEFAULT_ATR_PERIOD);
return unstable;
}
/**
* Formats a double for strategy labels without trailing zeros.
*
* @param value numeric value
* @return formatted string
*/
private static String formatDouble(final double value) {
return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
/**
* Trade direction for the strategy.
*/
enum SignalDirection {
BULLISH, BEARISH;
/**
* @return {@code true} when the direction is bullish
*/
boolean isBullish() {
return this == BULLISH;
}
}
/**
* Immutable configuration for the strategy.
*/
static final class Config {
private final SignalDirection direction;
private final ElliottDegree degree;
private final double minConfidence;
private final double minRiskReward;
private final double minAlternationRatio;
private final double minTrendBiasStrength;
private final int trendSmaPeriod;
private final int rsiPeriod;
private final double rsiThreshold;
private final int macdFastPeriod;
private final int macdSlowPeriod;
private final double minRelativeSwing;
/**
* Creates a configuration with the supplied parameters.
*
* @param direction trade direction
* @param degree Elliott wave degree to analyze
* @param minConfidence minimum confidence threshold
* @param minRiskReward minimum risk/reward ratio
* @param minAlternationRatio minimum alternation duration ratio
* @param minTrendBiasStrength minimum trend bias strength
* @param trendSmaPeriod SMA period for trend confirmation
* @param rsiPeriod RSI period for momentum confirmation
* @param rsiThreshold RSI threshold for momentum
* @param macdFastPeriod MACD fast period
* @param macdSlowPeriod MACD slow period
* @param minRelativeSwing minimum relative swing magnitude
*/
Config(final SignalDirection direction, final ElliottDegree degree, final double minConfidence,
final double minRiskReward, final double minAlternationRatio, final double minTrendBiasStrength,
final int trendSmaPeriod, final int rsiPeriod, final double rsiThreshold, final int macdFastPeriod,
final int macdSlowPeriod, final double minRelativeSwing) {
this.direction = Objects.requireNonNull(direction, "direction");
this.degree = Objects.requireNonNull(degree, "degree");
if (minConfidence <= 0.0 || minConfidence > 1.0) {
throw new IllegalArgumentException("minConfidence must be in (0.0, 1.0]");
}
if (minRiskReward <= 0.0) {
throw new IllegalArgumentException("minRiskReward must be positive");
}
if (minAlternationRatio < 1.0) {
throw new IllegalArgumentException("minAlternationRatio must be >= 1.0");
}
if (minTrendBiasStrength < 0.0 || minTrendBiasStrength > 1.0) {
throw new IllegalArgumentException("minTrendBiasStrength must be in [0.0, 1.0]");
}
if (trendSmaPeriod <= 0) {
throw new IllegalArgumentException("trendSmaPeriod must be positive");
}
if (rsiPeriod <= 0) {
throw new IllegalArgumentException("rsiPeriod must be positive");
}
if (rsiThreshold < 0.0 || rsiThreshold > 100.0) {
throw new IllegalArgumentException("rsiThreshold must be in [0.0, 100.0]");
}
if (macdFastPeriod <= 0 || macdSlowPeriod <= 0) {
throw new IllegalArgumentException("MACD periods must be positive");
}
if (macdFastPeriod >= macdSlowPeriod) {
throw new IllegalArgumentException("macdFastPeriod must be less than macdSlowPeriod");
}
if (minRelativeSwing <= 0.0 || minRelativeSwing > 1.0) {
throw new IllegalArgumentException("minRelativeSwing must be in (0.0, 1.0]");
}
this.minConfidence = minConfidence;
this.minRiskReward = minRiskReward;
this.minAlternationRatio = minAlternationRatio;
this.minTrendBiasStrength = minTrendBiasStrength;
this.trendSmaPeriod = trendSmaPeriod;
this.rsiPeriod = rsiPeriod;
this.rsiThreshold = rsiThreshold;
this.macdFastPeriod = macdFastPeriod;
this.macdSlowPeriod = macdSlowPeriod;
this.minRelativeSwing = minRelativeSwing;
}
/**
* @return default configuration values
*/
static Config defaults() {
return new Config(DEFAULT_DIRECTION, DEFAULT_DEGREE, DEFAULT_MIN_CONFIDENCE, DEFAULT_MIN_RISK_REWARD,
DEFAULT_MIN_ALTERNATION_RATIO, DEFAULT_MIN_TREND_BIAS_STRENGTH, DEFAULT_TREND_SMA_PERIOD,
DEFAULT_RSI_PERIOD, DEFAULT_RSI_THRESHOLD, DEFAULT_MACD_FAST, DEFAULT_MACD_SLOW,
DEFAULT_MIN_RELATIVE_SWING);
}
/**
* Parses a serialized parameter list into a configuration.
*
* @param params serialized parameters
* @return parsed configuration
*/
static Config fromParameters(final String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length == 0) {
return defaults();
}
if (params.length != PARAMETER_COUNT) {
throw new IllegalArgumentException("Expected " + PARAMETER_COUNT
+ " parameters (direction, degree, minConfidence, minRiskReward, minAlternationRatio, "
+ "minTrendBiasStrength, trendSmaPeriod, rsiPeriod, rsiThreshold, macdFastPeriod, "
+ "macdSlowPeriod, minRelativeSwing), but got " + params.length);
}
SignalDirection direction = parseEnum(params[0], SignalDirection.class, "direction");
ElliottDegree degree = parseEnum(params[1], ElliottDegree.class, "degree");
double minConfidence = parseDouble(params[2], "minConfidence");
double minRiskReward = parseDouble(params[3], "minRiskReward");
double minAlternation = parseDouble(params[4], "minAlternationRatio");
double minTrendBias = parseDouble(params[5], "minTrendBiasStrength");
int trendSma = parseInt(params[6], "trendSmaPeriod");
int rsiPeriod = parseInt(params[7], "rsiPeriod");
double rsiThreshold = parseDouble(params[8], "rsiThreshold");
int macdFast = parseInt(params[9], "macdFastPeriod");
int macdSlow = parseInt(params[10], "macdSlowPeriod");
double minRelativeSwing = parseDouble(params[11], "minRelativeSwing");
return new Config(direction, degree, minConfidence, minRiskReward, minAlternation, minTrendBias, trendSma,
rsiPeriod, rsiThreshold, macdFast, macdSlow, minRelativeSwing);
}
/**
* @return label parts used for NamedStrategy labels
*/
String[] labelParts() {
return new String[] { direction.name(), degree.name(), formatDouble(minConfidence),
formatDouble(minRiskReward), formatDouble(minAlternationRatio), formatDouble(minTrendBiasStrength),
String.valueOf(trendSmaPeriod), String.valueOf(rsiPeriod), formatDouble(rsiThreshold),
String.valueOf(macdFastPeriod), String.valueOf(macdSlowPeriod), formatDouble(minRelativeSwing) };
}
/**
* @return configured trade direction
*/
SignalDirection direction() {
return direction;
}
/**
* @return configured Elliott wave degree
*/
ElliottDegree degree() {
return degree;
}
/**
* @return minimum confidence threshold
*/
double minConfidence() {
return minConfidence;
}
/**
* @return minimum risk/reward ratio
*/
double minRiskReward() {
return minRiskReward;
}
/**
* @return minimum alternation ratio
*/
double minAlternationRatio() {
return minAlternationRatio;
}
/**
* @return minimum trend bias strength
*/
double minTrendBiasStrength() {
return minTrendBiasStrength;
}
/**
* @return trend SMA period
*/
int trendSmaPeriod() {
return trendSmaPeriod;
}
/**
* @return RSI period
*/
int rsiPeriod() {
return rsiPeriod;
}
/**
* @return RSI threshold
*/
double rsiThreshold() {
return rsiThreshold;
}
/**
* @return MACD fast period
*/
int macdFastPeriod() {
return macdFastPeriod;
}
/**
* @return MACD slow period
*/
int macdSlowPeriod() {
return macdSlowPeriod;
}
/**
* @return minimum relative swing magnitude
*/
double minRelativeSwing() {
return minRelativeSwing;
}
/**
* Parses an integer parameter.
*
* @param value parameter value
* @param label parameter label
* @return parsed integer
*/
private static int parseInt(final String value, final String label) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Invalid " + label + " value: '" + value + "'", ex);
}
}
/**
* Parses a double parameter.
*
* @param value parameter value
* @param label parameter label
* @return parsed double
*/
private static double parseDouble(final String value, final String label) {
try {
return Double.parseDouble(value);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Invalid " + label + " value: '" + value + "'", ex);
}
}
/**
* Parses an enum parameter.
*
* @param value parameter value
* @param type enum type
* @param label parameter label
* @param <E> enum type
* @return parsed enum
*/
private static <E extends Enum<E>> E parseEnum(final String value, final Class<E> type, final String label) {
try {
return Enum.valueOf(type, value);
} catch (IllegalArgumentException ex) {
Set<String> allowed = enumNames(type);
throw new IllegalArgumentException(
"Invalid " + label + " value: '" + value + "'. Valid values are: " + String.join(", ", allowed),
ex);
}
}
/**
* Returns all allowed enum names for an error message.
*
* @param type enum type
* @param <E> enum type
* @return set of enum names
*/
private static <E extends Enum<E>> Set<String> enumNames(final Class<E> type) {
if (type == null) {
return Set.of();
}
return EnumSet.allOf(type).stream().map(Enum::name).collect(Collectors.toSet());
}
}
/**
* Cached indicator that assembles scenario sets for each bar index.
*/
private static final class ScenarioSetIndicator extends CachedIndicator<ElliottScenarioSet> {
private final ElliottSwingIndicator swingIndicator;
private final ElliottChannelIndicator channelIndicator;
private final ElliottScenarioGenerator generator;
private final ElliottSwingCompressor compressor;
private final int scenarioSwingWindow;
/**
* Creates a scenario indicator with the supplied dependencies.
*
* @param series bar series
* @param swingIndicator swing detector indicator
* @param channelIndicator channel indicator for scoring
* @param generator scenario generator
* @param compressor optional swing compressor
* @param scenarioSwingWindow max number of swings to score
*/
private ScenarioSetIndicator(final BarSeries series, final ElliottSwingIndicator swingIndicator,
final ElliottChannelIndicator channelIndicator, final ElliottScenarioGenerator generator,
final ElliottSwingCompressor compressor, final int scenarioSwingWindow) {
super(series);
this.swingIndicator = Objects.requireNonNull(swingIndicator, "swingIndicator");
this.channelIndicator = Objects.requireNonNull(channelIndicator, "channelIndicator");
this.generator = Objects.requireNonNull(generator, "generator");
this.compressor = compressor;
this.scenarioSwingWindow = scenarioSwingWindow;
}
/**
* Computes the scenario set for the provided index.
*
* @param index bar index
* @return scenario set for the index
*/
@Override
protected ElliottScenarioSet calculate(final int index) {
final BarSeries series = getBarSeries();
if (series.isEmpty()) {
throw new IllegalArgumentException("series cannot be empty");
}
int clampedIndex = Math.max(series.getBeginIndex(), Math.min(index, series.getEndIndex()));
List<ElliottSwing> swings = swingIndicator.getValue(clampedIndex);
if (compressor != null) {
swings = compressor.compress(swings);
}
if (swings.isEmpty()) {
return ElliottScenarioSet.empty(clampedIndex);
}
List<ElliottSwing> recent = swings;
if (scenarioSwingWindow > 0 && swings.size() > scenarioSwingWindow) {
recent = List.copyOf(swings.subList(swings.size() - scenarioSwingWindow, swings.size()));
}
return generator.generate(recent, swingIndicator.getDegree(), channelIndicator.getValue(clampedIndex),
clampedIndex);
}
/**
* @return the number of unstable bars for the underlying swing indicator
*/
@Override
public int getCountOfUnstableBars() {
return swingIndicator.getCountOfUnstableBars();
}
}
}
@@ -0,0 +1,155 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.indicators.helpers.DateTimeIndicator;
import org.ta4j.core.rules.HourOfDayRule;
import org.ta4j.core.strategy.named.NamedStrategy;
import java.util.ArrayList;
import java.util.List;
/**
* A trading strategy that enters and exits positions based on specific hours of
* the day.
*
* <p>
* This strategy uses {@link HourOfDayRule} to determine entry and exit signals
* based on the hour of the day (0-23) of each bar in the series. The strategy
* will enter a position when the bar's hour of the day matches the specified
* entry hour, and exit when it matches the specified exit hour.
*
* <p>
* The strategy name is automatically generated as
* {@code "HourOfDayStrategy_<entryHour>_<exitHour>"} (e.g.,
* "HourOfDayStrategy_9_17").
*
* <p>
* This strategy is useful for testing intraday trading patterns, such as market
* open/close effects or specific trading hours in different time zones.
*
* @since 0.19
*/
public class HourOfDayStrategy extends NamedStrategy {
static {
registerImplementation(HourOfDayStrategy.class);
}
/**
* Constructs a new HourOfDayStrategy with the specified entry and exit hours.
*
* @param series the bar series to analyze
* @param entryHour the hour of the day (0-23) to enter positions
* @param exitHour the hour of the day (0-23) to exit positions
* @throws IllegalArgumentException if series is null, if entryHour or exitHour
* is not in range 0-23, or if entryHour equals
* exitHour
*/
public HourOfDayStrategy(BarSeries series, int entryHour, int exitHour) {
super(NamedStrategy.buildLabel(HourOfDayStrategy.class, String.valueOf(entryHour), String.valueOf(exitHour)),
new HourOfDayRule(new DateTimeIndicator(series), entryHour),
new HourOfDayRule(new DateTimeIndicator(series), exitHour));
if (entryHour == exitHour) {
throw new IllegalArgumentException(
"Entry hour and exit hour must be different, but both were: " + entryHour);
}
}
/**
* Constructs a new HourOfDayStrategy from string parameters.
*
* <p>
* The parameters should be two strings representing the entry and exit hours of
* the day. Valid values are integers in the range 0-23 (inclusive).
*
* @param series the bar series to analyze
* @param params array containing [entryHour, exitHour] as strings
* @throws IllegalArgumentException if params is null, has fewer than 2
* elements, contains invalid hour values, or
* if entryHour equals exitHour
*/
public HourOfDayStrategy(BarSeries series, String... params) {
this(series, parseEntryHour(params), parseExitHour(params));
}
/**
* Builds all possible strategy permutations for all combinations of entry and
* exit hours.
*
* <p>
* This method generates strategies for all pairs of different hours of the day
* (24 * 23 = 552 total strategies). Strategies where the entry hour equals the
* exit hour are excluded. If any strategy construction fails, a warning is
* logged and that strategy is skipped.
*
* @param series the bar series to analyze
* @return a list of all valid HourOfDayStrategy permutations
*/
public static List<Strategy> buildAllStrategyPermutations(BarSeries series) {
List<String[]> permutations = new ArrayList<>();
for (int entryHour = 0; entryHour < 24; entryHour++) {
for (int exitHour = 0; exitHour < 24; exitHour++) {
if (entryHour != exitHour) {
permutations.add(new String[] { String.valueOf(entryHour), String.valueOf(exitHour) });
}
}
}
return NamedStrategy.buildAllStrategyPermutations(series, permutations, HourOfDayStrategy::new,
(params, error) -> {
String entry = params.length > 0 ? params[0] : "<missing>";
String exit = params.length > 1 ? params[1] : "<missing>";
LogManager.getLogger()
.warn("Failed to build strategy for entry hour {} and exit hour {} - {}", entry, exit,
error.getMessage());
});
}
private static int parseEntryHour(String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length < 1) {
throw new IllegalArgumentException(
"At least 2 parameters required (entryHour, exitHour), but got " + params.length);
}
try {
int hour = Integer.parseInt(params[0]);
if (hour < 0 || hour > 23) {
throw new IllegalArgumentException("Invalid entry hour value: '" + params[0]
+ "'. Valid values are integers in the range 0-23 (inclusive)");
}
return hour;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid entry hour value: '" + params[0]
+ "'. Valid values are integers in the range 0-23 (inclusive)", e);
}
}
private static int parseExitHour(String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length < 2) {
throw new IllegalArgumentException(
"At least 2 parameters required (entryHour, exitHour), but got " + params.length);
}
try {
int hour = Integer.parseInt(params[1]);
if (hour < 0 || hour > 23) {
throw new IllegalArgumentException("Invalid exit hour value: '" + params[1]
+ "'. Valid values are integers in the range 0-23 (inclusive)");
}
return hour;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid exit hour value: '" + params[1]
+ "'. Valid values are integers in the range 0-23 (inclusive)", e);
}
}
}
@@ -0,0 +1,112 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Indicator;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.macd.MACDHistogramMode;
import org.ta4j.core.indicators.macd.MACDVMomentumProfile;
import org.ta4j.core.indicators.macd.MACDVMomentumState;
import org.ta4j.core.indicators.macd.VolatilityNormalizedMACDIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.numeric.NumericIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* Strategy showing the volatility-normalized MACD-V workflow:
*
* <ul>
* <li>custom signal-line injection ({@link SMAIndicator})</li>
* <li>histogram polarity configuration</li>
* <li>momentum-state filtering via {@link MACDVMomentumProfile}</li>
* </ul>
*/
public class MACDVMomentumStateStrategy {
private static final Logger LOG = LogManager.getLogger(MACDVMomentumStateStrategy.class);
private static final int FAST_EMA = 12;
private static final int SLOW_EMA = 26;
private static final int ATR_PERIOD = 26;
private static final int SIGNAL_PERIOD = 9;
private static final MACDVMomentumProfile MOMENTUM_PROFILE = new MACDVMomentumProfile(25, 80, -25, -80);
/**
* @param series bar series
* @return strategy based on volatility-normalized MACD-V momentum-state helpers
*/
public static Strategy buildStrategy(BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
VolatilityNormalizedMACDIndicator macdV = new VolatilityNormalizedMACDIndicator(closePrice, FAST_EMA, SLOW_EMA,
ATR_PERIOD, SIGNAL_PERIOD, 100);
NumericIndicator histogram = macdV.getHistogram(SIGNAL_PERIOD, SMAIndicator::new,
MACDHistogramMode.MACD_MINUS_SIGNAL);
Rule bullishMomentum = macdV.inMomentumState(MOMENTUM_PROFILE, MACDVMomentumState.RALLYING_OR_RETRACING)
.or(macdV.inMomentumState(MOMENTUM_PROFILE, MACDVMomentumState.HIGH_RISK));
Rule bearishMomentum = macdV.inMomentumState(MOMENTUM_PROFILE, MACDVMomentumState.REBOUNDING_OR_REVERSING)
.or(macdV.inMomentumState(MOMENTUM_PROFILE, MACDVMomentumState.LOW_RISK));
Rule entryRule = macdV.crossedUpSignal(SIGNAL_PERIOD, SMAIndicator::new)
.and(new OverIndicatorRule(histogram, 0))
.and(bullishMomentum);
Rule exitRule = macdV.crossedDownSignal(SIGNAL_PERIOD, SMAIndicator::new)
.or(new UnderIndicatorRule(histogram, 0))
.or(bearishMomentum);
return new BaseStrategy(MACDVMomentumStateStrategy.class.getSimpleName(), entryRule, exitRule,
macdV.getCountOfUnstableBars());
}
public static void main(String[] args) {
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
Strategy strategy = buildStrategy(series);
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
Num grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s gross return: {}", strategy.getName(), grossReturn);
VolatilityNormalizedMACDIndicator macdV = new VolatilityNormalizedMACDIndicator(series, FAST_EMA, SLOW_EMA,
ATR_PERIOD, SIGNAL_PERIOD, 100);
Indicator<Num> signal = macdV.getSignalLine(SIGNAL_PERIOD, SMAIndicator::new);
NumericIndicator histogram = macdV.getHistogram(SIGNAL_PERIOD, SMAIndicator::new,
MACDHistogramMode.MACD_MINUS_SIGNAL);
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withSubChart(macdV)
.withIndicatorOverlay(signal)
.withSubChart(histogram)
.withAnalysisCriterionOverlay(new GrossReturnCriterion(), tradingRecord)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "macdv-momentum-state-strategy", "temp/charts");
}
}
@@ -0,0 +1,156 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.indicators.helpers.DateTimeIndicator;
import org.ta4j.core.rules.MinuteOfHourRule;
import org.ta4j.core.strategy.named.NamedStrategy;
import java.util.ArrayList;
import java.util.List;
/**
* A trading strategy that enters and exits positions based on specific minutes
* of the hour.
*
* <p>
* This strategy uses {@link MinuteOfHourRule} to determine entry and exit
* signals based on the minute of the hour (0-59) of each bar in the series. The
* strategy will enter a position when the bar's minute of the hour matches the
* specified entry minute, and exit when it matches the specified exit minute.
*
* <p>
* The strategy name is automatically generated as
* {@code "MinuteOfHourStrategy_<entryMinute>_<exitMinute>"} (e.g.,
* "MinuteOfHourStrategy_15_45").
*
* <p>
* This strategy is useful for testing intraday trading patterns at a finer
* granularity, such as specific minute-based entry/exit points within an hour.
*
* @since 0.19
*/
public class MinuteOfHourStrategy extends NamedStrategy {
static {
registerImplementation(MinuteOfHourStrategy.class);
}
/**
* Constructs a new MinuteOfHourStrategy with the specified entry and exit
* minutes.
*
* @param series the bar series to analyze
* @param entryMinute the minute of the hour (0-59) to enter positions
* @param exitMinute the minute of the hour (0-59) to exit positions
* @throws IllegalArgumentException if series is null, if entryMinute or
* exitMinute is not in range 0-59, or if
* entryMinute equals exitMinute
*/
public MinuteOfHourStrategy(BarSeries series, int entryMinute, int exitMinute) {
super(NamedStrategy.buildLabel(MinuteOfHourStrategy.class, String.valueOf(entryMinute),
String.valueOf(exitMinute)), new MinuteOfHourRule(new DateTimeIndicator(series), entryMinute),
new MinuteOfHourRule(new DateTimeIndicator(series), exitMinute));
if (entryMinute == exitMinute) {
throw new IllegalArgumentException(
"Entry minute and exit minute must be different, but both were: " + entryMinute);
}
}
/**
* Constructs a new MinuteOfHourStrategy from string parameters.
*
* <p>
* The parameters should be two strings representing the entry and exit minutes
* of the hour. Valid values are integers in the range 0-59 (inclusive).
*
* @param series the bar series to analyze
* @param params array containing [entryMinute, exitMinute] as strings
* @throws IllegalArgumentException if params is null, has fewer than 2
* elements, contains invalid minute values, or
* if entryMinute equals exitMinute
*/
public MinuteOfHourStrategy(BarSeries series, String... params) {
this(series, parseEntryMinute(params), parseExitMinute(params));
}
/**
* Builds all possible strategy permutations for all combinations of entry and
* exit minutes.
*
* <p>
* This method generates strategies for all pairs of different minutes of the
* hour (60 * 59 = 3540 total strategies). Strategies where the entry minute
* equals the exit minute are excluded. If any strategy construction fails, a
* warning is logged and that strategy is skipped.
*
* @param series the bar series to analyze
* @return a list of all valid MinuteOfHourStrategy permutations
*/
public static List<Strategy> buildAllStrategyPermutations(BarSeries series) {
List<String[]> permutations = new ArrayList<>();
for (int entryMinute = 0; entryMinute < 60; entryMinute++) {
for (int exitMinute = 0; exitMinute < 60; exitMinute++) {
if (entryMinute != exitMinute) {
permutations.add(new String[] { String.valueOf(entryMinute), String.valueOf(exitMinute) });
}
}
}
return NamedStrategy.buildAllStrategyPermutations(series, permutations, MinuteOfHourStrategy::new,
(params, error) -> {
String entry = params.length > 0 ? params[0] : "<missing>";
String exit = params.length > 1 ? params[1] : "<missing>";
LogManager.getLogger()
.warn("Failed to build strategy for entry minute {} and exit minute {} - {}", entry, exit,
error.getMessage());
});
}
private static int parseEntryMinute(String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length < 1) {
throw new IllegalArgumentException(
"At least 2 parameters required (entryMinute, exitMinute), but got " + params.length);
}
try {
int minute = Integer.parseInt(params[0]);
if (minute < 0 || minute > 59) {
throw new IllegalArgumentException("Invalid entry minute value: '" + params[0]
+ "'. Valid values are integers in the range 0-59 (inclusive)");
}
return minute;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid entry minute value: '" + params[0]
+ "'. Valid values are integers in the range 0-59 (inclusive)", e);
}
}
private static int parseExitMinute(String... params) {
if (params == null) {
throw new IllegalArgumentException("Params cannot be null");
}
if (params.length < 2) {
throw new IllegalArgumentException(
"At least 2 parameters required (entryMinute, exitMinute), but got " + params.length);
}
try {
int minute = Integer.parseInt(params[1]);
if (minute < 0 || minute > 59) {
throw new IllegalArgumentException("Invalid exit minute value: '" + params[1]
+ "'. Valid values are integers in the range 0-59 (inclusive)");
}
return minute;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid exit minute value: '" + params[1]
+ "'. Valid values are integers in the range 0-59 (inclusive)", e);
}
}
}
@@ -0,0 +1,114 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.MACDIndicator;
import org.ta4j.core.indicators.StochasticOscillatorKIndicator;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* Moving momentum strategy.
*
* @see <a href=
* "http://stockcharts.com/help/doku.php?id=chart_school:trading_strategies:moving_momentum">
* http://stockcharts.com/help/doku.php?id=chart_school:trading_strategies:moving_momentum</a>
*/
public class MovingMomentumStrategy {
private static final Logger LOG = LogManager.getLogger(MovingMomentumStrategy.class);
/**
* @param series the bar series
* @return the moving momentum strategy
*/
public static Strategy buildStrategy(BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// The bias is bullish when the shorter-moving average moves above the longer
// moving average.
// The bias is bearish when the shorter-moving average moves below the longer
// moving average.
EMAIndicator shortEma = new EMAIndicator(closePrice, 9);
EMAIndicator longEma = new EMAIndicator(closePrice, 26);
StochasticOscillatorKIndicator stochasticOscillK = new StochasticOscillatorKIndicator(series, 14);
MACDIndicator macd = new MACDIndicator(closePrice, 9, 26);
EMAIndicator emaMacd = new EMAIndicator(macd, 18);
// Entry rule
Rule entryRule = new OverIndicatorRule(shortEma, longEma) // Trend
.and(new CrossedDownIndicatorRule(stochasticOscillK, 20)) // Signal 1
.and(new OverIndicatorRule(macd, emaMacd)); // Signal 2
// Exit rule
Rule exitRule = new UnderIndicatorRule(shortEma, longEma) // Trend
.and(new CrossedUpIndicatorRule(stochasticOscillK, 80)) // Signal 1
.and(new UnderIndicatorRule(macd, emaMacd)); // Signal 2
String strategyName = "MovingMomentumStrategy";
return new BaseStrategy(strategyName, entryRule, exitRule);
}
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
// Analysis
var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s gross return: {}", strategy.getName(), grossReturn);
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
EMAIndicator shortEma = new EMAIndicator(closePrice, 9);
EMAIndicator longEma = new EMAIndicator(closePrice, 26);
MACDIndicator macd = new MACDIndicator(closePrice, 9, 26);
EMAIndicator emaMacd = new EMAIndicator(macd, 18);
StochasticOscillatorKIndicator stochasticOscillK = new StochasticOscillatorKIndicator(series, 14);
// Charting
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withIndicatorOverlay(shortEma)
.withIndicatorOverlay(longEma)
.withSubChart(macd)
.withIndicatorOverlay(emaMacd)
.withSubChart(stochasticOscillK)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "moving-momentum-strategy", "temp/charts");
}
}
@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.*;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.indicators.NetMomentumIndicator;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import java.awt.Color;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
public class NetMomentumStrategy {
private static final Logger LOG = LogManager.getLogger(NetMomentumStrategy.class);
private static final int DEFAULT_OVERBOUGHT_THRESHOLD = 900;
private static final int DEFAULT_MOMENTUM_TIMEFRAME = 200;
private static final int DEFAULT_OVERSOLD_THRESHOLD = -200;
private static final int DEFAULT_RSI_BARCOUNT = 14;
private static final double DEFAULT_DECAY_FACTOR = 1;
public static void main(String[] args) {
String jsonOhlcResourceFile = "Coinbase-ETH-USD-PT1D-20160517_20251028.json";
BarSeries series = null;
try (InputStream resourceStream = NetMomentumStrategy.class.getClassLoader()
.getResourceAsStream(jsonOhlcResourceFile)) {
series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resourceStream);
} catch (IOException ex) {
LOG.error("IOException while loading resource: {} - {}", jsonOhlcResourceFile, ex.getMessage());
}
Objects.requireNonNull(series, "Bar series was null");
// Running the strategy
runSingleStrategy(series);
}
private static void runSingleStrategy(BarSeries series) {
BarSeriesManager seriesManager = new BarSeriesManager(series);
ClosePriceIndicator closePriceIndicator = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePriceIndicator, DEFAULT_RSI_BARCOUNT);
NetMomentumIndicator rsiM = NetMomentumIndicator.forRsiWithDecay(rsiIndicator, DEFAULT_MOMENTUM_TIMEFRAME,
DEFAULT_DECAY_FACTOR);
Strategy strategy = createStrategy(rsiM);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
var netProfitLoss = new NetProfitLossCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s net profit/loss: {}", strategy.getName(), netProfitLoss);
// Charting
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withAnalysisCriterionOverlay(new NetProfitCriterion(), tradingRecord)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50)
.withLineColor(Color.GRAY)
.withOpacity(0.3f)
.withHorizontalMarker(70)
.withLineColor(Color.RED)
.withOpacity(0.3f)
.withHorizontalMarker(30)
.withLineColor(Color.GREEN)
.withOpacity(0.3f)
.withSubChart(rsiM)
.withHorizontalMarker(0)
.withLineColor(Color.GRAY)
.withOpacity(0.3f)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "net-momentum-strategy", "temp/charts");
}
private static Strategy createStrategy(NetMomentumIndicator rsiM) {
Rule entryRule = new CrossedUpIndicatorRule(rsiM, DEFAULT_OVERSOLD_THRESHOLD);
Rule exitRule = new CrossedDownIndicatorRule(rsiM, DEFAULT_OVERBOUGHT_THRESHOLD);
return new BaseStrategy(NetMomentumStrategy.class.getSimpleName(), entryRule, exitRule);
}
}
@@ -0,0 +1,107 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* 2-Period RSI Strategy
*
* @see <a href=
* "http://stockcharts.com/school/doku.php?id=chart_school:trading_strategies:rsi2">
* http://stockcharts.com/school/doku.php?id=chart_school:trading_strategies:rsi2</a>
*/
public class RSI2Strategy {
private static final Logger LOG = LogManager.getLogger(RSI2Strategy.class);
/**
* @param series a bar series
* @return a 2-period RSI strategy
*/
public static Strategy buildStrategy(BarSeries series) {
if (series == null) {
throw new IllegalArgumentException("Series cannot be null");
}
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
SMAIndicator shortSma = new SMAIndicator(closePrice, 5);
SMAIndicator longSma = new SMAIndicator(closePrice, 200);
// We use a 2-period RSI indicator to identify buying
// or selling opportunities within the bigger trend.
RSIIndicator rsi = new RSIIndicator(closePrice, 2);
// Entry rule
// The long-term trend is up when a security is above its 200-period SMA.
Rule entryRule = new OverIndicatorRule(shortSma, longSma) // Trend
.and(new CrossedDownIndicatorRule(rsi, 5)) // Signal 1
.and(new OverIndicatorRule(shortSma, closePrice)); // Signal 2
// Exit rule
// The long-term trend is down when a security is below its 200-period SMA.
Rule exitRule = new UnderIndicatorRule(shortSma, longSma) // Trend
.and(new CrossedUpIndicatorRule(rsi, 95)) // Signal 1
.and(new UnderIndicatorRule(shortSma, closePrice)); // Signal 2
String strategyName = "RSI2Strategy";
return new BaseStrategy(strategyName, entryRule, exitRule);
}
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug(() -> strategy.toJson());
LOG.debug("{}'s number of positions: {}", strategy.getName(), tradingRecord.getPositionCount());
// Analysis
var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("{}'s gross return: {}", strategy.getName(), grossReturn);
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
SMAIndicator shortSma = new SMAIndicator(closePrice, 5);
SMAIndicator longSma = new SMAIndicator(closePrice, 200);
RSIIndicator rsiOverlay = new RSIIndicator(closePrice, 2);
// Charting
ChartWorkflow chartWorkflow = new ChartWorkflow();
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withIndicatorOverlay(shortSma)
.withIndicatorOverlay(longSma)
.withAnalysisCriterionOverlay(new GrossReturnCriterion(), tradingRecord)
.withSubChart(rsiOverlay)
.toChart();
chartWorkflow.displayChart(chart);
chartWorkflow.saveChartImage(chart, series, "rsi2-strategy", "temp/charts");
}
}
@@ -0,0 +1,90 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import java.time.Duration;
import java.time.Instant;
import java.util.stream.Stream;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Indicator;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.indicators.helpers.UnstableIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
public class UnstableIndicatorStrategy {
private static final Logger LOG = LogManager.getLogger(UnstableIndicatorStrategy.class);
public static final Duration MINUTE = Duration.ofMinutes(1);
public static final Instant TIME = Instant.parse("2020-01-01T00:00:00Z");
public static Strategy buildStrategy(BarSeries series) {
ClosePriceIndicator close = new ClosePriceIndicator(series);
int smaPeriod = 3;
Indicator<Num> sma = new UnstableIndicator(new SMAIndicator(close, smaPeriod), smaPeriod - 1);
Rule entryRule = new CrossedUpIndicatorRule(close, sma);
Rule exitRule = new CrossedDownIndicatorRule(close, sma);
BaseStrategy strategy = new BaseStrategy(entryRule, exitRule);
strategy.setUnstableBars(3);
return strategy;
}
public static void main(String[] args) {
inappropriateTrade();
appropriateTrade();
}
public static void inappropriateTrade() {
// Should not trade
test("Inappropriate trade", Stream.of(10d, 2d, 6d, 16d, 8d));
}
public static void appropriateTrade() {
// Should trade
test("Appropriate trade", Stream.of(10d, 8d, 6d, 16d, 8d));
}
public static void test(String name, Stream<Double> closePrices) {
// Getting the bar series
BarSeries series = new BaseBarSeriesBuilder().build();
Instant[] currentTime = { TIME };
closePrices.forEach(close -> {
series.barBuilder()
.timePeriod(MINUTE)
.endTime(currentTime[0])
.openPrice(0)
.closePrice(close)
.highPrice(0)
.lowPrice(0)
.add();
currentTime[0] = currentTime[0].plus(MINUTE);
});
// Building the trading strategy
Strategy strategy = buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
LOG.debug("{} {}", name, tradingRecord.getPositions());
}
}
@@ -0,0 +1,135 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.walkforward;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.backtest.BacktestExecutor;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.backtest.StrategyWalkForwardExecutionResult;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.num.Num;
import org.ta4j.core.walkforward.WalkForwardConfig;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.strategies.CCICorrectionStrategy;
import ta4jexamples.strategies.GlobalExtremaStrategy;
import ta4jexamples.strategies.MovingMomentumStrategy;
import ta4jexamples.strategies.RSI2Strategy;
/**
* Walk-forward example using ta4j-core walk-forward APIs.
*
* <p>
* This example evaluates several strategies on one {@link BarSeries} using
* {@link BarSeriesManager#runWalkForward(Strategy, org.ta4j.core.Trade.TradeType, Num, WalkForwardConfig)}
* and ranks them by average out-of-sample gross return. It then demonstrates
* the symmetric one-call API through
* {@link BacktestExecutor#executeWithWalkForward(Strategy, WalkForwardConfig)}.
* </p>
*
* <p>
* The walk-forward configuration is generated from the series using
* {@link WalkForwardConfig#defaultConfig(BarSeries)}.
* </p>
*
* @since 0.22.4
* @see <a href="http://en.wikipedia.org/wiki/Walk_forward_optimization">
* http://en.wikipedia.org/wiki/Walk_forward_optimization</a>
*/
public class WalkForward {
private static final Logger LOG = LogManager.getLogger(WalkForward.class);
/**
* @param series the bar series
* @return a map (key: strategy, value: name) of trading strategies
*/
public static Map<Strategy, String> buildStrategiesMap(BarSeries series) {
LinkedHashMap<Strategy, String> strategies = new LinkedHashMap<>();
strategies.put(CCICorrectionStrategy.buildStrategy(series), "CCI Correction");
strategies.put(GlobalExtremaStrategy.buildStrategy(series), "Global Extrema");
strategies.put(MovingMomentumStrategy.buildStrategy(series), "Moving Momentum");
strategies.put(RSI2Strategy.buildStrategy(series), "RSI-2");
return strategies;
}
private static Num average(List<Num> values, Num fallback) {
if (values.isEmpty()) {
return fallback;
}
Num sum = values.getFirst().getNumFactory().zero();
for (Num value : values) {
sum = sum.plus(value);
}
return sum.dividedBy(values.getFirst().getNumFactory().numOf(values.size()));
}
private static Strategy chooseBest(Map<Strategy, Num> strategyScores, AnalysisCriterion criterion) {
Strategy bestStrategy = null;
Num bestScore = null;
for (Map.Entry<Strategy, Num> entry : strategyScores.entrySet()) {
if (bestStrategy == null) {
bestStrategy = entry.getKey();
bestScore = entry.getValue();
continue;
}
Num candidateScore = entry.getValue();
if (bestScore != null && criterion.betterThan(candidateScore, bestScore)) {
bestStrategy = entry.getKey();
bestScore = candidateScore;
}
}
return bestStrategy;
}
public static void main(String[] args) {
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
WalkForwardConfig config = WalkForwardConfig.defaultConfig(series);
Map<Strategy, String> strategies = buildStrategiesMap(series);
AnalysisCriterion returnCriterion = new GrossReturnCriterion();
BarSeriesManager manager = new BarSeriesManager(series);
LOG.info("Running walk-forward on {} bars with config hash {}", series.getBarCount(), config.configHash());
Map<Strategy, Num> strategyOutOfSampleScores = new LinkedHashMap<>();
for (Map.Entry<Strategy, String> entry : strategies.entrySet()) {
Strategy strategy = entry.getKey();
String strategyName = entry.getValue();
StrategyWalkForwardExecutionResult walkForwardResult = manager.runWalkForward(strategy, config);
List<Num> outOfSampleScores = walkForwardResult.outOfSampleCriterionValues(returnCriterion);
Num averageOutOfSampleScore = average(outOfSampleScores, series.numFactory().one());
strategyOutOfSampleScores.put(strategy, averageOutOfSampleScore);
LOG.info("{} -> avg OOS gross return: {} (folds={}, holdoutPresent={})", strategyName,
averageOutOfSampleScore, walkForwardResult.folds().size(),
walkForwardResult.holdoutCriterionValue(returnCriterion).isPresent());
}
Strategy bestStrategy = chooseBest(strategyOutOfSampleScores, returnCriterion);
if (bestStrategy == null) {
LOG.warn("No best strategy selected from walk-forward results.");
return;
}
String bestStrategyName = strategies.get(bestStrategy);
LOG.info("Best walk-forward strategy by avg OOS gross return: {}", bestStrategyName);
BacktestExecutor executor = new BacktestExecutor(series);
BacktestExecutor.BacktestAndWalkForwardResult combined = executor.executeWithWalkForward(bestStrategy, config);
Num backtestGrossReturn = returnCriterion.calculate(series,
combined.backtest().tradingStatements().getFirst().getTradingRecord());
Num combinedOutOfSampleAverage = average(combined.walkForward().outOfSampleCriterionValues(returnCriterion),
series.numFactory().one());
LOG.info("Combined run for {} -> backtest gross return={}, walk-forward avg OOS gross return={}",
bestStrategyName, backtestGrossReturn, combinedOutOfSampleAverage);
}
}
@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.wyckoff;
import java.util.Locale;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.wyckoff.WyckoffCycleAnalysisRunner;
import org.ta4j.core.indicators.wyckoff.WyckoffCycleFacade;
import org.ta4j.core.indicators.wyckoff.WyckoffCycleType;
import org.ta4j.core.indicators.wyckoff.WyckoffPhase;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
/**
* Demonstrates Wyckoff cycle indicator-suite and one-shot analysis entry
* points.
*/
public final class WyckoffCycleIndicatorSuiteDemo {
private static final Logger LOG = LogManager.getLogger(WyckoffCycleIndicatorSuiteDemo.class);
/**
* Creates a new WyckoffCycleIndicatorSuiteDemo instance.
*/
private WyckoffCycleIndicatorSuiteDemo() {
}
/**
* Executes the demo entry point.
*/
public static void main(String[] args) {
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
var numFactory = series.numFactory();
WyckoffCycleFacade facade = WyckoffCycleFacade.builder(series)
.withSwingConfiguration(3, 3, 1)
.withVolumeWindows(5, 20)
.withTolerances(numFactory.numOf(0.02), numFactory.numOf(0.05))
.withVolumeThresholds(numFactory.numOf(1.5), numFactory.numOf(0.7))
.build();
int begin = series.getBeginIndex() + facade.unstableBars();
for (int i = begin; i <= series.getEndIndex(); i++) {
WyckoffPhase phase = facade.phase(i);
if (phase.cycleType() == WyckoffCycleType.UNKNOWN) {
continue;
}
if (facade.lastPhaseTransitionIndex(i) != i) {
continue;
}
var bar = series.getBar(i);
var low = facade.tradingRangeLow(i);
var high = facade.tradingRangeHigh(i);
LOG.info("{} -> {} {} (confidence={}, range=[{}, {}])", bar.getEndTime(), phase.cycleType(),
phase.phaseType(), String.format(Locale.US, "%.2f", phase.confidence()), low, high);
}
var analysis = WyckoffCycleAnalysisRunner.builder()
.withSwingConfiguration(3, 3, 1)
.withVolumeWindows(5, 20)
.withTolerances(0.02, 0.05)
.withVolumeThresholds(1.5, 0.7)
.build();
var result = analysis.analyze(series);
result.baseAnalysis()
.ifPresent(base -> LOG.info("One-shot analysis transitions: {}",
base.cycleSnapshot().transitions().size()));
}
}
@@ -0,0 +1,253 @@
date,open,high,low,close,volume
2013-01-02,553.82,555.00,541.63,549.03,20018500
2013-01-03,547.88,549.67,541.00,542.10,12605900
2013-01-04,536.97,538.63,525.83,527.00,21226200
2013-01-07,522.00,529.30,515.20,523.90,17291300
2013-01-08,529.21,531.89,521.25,525.31,16382400
2013-01-09,522.50,525.01,515.99,517.10,14557300
2013-01-10,528.55,528.72,515.52,523.51,21469500
2013-01-11,521.00,525.32,519.02,520.30,12518100
2013-01-14,502.68,507.50,498.51,501.75,26221700
2013-01-15,498.30,498.99,483.38,485.92,31313300
2013-01-16,494.64,509.44,492.50,506.09,24671600
2013-01-17,510.31,510.75,502.03,502.68,16202800
2013-01-18,498.52,502.22,496.40,500.00,16890100
2013-01-22,504.56,507.88,496.63,504.77,16483800
2013-01-23,508.81,514.99,504.77,514.01,30768200
2013-01-24,460.00,465.73,450.25,450.50,52173300
2013-01-25,451.69,456.23,435.00,439.88,43143800
2013-01-28,437.83,453.21,435.86,449.83,28054200
2013-01-29,458.50,460.20,452.12,458.27,20398500
2013-01-30,457.00,462.60,454.50,456.83,14898400
2013-01-31,456.98,459.28,454.98,455.49,11404800
2013-02-01,459.11,459.48,448.35,453.62,19267300
2013-02-04,453.91,455.94,442.00,442.32,17039900
2013-02-05,444.05,459.74,442.22,457.84,20476700
2013-02-06,456.47,466.50,452.58,457.35,21203800
2013-02-07,463.25,470.00,454.12,468.22,25163600
2013-02-08,474.00,478.81,468.25,474.98,22612800
2013-02-11,476.50,484.94,473.25,479.93,18481800
2013-02-12,479.51,482.38,467.74,467.90,21751900
2013-02-13,467.21,473.64,463.22,467.01,16971700
2013-02-14,464.52,471.64,464.02,466.59,12688400
2013-02-15,468.85,470.16,459.92,460.16,13990900
2013-02-19,461.10,462.73,453.85,459.99,15563700
2013-02-20,457.69,457.69,448.80,448.85,17010800
2013-02-21,446.00,449.17,442.82,446.06,15970800
2013-02-22,449.25,451.60,446.60,450.81,11809100
2013-02-25,453.85,455.12,442.57,442.80,13306400
2013-02-26,443.82,451.54,437.66,448.97,17910700
2013-02-27,448.43,452.44,440.65,444.57,20976800
2013-02-28,444.05,447.87,441.40,441.40,11518400
2013-03-01,438.00,438.18,429.98,430.47,19730300
2013-03-04,427.80,428.20,419.00,420.05,20812700
2013-03-05,421.48,435.19,420.75,431.14,22801200
2013-03-06,434.51,435.25,424.43,425.66,16437500
2013-03-07,424.50,432.01,421.06,430.58,16731200
2013-03-08,429.80,435.43,428.61,431.72,13981500
2013-03-11,429.75,439.01,425.14,437.87,16937000
2013-03-12,435.60,438.88,427.57,428.43,16639700
2013-03-13,428.45,434.50,425.36,428.35,14483900
2013-03-14,432.83,434.64,430.45,432.50,10852700
2013-03-15,437.93,444.23,437.25,443.66,22998600
2013-03-18,441.45,457.46,441.20,455.72,21649900
2013-03-19,459.50,460.97,448.50,454.49,18813400
2013-03-20,457.42,457.63,449.59,452.08,11023600
2013-03-21,450.22,457.98,450.10,452.73,13687700
2013-03-22,454.58,462.10,453.11,461.91,14110900
2013-03-25,464.69,469.95,461.78,463.58,17897700
2013-03-26,465.44,465.84,460.53,461.14,10510500
2013-03-27,456.46,456.80,450.73,452.08,11829900
2013-03-28,449.82,451.82,441.62,442.66,15815700
2013-04-01,441.90,443.70,427.74,428.91,13919000
2013-04-02,427.60,438.14,426.40,429.79,18911400
2013-04-03,431.37,437.28,430.31,431.99,12972000
2013-04-04,433.76,435.00,425.25,427.72,12801700
2013-04-05,424.50,424.95,419.68,423.20,13703400
2013-04-08,424.85,427.50,422.49,426.21,10743900
2013-04-09,426.36,428.50,422.75,426.98,10950500
2013-04-10,428.10,437.06,426.01,435.69,13426000
2013-04-11,433.72,437.99,431.20,434.33,11727300
2013-04-12,434.15,434.15,429.09,429.80,8521900
2013-04-15,427.00,427.89,419.55,419.85,11340000
2013-04-16,421.57,426.61,420.57,426.24,10920400
2013-04-17,420.27,420.60,398.11,402.80,33752000
2013-04-18,404.99,405.79,389.74,392.05,23796400
2013-04-19,387.97,399.60,385.10,390.53,21759800
2013-04-22,392.64,402.20,391.27,398.67,15354300
2013-04-23,403.99,408.38,398.81,406.13,23722800
2013-04-24,393.54,415.25,392.50,405.46,34630400
2013-04-25,411.23,413.94,407.00,408.38,13744200
2013-04-26,409.81,418.77,408.25,417.20,27289200
2013-04-29,420.45,433.62,420.00,430.12,22868800
2013-04-30,435.10,445.25,432.07,442.78,24697800
2013-05-01,444.46,444.93,434.39,439.29,18103900
2013-05-02,441.78,448.59,440.63,445.52,15065300
2013-05-03,451.31,453.23,449.15,449.98,12903600
2013-05-06,455.71,462.20,454.31,460.71,17737200
2013-05-07,464.97,465.75,453.70,458.66,17276900
2013-05-08,459.04,465.37,455.81,463.84,16878500
2013-05-09,459.81,463.00,455.58,456.77,14231700
2013-05-10,457.97,459.71,450.48,452.97,11959000
2013-05-13,451.51,457.90,451.50,454.74,11319600
2013-05-14,453.85,455.20,442.15,443.86,15968500
2013-05-15,439.16,441.00,422.36,428.85,26486200
2013-05-16,423.24,437.85,418.90,434.58,21543000
2013-05-17,439.05,440.09,431.01,433.26,15282300
2013-05-20,431.91,445.80,430.10,442.93,16127800
2013-05-21,438.15,445.48,434.20,439.66,16286500
2013-05-22,444.05,448.35,438.22,441.35,15822800
2013-05-23,435.95,446.16,435.79,442.14,12607900
2013-05-24,440.85,445.66,440.36,445.15,9863100
2013-05-28,449.90,451.11,440.85,441.44,13790900
2013-05-29,440.00,447.50,439.40,444.95,11806300
2013-05-30,445.65,454.50,444.51,451.58,12625700
2013-05-31,452.50,457.10,449.50,449.73,13725100
2013-06-03,450.73,452.36,442.48,450.72,13298300
2013-06-04,453.22,454.43,447.39,449.31,10454600
2013-06-05,445.65,450.72,443.71,445.11,10378200
2013-06-06,445.47,447.00,434.05,438.46,14890500
2013-06-07,436.50,443.24,432.77,441.81,14447700
2013-06-10,444.73,449.08,436.80,438.89,16076900
2013-06-11,435.74,442.76,433.32,437.60,10218300
2013-06-12,439.50,441.25,431.50,432.19,9472400
2013-06-13,432.50,437.14,428.75,435.96,10208300
2013-06-14,435.40,436.29,428.50,430.05,9709500
2013-06-17,431.44,435.70,430.36,432.00,9264800
2013-06-18,431.56,434.90,430.21,431.77,6965200
2013-06-19,431.40,431.66,423.00,423.00,11105000
2013-06-20,419.30,425.98,415.17,416.84,12761100
2013-06-21,418.49,420.00,408.10,413.50,17182800
2013-06-24,407.40,408.66,398.05,402.54,17169500
2013-06-25,405.70,407.79,398.83,402.63,11220100
2013-06-26,403.90,404.79,395.66,398.07,13133000
2013-06-27,399.25,401.39,393.54,393.78,12044500
2013-06-28,391.36,400.27,388.87,396.53,20661300
2013-07-01,402.69,412.27,401.22,409.22,13966200
2013-07-02,409.96,421.63,409.47,418.49,16780900
2013-07-03,420.86,422.98,417.45,420.80,8604600
2013-07-05,420.39,423.29,415.35,417.42,9786600
2013-07-08,420.11,421.00,410.65,415.05,10647800
2013-07-09,413.60,423.50,410.38,422.35,12592300
2013-07-10,419.60,424.80,418.25,420.73,10050200
2013-07-11,422.95,428.25,421.17,427.29,11653300
2013-07-12,427.65,429.79,423.41,426.51,9984400
2013-07-15,425.01,431.46,424.80,427.44,8639900
2013-07-16,426.52,430.71,424.17,430.20,7733500
2013-07-17,429.70,432.22,428.22,430.31,7106800
2013-07-18,433.38,434.87,430.61,431.76,7817100
2013-07-19,433.10,433.98,424.35,424.95,9597200
2013-07-22,429.46,429.75,425.47,426.31,7421300
2013-07-23,426.00,426.96,418.71,418.99,13192700
2013-07-24,438.93,444.59,435.26,440.51,21140600
2013-07-25,440.70,441.40,435.81,438.50,8196200
2013-07-26,435.30,441.04,434.34,440.99,7148300
2013-07-29,440.80,449.99,440.20,447.79,8859200
2013-07-30,449.96,457.15,449.23,453.32,11050800
2013-07-31,454.99,457.34,449.43,452.53,11534200
2013-08-01,455.75,456.80,453.26,456.68,7366100
2013-08-02,458.01,462.85,456.66,462.54,9813700
2013-08-05,464.69,470.67,462.15,469.45,11387700
2013-08-06,468.02,471.89,462.17,465.25,11959200
2013-08-07,463.80,467.00,461.77,464.98,10673500
2013-08-08,463.86,464.10,457.95,461.01,9134900
2013-08-09,458.64,460.46,453.65,454.45,9530900
2013-08-12,456.86,468.65,456.63,467.36,13015500
2013-08-13,470.94,494.66,468.05,489.57,31497900
2013-08-14,497.88,504.25,493.40,498.50,27013300
2013-08-15,496.42,502.40,489.08,497.91,17510500
2013-08-16,500.15,502.94,498.86,502.33,12939500
2013-08-19,504.34,513.74,504.00,507.74,18232800
2013-08-20,509.71,510.57,500.82,501.07,12810300
2013-08-21,503.59,507.15,501.20,502.36,11995700
2013-08-22,504.98,505.59,498.20,502.96,8721700
2013-08-23,503.27,503.35,499.35,501.02,7954700
2013-08-26,500.75,510.20,500.50,502.97,11820200
2013-08-27,498.00,502.51,486.30,488.59,15149600
2013-08-28,486.00,495.80,486.00,490.90,10986000
2013-08-29,491.65,496.50,491.13,491.70,8559200
2013-08-30,492.00,492.95,486.50,487.22,9724900
2013-09-03,493.10,500.60,487.35,488.58,11854600
2013-09-04,499.56,502.24,496.28,498.69,12322600
2013-09-05,500.25,500.68,493.64,495.27,8441700
2013-09-06,498.44,499.38,489.95,498.22,12840200
2013-09-09,505.00,507.92,503.48,506.17,12167400
2013-09-10,506.20,507.45,489.50,494.64,26542700
2013-09-11,467.01,473.69,464.81,467.71,32096300
2013-09-12,468.50,475.40,466.01,472.69,14430400
2013-09-13,469.34,471.83,464.70,464.90,10672700
2013-09-16,461.00,461.61,447.22,450.12,19418100
2013-09-17,447.96,459.71,447.50,455.32,14263600
2013-09-18,463.18,466.35,460.66,464.68,16316500
2013-09-19,470.70,475.83,469.25,472.30,14447900
2013-09-20,478.00,478.55,466.00,467.41,24975100
2013-09-23,496.10,496.91,482.60,490.64,27218100
2013-09-24,494.88,495.47,487.82,489.10,13012300
2013-09-25,489.20,489.64,481.43,481.53,11319900
2013-09-26,486.00,488.56,483.90,486.22,8472200
2013-09-27,483.78,484.67,480.72,482.75,8144300
2013-09-30,477.25,481.66,474.41,476.75,9291300
2013-10-01,478.45,489.14,478.38,487.96,12638700
2013-10-02,485.63,491.80,483.75,489.56,10328000
2013-10-03,490.51,492.35,480.74,483.41,11526900
2013-10-04,483.86,484.60,478.60,483.03,9245300
2013-10-07,486.56,492.65,485.35,487.75,11153300
2013-10-08,489.94,490.64,480.54,480.94,10389900
2013-10-09,484.64,487.79,478.28,486.59,10775900
2013-10-10,491.32,492.38,487.04,489.64,9950100
2013-10-11,486.99,493.84,485.16,492.81,9562100
2013-10-14,489.83,497.58,489.35,496.04,9353500
2013-10-15,497.51,502.00,495.52,498.68,11431200
2013-10-16,500.79,502.53,499.23,501.11,8967900
2013-10-17,499.98,504.78,499.68,504.50,9056900
2013-10-18,505.99,509.26,505.71,508.89,10376500
2013-10-21,511.77,524.30,511.52,521.36,14218100
2013-10-22,526.41,528.45,508.03,519.87,19073700
2013-10-23,519.00,525.67,519.00,524.96,11204400
2013-10-24,525.00,532.47,522.45,531.91,13741600
2013-10-25,531.32,533.23,525.11,525.96,12064000
2013-10-28,529.04,531.00,523.21,529.88,19658600
2013-10-29,536.27,539.25,514.54,516.68,22707400
2013-10-30,519.61,527.52,517.02,524.90,12648700
2013-10-31,525.00,527.49,521.27,522.70,9846300
2013-11-01,524.02,524.80,515.84,520.03,9817500
2013-11-04,521.10,526.82,518.81,526.75,8736700
2013-11-05,524.58,528.89,523.00,525.45,9471900
2013-11-06,524.15,524.86,518.20,520.92,7977700
2013-11-07,519.58,523.19,512.38,512.49,9379300
2013-11-08,514.58,521.13,512.59,520.56,9975600
2013-11-11,519.99,521.67,514.41,519.05,8123300
2013-11-12,517.67,523.92,517.00,520.01,7295600
2013-11-13,518.00,522.25,516.96,520.63,7043600
2013-11-14,522.81,529.28,521.87,528.16,10086400
2013-11-15,526.58,529.09,524.49,524.99,11354300
2013-11-18,524.99,527.19,518.20,518.63,8748000
2013-11-19,519.03,523.38,517.97,519.55,7462100
2013-11-20,519.23,520.42,514.33,515.00,6925600
2013-11-21,517.60,521.21,513.67,521.14,9358100
2013-11-22,519.52,522.16,518.53,519.80,7990200
2013-11-25,521.02,525.87,521.00,523.74,8189700
2013-11-26,524.12,536.14,524.00,533.40,14335100
2013-11-27,536.31,546.00,533.40,545.96,12980300
2013-11-29,549.48,558.33,547.81,556.07,11361700
2013-12-02,558.00,564.33,550.82,551.23,16876600
2013-12-03,558.30,566.38,557.68,566.32,16106000
2013-12-04,565.50,569.19,560.82,565.00,13478200
2013-12-05,572.65,575.14,566.41,567.90,15985000
2013-12-06,565.79,566.75,559.57,560.02,12298300
2013-12-09,560.90,569.58,560.90,566.43,11446200
2013-12-10,563.58,567.88,561.20,565.55,9938200
2013-12-11,567.00,570.97,559.69,561.36,12847100
2013-12-12,562.14,565.34,560.03,560.54,9367500
2013-12-13,562.85,562.88,553.67,554.43,11886500
2013-12-16,555.02,562.64,555.01,557.50,10092600
2013-12-17,555.81,559.44,553.38,554.99,8210800
2013-12-18,549.70,551.45,538.80,550.77,20209400
2013-12-19,549.50,550.00,543.73,544.46,11462800
2013-12-20,545.43,551.61,544.82,549.02,15586200
2013-12-23,568.00,570.72,562.76,570.09,17903800
2013-12-24,569.89,571.88,566.03,567.67,5984100
2013-12-26,568.10,569.50,563.38,563.90,7286000
2013-12-27,563.82,564.41,559.50,560.09,8067300
2013-12-30,557.46,560.09,552.32,554.52,9058200
2013-12-31,554.17,561.28,554.00,561.02,7967300
1 date open high low close volume
2 2013-01-02 553.82 555.00 541.63 549.03 20018500
3 2013-01-03 547.88 549.67 541.00 542.10 12605900
4 2013-01-04 536.97 538.63 525.83 527.00 21226200
5 2013-01-07 522.00 529.30 515.20 523.90 17291300
6 2013-01-08 529.21 531.89 521.25 525.31 16382400
7 2013-01-09 522.50 525.01 515.99 517.10 14557300
8 2013-01-10 528.55 528.72 515.52 523.51 21469500
9 2013-01-11 521.00 525.32 519.02 520.30 12518100
10 2013-01-14 502.68 507.50 498.51 501.75 26221700
11 2013-01-15 498.30 498.99 483.38 485.92 31313300
12 2013-01-16 494.64 509.44 492.50 506.09 24671600
13 2013-01-17 510.31 510.75 502.03 502.68 16202800
14 2013-01-18 498.52 502.22 496.40 500.00 16890100
15 2013-01-22 504.56 507.88 496.63 504.77 16483800
16 2013-01-23 508.81 514.99 504.77 514.01 30768200
17 2013-01-24 460.00 465.73 450.25 450.50 52173300
18 2013-01-25 451.69 456.23 435.00 439.88 43143800
19 2013-01-28 437.83 453.21 435.86 449.83 28054200
20 2013-01-29 458.50 460.20 452.12 458.27 20398500
21 2013-01-30 457.00 462.60 454.50 456.83 14898400
22 2013-01-31 456.98 459.28 454.98 455.49 11404800
23 2013-02-01 459.11 459.48 448.35 453.62 19267300
24 2013-02-04 453.91 455.94 442.00 442.32 17039900
25 2013-02-05 444.05 459.74 442.22 457.84 20476700
26 2013-02-06 456.47 466.50 452.58 457.35 21203800
27 2013-02-07 463.25 470.00 454.12 468.22 25163600
28 2013-02-08 474.00 478.81 468.25 474.98 22612800
29 2013-02-11 476.50 484.94 473.25 479.93 18481800
30 2013-02-12 479.51 482.38 467.74 467.90 21751900
31 2013-02-13 467.21 473.64 463.22 467.01 16971700
32 2013-02-14 464.52 471.64 464.02 466.59 12688400
33 2013-02-15 468.85 470.16 459.92 460.16 13990900
34 2013-02-19 461.10 462.73 453.85 459.99 15563700
35 2013-02-20 457.69 457.69 448.80 448.85 17010800
36 2013-02-21 446.00 449.17 442.82 446.06 15970800
37 2013-02-22 449.25 451.60 446.60 450.81 11809100
38 2013-02-25 453.85 455.12 442.57 442.80 13306400
39 2013-02-26 443.82 451.54 437.66 448.97 17910700
40 2013-02-27 448.43 452.44 440.65 444.57 20976800
41 2013-02-28 444.05 447.87 441.40 441.40 11518400
42 2013-03-01 438.00 438.18 429.98 430.47 19730300
43 2013-03-04 427.80 428.20 419.00 420.05 20812700
44 2013-03-05 421.48 435.19 420.75 431.14 22801200
45 2013-03-06 434.51 435.25 424.43 425.66 16437500
46 2013-03-07 424.50 432.01 421.06 430.58 16731200
47 2013-03-08 429.80 435.43 428.61 431.72 13981500
48 2013-03-11 429.75 439.01 425.14 437.87 16937000
49 2013-03-12 435.60 438.88 427.57 428.43 16639700
50 2013-03-13 428.45 434.50 425.36 428.35 14483900
51 2013-03-14 432.83 434.64 430.45 432.50 10852700
52 2013-03-15 437.93 444.23 437.25 443.66 22998600
53 2013-03-18 441.45 457.46 441.20 455.72 21649900
54 2013-03-19 459.50 460.97 448.50 454.49 18813400
55 2013-03-20 457.42 457.63 449.59 452.08 11023600
56 2013-03-21 450.22 457.98 450.10 452.73 13687700
57 2013-03-22 454.58 462.10 453.11 461.91 14110900
58 2013-03-25 464.69 469.95 461.78 463.58 17897700
59 2013-03-26 465.44 465.84 460.53 461.14 10510500
60 2013-03-27 456.46 456.80 450.73 452.08 11829900
61 2013-03-28 449.82 451.82 441.62 442.66 15815700
62 2013-04-01 441.90 443.70 427.74 428.91 13919000
63 2013-04-02 427.60 438.14 426.40 429.79 18911400
64 2013-04-03 431.37 437.28 430.31 431.99 12972000
65 2013-04-04 433.76 435.00 425.25 427.72 12801700
66 2013-04-05 424.50 424.95 419.68 423.20 13703400
67 2013-04-08 424.85 427.50 422.49 426.21 10743900
68 2013-04-09 426.36 428.50 422.75 426.98 10950500
69 2013-04-10 428.10 437.06 426.01 435.69 13426000
70 2013-04-11 433.72 437.99 431.20 434.33 11727300
71 2013-04-12 434.15 434.15 429.09 429.80 8521900
72 2013-04-15 427.00 427.89 419.55 419.85 11340000
73 2013-04-16 421.57 426.61 420.57 426.24 10920400
74 2013-04-17 420.27 420.60 398.11 402.80 33752000
75 2013-04-18 404.99 405.79 389.74 392.05 23796400
76 2013-04-19 387.97 399.60 385.10 390.53 21759800
77 2013-04-22 392.64 402.20 391.27 398.67 15354300
78 2013-04-23 403.99 408.38 398.81 406.13 23722800
79 2013-04-24 393.54 415.25 392.50 405.46 34630400
80 2013-04-25 411.23 413.94 407.00 408.38 13744200
81 2013-04-26 409.81 418.77 408.25 417.20 27289200
82 2013-04-29 420.45 433.62 420.00 430.12 22868800
83 2013-04-30 435.10 445.25 432.07 442.78 24697800
84 2013-05-01 444.46 444.93 434.39 439.29 18103900
85 2013-05-02 441.78 448.59 440.63 445.52 15065300
86 2013-05-03 451.31 453.23 449.15 449.98 12903600
87 2013-05-06 455.71 462.20 454.31 460.71 17737200
88 2013-05-07 464.97 465.75 453.70 458.66 17276900
89 2013-05-08 459.04 465.37 455.81 463.84 16878500
90 2013-05-09 459.81 463.00 455.58 456.77 14231700
91 2013-05-10 457.97 459.71 450.48 452.97 11959000
92 2013-05-13 451.51 457.90 451.50 454.74 11319600
93 2013-05-14 453.85 455.20 442.15 443.86 15968500
94 2013-05-15 439.16 441.00 422.36 428.85 26486200
95 2013-05-16 423.24 437.85 418.90 434.58 21543000
96 2013-05-17 439.05 440.09 431.01 433.26 15282300
97 2013-05-20 431.91 445.80 430.10 442.93 16127800
98 2013-05-21 438.15 445.48 434.20 439.66 16286500
99 2013-05-22 444.05 448.35 438.22 441.35 15822800
100 2013-05-23 435.95 446.16 435.79 442.14 12607900
101 2013-05-24 440.85 445.66 440.36 445.15 9863100
102 2013-05-28 449.90 451.11 440.85 441.44 13790900
103 2013-05-29 440.00 447.50 439.40 444.95 11806300
104 2013-05-30 445.65 454.50 444.51 451.58 12625700
105 2013-05-31 452.50 457.10 449.50 449.73 13725100
106 2013-06-03 450.73 452.36 442.48 450.72 13298300
107 2013-06-04 453.22 454.43 447.39 449.31 10454600
108 2013-06-05 445.65 450.72 443.71 445.11 10378200
109 2013-06-06 445.47 447.00 434.05 438.46 14890500
110 2013-06-07 436.50 443.24 432.77 441.81 14447700
111 2013-06-10 444.73 449.08 436.80 438.89 16076900
112 2013-06-11 435.74 442.76 433.32 437.60 10218300
113 2013-06-12 439.50 441.25 431.50 432.19 9472400
114 2013-06-13 432.50 437.14 428.75 435.96 10208300
115 2013-06-14 435.40 436.29 428.50 430.05 9709500
116 2013-06-17 431.44 435.70 430.36 432.00 9264800
117 2013-06-18 431.56 434.90 430.21 431.77 6965200
118 2013-06-19 431.40 431.66 423.00 423.00 11105000
119 2013-06-20 419.30 425.98 415.17 416.84 12761100
120 2013-06-21 418.49 420.00 408.10 413.50 17182800
121 2013-06-24 407.40 408.66 398.05 402.54 17169500
122 2013-06-25 405.70 407.79 398.83 402.63 11220100
123 2013-06-26 403.90 404.79 395.66 398.07 13133000
124 2013-06-27 399.25 401.39 393.54 393.78 12044500
125 2013-06-28 391.36 400.27 388.87 396.53 20661300
126 2013-07-01 402.69 412.27 401.22 409.22 13966200
127 2013-07-02 409.96 421.63 409.47 418.49 16780900
128 2013-07-03 420.86 422.98 417.45 420.80 8604600
129 2013-07-05 420.39 423.29 415.35 417.42 9786600
130 2013-07-08 420.11 421.00 410.65 415.05 10647800
131 2013-07-09 413.60 423.50 410.38 422.35 12592300
132 2013-07-10 419.60 424.80 418.25 420.73 10050200
133 2013-07-11 422.95 428.25 421.17 427.29 11653300
134 2013-07-12 427.65 429.79 423.41 426.51 9984400
135 2013-07-15 425.01 431.46 424.80 427.44 8639900
136 2013-07-16 426.52 430.71 424.17 430.20 7733500
137 2013-07-17 429.70 432.22 428.22 430.31 7106800
138 2013-07-18 433.38 434.87 430.61 431.76 7817100
139 2013-07-19 433.10 433.98 424.35 424.95 9597200
140 2013-07-22 429.46 429.75 425.47 426.31 7421300
141 2013-07-23 426.00 426.96 418.71 418.99 13192700
142 2013-07-24 438.93 444.59 435.26 440.51 21140600
143 2013-07-25 440.70 441.40 435.81 438.50 8196200
144 2013-07-26 435.30 441.04 434.34 440.99 7148300
145 2013-07-29 440.80 449.99 440.20 447.79 8859200
146 2013-07-30 449.96 457.15 449.23 453.32 11050800
147 2013-07-31 454.99 457.34 449.43 452.53 11534200
148 2013-08-01 455.75 456.80 453.26 456.68 7366100
149 2013-08-02 458.01 462.85 456.66 462.54 9813700
150 2013-08-05 464.69 470.67 462.15 469.45 11387700
151 2013-08-06 468.02 471.89 462.17 465.25 11959200
152 2013-08-07 463.80 467.00 461.77 464.98 10673500
153 2013-08-08 463.86 464.10 457.95 461.01 9134900
154 2013-08-09 458.64 460.46 453.65 454.45 9530900
155 2013-08-12 456.86 468.65 456.63 467.36 13015500
156 2013-08-13 470.94 494.66 468.05 489.57 31497900
157 2013-08-14 497.88 504.25 493.40 498.50 27013300
158 2013-08-15 496.42 502.40 489.08 497.91 17510500
159 2013-08-16 500.15 502.94 498.86 502.33 12939500
160 2013-08-19 504.34 513.74 504.00 507.74 18232800
161 2013-08-20 509.71 510.57 500.82 501.07 12810300
162 2013-08-21 503.59 507.15 501.20 502.36 11995700
163 2013-08-22 504.98 505.59 498.20 502.96 8721700
164 2013-08-23 503.27 503.35 499.35 501.02 7954700
165 2013-08-26 500.75 510.20 500.50 502.97 11820200
166 2013-08-27 498.00 502.51 486.30 488.59 15149600
167 2013-08-28 486.00 495.80 486.00 490.90 10986000
168 2013-08-29 491.65 496.50 491.13 491.70 8559200
169 2013-08-30 492.00 492.95 486.50 487.22 9724900
170 2013-09-03 493.10 500.60 487.35 488.58 11854600
171 2013-09-04 499.56 502.24 496.28 498.69 12322600
172 2013-09-05 500.25 500.68 493.64 495.27 8441700
173 2013-09-06 498.44 499.38 489.95 498.22 12840200
174 2013-09-09 505.00 507.92 503.48 506.17 12167400
175 2013-09-10 506.20 507.45 489.50 494.64 26542700
176 2013-09-11 467.01 473.69 464.81 467.71 32096300
177 2013-09-12 468.50 475.40 466.01 472.69 14430400
178 2013-09-13 469.34 471.83 464.70 464.90 10672700
179 2013-09-16 461.00 461.61 447.22 450.12 19418100
180 2013-09-17 447.96 459.71 447.50 455.32 14263600
181 2013-09-18 463.18 466.35 460.66 464.68 16316500
182 2013-09-19 470.70 475.83 469.25 472.30 14447900
183 2013-09-20 478.00 478.55 466.00 467.41 24975100
184 2013-09-23 496.10 496.91 482.60 490.64 27218100
185 2013-09-24 494.88 495.47 487.82 489.10 13012300
186 2013-09-25 489.20 489.64 481.43 481.53 11319900
187 2013-09-26 486.00 488.56 483.90 486.22 8472200
188 2013-09-27 483.78 484.67 480.72 482.75 8144300
189 2013-09-30 477.25 481.66 474.41 476.75 9291300
190 2013-10-01 478.45 489.14 478.38 487.96 12638700
191 2013-10-02 485.63 491.80 483.75 489.56 10328000
192 2013-10-03 490.51 492.35 480.74 483.41 11526900
193 2013-10-04 483.86 484.60 478.60 483.03 9245300
194 2013-10-07 486.56 492.65 485.35 487.75 11153300
195 2013-10-08 489.94 490.64 480.54 480.94 10389900
196 2013-10-09 484.64 487.79 478.28 486.59 10775900
197 2013-10-10 491.32 492.38 487.04 489.64 9950100
198 2013-10-11 486.99 493.84 485.16 492.81 9562100
199 2013-10-14 489.83 497.58 489.35 496.04 9353500
200 2013-10-15 497.51 502.00 495.52 498.68 11431200
201 2013-10-16 500.79 502.53 499.23 501.11 8967900
202 2013-10-17 499.98 504.78 499.68 504.50 9056900
203 2013-10-18 505.99 509.26 505.71 508.89 10376500
204 2013-10-21 511.77 524.30 511.52 521.36 14218100
205 2013-10-22 526.41 528.45 508.03 519.87 19073700
206 2013-10-23 519.00 525.67 519.00 524.96 11204400
207 2013-10-24 525.00 532.47 522.45 531.91 13741600
208 2013-10-25 531.32 533.23 525.11 525.96 12064000
209 2013-10-28 529.04 531.00 523.21 529.88 19658600
210 2013-10-29 536.27 539.25 514.54 516.68 22707400
211 2013-10-30 519.61 527.52 517.02 524.90 12648700
212 2013-10-31 525.00 527.49 521.27 522.70 9846300
213 2013-11-01 524.02 524.80 515.84 520.03 9817500
214 2013-11-04 521.10 526.82 518.81 526.75 8736700
215 2013-11-05 524.58 528.89 523.00 525.45 9471900
216 2013-11-06 524.15 524.86 518.20 520.92 7977700
217 2013-11-07 519.58 523.19 512.38 512.49 9379300
218 2013-11-08 514.58 521.13 512.59 520.56 9975600
219 2013-11-11 519.99 521.67 514.41 519.05 8123300
220 2013-11-12 517.67 523.92 517.00 520.01 7295600
221 2013-11-13 518.00 522.25 516.96 520.63 7043600
222 2013-11-14 522.81 529.28 521.87 528.16 10086400
223 2013-11-15 526.58 529.09 524.49 524.99 11354300
224 2013-11-18 524.99 527.19 518.20 518.63 8748000
225 2013-11-19 519.03 523.38 517.97 519.55 7462100
226 2013-11-20 519.23 520.42 514.33 515.00 6925600
227 2013-11-21 517.60 521.21 513.67 521.14 9358100
228 2013-11-22 519.52 522.16 518.53 519.80 7990200
229 2013-11-25 521.02 525.87 521.00 523.74 8189700
230 2013-11-26 524.12 536.14 524.00 533.40 14335100
231 2013-11-27 536.31 546.00 533.40 545.96 12980300
232 2013-11-29 549.48 558.33 547.81 556.07 11361700
233 2013-12-02 558.00 564.33 550.82 551.23 16876600
234 2013-12-03 558.30 566.38 557.68 566.32 16106000
235 2013-12-04 565.50 569.19 560.82 565.00 13478200
236 2013-12-05 572.65 575.14 566.41 567.90 15985000
237 2013-12-06 565.79 566.75 559.57 560.02 12298300
238 2013-12-09 560.90 569.58 560.90 566.43 11446200
239 2013-12-10 563.58 567.88 561.20 565.55 9938200
240 2013-12-11 567.00 570.97 559.69 561.36 12847100
241 2013-12-12 562.14 565.34 560.03 560.54 9367500
242 2013-12-13 562.85 562.88 553.67 554.43 11886500
243 2013-12-16 555.02 562.64 555.01 557.50 10092600
244 2013-12-17 555.81 559.44 553.38 554.99 8210800
245 2013-12-18 549.70 551.45 538.80 550.77 20209400
246 2013-12-19 549.50 550.00 543.73 544.46 11462800
247 2013-12-20 545.43 551.61 544.82 549.02 15586200
248 2013-12-23 568.00 570.72 562.76 570.09 17903800
249 2013-12-24 569.89 571.88 566.03 567.67 5984100
250 2013-12-26 568.10 569.50 563.38 563.90 7286000
251 2013-12-27 563.82 564.41 559.50 560.09 8067300
252 2013-12-30 557.46 560.09 552.32 554.52 9058200
253 2013-12-31 554.17 561.28 554.00 561.02 7967300
@@ -0,0 +1,948 @@
{
"candles": [
{
"start": "1696982400",
"low": "26521.32",
"high": "27478.61",
"open": "27393.28",
"close": "26869.19",
"volume": "16466.26612207"
},
{
"start": "1696896000",
"low": "27291.81",
"high": "27739.94",
"open": "27596.67",
"close": "27393.29",
"volume": "10265.79357936"
},
{
"start": "1696809600",
"low": "27268.63",
"high": "28003.7",
"open": "27933.54",
"close": "27596.67",
"volume": "10218.62556021"
},
{
"start": "1696723200",
"low": "27708.6",
"high": "28109.94",
"open": "27976.44",
"close": "27933.55",
"volume": "4118.56381073"
},
{
"start": "1696636800",
"low": "27861.2",
"high": "28040.84",
"open": "27945.17",
"close": "27976.43",
"volume": "3489.91595717"
},
{
"start": "1696550400",
"low": "27173.41",
"high": "28288.88",
"open": "27413.15",
"close": "27945.17",
"volume": "12908.75146274"
},
{
"start": "1696464000",
"low": "27358.05",
"high": "28140",
"open": "27786.12",
"close": "27412.96",
"volume": "14136.04783544"
},
{
"start": "1696377600",
"low": "27208.65",
"high": "27842.38",
"open": "27428.96",
"close": "27786.75",
"volume": "10238.17053376"
},
{
"start": "1696291200",
"low": "27160.47",
"high": "27672.12",
"open": "27504.03",
"close": "27428.96",
"volume": "14334.10857629"
},
{
"start": "1696204800",
"low": "27302.3",
"high": "28613.37",
"open": "27995.46",
"close": "27504.04",
"volume": "25148.25341854"
},
{
"start": "1696118400",
"low": "26955.25",
"high": "28062.62",
"open": "26961",
"close": "27995.46",
"volume": "8747.06888783"
},
{
"start": "1696032000",
"low": "26884.86",
"high": "27097.25",
"open": "26905.57",
"close": "26961",
"volume": "4413.30948101"
},
{
"start": "1695945600",
"low": "26661.71",
"high": "27238.9",
"open": "27027.44",
"close": "26907",
"volume": "12712.27177691"
},
{
"start": "1695859200",
"low": "26321.37",
"high": "27313.86",
"open": "26357.56",
"close": "27027.14",
"volume": "18975.87763693"
},
{
"start": "1695772800",
"low": "26088.37",
"high": "26830",
"open": "26208.43",
"close": "26357.62",
"volume": "14123.89690964"
},
{
"start": "1695686400",
"low": "26080",
"high": "26397.59",
"open": "26296.07",
"close": "26208.44",
"volume": "7016.42122845"
},
{
"start": "1695600000",
"low": "25983.78",
"high": "26444.95",
"open": "26248.17",
"close": "26296.08",
"volume": "10762.98401397"
},
{
"start": "1695513600",
"low": "26118.86",
"high": "26731.1",
"open": "26579.59",
"close": "26250.24",
"volume": "4587.80529114"
},
{
"start": "1695427200",
"low": "26513",
"high": "26638.58",
"open": "26578.4",
"close": "26579.6",
"volume": "2597.25705711"
},
{
"start": "1695340800",
"low": "26467.94",
"high": "26742.86",
"open": "26564.54",
"close": "26578.4",
"volume": "8563.0523313"
},
{
"start": "1695254400",
"low": "26359.7",
"high": "27163.48",
"open": "27122.65",
"close": "26564.54",
"volume": "11801.98943642"
},
{
"start": "1695168000",
"low": "26798.98",
"high": "27393.78",
"open": "27216.52",
"close": "27123.94",
"volume": "11590.24027633"
},
{
"start": "1695081600",
"low": "26666.93",
"high": "27500",
"open": "26764.03",
"close": "27216.13",
"volume": "14386.07884728"
},
{
"start": "1694995200",
"low": "26382.13",
"high": "27427.34",
"open": "26530.96",
"close": "26764.49",
"volume": "17493.70820354"
},
{
"start": "1694908800",
"low": "26405.04",
"high": "26626.49",
"open": "26569.69",
"close": "26530.95",
"volume": "3252.26976131"
},
{
"start": "1694822400",
"low": "26453.32",
"high": "26777",
"open": "26601.71",
"close": "26569.69",
"volume": "4132.04817572"
},
{
"start": "1694736000",
"low": "26213.96",
"high": "26892.65",
"open": "26532.5",
"close": "26602.42",
"volume": "9345.93913701"
},
{
"start": "1694649600",
"low": "26128.25",
"high": "26869.06",
"open": "26227.16",
"close": "26532.76",
"volume": "12929.08237721"
},
{
"start": "1694563200",
"low": "25763.43",
"high": "26413.59",
"open": "25839.95",
"close": "26226.65",
"volume": "10099.7383701"
},
{
"start": "1694476800",
"low": "25120.76",
"high": "26556.91",
"open": "25152.13",
"close": "25840.61",
"volume": "19789.48058328"
},
{
"start": "1694390400",
"low": "24900",
"high": "25891.6",
"open": "25831.77",
"close": "25152.14",
"volume": "13404.11510617"
},
{
"start": "1694304000",
"low": "25578.01",
"high": "26023.89",
"open": "25897.82",
"close": "25831.76",
"volume": "4361.60285333"
},
{
"start": "1694217600",
"low": "25791.45",
"high": "25942.69",
"open": "25906.33",
"close": "25897.82",
"volume": "2225.82211069"
},
{
"start": "1694131200",
"low": "25640.1",
"high": "26460.41",
"open": "26276.38",
"close": "25906.34",
"volume": "11149.95850667"
},
{
"start": "1694044800",
"low": "25595.8",
"high": "26440.55",
"open": "25747.63",
"close": "26276.3",
"volume": "10885.13902329"
},
{
"start": "1693958400",
"low": "25358",
"high": "26025.78",
"open": "25782.49",
"close": "25747.63",
"volume": "9374.33792185"
},
{
"start": "1693872000",
"low": "25551.36",
"high": "25884.04",
"open": "25816.14",
"close": "25783.91",
"volume": "6051.14811848"
},
{
"start": "1693785600",
"low": "25627",
"high": "26093.07",
"open": "25969.83",
"close": "25816.13",
"volume": "4635.8154655"
},
{
"start": "1693699200",
"low": "25792.29",
"high": "26123",
"open": "25867.82",
"close": "25969.83",
"volume": "3434.5195669"
},
{
"start": "1693612800",
"low": "25707.3",
"high": "25982.48",
"open": "25795.61",
"close": "25867.83",
"volume": "3810.77429103"
},
{
"start": "1693526400",
"low": "25307.37",
"high": "26142.28",
"open": "25931.51",
"close": "25794.88",
"volume": "14995.72610123"
},
{
"start": "1693440000",
"low": "25660.61",
"high": "27576.99",
"open": "27306.06",
"close": "25932.45",
"volume": "14462.02738125"
},
{
"start": "1693353600",
"low": "27015.75",
"high": "27775",
"open": "27717.15",
"close": "27306.06",
"volume": "9793.95679478"
},
{
"start": "1693267200",
"low": "25903.18",
"high": "28184.89",
"open": "26101.03",
"close": "27717.98",
"volume": "21402.10102797"
},
{
"start": "1693180800",
"low": "25850",
"high": "26232.88",
"open": "26091.16",
"close": "26101.04",
"volume": "5460.42038393"
},
{
"start": "1693094400",
"low": "25961.81",
"high": "26171.87",
"open": "26009.62",
"close": "26091.16",
"volume": "2069.84831133"
},
{
"start": "1693008000",
"low": "25971.44",
"high": "26115.44",
"open": "26049.4",
"close": "26009.61",
"volume": "1862.15021008"
},
{
"start": "1692921600",
"low": "25750",
"high": "26294.53",
"open": "26163.49",
"close": "26049.39",
"volume": "10926.33773703"
},
{
"start": "1692835200",
"low": "25850",
"high": "26567.38",
"open": "26427.73",
"close": "26163.49",
"volume": "10002.33992977"
},
{
"start": "1692748800",
"low": "25796.47",
"high": "26818.28",
"open": "26041.94",
"close": "26427.54",
"volume": "15743.86437208"
},
{
"start": "1692662400",
"low": "25350",
"high": "26137.99",
"open": "26125.16",
"close": "26042.11",
"volume": "12418.87289323"
},
{
"start": "1692576000",
"low": "25814",
"high": "26260.25",
"open": "26189.36",
"close": "26125.18",
"volume": "9936.98111023"
},
{
"start": "1692489600",
"low": "25975.01",
"high": "26296.78",
"open": "26096.4",
"close": "26189.14",
"volume": "4345.07228674"
},
{
"start": "1692403200",
"low": "25793.11",
"high": "26269.11",
"open": "26048.9",
"close": "26096.42",
"volume": "6554.05344759"
},
{
"start": "1692316800",
"low": "25610",
"high": "26834.37",
"open": "26627.21",
"close": "26048.89",
"volume": "21190.13041519"
},
{
"start": "1692230400",
"low": "25234.76",
"high": "28751.84",
"open": "28700.51",
"close": "26627.57",
"volume": "27723.2108711"
},
{
"start": "1692144000",
"low": "28690.37",
"high": "29231.92",
"open": "29170.13",
"close": "28700.51",
"volume": "9669.42535941"
},
{
"start": "1692057600",
"low": "29046.58",
"high": "29464.62",
"open": "29405.49",
"close": "29170.14",
"volume": "5815.32226178"
},
{
"start": "1691971200",
"low": "29072.96",
"high": "29665.27",
"open": "29275.78",
"close": "29405.49",
"volume": "7063.46806345"
},
{
"start": "1691884800",
"low": "29247.15",
"high": "29447.6",
"open": "29417.19",
"close": "29275.94",
"volume": "2128.37437697"
},
{
"start": "1691798400",
"low": "29349.98",
"high": "29477.31",
"open": "29400.45",
"close": "29416.96",
"volume": "1906.1707986"
},
{
"start": "1691712000",
"low": "29213.59",
"high": "29534.14",
"open": "29424.04",
"close": "29400.45",
"volume": "5961.21744691"
},
{
"start": "1691625600",
"low": "29306",
"high": "29709.38",
"open": "29562.76",
"close": "29424.03",
"volume": "7104.55622213"
},
{
"start": "1691539200",
"low": "29344.16",
"high": "30128.88",
"open": "29770.01",
"close": "29562.76",
"volume": "10451.45167511"
},
{
"start": "1691452800",
"low": "29104.65",
"high": "30222",
"open": "29179.28",
"close": "29770.01",
"volume": "15610.25491294"
},
{
"start": "1691366400",
"low": "28660.53",
"high": "29246",
"open": "29040.01",
"close": "29179.21",
"volume": "9200.08523317"
},
{
"start": "1691280000",
"low": "28955",
"high": "29163.24",
"open": "29047.33",
"close": "29040.01",
"volume": "2461.64012073"
},
{
"start": "1691193600",
"low": "28946.17",
"high": "29111.87",
"open": "29077.04",
"close": "29047.12",
"volume": "2490.84710469"
},
{
"start": "1691107200",
"low": "28747.1",
"high": "29312.28",
"open": "29174.84",
"close": "29076.48",
"volume": "7291.14687702"
},
{
"start": "1691020800",
"low": "28938.93",
"high": "29399.97",
"open": "29163.87",
"close": "29174.18",
"volume": "8920.01477716"
},
{
"start": "1690934400",
"low": "28903.79",
"high": "30033",
"open": "29699.25",
"close": "29164.22",
"volume": "17967.34799565"
},
{
"start": "1690848000",
"low": "28477",
"high": "29726.73",
"open": "29230.61",
"close": "29697.27",
"volume": "17751.31113096"
},
{
"start": "1690761600",
"low": "29102.65",
"high": "29526.99",
"open": "29280.41",
"close": "29230.67",
"volume": "8793.83887155"
},
{
"start": "1690675200",
"low": "29033",
"high": "29450",
"open": "29356.65",
"close": "29281.39",
"volume": "4387.57256615"
},
{
"start": "1690588800",
"low": "29253.44",
"high": "29411.09",
"open": "29316.05",
"close": "29355.71",
"volume": "2893.05298823"
},
{
"start": "1690502400",
"low": "29115.32",
"high": "29532.45",
"open": "29214.93",
"close": "29316.15",
"volume": "7105.49434797"
},
{
"start": "1690416000",
"low": "29075",
"high": "29571.42",
"open": "29350.88",
"close": "29214.92",
"volume": "7327.4507106"
},
{
"start": "1690329600",
"low": "29089.54",
"high": "29686.41",
"open": "29225.11",
"close": "29353.23",
"volume": "9635.09206614"
},
{
"start": "1690243200",
"low": "29046",
"high": "29376.51",
"open": "29176.98",
"close": "29225.14",
"volume": "7140.85368901"
},
{
"start": "1690156800",
"low": "28850",
"high": "30099.99",
"open": "30081.61",
"close": "29176.98",
"volume": "14484.27557634"
},
{
"start": "1690070400",
"low": "29733.55",
"high": "30350.7",
"open": "29793.62",
"close": "30081.61",
"volume": "4513.83214295"
},
{
"start": "1689984000",
"low": "29626.86",
"high": "30002.38",
"open": "29907.15",
"close": "29795.03",
"volume": "3629.48251055"
},
{
"start": "1689897600",
"low": "29729.48",
"high": "30060.28",
"open": "29809.13",
"close": "29907.98",
"volume": "7598.61053359"
},
{
"start": "1689811200",
"low": "29564.19",
"high": "30421.29",
"open": "29916.69",
"close": "29809.13",
"volume": "11494.95348803"
},
{
"start": "1689724800",
"low": "29757.44",
"high": "30196.24",
"open": "29861.92",
"close": "29916.72",
"volume": "8804.54675154"
},
{
"start": "1689638400",
"low": "29525",
"high": "30249.3",
"open": "30143.08",
"close": "29861.93",
"volume": "11124.83936583"
},
{
"start": "1689552000",
"low": "29668.58",
"high": "30345.45",
"open": "30247.56",
"close": "30143.08",
"volume": "10632.8066781"
},
{
"start": "1689465600",
"low": "30075.93",
"high": "30457.03",
"open": "30302.01",
"close": "30247.64",
"volume": "4021.41012275"
},
{
"start": "1689379200",
"low": "30254.67",
"high": "30406.27",
"open": "30330.93",
"close": "30301.65",
"volume": "2559.07232743"
},
{
"start": "1689292800",
"low": "29920.31",
"high": "31645.66",
"open": "31471.83",
"close": "30330.52",
"volume": "19272.20339271"
},
{
"start": "1689206400",
"low": "30250.73",
"high": "31862.21",
"open": "30383.26",
"close": "31471.83",
"volume": "26135.6192364"
},
{
"start": "1689120000",
"low": "30200",
"high": "31000",
"open": "30624.65",
"close": "30381.81",
"volume": "14881.70511963"
},
{
"start": "1689033600",
"low": "30304.45",
"high": "30811.65",
"open": "30419.89",
"close": "30626.55",
"volume": "10653.5761104"
},
{
"start": "1688947200",
"low": "29955",
"high": "31055.75",
"open": "30168.26",
"close": "30419.89",
"volume": "12703.78162563"
},
{
"start": "1688860800",
"low": "30066.95",
"high": "30451.78",
"open": "30291.69",
"close": "30168.38",
"volume": "3884.04687903"
},
{
"start": "1688774400",
"low": "30047.78",
"high": "30388.91",
"open": "30350.35",
"close": "30291.69",
"volume": "3856.11078847"
},
{
"start": "1688688000",
"low": "29715.87",
"high": "30456",
"open": "29895.6",
"close": "30352.08",
"volume": "11137.14221055"
},
{
"start": "1688601600",
"low": "29850",
"high": "31525.1",
"open": "30499.27",
"close": "29899.38",
"volume": "17229.92231032"
},
{
"start": "1688515200",
"low": "30189.56",
"high": "30882.95",
"open": "30772.93",
"close": "30499.27",
"volume": "8782.6192239"
},
{
"start": "1688428800",
"low": "30628.3",
"high": "31333",
"open": "31162.71",
"close": "30771.25",
"volume": "7233.95188576"
},
{
"start": "1688342400",
"low": "30569",
"high": "31399.08",
"open": "30613.57",
"close": "31161.8",
"volume": "11391.98257522"
},
{
"start": "1688256000",
"low": "30165.39",
"high": "30791.75",
"open": "30587.22",
"close": "30613.51",
"volume": "4829.83871313"
},
{
"start": "1688169600",
"low": "30312.8",
"high": "30659.33",
"open": "30466.73",
"close": "30587.21",
"volume": "4012.76538882"
},
{
"start": "1688083200",
"low": "29417.14",
"high": "31277",
"open": "30445.67",
"close": "30466.72",
"volume": "26013.22734909"
},
{
"start": "1687996800",
"low": "30036.18",
"high": "30838",
"open": "30074.93",
"close": "30446.47",
"volume": "12261.34048625"
},
{
"start": "1687910400",
"low": "29840",
"high": "30716.93",
"open": "30696.06",
"close": "30074.94",
"volume": "9391.66542094"
},
{
"start": "1687824000",
"low": "30228.44",
"high": "31020.54",
"open": "30272.24",
"close": "30697.38",
"volume": "11995.526853"
},
{
"start": "1687737600",
"low": "29900",
"high": "30662.66",
"open": "30480.24",
"close": "30272.18",
"volume": "12491.83081677"
},
{
"start": "1687651200",
"low": "30288.33",
"high": "31057.86",
"open": "30547.3",
"close": "30480.24",
"volume": "6265.47434464"
},
{
"start": "1687564800",
"low": "30265.88",
"high": "30815.92",
"open": "30709.59",
"close": "30547.18",
"volume": "6883.28318615"
},
{
"start": "1687478400",
"low": "29802.65",
"high": "31443.67",
"open": "29886.25",
"close": "30707.61",
"volume": "22659.54058513"
},
{
"start": "1687392000",
"low": "29539.57",
"high": "30513.25",
"open": "29999.85",
"close": "29886.31",
"volume": "18457.66785815"
},
{
"start": "1687305600",
"low": "28271.67",
"high": "30800",
"open": "28320.41",
"close": "29995.08",
"volume": "31445.78143275"
},
{
"start": "1687219200",
"low": "26637.41",
"high": "28417.11",
"open": "26839.04",
"close": "28320.43",
"volume": "28704.55295587"
},
{
"start": "1687132800",
"low": "26250",
"high": "27040.64",
"open": "26336.46",
"close": "26837.78",
"volume": "9643.06748389"
},
{
"start": "1687046400",
"low": "26245.27",
"high": "26693.11",
"open": "26507.97",
"close": "26336.46",
"volume": "5233.68378154"
},
{
"start": "1686960000",
"low": "26165.98",
"high": "26783.2",
"open": "26329.6",
"close": "26506.56",
"volume": "7209.4251028"
},
{
"start": "1686873600",
"low": "25143.24",
"high": "26486.56",
"open": "25569.99",
"close": "26329.6",
"volume": "16632.7425402"
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,652 @@
{
"candles": [
{
"start": "1696982400",
"low": "4345.34",
"high": "4378.64",
"open": "4366.59",
"close": "4376.95",
"volume": "2123886315"
},
{
"start": "1696896000",
"low": "4339.64",
"high": "4385.46",
"open": "4339.75",
"close": "4358.24",
"volume": "2166155537"
},
{
"start": "1696809600",
"low": "4283.79",
"high": "4341.73",
"open": "4289.02",
"close": "4335.66",
"volume": "1988768924"
},
{
"start": "1696550400",
"low": "4219.55",
"high": "4324.1",
"open": "4234.79",
"close": "4308.5",
"volume": "2489880710"
},
{
"start": "1696464000",
"low": "4225.91",
"high": "4267.13",
"open": "4259.31",
"close": "4258.19",
"volume": "2214792439"
},
{
"start": "1696377600",
"low": "4220.48",
"high": "4268.5",
"open": "4233.83",
"close": "4263.75",
"volume": "2324498903"
},
{
"start": "1696291200",
"low": "4216.45",
"high": "4281.15",
"open": "4269.75",
"close": "4229.45",
"volume": "2421468261"
},
{
"start": "1696204800",
"low": "4260.21",
"high": "4300.58",
"open": "4284.52",
"close": "4288.39",
"volume": "2374624170"
},
{
"start": "1695945600",
"low": "4274.86",
"high": "4333.15",
"open": "4328.18",
"close": "4288.05",
"volume": "2406132169"
},
{
"start": "1695859200",
"low": "4264.38",
"high": "4317.27",
"open": "4269.65",
"close": "4299.7",
"volume": "2240204319"
},
{
"start": "1695772800",
"low": "4238.63",
"high": "4292.07",
"open": "4282.63",
"close": "4274.51",
"volume": "2299352689"
},
{
"start": "1695686400",
"low": "4265.98",
"high": "4313.01",
"open": "4312.88",
"close": "4273.53",
"volume": "2130315512"
},
{
"start": "1695600000",
"low": "4302.7",
"high": "4338.51",
"open": "4310.62",
"close": "4337.44",
"volume": "1860152658"
},
{
"start": "1695340800",
"low": "4316.49",
"high": "4357.4",
"open": "4341.74",
"close": "4320.06",
"volume": "2131667228"
},
{
"start": "1695254400",
"low": "4329.17",
"high": "4375.7",
"open": "4374.36",
"close": "4330",
"volume": "2292425252"
},
{
"start": "1695168000",
"low": "4401.38",
"high": "4461.03",
"open": "4452.81",
"close": "4402.2",
"volume": "1981250299"
},
{
"start": "1695081600",
"low": "4416.61",
"high": "4449.85",
"open": "4445.41",
"close": "4443.95",
"volume": "2013002833"
},
{
"start": "1694995200",
"low": "4442.11",
"high": "4466.36",
"open": "4445.13",
"close": "4453.53",
"volume": "1925329039"
},
{
"start": "1694736000",
"low": "4447.21",
"high": "4497.98",
"open": "4497.98",
"close": "4450.32",
"volume": "4239330106"
},
{
"start": "1694649600",
"low": "4478.69",
"high": "4511.99",
"open": "4487.78",
"close": "4505.1",
"volume": "2273685163"
},
{
"start": "1694563200",
"low": "4453.52",
"high": "4479.39",
"open": "4462.65",
"close": "4467.44",
"volume": "2265557149"
},
{
"start": "1694476800",
"low": "4456.83",
"high": "4487.11",
"open": "4473.27",
"close": "4461.9",
"volume": "2188365100"
},
{
"start": "1694390400",
"low": "4467.89",
"high": "4490.77",
"open": "4480.98",
"close": "4487.46",
"volume": "2165432407"
},
{
"start": "1694131200",
"low": "4448.38",
"high": "4473.53",
"open": "4451.3",
"close": "4457.49",
"volume": "2079878833"
},
{
"start": "1694044800",
"low": "4430.46",
"high": "4457.81",
"open": "4434.55",
"close": "4451.14",
"volume": "2443175221"
},
{
"start": "1693958400",
"low": "4442.38",
"high": "4490.35",
"open": "4490.35",
"close": "4465.48",
"volume": "2134658586"
},
{
"start": "1693872000",
"low": "4496.01",
"high": "4514.29",
"open": "4510.06",
"close": "4496.83",
"volume": "2126482524"
},
{
"start": "1693526400",
"low": "4501.35",
"high": "4541.25",
"open": "4530.6",
"close": "4515.77",
"volume": "1927089047"
},
{
"start": "1693440000",
"low": "4507.39",
"high": "4532.26",
"open": "4517.01",
"close": "4507.66",
"volume": "2319094206"
},
{
"start": "1693353600",
"low": "4493.59",
"high": "4521.65",
"open": "4500.34",
"close": "4514.87",
"volume": "1788662114"
},
{
"start": "1693267200",
"low": "4431.68",
"high": "4500.14",
"open": "4432.75",
"close": "4497.63",
"volume": "1884706898"
},
{
"start": "1693180800",
"low": "4414.98",
"high": "4439.56",
"open": "4426.03",
"close": "4433.31",
"volume": "1631738412"
},
{
"start": "1692921600",
"low": "4356.29",
"high": "4418.46",
"open": "4389.38",
"close": "4405.71",
"volume": "1892067072"
},
{
"start": "1692835200",
"low": "4375.55",
"high": "4458.3",
"open": "4455.16",
"close": "4376.31",
"volume": "2082566773"
},
{
"start": "1692748800",
"low": "4396.44",
"high": "4443.18",
"open": "4396.44",
"close": "4436.01",
"volume": "2021761268"
},
{
"start": "1692662400",
"low": "4382.77",
"high": "4418.59",
"open": "4415.33",
"close": "4387.55",
"volume": "1904585562"
},
{
"start": "1692576000",
"low": "4360.3",
"high": "4407.55",
"open": "4380.28",
"close": "4399.77",
"volume": "2058836126"
},
{
"start": "1692316800",
"low": "4335.31",
"high": "4381.82",
"open": "4344.88",
"close": "4369.71",
"volume": "2159681725"
},
{
"start": "1692230400",
"low": "4364.83",
"high": "4421.17",
"open": "4416.32",
"close": "4370.36",
"volume": "2222818962"
},
{
"start": "1692144000",
"low": "4403.55",
"high": "4449.95",
"open": "4433.79",
"close": "4404.33",
"volume": "2007563221"
},
{
"start": "1692057600",
"low": "4432.19",
"high": "4478.87",
"open": "4478.87",
"close": "4437.86",
"volume": "1976890010"
},
{
"start": "1691971200",
"low": "4453.44",
"high": "4490.33",
"open": "4458.13",
"close": "4489.72",
"volume": "1957703753"
},
{
"start": "1691712000",
"low": "4443.98",
"high": "4476.23",
"open": "4450.69",
"close": "4464.05",
"volume": "1849920370"
},
{
"start": "1691625600",
"low": "4457.92",
"high": "4527.37",
"open": "4487.16",
"close": "4468.83",
"volume": "2116254445"
},
{
"start": "1691539200",
"low": "4461.33",
"high": "4502.44",
"open": "4501.57",
"close": "4467.71",
"volume": "2058789858"
},
{
"start": "1691452800",
"low": "4464.39",
"high": "4503.31",
"open": "4498.03",
"close": "4499.38",
"volume": "2169756994"
},
{
"start": "1691366400",
"low": "4491.15",
"high": "4519.84",
"open": "4491.58",
"close": "4518.44",
"volume": "2051647102"
},
{
"start": "1691107200",
"low": "4474.55",
"high": "4540.34",
"open": "4513.96",
"close": "4478.03",
"volume": "2411632698"
},
{
"start": "1691020800",
"low": "4485.54",
"high": "4519.49",
"open": "4494.27",
"close": "4501.89",
"volume": "2363725888"
},
{
"start": "1690934400",
"low": "4505.75",
"high": "4550.93",
"open": "4550.93",
"close": "4513.39",
"volume": "2467424038"
},
{
"start": "1690848000",
"low": "4567.53",
"high": "4584.62",
"open": "4578.83",
"close": "4576.73",
"volume": "2179828757"
},
{
"start": "1690761600",
"low": "4573.14",
"high": "4594.22",
"open": "4584.82",
"close": "4588.96",
"volume": "2415231060"
},
{
"start": "1690502400",
"low": "4564.01",
"high": "4590.16",
"open": "4565.75",
"close": "4582.23",
"volume": "2363048132"
},
{
"start": "1690416000",
"low": "4528.56",
"high": "4607.07",
"open": "4598.26",
"close": "4537.41",
"volume": "2680598316"
},
{
"start": "1690329600",
"low": "4547.58",
"high": "4582.47",
"open": "4558.96",
"close": "4566.75",
"volume": "2320509101"
},
{
"start": "1690243200",
"low": "4552.42",
"high": "4580.62",
"open": "4555.19",
"close": "4567.46",
"volume": "2305628861"
},
{
"start": "1690156800",
"low": "4541.29",
"high": "4563.41",
"open": "4543.39",
"close": "4554.64",
"volume": "2156130111"
},
{
"start": "1689897600",
"low": "4535.79",
"high": "4555",
"open": "4550.16",
"close": "4536.34",
"volume": "3277670172"
},
{
"start": "1689811200",
"low": "4527.56",
"high": "4564.74",
"open": "4554.38",
"close": "4534.87",
"volume": "2595426819"
},
{
"start": "1689724800",
"low": "4557.48",
"high": "4578.43",
"open": "4563.87",
"close": "4565.72",
"volume": "2597461645"
},
{
"start": "1689638400",
"low": "4514.59",
"high": "4562.3",
"open": "4521.78",
"close": "4554.98",
"volume": "2538849602"
},
{
"start": "1689552000",
"low": "4504.9",
"high": "4532.85",
"open": "4508.86",
"close": "4522.79",
"volume": "2359710601"
},
{
"start": "1689292800",
"low": "4499.56",
"high": "4527.76",
"open": "4514.61",
"close": "4505.42",
"volume": "2379047405"
},
{
"start": "1689206400",
"low": "4489.36",
"high": "4517.38",
"open": "4491.5",
"close": "4510.04",
"volume": "2254150794"
},
{
"start": "1689120000",
"low": "4463.23",
"high": "4488.34",
"open": "4467.69",
"close": "4472.16",
"volume": "2330249912"
},
{
"start": "1689033600",
"low": "4408.46",
"high": "4443.64",
"open": "4415.55",
"close": "4439.26",
"volume": "2129927844"
},
{
"start": "1688947200",
"low": "4389.92",
"high": "4412.6",
"open": "4394.23",
"close": "4409.53",
"volume": "2168941075"
},
{
"start": "1688688000",
"low": "4397.4",
"high": "4440.39",
"open": "4404.54",
"close": "4398.95",
"volume": "2111125773"
},
{
"start": "1688601600",
"low": "4385.05",
"high": "4422.62",
"open": "4422.62",
"close": "4411.59",
"volume": "2224381526"
},
{
"start": "1688515200",
"low": "4436.61",
"high": "4454.06",
"open": "4442.04",
"close": "4446.82",
"volume": "2115015525"
},
{
"start": "1688342400",
"low": "4442.29",
"high": "4456.46",
"open": "4450.48",
"close": "4455.59",
"volume": "1236150700"
},
{
"start": "1688083200",
"low": "4422.44",
"high": "4458.48",
"open": "4422.44",
"close": "4450.38",
"volume": "2491966612"
},
{
"start": "1687996800",
"low": "4371.97",
"high": "4398.39",
"open": "4374.94",
"close": "4396.44",
"volume": "2158939109"
},
{
"start": "1687910400",
"low": "4360.22",
"high": "4390.35",
"open": "4367.48",
"close": "4376.86",
"volume": "2283456075"
},
{
"start": "1687824000",
"low": "4335",
"high": "4384.42",
"open": "4337.36",
"close": "4378.41",
"volume": "2289549269"
},
{
"start": "1687737600",
"low": "4328.08",
"high": "4362.06",
"open": "4344.84",
"close": "4328.82",
"volume": "2249250625"
},
{
"start": "1687478400",
"low": "4341.34",
"high": "4366.55",
"open": "4354.17",
"close": "4348.33",
"volume": "3062977301"
},
{
"start": "1687392000",
"low": "4351.82",
"high": "4382.25",
"open": "4355.4",
"close": "4381.89",
"volume": "2101340593"
},
{
"start": "1687305600",
"low": "4360.14",
"high": "4386.22",
"open": "4380.01",
"close": "4365.69",
"volume": "2342110707"
},
{
"start": "1687219200",
"low": "4367.19",
"high": "4400.15",
"open": "4396.11",
"close": "4388.71",
"volume": "2438264940"
},
{
"start": "1686873600",
"low": "4407.44",
"high": "4448.47",
"open": "4440.95",
"close": "4409.59",
"volume": "4185054291"
}
]
}

Some files were not shown because too many files have changed in this diff Show More