goldenChat base source add
This commit is contained in:
@@ -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`).
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+547
@@ -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);
|
||||
}
|
||||
}
|
||||
+844
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1374
File diff suppressed because it is too large
Load Diff
+254
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2635
File diff suppressed because it is too large
Load Diff
+418
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2303
File diff suppressed because it is too large
Load Diff
+234
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2005
File diff suppressed because it is too large
Load Diff
+432
@@ -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();
|
||||
}
|
||||
}
|
||||
+153
@@ -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;
|
||||
}
|
||||
}
|
||||
+142
@@ -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));
|
||||
}
|
||||
}
|
||||
+158
@@ -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;
|
||||
}
|
||||
}
|
||||
+175
@@ -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"));
|
||||
}
|
||||
}
|
||||
+179
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+365
@@ -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;
|
||||
}
|
||||
}
|
||||
+173
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
@@ -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 + ")";
|
||||
}
|
||||
}
|
||||
+92
@@ -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);
|
||||
}
|
||||
}
|
||||
+1938
File diff suppressed because it is too large
Load Diff
+39
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
+1741
File diff suppressed because it is too large
Load Diff
+109
@@ -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;
|
||||
}
|
||||
}
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+469
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+116
@@ -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+", "_");
|
||||
}
|
||||
}
|
||||
+1075
File diff suppressed because it is too large
Load Diff
+145
@@ -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 "";
|
||||
}
|
||||
}
|
||||
+315
@@ -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());
|
||||
}
|
||||
}
|
||||
+867
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+250
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+978
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+247
@@ -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;
|
||||
}
|
||||
}
|
||||
+430
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
@@ -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();
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
+316
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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();
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+135
@@ -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'");
|
||||
}
|
||||
}
|
||||
+768
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+461
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -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);
|
||||
}
|
||||
}
|
||||
+226
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+181
@@ -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");
|
||||
}
|
||||
}
|
||||
+97
@@ -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");
|
||||
}
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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");
|
||||
}
|
||||
}
|
||||
+689
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -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");
|
||||
}
|
||||
}
|
||||
+156
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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");
|
||||
}
|
||||
}
|
||||
+101
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
+73
@@ -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
|
||||
|
+5225
File diff suppressed because it is too large
Load Diff
+100001
File diff suppressed because it is too large
Load Diff
+948
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1020
File diff suppressed because it is too large
Load Diff
+165564
File diff suppressed because it is too large
Load Diff
+27604
File diff suppressed because it is too large
Load Diff
+32243
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+165404
File diff suppressed because it is too large
Load Diff
+47244
File diff suppressed because it is too large
Load Diff
+652
@@ -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
Reference in New Issue
Block a user