goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,13 @@
# ta4jexamples.analysis instructions
- Analysis demo classes in this package should load *ossified* OHLCV datasets committed under `ta4j-examples/src/main/resources` (avoid live HTTP calls in the demo itself).
- Long-running analysis/calibration flows must log progress and persist primary artifacts incrementally.
- Write the primary dataset/instrument result before any portability or secondary checks.
- Final aggregate JSON/bundles may append later, but must not be the only durable output.
- When adding a new analysis demo, first stage the dataset locally:
1. Fetch the desired OHLCV data using an HTTP datasource (prefer `ta4jexamples.datasources.CoinbaseHttpBarSeriesDataSource` for crypto examples) with response caching enabled.
2. Let the datasource paginate as needed (Coinbase is capped at 350 candles/request) and write cached JSON responses under `ta4j-examples/temp/responses`.
3. Combine/merge the paginated cached files into a single consolidated OHLCV JSON file (deduplicate by candle start time, keep chronological ordering).
4. Rename the consolidated file to the resource naming convention `{Source}-{TICKER}-{INTERVAL}-{START}_{END}.json` (example: `Coinbase-BTC-USD-PT1D-20230616_20231011.json`) and move it to `ta4j-examples/src/main/resources`.
- Note: cached response filenames may use `PT24H` for daily candles; resources should use the `PT1D` / `PT4H` / `PT5M` style used by `ta4jexamples.datasources.file.AbstractFileBarSeriesDataSource`.
5. Update the demo class to initialize its `BarSeries` from the resource JSON (via `ta4jexamples.datasources.JsonFileBarSeriesDataSource`).
@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.BarSeries;
import ta4jexamples.strategies.MovingMomentumStrategy;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.charting.workflow.ChartWorkflow;
/**
* This class builds a graphical chart showing the buy/sell signals of a
* strategy.
*/
public class BuyAndSellSignalsToChart {
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = MovingMomentumStrategy.buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
// Displaying the chart using the shared ChartWorkflow utility
ChartWorkflow chartWorkflow = new ChartWorkflow();
String strategyName = strategy.getName() != null ? strategy.getName() : "Moving Momentum Strategy";
chartWorkflow.displayTradingRecordChart(series, strategyName, tradingRecord);
}
}
@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.CashFlow;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.strategies.MovingMomentumStrategy;
/**
* This class builds a graphical chart showing the cash flow of a strategy.
*
* <p>
* This example demonstrates the use of the dual-axis chart functionality in
* {@link ChartWorkflow} to display both the close price and cash flow of a
* trading strategy on the same chart with separate Y-axes.
* </p>
*/
public class CashFlowToChart {
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
Strategy strategy = MovingMomentumStrategy.buildStrategy(series);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series);
TradingRecord tradingRecord = seriesManager.run(strategy);
// Getting the cash flow of the resulting positions
CashFlow cashFlow = new CashFlow(series, tradingRecord);
// Creating indicators for the dual-axis chart
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// Building and displaying the dual-axis chart using ChartWorkflow
ChartWorkflow chartWorkflow = new ChartWorkflow();
chartWorkflow.displayDualAxisChart(series, closePrice, "Price (USD)", cashFlow, "Cash Flow Ratio",
"Bitstamp BTC", "Ta4j example - Cash flow to chart");
}
}
@@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.AnalysisCriterion.PositionFilter;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.AverageReturnPerBarCriterion;
import org.ta4j.core.criteria.EnterAndHoldCriterion;
import org.ta4j.core.criteria.LinearTransactionCostCriterion;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.NumberOfBarsCriterion;
import org.ta4j.core.criteria.NumberOfPositionsCriterion;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.drawdown.ReturnOverMaxDrawdownCriterion;
import org.ta4j.core.criteria.VersusEnterAndHoldCriterion;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetReturnCriterion;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.strategies.MovingMomentumStrategy;
/**
* This class displays analysis criterion values after running a trading
* strategy over a bar series.
*/
public class StrategyAnalysis {
private static final Logger LOG = LogManager.getLogger(StrategyAnalysis.class);
public static void main(String[] args) {
// Getting the bar series
var series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the trading strategy
var strategy = MovingMomentumStrategy.buildStrategy(series);
// Running the strategy
var seriesManager = new BarSeriesManager(series);
var tradingRecord = seriesManager.run(strategy);
/*
* Analysis criteria
*/
var grossReturn = new GrossReturnCriterion().calculate(series, tradingRecord);
LOG.debug("Gross return: {}", grossReturn);
var netReturnCriterion = new NetReturnCriterion();
var netReturn = netReturnCriterion.calculate(series, tradingRecord);
LOG.debug("Net return: {}", netReturn);
var numberOfBars = new NumberOfBarsCriterion().calculate(series, tradingRecord);
LOG.debug("Number of bars: {}", numberOfBars);
var AverageReturnPerBar = new AverageReturnPerBarCriterion().calculate(series, tradingRecord);
LOG.debug("Average return per bar: {}", AverageReturnPerBar);
var numberOfPositions = new NumberOfPositionsCriterion().calculate(series, tradingRecord);
LOG.debug("Number of positions: {}", numberOfPositions);
var positionsRatio = new PositionsRatioCriterion(PositionFilter.PROFIT).calculate(series, tradingRecord);
LOG.debug("Winning positions ratio: {}", positionsRatio);
var maximumDrawdown = new MaximumDrawdownCriterion().calculate(series, tradingRecord);
LOG.debug("Maximum drawdown: {}", maximumDrawdown);
var returnOverMaxDrawdown = new ReturnOverMaxDrawdownCriterion().calculate(series, tradingRecord);
LOG.debug("Return over maximum drawdown: {}", returnOverMaxDrawdown);
var linearTransactionCost = new LinearTransactionCostCriterion(1000, 0.005).calculate(series, tradingRecord);
LOG.debug("Total transaction cost (from $1000): {}", linearTransactionCost);
var enterAndHold = EnterAndHoldCriterion.EnterAndHoldReturnCriterion().calculate(series, tradingRecord);
LOG.debug("Buy-and-hold return: {}", enterAndHold);
var versusEnterAndHold = new VersusEnterAndHoldCriterion(netReturnCriterion).calculate(series, tradingRecord);
LOG.debug("Custom strategy return vs buy-and-hold strategy return: {}", versusEnterAndHold);
}
}
@@ -0,0 +1,77 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import java.text.DecimalFormat;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Indicator;
import org.ta4j.core.Rule;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.LinearBorrowingCostModel;
import org.ta4j.core.analysis.cost.LinearTransactionCostModel;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.OverIndicatorRule;
import org.ta4j.core.rules.UnderIndicatorRule;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
/**
* This class displays an example of the transaction cost calculation.
*/
public class TradeCost {
private static final Logger LOG = LogManager.getLogger(TradeCost.class);
public static void main(String[] args) {
// Getting the bar series
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
// Building the short selling trading strategy
Strategy strategy = buildShortSellingMomentumStrategy(series);
// Setting the trading cost models
double feePerTrade = 0.0005;
double borrowingFee = 0.00001;
CostModel transactionCostModel = new LinearTransactionCostModel(feePerTrade);
CostModel borrowingCostModel = new LinearBorrowingCostModel(borrowingFee);
// Running the strategy
BarSeriesManager seriesManager = new BarSeriesManager(series, transactionCostModel, borrowingCostModel);
Trade.TradeType entryTrade = Trade.TradeType.SELL;
TradingRecord tradingRecord = seriesManager.run(strategy, entryTrade);
DecimalFormat df = new DecimalFormat("##.##");
LOG.debug("------------ Borrowing Costs ------------");
tradingRecord.getPositions()
.forEach(position -> LOG.debug("Borrowing cost for {} periods is: {}",
df.format(position.getExit().getIndex() - position.getEntry().getIndex()),
df.format(position.getHoldingCost().doubleValue())));
LOG.debug("------------ Transaction Costs ------------");
tradingRecord.getPositions()
.forEach(position -> LOG.debug("Transaction cost for selling: {} -- Transaction cost for buying: {}",
df.format(position.getEntry().getCost().doubleValue()),
df.format(position.getExit().getCost().doubleValue())));
}
private static Strategy buildShortSellingMomentumStrategy(BarSeries series) {
Indicator<Num> closingPrices = new ClosePriceIndicator(series);
SMAIndicator shortEma = new SMAIndicator(closingPrices, 10);
SMAIndicator longEma = new SMAIndicator(closingPrices, 50);
Rule shortOverLongRule = new OverIndicatorRule(shortEma, longEma);
Rule shortUnderLongRule = new UnderIndicatorRule(shortEma, longEma);
String strategyName = "Momentum short-selling strategy";
return new BaseStrategy(strategyName, shortOverLongRule, shortUnderLongRule);
}
}
@@ -0,0 +1,547 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import java.awt.Color;
import java.awt.GraphicsEnvironment;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.indicators.RecentFractalSwingHighIndicator;
import org.ta4j.core.indicators.RecentFractalSwingLowIndicator;
import org.ta4j.core.indicators.RecentSwingIndicator;
import org.ta4j.core.indicators.SwingPointMarkerIndicator;
import org.ta4j.core.indicators.helpers.HighPriceIndicator;
import org.ta4j.core.indicators.helpers.LowPriceIndicator;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator.TrendLineSegment;
import org.ta4j.core.indicators.supportresistance.TrendLineResistanceIndicator;
import org.ta4j.core.indicators.supportresistance.TrendLineSupportIndicator;
import org.ta4j.core.indicators.zigzag.RecentZigZagSwingHighIndicator;
import org.ta4j.core.indicators.zigzag.RecentZigZagSwingLowIndicator;
import org.ta4j.core.num.Num;
import ta4jexamples.charting.builder.ChartPlan;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.BitStampCsvTradesFileBarSeriesDataSource;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Combined regression + visualization harness for trendline and swing point
* indicators.
*
* <p>
* This runner performs two kinds of verification:
* <ul>
* <li>Capacity headroom checks across bundled example datasets (fractal +
* ZigZag, highs + lows) to ensure default caps remain comfortably above
* real-world usage.</li>
* <li>Chart generation that overlays trendline indicators and swing point
* markers on a representative dataset, enabling quick visual inspection after
* large changes.</li>
* </ul>
*
* <p>
* The companion unit test asserts the regression invariants programmatically
* and validates that rendered overlays contain data without requiring GUI
* display.
*
* @see org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator
* @see org.ta4j.core.indicators.RecentFractalSwingHighIndicator
* @see org.ta4j.core.indicators.RecentFractalSwingLowIndicator
* @see org.ta4j.core.indicators.zigzag.RecentZigZagSwingHighIndicator
* @see org.ta4j.core.indicators.zigzag.RecentZigZagSwingLowIndicator
*/
public class TrendLineAndSwingPointAnalysis {
static final int DEFAULT_TRENDLINE_LOOKBACK = 200;
static final int DEFAULT_SURROUNDING_BARS = 5;
static final int DEFAULT_FRACTAL_PRECEDING_BARS = 5;
static final int DEFAULT_FRACTAL_FOLLOWING_BARS = 5;
static final int DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS = 0;
static final int HEADROOM_SWING_POINT_LIMIT = AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE / 2;
static final int HEADROOM_CANDIDATE_PAIR_LIMIT = AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS / 2;
static final String DEFAULT_CHART_OUTPUT_DIRECTORY = "temp/charts";
static final String DEFAULT_CHART_FILE_NAME = "trendline-swingpoint-analysis";
private static final Logger LOG = LogManager.getLogger(TrendLineAndSwingPointAnalysis.class);
/**
* Represents a dataset with its name and bar series.
*/
record Dataset(String name, BarSeries series) {
}
private record DatasetSpec(String name, Supplier<BarSeries> loader) {
}
/**
* Statistics about swing points observed in a dataset.
*
* @param maxSwings the maximum number of swing points found in any lookback
* window
* @param maxPairs the maximum number of candidate pairs (n*(n-1)/2 for n
* swings)
*/
private record SwingStats(int maxSwings, int maxPairs) {
}
private enum SwingMethod {
FRACTAL, ZIGZAG
}
private record HeadroomObservation(String datasetName, SwingMethod method, PriceSide side, int lookback,
SwingStats stats) {
}
/**
* Indicates whether we're analyzing support (lows) or resistance (highs)
* trendlines.
*/
private enum PriceSide {
SUPPORT, RESISTANCE
}
public static void main(String[] args) {
Config config = Config.fromArgs(args);
TrendLineAndSwingPointAnalysis analysis = new TrendLineAndSwingPointAnalysis();
analysis.verifyDefaultCapsHeadroomForBundledDatasets();
BarSeries chartSeries = analysis.loadChartSeries(config.chartDatasetResource());
AnalysisChartArtifacts artifacts = analysis.buildAnalysisChartArtifacts(chartSeries,
Math.min(chartSeries.getBarCount(), config.trendLineLookback()), config.surroundingBars());
analysis.renderAnalysisChart(artifacts, config);
}
void verifyDefaultCapsHeadroomForBundledDatasets() {
List<Dataset> datasets = loadRequiredDatasets();
List<HeadroomObservation> observations = new ArrayList<>();
for (Dataset dataset : datasets) {
BarSeries series = dataset.series();
int lookback = Math.min(series.getBarCount(), DEFAULT_TRENDLINE_LOOKBACK);
observations.addAll(analyzeHeadroom(dataset, lookback));
}
verifyHeadroom(observations);
logHeadroomSummary(observations, datasets.size());
}
private List<HeadroomObservation> analyzeHeadroom(Dataset dataset, int lookback) {
BarSeries series = dataset.series();
LowPriceIndicator lowPrice = new LowPriceIndicator(series);
HighPriceIndicator highPrice = new HighPriceIndicator(series);
RecentSwingIndicator fractalLows = new RecentFractalSwingLowIndicator(lowPrice, DEFAULT_FRACTAL_PRECEDING_BARS,
DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentSwingIndicator fractalHighs = new RecentFractalSwingHighIndicator(highPrice,
DEFAULT_FRACTAL_PRECEDING_BARS, DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentSwingIndicator zigzagLows = new RecentZigZagSwingLowIndicator(series);
RecentSwingIndicator zigzagHighs = new RecentZigZagSwingHighIndicator(series);
return List.of(
new HeadroomObservation(dataset.name(), SwingMethod.FRACTAL, PriceSide.SUPPORT, lookback,
analyzeSwings(series, fractalLows, PriceSide.SUPPORT, lookback)),
new HeadroomObservation(dataset.name(), SwingMethod.FRACTAL, PriceSide.RESISTANCE, lookback,
analyzeSwings(series, fractalHighs, PriceSide.RESISTANCE, lookback)),
new HeadroomObservation(dataset.name(), SwingMethod.ZIGZAG, PriceSide.SUPPORT, lookback,
analyzeSwings(series, zigzagLows, PriceSide.SUPPORT, lookback)),
new HeadroomObservation(dataset.name(), SwingMethod.ZIGZAG, PriceSide.RESISTANCE, lookback,
analyzeSwings(series, zigzagHighs, PriceSide.RESISTANCE, lookback)));
}
private SwingStats analyzeSwings(BarSeries series, RecentSwingIndicator swingIndicator, PriceSide side,
int lookback) {
final Indicator<Num> priceIndicator = swingIndicator.getPriceIndicator();
final int beginIndex = series.getBeginIndex();
final int endIndex = series.getEndIndex();
swingIndicator.getValue(endIndex);
validateSwingIndexes(series, swingIndicator.getSwingPointIndexesUpTo(endIndex));
int maxSwings = 0;
for (int end = beginIndex; end <= endIndex; end++) {
swingIndicator.getValue(end);
final int windowStart = Math.max(beginIndex, end - lookback + 1);
int swingCount = 0;
List<Integer> swingPoints = swingIndicator.getSwingPointIndexesUpTo(end);
for (int i = swingPoints.size() - 1; i >= 0; i--) {
int swingIndex = swingPoints.get(i);
if (swingIndex < windowStart) {
break;
}
if (isValidPrice(priceIndicator.getValue(swingIndex)) || isValidBarFallback(series, swingIndex, side)) {
swingCount++;
}
}
maxSwings = Math.max(maxSwings, swingCount);
}
final int maxPairs = maxSwings < 2 ? 0 : (maxSwings * (maxSwings - 1)) / 2;
return new SwingStats(maxSwings, maxPairs);
}
private boolean isValidPrice(Num value) {
return value != null && !value.isNaN() && !Double.isNaN(value.doubleValue());
}
private boolean isValidBarFallback(BarSeries series, int index, PriceSide side) {
if (series == null || index < series.getBeginIndex() || index > series.getEndIndex()) {
return false;
}
Bar bar = series.getBar(index);
final Num fallback = side == PriceSide.SUPPORT ? bar.getLowPrice() : bar.getHighPrice();
return isValidPrice(fallback);
}
private void validateSwingIndexes(BarSeries series, List<Integer> swingIndexes) {
int beginIndex = series.getBeginIndex();
int endIndex = series.getEndIndex();
Integer previous = null;
for (Integer index : swingIndexes) {
requireTrue(index != null, "Swing indicator returned a null index");
requireTrue(index >= beginIndex && index <= endIndex,
"Swing index " + index + " must fall within [" + beginIndex + ", " + endIndex + "]");
if (previous != null) {
requireTrue(index > previous,
"Swing indexes must be strictly increasing (found " + previous + " then " + index + ")");
}
previous = index;
}
}
private void verifyHeadroom(List<HeadroomObservation> observations) {
for (HeadroomObservation observation : observations) {
SwingStats stats = observation.stats();
requireTrue(stats.maxSwings() <= AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE,
headroomMessage(observation, "Exceeded default swing cap", stats.maxSwings(),
AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE));
requireTrue(stats.maxPairs() <= AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS,
headroomMessage(observation, "Exceeded default candidate-pair cap", stats.maxPairs(),
AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS));
requireTrue(stats.maxSwings() <= HEADROOM_SWING_POINT_LIMIT, headroomMessage(observation,
"Swing headroom shrank below 50%", stats.maxSwings(), HEADROOM_SWING_POINT_LIMIT));
requireTrue(stats.maxPairs() <= HEADROOM_CANDIDATE_PAIR_LIMIT, headroomMessage(observation,
"Candidate-pair headroom shrank below 50%", stats.maxPairs(), HEADROOM_CANDIDATE_PAIR_LIMIT));
}
}
private String headroomMessage(HeadroomObservation observation, String prefix, int observed, int limit) {
return prefix + " for dataset=" + observation.datasetName + ", method=" + observation.method + ", side="
+ observation.side + ", lookback=" + observation.lookback + " (observed=" + observed + ", limit="
+ limit + ")";
}
private void logHeadroomSummary(List<HeadroomObservation> observations, int datasetCount) {
int maxSwings = 0;
int maxPairs = 0;
for (HeadroomObservation observation : observations) {
SwingStats stats = observation.stats();
maxSwings = Math.max(maxSwings, stats.maxSwings());
maxPairs = Math.max(maxPairs, stats.maxPairs());
}
LOG.info(
"Trendline cap headroom summary: datasets={}, maxSwings={}, maxPairs={}, caps(swings={}, pairs={}), headroom(swings<= {}, pairs<= {})",
datasetCount, maxSwings, maxPairs, AbstractTrendLineIndicator.DEFAULT_MAX_SWING_POINTS_FOR_TRENDLINE,
AbstractTrendLineIndicator.DEFAULT_MAX_CANDIDATE_PAIRS, HEADROOM_SWING_POINT_LIMIT,
HEADROOM_CANDIDATE_PAIR_LIMIT);
}
private BarSeries loadJsonSeries(String resourceName) {
final InputStream stream = TrendLineAndSwingPointAnalysis.class.getClassLoader()
.getResourceAsStream(resourceName);
if (stream == null) {
return null;
}
return JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
}
private List<Dataset> loadRequiredDatasets() {
List<Dataset> datasets = new ArrayList<>();
for (DatasetSpec spec : requiredDatasetSpecs()) {
datasets.add(loadRequiredDataset(spec));
}
return datasets;
}
private List<DatasetSpec> requiredDatasetSpecs() {
return List.of(
new DatasetSpec("AAPL-PT1D-20130102_20131231.csv",
() -> CsvFileBarSeriesDataSource.loadSeriesFromFile("AAPL-PT1D-20130102_20131231.csv")),
new DatasetSpec("Bitstamp-BTC-USD-PT5M-20131125_20131201.csv",
() -> BitStampCsvTradesFileBarSeriesDataSource
.loadBitstampSeries("Bitstamp-BTC-USD-PT5M-20131125_20131201.csv")),
new DatasetSpec("Binance-ETH-USD-PT5M-20230313_20230315.json",
() -> loadJsonSeries("Binance-ETH-USD-PT5M-20230313_20230315.json")),
new DatasetSpec("Coinbase-ETH-USD-PT1D-20241105_20251020.json",
() -> loadJsonSeries("Coinbase-ETH-USD-PT1D-20241105_20251020.json")),
new DatasetSpec("Coinbase-ETH-USD-PT1D-20160517_20251028.json",
() -> loadJsonSeries("Coinbase-ETH-USD-PT1D-20160517_20251028.json")));
}
private Dataset loadRequiredDataset(DatasetSpec spec) {
BarSeries series;
try {
series = spec.loader.get();
} catch (Exception e) {
throw new IllegalStateException("Failed to load required dataset '" + spec.name + '\'', e);
}
requireTrue(series != null && !series.isEmpty(), "Required dataset '" + spec.name + "' is missing or empty");
return new Dataset(spec.name, series);
}
private BarSeries loadChartSeries(String datasetResource) {
if (datasetResource == null || datasetResource.isBlank()) {
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
requireTrue(series != null && !series.isEmpty(), "Default chart series is missing or empty");
return series;
}
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile(datasetResource);
if (series == null || series.isEmpty()) {
series = loadJsonSeries(datasetResource);
}
requireTrue(series != null && !series.isEmpty(), "Chart series '" + datasetResource + "' is missing or empty");
return series;
}
record TrendLineVariants(TrendLineSupportIndicator support, TrendLineSupportIndicator supportTouchBiased,
TrendLineSupportIndicator supportExtremeBiased, TrendLineResistanceIndicator resistance,
TrendLineResistanceIndicator resistanceTouchBiased, TrendLineResistanceIndicator resistanceExtremeBiased) {
}
record SwingMarkerVariants(SwingPointMarkerIndicator fractalLows, SwingPointMarkerIndicator fractalHighs,
SwingPointMarkerIndicator zigzagLows, SwingPointMarkerIndicator zigzagHighs) {
}
record AnalysisChartArtifacts(BarSeries series, TrendLineVariants trendLines, SwingMarkerVariants swings,
ChartPlan plan) {
}
AnalysisChartArtifacts buildAnalysisChartArtifacts(BarSeries series, int lookback, int surroundingBars) {
Objects.requireNonNull(series, "Series cannot be null");
requireFalse(series.isEmpty(), "Series cannot be empty");
TrendLineVariants trendLines = buildTrendLines(series, lookback, surroundingBars);
SwingMarkerVariants swings = buildSwingMarkers(series);
ChartPlan plan = buildChartPlan(series, trendLines, swings);
return new AnalysisChartArtifacts(series, trendLines, swings, plan);
}
private TrendLineVariants buildTrendLines(BarSeries series, int lookback, int surroundingBars) {
TrendLineSupportIndicator support = new TrendLineSupportIndicator(series, surroundingBars, lookback);
TrendLineSupportIndicator supportTouchBiased = new TrendLineSupportIndicator(series, surroundingBars, lookback,
TrendLineSupportIndicator.ScoringWeights.touchCountBiasPreset());
TrendLineSupportIndicator supportExtremeBiased = new TrendLineSupportIndicator(series, surroundingBars,
lookback, TrendLineSupportIndicator.ScoringWeights.extremeSwingBiasPreset());
TrendLineResistanceIndicator resistance = new TrendLineResistanceIndicator(series, surroundingBars, lookback);
TrendLineResistanceIndicator resistanceTouchBiased = new TrendLineResistanceIndicator(series, surroundingBars,
lookback, TrendLineResistanceIndicator.ScoringWeights.touchCountBiasPreset());
TrendLineResistanceIndicator resistanceExtremeBiased = new TrendLineResistanceIndicator(series, surroundingBars,
lookback, TrendLineResistanceIndicator.ScoringWeights.extremeSwingBiasPreset());
return new TrendLineVariants(support, supportTouchBiased, supportExtremeBiased, resistance,
resistanceTouchBiased, resistanceExtremeBiased);
}
private SwingMarkerVariants buildSwingMarkers(BarSeries series) {
LowPriceIndicator lowPrice = new LowPriceIndicator(series);
HighPriceIndicator highPrice = new HighPriceIndicator(series);
RecentFractalSwingLowIndicator fractalLows = new RecentFractalSwingLowIndicator(lowPrice,
DEFAULT_FRACTAL_PRECEDING_BARS, DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentFractalSwingHighIndicator fractalHighs = new RecentFractalSwingHighIndicator(highPrice,
DEFAULT_FRACTAL_PRECEDING_BARS, DEFAULT_FRACTAL_FOLLOWING_BARS, DEFAULT_FRACTAL_ALLOWED_EQUAL_BARS);
RecentZigZagSwingLowIndicator zigzagLows = new RecentZigZagSwingLowIndicator(series);
RecentZigZagSwingHighIndicator zigzagHighs = new RecentZigZagSwingHighIndicator(series);
SwingPointMarkerIndicator fractalLowMarkers = new SwingPointMarkerIndicator(series, fractalLows);
SwingPointMarkerIndicator fractalHighMarkers = new SwingPointMarkerIndicator(series, fractalHighs);
SwingPointMarkerIndicator zigzagLowMarkers = new SwingPointMarkerIndicator(series, zigzagLows);
SwingPointMarkerIndicator zigzagHighMarkers = new SwingPointMarkerIndicator(series, zigzagHighs);
return new SwingMarkerVariants(fractalLowMarkers, fractalHighMarkers, zigzagLowMarkers, zigzagHighMarkers);
}
private ChartPlan buildChartPlan(BarSeries series, TrendLineVariants trendLines, SwingMarkerVariants swings) {
ChartWorkflow chartWorkflow = new ChartWorkflow();
return chartWorkflow.builder()
.withTitle("Trendlines + Swing Points (Regression Harness)")
.withSeries(series)
.withIndicatorOverlay(trendLines.support)
.withLineColor(Color.GREEN)
.withLineWidth(2.0f)
.withOpacity(0.55f)
.withLabel("Support (default)")
.withIndicatorOverlay(trendLines.supportTouchBiased)
.withLineColor(Color.BLUE)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Support (touchCountBiasPreset)")
.withIndicatorOverlay(trendLines.supportExtremeBiased)
.withLineColor(Color.MAGENTA)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Support (extremeSwingBiasPreset)")
.withIndicatorOverlay(trendLines.resistance)
.withLineColor(Color.RED)
.withLineWidth(2.0f)
.withOpacity(0.55f)
.withLabel("Resistance (default)")
.withIndicatorOverlay(trendLines.resistanceTouchBiased)
.withLineColor(Color.CYAN)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Resistance (touchCountBiasPreset)")
.withIndicatorOverlay(trendLines.resistanceExtremeBiased)
.withLineColor(Color.ORANGE)
.withLineWidth(2.0f)
.withOpacity(0.45f)
.withLabel("Resistance (extremeSwingBiasPreset)")
.withIndicatorOverlay(swings.fractalLows)
.withLineColor(Color.GREEN)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.85f)
.withLabel("Fractal swing lows")
.withIndicatorOverlay(swings.fractalHighs)
.withLineColor(Color.RED)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.85f)
.withLabel("Fractal swing highs")
.withIndicatorOverlay(swings.zigzagLows)
.withLineColor(Color.GREEN)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.25f)
.withLabel("ZigZag swing lows")
.withIndicatorOverlay(swings.zigzagHighs)
.withLineColor(Color.RED)
.withLineWidth(3.0f)
.withConnectAcrossNaN(true)
.withOpacity(0.25f)
.withLabel("ZigZag swing highs")
.toPlan();
}
private void renderAnalysisChart(AnalysisChartArtifacts artifacts, Config config) {
int endIndex = artifacts.series().getEndIndex();
TrendLineVariants trendLines = artifacts.trendLines();
warmTrendLine(trendLines.support(), endIndex, "Support (default)");
warmTrendLine(trendLines.supportTouchBiased(), endIndex, "Support (touchCountBiasPreset)");
warmTrendLine(trendLines.supportExtremeBiased(), endIndex, "Support (extremeSwingBiasPreset)");
warmTrendLine(trendLines.resistance(), endIndex, "Resistance (default)");
warmTrendLine(trendLines.resistanceTouchBiased(), endIndex, "Resistance (touchCountBiasPreset)");
warmTrendLine(trendLines.resistanceExtremeBiased(), endIndex, "Resistance (extremeSwingBiasPreset)");
ChartWorkflow chartWorkflow = new ChartWorkflow();
if (config.saveChart()) {
chartWorkflow.save(artifacts.plan(), config.chartOutputDirectory(), config.chartFileName());
}
if (config.displayChart() && !GraphicsEnvironment.isHeadless()) {
try {
chartWorkflow.display(artifacts.plan());
} catch (Exception e) {
LOG.warn("Unable to display chart; continuing (headless runner?)", e);
}
}
}
private void warmTrendLine(AbstractTrendLineIndicator trendLine, int endIndex, String label) {
trendLine.getValue(endIndex);
TrendLineSegment segment = trendLine.getCurrentSegment();
if (segment == null) {
LOG.info("{} trendline: no active segment", label);
return;
}
LOG.info(
"{} trendline anchors=({}, {}), slope={}, intercept={}, swingTouches={}, swingsOutside={}, anchoredAtExtreme={}, score={}",
label, segment.firstIndex, segment.secondIndex, segment.slope, segment.intercept, segment.touchCount,
segment.outsideCount, segment.touchesExtreme, segment.score);
}
private record Config(String chartDatasetResource, String chartOutputDirectory, String chartFileName,
boolean displayChart, boolean saveChart, int trendLineLookback, int surroundingBars) {
private static Config fromArgs(String[] args) {
String dataset = null;
String outDir = DEFAULT_CHART_OUTPUT_DIRECTORY;
String fileName = DEFAULT_CHART_FILE_NAME;
boolean display = true;
boolean save = true;
int lookback = DEFAULT_TRENDLINE_LOOKBACK;
int surroundingBars = DEFAULT_SURROUNDING_BARS;
if (args != null) {
for (String arg : args) {
if (arg == null || arg.isBlank()) {
continue;
}
if (arg.equals("--no-display")) {
display = false;
continue;
}
if (arg.equals("--no-save")) {
save = false;
continue;
}
if (arg.startsWith("--dataset=")) {
dataset = arg.substring("--dataset=".length());
continue;
}
if (arg.startsWith("--outDir=")) {
outDir = arg.substring("--outDir=".length());
continue;
}
if (arg.startsWith("--file=")) {
fileName = arg.substring("--file=".length());
continue;
}
if (arg.startsWith("--lookback=")) {
lookback = Integer.parseInt(arg.substring("--lookback=".length()));
continue;
}
if (arg.startsWith("--surroundingBars=")) {
surroundingBars = Integer.parseInt(arg.substring("--surroundingBars=".length()));
}
}
}
return new Config(dataset, outDir, fileName, display, save, lookback, surroundingBars);
}
}
private static void requireTrue(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private static void requireFalse(boolean condition, String message) {
requireTrue(!condition, message);
}
}
@@ -0,0 +1,844 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import java.math.RoundingMode;
import java.math.BigDecimal;
import java.io.IOException;
import java.util.Optional;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.indicators.elliott.ElliottInvalidationIndicator;
import org.ta4j.core.indicators.elliott.ElliottConfluenceIndicator;
import org.ta4j.core.indicators.elliott.ElliottChannelIndicator;
import org.ta4j.core.indicators.elliott.ElliottRatio.RatioType;
import org.ta4j.core.indicators.elliott.ElliottPhaseIndicator;
import org.ta4j.core.indicators.elliott.ElliottRatioIndicator;
import org.ta4j.core.indicators.elliott.ElliottSwingMetadata;
import org.ta4j.core.indicators.elliott.ElliottScenarioSet;
import org.ta4j.core.indicators.elliott.ElliottConfidence;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottChannel;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottRatio;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ScenarioType;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.walkforward.ElliottWaveWalkForwardProfiles;
import org.ta4j.core.num.Num;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.charting.builder.ChartPlan;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.ExclusionStrategy;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.JsonToken;
import com.google.gson.FieldAttributes;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.Gson;
/**
* Domain model capturing all Elliott Wave analysis results currently logged via
* {@link ElliottWaveIndicatorSuiteDemo#logBaseCaseScenario(ElliottScenario)}
* and {@link ElliottWaveIndicatorSuiteDemo#logAlternativeScenarios(List)}.
* <p>
* This class provides structured access to analysis results including swing
* snapshots, phase information, ratio and channel data, confluence scores,
* scenario summaries, trend bias, and detailed base case and alternative
* scenario information.
* <p>
* The class contains pre-rendered chart images (PNG format, base64-encoded) for
* all scenarios, allowing charts to be viewed without requiring external data
* or chart generation libraries. Charts are embedded as base64-encoded PNG byte
* arrays, making them directly viewable in browsers, reports, or any system
* that supports PNG images.
* <p>
* The class is serializable to JSON using Gson, providing a structured
* representation suitable for storage, transmission, or further processing.
*
* @see ElliottWaveIndicatorSuiteDemo
* @since 0.22.4
*/
public record ElliottWaveAnalysisReport(ElliottDegree degree, int endIndex, SwingSnapshot swingSnapshot,
LatestAnalysis latestAnalysis, ScenarioSummary scenarioSummary, ElliottTrendBias trendBias,
ProbabilityCalibration probabilityCalibration, OutlookGate outlookGate, BaseCaseScenario baseCase,
List<AlternativeScenario> alternatives, String baseCaseChartImage, List<String> alternativeChartImages) {
private static final Logger LOG = LogManager.getLogger(ElliottWaveAnalysisReport.class);
private static final double SCENARIO_TYPE_OVERLAP_WEIGHT = 0.3;
private static final double CONSENSUS_ADJUSTMENT_WEIGHT = 0.4;
private static final double DIRECTION_OVERLAP_WEIGHT = 0.2;
private static final double PHASE_OVERLAP_WEIGHT = 0.5;
private static final double MIN_CONFIDENCE_CONTRAST_EXPONENT = 1.5;
private static final double MAX_CONFIDENCE_CONTRAST_EXPONENT = 6.0;
private static final double CONFIDENCE_SPREAD_TARGET = 0.25;
private static final String CALIBRATION_METHOD = "centered_shrinkage_renormalized";
private static final String CALIBRATION_PROFILE = "wf-baseline-minute-f2-h2l2-max25-sw0__k1-200-65-320";
private static final double CALIBRATION_BASE_SHRINK_FACTOR = 0.72;
private static final double CALIBRATION_STRONG_CONSENSUS_BONUS = 0.08;
private static final double CALIBRATION_DIRECTIONAL_CONSENSUS_BONUS = 0.06;
private static final double CALIBRATION_WEAK_TREND_PENALTY = 0.06;
private static final double CALIBRATION_MIN_SHRINK_FACTOR = 0.45;
private static final double CALIBRATION_MAX_SHRINK_FACTOR = 0.95;
private static final double OUTLOOK_MIN_TOP_PROBABILITY = 0.30;
private static final double OUTLOOK_MIN_TOP_TWO_SPREAD = 0.03;
private static final double OUTLOOK_MIN_TOP_THREE_SPREAD = 0.08;
private static final double OUTLOOK_MIN_TREND_STRENGTH = 0.15;
private static final double CALIBRATION_EPSILON = 1.0e-12;
private static final TypeAdapter<Double> NULLING_DOUBLE_ADAPTER = new TypeAdapter<>() {
@Override
public void write(JsonWriter out, Double value) throws IOException {
if (value == null || value.isNaN() || value.isInfinite()) {
out.nullValue();
return;
}
out.value(value);
}
@Override
public Double read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
return in.nextDouble();
}
};
/**
* Creates an analysis result from the current analysis state.
*
* @param degree the Elliott wave degree used
* @param swingMetadata swing metadata snapshot
* @param phaseIndicator phase indicator (for latest phase and
* confirmation flags)
* @param ratioIndicator ratio indicator (for latest ratio)
* @param channelIndicator channel indicator (for latest channel)
* @param confluenceIndicator confluence indicator (for latest confluence)
* @param invalidationIndicator invalidation indicator (for latest invalidation)
* @param scenarioSet scenario set (for scenario summary and
* scenarios)
* @param endIndex index at which to evaluate indicators
* @param baseCaseChartPlan chart plan for base case scenario (optional)
* @param alternativeChartPlans chart plans for alternative scenarios
* @return analysis result capturing all logged data and chart images
*/
public static ElliottWaveAnalysisReport from(ElliottDegree degree, ElliottSwingMetadata swingMetadata,
ElliottPhaseIndicator phaseIndicator, ElliottRatioIndicator ratioIndicator,
ElliottChannelIndicator channelIndicator, ElliottConfluenceIndicator confluenceIndicator,
ElliottInvalidationIndicator invalidationIndicator, ElliottScenarioSet scenarioSet, int endIndex,
Optional<ChartPlan> baseCaseChartPlan, List<ChartPlan> alternativeChartPlans) {
Objects.requireNonNull(degree, "degree");
Objects.requireNonNull(swingMetadata, "swingMetadata");
Objects.requireNonNull(phaseIndicator, "phaseIndicator");
Objects.requireNonNull(ratioIndicator, "ratioIndicator");
Objects.requireNonNull(channelIndicator, "channelIndicator");
Objects.requireNonNull(confluenceIndicator, "confluenceIndicator");
Objects.requireNonNull(invalidationIndicator, "invalidationIndicator");
Objects.requireNonNull(scenarioSet, "scenarioSet");
Objects.requireNonNull(baseCaseChartPlan, "baseCaseChartPlan");
Objects.requireNonNull(alternativeChartPlans, "alternativeChartPlans");
SwingSnapshot snapshot = SwingSnapshot.from(swingMetadata);
LatestAnalysis latest = LatestAnalysis.from(phaseIndicator, ratioIndicator, channelIndicator,
confluenceIndicator, invalidationIndicator, endIndex);
ScenarioSummary summary = ScenarioSummary.from(scenarioSet);
ElliottTrendBias trendBias = scenarioSet.trendBias();
Map<String, Double> scenarioProbabilities = computeScenarioProbabilities(scenarioSet);
CalibrationResult calibrationResult = calibrateScenarioProbabilities(scenarioProbabilities, summary, trendBias);
Map<String, Double> calibratedProbabilities = calibrationResult.calibratedProbabilities();
ProbabilityCalibration probabilityCalibration = calibrationResult.calibration();
OutlookGate outlookGate = OutlookGate.from(scenarioProbabilities, calibratedProbabilities, summary, trendBias);
BaseCaseScenario baseCase = scenarioSet.base()
.map(scenario -> BaseCaseScenario.from(scenario, scenarioProbabilities.getOrDefault(scenario.id(), 0.0),
calibratedProbabilities.getOrDefault(scenario.id(), 0.0)))
.orElse(null);
List<AlternativeScenario> alternatives = scenarioSet.alternatives()
.stream()
.map(scenario -> AlternativeScenario.from(scenario,
scenarioProbabilities.getOrDefault(scenario.id(), 0.0),
calibratedProbabilities.getOrDefault(scenario.id(), 0.0)))
.toList();
ChartWorkflow chartWorkflow = new ChartWorkflow();
String baseCaseChartImage = baseCaseChartPlan.map(plan -> encodeChartAsBase64(chartWorkflow, plan))
.orElse(null);
List<String> alternativeChartImages = alternativeChartPlans.stream()
.map(plan -> encodeChartAsBase64(chartWorkflow, plan))
.toList();
return new ElliottWaveAnalysisReport(degree, endIndex, snapshot, latest, summary, trendBias,
probabilityCalibration, outlookGate, baseCase, alternatives, baseCaseChartImage,
alternativeChartImages);
}
/**
* Serializes this analysis result to JSON using Gson.
* <p>
* By default, chart image data is excluded to keep the JSON size manageable.
* Use {@link #toJson(boolean)} with {@code true} to include chart images.
*
* @return JSON representation of the analysis result (without chart images)
*/
public String toJson() {
return toJson(false);
}
/**
* Serializes this analysis result to JSON using Gson.
*
* @param includeChartData if {@code true}, includes base64-encoded PNG chart
* images; if {@code false}, excludes chart images to
* reduce JSON size
* @return JSON representation of the analysis result
*/
public String toJson(boolean includeChartData) {
GsonBuilder builder = new GsonBuilder().setPrettyPrinting()
.serializeNulls()
.registerTypeAdapter(Double.class, NULLING_DOUBLE_ADAPTER)
.registerTypeAdapter(Double.TYPE, NULLING_DOUBLE_ADAPTER);
if (!includeChartData) {
builder.setExclusionStrategies(new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes f) {
return "baseCaseChartImage".equals(f.getName()) || "alternativeChartImages".equals(f.getName());
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return false;
}
});
}
Gson gson = builder.create();
return gson.toJson(this);
}
/**
* Swing snapshot data capturing the current state of detected swings.
*
* @param valid whether the snapshot contains valid prices for each swing
* @param swings number of swings represented
* @param high highest price touched by any swing
* @param low lowest price touched by any swing
*/
public record SwingSnapshot(boolean valid, int swings, double high, double low) {
static SwingSnapshot from(ElliottSwingMetadata metadata) {
return new SwingSnapshot(metadata.isValid(), metadata.size(), safeDoubleValue(metadata.highestPrice()),
safeDoubleValue(metadata.lowestPrice()));
}
}
/**
* Latest analysis results at the evaluation index.
*
* @param phase current Elliott phase
* @param impulseConfirmed whether impulse waves are confirmed
* @param correctiveConfirmed whether corrective waves are confirmed
* @param ratioType type of ratio relationship (retracement/extension)
* @param ratioValue measured ratio value
* @param channel channel data (null if invalid)
* @param confluenceScore confluence score
* @param confluent whether confluence threshold is met
* @param invalidation whether current price action invalidates the wave
* count
*/
public record LatestAnalysis(ElliottPhase phase, boolean impulseConfirmed, boolean correctiveConfirmed,
RatioType ratioType, double ratioValue, ChannelData channel, double confluenceScore, boolean confluent,
boolean invalidation) {
static LatestAnalysis from(ElliottPhaseIndicator phaseIndicator, ElliottRatioIndicator ratioIndicator,
ElliottChannelIndicator channelIndicator, ElliottConfluenceIndicator confluenceIndicator,
ElliottInvalidationIndicator invalidationIndicator, int endIndex) {
ElliottPhase phase = phaseIndicator.getValue(endIndex);
boolean impulseConfirmed = phaseIndicator.isImpulseConfirmed(endIndex);
boolean correctiveConfirmed = phaseIndicator.isCorrectiveConfirmed(endIndex);
ElliottRatio ratio = ratioIndicator.getValue(endIndex);
RatioType ratioType = ratio != null ? ratio.type() : RatioType.NONE;
double ratioValue = ratio != null ? safeDoubleValue(ratio.value()) : Double.NaN;
ElliottChannel channel = channelIndicator.getValue(endIndex);
ChannelData channelData = channel != null && channel.isValid() ? ChannelData.from(channel) : null;
Num confluenceNum = confluenceIndicator.getValue(endIndex);
double confluenceScore = safeDoubleValue(confluenceNum);
boolean confluent = confluenceIndicator.isConfluent(endIndex);
boolean invalidation = invalidationIndicator.getValue(endIndex);
return new LatestAnalysis(phase, impulseConfirmed, correctiveConfirmed, ratioType, ratioValue, channelData,
confluenceScore, confluent, invalidation);
}
}
/**
* Channel boundary data.
*
* @param valid whether the channel is valid
* @param upper expected resistance boundary
* @param lower expected support boundary
* @param median arithmetic midline between upper and lower bounds
*/
public record ChannelData(boolean valid, double upper, double lower, double median) {
static ChannelData from(ElliottChannel channel) {
return new ChannelData(channel.isValid(), safeDoubleValue(channel.upper()),
safeDoubleValue(channel.lower()), safeDoubleValue(channel.median()));
}
}
/**
* Scenario summary across all scenarios.
*
* @param summary human-readable summary describing scenario
* distribution
* @param strongConsensus whether there is strong consensus (single
* high-confidence scenario or large spread)
* @param consensusPhase agreed-upon phase if all high-confidence scenarios
* match, otherwise NONE
*/
public record ScenarioSummary(String summary, boolean strongConsensus, ElliottPhase consensusPhase) {
static ScenarioSummary from(ElliottScenarioSet scenarioSet) {
return new ScenarioSummary(scenarioSet.summary(), scenarioSet.hasStrongConsensus(),
scenarioSet.consensus());
}
}
/**
* Metadata describing how probabilities were calibrated for this analysis run.
*
* @param profile calibration profile id sourced from walk-forward tuning
* @param method calibration transform identifier
* @param shrinkFactor shrink factor applied to centered scenario probabilities
*/
public record ProbabilityCalibration(String profile, String method, double shrinkFactor) {
static ProbabilityCalibration from(double shrinkFactor) {
final String profile = CALIBRATION_PROFILE + "|cfg="
+ ElliottWaveWalkForwardProfiles.baselineConfig().configHash();
return new ProbabilityCalibration(profile, CALIBRATION_METHOD, shrinkFactor);
}
}
/**
* Directional outlook gate status.
*
* <p>
* The gate blocks directional publication when scenario probabilities are too
* crowded or consensus signals are weak.
*
* @param eligible whether directional outlook can be published
* @param outlookLabel directional label or {@code NEUTRAL}
* @param reason concise gate decision explanation
* @param topScenarioProbability top raw scenario probability
* @param calibratedTopProbability top calibrated scenario probability
* @param topTwoSpread spread between top-1 and top-2 raw
* probabilities
* @param topThreeSpread spread between top-1 and top-3 raw
* probabilities
* @param strongConsensus whether strong scenario consensus is present
* @param directionalConsensus whether directional consensus is present
* @param trendStrength trend bias strength (0.0-1.0)
*/
public record OutlookGate(boolean eligible, String outlookLabel, String reason, double topScenarioProbability,
double calibratedTopProbability, double topTwoSpread, double topThreeSpread, boolean strongConsensus,
boolean directionalConsensus, double trendStrength) {
static OutlookGate from(Map<String, Double> rawProbabilities, Map<String, Double> calibratedProbabilities,
ScenarioSummary summary, ElliottTrendBias trendBias) {
if (rawProbabilities == null || rawProbabilities.isEmpty()) {
return new OutlookGate(false, "NEUTRAL", "No scenarios available", Double.NaN, Double.NaN, Double.NaN,
Double.NaN, false, false, Double.NaN);
}
final List<Double> rawSorted = rawProbabilities.values()
.stream()
.filter(Double::isFinite)
.sorted((a, b) -> Double.compare(b, a))
.toList();
final List<Double> calibratedSorted = calibratedProbabilities.values()
.stream()
.filter(Double::isFinite)
.sorted((a, b) -> Double.compare(b, a))
.toList();
if (rawSorted.isEmpty()) {
return new OutlookGate(false, "NEUTRAL", "Scenario probabilities were not finite", Double.NaN,
Double.NaN, Double.NaN, Double.NaN, false, false, Double.NaN);
}
final double top = rawSorted.get(0);
final double topTwoSpread = rawSorted.size() > 1 ? top - rawSorted.get(1) : top;
final double topThreeSpread = rawSorted.size() > 2 ? top - rawSorted.get(2) : topTwoSpread;
final double calibratedTop = calibratedSorted.isEmpty() ? Double.NaN : calibratedSorted.get(0);
final boolean strongConsensus = summary != null && summary.strongConsensus();
final boolean directionalConsensus = trendBias != null && trendBias.consensus();
final boolean knownTrend = trendBias != null && !trendBias.isUnknown() && !trendBias.isNeutral();
final double trendStrength = trendBias == null ? Double.NaN : trendBias.strength();
final boolean strongTrend = Double.isFinite(trendStrength) && trendStrength >= OUTLOOK_MIN_TREND_STRENGTH;
final boolean topProbabilityPass = top >= OUTLOOK_MIN_TOP_PROBABILITY;
final boolean spreadPass = topTwoSpread >= OUTLOOK_MIN_TOP_TWO_SPREAD
&& topThreeSpread >= OUTLOOK_MIN_TOP_THREE_SPREAD;
final boolean eligible = strongConsensus && directionalConsensus && knownTrend && strongTrend
&& topProbabilityPass && spreadPass;
final String label = eligible ? trendBias.direction().name() : "NEUTRAL";
final String reason;
if (eligible) {
reason = "Directional outlook passed consensus and spread gates";
} else if (!strongConsensus) {
reason = "Strong scenario consensus not established";
} else if (!directionalConsensus || !knownTrend) {
reason = "Directional consensus is weak or trend is neutral";
} else if (!strongTrend) {
reason = "Trend strength is below baseline threshold";
} else if (!topProbabilityPass) {
reason = "Top scenario probability is below publication threshold";
} else {
reason = "Top scenarios are too close; low-conviction outlook";
}
return new OutlookGate(eligible, label, reason, top, calibratedTop, topTwoSpread, topThreeSpread,
strongConsensus, directionalConsensus, trendStrength);
}
}
/**
* Internal result container for calibrated probabilities and calibration
* metadata.
*/
private record CalibrationResult(Map<String, Double> calibratedProbabilities, ProbabilityCalibration calibration) {
}
/**
* Base case scenario details.
*
* @param currentPhase the phase this scenario assigns to current price
* action
* @param type pattern type classification
* @param overallConfidence overall confidence percentage (0-100)
* @param scenarioProbability raw scenario probability ratio (0.0-1.0)
* @param calibratedProbability walk-forward calibrated scenario probability
* ratio (0.0-1.0)
* @param confidenceLevel confidence level (HIGH, MEDIUM, or LOW)
* @param fibonacciScore Fibonacci proximity score as percentage (0-100)
* @param timeScore time proportion score as percentage (0-100)
* @param alternationScore alternation quality score as percentage (0-100)
* @param channelScore channel adherence score as percentage (0-100)
* @param completenessScore structure completeness score as percentage
* (0-100)
* @param primaryReason human-readable description of dominant factor
* @param weakestFactor description of the weakest scoring factor
* @param direction direction (BULLISH or BEARISH)
* @param invalidationPrice price level that would invalidate this count
* @param primaryTarget primary Fibonacci projection target
* @param swings swing sequence for building wave labels
*/
public record BaseCaseScenario(ElliottPhase currentPhase, ScenarioType type, double overallConfidence,
@JsonAdapter(ScenarioProbabilityAdapter.class) double scenarioProbability,
@JsonAdapter(ScenarioProbabilityAdapter.class) double calibratedProbability, String confidenceLevel,
double fibonacciScore, double timeScore, double alternationScore, double channelScore,
double completenessScore, String primaryReason, String weakestFactor, String direction,
double invalidationPrice, double primaryTarget, List<SwingData> swings) {
static BaseCaseScenario from(ElliottScenario scenario, double scenarioProbability,
double calibratedProbability) {
ElliottConfidence confidence = scenario.confidence();
double overallConfidence = confidence.asPercentage();
String confidenceLevel = confidence.isHighConfidence() ? "HIGH"
: confidence.isLowConfidence() ? "LOW" : "MEDIUM";
double fibonacciScore = safeDoubleValue(confidence.fibonacciScore()) * 100.0;
double timeScore = safeDoubleValue(confidence.timeProportionScore()) * 100.0;
double alternationScore = safeDoubleValue(confidence.alternationScore()) * 100.0;
double channelScore = safeDoubleValue(confidence.channelScore()) * 100.0;
double completenessScore = safeDoubleValue(confidence.completenessScore()) * 100.0;
String direction = scenario.isBullish() ? "BULLISH" : "BEARISH";
double invalidationPrice = safeDoubleValue(scenario.invalidationPrice());
double primaryTarget = safeDoubleValue(scenario.primaryTarget());
List<SwingData> swings = scenario.swings().stream().map(SwingData::from).toList();
return new BaseCaseScenario(scenario.currentPhase(), scenario.type(), overallConfidence,
scenarioProbability, calibratedProbability, confidenceLevel, fibonacciScore, timeScore,
alternationScore, channelScore, completenessScore, confidence.primaryReason(),
confidence.weakestFactor(), direction, invalidationPrice, primaryTarget, swings);
}
}
/**
* Alternative scenario details.
*
* @param currentPhase the phase this scenario assigns to current price
* action
* @param type pattern type classification
* @param confidencePercent overall confidence percentage (0-100)
* @param scenarioProbability raw scenario probability ratio (0.0-1.0)
* @param calibratedProbability walk-forward calibrated scenario probability
* ratio (0.0-1.0)
* @param swings swing sequence for building wave labels
*/
public record AlternativeScenario(ElliottPhase currentPhase, ScenarioType type, double confidencePercent,
@JsonAdapter(ScenarioProbabilityAdapter.class) double scenarioProbability,
@JsonAdapter(ScenarioProbabilityAdapter.class) double calibratedProbability, List<SwingData> swings) {
static AlternativeScenario from(ElliottScenario scenario, double scenarioProbability,
double calibratedProbability) {
List<SwingData> swings = scenario.swings().stream().map(SwingData::from).toList();
return new AlternativeScenario(scenario.currentPhase(), scenario.type(),
scenario.confidence().asPercentage(), scenarioProbability, calibratedProbability, swings);
}
}
/**
* Swing data for building wave labels.
*
* @param fromIndex starting bar index
* @param toIndex ending bar index
* @param fromPrice starting price
* @param toPrice ending price
* @param isRising whether the swing is rising
*/
public record SwingData(int fromIndex, int toIndex, double fromPrice, double toPrice, boolean isRising) {
static SwingData from(ElliottSwing swing) {
return new SwingData(swing.fromIndex(), swing.toIndex(), safeDoubleValue(swing.fromPrice()),
safeDoubleValue(swing.toPrice()), swing.isRising());
}
}
/**
* Computes scenario probabilities by applying adaptive contrast to confidence
* scores, then tilting toward overlapping consensus factors (phase, type, and
* direction).
*
* @param scenarioSet scenario set to evaluate
* @return scenario probability ratios keyed by scenario id
*/
static Map<String, Double> computeScenarioProbabilities(ElliottScenarioSet scenarioSet) {
Objects.requireNonNull(scenarioSet, "scenarioSet");
List<ElliottScenario> scenarios = scenarioSet.all();
if (scenarios.isEmpty()) {
return Map.of();
}
EnumMap<ElliottPhase, Integer> phaseCounts = new EnumMap<>(ElliottPhase.class);
EnumMap<ScenarioType, Integer> typeCounts = new EnumMap<>(ScenarioType.class);
double minConfidence = Double.POSITIVE_INFINITY;
double maxConfidence = Double.NEGATIVE_INFINITY;
int scenarioCount = scenarios.size();
double[] confidenceScores = new double[scenarioCount];
int knownPhaseCount = 0;
int knownTypeCount = 0;
int bullishCount = 0;
int bearishCount = 0;
int knownDirectionCount = 0;
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
ElliottPhase phase = scenario.currentPhase();
if (phase != ElliottPhase.NONE) {
phaseCounts.merge(phase, 1, Integer::sum);
knownPhaseCount++;
}
ScenarioType type = scenario.type();
if (type != ScenarioType.UNKNOWN) {
typeCounts.merge(type, 1, Integer::sum);
knownTypeCount++;
}
if (scenario.hasKnownDirection()) {
knownDirectionCount++;
if (scenario.isBullish()) {
bullishCount++;
} else {
bearishCount++;
}
}
double confidence = safeScoreValue(scenario.confidenceScore());
confidenceScores[i] = confidence;
minConfidence = Math.min(minConfidence, confidence);
maxConfidence = Math.max(maxConfidence, confidence);
}
double[] baseWeights = new double[scenarioCount];
double totalConfidence = 0.0;
double contrastExponent = confidenceContrastExponent(minConfidence, maxConfidence, scenarioCount);
for (int i = 0; i < scenarioCount; i++) {
double confidence = confidenceScores[i];
double contrasted = applyConfidenceContrast(confidence, contrastExponent);
baseWeights[i] = contrasted;
totalConfidence += contrasted;
}
if (totalConfidence > 0.0) {
for (int i = 0; i < scenarioCount; i++) {
baseWeights[i] /= totalConfidence;
}
} else {
double equalWeight = 1.0 / scenarioCount;
for (int i = 0; i < scenarioCount; i++) {
baseWeights[i] = equalWeight;
}
}
double[] overlapScores = new double[scenarioCount];
double overlapTotal = 0.0;
int overlapCount = 0;
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
double overlapScore = overlapScoreForScenario(scenario, phaseCounts, knownPhaseCount, typeCounts,
knownTypeCount, bullishCount, bearishCount, knownDirectionCount);
overlapScores[i] = overlapScore;
if (overlapScore > 0.0) {
overlapTotal += overlapScore;
overlapCount++;
}
}
double averageOverlap = overlapCount > 0 ? overlapTotal / overlapCount : 0.0;
double[] adjustedWeights = new double[scenarioCount];
double adjustedTotal = 0.0;
for (int i = 0; i < scenarioCount; i++) {
double overlapScore = overlapScores[i];
double multiplier = overlapScore > 0.0
? 1.0 + (CONSENSUS_ADJUSTMENT_WEIGHT * (overlapScore - averageOverlap))
: 1.0;
double adjustedWeight = baseWeights[i] * multiplier;
adjustedWeights[i] = adjustedWeight;
adjustedTotal += adjustedWeight;
}
Map<String, Double> probabilities = new HashMap<>();
if (adjustedTotal <= 0.0) {
double fallback = 1.0 / scenarioCount;
for (ElliottScenario scenario : scenarios) {
probabilities.put(scenario.id(), fallback);
}
return Map.copyOf(probabilities);
}
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
double probability = adjustedWeights[i] / adjustedTotal;
probabilities.put(scenario.id(), probability);
}
return Map.copyOf(probabilities);
}
/**
* Applies walk-forward-informed probability calibration using centered
* shrinkage followed by renormalization.
*
* <p>
* The transform shrinks probabilities toward the uniform prior to reduce
* over-confident tails while preserving ordering signals from the raw scenario
* model.
*/
private static CalibrationResult calibrateScenarioProbabilities(Map<String, Double> rawProbabilities,
ScenarioSummary summary, ElliottTrendBias trendBias) {
if (rawProbabilities == null || rawProbabilities.isEmpty()) {
ProbabilityCalibration calibration = ProbabilityCalibration.from(CALIBRATION_BASE_SHRINK_FACTOR);
return new CalibrationResult(Map.of(), calibration);
}
Map<String, Double> normalizedRaw = normalizeProbabilityMap(rawProbabilities);
if (normalizedRaw.isEmpty()) {
ProbabilityCalibration calibration = ProbabilityCalibration.from(CALIBRATION_BASE_SHRINK_FACTOR);
return new CalibrationResult(Map.of(), calibration);
}
double shrinkFactor = CALIBRATION_BASE_SHRINK_FACTOR;
if (summary != null && summary.strongConsensus()) {
shrinkFactor += CALIBRATION_STRONG_CONSENSUS_BONUS;
}
if (trendBias != null && trendBias.consensus()) {
shrinkFactor += CALIBRATION_DIRECTIONAL_CONSENSUS_BONUS;
}
if (trendBias == null || trendBias.isUnknown() || trendBias.isNeutral()
|| !Double.isFinite(trendBias.strength()) || trendBias.strength() < OUTLOOK_MIN_TREND_STRENGTH) {
shrinkFactor -= CALIBRATION_WEAK_TREND_PENALTY;
}
shrinkFactor = clamp(shrinkFactor, CALIBRATION_MIN_SHRINK_FACTOR, CALIBRATION_MAX_SHRINK_FACTOR);
int scenarioCount = normalizedRaw.size();
double uniformPrior = 1.0 / scenarioCount;
Map<String, Double> centered = new LinkedHashMap<>();
for (Map.Entry<String, Double> entry : normalizedRaw.entrySet()) {
double raw = entry.getValue();
double calibrated = uniformPrior + (shrinkFactor * (raw - uniformPrior));
centered.put(entry.getKey(), Math.max(CALIBRATION_EPSILON, calibrated));
}
Map<String, Double> calibratedProbabilities = normalizeProbabilityMap(centered);
ProbabilityCalibration calibration = ProbabilityCalibration.from(shrinkFactor);
return new CalibrationResult(calibratedProbabilities, calibration);
}
private static double overlapScoreForScenario(ElliottScenario scenario, EnumMap<ElliottPhase, Integer> phaseCounts,
int knownPhaseCount, EnumMap<ScenarioType, Integer> typeCounts, int knownTypeCount, int bullishCount,
int bearishCount, int knownDirectionCount) {
double weightedSum = 0.0;
double weightTotal = 0.0;
ElliottPhase phase = scenario.currentPhase();
if (phase != ElliottPhase.NONE && knownPhaseCount > 0) {
weightedSum += PHASE_OVERLAP_WEIGHT * (phaseCounts.getOrDefault(phase, 0) / (double) knownPhaseCount);
weightTotal += PHASE_OVERLAP_WEIGHT;
}
ScenarioType type = scenario.type();
if (type != ScenarioType.UNKNOWN && knownTypeCount > 0) {
weightedSum += SCENARIO_TYPE_OVERLAP_WEIGHT * (typeCounts.getOrDefault(type, 0) / (double) knownTypeCount);
weightTotal += SCENARIO_TYPE_OVERLAP_WEIGHT;
}
if (scenario.hasKnownDirection() && knownDirectionCount > 0) {
double directionOverlap = scenario.isBullish() ? (double) bullishCount / knownDirectionCount
: (double) bearishCount / knownDirectionCount;
weightedSum += DIRECTION_OVERLAP_WEIGHT * directionOverlap;
weightTotal += DIRECTION_OVERLAP_WEIGHT;
}
if (weightTotal <= 0.0) {
return 0.0;
}
return weightedSum / weightTotal;
}
private static double confidenceContrastExponent(double minConfidence, double maxConfidence, int scenarioCount) {
if (scenarioCount <= 1) {
return 1.0;
}
double spread = maxConfidence - minConfidence;
if (spread <= 0.0) {
return MAX_CONFIDENCE_CONTRAST_EXPONENT;
}
double normalizedSpread = Math.min(1.0, spread / CONFIDENCE_SPREAD_TARGET);
return MIN_CONFIDENCE_CONTRAST_EXPONENT
+ (MAX_CONFIDENCE_CONTRAST_EXPONENT - MIN_CONFIDENCE_CONTRAST_EXPONENT) * (1.0 - normalizedSpread);
}
private static double applyConfidenceContrast(double confidence, double exponent) {
if (confidence <= 0.0) {
return 0.0;
}
if (exponent <= 1.0) {
return confidence;
}
return Math.pow(confidence, exponent);
}
private static Map<String, Double> normalizeProbabilityMap(Map<String, Double> probabilities) {
if (probabilities == null || probabilities.isEmpty()) {
return Map.of();
}
Map<String, Double> finitePositive = new LinkedHashMap<>();
double total = 0.0;
for (Map.Entry<String, Double> entry : probabilities.entrySet()) {
if (entry.getKey() == null) {
continue;
}
Double raw = entry.getValue();
if (raw == null || !Double.isFinite(raw)) {
continue;
}
double sanitized = Math.max(0.0, raw.doubleValue());
if (sanitized <= 0.0) {
continue;
}
finitePositive.put(entry.getKey(), sanitized);
total += sanitized;
}
if (finitePositive.isEmpty() || total <= CALIBRATION_EPSILON) {
return Map.of();
}
Map<String, Double> normalized = new LinkedHashMap<>();
for (Map.Entry<String, Double> entry : finitePositive.entrySet()) {
normalized.put(entry.getKey(), entry.getValue() / total);
}
return Map.copyOf(normalized);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
/**
* Encodes a chart plan as a base64-encoded PNG image string.
*
* @param chartWorkflow the chart workflow for rendering
* @param chartPlan the chart plan to encode
* @return base64-encoded PNG image string, or null if encoding fails
*/
private static String encodeChartAsBase64(ChartWorkflow chartWorkflow, ChartPlan chartPlan) {
try {
byte[] pngBytes = chartWorkflow.getChartAsByteArray(chartWorkflow.render(chartPlan));
return Base64.getEncoder().encodeToString(pngBytes);
} catch (Exception ex) {
LOG.warn("Chart encoding failed for chart plan {}: {}", chartPlan, ex.getMessage(), ex);
return null;
}
}
/**
* Safely converts a Num to a confidence score, treating invalid values as zero.
*
* @param num the numeric value to convert
* @return double value, or 0.0 if null or invalid
*/
private static double safeScoreValue(Num num) {
if (num == null || !Num.isValid(num)) {
return 0.0;
}
return num.doubleValue();
}
private static double roundScenarioProbability(double value) {
return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue();
}
/**
* Safely converts a Num to double, handling null and NaN cases.
*
* @param num the numeric value to convert
* @return double value, or NaN if null or invalid
*/
private static double safeDoubleValue(Num num) {
if (num == null || !Num.isValid(num)) {
return Double.NaN;
}
return num.doubleValue();
}
private static final class ScenarioProbabilityAdapter extends TypeAdapter<Double> {
@Override
public void write(JsonWriter out, Double value) throws IOException {
if (value == null || value.isNaN() || value.isInfinite()) {
out.nullValue();
return;
}
out.value(roundScenarioProbability(value));
}
@Override
public Double read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
return in.nextDouble();
}
}
}
@@ -0,0 +1,254 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveBtcMacroCycleDemo;
/**
* Consolidated preset launcher for Elliott Wave demos.
*
* <p>
* This entry point replaces multiple thin wrappers by supporting:
* <ul>
* <li>Ossified presets mapped to bundled datasets</li>
* <li>Live runs where users can specify any ticker</li>
* </ul>
*
* <p>
* Usage:
* <ul>
* <li>{@code ossified <btc|eth|sp500>}</li>
* <li>{@code live <Coinbase|YahooFinance> <ticker> [barDuration] [lookbackDays] [degree]}</li>
* </ul>
*
* <p>
* Examples:
* <ul>
* <li>{@code ossified btc}</li>
* <li>{@code live Coinbase BTC-USD PT1D 1825}</li>
* <li>{@code live YahooFinance AAPL PT1D 1460 PRIMARY}</li>
* </ul>
*
* @since 0.22.4
*/
public class ElliottWavePresetDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWavePresetDemo.class);
private static final String DEFAULT_BAR_DURATION = "PT1D";
private static final long DEFAULT_LOOKBACK_DAYS = 1825L;
private static final Path DEFAULT_BTC_MACRO_CHART_DIRECTORY = Path.of("temp", "charts");
/**
* Runs a preset Elliott Wave demo.
*
* @param args command-line arguments; see class-level usage documentation
*/
public static void main(String[] args) {
if (args == null || args.length == 0) {
logUsage();
return;
}
final String mode = normalize(args[0]);
switch (mode) {
case "ossified":
runOssified(args);
return;
case "live":
runLive(args);
return;
default:
LOG.error("Unknown mode '{}'. Expected 'ossified' or 'live'.", args[0]);
logUsage();
}
}
private static void runOssified(String[] args) {
if (args.length < 2) {
LOG.error("Missing ossified preset. Expected one of: btc, eth, sp500");
logUsage();
return;
}
Optional<OssifiedPreset> preset = OssifiedPreset.fromToken(args[1]);
if (preset.isEmpty()) {
LOG.error("Unknown ossified preset '{}'. Expected one of: btc, eth, sp500", args[1]);
logUsage();
return;
}
OssifiedPreset selected = preset.orElseThrow();
ElliottWaveIndicatorSuiteDemo.runOssifiedResource(ElliottWavePresetDemo.class, selected.resource(),
selected.seriesName(), selected.degreeOverride().orElse(null));
}
private static void runLive(String[] args) {
if (args.length < 3) {
LOG.error("Live mode expects at least dataSource and ticker");
logUsage();
return;
}
final String dataSource = Objects.requireNonNull(args[1], "dataSource");
final String ticker = Objects.requireNonNull(args[2], "ticker");
final String barDuration = args.length > 3 ? args[3] : DEFAULT_BAR_DURATION;
long lookbackDays = DEFAULT_LOOKBACK_DAYS;
if (args.length > 4) {
try {
lookbackDays = Long.parseLong(args[4]);
} catch (NumberFormatException ex) {
LOG.error("Invalid lookbackDays '{}': expected a positive integer", args[4]);
return;
}
}
if (lookbackDays <= 0) {
LOG.error("Invalid lookbackDays '{}': must be > 0", lookbackDays);
return;
}
ElliottDegree degree = null;
if (args.length > 5) {
try {
degree = ElliottDegree.valueOf(args[5].trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
LOG.error("Invalid degree '{}'", args[5]);
return;
}
}
if (shouldUseBtcMacroPreset(ticker, barDuration)) {
if (degree != null) {
LOG.info(
"Ignoring explicit degree '{}' for BTC daily macro preset; the validated macro profile selects its own structural interpretation.",
degree);
}
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofDays(lookbackDays));
Duration parsedDuration = parseBarDuration(barDuration);
BarSeries series = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(dataSource, ticker,
parsedDuration, startTime, endTime);
if (series == null || series.isEmpty()) {
LOG.error("Unable to load live BTC series for macro preset from {} {} {}", dataSource, ticker,
barDuration);
return;
}
ElliottWaveBtcMacroCycleDemo.runLivePreset(series, DEFAULT_BTC_MACRO_CHART_DIRECTORY);
return;
}
String[] suiteArgs = buildLiveSuiteArgs(dataSource, ticker, barDuration, lookbackDays, Instant.now(), degree);
ElliottWaveIndicatorSuiteDemo.main(suiteArgs);
}
static boolean shouldUseBtcMacroPreset(String ticker, String barDuration) {
if (ticker == null || barDuration == null) {
return false;
}
String normalizedTicker = ticker.trim().toUpperCase(Locale.ROOT);
String normalizedDuration = barDuration.trim().toUpperCase(Locale.ROOT);
return "BTC-USD".equals(normalizedTicker)
&& ("PT1D".equals(normalizedDuration) || "PT24H".equals(normalizedDuration));
}
static String[] buildLiveSuiteArgs(String dataSource, String ticker, String barDuration, long lookbackDays,
Instant endTime, ElliottDegree degree) {
Objects.requireNonNull(dataSource, "dataSource");
Objects.requireNonNull(ticker, "ticker");
Objects.requireNonNull(barDuration, "barDuration");
Objects.requireNonNull(endTime, "endTime");
if (lookbackDays <= 0) {
throw new IllegalArgumentException("lookbackDays must be > 0");
}
Instant startTime = endTime.minus(Duration.ofDays(lookbackDays));
if (degree == null) {
return new String[] { dataSource, ticker, barDuration, Long.toString(startTime.getEpochSecond()),
Long.toString(endTime.getEpochSecond()) };
}
return new String[] { dataSource, ticker, barDuration, degree.name(), Long.toString(startTime.getEpochSecond()),
Long.toString(endTime.getEpochSecond()) };
}
private static void logUsage() {
LOG.info("Usage:");
LOG.info(" ossified <btc|eth|sp500>");
LOG.info(" live <Coinbase|YahooFinance> <ticker> [barDuration] [lookbackDays] [degree]");
}
private static Duration parseBarDuration(String barDuration) {
Objects.requireNonNull(barDuration, "barDuration");
String normalized = barDuration.trim().toUpperCase(Locale.ROOT);
if (normalized.startsWith("PT") && normalized.endsWith("D")) {
final String dayString = normalized.substring(2, normalized.length() - 1);
try {
final int days = Integer.parseInt(dayString);
return Duration.ofHours(days * 24L);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid day count in barDuration: " + barDuration, e);
}
}
return Duration.parse(normalized);
}
private static String normalize(String value) {
if (value == null) {
return "";
}
return value.trim().toLowerCase(Locale.ROOT);
}
private enum OssifiedPreset {
BTC("Coinbase-BTC-USD-PT1D-20230616_20231011.json", "BTC-USD_PT1D@Coinbase (ossified)", Optional.empty()),
ETH("Coinbase-ETH-USD-PT1D-20241105_20251020.json", "ETH-USD_PT1D@Coinbase (ossified)", Optional.empty()),
SP500("YahooFinance-SP500-PT1D-20230616_20231011.json", "^GSPC_PT1D@YahooFinance (ossified)", Optional.empty());
private final String resource;
private final String seriesName;
private final Optional<ElliottDegree> degreeOverride;
OssifiedPreset(String resource, String seriesName, Optional<ElliottDegree> degreeOverride) {
this.resource = resource;
this.seriesName = seriesName;
this.degreeOverride = degreeOverride;
}
static Optional<OssifiedPreset> fromToken(String token) {
if (token == null) {
return Optional.empty();
}
String normalized = token.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "btc" -> Optional.of(BTC);
case "eth" -> Optional.of(ETH);
case "sp500", "s&p500", "gspc", "^gspc" -> Optional.of(SP500);
default -> Optional.empty();
};
}
String resource() {
return resource;
}
String seriesName() {
return seriesName;
}
Optional<ElliottDegree> degreeOverride() {
return degreeOverride;
}
}
}
@@ -0,0 +1,418 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import com.google.gson.Gson;
/**
* Loads and resolves the BTC anchor registry used by the CF-17 anchor-aware
* calibration study.
*
* <p>
* The registry stores broad calendar windows plus provenance. Resolution is
* deterministic: each anchor window is collapsed to the highest high or lowest
* low inside the local ossified BTC dataset, then the resolved anchors are
* partitioned chronologically into validation and holdout segments.
*
* <p>
* These committed windows are the canonical BTC daily validation set for the
* CF-17 macro study.
* {@link ElliottWaveAnchorCalibrationHarness#defaultBitcoinAnchors(BarSeries)}
* preserves that contract by translating the distance from the resolved
* extremum to each window edge into {@code toleranceBefore} and
* {@code toleranceAfter}, so acceptable match windows stay pinned to the
* registry instead of drifting via runtime heuristics.
*
* @since 0.22.7
*/
final class ElliottWaveAnchorRegistry {
static final String DEFAULT_RESOURCE = "/ta4jexamples/analysis/elliottwave/backtest/BTC-anchor-registry-v2.json";
private static final Gson GSON = new Gson();
private final String registryId;
private final String datasetResource;
private final String provenance;
private final List<AnchorSpec> anchors;
private ElliottWaveAnchorRegistry(String registryId, String datasetResource, String provenance,
List<AnchorSpec> anchors) {
this.registryId = requireText(registryId, "registryId");
this.datasetResource = requireText(datasetResource, "datasetResource");
this.provenance = requireText(provenance, "provenance");
if (anchors == null || anchors.isEmpty()) {
throw new IllegalArgumentException("anchors must not be empty");
}
this.anchors = anchors.stream().sorted(Comparator.comparing(AnchorSpec::windowStart)).toList();
}
/**
* Loads the registry from the given classpath resource.
*
* @param resource classpath resource path
* @return parsed registry
* @since 0.22.7
*/
static ElliottWaveAnchorRegistry load(String resource) {
String normalized = resource != null && resource.startsWith("/") ? resource : "/" + resource;
try (InputStream stream = ElliottWaveAnchorRegistry.class.getResourceAsStream(normalized)) {
if (stream == null) {
throw new IllegalStateException("Missing anchor registry resource: " + normalized);
}
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
RegistryDocument document = GSON.fromJson(reader, RegistryDocument.class);
Objects.requireNonNull(document, "document");
List<RegistryAnchor> rawAnchors = document.anchors();
if (rawAnchors == null) {
throw new IllegalArgumentException("Anchor registry " + normalized + " is missing \"anchors\"");
}
List<AnchorSpec> anchorSpecs = new ArrayList<>(rawAnchors.size());
for (int index = 0; index < rawAnchors.size(); index++) {
RegistryAnchor anchor = rawAnchors.get(index);
if (anchor == null) {
throw new IllegalArgumentException(
"Anchor registry " + normalized + " contains null anchor at index " + index);
}
anchorSpecs.add(anchor.toSpec());
}
return new ElliottWaveAnchorRegistry(document.registryId(), document.datasetResource(),
document.provenance(), anchorSpecs);
}
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("Failed to load anchor registry: " + normalized, ex);
}
}
/**
* Resolves every stored anchor window against the supplied series and assigns
* the trailing anchors to holdout.
*
* <p>
* Resolution never expands or contracts the committed calendar windows. The
* JSON window bounds remain authoritative; the selected bar is simply the local
* extremum inside each stored range.
*
* @param series BTC series used for resolution
* @param holdoutCount trailing resolved anchors reserved for holdout
* @return resolved anchors in chronological order
* @since 0.22.7
*/
List<ResolvedAnchor> resolve(BarSeries series, int holdoutCount) {
Objects.requireNonNull(series, "series");
List<ResolvedAnchor> resolved = new ArrayList<>(anchors.size());
for (AnchorSpec anchor : anchors) {
resolved.add(resolve(series, anchor));
}
resolved.sort(Comparator.comparing(ResolvedAnchor::resolvedTime));
if (holdoutCount < 0 || holdoutCount > resolved.size()) {
throw new IllegalArgumentException("holdoutCount must be between 0 and " + resolved.size());
}
int validationCutoff = resolved.size() - holdoutCount;
List<ResolvedAnchor> partitioned = new ArrayList<>(resolved.size());
for (int i = 0; i < resolved.size(); i++) {
AnchorPartition partition = i < validationCutoff ? AnchorPartition.VALIDATION : AnchorPartition.HOLDOUT;
partitioned.add(resolved.get(i).withPartition(partition));
}
return List.copyOf(partitioned);
}
/**
* @return registry identifier
* @since 0.22.7
*/
String registryId() {
return registryId;
}
/**
* @return backing BTC dataset resource name
* @since 0.22.7
*/
String datasetResource() {
return datasetResource;
}
/**
* @return provenance summary
* @since 0.22.7
*/
String provenance() {
return provenance;
}
/**
* @return stored anchor specs
* @since 0.22.7
*/
List<AnchorSpec> anchors() {
return anchors;
}
private static ResolvedAnchor resolve(BarSeries series, AnchorSpec anchor) {
long midpointEpochMillis = anchor.windowStart().toEpochMilli()
+ ((anchor.windowEnd().toEpochMilli() - anchor.windowStart().toEpochMilli()) / 2L);
int bestIndex = -1;
double bestPrice = anchor.kind() == AnchorKind.TOP ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
long bestDistance = Long.MAX_VALUE;
boolean foundBarInWindow = false;
boolean foundValidPrice = false;
for (int index = series.getBeginIndex(); index <= series.getEndIndex(); index++) {
Bar bar = series.getBar(index);
Instant endTime = bar.getEndTime();
if (endTime.isBefore(anchor.windowStart()) || endTime.isAfter(anchor.windowEnd())) {
continue;
}
foundBarInWindow = true;
double candidatePrice = priceFor(bar, anchor.kind());
if (!Double.isFinite(candidatePrice)) {
continue;
}
foundValidPrice = true;
long candidateDistance = Math.abs(endTime.toEpochMilli() - midpointEpochMillis);
if (shouldReplaceBest(anchor.kind(), candidatePrice, bestPrice, candidateDistance, bestDistance, index,
bestIndex)) {
bestIndex = index;
bestPrice = candidatePrice;
bestDistance = candidateDistance;
}
}
if (bestIndex < 0) {
if (!foundBarInWindow) {
throw new IllegalStateException("No bars found inside anchor window " + anchor.id());
}
if (!foundValidPrice) {
throw new IllegalStateException("Bars found inside anchor window " + anchor.id() + " but no finite "
+ anchor.kind() + " prices were available");
}
throw new IllegalStateException("Failed to resolve anchor window " + anchor.id());
}
return new ResolvedAnchor(anchor, bestIndex, series.getBar(bestIndex).getEndTime(), bestPrice,
AnchorPartition.VALIDATION);
}
private static boolean better(AnchorKind kind, double candidatePrice, double bestPrice) {
if (kind == AnchorKind.TOP) {
return candidatePrice > bestPrice;
}
return candidatePrice < bestPrice;
}
private static boolean shouldReplaceBest(AnchorKind kind, double candidatePrice, double bestPrice,
long candidateDistance, long bestDistance, int candidateIndex, int bestIndex) {
return bestIndex < 0 || better(kind, candidatePrice, bestPrice)
|| (same(candidatePrice, bestPrice) && (candidateDistance < bestDistance
|| (candidateDistance == bestDistance && candidateIndex < bestIndex)));
}
private static boolean same(double left, double right) {
return Double.compare(left, right) == 0;
}
private static double priceFor(Bar bar, AnchorKind kind) {
Objects.requireNonNull(bar, "bar");
double value = kind == AnchorKind.TOP ? bar.getHighPrice().doubleValue() : bar.getLowPrice().doubleValue();
if (Double.isNaN(value) || Double.isInfinite(value)) {
value = bar.getClosePrice().doubleValue();
}
return value;
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field);
if (value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
private static AnchorKind parseKind(String kind, String anchorId) {
String normalizedKind = requireText(kind, "kind");
try {
return AnchorKind.valueOf(normalizedKind);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unknown anchor kind '" + normalizedKind + "' for anchor " + anchorId,
ex);
}
}
private static Instant parseInstant(String value, String field, String anchorId) {
String normalizedValue = requireText(value, field);
try {
return Instant.parse(normalizedValue);
} catch (DateTimeParseException ex) {
throw new IllegalArgumentException("Invalid " + field + " '" + normalizedValue + "' for anchor " + anchorId,
ex);
}
}
private static ElliottPhase parsePhase(String phase, String anchorId) {
String normalizedPhase = requireText(phase, "phase");
try {
return ElliottPhase.valueOf(normalizedPhase);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException(
"Unknown expected phase '" + normalizedPhase + "' for anchor " + anchorId, ex);
}
}
/**
* Anchor direction used when resolving local extrema.
*
* @since 0.22.7
*/
enum AnchorKind {
TOP, BOTTOM
}
/**
* Chronological anchor partition used for validation-versus-holdout reporting.
*
* @since 0.22.7
*/
enum AnchorPartition {
VALIDATION, HOLDOUT
}
/**
* Immutable anchor specification loaded from the registry file.
*
* @param id stable anchor identifier
* @param label human-readable label
* @param kind anchor direction
* @param windowStart inclusive window start
* @param windowEnd inclusive window end
* @param expectedPhases acceptable Elliott phases for a hit
* @param source provenance string
* @param notes optional note
* @since 0.22.7
*/
record AnchorSpec(String id, String label, AnchorKind kind, Instant windowStart, Instant windowEnd,
Set<ElliottPhase> expectedPhases, String source, String notes) {
/**
* Creates a validated anchor specification.
*/
AnchorSpec {
id = requireText(id, "id");
label = requireText(label, "label");
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(windowStart, "windowStart");
Objects.requireNonNull(windowEnd, "windowEnd");
if (windowEnd.isBefore(windowStart)) {
throw new IllegalArgumentException("windowEnd must not be before windowStart");
}
expectedPhases = expectedPhases == null || expectedPhases.isEmpty() ? EnumSet.noneOf(ElliottPhase.class)
: Collections.unmodifiableSet(EnumSet.copyOf(expectedPhases));
if (expectedPhases.isEmpty()) {
throw new IllegalArgumentException("expectedPhases must not be empty");
}
source = requireText(source, "source");
notes = notes == null ? "" : notes.trim();
}
}
/**
* Concrete anchor resolved against the BTC series.
*
* @param spec original anchor specification
* @param decisionIndex resolved series index
* @param resolvedTime resolved bar end time
* @param resolvedPrice resolved bar high/low used for matching
* @param partition validation or holdout
* @since 0.22.7
*/
record ResolvedAnchor(AnchorSpec spec, int decisionIndex, Instant resolvedTime, double resolvedPrice,
AnchorPartition partition) {
/**
* Creates a validated resolved anchor.
*/
ResolvedAnchor {
Objects.requireNonNull(spec, "spec");
if (decisionIndex < 0) {
throw new IllegalArgumentException("decisionIndex must be >= 0");
}
Objects.requireNonNull(resolvedTime, "resolvedTime");
Objects.requireNonNull(partition, "partition");
if (Double.isNaN(resolvedPrice) || Double.isInfinite(resolvedPrice)) {
throw new IllegalArgumentException("resolvedPrice must be finite");
}
}
/**
* Creates a copy with a different partition.
*
* @param newPartition new partition
* @return copied resolved anchor
* @since 0.22.7
*/
ResolvedAnchor withPartition(AnchorPartition newPartition) {
return new ResolvedAnchor(spec, decisionIndex, resolvedTime, resolvedPrice, newPartition);
}
}
/**
* Raw registry document loaded through Gson.
*
* @param registryId registry identifier
* @param datasetResource dataset resource name
* @param provenance provenance summary
* @param anchors raw anchors
*/
private record RegistryDocument(String registryId, String datasetResource, String provenance,
List<RegistryAnchor> anchors) {
}
/**
* Raw anchor entry loaded through Gson.
*
* @param id stable anchor identifier
* @param label human-readable label
* @param kind anchor direction
* @param windowStart inclusive window start in ISO-8601 form
* @param windowEnd inclusive window end in ISO-8601 form
* @param expectedPhases accepted Elliott phases as enum names
* @param source provenance string
* @param notes optional note
*/
private record RegistryAnchor(String id, String label, String kind, String windowStart, String windowEnd,
List<String> expectedPhases, String source, String notes) {
private AnchorSpec toSpec() {
Set<ElliottPhase> phases = EnumSet.noneOf(ElliottPhase.class);
for (String phase : Objects.requireNonNull(expectedPhases, "expectedPhases")) {
phases.add(parsePhase(phase, id));
}
return new AnchorSpec(id, label, parseKind(kind, id), parseInstant(windowStart, "windowStart", id),
parseInstant(windowEnd, "windowEnd", id), phases, source, notes);
}
}
}
@@ -0,0 +1,201 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.awt.Color;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.CurrentCycleSummary;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.DirectionalCycleSummary;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.HypothesisResult;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.MacroStudy;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.ProfileScoreSummary;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* BTC-specific wrapper around the generic macro-cycle demo.
*
* <p>
* The generic demo now owns both the historical macro study and the live
* current-cycle reporting flow. This wrapper exists to keep the fixed BTC
* dataset entry points, the locked BTC anchor truth set, and the canonical BTC
* chart/summary filenames stable for users and regression tests.
*
* @since 0.22.4
*/
public final class ElliottWaveBtcMacroCycleDemo {
static final String RESULT_PREFIX = "EW_BTC_MACRO_DEMO: ";
static final String LIVE_RESULT_PREFIX = "EW_BTC_LIVE_MACRO: ";
static final Path DEFAULT_CHART_DIRECTORY = Path.of("temp", "charts");
static final String DEFAULT_CHART_FILE_NAME = "elliott-wave-btc-macro-cycles";
static final String DEFAULT_SUMMARY_FILE_NAME = "elliott-wave-btc-macro-cycles-summary.json";
static final String DEFAULT_LIVE_CHART_FILE_NAME = "elliott-wave-btc-live-macro-current-cycle";
static final String DEFAULT_LIVE_SUMMARY_FILE_NAME = "elliott-wave-btc-live-macro-current-cycle-summary.json";
static final int DEFAULT_CHART_WIDTH = 3840;
static final int DEFAULT_CHART_HEIGHT = 2160;
static final int MIN_CORE_SEGMENT_SCENARIOS = 1000;
static final int MAX_CORE_ANCHOR_DRIFT_BARS = 3;
static final double DEFAULT_ACCEPTED_SEGMENT_SCORE = 0.64;
static final int LABEL_CLUSTER_BAR_GAP = 18;
static final Color BULLISH_LEG_COLOR = new Color(0x66BB6A);
static final Color BEARISH_LEG_COLOR = new Color(0xEF5350);
static final Color BULLISH_WAVE_COLOR = new Color(0x81C784);
static final Color BEARISH_WAVE_COLOR = new Color(0xE57373);
static final Color BULLISH_CANDIDATE_COLOR = new Color(0xC8E6C9);
static final Color BEARISH_CANDIDATE_COLOR = new Color(0xFFCDD2);
static final Color ANCHOR_OVERLAY_COLOR = new Color(0xCFD8DC);
static final double WAVE_LABEL_FONT_SCALE = 3.0;
static final double EPSILON = 1e-9;
private static final Logger LOG = LogManager.getLogger(ElliottWaveBtcMacroCycleDemo.class);
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String BTC_LIVE_HISTORICAL_STATUS = "BTC macro profile prevalidated from historical cycle truth set";
private ElliottWaveBtcMacroCycleDemo() {
}
/**
* Runs the BTC macro-cycle study and logs the resulting JSON summary.
*
* @param args unused
*/
public static void main(final String[] args) {
final DemoReport report = generateReport(DEFAULT_CHART_DIRECTORY);
LOG.info("{}{}", RESULT_PREFIX, report.toJson());
}
/**
* Runs the series-native live BTC macro preset on the supplied series and logs
* the resulting JSON summary.
*
* @param series live or loaded BTC series to analyze
* @param chartDirectory directory for the saved current-cycle chart and JSON
* summary
* @since 0.22.4
*/
public static void runLivePreset(final BarSeries series, final Path chartDirectory) {
ElliottWaveMacroCycleDemo.runLivePreset(series, chartDirectory, DEFAULT_LIVE_CHART_FILE_NAME,
DEFAULT_LIVE_SUMMARY_FILE_NAME, "btc-usd", BTC_LIVE_HISTORICAL_STATUS);
}
static DemoReport generateReport(final Path chartDirectory) {
final BarSeries series = requireSeries(ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE,
ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME);
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry = ElliottWaveAnchorCalibrationHarness
.defaultBitcoinAnchors(series);
return DemoReport.from(ElliottWaveMacroCycleDemo.generateHistoricalReport(series, registry, chartDirectory));
}
static DemoReport generateReport(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry, final Path chartDirectory) {
return DemoReport.from(ElliottWaveMacroCycleDemo.generateHistoricalReport(series, registry, chartDirectory));
}
static LivePresetReport generateLivePresetReport(final BarSeries series, final Path chartDirectory) {
return LivePresetReport.from(ElliottWaveMacroCycleDemo.generateLivePresetReport(series, chartDirectory,
DEFAULT_LIVE_CHART_FILE_NAME, DEFAULT_LIVE_SUMMARY_FILE_NAME, BTC_LIVE_HISTORICAL_STATUS));
}
static Optional<Path> saveMacroCycleChart(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry, final Path chartDirectory) {
return ElliottWaveMacroCycleDemo.saveHistoricalChart(series, registry, chartDirectory);
}
static JFreeChart renderMacroCycleChart(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry) {
return ElliottWaveMacroCycleDemo.renderHistoricalChart(series, registry);
}
static JFreeChart renderMacroCycleChart(final BarSeries series, final MacroStudy study) {
return ElliottWaveMacroCycleDemo.renderHistoricalChart(series, study);
}
static MacroStudy evaluateMacroStudy(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry) {
return ElliottWaveMacroCycleDemo.evaluateMacroStudy(series, registry);
}
private static BarSeries requireSeries(final String resource, final String seriesName) {
final BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveBtcMacroCycleDemo.class,
resource, seriesName, LOG);
if (series == null) {
throw new IllegalStateException("Unable to load required resource " + resource);
}
return series;
}
record DemoReport(String registryVersion, String datasetResource, String baselineProfileId,
String selectedProfileId, String selectedHypothesisId, boolean historicalFitPassed,
String harnessDecisionRationale, String chartPath, String summaryPath, String structureSource,
List<ProfileScoreSummary> profileScores, List<DirectionalCycleSummary> cycles,
List<HypothesisResult> hypotheses, CurrentCycleSummary currentCycle) {
DemoReport {
Objects.requireNonNull(registryVersion, "registryVersion");
Objects.requireNonNull(datasetResource, "datasetResource");
Objects.requireNonNull(baselineProfileId, "baselineProfileId");
Objects.requireNonNull(selectedProfileId, "selectedProfileId");
Objects.requireNonNull(selectedHypothesisId, "selectedHypothesisId");
Objects.requireNonNull(harnessDecisionRationale, "harnessDecisionRationale");
Objects.requireNonNull(chartPath, "chartPath");
Objects.requireNonNull(summaryPath, "summaryPath");
Objects.requireNonNull(structureSource, "structureSource");
profileScores = profileScores == null ? List.of() : List.copyOf(profileScores);
cycles = cycles == null ? List.of() : List.copyOf(cycles);
hypotheses = hypotheses == null ? List.of() : List.copyOf(hypotheses);
Objects.requireNonNull(currentCycle, "currentCycle");
}
String toJson() {
return GSON.toJson(this);
}
static DemoReport from(final ElliottWaveMacroCycleDemo.DemoReport report) {
return new DemoReport(report.registryVersion(), report.datasetResource(), report.baselineProfileId(),
report.selectedProfileId(), report.selectedHypothesisId(), report.historicalFitPassed(),
report.harnessDecisionRationale(), report.chartPath(), report.summaryPath(),
report.structureSource(), report.profileScores(), report.cycles(), report.hypotheses(),
report.currentCycle());
}
}
record LivePresetReport(String seriesName, String startTimeUtc, String latestTimeUtc, String selectedProfileId,
String selectedHypothesisId, String chartPath, String summaryPath, String structureSource,
CurrentCycleSummary currentCycle) {
LivePresetReport {
Objects.requireNonNull(seriesName, "seriesName");
Objects.requireNonNull(startTimeUtc, "startTimeUtc");
Objects.requireNonNull(latestTimeUtc, "latestTimeUtc");
Objects.requireNonNull(selectedProfileId, "selectedProfileId");
Objects.requireNonNull(selectedHypothesisId, "selectedHypothesisId");
Objects.requireNonNull(chartPath, "chartPath");
Objects.requireNonNull(summaryPath, "summaryPath");
Objects.requireNonNull(structureSource, "structureSource");
Objects.requireNonNull(currentCycle, "currentCycle");
}
String toJson() {
return GSON.toJson(this);
}
static LivePresetReport from(final ElliottWaveMacroCycleDemo.LivePresetReport report) {
return new LivePresetReport(report.seriesName(), report.startTimeUtc(), report.latestTimeUtc(),
report.selectedProfileId(), report.selectedHypothesisId(), report.chartPath(), report.summaryPath(),
report.structureSource(), report.currentCycle());
}
}
}
@@ -0,0 +1,234 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottLogicProfile;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
/**
* Infers broad macro-cycle anchors directly from a series-wide Elliott swing
* set.
*
* <p>
* This detector is the anchor-free path for {@link ElliottWaveMacroCycleDemo}.
* It runs the same full-history core analysis profile used by the BTC macro
* demo, then collapses the processed swing pivots into major cycle turns by
* keeping only all-time-high peaks that are followed by severe drawdowns before
* the next higher high. The detector keeps the deepest corrective low inside
* that regime, then rejects short-lived crashes that never mature into broad
* macro cycles. The resulting top/bottom chain is converted into an
* {@link ElliottWaveAnchorCalibrationHarness.AnchorRegistry} so the existing
* historical chart and JSON report flow can be reused unchanged.
*
* <p>
* The heuristic is intentionally simple:
* <ul>
* <li>build a full-history orthodox swing map with no curated anchors</li>
* <li>track each new all-time-high pivot</li>
* <li>keep only peaks that lead to a material trough before the next higher
* high</li>
* </ul>
*
* <p>
* For BTC full-history runs this naturally recovers the committed 2011, 2013,
* 2017, and 2021 macro tops plus their following corrective lows closely enough
* to compare against the truth-set registry.
*
* @since 0.22.7
*/
final class ElliottWaveMacroCycleDetector {
private static final String INFERRED_REGISTRY_VERSION = "inferred-macro-cycle-anchors-v1";
private static final int HOLDOUT_ANCHOR_COUNT = 2;
private static final double MIN_MACRO_DRAWDOWN_FRACTION = 0.55;
private static final Duration MIN_MACRO_SPAN = Duration.ofDays(120);
private static final Duration DEFAULT_TOLERANCE = Duration.ofDays(45);
private ElliottWaveMacroCycleDetector() {
}
/**
* Infers a macro-cycle anchor registry for the supplied series.
*
* @param series series to analyze
* @return inferred anchor registry
* @since 0.22.7
*/
static ElliottWaveAnchorCalibrationHarness.AnchorRegistry inferAnchorRegistry(final BarSeries series) {
Objects.requireNonNull(series, "series");
final ElliottWaveAnalysisResult analysis = buildRunner().analyze(series);
final ElliottAnalysisResult baseAnalysis = analysis.analysisFor(ElliottDegree.MINOR)
.orElseThrow(() -> new IllegalStateException("Missing base-degree MINOR analysis"))
.analysis();
final List<Pivot> pivots = toPivots(series, baseAnalysis.rawSwings());
final List<MacroDrawdown> macroDrawdowns = detectMacroDrawdowns(pivots);
if (macroDrawdowns.isEmpty()) {
throw new IllegalStateException("Unable to infer macro-cycle anchors from processed swings");
}
return new ElliottWaveAnchorCalibrationHarness.AnchorRegistry(INFERRED_REGISTRY_VERSION,
datasetResource(series), inferredProvenance(series), toAnchors(macroDrawdowns));
}
private static ElliottWaveAnalysisRunner buildRunner() {
return ElliottWaveAnalysisRunner.builder()
.degree(ElliottDegree.MINOR)
.logicProfile(ElliottLogicProfile.ORTHODOX_CLASSICAL)
.maxScenarios(ElliottLogicProfile.ORTHODOX_CLASSICAL.maxScenarios())
.minConfidence(0.0)
.seriesSelector((inputSeries, ignoredDegree) -> inputSeries)
.build();
}
private static List<Pivot> toPivots(final BarSeries series, final List<ElliottSwing> processedSwings) {
if (processedSwings == null || processedSwings.isEmpty()) {
return List.of();
}
final List<Pivot> pivots = new ArrayList<>(processedSwings.size() + 1);
final ElliottSwing firstSwing = processedSwings.getFirst();
pivots.add(new Pivot(firstSwing.fromIndex(), series.getBar(firstSwing.fromIndex()).getEndTime(),
firstSwing.fromPrice().doubleValue(), !firstSwing.isRising()));
for (final ElliottSwing swing : processedSwings) {
pivots.add(new Pivot(swing.toIndex(), series.getBar(swing.toIndex()).getEndTime(),
swing.toPrice().doubleValue(), swing.isRising()));
}
return List.copyOf(pivots);
}
private static List<MacroDrawdown> detectMacroDrawdowns(final List<Pivot> pivots) {
final List<Pivot> allTimeHighs = new ArrayList<>();
double bestHigh = Double.NEGATIVE_INFINITY;
for (final Pivot pivot : pivots) {
if (!pivot.high() || pivot.price() <= bestHigh) {
continue;
}
allTimeHighs.add(pivot);
bestHigh = pivot.price();
}
final List<MacroDrawdown> drawdowns = new ArrayList<>();
for (int index = 0; index < allTimeHighs.size(); index++) {
final Pivot top = allTimeHighs.get(index);
final Instant nextHigherHighTime = index + 1 < allTimeHighs.size() ? allTimeHighs.get(index + 1).at()
: null;
Pivot trough = lowestLowAfter(top, pivots, nextHigherHighTime);
if (trough == null) {
continue;
}
final double drawdownFraction = (top.price() - trough.price()) / top.price();
final Duration span = Duration.between(top.at(), trough.at());
if (drawdownFraction >= MIN_MACRO_DRAWDOWN_FRACTION && span.compareTo(MIN_MACRO_SPAN) >= 0) {
drawdowns.add(new MacroDrawdown(top, trough, drawdownFraction));
}
}
return List.copyOf(drawdowns);
}
private static Pivot lowestLowAfter(final Pivot top, final List<Pivot> pivots, final Instant nextHigherHighTime) {
Pivot trough = null;
for (final Pivot pivot : pivots) {
if (pivot.at().isBefore(top.at()) || pivot.at().equals(top.at()) || pivot.high()) {
continue;
}
if (nextHigherHighTime != null && !pivot.at().isBefore(nextHigherHighTime)) {
break;
}
if (trough == null || pivot.price() < trough.price()) {
trough = pivot;
}
}
return trough;
}
private static List<ElliottWaveAnchorCalibrationHarness.Anchor> toAnchors(
final List<MacroDrawdown> macroDrawdowns) {
final int anchorCount = macroDrawdowns.size() * 2;
final int validationCutoff = Math.max(0, anchorCount - HOLDOUT_ANCHOR_COUNT);
final List<ElliottWaveAnchorCalibrationHarness.Anchor> anchors = new ArrayList<>(anchorCount);
int anchorIndex = 0;
for (int index = 0; index < macroDrawdowns.size(); index++) {
final MacroDrawdown drawdown = macroDrawdowns.get(index);
anchors.add(toAnchor(index, drawdown.top(), ElliottWaveAnchorCalibrationHarness.AnchorType.TOP,
partitionFor(anchorIndex, validationCutoff)));
anchorIndex++;
anchors.add(toAnchor(index, drawdown.trough(), ElliottWaveAnchorCalibrationHarness.AnchorType.BOTTOM,
partitionFor(anchorIndex, validationCutoff)));
anchorIndex++;
}
return List.copyOf(anchors);
}
private static ElliottWaveAnchorCalibrationHarness.Anchor toAnchor(final int sequence, final Pivot pivot,
final ElliottWaveAnchorCalibrationHarness.AnchorType type,
final ElliottWaveAnchorRegistry.AnchorPartition partition) {
final Set<ElliottPhase> expectedPhases = type == ElliottWaveAnchorCalibrationHarness.AnchorType.TOP
? EnumSet.of(ElliottPhase.WAVE5)
: EnumSet.of(ElliottPhase.CORRECTIVE_C);
final String direction = type == ElliottWaveAnchorCalibrationHarness.AnchorType.TOP ? "top" : "bottom";
return new ElliottWaveAnchorCalibrationHarness.Anchor("inferred-" + direction + "-" + (sequence + 1), type,
pivot.at(), DEFAULT_TOLERANCE, DEFAULT_TOLERANCE, expectedPhases, partition,
"Inferred from full-history orthodox Elliott swings and macro drawdown turns.");
}
private static ElliottWaveAnchorRegistry.AnchorPartition partitionFor(int anchorIndex, int validationCutoff) {
return anchorIndex < validationCutoff ? ElliottWaveAnchorRegistry.AnchorPartition.VALIDATION
: ElliottWaveAnchorRegistry.AnchorPartition.HOLDOUT;
}
private static String datasetResource(final BarSeries series) {
final String name = series.getName();
return name == null || name.isBlank() ? "in-memory-series" : name;
}
private static String inferredProvenance(final BarSeries series) {
return "Inferred from full-history orthodox Elliott swing pivots for " + datasetResource(series)
+ " without a curated anchor registry.";
}
private record Pivot(int index, Instant at, double price, boolean high) {
private Pivot {
Objects.requireNonNull(at, "at");
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
if (!Double.isFinite(price)) {
throw new IllegalArgumentException("price must be finite");
}
}
}
private record MacroDrawdown(Pivot top, Pivot trough, double drawdownFraction) {
private MacroDrawdown {
Objects.requireNonNull(top, "top");
Objects.requireNonNull(trough, "trough");
if (!top.high()) {
throw new IllegalArgumentException("top must be a high pivot");
}
if (trough.high()) {
throw new IllegalArgumentException("trough must be a low pivot");
}
if (trough.index() <= top.index()) {
throw new IllegalArgumentException("trough must follow top");
}
if (!Double.isFinite(drawdownFraction) || drawdownFraction <= 0.0) {
throw new IllegalArgumentException("drawdownFraction must be positive and finite");
}
}
}
}
@@ -0,0 +1,374 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.time.format.DateTimeFormatter;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwingCompressor;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.ElliottTrendBiasIndicator;
import org.ta4j.core.indicators.elliott.ElliottWaveFacade;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Backtests and walk-forward tests Elliott Wave trend bias predictions using
* ossified datasets.
*
* <p>
* This demo evaluates whether the trend bias derived from Elliott Wave
* scenarios correctly predicts the direction of price action over a fixed
* lookahead horizon. It runs a full-history backtest and a rolling walk-forward
* evaluation over multiple instruments.
*
* @since 0.22.2
*/
public class ElliottWaveTrendBacktest {
private static final Logger LOG = LogManager.getLogger(ElliottWaveTrendBacktest.class);
private static final double DEFAULT_FIB_TOLERANCE = 0.25;
private static final int DEFAULT_LOOKAHEAD_BARS = 20;
private static final double DEFAULT_MIN_STRENGTH = 0.20;
private static final int DEFAULT_WALK_FORWARD_WINDOW = 180;
private static final int DEFAULT_WALK_FORWARD_STEP = 60;
/**
* Runs the trend-bias backtest and walk-forward evaluation demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
List<DatasetSpec> datasets = List.of(
DatasetSpec.json("Coinbase-ETH-USD-PT1D-20160517_20251028.json", "ETH-USD PT1D (Coinbase)",
ElliottDegree.PRIMARY),
DatasetSpec.csv("AAPL-PT1D-20130102_20131231.csv", "AAPL PT1D (Yahoo)", ElliottDegree.MINOR));
for (DatasetSpec dataset : datasets) {
BarSeries series = dataset.loadSeries();
if (series == null || series.isEmpty()) {
LOG.warn("Dataset {} could not be loaded or is empty.", dataset.label());
continue;
}
LOG.info("=== Elliott Trend Bias Backtest: {} ===", dataset.label());
LOG.info("Bars: {} | Range: {}", series.getBarCount(), describeRange(series));
ElliottWaveFacade facade = buildFacade(series, dataset.degree());
ElliottTrendBiasIndicator trendBiasIndicator = facade.trendBias();
TrendAccuracy backtest = evaluateWindow(series, trendBiasIndicator, series.getBeginIndex(),
series.getEndIndex(), DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH);
logAccuracy("Backtest", backtest, DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH);
runWalkForward(series, trendBiasIndicator, DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH,
DEFAULT_WALK_FORWARD_WINDOW, DEFAULT_WALK_FORWARD_STEP);
}
}
/**
* Builds an Elliott Wave facade with zigzag swings and compression.
*
* @param series bar series to analyze
* @param degree Elliott wave degree to use
* @return configured Elliott Wave facade
*/
private static ElliottWaveFacade buildFacade(BarSeries series, ElliottDegree degree) {
ElliottSwingCompressor compressor = new ElliottSwingCompressor(series);
return ElliottWaveFacade.zigZag(series, degree, Optional.of(series.numFactory().numOf(DEFAULT_FIB_TOLERANCE)),
Optional.of(compressor));
}
/**
* Evaluates directional accuracy across a contiguous bar window.
*
* @param series bar series being evaluated
* @param trendBiasIndicator indicator supplying trend bias values
* @param startIndex first index to evaluate
* @param endIndex last index to evaluate
* @param lookaheadBars bars to look ahead for validation
* @param minStrength minimum bias strength to include
* @return aggregated accuracy statistics
*/
private static TrendAccuracy evaluateWindow(BarSeries series, ElliottTrendBiasIndicator trendBiasIndicator,
int startIndex, int endIndex, int lookaheadBars, double minStrength) {
int effectiveStart = Math.max(startIndex, series.getBeginIndex());
int effectiveEnd = Math.min(endIndex, series.getEndIndex());
int unstableBars = trendBiasIndicator.getCountOfUnstableBars();
effectiveStart = Math.max(effectiveStart, unstableBars);
int lastEvaluationIndex = effectiveEnd - Math.max(1, lookaheadBars);
if (lastEvaluationIndex < effectiveStart) {
return TrendAccuracy.empty();
}
int totalPredictions = 0;
int correctPredictions = 0;
int bullishPredictions = 0;
int bullishCorrect = 0;
int bearishPredictions = 0;
int bearishCorrect = 0;
int skipped = 0;
for (int i = effectiveStart; i <= lastEvaluationIndex; i++) {
ElliottTrendBias bias = trendBiasIndicator.getValue(i);
if (bias == null || bias.isUnknown() || bias.isNeutral()) {
skipped++;
continue;
}
double strength = bias.strength();
if (Double.isNaN(strength) || strength < minStrength) {
skipped++;
continue;
}
Num currentClose = series.getBar(i).getClosePrice();
Num futureClose = series.getBar(i + lookaheadBars).getClosePrice();
if (Num.isNaNOrNull(currentClose) || Num.isNaNOrNull(futureClose)) {
skipped++;
continue;
}
boolean bullish = bias.isBullish();
boolean correct = bullish ? futureClose.isGreaterThan(currentClose) : futureClose.isLessThan(currentClose);
totalPredictions++;
if (bullish) {
bullishPredictions++;
if (correct) {
bullishCorrect++;
}
} else {
bearishPredictions++;
if (correct) {
bearishCorrect++;
}
}
if (correct) {
correctPredictions++;
}
}
return new TrendAccuracy(totalPredictions, correctPredictions, bullishPredictions, bullishCorrect,
bearishPredictions, bearishCorrect, skipped);
}
/**
* Runs a rolling walk-forward evaluation across the series.
*
* @param series bar series being evaluated
* @param trendBiasIndicator indicator supplying trend bias values
* @param lookaheadBars bars to look ahead for validation
* @param minStrength minimum bias strength to include
* @param windowSize rolling window size in bars
* @param stepSize step size between windows in bars
*/
private static void runWalkForward(BarSeries series, ElliottTrendBiasIndicator trendBiasIndicator,
int lookaheadBars, double minStrength, int windowSize, int stepSize) {
int startIndex = Math.max(series.getBeginIndex(), trendBiasIndicator.getCountOfUnstableBars());
int endIndex = series.getEndIndex();
int availableBars = endIndex - startIndex + 1;
if (availableBars <= 0) {
LOG.info("Walk-forward skipped: insufficient bars after unstable period.");
return;
}
int effectiveWindow = Math.min(windowSize, availableBars);
int effectiveStep = Math.max(1, stepSize);
LOG.info("Walk-forward windows: size={} bars, step={} bars", effectiveWindow, effectiveStep);
for (int windowStart = startIndex; windowStart + effectiveWindow
- 1 <= endIndex; windowStart += effectiveStep) {
int windowEnd = windowStart + effectiveWindow - 1;
TrendAccuracy accuracy = evaluateWindow(series, trendBiasIndicator, windowStart, windowEnd, lookaheadBars,
minStrength);
String windowRange = describeRange(series, windowStart, windowEnd);
LOG.info("Walk-forward {} -> {}", windowRange, formatAccuracy(accuracy, lookaheadBars, minStrength));
}
}
/**
* Logs summary accuracy output.
*
* @param label label for the output
* @param accuracy aggregated accuracy statistics
* @param lookaheadBars lookahead window used in bars
* @param minStrength minimum bias strength used for inclusion
*/
private static void logAccuracy(String label, TrendAccuracy accuracy, int lookaheadBars, double minStrength) {
LOG.info("{} -> {}", label, formatAccuracy(accuracy, lookaheadBars, minStrength));
}
/**
* Formats accuracy statistics into a single summary string.
*
* @param accuracy aggregated accuracy statistics
* @param lookaheadBars lookahead window used in bars
* @param minStrength minimum bias strength used for inclusion
* @return formatted summary string
*/
private static String formatAccuracy(TrendAccuracy accuracy, int lookaheadBars, double minStrength) {
String overall = formatPercent(accuracy.accuracy());
String bullish = formatPercent(accuracy.bullishAccuracy());
String bearish = formatPercent(accuracy.bearishAccuracy());
return String.format(
"lookahead=%d bars | minStrength=%.2f | predictions=%d | accuracy=%s | bullish=%s | bearish=%s | skipped=%d",
lookaheadBars, minStrength, accuracy.totalPredictions(), overall, bullish, bearish,
accuracy.skippedPredictions());
}
/**
* Formats a ratio as a percentage string.
*
* @param value ratio value (0.0-1.0)
* @return formatted percentage string, or {@code n/a} if undefined
*/
private static String formatPercent(double value) {
if (Double.isNaN(value)) {
return "n/a";
}
return String.format("%.1f%%", value * 100.0);
}
/**
* Describes the full range of the series in ISO date form.
*
* @param series bar series to describe
* @return formatted date range string
*/
private static String describeRange(BarSeries series) {
return describeRange(series, series.getBeginIndex(), series.getEndIndex());
}
/**
* Describes a subset of the series in ISO date form.
*
* @param series bar series to describe
* @param startIndex start bar index
* @param endIndex end bar index
* @return formatted date range string
*/
private static String describeRange(BarSeries series, int startIndex, int endIndex) {
DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE;
String start = series.getBar(startIndex).getEndTime().atZone(ZoneOffset.UTC).toLocalDate().format(formatter);
String end = series.getBar(endIndex).getEndTime().atZone(ZoneOffset.UTC).toLocalDate().format(formatter);
return start + " -> " + end;
}
/**
* Dataset metadata for backtesting inputs.
*
* @param resource classpath resource identifier
* @param label label for logging output
* @param degree Elliott wave degree to analyze
* @param sourceType data source kind
*/
private record DatasetSpec(String resource, String label, ElliottDegree degree, DataSourceType sourceType) {
/**
* Loads the bar series for this dataset.
*
* @return loaded series, or {@code null} if unavailable
*/
BarSeries loadSeries() {
if (sourceType == DataSourceType.JSON) {
return JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resource);
}
return new CsvFileBarSeriesDataSource().loadSeries(resource);
}
/**
* Builds a JSON-backed dataset spec.
*
* @param resource resource path
* @param label label for logging
* @param degree Elliott wave degree
* @return dataset spec
*/
static DatasetSpec json(String resource, String label, ElliottDegree degree) {
return new DatasetSpec(resource, label, degree, DataSourceType.JSON);
}
/**
* Builds a CSV-backed dataset spec.
*
* @param resource resource path
* @param label label for logging
* @param degree Elliott wave degree
* @return dataset spec
*/
static DatasetSpec csv(String resource, String label, ElliottDegree degree) {
return new DatasetSpec(resource, label, degree, DataSourceType.CSV);
}
}
private enum DataSourceType {
JSON, CSV
}
/**
* Aggregated trend-bias prediction accuracy metrics.
*
* @param totalPredictions total evaluated predictions
* @param correctPredictions total correct predictions
* @param bullishPredictions total bullish predictions
* @param bullishCorrect correct bullish predictions
* @param bearishPredictions total bearish predictions
* @param bearishCorrect correct bearish predictions
* @param skippedPredictions skipped predictions
*/
private record TrendAccuracy(int totalPredictions, int correctPredictions, int bullishPredictions,
int bullishCorrect, int bearishPredictions, int bearishCorrect, int skippedPredictions) {
/**
* @return empty accuracy metrics
*/
static TrendAccuracy empty() {
return new TrendAccuracy(0, 0, 0, 0, 0, 0, 0);
}
/**
* @return overall accuracy ratio
*/
double accuracy() {
return ratio(correctPredictions, totalPredictions);
}
/**
* @return bullish-only accuracy ratio
*/
double bullishAccuracy() {
return ratio(bullishCorrect, bullishPredictions);
}
/**
* @return bearish-only accuracy ratio
*/
double bearishAccuracy() {
return ratio(bearishCorrect, bearishPredictions);
}
/**
* Computes a safe ratio, returning NaN when the denominator is zero.
*
* @param numerator numerator value
* @param denominator denominator value
* @return ratio or NaN
*/
private double ratio(int numerator, int denominator) {
return denominator > 0 ? (double) numerator / denominator : Double.NaN;
}
}
}
@@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.ExpectancyCriterion;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.pnl.GrossProfitLossRatioCriterion;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import ta4jexamples.strategies.HighRewardElliottWaveStrategy;
/**
* Backtests the high-reward Elliott Wave strategy using ossified datasets.
*
* @since 0.22.2
*/
public class HighRewardElliottWaveBacktest {
private static final Logger LOG = LogManager.getLogger(HighRewardElliottWaveBacktest.class);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-ETH-USD-PT4H-20160518_20251028.json";
private static final List<StrategySpec> DEFAULT_SPECS = List.of(StrategySpec.defaultSpec(),
StrategySpec.relaxedSpec(), StrategySpec.exploratorySpec(), StrategySpec.strictSpec());
/**
* Runs the backtest demo.
*
* @param args command-line arguments (optional: override dataset resource)
*/
public static void main(String[] args) {
String resource = args.length > 0 ? args[0] : DEFAULT_OHLCV_RESOURCE;
BarSeries series = loadSeries(resource);
if (series == null || series.isEmpty()) {
LOG.error("No data available for backtest: {}", resource);
return;
}
LOG.info("High-reward Elliott Wave backtest: {}", resource);
LOG.info("Bars: {} | Range: {}", series.getBarCount(), describeRange(series));
for (StrategySpec spec : DEFAULT_SPECS) {
runBacktest(series, spec);
}
}
private static void runBacktest(BarSeries series, StrategySpec spec) {
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, spec.toParameters());
BarSeriesManager manager = new BarSeriesManager(series);
TradingRecord record = manager.run(strategy);
Num netProfit = new NetProfitLossCriterion().calculate(series, record);
Num grossReturn = new GrossReturnCriterion().calculate(series, record);
Num profitFactor = new GrossProfitLossRatioCriterion().calculate(series, record);
Num maxDrawdown = new MaximumDrawdownCriterion().calculate(series, record);
Num winRate = PositionsRatioCriterion.WinningPositionsRatioCriterion().calculate(series, record);
Num expectancy = new ExpectancyCriterion().calculate(series, record);
LOG.info("Spec: {}", spec.label());
LOG.info(
"Trades: {} | WinRate: {} | Net PnL: {} | Gross Return: {} | Profit Factor: {} | Max DD: {} | Expectancy: {}",
record.getPositionCount(), formatPercent(winRate), netProfit, grossReturn, profitFactor, maxDrawdown,
expectancy);
logTrades(series, record);
}
private static String formatPercent(Num ratio) {
if (ratio == null || ratio.isNaN()) {
return "n/a";
}
return String.format("%.1f%%", ratio.doubleValue() * 100.0);
}
private static BarSeries loadSeries(String resource) {
try (InputStream stream = HighRewardElliottWaveBacktest.class.getClassLoader().getResourceAsStream(resource)) {
if (stream == null) {
LOG.error("Missing resource: {}", resource);
return null;
}
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
if (loaded == null) {
LOG.error("Failed to load resource: {}", resource);
return null;
}
BarSeries series = new BaseBarSeriesBuilder().withName(resource).build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
LOG.error("Failed to load dataset: {}", ex.getMessage(), ex);
return null;
}
}
private static String describeRange(BarSeries series) {
String start = DATE_FORMATTER.format(series.getBar(series.getBeginIndex()).getEndTime());
String end = DATE_FORMATTER.format(series.getBar(series.getEndIndex()).getEndTime());
return start + " -> " + end;
}
private static void logTrades(BarSeries series, TradingRecord record) {
if (record == null || record.getPositionCount() == 0) {
return;
}
record.getPositions().stream().filter(position -> !position.isOpened()).forEach(position -> {
Trade entry = position.getEntry();
Trade exit = position.getExit();
String entryTime = DATE_FORMATTER.format(series.getBar(entry.getIndex()).getEndTime());
String exitTime = DATE_FORMATTER.format(series.getBar(exit.getIndex()).getEndTime());
LOG.info("Trade: entry={}@{} ({}), exit={}@{} ({}), profit={}", entry.getIndex(), entry.getPricePerAsset(),
entryTime, exit.getIndex(), exit.getPricePerAsset(), exitTime, position.getProfit());
});
}
private record StrategySpec(String direction, String degree, double minConfidence, double minRiskReward,
double minAlternationRatio, double minTrendBiasStrength, int trendSmaPeriod, int rsiPeriod,
double rsiThreshold, int macdFastPeriod, int macdSlowPeriod, double minRelativeSwing) {
static StrategySpec defaultSpec() {
return new StrategySpec("BULLISH", "PRIMARY", 0.35, 2.0, 1.50, 0.10, 100, 14, 50.0, 12, 26, 0.10);
}
static StrategySpec relaxedSpec() {
return new StrategySpec("BULLISH", "MINOR", 0.30, 1.8, 1.05, 0.05, 80, 14, 48.0, 12, 26, 0.08);
}
static StrategySpec exploratorySpec() {
return new StrategySpec("BULLISH", "MINOR", 0.25, 1.5, 1.00, 0.00, 80, 14, 45.0, 12, 26, 0.05);
}
static StrategySpec strictSpec() {
return new StrategySpec("BULLISH", "PRIMARY", 0.50, 2.5, 1.40, 0.20, 200, 14, 50.0, 12, 26, 0.15);
}
String[] toParameters() {
return new String[] { direction, degree, format(minConfidence), format(minRiskReward),
format(minAlternationRatio), format(minTrendBiasStrength), String.valueOf(trendSmaPeriod),
String.valueOf(rsiPeriod), format(rsiThreshold), String.valueOf(macdFastPeriod),
String.valueOf(macdSlowPeriod), format(minRelativeSwing) };
}
String label() {
return String.format("dir=%s deg=%s conf=%s rr=%s alt=%s bias=%s swing=%s", direction, degree,
format(minConfidence), format(minRiskReward), format(minAlternationRatio),
format(minTrendBiasStrength), format(minRelativeSwing));
}
private static String format(double value) {
return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
}
}
@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.confidence.ConfidenceFactorResult;
import org.ta4j.core.indicators.elliott.confidence.ElliottConfidenceBreakdown;
import org.ta4j.core.indicators.elliott.swing.AdaptiveZigZagConfig;
import org.ta4j.core.indicators.elliott.swing.CompositeSwingDetector;
import org.ta4j.core.indicators.elliott.swing.MinMagnitudeSwingFilter;
import org.ta4j.core.indicators.elliott.swing.SwingDetector;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates adaptive ZigZag and composite swing detection for Elliott Wave
* analysis.
*
* @since 0.22.2
*/
public class ElliottWaveAdaptiveSwingAnalysis {
private static final Logger LOG = LogManager.getLogger(ElliottWaveAdaptiveSwingAnalysis.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
/**
* Runs the adaptive swing analysis demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
BarSeries series = loadSeries();
if (series == null || series.isEmpty()) {
LOG.error("No data available for adaptive swing analysis");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
AdaptiveZigZagConfig config = new AdaptiveZigZagConfig(14, 1.0, 0.0, 0.0, 3);
SwingDetector detector = SwingDetectors.composite(CompositeSwingDetector.Policy.OR, SwingDetectors.fractal(5),
SwingDetectors.adaptiveZigZag(config));
ElliottWaveAnalysisRunner analyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(detector)
.swingFilter(new MinMagnitudeSwingFilter(0.2))
.build();
ElliottWaveAnalysisResult analysisResult = analyzer.analyze(series);
ElliottAnalysisResult result = analysisResult.analysisFor(baseDegree).orElseThrow().analysis();
LOG.info("Adaptive swing analysis complete. Trend bias: {}", result.trendBias().direction());
result.scenarios().base().ifPresent(base -> logScenario("BASE", base, result));
List<ElliottScenario> alternatives = result.scenarios().alternatives();
for (int i = 0; i < Math.min(2, alternatives.size()); i++) {
logScenario("ALT " + (i + 1), alternatives.get(i), result);
}
}
/**
* Logs scenario-level confidence breakdown details.
*
* @param label scenario label
* @param scenario scenario to log
* @param result analysis result containing factor breakdowns
*/
private static void logScenario(String label, ElliottScenario scenario, ElliottAnalysisResult result) {
LOG.info("{} SCENARIO: {} ({}) - confidence={}%%", label, scenario.currentPhase(), scenario.type(),
String.format("%.1f", scenario.confidence().asPercentage()));
ElliottConfidenceBreakdown breakdown = result.breakdownFor(scenario).orElse(null);
if (breakdown == null) {
return;
}
for (ConfidenceFactorResult factor : breakdown.factors()) {
LOG.info(" Factor: {} score={} weight={} diagnostics={}", factor.name(),
String.format("%.2f", factor.score().doubleValue()), String.format("%.2f", factor.weight()),
factor.diagnostics());
}
}
/**
* Loads the ossified BTC-USD dataset from classpath resources.
*
* @return loaded bar series, or {@code null} if unavailable
*/
private static BarSeries loadSeries() {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveAdaptiveSwingAnalysis.class,
DEFAULT_OHLCV_RESOURCE, "BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import java.util.List;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.swing.AdaptiveZigZagConfig;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates multi-degree Elliott Wave analysis, validating scenarios across
* neighboring degrees and re-ranking base-degree outcomes.
*
* <p>
* This demo uses an ossified BTC-USD dataset from classpath resources and runs
* an auto-selected base-degree analysis, plus one supporting degree higher and
* lower.
*
* @since 0.22.4
*/
public class ElliottWaveMultiDegreeAnalysisDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWaveMultiDegreeAnalysisDemo.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231020.json";
public static void main(String[] args) {
BarSeries series = loadSeries(DEFAULT_OHLCV_RESOURCE);
if (series == null || series.isEmpty()) {
LOG.error("No series available for multi-degree Elliott Wave analysis");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
ElliottWaveAnalysisRunner analyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.adaptiveZigZag(new AdaptiveZigZagConfig(14, 1.0, 0.0, 0.0, 1)))
.build();
ElliottWaveAnalysisResult result = analyzer.analyze(series);
LOG.info("Auto-selected base degree: {}", baseDegree);
for (ElliottWaveAnalysisResult.DegreeAnalysis analysis : result.analyses()) {
LOG.info("{} Degree {}: bars={} duration={} historyFit={} trendBias={}", series.getName(),
analysis.degree(), analysis.barCount(), analysis.barDuration(),
String.format("%.2f", analysis.historyFitScore()), analysis.analysis().trendBias().direction());
analysis.analysis().scenarios().base().ifPresent(base -> logScenario(" Base", base));
}
Optional<ElliottWaveAnalysisResult.BaseScenarioAssessment> recommended = result.recommendedScenario();
if (recommended.isEmpty()) {
LOG.warn("No base-degree scenarios were produced");
return;
}
ElliottWaveAnalysisResult.BaseScenarioAssessment assessment = recommended.orElseThrow();
ElliottScenario scenario = assessment.scenario();
LOG.info("Recommended base scenario: id={} phase={} type={} confidence={} crossDegree={} composite={}",
scenario.id(), scenario.currentPhase(), scenario.type(),
String.format("%.2f", assessment.confidenceScore()),
String.format("%.2f", assessment.crossDegreeScore()),
String.format("%.2f", assessment.compositeScore()));
List<ElliottWaveAnalysisResult.SupportingScenarioMatch> matches = assessment.supportingMatches();
for (ElliottWaveAnalysisResult.SupportingScenarioMatch match : matches) {
LOG.info(" Match {}: scenarioId={} compat={} weightedCompat={} historyFit={}", match.degree(),
match.scenarioId(), String.format("%.2f", match.compatibilityScore()),
String.format("%.2f", match.weightedCompatibility()),
String.format("%.2f", match.historyFitScore()));
}
for (String note : result.notes()) {
LOG.info("Note: {}", note);
}
}
private static void logScenario(final String label, final ElliottScenario scenario) {
LOG.info("{} scenarioId={} phase={} type={} confidence={} direction={}", label, scenario.id(),
scenario.currentPhase(), scenario.type(), String.format("%.1f", scenario.confidence().asPercentage()),
scenario.hasKnownDirection() ? (scenario.isBullish() ? "bullish" : "bearish") : "unknown");
}
private static BarSeries loadSeries(final String resource) {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveMultiDegreeAnalysisDemo.class, resource,
"BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,100 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.confidence.ConfidenceProfiles;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates how pattern-specific confidence profiles influence scenario
* ranking.
*
* @since 0.22.2
*/
public class ElliottWavePatternProfileDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWavePatternProfileDemo.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
/**
* Runs the pattern profile comparison demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
BarSeries series = loadSeries();
if (series == null || series.isEmpty()) {
LOG.error("No data available for pattern profile demo");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
ElliottWaveAnalysisRunner defaultAnalyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.fractal(5))
.confidenceModelFactory(ConfidenceProfiles::defaultModel)
.build();
ElliottWaveAnalysisRunner patternAwareAnalyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.fractal(5))
.confidenceModelFactory(ConfidenceProfiles::patternAwareModel)
.build();
ElliottWaveAnalysisResult defaultSnapshot = defaultAnalyzer.analyze(series);
ElliottWaveAnalysisResult patternSnapshot = patternAwareAnalyzer.analyze(series);
ElliottAnalysisResult defaultResult = defaultSnapshot.analysisFor(baseDegree)
.orElseThrow(() -> new IllegalStateException("No base-degree analysis in default snapshot"))
.analysis();
ElliottAnalysisResult patternResult = patternSnapshot.analysisFor(baseDegree)
.orElseThrow(() -> new IllegalStateException("No base-degree analysis in pattern-aware snapshot"))
.analysis();
logBaseScenario("Default profile", defaultResult);
logBaseScenario("Pattern-aware profile", patternResult);
}
/**
* Logs the base scenario for the provided analysis result.
*
* @param label label for the output
* @param result analysis result to inspect
*/
private static void logBaseScenario(String label, ElliottAnalysisResult result) {
ElliottScenario base = result.scenarios().base().orElse(null);
if (base == null) {
LOG.info("{}: No scenarios available", label);
return;
}
LOG.info("{}: {} ({}) confidence={}%%", label, base.currentPhase(), base.type(),
String.format("%.1f", base.confidence().asPercentage()));
}
/**
* Loads the ossified BTC-USD dataset from classpath resources.
*
* @return loaded bar series, or {@code null} if unavailable
*/
private static BarSeries loadSeries() {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWavePatternProfileDemo.class, DEFAULT_OHLCV_RESOURCE,
"BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.support;
import java.io.InputStream;
import java.util.Objects;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Utility for loading ossified Elliott Wave demo series from classpath JSON
* resources.
*
* @since 0.22.4
*/
public final class OssifiedElliottWaveSeriesLoader {
/**
* Utility class.
*/
private OssifiedElliottWaveSeriesLoader() {
}
/**
* Loads an ossified classpath dataset into a detached {@link BarSeries} with a
* caller-provided display name.
*
* @param resourceOwner class used to resolve classpath resources
* @param resource classpath resource path
* @param seriesName name assigned to the returned series
* @param logger logger used for diagnostics
* @return loaded series, or {@code null} when loading fails
*/
public static BarSeries loadSeries(final Class<?> resourceOwner, final String resource, final String seriesName,
final Logger logger) {
Objects.requireNonNull(resourceOwner, "resourceOwner");
Objects.requireNonNull(resource, "resource");
Objects.requireNonNull(seriesName, "seriesName");
Objects.requireNonNull(logger, "logger");
String normalizedResource = resource.startsWith("/") ? resource : "/" + resource;
try (InputStream stream = resourceOwner.getResourceAsStream(normalizedResource)) {
if (stream == null) {
logger.error("Missing resource: {}", resource);
return null;
}
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
if (loaded == null) {
logger.error("Failed to load resource: {}", resource);
return null;
}
BarSeries series = new BaseBarSeriesBuilder().withName(seriesName).build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
logger.error("Failed to load dataset from {}: {}", resource, ex.getMessage(), ex);
return null;
}
}
}