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,7 @@
# ta4jexamples.analysis test instructions
- Regression tests under this package should assert on underlying analysis data and persisted artifacts, not rendered chart labels or screenshots, unless the visual rendering itself is the feature under test.
- For long-running analysis/calibration flows, tests should lock down incremental persistence:
- primary results land before portability/secondary checks
- phase outputs survive later failures
- final aggregate output appends rather than replaces
@@ -0,0 +1,175 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.plot.CombinedDomainXYPlot;
import org.jfree.chart.plot.XYPlot;
import org.jfree.data.time.TimeSeriesCollection;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.SwingPointMarkerIndicator;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator.TrendLineSegment;
import org.ta4j.core.num.Num;
import ta4jexamples.charting.builder.ChartPlan;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
/**
* Analysis coverage for trendline + swing point indicators on realistic data.
*/
class TrendLineAndSwingPointAnalysisTest {
@Test
void analysisHarnessVerifiesHeadroomAndRendersOverlays() {
TrendLineAndSwingPointAnalysis analysis = new TrendLineAndSwingPointAnalysis();
analysis.verifyDefaultCapsHeadroomForBundledDatasets();
BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
assertNotNull(series, "Example bar series should not be null");
assertFalse(series.isEmpty(), "Example bar series should not be empty");
int lookback = Math.min(series.getBarCount(), TrendLineAndSwingPointAnalysis.DEFAULT_TRENDLINE_LOOKBACK);
TrendLineAndSwingPointAnalysis.AnalysisChartArtifacts artifacts = analysis.buildAnalysisChartArtifacts(series,
lookback, TrendLineAndSwingPointAnalysis.DEFAULT_SURROUNDING_BARS);
int endIndex = series.getEndIndex();
TrendLineAndSwingPointAnalysis.TrendLineVariants trendLines = artifacts.trendLines();
TrendLineAndSwingPointAnalysis.SwingMarkerVariants swings = artifacts.swings();
assertAll(() -> assertTrendLinePopulates(trendLines.support(), endIndex),
() -> assertTrendLinePopulates(trendLines.supportTouchBiased(), endIndex),
() -> assertTrendLinePopulates(trendLines.supportExtremeBiased(), endIndex),
() -> assertTrendLinePopulates(trendLines.resistance(), endIndex),
() -> assertTrendLinePopulates(trendLines.resistanceTouchBiased(), endIndex),
() -> assertTrendLinePopulates(trendLines.resistanceExtremeBiased(), endIndex));
assertAll(() -> assertSwingMarkersPopulated(swings.fractalLows(), series),
() -> assertSwingMarkersPopulated(swings.fractalHighs(), series),
() -> assertSwingMarkersPopulated(swings.zigzagLows(), series),
() -> assertSwingMarkersPopulated(swings.zigzagHighs(), series));
ChartWorkflow chartWorkflow = new ChartWorkflow();
ChartPlan plan = artifacts.plan();
JFreeChart chart = chartWorkflow.render(plan);
assertNotNull(chart, "Rendered chart should not be null");
byte[] png = chartWorkflow.getChartAsByteArray(chart);
assertTrue(png.length > 0, "Rendered chart should produce non-empty PNG bytes");
assertRenderedOverlaysHaveData(chart, plan);
}
private void assertTrendLinePopulates(AbstractTrendLineIndicator trendLine, int endIndex) {
trendLine.getValue(endIndex);
TrendLineSegment segment = trendLine.getCurrentSegment();
assertNotNull(segment, "Trendline should have an active segment at the end of the series");
assertTrue(segment.firstIndex < segment.secondIndex, "Trendline anchors should be ordered in time");
assertTrue(segment.windowStart <= segment.firstIndex && segment.firstIndex <= segment.windowEnd,
"First anchor should be inside the evaluation window");
assertTrue(segment.secondIndex <= segment.windowEnd,
"Second anchor should be before or at the end of the evaluation window");
assertEquals(endIndex, segment.windowEnd, "Trendline window should be anchored at endIndex");
List<Integer> swingIndexes = trendLine.getSwingPointIndexes();
assertTrue(swingIndexes.contains(segment.firstIndex), "First anchor should be a confirmed swing point");
assertTrue(swingIndexes.contains(segment.secondIndex), "Second anchor should be a confirmed swing point");
Num valueAtEnd = trendLine.getValue(endIndex);
assertNotNull(valueAtEnd, "Trendline value should not be null");
assertFalse(valueAtEnd.isNaN(), "Trendline value should be defined at endIndex");
Num valueAtStart = trendLine.getValue(segment.windowStart);
assertNotNull(valueAtStart, "Trendline value should not be null within the window");
assertFalse(valueAtStart.isNaN(), "Trendline should backfill values within the window");
if (segment.windowStart > trendLine.getBarSeries().getBeginIndex()) {
Num beforeWindow = trendLine.getValue(segment.windowStart - 1);
assertTrue(beforeWindow.isNaN(), "Trendline should return NaN before the evaluation window");
}
}
private void assertSwingMarkersPopulated(SwingPointMarkerIndicator marker, BarSeries series) {
int endIndex = series.getEndIndex();
marker.getValue(endIndex);
List<Integer> swingIndexes = marker.getSwingIndicator().getSwingPointIndexesUpTo(endIndex);
assertFalse(swingIndexes.isEmpty(), "Swing indicator should produce at least one swing point");
assertStrictlyIncreasingWithinBounds(swingIndexes, series.getBeginIndex(), endIndex);
int firstSwing = swingIndexes.get(0);
Num markerPrice = marker.getPriceIndicator().getValue(firstSwing);
if (markerPrice == null || markerPrice.isNaN()) {
markerPrice = series.getBar(firstSwing).getClosePrice();
}
assertNotNull(markerPrice, "Swing marker should have a price value available at a swing point index");
assertFalse(markerPrice.isNaN(), "Swing marker should have a defined price value at a swing point index");
Integer nonSwingIndex = findNonSwingIndex(series.getBeginIndex(), endIndex, swingIndexes);
assertNotNull(nonSwingIndex, "Series should contain at least one non-swing index");
assertFalse(marker.getSwingPointIndexes().contains(nonSwingIndex),
"Non-swing index should not be a swing point");
}
private Integer findNonSwingIndex(int beginIndex, int endIndex, List<Integer> swingIndexes) {
for (int i = beginIndex; i <= endIndex; i++) {
if (!swingIndexes.contains(i)) {
return i;
}
}
return null;
}
private void assertStrictlyIncreasingWithinBounds(List<Integer> indexes, int beginIndex, int endIndex) {
Integer previous = null;
for (Integer index : indexes) {
assertNotNull(index, "Swing index must not be null");
assertTrue(index >= beginIndex && index <= endIndex,
"Swing index " + index + " must be within [" + beginIndex + ", " + endIndex + "]");
if (previous != null) {
assertTrue(index > previous, "Swing indexes must be strictly increasing");
}
previous = index;
}
}
private void assertRenderedOverlaysHaveData(JFreeChart chart, ChartPlan plan) {
CombinedDomainXYPlot combinedPlot = assertInstanceOf(CombinedDomainXYPlot.class, chart.getPlot(),
"Regression chart should use a combined domain plot");
assertNotNull(combinedPlot.getSubplots(), "Combined plot should expose its subplots");
assertFalse(combinedPlot.getSubplots().isEmpty(), "Combined plot should contain at least one subplot");
XYPlot basePlot = assertInstanceOf(XYPlot.class, combinedPlot.getSubplots().get(0),
"First subplot should be an XYPlot");
int expectedOverlays = plan.definition().basePlot().overlays().size();
assertEquals(1 + expectedOverlays, basePlot.getDatasetCount(), "Each overlay should map to a dataset");
for (int datasetIndex = 1; datasetIndex < basePlot.getDatasetCount(); datasetIndex++) {
TimeSeriesCollection dataset = assertInstanceOf(TimeSeriesCollection.class,
basePlot.getDataset(datasetIndex), "Overlay datasets should be time series collections");
assertTrue(totalItemCount(dataset) > 0, "Overlay dataset " + datasetIndex + " should contain data points");
}
}
private int totalItemCount(TimeSeriesCollection dataset) {
int total = 0;
for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
total += dataset.getSeries(seriesIndex).getItemCount();
}
return total;
}
}
@@ -0,0 +1,62 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.supportresistance.AbstractTrendLineIndicator.TrendLineSegment;
import org.ta4j.core.indicators.supportresistance.TrendLineSupportIndicator;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import static org.junit.jupiter.api.Assertions.*;
class TrendLinePresetDivergenceTest {
@Test
void supportPresetsProduceDistinctSegmentsForAppleSeries() {
final BarSeries series = CsvFileBarSeriesDataSource.loadSeriesFromFile();
assertFalse(series.isEmpty(), "Example bar series should not be empty");
final int lookback = Math.min(series.getBarCount(), 200);
final int surroundingBars = 5;
final int endIndex = series.getEndIndex();
final TrendLineSupportIndicator defaultLine = new TrendLineSupportIndicator(series, surroundingBars, lookback);
final TrendLineSupportIndicator touchBiasedLine = new TrendLineSupportIndicator(series, surroundingBars,
lookback, TrendLineSupportIndicator.ScoringWeights.touchCountBiasPreset());
final TrendLineSupportIndicator extremeBiasedLine = new TrendLineSupportIndicator(series, surroundingBars,
lookback, TrendLineSupportIndicator.ScoringWeights.extremeSwingBiasPreset());
defaultLine.getValue(endIndex);
touchBiasedLine.getValue(endIndex);
extremeBiasedLine.getValue(endIndex);
final TrendLineSegment defaultSegment = defaultLine.getCurrentSegment();
final TrendLineSegment touchSegment = touchBiasedLine.getCurrentSegment();
final TrendLineSegment extremeSegment = extremeBiasedLine.getCurrentSegment();
assertAll(() -> assertNotNull(defaultSegment), () -> assertNotNull(touchSegment),
() -> assertNotNull(extremeSegment));
assertAll(() -> assertEquals(123, defaultSegment.firstIndex),
() -> assertEquals(177, defaultSegment.secondIndex), () -> assertEquals(177, touchSegment.firstIndex),
() -> assertEquals(243, touchSegment.secondIndex), () -> assertEquals(74, extremeSegment.firstIndex),
() -> assertEquals(123, extremeSegment.secondIndex));
assertNotEquals(anchorLabel(defaultSegment), anchorLabel(touchSegment));
assertNotEquals(anchorLabel(defaultSegment), anchorLabel(extremeSegment));
assertNotEquals(anchorLabel(touchSegment), anchorLabel(extremeSegment));
assertTrue(touchSegment.touchCount > defaultSegment.touchCount,
"Touch-biased preset should prefer the line with the most swing touches");
assertTrue(extremeSegment.touchesExtreme, "Extreme-biased preset should include the extreme swing");
assertFalse(defaultSegment.touchesExtreme,
"Default preset should select a line that balances proximity over extreme anchoring");
}
private String anchorLabel(TrendLineSegment segment) {
return segment.firstIndex + "-" + segment.secondIndex;
}
}
@@ -0,0 +1,642 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.RoundingMode;
import java.math.BigDecimal;
import java.awt.GraphicsEnvironment;
import java.io.InputStream;
import java.util.Optional;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.junit.Assume;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ta4j.core.indicators.elliott.ElliottRatio.RatioType;
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.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.ScenarioType;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.num.DoubleNum;
import org.ta4j.core.BarSeries;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import ta4jexamples.charting.display.SwingChartDisplayer;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@Tag("analysis-demo")
class ElliottWaveAnalysisReportTest {
private static final String OSSIFIED_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
private static final double FIB_TOLERANCE = 0.25;
@BeforeEach
void setUp() {
// Disable chart display to prevent windows from appearing in tests
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
}
@Test
void from_withValidInputs_createsResult() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
// The structured result is now automatically created in analyze()
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
assertNotNull(result, "Result should not be null");
assertEquals(ElliottDegree.PRIMARY, result.degree(), "Degree should match");
assertEquals(series.getEndIndex(), result.endIndex(), "End index should match");
assertNotNull(result.swingSnapshot(), "Swing snapshot should not be null");
assertNotNull(result.latestAnalysis(), "Latest analysis should not be null");
assertNotNull(result.scenarioSummary(), "Scenario summary should not be null");
assertNotNull(result.probabilityCalibration(), "Probability calibration should not be null");
assertNotNull(result.outlookGate(), "Outlook gate should not be null");
assertNotNull(result.alternatives(), "Alternatives list should not be null");
}
@Test
void from_withBaseCaseScenario_includesBaseCase() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
if (analysisResult.scenarioSet().base().isPresent()) {
assertNotNull(result.baseCase(), "Base case should not be null when scenario exists");
assertNotNull(result.baseCase().currentPhase(), "Base case phase should not be null");
assertNotNull(result.baseCase().type(), "Base case type should not be null");
assertTrue(result.baseCase().overallConfidence() >= 0 && result.baseCase().overallConfidence() <= 100,
"Confidence should be between 0 and 100");
assertTrue(result.baseCase().scenarioProbability() >= 0 && result.baseCase().scenarioProbability() <= 1.0,
"Scenario probability should be between 0 and 1");
assertTrue(
result.baseCase().calibratedProbability() >= 0 && result.baseCase().calibratedProbability() <= 1.0,
"Calibrated probability should be between 0 and 1");
assertNotNull(result.baseCase().confidenceLevel(), "Confidence level should not be null");
assertNotNull(result.baseCase().swings(), "Swings list should not be null");
}
}
@Test
void from_withBaseCaseChartPlan_encodesChartImage() {
Assume.assumeFalse("Headless environment", GraphicsEnvironment.isHeadless());
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
if (analysisResult.baseCaseChartPlan().isPresent()) {
assertNotNull(result.baseCaseChartImage(), "Base case chart image should not be null");
assertTrue(result.baseCaseChartImage().length() > 0, "Base case chart image should not be empty");
// Verify it's valid base64
assertTrue(isValidBase64(result.baseCaseChartImage()), "Base case chart image should be valid base64");
} else {
assertNull(result.baseCaseChartImage(), "Base case chart image should be null when no chart plan");
}
}
@Test
void from_withAlternativeChartPlans_encodesChartImages() {
Assume.assumeFalse("Headless environment", GraphicsEnvironment.isHeadless());
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
assertEquals(analysisResult.alternativeChartPlans().size(), result.alternativeChartImages().size(),
"Number of alternative chart images should match number of chart plans");
for (String chartImage : result.alternativeChartImages()) {
assertNotNull(chartImage, "Alternative chart image should not be null");
assertTrue(chartImage.length() > 0, "Alternative chart image should not be empty");
assertTrue(isValidBase64(chartImage), "Alternative chart image should be valid base64");
}
}
@Test
void from_withNullDegree_throwsNullPointerException() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
assertThrows(NullPointerException.class,
() -> ElliottWaveAnalysisReport.from(null, analysisResult.swingMetadata(),
analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), analysisResult.scenarioSet(), endIndex,
analysisResult.baseCaseChartPlan(), analysisResult.alternativeChartPlans()));
}
@Test
void from_withNullSwingMetadata_throwsNullPointerException() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
assertThrows(NullPointerException.class,
() -> ElliottWaveAnalysisReport.from(analysisResult.degree(), null, analysisResult.phaseIndicator(),
analysisResult.ratioIndicator(), analysisResult.channelIndicator(),
analysisResult.confluenceIndicator(), analysisResult.invalidationIndicator(),
analysisResult.scenarioSet(), endIndex, analysisResult.baseCaseChartPlan(),
analysisResult.alternativeChartPlans()));
}
@Test
void from_withNullPhaseIndicator_throwsNullPointerException() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
assertThrows(NullPointerException.class,
() -> ElliottWaveAnalysisReport.from(analysisResult.degree(), analysisResult.swingMetadata(), null,
analysisResult.ratioIndicator(), analysisResult.channelIndicator(),
analysisResult.confluenceIndicator(), analysisResult.invalidationIndicator(),
analysisResult.scenarioSet(), endIndex, analysisResult.baseCaseChartPlan(),
analysisResult.alternativeChartPlans()));
}
@Test
void from_withNullScenarioSet_throwsNullPointerException() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
assertThrows(NullPointerException.class,
() -> ElliottWaveAnalysisReport.from(analysisResult.degree(), analysisResult.swingMetadata(),
analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), null, endIndex, analysisResult.baseCaseChartPlan(),
analysisResult.alternativeChartPlans()));
}
@Test
void from_withNullChartPlans_throwsNullPointerException() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
assertThrows(NullPointerException.class,
() -> ElliottWaveAnalysisReport.from(analysisResult.degree(), analysisResult.swingMetadata(),
analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), analysisResult.scenarioSet(), endIndex, null,
analysisResult.alternativeChartPlans()));
assertThrows(NullPointerException.class,
() -> ElliottWaveAnalysisReport.from(analysisResult.degree(), analysisResult.swingMetadata(),
analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), analysisResult.scenarioSet(), endIndex,
analysisResult.baseCaseChartPlan(), null));
}
@Test
void toJson_serializesResultToValidJson() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
String json = result.toJson();
assertNotNull(json, "JSON should not be null");
assertTrue(json.length() > 0, "JSON should not be empty");
assertTrue(json.contains("\"degree\""), "JSON should contain degree field");
assertTrue(json.contains("\"endIndex\""), "JSON should contain endIndex field");
assertTrue(json.contains("\"swingSnapshot\""), "JSON should contain swingSnapshot field");
assertTrue(json.contains("\"latestAnalysis\""), "JSON should contain latestAnalysis field");
assertTrue(json.contains("\"scenarioSummary\""), "JSON should contain scenarioSummary field");
assertTrue(json.contains("\"trendBias\""), "JSON should contain trendBias field");
assertTrue(json.contains("\"probabilityCalibration\""), "JSON should contain probabilityCalibration field");
assertTrue(json.contains("\"outlookGate\""), "JSON should contain outlookGate field");
}
@Test
void toJson_withNaNValues_serializesNulls() {
ElliottWaveAnalysisReport.SwingSnapshot snapshot = new ElliottWaveAnalysisReport.SwingSnapshot(false, 0,
Double.NaN, Double.NaN);
ElliottWaveAnalysisReport.LatestAnalysis latest = new ElliottWaveAnalysisReport.LatestAnalysis(
ElliottPhase.NONE, false, false, RatioType.NONE, Double.NaN, null, Double.NaN, false, false);
ElliottWaveAnalysisReport.ScenarioSummary summary = new ElliottWaveAnalysisReport.ScenarioSummary("summary",
false, ElliottPhase.NONE);
ElliottTrendBias trendBias = ElliottTrendBias.unknown();
ElliottWaveAnalysisReport.ProbabilityCalibration calibration = new ElliottWaveAnalysisReport.ProbabilityCalibration(
"test-profile", "test-method", 0.5);
ElliottWaveAnalysisReport.OutlookGate outlookGate = new ElliottWaveAnalysisReport.OutlookGate(false, "NEUTRAL",
"test", Double.NaN, Double.NaN, Double.NaN, Double.NaN, false, false, Double.NaN);
ElliottWaveAnalysisReport result = new ElliottWaveAnalysisReport(ElliottDegree.PRIMARY, 0, snapshot, latest,
summary, trendBias, calibration, outlookGate, null, List.of(), null, List.of());
String json = result.toJson();
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
JsonObject swingSnapshot = root.getAsJsonObject("swingSnapshot");
JsonObject latestAnalysis = root.getAsJsonObject("latestAnalysis");
assertTrue(swingSnapshot.get("high").isJsonNull(), "NaN values should serialize as null");
assertTrue(latestAnalysis.get("ratioValue").isJsonNull(), "NaN values should serialize as null");
assertTrue(latestAnalysis.get("confluenceScore").isJsonNull(), "NaN values should serialize as null");
}
@Test
void swingSnapshot_fromMetadata_capturesCorrectData() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
ElliottWaveAnalysisReport.SwingSnapshot snapshot = result.swingSnapshot();
assertNotNull(snapshot, "Swing snapshot should not be null");
assertEquals(analysisResult.swingMetadata().isValid(), snapshot.valid(), "Valid flag should match");
assertEquals(analysisResult.swingMetadata().size(), snapshot.swings(), "Swing count should match");
assertTrue(snapshot.high() >= snapshot.low(), "High should be >= low");
}
@Test
void latestAnalysis_fromIndicators_capturesCorrectData() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
ElliottWaveAnalysisReport.LatestAnalysis latest = result.latestAnalysis();
assertNotNull(latest, "Latest analysis should not be null");
assertNotNull(latest.phase(), "Phase should not be null");
assertEquals(analysisResult.phaseIndicator().getValue(endIndex), latest.phase(), "Phase should match");
assertEquals(analysisResult.phaseIndicator().isImpulseConfirmed(endIndex), latest.impulseConfirmed(),
"Impulse confirmed should match");
assertEquals(analysisResult.phaseIndicator().isCorrectiveConfirmed(endIndex), latest.correctiveConfirmed(),
"Corrective confirmed should match");
assertNotNull(latest.ratioType(), "Ratio type should not be null");
}
@Test
void scenarioSummary_fromScenarioSet_capturesCorrectData() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
ElliottWaveAnalysisReport result = ElliottWaveAnalysisReport.from(analysisResult.degree(),
analysisResult.swingMetadata(), analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), analysisResult.scenarioSet(), endIndex,
analysisResult.baseCaseChartPlan(), analysisResult.alternativeChartPlans());
ElliottWaveAnalysisReport.ScenarioSummary summary = result.scenarioSummary();
assertNotNull(summary, "Scenario summary should not be null");
assertNotNull(summary.summary(), "Summary string should not be null");
assertEquals(analysisResult.scenarioSet().hasStrongConsensus(), summary.strongConsensus(),
"Strong consensus should match");
assertEquals(analysisResult.scenarioSet().consensus(), summary.consensusPhase(),
"Consensus phase should match");
}
@Test
void baseCaseScenario_fromScenario_capturesCorrectData() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
if (result.baseCase() != null) {
ElliottWaveAnalysisReport.BaseCaseScenario baseCase = result.baseCase();
assertNotNull(baseCase.currentPhase(), "Current phase should not be null");
assertNotNull(baseCase.type(), "Type should not be null");
assertTrue(baseCase.overallConfidence() >= 0 && baseCase.overallConfidence() <= 100,
"Overall confidence should be between 0 and 100");
assertTrue(baseCase.scenarioProbability() >= 0 && baseCase.scenarioProbability() <= 1.0,
"Scenario probability should be between 0 and 1");
assertTrue(baseCase.calibratedProbability() >= 0 && baseCase.calibratedProbability() <= 1.0,
"Calibrated probability should be between 0 and 1");
assertTrue(
baseCase.confidenceLevel().equals("HIGH") || baseCase.confidenceLevel().equals("MEDIUM")
|| baseCase.confidenceLevel().equals("LOW"),
"Confidence level should be HIGH, MEDIUM, or LOW");
assertTrue(baseCase.fibonacciScore() >= 0 && baseCase.fibonacciScore() <= 100,
"Fibonacci score should be between 0 and 100");
assertTrue(baseCase.timeScore() >= 0 && baseCase.timeScore() <= 100,
"Time score should be between 0 and 100");
assertTrue(baseCase.alternationScore() >= 0 && baseCase.alternationScore() <= 100,
"Alternation score should be between 0 and 100");
assertTrue(baseCase.channelScore() >= 0 && baseCase.channelScore() <= 100,
"Channel score should be between 0 and 100");
assertTrue(baseCase.completenessScore() >= 0 && baseCase.completenessScore() <= 100,
"Completeness score should be between 0 and 100");
assertNotNull(baseCase.primaryReason(), "Primary reason should not be null");
assertNotNull(baseCase.weakestFactor(), "Weakest factor should not be null");
assertTrue(baseCase.direction().equals("BULLISH") || baseCase.direction().equals("BEARISH"),
"Direction should be BULLISH or BEARISH");
assertNotNull(baseCase.swings(), "Swings list should not be null");
}
}
@Test
void alternativeScenario_fromScenario_capturesCorrectData() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
for (ElliottWaveAnalysisReport.AlternativeScenario alt : result.alternatives()) {
assertNotNull(alt.currentPhase(), "Current phase should not be null");
assertNotNull(alt.type(), "Type should not be null");
assertTrue(alt.confidencePercent() >= 0 && alt.confidencePercent() <= 100,
"Confidence percent should be between 0 and 100");
assertTrue(alt.scenarioProbability() >= 0 && alt.scenarioProbability() <= 1.0,
"Scenario probability should be between 0 and 1");
assertTrue(alt.calibratedProbability() >= 0 && alt.calibratedProbability() <= 1.0,
"Calibrated scenario probability should be between 0 and 1");
assertNotNull(alt.swings(), "Swings list should not be null");
}
}
@Test
void scenarioProbabilities_sumToOne() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
if (result.baseCase() == null) {
assertTrue(result.alternatives().isEmpty(), "No base case implies no alternatives");
return;
}
double totalProbability = result.baseCase().scenarioProbability();
double totalCalibratedProbability = result.baseCase().calibratedProbability();
for (ElliottWaveAnalysisReport.AlternativeScenario alt : result.alternatives()) {
totalProbability += alt.scenarioProbability();
totalCalibratedProbability += alt.calibratedProbability();
}
assertEquals(1.0, totalProbability, 0.0001, "Scenario probabilities should sum to 1");
assertEquals(1.0, totalCalibratedProbability, 0.0001, "Calibrated probabilities should sum to 1");
}
@Test
void probabilityCalibration_containsExpectedMetadata() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
ElliottWaveAnalysisReport.ProbabilityCalibration calibration = result.probabilityCalibration();
assertNotNull(calibration, "Calibration metadata should be present");
assertNotNull(calibration.profile(), "Calibration profile should be present");
assertNotNull(calibration.method(), "Calibration method should be present");
assertTrue(calibration.shrinkFactor() >= 0.0 && calibration.shrinkFactor() <= 1.0,
"Shrink factor should be in [0,1]");
ElliottWaveAnalysisReport.OutlookGate outlookGate = result.outlookGate();
assertNotNull(outlookGate, "Outlook gate should be present");
assertNotNull(outlookGate.outlookLabel(), "Outlook label should be present");
assertNotNull(outlookGate.reason(), "Outlook reason should be present");
}
@Test
void scenarioProbabilities_preferConsensusOverlap() {
ElliottScenario scenarioA = buildScenario("A", 0.8, ElliottPhase.WAVE5, ScenarioType.IMPULSE, true);
ElliottScenario scenarioB = buildScenario("B", 0.6, ElliottPhase.WAVE5, ScenarioType.IMPULSE, true);
ElliottScenario scenarioC = buildScenario("C", 0.4, ElliottPhase.CORRECTIVE_C, ScenarioType.CORRECTIVE_ZIGZAG,
false);
ElliottScenarioSet scenarioSet = ElliottScenarioSet.of(List.of(scenarioA, scenarioB, scenarioC), 0);
Map<String, Double> probabilities = ElliottWaveAnalysisReport.computeScenarioProbabilities(scenarioSet);
double normalizedConsensus = (0.8 + 0.6) / 1.8;
double normalizedOutlier = 0.4 / 1.8;
double probA = probabilities.get("A");
double probB = probabilities.get("B");
double probC = probabilities.get("C");
assertEquals(1.0, probA + probB + probC, 0.0001, "Scenario probabilities should sum to 1");
assertTrue(probA > probB, "Higher confidence should remain higher within a consensus group");
assertTrue(probA + probB > normalizedConsensus, "Consensus overlap should boost aligned scenarios");
assertTrue(probC < normalizedOutlier, "Outlier scenarios should receive less than raw confidence");
}
@Test
void scenarioProbabilities_applyConfidenceContrastForCloseScores() {
ElliottScenario scenarioA = buildScenario("A", 0.55, ElliottPhase.WAVE3, ScenarioType.IMPULSE, true);
ElliottScenario scenarioB = buildScenario("B", 0.54, ElliottPhase.WAVE3, ScenarioType.IMPULSE, true);
ElliottScenario scenarioC = buildScenario("C", 0.53, ElliottPhase.WAVE3, ScenarioType.IMPULSE, true);
ElliottScenarioSet scenarioSet = ElliottScenarioSet.of(List.of(scenarioA, scenarioB, scenarioC), 0);
Map<String, Double> probabilities = ElliottWaveAnalysisReport.computeScenarioProbabilities(scenarioSet);
double rawRatio = 0.55 / 0.53;
double adjustedRatio = probabilities.get("A") / probabilities.get("C");
assertTrue(adjustedRatio > rawRatio, "Contrast should widen the gap between close confidence scores");
}
@Test
void toJson_roundsScenarioProbabilityToThreeDecimals() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
String json = result.toJson();
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
if (result.baseCase() != null) {
double expected = roundScenarioProbability(result.baseCase().scenarioProbability());
double actual = root.getAsJsonObject("baseCase").get("scenarioProbability").getAsDouble();
assertEquals(expected, actual, 0.0001, "Base case scenario probability should be rounded to 3 decimals");
double expectedCalibrated = roundScenarioProbability(result.baseCase().calibratedProbability());
double actualCalibrated = root.getAsJsonObject("baseCase").get("calibratedProbability").getAsDouble();
assertEquals(expectedCalibrated, actualCalibrated, 0.0001,
"Base case calibrated probability should be rounded to 3 decimals");
}
if (!result.alternatives().isEmpty()) {
ElliottWaveAnalysisReport.AlternativeScenario alt = result.alternatives().get(0);
double expected = roundScenarioProbability(alt.scenarioProbability());
double actual = root.getAsJsonArray("alternatives")
.get(0)
.getAsJsonObject()
.get("scenarioProbability")
.getAsDouble();
assertEquals(expected, actual, 0.0001, "Alternative scenario probability should be rounded to 3 decimals");
double expectedCalibrated = roundScenarioProbability(alt.calibratedProbability());
double actualCalibrated = root.getAsJsonArray("alternatives")
.get(0)
.getAsJsonObject()
.get("calibratedProbability")
.getAsDouble();
assertEquals(expectedCalibrated, actualCalibrated, 0.0001,
"Alternative calibrated probability should be rounded to 3 decimals");
}
}
@Test
void swingData_fromSwing_capturesCorrectData() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
ElliottWaveAnalysisReport result = analysisResult.structuredResult();
if (result.baseCase() != null && !result.baseCase().swings().isEmpty()) {
ElliottWaveAnalysisReport.SwingData swingData = result.baseCase().swings().get(0);
assertTrue(swingData.fromIndex() >= 0, "From index should be non-negative");
assertTrue(swingData.toIndex() >= 0, "To index should be non-negative");
assertTrue(swingData.toIndex() != swingData.fromIndex(), "To index should differ from from index");
assertTrue(swingData.fromPrice() >= 0 || Double.isNaN(swingData.fromPrice()),
"From price should be non-negative or NaN");
assertTrue(swingData.toPrice() >= 0 || Double.isNaN(swingData.toPrice()),
"To price should be non-negative or NaN");
}
}
@Test
void from_withEmptyAlternativeChartPlans_createsEmptyList() {
// This test verifies that when there are no alternative chart plans, the list
// is empty
// The actual integration creates the result automatically, so we test the
// factory method directly
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
// Test factory method with empty list
ElliottWaveAnalysisReport result = ElliottWaveAnalysisReport.from(analysisResult.degree(),
analysisResult.swingMetadata(), analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), analysisResult.scenarioSet(), endIndex,
analysisResult.baseCaseChartPlan(), List.of());
assertNotNull(result.alternativeChartImages(), "Alternative chart images list should not be null");
assertEquals(0, result.alternativeChartImages().size(), "Alternative chart images list should be empty");
}
@Test
void from_withEmptyBaseCaseChartPlan_createsNullImage() {
// This test verifies that when there's no base case chart plan, the image is
// null
// The actual integration creates the result automatically, so we test the
// factory method directly
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult analysisResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
int endIndex = series.getEndIndex();
// Test factory method with empty optional
ElliottWaveAnalysisReport result = ElliottWaveAnalysisReport.from(analysisResult.degree(),
analysisResult.swingMetadata(), analysisResult.phaseIndicator(), analysisResult.ratioIndicator(),
analysisResult.channelIndicator(), analysisResult.confluenceIndicator(),
analysisResult.invalidationIndicator(), analysisResult.scenarioSet(), endIndex, Optional.empty(),
analysisResult.alternativeChartPlans());
assertNull(result.baseCaseChartImage(), "Base case chart image should be null when no chart plan");
}
/**
* Helper method to build synthetic scenarios for probability tests.
*/
private static ElliottScenario buildScenario(String id, double confidence, ElliottPhase phase, ScenarioType type,
boolean bullish) {
DoubleNum score = DoubleNum.valueOf(confidence);
ElliottConfidence confidenceScore = new ElliottConfidence(score, score, score, score, score, score,
"Test confidence");
return ElliottScenario.builder()
.id(id)
.currentPhase(phase)
.confidence(confidenceScore)
.degree(ElliottDegree.PRIMARY)
.type(type)
.bullishDirection(bullish)
.build();
}
private static double roundScenarioProbability(double value) {
return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue();
}
/**
* Helper method to load the ossified dataset used in tests.
*/
private static BarSeries loadOssifiedSeries() {
try (InputStream stream = ElliottWaveAnalysisReportTest.class.getClassLoader()
.getResourceAsStream(OSSIFIED_OHLCV_RESOURCE)) {
assertNotNull(stream, "Missing resource: " + OSSIFIED_OHLCV_RESOURCE);
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
assertNotNull(loaded, "Failed to deserialize BarSeries from resource");
BarSeries series = new BaseBarSeriesBuilder().withName("BTC-USD_PT1D@Coinbase (ossified)").build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
throw new AssertionError("Failed to load dataset", ex);
}
}
/**
* Helper method to validate base64 encoding.
*/
private static boolean isValidBase64(String str) {
try {
Base64.getDecoder().decode(str);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}
@@ -0,0 +1,606 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.awt.GraphicsEnvironment;
import java.io.InputStream;
import java.time.Duration;
import java.util.Optional;
import java.time.Instant;
import java.util.List;
import org.jfree.chart.annotations.XYTextAnnotation;
import org.jfree.chart.plot.CombinedDomainXYPlot;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.JFreeChart;
import org.junit.Assume;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ta4j.core.indicators.elliott.ElliottFibonacciValidator;
import org.ta4j.core.indicators.elliott.ElliottSwingIndicator;
import org.ta4j.core.indicators.elliott.ElliottPhaseIndicator;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator.LabelPlacement;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator.BarLabel;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator;
import ta4jexamples.charting.display.SwingChartDisplayer;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import ta4jexamples.datasources.BarSeriesDataSource;
class ElliottWaveAnalysisTest {
private static final String OSSIFIED_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
private static final double FIB_TOLERANCE = 0.25;
@BeforeEach
void setUp() {
// Disable chart display to prevent windows from appearing in tests
// This allows tests to create charts without failing in headless environments
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
}
@AfterEach
void tearDown() {
// Clear the property after each test
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
@Test
@Tag("analysis-demo")
void ossifiedDatasetProducesImpulseAndCorrection() {
BarSeries series = loadOssifiedSeries();
ElliottSwingIndicator swingIndicator = ElliottSwingIndicator.zigZag(series, ElliottDegree.PRIMARY);
ElliottFibonacciValidator validator = new ElliottFibonacciValidator(series.numFactory(),
series.numFactory().numOf(FIB_TOLERANCE));
ElliottPhaseIndicator phaseIndicator = new ElliottPhaseIndicator(swingIndicator, validator);
int endIndex = series.getEndIndex();
assertEquals(ElliottPhase.CORRECTIVE_C, phaseIndicator.getValue(endIndex));
assertTrue(phaseIndicator.isImpulseConfirmed(endIndex));
assertTrue(phaseIndicator.isCorrectiveConfirmed(endIndex));
assertEquals(5, phaseIndicator.impulseSwings(endIndex).size());
assertEquals(3, phaseIndicator.correctiveSwings(endIndex).size());
}
@Test
@Tag("analysis-demo")
void rendersWaveLabelsOnChart() {
Assume.assumeFalse("Headless environment", GraphicsEnvironment.isHeadless());
BarSeries series = loadOssifiedSeries();
ElliottSwingIndicator swingIndicator = ElliottSwingIndicator.zigZag(series, ElliottDegree.PRIMARY);
ElliottFibonacciValidator validator = new ElliottFibonacciValidator(series.numFactory(),
series.numFactory().numOf(FIB_TOLERANCE));
ElliottPhaseIndicator phaseIndicator = new ElliottPhaseIndicator(swingIndicator, validator);
BarSeriesLabelIndicator waveLabels = buildWaveLabels(series, phaseIndicator);
ChartWorkflow workflow = new ChartWorkflow();
JFreeChart chart = workflow.builder()
.withSeries(series)
.withIndicatorOverlay(waveLabels)
.withLabel("Wave labels")
.toChart();
CombinedDomainXYPlot combinedPlot = (CombinedDomainXYPlot) chart.getPlot();
XYPlot pricePlot = combinedPlot.getSubplots().get(0);
List<String> annotationTexts = pricePlot.getAnnotations()
.stream()
.filter(XYTextAnnotation.class::isInstance)
.map(annotation -> ((XYTextAnnotation) annotation).getText())
.toList();
assertTrue(annotationTexts.containsAll(List.of("1", "2", "3", "4", "5", "A", "B", "C")));
}
private static BarSeries loadOssifiedSeries() {
try (InputStream stream = ElliottWaveAnalysisTest.class.getClassLoader()
.getResourceAsStream(OSSIFIED_OHLCV_RESOURCE)) {
assertNotNull(stream, "Missing resource: " + OSSIFIED_OHLCV_RESOURCE);
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
assertNotNull(loaded, "Failed to deserialize BarSeries from resource");
BarSeries series = new BaseBarSeriesBuilder().withName("BTC-USD_PT1D@Coinbase (ossified)").build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
throw new AssertionError("Failed to load dataset", ex);
}
}
private static BarSeries syntheticSeries(String name, int barCount) {
BarSeries series = new BaseBarSeriesBuilder().withName(name).build();
Duration period = Duration.ofDays(1);
Instant start = Instant.parse("2023-01-01T00:00:00Z");
for (int index = 0; index < barCount; index++) {
series.barBuilder()
.timePeriod(period)
.endTime(start.plus(period.multipliedBy(index + 1L)))
.openPrice(100 + index)
.highPrice(103 + index)
.lowPrice(97 + index)
.closePrice(101 + index)
.volume(1_000 + index)
.add();
}
return series;
}
private static BarSeriesLabelIndicator buildWaveLabels(BarSeries series, ElliottPhaseIndicator phaseIndicator) {
int endIndex = series.getEndIndex();
List<ElliottSwing> impulse = phaseIndicator.impulseSwings(endIndex);
List<ElliottSwing> correction = phaseIndicator.correctiveSwings(endIndex);
List<BarLabel> labels = new ArrayList<>();
if (!impulse.isEmpty()) {
ElliottSwing first = impulse.get(0);
labels.add(new BarLabel(first.fromIndex(), first.fromPrice(), "", placementForPivot(!first.isRising())));
}
for (int i = 0; i < impulse.size(); i++) {
ElliottSwing swing = impulse.get(i);
labels.add(new BarLabel(swing.toIndex(), swing.toPrice(), String.valueOf(i + 1),
placementForPivot(swing.isRising())));
}
for (int i = 0; i < correction.size(); i++) {
ElliottSwing swing = correction.get(i);
String label = switch (i) {
case 0 -> "A";
case 1 -> "B";
default -> "C";
};
labels.add(new BarLabel(swing.toIndex(), swing.toPrice(), label, placementForPivot(swing.isRising())));
}
return new BarSeriesLabelIndicator(series, labels);
}
private static LabelPlacement placementForPivot(boolean isHighPivot) {
return isHighPivot ? LabelPlacement.ABOVE : LabelPlacement.BELOW;
}
private static final class StubBarSeriesDataSource implements BarSeriesDataSource {
private final boolean throwOnLoad;
private final String sourceName;
private final BarSeries series;
private StubBarSeriesDataSource(String sourceName, BarSeries series, boolean throwOnLoad) {
this.throwOnLoad = throwOnLoad;
this.sourceName = sourceName;
this.series = series;
}
@Override
public BarSeries loadSeries(String ticker, Duration interval, Instant start, Instant end) {
if (throwOnLoad) {
throw new IllegalStateException("Stubbed load failure");
}
return series;
}
@Override
public BarSeries loadSeries(String source) {
return series;
}
@Override
public String getSourceName() {
return sourceName;
}
}
@Test
void loadSeriesFromDataSource_withUnsupportedDataSource_returnsNull() {
String unsupportedDataSource = "UnsupportedSource";
String ticker = "BTC-USD";
Duration barDuration = Duration.ofDays(1);
Instant startTime = Instant.parse("2023-01-01T00:00:00Z");
Instant endTime = Instant.parse("2023-01-31T23:59:59Z");
BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(unsupportedDataSource, ticker,
barDuration, startTime, endTime);
assertNull(result, "Should return null for unsupported data source");
}
@Test
void loadSeriesFromDataSource_withNullDataSource_returnsNull() {
String ticker = "BTC-USD";
Duration barDuration = Duration.ofDays(1);
Instant startTime = Instant.parse("2023-01-01T00:00:00Z");
Instant endTime = Instant.parse("2023-01-31T23:59:59Z");
BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource((String) null, ticker, barDuration,
startTime, endTime);
assertNull(result, "Should return null for null data source");
}
@Test
void loadSeriesFromDataSource_withStubSeries_returnsSeries() {
BarSeries series = syntheticSeries("Stub", 8);
BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", series, false);
BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));
assertEquals(series, result, "Should return the stubbed series");
}
@Test
void loadSeriesFromDataSource_withStubReturningNull_returnsNull() {
BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", null, false);
BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));
assertNull(result, "Should return null when the data source returns null");
}
@Test
void loadSeriesFromDataSource_withStubReturningEmpty_returnsNull() {
BarSeries emptySeries = new BaseBarSeriesBuilder().withName("Empty").build();
BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", emptySeries, false);
BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));
assertNull(result, "Should return null when the data source returns an empty series");
}
@Test
void loadSeriesFromDataSource_withStubThrowingException_returnsNull() {
BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", null, true);
BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));
assertNull(result, "Should return null when the data source throws");
}
@Test
void parseBarSeriesRequest_withDayBasedDuration_convertsToHours() {
String[] args = { "Coinbase", "BTC-USD", "PT1D", "1686960000", "1697040000" };
Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
.parseBarSeriesRequest(args);
assertTrue(request.isPresent(), "Request should parse successfully");
assertEquals(Duration.ofDays(1), request.get().barDuration(), "Duration should normalize to 24 hours");
}
@Test
void parseBarSeriesRequest_withInvalidEpoch_returnsEmpty() {
String[] args = { "Coinbase", "BTC-USD", "PT1D", "invalid-epoch" };
Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
.parseBarSeriesRequest(args);
assertTrue(request.isEmpty(), "Invalid epoch should return empty request");
}
@Test
void parseBarSeriesRequest_withInvalidDataSource_returnsEmpty() {
String[] args = { "InvalidSource", "BTC-USD", "PT1D", "1686960000", "1697040000" };
Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
.parseBarSeriesRequest(args);
assertTrue(request.isEmpty(), "Invalid data source should return empty request");
}
@Test
void parseBarSeriesRequest_withInvalidTimeRange_returnsEmpty() {
String[] args = { "Coinbase", "BTC-USD", "PT1D", "1697040000", "1686960000" };
Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
.parseBarSeriesRequest(args);
assertTrue(request.isEmpty(), "Invalid time range should return empty request");
}
@Test
void resolveDegree_withExplicitDegree_usesProvidedDegree() {
BarSeries series = syntheticSeries("Degree", 90);
String[] args = { "Coinbase", "BTC-USD", "PT1D", "PRIMARY", "1686960000", "1697040000" };
ElliottDegree resolved = ElliottWaveIndicatorSuiteDemo.resolveDegree(args, series);
assertEquals(ElliottDegree.PRIMARY, resolved, "Explicit degree should be used");
}
@Test
void resolveDegree_withCaseInsensitiveDegree_usesProvidedDegree() {
BarSeries series = syntheticSeries("Degree", 90);
String[] args = { "Coinbase", "BTC-USD", "PT1D", "primary", "1686960000", "1697040000" };
ElliottDegree resolved = ElliottWaveIndicatorSuiteDemo.resolveDegree(args, series);
assertEquals(ElliottDegree.PRIMARY, resolved, "Degree should be parsed case-insensitively");
}
@Test
void resolveDegree_withoutExplicitDegree_usesRecommendation() {
BarSeries series = syntheticSeries("Degree", 90);
String[] args = { "Coinbase", "BTC-USD", "PT1D", "1686960000", "1697040000" };
List<ElliottDegree> recommendations = ElliottDegree.getRecommendedDegrees(series.getFirstBar().getTimePeriod(),
series.getBarCount());
ElliottDegree resolved = ElliottWaveIndicatorSuiteDemo.resolveDegree(args, series);
if (recommendations.isEmpty()) {
assertEquals(ElliottDegree.PRIMARY, resolved, "Default degree should be used when none recommended");
} else {
assertEquals(recommendations.get(0), resolved, "Recommended degree should be used");
}
}
@Test
@Tag("analysis-demo")
void analyze_withValidSeries_completesSuccessfully() {
// Test that analyze() completes successfully and generates wave labels
// This tests buildWaveLabelsFromScenario and placementForPivot indirectly
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
// Should not throw exception
analysis.analyze(series, ElliottDegree.PRIMARY, FIB_TOLERANCE);
// The analyze method internally calls buildWaveLabelsFromScenario
// We can verify it worked by checking that charts were generated
// (though we can't directly access the labels, we know they were created)
}
@Test
@Tag("analysis-demo")
void analyze_withDifferentScenarioTypes_generatesAppropriateLabels() {
// Test that analyze() handles different scenario types
// This indirectly tests buildWaveLabelsFromScenario with different types
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
// Test with different degrees which may produce different scenario types
analysis.analyze(series, ElliottDegree.PRIMARY, FIB_TOLERANCE);
analysis.analyze(series, ElliottDegree.INTERMEDIATE, FIB_TOLERANCE);
analysis.analyze(series, ElliottDegree.MINOR, FIB_TOLERANCE);
// Should complete without exception, indicating label generation worked
}
@Test
@Tag("analysis-demo")
void analyze_withDifferentDegrees_completesSuccessfully() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
// Test with different degrees
analysis.analyze(series, ElliottDegree.INTERMEDIATE, FIB_TOLERANCE);
analysis.analyze(series, ElliottDegree.MINOR, FIB_TOLERANCE);
}
@Test
@Tag("analysis-demo")
void analyze_withDifferentFibTolerances_completesSuccessfully() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
// Test with different tolerances
analysis.analyze(series, ElliottDegree.PRIMARY, 0.1);
analysis.analyze(series, ElliottDegree.PRIMARY, 0.5);
}
@Test
@Tag("analysis-demo")
void analyze_returnsAnalysisResult() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
assertNotNull(result, "Analysis result should not be null");
assertEquals(series, result.series(), "Series should match");
assertEquals(ElliottDegree.PRIMARY, result.degree(), "Degree should match");
assertNotNull(result.phaseIndicator(), "Phase indicator should not be null");
assertNotNull(result.invalidationIndicator(), "Invalidation indicator should not be null");
assertNotNull(result.channelIndicator(), "Channel indicator should not be null");
assertNotNull(result.ratioIndicator(), "Ratio indicator should not be null");
assertNotNull(result.confluenceIndicator(), "Confluence indicator should not be null");
assertNotNull(result.swingCount(), "Swing count indicator should not be null");
assertNotNull(result.filteredSwingCount(), "Filtered swing count indicator should not be null");
assertNotNull(result.scenarioIndicator(), "Scenario indicator should not be null");
assertNotNull(result.swingMetadata(), "Swing metadata should not be null");
assertNotNull(result.scenarioSet(), "Scenario set should not be null");
assertNotNull(result.ratioValue(), "Ratio value indicator should not be null");
assertNotNull(result.swingCountAsNum(), "Swing count as num indicator should not be null");
assertNotNull(result.filteredSwingCountAsNum(), "Filtered swing count as num indicator should not be null");
assertNotNull(result.baseCaseChartPlan(), "Base case chart plan optional should not be null");
assertNotNull(result.alternativeChartPlans(), "Alternative chart plans list should not be null");
assertNotNull(result.structuredResult(), "Structured result should not be null");
assertNotNull(result.structuredResult().swingSnapshot(), "Structured result swing snapshot should not be null");
assertNotNull(result.structuredResult().latestAnalysis(),
"Structured result latest analysis should not be null");
assertNotNull(result.structuredResult().scenarioSummary(),
"Structured result scenario summary should not be null");
}
@Test
@Tag("analysis-demo")
void analyze_withBaseCaseScenario_createsBaseCaseChartPlan() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
assertTrue(result.baseCaseChartPlan().isPresent(),
"Base case chart plan should be present when base case scenario exists");
assertNotNull(result.baseCaseChartPlan().get(), "Base case chart plan should not be null");
assertNotNull(result.scenarioSet().base(), "Base case scenario should exist");
}
@Test
@Tag("analysis-demo")
void analyze_createsChartPlansForAllScenarios() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
// Verify base case chart plan exists if base case scenario exists
if (result.scenarioSet().base().isPresent()) {
assertTrue(result.baseCaseChartPlan().isPresent(),
"Base case chart plan should exist when base case scenario exists");
}
// Verify alternative chart plans match alternative scenarios
int alternativeCount = result.scenarioSet().alternatives().size();
assertEquals(alternativeCount, result.alternativeChartPlans().size(),
"Number of alternative chart plans should match number of alternative scenarios");
}
@Test
@Tag("analysis-demo")
void visualizeAnalysisResult_withValidResult_completesSuccessfully() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
// Should not throw exception
analysis.visualizeAnalysisResult(result);
}
@Test
void visualizeAnalysisResult_withNullResult_throwsNullPointerException() {
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
NullPointerException exception = assertThrows(NullPointerException.class,
() -> analysis.visualizeAnalysisResult(null));
assertNotNull(exception.getMessage(), "Exception message should not be null");
assertTrue(exception.getMessage().contains("Analysis result cannot be null"),
"Exception message should indicate null result");
}
@Test
@Tag("analysis-demo")
void visualizeAnalysisResult_withDifferentDegrees_formatsWindowTitlesCorrectly() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
// Test with PRIMARY degree
ElliottWaveIndicatorSuiteDemo.AnalysisResult primaryResult = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
if (primaryResult.baseCaseChartPlan().isPresent() && primaryResult.scenarioSet().base().isPresent()) {
ElliottScenario baseCase = primaryResult.scenarioSet().base().get();
String expectedBaseCaseTitle = String.format("%s - BASE CASE: %s (%s) - %.1f%% - %s", ElliottDegree.PRIMARY,
baseCase.currentPhase(), baseCase.type(), baseCase.confidence().asPercentage(), series.getName());
assertTrue(expectedBaseCaseTitle.startsWith("PRIMARY -"),
"Base case window title should start with degree");
assertTrue(expectedBaseCaseTitle.contains("BASE CASE:"),
"Base case window title should contain BASE CASE:");
}
// Test with INTERMEDIATE degree
ElliottWaveIndicatorSuiteDemo.AnalysisResult intermediateResult = analysis.analyze(series,
ElliottDegree.INTERMEDIATE, FIB_TOLERANCE);
if (intermediateResult.baseCaseChartPlan().isPresent() && intermediateResult.scenarioSet().base().isPresent()) {
ElliottScenario baseCase = intermediateResult.scenarioSet().base().get();
String expectedIntermediateTitle = String.format("%s - BASE CASE: %s (%s) - %.1f%% - %s",
ElliottDegree.INTERMEDIATE, baseCase.currentPhase(), baseCase.type(),
baseCase.confidence().asPercentage(), series.getName());
assertTrue(expectedIntermediateTitle.startsWith("INTERMEDIATE -"),
"Intermediate window title should start with degree");
}
// Test with MINOR degree
ElliottWaveIndicatorSuiteDemo.AnalysisResult minorResult = analysis.analyze(series, ElliottDegree.MINOR,
FIB_TOLERANCE);
if (minorResult.baseCaseChartPlan().isPresent() && minorResult.scenarioSet().base().isPresent()) {
ElliottScenario baseCase = minorResult.scenarioSet().base().get();
String expectedMinorTitle = String.format("%s - BASE CASE: %s (%s) - %.1f%% - %s", ElliottDegree.MINOR,
baseCase.currentPhase(), baseCase.type(), baseCase.confidence().asPercentage(), series.getName());
assertTrue(expectedMinorTitle.startsWith("MINOR -"), "Minor window title should start with degree");
}
}
@Test
@Tag("analysis-demo")
void visualizeAnalysisResult_withAlternativeScenarios_formatsWindowTitlesCorrectly() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
List<ElliottScenario> alternatives = result.scenarioSet().alternatives();
for (int i = 0; i < alternatives.size() && i < result.alternativeChartPlans().size(); i++) {
ElliottScenario alt = alternatives.get(i);
String expectedAltTitle = String.format("%s - ALTERNATIVE %d: %s (%s) - %.1f%% - %s", result.degree(),
i + 1, alt.currentPhase(), alt.type(), alt.confidence().asPercentage(), series.getName());
assertTrue(expectedAltTitle.startsWith(result.degree().toString() + " -"),
"Alternative window title should start with degree");
assertTrue(expectedAltTitle.contains("ALTERNATIVE " + (i + 1) + ":"),
"Alternative window title should contain alternative number");
}
}
@Test
void analyze_withEmptySeries_throwsIllegalArgumentException() {
BarSeries emptySeries = new BaseBarSeriesBuilder().withName("Empty").build();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> analysis.analyze(emptySeries, ElliottDegree.PRIMARY, FIB_TOLERANCE));
assertNotNull(exception.getMessage(), "Exception message should not be null");
assertTrue(exception.getMessage().contains("Series cannot be empty"),
"Exception message should indicate empty series");
}
@Test
void analyze_withNullSeries_throwsNullPointerException() {
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
NullPointerException exception = assertThrows(NullPointerException.class,
() -> analysis.analyze(null, ElliottDegree.PRIMARY, FIB_TOLERANCE));
assertNotNull(exception.getMessage(), "Exception message should not be null");
assertTrue(exception.getMessage().contains("Series cannot be null"),
"Exception message should indicate null series");
}
@Test
@Tag("analysis-demo")
void analyze_separatesAnalysisFromVisualization() {
BarSeries series = loadOssifiedSeries();
ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
// Analyze without visualization
ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
FIB_TOLERANCE);
// Verify analysis completed
assertNotNull(result);
assertNotNull(result.scenarioSet());
// Now visualize separately
analysis.visualizeAnalysisResult(result);
// Both should complete without exception
}
}
@@ -0,0 +1,59 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.ta4j.core.indicators.elliott.ElliottDegree;
class ElliottWavePresetDemoTest {
private static final Instant FIXED_END_TIME = Instant.parse("2026-02-25T00:00:00Z");
private static final Instant EXPECTED_START_TIME = FIXED_END_TIME.minus(Duration.ofDays(1825L));
@Test
void buildLiveSuiteArgsWithoutDegreeUsesExpectedLayout() {
String[] args = ElliottWavePresetDemo.buildLiveSuiteArgs("Coinbase", "BTC-USD", "PT1D", 1825L, FIXED_END_TIME,
null);
assertEquals(5, args.length, "Expected auto-degree argument count");
assertEquals("Coinbase", args[0], "Unexpected data source");
assertEquals("BTC-USD", args[1], "Unexpected ticker");
assertEquals("PT1D", args[2], "Unexpected bar duration");
assertEquals(Long.toString(EXPECTED_START_TIME.getEpochSecond()), args[3], "Unexpected start epoch");
assertEquals(Long.toString(FIXED_END_TIME.getEpochSecond()), args[4], "Unexpected end epoch");
}
@Test
void buildLiveSuiteArgsWithDegreeUsesExplicitDegreeLayout() {
String[] args = ElliottWavePresetDemo.buildLiveSuiteArgs("YahooFinance", "AAPL", "PT1D", 1825L, FIXED_END_TIME,
ElliottDegree.PRIMARY);
assertEquals(6, args.length, "Expected explicit-degree argument count");
assertEquals("YahooFinance", args[0], "Unexpected data source");
assertEquals("AAPL", args[1], "Unexpected ticker");
assertEquals("PT1D", args[2], "Unexpected bar duration");
assertEquals("PRIMARY", args[3], "Unexpected degree");
assertEquals(Long.toString(EXPECTED_START_TIME.getEpochSecond()), args[4], "Unexpected start epoch");
assertEquals(Long.toString(FIXED_END_TIME.getEpochSecond()), args[5], "Unexpected end epoch");
}
@Test
void shouldUseBtcMacroPresetForDailyBitcoin() {
assertTrue(ElliottWavePresetDemo.shouldUseBtcMacroPreset("BTC-USD", "PT1D"));
assertTrue(ElliottWavePresetDemo.shouldUseBtcMacroPreset("btc-usd", "PT24H"));
}
@Test
void shouldNotUseBtcMacroPresetForOtherAssetsOrIntervals() {
assertFalse(ElliottWavePresetDemo.shouldUseBtcMacroPreset("ETH-USD", "PT1D"));
assertFalse(ElliottWavePresetDemo.shouldUseBtcMacroPreset("BTC-USD", "PT4H"));
}
}
@@ -0,0 +1,220 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.ta4j.core.BarSeries;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import ta4jexamples.charting.display.SwingChartDisplayer;
import ta4jexamples.datasources.CoinbaseHttpBarSeriesDataSource;
import ta4jexamples.datasources.CoinbaseHttpBarSeriesDataSource.CoinbaseInterval;
/**
* Black-box analysis-demo funnel for the live Elliott Wave macro report.
*
* <p>
* Set {@code ta4j.analysisDemoInstrument} to a provider-qualified input such as
* {@code coinbase:BTC-USD}. Set {@code ta4j.analysisDemoOutputDir} to control
* where JSON, chart, and cached provider-response artifacts are written.
*/
@Tag("analysis-demo")
class ElliottWaveAnalysisDemoReportTest {
private static final String INSTRUMENT_PROPERTY = "ta4j.analysisDemoInstrument";
private static final String OUTPUT_DIRECTORY_PROPERTY = "ta4j.analysisDemoOutputDir";
private static final String DEFAULT_INSTRUMENT = "coinbase:BTC-USD";
private static final String COINBASE_PROVIDER = "coinbase";
private static final Pattern COINBASE_PRODUCT_ID_PATTERN = Pattern.compile("[A-Z0-9]+(?:-[A-Z0-9]+)+");
private static final int DAILY_BAR_COUNT = 1825;
@TempDir
Path tempDirectory;
@Test
void liveMacroReportProducesCurrentElliottAnalysisArtifacts() throws IOException {
String previousDisableDisplay = System.getProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
try {
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
AnalysisInstrument instrument = parseInstrument(
System.getProperty(INSTRUMENT_PROPERTY, DEFAULT_INSTRUMENT));
Path outputDirectory = resolveOutputDirectory();
Path responseCacheDirectory = outputDirectory.resolve("responses");
Files.createDirectories(responseCacheDirectory);
BarSeries series = loadSeries(instrument, responseCacheDirectory);
assertNotNull(series);
assertFalse(series.isEmpty());
assertTrue(series.getBarCount() > 1000,
() -> "Expected enough daily bars for live macro analysis but got " + series.getBarCount());
ElliottWaveMacroCycleDemo.LivePresetReport report = ElliottWaveMacroCycleDemo
.generateLivePresetReport(series, outputDirectory);
Path summaryPath = Path.of(report.summaryPath());
Path chartPath = Path.of(report.chartPath());
assertEquals(instrument.productId(), report.seriesName());
assertTrue(Files.exists(summaryPath), () -> "Missing summary artifact: " + summaryPath);
assertTrue(Files.size(summaryPath) > 0, () -> "Empty summary artifact: " + summaryPath);
assertTrue(Files.exists(chartPath), () -> "Missing chart artifact: " + chartPath);
assertTrue(Files.size(chartPath) > 0, () -> "Empty chart artifact: " + chartPath);
assertTrue(hasCachedResponses(responseCacheDirectory),
() -> "Expected cached provider responses under " + responseCacheDirectory);
JsonObject summary = JsonParser.parseString(Files.readString(summaryPath)).getAsJsonObject();
JsonObject currentCycle = summary.getAsJsonObject("currentCycle");
assertEquals(instrument.productId(), summary.get("seriesName").getAsString());
assertNotNull(currentCycle);
assertFalse(currentCycle.get("primaryCount").getAsString().isBlank());
assertFalse(currentCycle.get("currentWave").getAsString().isBlank());
assertEquals(chartPath.toAbsolutePath().normalize().toString(),
currentCycle.get("chartPath").getAsString());
} finally {
restoreDisableDisplayProperty(previousDisableDisplay);
}
}
@Test
void providerQualifiedInstrumentAcceptsCoinbaseProducts() {
AnalysisInstrument btcUsd = parseInstrument(" coinbase:btc/usd ");
AnalysisInstrument ethUsd = parseInstrument("COINBASE:eth-usd");
assertEquals(COINBASE_PROVIDER, btcUsd.provider());
assertEquals("BTC-USD", btcUsd.productId());
assertEquals(COINBASE_PROVIDER, ethUsd.provider());
assertEquals("ETH-USD", ethUsd.productId());
}
@Test
void providerQualifiedInstrumentRejectsUnsupportedProviders() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> parseInstrument("yahoo:SPY"));
assertTrue(exception.getMessage().contains("Unsupported analysis demo provider"));
}
@Test
void providerQualifiedInstrumentRejectsInvalidCoinbaseProductIds() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> parseInstrument("coinbase:BTC USD"));
assertTrue(exception.getMessage().contains("Coinbase analysis demo product id"));
}
private static BarSeries loadSeries(final AnalysisInstrument instrument, final Path responseCacheDirectory) {
CoinbaseHttpBarSeriesDataSource dataSource = new CoinbaseHttpBarSeriesDataSource(
responseCacheDirectory.toString());
BarSeries series = dataSource.loadSeriesInstance(instrument.productId(), CoinbaseInterval.ONE_DAY,
DAILY_BAR_COUNT, "analysis-demo");
if (series == null || series.isEmpty()) {
throw new IllegalStateException("No daily Coinbase candles returned for " + instrument.productId());
}
return series;
}
private Path resolveOutputDirectory() {
String configured = System.getProperty(OUTPUT_DIRECTORY_PROPERTY);
if (configured == null || configured.isBlank()) {
return tempDirectory.resolve("analysis-demos").resolve("elliott-wave").toAbsolutePath().normalize();
}
Path configuredPath = Path.of(configured.trim());
if (configuredPath.isAbsolute()) {
return configuredPath.normalize();
}
return repositoryRoot().resolve(configuredPath).toAbsolutePath().normalize();
}
private static AnalysisInstrument parseInstrument(final String rawInstrument) {
String instrument = rawInstrument == null ? "" : rawInstrument.trim();
int separator = instrument.indexOf(':');
if (separator <= 0 || separator == instrument.length() - 1) {
throw new IllegalArgumentException(
"Analysis demo instrument must use provider-qualified format, for example coinbase:BTC-USD");
}
String provider = instrument.substring(0, separator).trim().toLowerCase(Locale.ROOT);
String productId = instrument.substring(separator + 1).trim().toUpperCase(Locale.ROOT).replace('/', '-');
if (!COINBASE_PROVIDER.equals(provider)) {
throw new IllegalArgumentException(
"Unsupported analysis demo provider '" + provider + "'. Supported providers: coinbase");
}
if (productId.isBlank()) {
throw new IllegalArgumentException("Coinbase analysis demo product id cannot be blank");
}
if (!COINBASE_PRODUCT_ID_PATTERN.matcher(productId).matches()) {
throw new IllegalArgumentException(
"Coinbase analysis demo product id must use Coinbase product format, for example BTC-USD");
}
return new AnalysisInstrument(provider, productId);
}
private static void restoreDisableDisplayProperty(final String previousDisableDisplay) {
if (previousDisableDisplay == null) {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
} else {
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, previousDisableDisplay);
}
}
private static boolean hasCachedResponses(final Path responseCacheDirectory) throws IOException {
if (!Files.isDirectory(responseCacheDirectory)) {
return false;
}
try (Stream<Path> responses = Files.list(responseCacheDirectory)) {
return responses.anyMatch(path -> path.getFileName().toString().endsWith(".json"));
}
}
private static Path repositoryRoot() {
String rootDirectory = System.getProperty("maven.multiModuleProjectDirectory");
if (rootDirectory != null && !rootDirectory.isBlank()) {
Path candidate = Path.of(rootDirectory).toAbsolutePath().normalize();
if (isRepositoryRoot(candidate)) {
return candidate;
}
}
Path current = Path.of("").toAbsolutePath().normalize();
for (Path candidate = current; candidate != null; candidate = candidate.getParent()) {
if (isRepositoryRoot(candidate)) {
return candidate;
}
}
return current;
}
private static boolean isRepositoryRoot(final Path candidate) {
return Files.isRegularFile(candidate.resolve("pom.xml")) && Files.isDirectory(candidate.resolve("ta4j-core"))
&& Files.isDirectory(candidate.resolve("ta4j-examples"));
}
private record AnalysisInstrument(String provider, String productId) {
private AnalysisInstrument {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(productId, "productId");
}
}
}
@@ -0,0 +1,265 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.num.DoubleNumFactory;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
class ElliottWaveAnchorRegistryTest {
@Test
void loadParsesDefaultRegistryResource() {
ElliottWaveAnchorRegistry registry = ElliottWaveAnchorRegistry.load(ElliottWaveAnchorRegistry.DEFAULT_RESOURCE);
assertEquals("btc-macro-cycle-anchors-v2", registry.registryId());
assertEquals(ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE, registry.datasetResource());
assertFalse(registry.provenance().isBlank());
assertEquals(8, registry.anchors().size());
assertEquals("btc-2011-cycle-top", registry.anchors().getFirst().id());
assertEquals("btc-2013-cycle-top", registry.anchors().get(2).id());
assertEquals("btc-2022-cycle-bottom", registry.anchors().getLast().id());
assertTrue(registry.anchors()
.stream()
.filter(anchor -> anchor.kind() == ElliottWaveAnchorRegistry.AnchorKind.BOTTOM)
.allMatch(anchor -> anchor.expectedPhases().equals(Set.of(ElliottPhase.CORRECTIVE_C))));
}
@Test
void resolveBindsAnchorsToSeriesAndAssignsTrailingHoldoutPartition() {
ElliottWaveAnchorRegistry registry = ElliottWaveAnchorRegistry.load(ElliottWaveAnchorRegistry.DEFAULT_RESOURCE);
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveAnchorRegistryTest.class,
ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE, ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME,
org.apache.logging.log4j.LogManager.getLogger(ElliottWaveAnchorRegistryTest.class));
List<ElliottWaveAnchorRegistry.ResolvedAnchor> resolved = registry.resolve(series, 3);
assertEquals(registry.anchors().size(), resolved.size());
int firstHoldoutIndex = resolved.size() - 3;
for (int index = 0; index < resolved.size(); index++) {
ElliottWaveAnchorRegistry.AnchorPartition expectedPartition = index < firstHoldoutIndex
? ElliottWaveAnchorRegistry.AnchorPartition.VALIDATION
: ElliottWaveAnchorRegistry.AnchorPartition.HOLDOUT;
assertEquals(expectedPartition, resolved.get(index).partition(),
"partition at resolved anchor index " + index);
}
for (ElliottWaveAnchorRegistry.ResolvedAnchor anchor : resolved) {
assertTrue(anchor.decisionIndex() >= series.getBeginIndex());
assertTrue(anchor.decisionIndex() <= series.getEndIndex());
assertFalse(anchor.resolvedTime().isBefore(anchor.spec().windowStart()));
assertFalse(anchor.resolvedTime().isAfter(anchor.spec().windowEnd()));
assertTrue(Double.isFinite(anchor.resolvedPrice()));
}
}
@Test
void resolveHonorsZeroRequestedHoldoutAnchors() {
ElliottWaveAnchorRegistry registry = ElliottWaveAnchorRegistry.load(ElliottWaveAnchorRegistry.DEFAULT_RESOURCE);
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveAnchorRegistryTest.class,
ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE, ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME,
org.apache.logging.log4j.LogManager.getLogger(ElliottWaveAnchorRegistryTest.class));
List<ElliottWaveAnchorRegistry.ResolvedAnchor> resolved = registry.resolve(series, 0);
assertEquals(0,
resolved.stream()
.filter(anchor -> anchor.partition() == ElliottWaveAnchorRegistry.AnchorPartition.HOLDOUT)
.count());
assertTrue(resolved.stream()
.allMatch(anchor -> anchor.partition() == ElliottWaveAnchorRegistry.AnchorPartition.VALIDATION));
}
@Test
void resolveRejectsInvalidHoldoutCounts() {
ElliottWaveAnchorRegistry registry = ElliottWaveAnchorRegistry.load(ElliottWaveAnchorRegistry.DEFAULT_RESOURCE);
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveAnchorRegistryTest.class,
ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE, ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME,
org.apache.logging.log4j.LogManager.getLogger(ElliottWaveAnchorRegistryTest.class));
IllegalArgumentException negative = assertThrows(IllegalArgumentException.class,
() -> registry.resolve(series, -1));
IllegalArgumentException oversized = assertThrows(IllegalArgumentException.class,
() -> registry.resolve(series, registry.anchors().size() + 1));
assertEquals("holdoutCount must be between 0 and " + registry.anchors().size(), negative.getMessage());
assertEquals("holdoutCount must be between 0 and " + registry.anchors().size(), oversized.getMessage());
}
@Test
void anchorSpecRejectsMissingExpectedPhasesAndBackwardsWindows() {
Instant start = Instant.parse("2024-01-01T00:00:00Z");
Instant end = Instant.parse("2024-01-02T00:00:00Z");
IllegalArgumentException emptyPhases = assertThrows(IllegalArgumentException.class,
() -> new ElliottWaveAnchorRegistry.AnchorSpec("btc-top", "Top",
ElliottWaveAnchorRegistry.AnchorKind.TOP, start, end, Set.of(), "test source", ""));
IllegalArgumentException backwardsWindow = assertThrows(IllegalArgumentException.class,
() -> new ElliottWaveAnchorRegistry.AnchorSpec("btc-top", "Top",
ElliottWaveAnchorRegistry.AnchorKind.TOP, end, start, Set.of(ElliottPhase.WAVE5), "test source",
""));
assertEquals("expectedPhases must not be empty", emptyPhases.getMessage());
assertEquals("windowEnd must not be before windowStart", backwardsWindow.getMessage());
}
@Test
void anchorSpecExposesExpectedPhasesAsUnmodifiableSet() {
ElliottWaveAnchorRegistry.AnchorSpec anchor = new ElliottWaveAnchorRegistry.AnchorSpec("btc-top", "Top",
ElliottWaveAnchorRegistry.AnchorKind.TOP, Instant.parse("2024-01-01T00:00:00Z"),
Instant.parse("2024-01-03T00:00:00Z"), Set.of(ElliottPhase.WAVE5), "test source", "");
assertThrows(UnsupportedOperationException.class, () -> anchor.expectedPhases().add(ElliottPhase.WAVE3));
}
@Test
void registryAnchorRejectsUnknownKindsWithAnchorSpecificMessage() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
() -> toSpec("btc-top", "sideways", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", List.of("WAVE5")));
assertEquals("Unknown anchor kind 'sideways' for anchor btc-top", thrown.getMessage());
}
@Test
void registryAnchorRejectsUnknownExpectedPhasesWithAnchorSpecificMessage() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
() -> toSpec("btc-top", "TOP", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", List.of("WAVE6")));
assertEquals("Unknown expected phase 'WAVE6' for anchor btc-top", thrown.getMessage());
}
@Test
void registryAnchorRejectsInvalidWindowTimestampsWithAnchorSpecificMessage() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
() -> toSpec("btc-top", "TOP", "not-a-timestamp", "2024-01-02T00:00:00Z", List.of("WAVE5")));
assertEquals("Invalid windowStart 'not-a-timestamp' for anchor btc-top", thrown.getMessage());
}
@Test
void loadRejectsMissingAnchorsListWithClearMessage() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> ElliottWaveAnchorRegistry
.load("ta4jexamples/analysis/elliottwave/backtest/test-anchor-registry-missing-anchors.json"));
assertEquals(
"Anchor registry /ta4jexamples/analysis/elliottwave/backtest/test-anchor-registry-missing-anchors.json is missing \"anchors\"",
thrown.getMessage());
}
@Test
void loadRejectsNullAnchorEntriesWithClearMessage() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> ElliottWaveAnchorRegistry
.load("ta4jexamples/analysis/elliottwave/backtest/test-anchor-registry-null-anchor.json"));
assertEquals(
"Anchor registry /ta4jexamples/analysis/elliottwave/backtest/test-anchor-registry-null-anchor.json contains null anchor at index 0",
thrown.getMessage());
}
@Test
void resolveSkipsInvalidWindowPricesWhenLaterBarsRemainUsable() {
BarSeries series = syntheticSeriesWithInvalidLeadingWindowPrice();
ElliottWaveAnchorRegistry.AnchorSpec anchor = new ElliottWaveAnchorRegistry.AnchorSpec("btc-top", "Top",
ElliottWaveAnchorRegistry.AnchorKind.TOP, Instant.parse("2024-01-02T00:00:00Z"),
Instant.parse("2024-01-03T00:00:00Z"), Set.of(ElliottPhase.WAVE5), "test source", "");
ElliottWaveAnchorRegistry registry = registry(anchor);
List<ElliottWaveAnchorRegistry.ResolvedAnchor> resolved = registry.resolve(series, 0);
assertEquals(1, resolved.size());
assertEquals(1, resolved.getFirst().decisionIndex());
assertEquals(110.0, resolved.getFirst().resolvedPrice());
assertEquals(Instant.parse("2024-01-03T00:00:00Z"), resolved.getFirst().resolvedTime());
}
private static BarSeries syntheticSeriesWithInvalidLeadingWindowPrice() {
BarSeries series = new BaseBarSeriesBuilder().withName("anchor-registry-test")
.withNumFactory(DoubleNumFactory.getInstance())
.build();
Duration period = Duration.ofDays(1);
Instant start = Instant.parse("2024-01-01T00:00:00Z");
series.barBuilder()
.timePeriod(period)
.endTime(start.plus(period))
.openPrice(100)
.highPrice(Double.NaN)
.lowPrice(95)
.closePrice(Double.NaN)
.volume(1000)
.add();
series.barBuilder()
.timePeriod(period)
.endTime(start.plus(period.multipliedBy(2)))
.openPrice(100)
.highPrice(110)
.lowPrice(96)
.closePrice(108)
.volume(1000)
.add();
series.barBuilder()
.timePeriod(period)
.endTime(start.plus(period.multipliedBy(3)))
.openPrice(108)
.highPrice(105)
.lowPrice(94)
.closePrice(100)
.volume(1000)
.add();
return series;
}
private static ElliottWaveAnchorRegistry.AnchorSpec toSpec(String id, String kind, String windowStart,
String windowEnd, List<String> expectedPhases) {
try {
Class<?> registryAnchorType = Class.forName(ElliottWaveAnchorRegistry.class.getName() + "$RegistryAnchor");
Constructor<?> constructor = registryAnchorType.getDeclaredConstructor(String.class, String.class,
String.class, String.class, String.class, List.class, String.class, String.class);
constructor.setAccessible(true);
Object registryAnchor = constructor.newInstance(id, "Test anchor", kind, windowStart, windowEnd,
expectedPhases, "test source", "");
Method toSpec = registryAnchorType.getDeclaredMethod("toSpec");
toSpec.setAccessible(true);
return (ElliottWaveAnchorRegistry.AnchorSpec) toSpec.invoke(registryAnchor);
} catch (InvocationTargetException ex) {
Throwable cause = ex.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
throw new AssertionError("Failed to invoke RegistryAnchor.toSpec", cause);
} catch (ReflectiveOperationException ex) {
throw new AssertionError("Failed to create raw registry anchor", ex);
}
}
private static ElliottWaveAnchorRegistry registry(ElliottWaveAnchorRegistry.AnchorSpec anchor) {
try {
Constructor<ElliottWaveAnchorRegistry> constructor = ElliottWaveAnchorRegistry.class
.getDeclaredConstructor(String.class, String.class, String.class, List.class);
constructor.setAccessible(true);
return constructor.newInstance("synthetic-registry", "synthetic-dataset", "synthetic-provenance",
List.of(anchor));
} catch (ReflectiveOperationException ex) {
throw new AssertionError("Failed to instantiate synthetic registry", ex);
}
}
}
@@ -0,0 +1,232 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Locks down anchor-free macro-cycle detection against the committed BTC truth
* set.
*
* <p>
* The detector is allowed to infer its own anchor ids and provenance, but the
* recovered macro turns must stay aligned with the committed BTC anchor
* windows. The runtime historical report itself is now series-native and no
* longer uses the detector as a front-end.
*
* @since 0.22.4
*/
@Tag("elliott-macro-cycle-replay")
class ElliottWaveMacroCycleDetectorTest {
private static final Logger LOG = LogManager.getLogger(ElliottWaveMacroCycleDetectorTest.class);
@TempDir
Path chartDirectory;
@Test
void inferredBitcoinAnchorsRecoverCommittedMacroTurns() {
final BarSeries series = loadBitcoinSeries();
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry expected = ElliottWaveAnchorCalibrationHarness
.defaultBitcoinAnchors(series);
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry inferred = ElliottWaveMacroCycleDetector
.inferAnchorRegistry(series);
assertEquals(expected.anchors().size(), inferred.anchors().size());
for (int index = 0; index < expected.anchors().size(); index++) {
final ElliottWaveAnchorCalibrationHarness.Anchor expectedAnchor = expected.anchors().get(index);
final ElliottWaveAnchorCalibrationHarness.Anchor inferredAnchor = inferred.anchors().get(index);
assertEquals(expectedAnchor.type(), inferredAnchor.type());
assertEquals(expectedAnchor.partition(), inferredAnchor.partition());
assertEquals(expectedAnchor.expectedPhases(), inferredAnchor.expectedPhases());
assertWithinDays(expectedAnchor.at(), inferredAnchor.at(), 21);
}
}
@Test
void seriesNativeHistoricalMacroDemoProducesCanonicalChronologicalCycles() throws Exception {
final BarSeries series = loadBitcoinSeries();
final ElliottWaveMacroCycleDemo.DemoReport seriesNative = ElliottWaveMacroCycleDemo
.generateHistoricalReport(series, chartDirectory.resolve("series-native"));
assertEquals("canonical-structure", seriesNative.structureSource());
assertChronologicalCycles(seriesNative.cycles(), "historical demo");
assertTrue(Files.exists(Path.of(seriesNative.chartPath())));
assertTrue(Files.exists(Path.of(seriesNative.summaryPath())));
}
@Test
void seriesNativeHistoricalAndLiveReportsShareCanonicalProfileSelection() throws Exception {
final BarSeries series = loadBitcoinSeries();
final ElliottWaveMacroCycleDemo.DemoReport historical = ElliottWaveMacroCycleDemo
.generateHistoricalReport(series, chartDirectory.resolve("historical"));
final ElliottWaveMacroCycleDemo.LivePresetReport live = ElliottWaveMacroCycleDemo
.generateLivePresetReport(series, chartDirectory.resolve("live"));
assertEquals("canonical-structure", historical.structureSource());
assertEquals("canonical-structure", live.structureSource());
assertEquals(historical.selectedProfileId(), live.selectedProfileId());
assertEquals(historical.selectedHypothesisId(), live.selectedHypothesisId());
assertTrue(Files.exists(Path.of(live.chartPath())));
assertTrue(Files.exists(Path.of(live.summaryPath())));
}
@Test
void canonicalReplayAtMajorMacroTurnsPreservesHistoricalCurrentProfileCoherence() {
final BarSeries fullSeries = loadBitcoinSeries();
assertReplay(fullSeries, "2013-11-30T00:00:00Z");
assertReplay(fullSeries, "2015-08-19T00:00:00Z");
assertReplay(fullSeries, "2017-12-18T00:00:00Z");
assertReplay(fullSeries, "2018-12-16T00:00:00Z");
assertReplay(fullSeries, "2021-11-11T00:00:00Z");
assertReplay(fullSeries, "2022-11-22T00:00:00Z");
}
@Test
void canonicalReplayAt2018BottomRecoversTwoCompletedCycles() {
final BarSeries fullSeries = loadBitcoinSeries();
final BarSeries slicedSeries = sliceThrough(fullSeries, Instant.parse("2018-12-16T00:00:00Z"));
final ElliottWaveMacroCycleDemo.CanonicalStructure structure = ElliottWaveMacroCycleDemo
.analyzeCanonicalStructure(slicedSeries);
final ElliottWaveMacroCycleDemo.MacroStudy study = structure.historicalStudy().orElseThrow();
assertEquals(2, study.cycles().size(), cycleDateSignatures(study.cycles()).toString());
assertWithinDays(Instant.parse("2011-11-18T00:00:00Z"), Instant.parse(study.cycles().get(0).startTimeUtc()),
21);
assertWithinDays(Instant.parse("2013-11-30T00:00:00Z"), Instant.parse(study.cycles().get(0).peakTimeUtc()), 21);
assertWithinDays(Instant.parse("2015-08-19T00:00:00Z"), Instant.parse(study.cycles().get(0).lowTimeUtc()), 21);
assertWithinDays(Instant.parse("2015-08-19T00:00:00Z"), Instant.parse(study.cycles().get(1).startTimeUtc()),
21);
assertWithinDays(Instant.parse("2017-12-18T00:00:00Z"), Instant.parse(study.cycles().get(1).peakTimeUtc()), 21);
assertWithinDays(Instant.parse("2018-12-16T00:00:00Z"), Instant.parse(study.cycles().get(1).lowTimeUtc()), 21);
}
@Test
void canonicalReplayAt2021TopUsesContinuationAdjustedSecondCycleLow() {
final BarSeries fullSeries = loadBitcoinSeries();
final BarSeries slicedSeries = sliceThrough(fullSeries, Instant.parse("2021-11-11T00:00:00Z"));
final ElliottWaveMacroCycleDemo.CanonicalStructure structure = ElliottWaveMacroCycleDemo
.analyzeCanonicalStructure(slicedSeries);
final ElliottWaveMacroCycleDemo.MacroStudy study = structure.historicalStudy().orElseThrow();
assertEquals(2, study.cycles().size(), cycleDateSignatures(study.cycles()).toString());
assertCycleWithinDays(study, 0, "2011-11-18T00:00:00Z", "2013-11-30T00:00:00Z", "2015-08-19T00:00:00Z");
assertCycleWithinDays(study, 1, "2015-08-19T00:00:00Z", "2017-12-18T00:00:00Z", "2020-05-26T00:00:00Z");
}
@Test
void canonicalReplayAt2022BottomRecoversChronologicalContinuationCandidates() {
final BarSeries fullSeries = loadBitcoinSeries();
final BarSeries slicedSeries = sliceThrough(fullSeries, Instant.parse("2022-11-22T00:00:00Z"));
final ElliottWaveMacroCycleDemo.CanonicalStructure structure = ElliottWaveMacroCycleDemo
.analyzeCanonicalStructure(slicedSeries);
final ElliottWaveMacroCycleDemo.MacroStudy study = structure.historicalStudy().orElseThrow();
assertEquals(4, study.cycles().size(), cycleDateSignatures(study.cycles()).toString());
assertCycleWithinDays(study, 0, "2011-11-18T00:00:00Z", "2013-11-30T00:00:00Z", "2015-08-19T00:00:00Z");
assertCycleWithinDays(study, 1, "2015-08-19T00:00:00Z", "2016-06-19T00:00:00Z", "2016-08-03T00:00:00Z");
assertCycleWithinDays(study, 2, "2016-08-03T00:00:00Z", "2017-12-18T00:00:00Z", "2020-03-14T00:00:00Z");
assertCycleWithinDays(study, 3, "2020-03-14T00:00:00Z", "2021-11-11T00:00:00Z", "2022-11-22T00:00:00Z");
}
@Test
void sliceThroughPreservesSourceNumFactory() {
final BarSeries fullSeries = loadBitcoinSeries();
final BarSeries slicedSeries = sliceThrough(fullSeries, Instant.parse("2022-11-22T00:00:00Z"));
assertEquals(fullSeries.numFactory(), slicedSeries.numFactory());
}
private static BarSeries loadBitcoinSeries() {
final BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveMacroCycleDetectorTest.class,
ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE, ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME,
LOG);
assertNotNull(series);
return series;
}
private static void assertWithinDays(final Instant expected, final Instant actual, final long maxDays) {
final Duration delta = Duration.between(expected, actual).abs();
assertTrue(delta.compareTo(Duration.ofDays(maxDays)) <= 0,
() -> "expected " + actual + " to stay within " + maxDays + " days of " + expected);
}
private static void assertCycleWithinDays(final ElliottWaveMacroCycleDemo.MacroStudy study, final int cycleIndex,
final String expectedStartIso, final String expectedPeakIso, final String expectedLowIso) {
final ElliottWaveMacroCycleDemo.DirectionalCycleSummary cycle = study.cycles().get(cycleIndex);
assertWithinDays(Instant.parse(expectedStartIso), Instant.parse(cycle.startTimeUtc()), 21);
assertWithinDays(Instant.parse(expectedPeakIso), Instant.parse(cycle.peakTimeUtc()), 21);
assertWithinDays(Instant.parse(expectedLowIso), Instant.parse(cycle.lowTimeUtc()), 21);
}
private static void assertReplay(final BarSeries fullSeries, final String cutoffIso) {
final BarSeries slicedSeries = sliceThrough(fullSeries, Instant.parse(cutoffIso));
final ElliottWaveMacroCycleDemo.CanonicalStructure structure = ElliottWaveMacroCycleDemo
.analyzeCanonicalStructure(slicedSeries);
final ElliottWaveMacroCycleDemo.MacroStudy study = structure.historicalStudy().orElseThrow();
assertChronologicalCycles(study.cycles(), cutoffIso);
for (ElliottWaveMacroCycleDemo.DirectionalCycleSummary cycle : study.cycles()) {
assertTrue(!Instant.parse(cycle.lowTimeUtc()).isAfter(Instant.parse(cutoffIso)));
}
assertEquals(study.selectedProfile().profile().id(), structure.currentCycle().summary().winningProfileId());
}
private static BarSeries sliceThrough(final BarSeries fullSeries, final Instant cutoff) {
final BarSeries slicedSeries = new BaseBarSeriesBuilder().withName(fullSeries.getName() + "@" + cutoff)
.withNumFactory(fullSeries.numFactory())
.build();
for (int index = fullSeries.getBeginIndex(); index <= fullSeries.getEndIndex(); index++) {
if (fullSeries.getBar(index).getEndTime().isAfter(cutoff)) {
break;
}
slicedSeries.addBar(fullSeries.getBar(index));
}
assertTrue(slicedSeries.getBarCount() > 0, () -> "No bars found through cutoff " + cutoff);
return slicedSeries;
}
private static void assertChronologicalCycles(final List<ElliottWaveMacroCycleDemo.DirectionalCycleSummary> cycles,
final String contextLabel) {
assertTrue(!cycles.isEmpty(), () -> "Expected at least one completed cycle for " + contextLabel);
for (ElliottWaveMacroCycleDemo.DirectionalCycleSummary cycle : cycles) {
final Instant cycleStart = Instant.parse(cycle.startTimeUtc());
final Instant cyclePeak = Instant.parse(cycle.peakTimeUtc());
final Instant cycleLow = Instant.parse(cycle.lowTimeUtc());
assertTrue(cycleStart.compareTo(cyclePeak) < 0, () -> "Non-chronological peak in " + cycleLabel(cycle));
assertTrue(cyclePeak.compareTo(cycleLow) < 0, () -> "Non-chronological low in " + cycleLabel(cycle));
}
}
private static List<String> cycleDateSignatures(
final List<ElliottWaveMacroCycleDemo.DirectionalCycleSummary> cycles) {
return cycles.stream().map(ElliottWaveMacroCycleDetectorTest::cycleLabel).toList();
}
private static String cycleLabel(final ElliottWaveMacroCycleDemo.DirectionalCycleSummary cycle) {
return String.join("|", cycle.startTimeUtc(), cycle.peakTimeUtc(), cycle.lowTimeUtc());
}
}
@@ -0,0 +1,64 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Comparator;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottLogicProfile;
class ElliottWaveMacroCycleTruthTargetScoringTest {
@Test
void truthTargetCoverageScorePenalizesMissingAndUnexpectedCycles() {
ElliottWaveMacroCycleDemo.TruthTargetCoverage complete = new ElliottWaveMacroCycleDemo.TruthTargetCoverage(3, 3,
0, 0, List.of(), List.of());
ElliottWaveMacroCycleDemo.TruthTargetCoverage missingCycle = new ElliottWaveMacroCycleDemo.TruthTargetCoverage(
3, 2, 1, 0, List.of("cycle-3"), List.of());
ElliottWaveMacroCycleDemo.TruthTargetCoverage unexpectedCycle = new ElliottWaveMacroCycleDemo.TruthTargetCoverage(
3, 3, 0, 1, List.of(), List.of("cycle-extra"));
assertEquals(1.0, complete.score());
assertTrue(missingCycle.score() < complete.score());
assertTrue(unexpectedCycle.score() < complete.score());
assertTrue(missingCycle.score() < unexpectedCycle.score());
}
@Test
void profileComparatorPrefersCompleteTruthTargetCoverageOverHigherAggregateScore() {
Comparator<ElliottWaveMacroCycleDemo.MacroProfileEvaluation> comparator = ElliottWaveMacroCycleDemo
.profileEvaluationComparator();
ElliottWaveMacroCycleDemo.MacroProfileEvaluation completeCoverage = evaluation("complete-coverage", 0, 0.62, 3,
6, true, new ElliottWaveMacroCycleDemo.TruthTargetCoverage(3, 3, 0, 0, List.of(), List.of()));
ElliottWaveMacroCycleDemo.MacroProfileEvaluation unexpectedCoverage = evaluation("unexpected-coverage", 1, 0.98,
3, 8, false,
new ElliottWaveMacroCycleDemo.TruthTargetCoverage(3, 3, 0, 1, List.of(), List.of("cycle-extra")));
ElliottWaveMacroCycleDemo.MacroProfileEvaluation missingCoverage = evaluation("missing-coverage", 2, 0.99, 4, 8,
false, new ElliottWaveMacroCycleDemo.TruthTargetCoverage(3, 2, 1, 0, List.of("cycle-3"), List.of()));
List<String> orderedProfileIds = List.of(missingCoverage, unexpectedCoverage, completeCoverage)
.stream()
.sorted(comparator)
.map(evaluation -> evaluation.profile().id())
.toList();
assertEquals(List.of("complete-coverage", "unexpected-coverage", "missing-coverage"), orderedProfileIds);
}
private static ElliottWaveMacroCycleDemo.MacroProfileEvaluation evaluation(final String profileId,
final int orthodoxyRank, final double aggregateScore, final int acceptedCycles, final int acceptedSegments,
final boolean historicalFitPassed,
final ElliottWaveMacroCycleDemo.TruthTargetCoverage truthTargetCoverage) {
ElliottWaveMacroCycleDemo.MacroLogicProfile profile = new ElliottWaveMacroCycleDemo.MacroLogicProfile(profileId,
"HX", "Test profile", orthodoxyRank, ElliottLogicProfile.ORTHODOX_CLASSICAL, ElliottDegree.MINUTE, null,
null);
return new ElliottWaveMacroCycleDemo.MacroProfileEvaluation(profile, aggregateScore, acceptedCycles,
acceptedSegments, historicalFitPassed, truthTargetCoverage, List.of(), List.of());
}
}
@@ -0,0 +1,57 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
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;
class ElliottWaveMultiDegreeAnalysisDemoTest {
private static final Logger LOG = LogManager.getLogger(ElliottWaveMultiDegreeAnalysisDemoTest.class);
private static final String OSSIFIED_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231020.json";
@Test
void ossifiedDatasetProducesRecommendedBaseScenario() {
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveMultiDegreeAnalysisDemo.class,
OSSIFIED_OHLCV_RESOURCE, "BTC-USD_PT1D@Coinbase (ossified)", LOG);
assertNotNull(series, "Series should load from ossified classpath resource");
assertFalse(series.isEmpty(), "Series should contain bars");
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);
assertTrue(result.recommendedScenario().isPresent(),
"Multi-degree analysis should produce a recommended base scenario");
Optional<ElliottWaveAnalysisResult.DegreeAnalysis> primary = result.analyses()
.stream()
.filter(analysis -> analysis.degree() == baseDegree)
.findFirst();
assertTrue(primary.isPresent(), "Base degree analysis should be available");
assertFalse(primary.orElseThrow().analysis().rawSwings().isEmpty(),
"Primary degree should have detected swings");
}
}
@@ -0,0 +1,48 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.support;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
class OssifiedElliottWaveSeriesLoaderTest {
private static final Logger LOG = LogManager.getLogger(OssifiedElliottWaveSeriesLoaderTest.class);
private static final String RESOURCE = "Binance-ETH-USD-PT5M-20230313_20230315.json";
@Test
void loadSeries_shouldResolveClasspathRootWithoutLeadingSlash() {
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(OssifiedElliottWaveSeriesLoaderTest.class,
RESOURCE, "ETH-USD_PT5M@Binance (ossified)", LOG);
assertNotNull(series);
assertEquals("ETH-USD_PT5M@Binance (ossified)", series.getName());
assertTrue(series.getBarCount() > 0);
}
@Test
void loadSeries_shouldResolveClasspathRootWithLeadingSlash() {
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(OssifiedElliottWaveSeriesLoaderTest.class,
"/" + RESOURCE, "ETH-USD_PT5M@Binance (ossified)", LOG);
assertNotNull(series);
assertEquals("ETH-USD_PT5M@Binance (ossified)", series.getName());
assertTrue(series.getBarCount() > 0);
}
@Test
void loadSeries_shouldReturnNullWhenResourceIsMissing() {
BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(OssifiedElliottWaveSeriesLoaderTest.class,
"does-not-exist.json", "missing", LOG);
assertNull(series);
}
}
@@ -0,0 +1,283 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.Strategy;
import org.ta4j.core.indicators.NetMomentumIndicator;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.CrossedDownIndicatorRule;
import org.ta4j.core.rules.CrossedUpIndicatorRule;
class BacktestPerformanceTuningHarnessTest {
@TempDir
Path tempDir;
@Test
void sliceToLastBarsReturnsSameInstanceWhenNoTruncationRequested() {
BarSeries series = buildSeries(50);
assertSame(series, BacktestPerformanceTuningHarness.sliceToLastBars(series, 0),
"barCount=0 should keep full series");
assertSame(series, BacktestPerformanceTuningHarness.sliceToLastBars(series, 50),
"barCount==available bars should keep full series");
assertSame(series, BacktestPerformanceTuningHarness.sliceToLastBars(series, 500),
"barCount>available bars should keep full series");
}
@Test
void sliceToLastBarsTruncatesToRequestedSizeAndPreservesLastBar() {
BarSeries series = buildSeries(100);
Num expectedLastClose = series.getLastBar().getClosePrice();
BarSeries sliced = BacktestPerformanceTuningHarness.sliceToLastBars(series, 10);
assertNotSame(series, sliced, "Sliced series should be a new instance");
assertEquals(10, sliced.getBarCount(), "Sliced series should expose requested number of bars");
assertEquals(expectedLastClose, sliced.getLastBar().getClosePrice(), "Sliced series should preserve last bar");
}
@Test
void applyMaximumBarCountHintOverridesMaximumBarCountWithoutMutatingDelegate() {
BarSeries series = buildSeries(20);
assertSame(series, BacktestPerformanceTuningHarness.applyMaximumBarCountHint(series, 0),
"hint=0 should be a no-op");
BarSeries wrapped = BacktestPerformanceTuningHarness.applyMaximumBarCountHint(series, 123);
assertEquals(123, wrapped.getMaximumBarCount(), "Wrapper should override BarSeries.getMaximumBarCount");
assertEquals(series.getBarCount(), wrapped.getBarCount(), "Wrapper should preserve bar count");
assertEquals(series.getLastBar().getClosePrice(), wrapped.getLastBar().getClosePrice(),
"Wrapper should delegate bar access");
assertThrows(UnsupportedOperationException.class, () -> wrapped.setMaximumBarCount(1),
"Hint wrapper should not allow mutating maximum bar count");
}
@Test
void createStrategiesRespectsRequestedCount() {
BarSeries series = buildSeries(500);
List<Strategy> strategies = BacktestPerformanceTuningHarness.createStrategies(series, 5);
assertEquals(5, strategies.size(), "Should only create the requested number of strategies");
assertTrue(strategies.stream().allMatch(strategy -> strategy != null),
"Strategies should not contain null entries");
assertThrows(IllegalArgumentException.class, () -> BacktestPerformanceTuningHarness.createStrategies(series, 0),
"Zero strategies is not a meaningful request");
}
@Test
void createStrategiesReusesEquivalentNetMomentumIndicatorGraphs() {
BarSeries series = buildSeries(500);
List<Strategy> strategies = BacktestPerformanceTuningHarness.createStrategies(series, 64);
NetMomentumIndicator firstIndicator = entryIndicator(strategies.get(0));
assertSame(firstIndicator, exitIndicator(strategies.get(0)),
"Entry and exit thresholds should share one momentum graph within each strategy");
assertTrue(strategies.stream().skip(1).anyMatch(strategy -> entryIndicator(strategy) == firstIndicator),
"Strategies with the same RSI/timeframe/decay inputs should reuse cached indicator work");
}
@Test
void isNonLinearReturnsFalseForRoughlyLinearScaling() {
Thresholds thresholds = new Thresholds(0.25d, 1.25d);
RunResult previous = sampleRun(1_000L, Duration.ofSeconds(10), Duration.ofSeconds(1));
RunResult current = sampleRun(1_300L, Duration.ofSeconds(13), Duration.ofSeconds(1));
assertFalse(BacktestPerformanceTuningHarness.isNonLinear(previous, current, thresholds),
"Near-linear scaling should not be flagged as non-linear");
}
@Test
void isNonLinearFlagsGcDominatedRuns() {
Thresholds thresholds = new Thresholds(0.25d, 1.25d);
RunResult previous = sampleRun(1_000L, Duration.ofSeconds(10), Duration.ofSeconds(1));
RunResult current = sampleRun(1_300L, Duration.ofSeconds(13), Duration.ofSeconds(4));
assertTrue(BacktestPerformanceTuningHarness.isNonLinear(previous, current, thresholds),
"Runs dominated by GC time should be flagged as non-linear");
}
@Test
void isNonLinearFlagsSlowdownBeyondThreshold() {
Thresholds thresholds = new Thresholds(0.25d, 1.25d);
RunResult previous = sampleRun(1_000L, Duration.ofSeconds(10), Duration.ofSeconds(1));
RunResult current = sampleRun(1_300L, Duration.ofSeconds(25), Duration.ofSeconds(1));
assertTrue(BacktestPerformanceTuningHarness.isNonLinear(previous, current, thresholds),
"Runs that slow down beyond the threshold should be flagged as non-linear");
}
@Test
void selectBestRecommendationPrefersHighestWorkUnits() {
RunResult smaller = sampleRun(1_000L, Duration.ofSeconds(10), Duration.ofSeconds(1));
RunResult larger = sampleRun(5_000L, Duration.ofSeconds(20), Duration.ofSeconds(2));
List<VariantTuningResult> results = List.of(new VariantTuningResult(new SeriesVariant(500, 0), smaller, null),
new VariantTuningResult(new SeriesVariant(2000, 0), larger, null));
assertEquals(larger, BacktestPerformanceTuningHarness.selectBestRecommendation(results),
"Best recommendation should be the last linear run with highest work units");
}
@Test
void throughputPlanBuildsDeterministicMatrixCellsAndAcceptsFullBarCount() {
HarnessCli cli = HarnessCli.parse(new String[] { "--throughputControl", "--throughputOutputDir",
tempDir.resolve("matrix").toString(), "--matrixStrategyCounts", "2,3", "--matrixBarCounts", "5,full",
"--matrixMaxBarCountHints", "0,4", "--executionMode", "topK", "--topK", "1", "--parallelism", "auto" });
ThroughputControlPlan plan = ThroughputControlPlan.fromCli(cli, 10);
assertEquals(8, plan.cells().size());
assertEquals("s2-b5-m0", plan.cells().get(0).cellId());
assertEquals(0, plan.cells().get(2).barCount(), "full should be represented as barCount=0");
assertTrue(plan.resolvedParallelism() >= 1);
assertEquals(tempDir.resolve("matrix").toAbsolutePath().normalize(), plan.outputDir());
}
@Test
void throughputPlanDeduplicatesRepeatedMatrixInputs() {
HarnessCli cli = HarnessCli.parse(new String[] { "--throughputControl", "--matrixStrategyCounts", "2,2",
"--matrixBarCounts", "5,5,full,full", "--matrixMaxBarCountHints", "0,0" });
ThroughputControlPlan plan = ThroughputControlPlan.fromCli(cli, 10);
assertEquals(2, plan.cells().size(), "Duplicate matrix input values should not duplicate output cells");
long uniqueCellIds = plan.cells().stream().map(ThroughputMatrixCell::cellId).distinct().count();
assertEquals(plan.cells().size(), uniqueCellIds, "cellId values should remain unambiguous");
}
@Test
void throughputPlanFingerprintIncludesExecutionKnobs() {
HarnessCli base = HarnessCli.parse(new String[] { "--throughputControl", "--matrixStrategyCounts", "2",
"--matrixBarCounts", "5", "--matrixMaxBarCountHints", "0", "--parallelism", "1" });
HarnessCli withProgress = HarnessCli.parse(new String[] { "--throughputControl", "--matrixStrategyCounts", "2",
"--matrixBarCounts", "5", "--matrixMaxBarCountHints", "0", "--parallelism", "1", "--progress" });
HarnessCli withoutGc = HarnessCli.parse(new String[] { "--throughputControl", "--matrixStrategyCounts", "2",
"--matrixBarCounts", "5", "--matrixMaxBarCountHints", "0", "--parallelism", "1", "--noGcBetweenRuns" });
ThroughputControlPlan basePlan = ThroughputControlPlan.fromCli(base, 10);
assertNotEquals(basePlan.specFingerprint(), ThroughputControlPlan.fromCli(withProgress, 10).specFingerprint());
assertNotEquals(basePlan.specFingerprint(), ThroughputControlPlan.fromCli(withoutGc, 10).specFingerprint());
}
@Test
void throughputPlanRejectsNegativeBarCounts() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> HarnessCli.parse(new String[] { "--matrixBarCounts", "-5" }));
assertEquals("--matrixBarCounts values must be >= 0 or full", exception.getMessage());
}
@Test
void matrixPerformanceTelemetryReportsCellsAndHypothesesPerMinute() {
ThroughputMatrixPerformanceTracker tracker = new ThroughputMatrixPerformanceTracker();
ThroughputMatrixCell first = new ThroughputMatrixCell("first", 2, 10, 0, ExecutionMode.FULL_RESULT, 1);
ThroughputMatrixCell second = new ThroughputMatrixCell("second", 3, 10, 0, ExecutionMode.FULL_RESULT, 1);
tracker.record(new ThroughputCellResult(first, sampleRun(2, 20L, Duration.ofMillis(10), Duration.ZERO), 10L));
tracker.record(new ThroughputCellResult(second, sampleRun(3, 30L, Duration.ofMillis(20), Duration.ZERO), 20L));
HarnessCli cli = HarnessCli
.parse(new String[] { "--throughputOutputDir", tempDir.resolve("matrix").toString() });
ThroughputControlPlan plan = ThroughputControlPlan.fromCli(cli, 10);
JsonObject telemetry = tracker.toJson(30_000L, plan, HostTelemetry.capture());
assertEquals(2, telemetry.get("cellCount").getAsInt());
assertFalse(telemetry.getAsJsonObject("host").has("hostname"),
"Shared benchmark artifacts should not expose raw hostnames");
assertTrue(telemetry.getAsJsonObject("host").get("hostId").getAsString().equals("unknown")
|| telemetry.getAsJsonObject("host").get("hostId").getAsString().startsWith("sha256:"));
assertFalse(telemetry.get("progress").getAsBoolean());
assertTrue(telemetry.get("gcBetweenRuns").getAsBoolean());
assertEquals(4.0d, telemetry.get("cellsPerMinute").getAsDouble());
assertEquals(5, telemetry.get("hypothesisCount").getAsInt());
assertEquals(10.0d, telemetry.get("hypothesesPerMinute").getAsDouble());
assertEquals("strategy", telemetry.get("hypothesisKind").getAsString());
}
@Test
void throughputControlWritesManifestCellsAndMatrixPerformance() throws Exception {
HarnessCli cli = HarnessCli.parse(new String[] { "--throughputControl", "--throughputOutputDir",
tempDir.resolve("output").toString(), "--matrixStrategyCounts", "2", "--matrixBarCounts", "10",
"--matrixMaxBarCountHints", "0", "--executionMode", "topK", "--topK", "1", "--parallelism", "1" });
BacktestPerformanceTuningHarness.runThroughputControl(buildSeries(30), cli);
Path performancePath = tempDir.resolve("output")
.resolve(BacktestPerformanceTuningHarness.MATRIX_PERFORMANCE_FILE);
Path manifestPath = tempDir.resolve("output")
.resolve(BacktestPerformanceTuningHarness.THROUGHPUT_MANIFEST_FILE);
Path cellsPath = tempDir.resolve("output").resolve(BacktestPerformanceTuningHarness.MATRIX_CELLS_FILE);
assertTrue(Files.isRegularFile(performancePath));
assertTrue(Files.isRegularFile(manifestPath));
assertTrue(Files.isRegularFile(cellsPath));
JsonObject performance = JsonParser.parseString(Files.readString(performancePath, StandardCharsets.UTF_8))
.getAsJsonObject();
assertEquals(1, performance.get("cellCount").getAsInt());
assertEquals(2, performance.get("hypothesisCount").getAsInt());
assertTrue(performance.get("cellsPerMinute").getAsDouble() > 0.0d);
assertTrue(performance.get("hypothesesPerMinute").getAsDouble() > 0.0d);
assertTrue(performance.getAsJsonObject("phases").has("backtest"));
}
private RunResult sampleRun(long workUnits, Duration runtime, Duration gcTime) {
return sampleRun(1, workUnits, runtime, gcTime);
}
private RunResult sampleRun(int strategyCount, long workUnits, Duration runtime, Duration gcTime) {
BacktestRuntimeStats runtimeStats = new BacktestRuntimeStats(runtime, Duration.ZERO, Duration.ZERO,
Duration.ZERO, Duration.ZERO, "{}");
HeapSnapshot heap = new HeapSnapshot(1024L * 1024L, 1024L * 1024L, 512L * 1024L);
GcSnapshot gcDelta = new GcSnapshot(0L, gcTime);
return new RunResult(ExecutionMode.FULL_RESULT, strategyCount, 1, 0, Integer.MAX_VALUE, 0, Duration.ZERO,
runtimeStats, workUnits, gcDelta, heap, heap, "MockNumFactory");
}
private BarSeries buildSeries(int barCount) {
double[] data = new double[barCount];
for (int i = 0; i < barCount; i++) {
data[i] = i + 1d;
}
return new MockBarSeriesBuilder().withData(data).build();
}
private NetMomentumIndicator entryIndicator(Strategy strategy) {
CrossedUpIndicatorRule rule = (CrossedUpIndicatorRule) strategy.getEntryRule();
Indicator<Num> indicator = rule.getLow();
return (NetMomentumIndicator) indicator;
}
private NetMomentumIndicator exitIndicator(Strategy strategy) {
CrossedDownIndicatorRule rule = (CrossedDownIndicatorRule) strategy.getExitRule();
Indicator<Num> indicator = rule.getUp();
return (NetMomentumIndicator) indicator;
}
}
@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import org.junit.Test;
public class SimpleMovingAverageBacktestTest {
@Test
public void test() throws InterruptedException {
SimpleMovingAverageBacktest.main(null);
}
}
@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseStrategy;
import org.ta4j.core.Strategy;
import org.ta4j.core.Trade;
import org.ta4j.core.backtest.BacktestExecutionResult;
import org.ta4j.core.backtest.BacktestExecutor;
import org.ta4j.core.backtest.TradingStatementExecutionResult.WeightedCriterion;
import org.ta4j.core.criteria.drawdown.ReturnOverMaxDrawdownCriterion;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
import org.ta4j.core.reports.TradingStatement;
import org.ta4j.core.rules.FixedRule;
public class SimpleMovingAverageRangeBacktestTest {
@Test
public void test() throws InterruptedException {
SimpleMovingAverageRangeBacktest.main(null);
}
@Test
public void selectTopStrategiesUsesWeightedRankingConvenienceApiAndPreservesCriterionScores() {
BacktestExecutionResult result = createBacktestResult();
List<TradingStatement> expected = result.getTopStrategiesWeighted(2,
WeightedCriterion.of(new NetProfitCriterion(), 7.0),
WeightedCriterion.of(new ReturnOverMaxDrawdownCriterion(), 3.0));
List<TradingStatement> actual = SimpleMovingAverageRangeBacktest.selectTopStrategies(result, 2);
assertEquals(expected.size(), actual.size());
for (int i = 0; i < expected.size(); i++) {
assertEquals(expected.get(i).getStrategy().getName(), actual.get(i).getStrategy().getName());
assertEquals(2, actual.get(i).getCriterionScores().size());
Set<String> criterionTypes = new HashSet<>();
actual.get(i)
.getCriterionScores()
.keySet()
.forEach(criterion -> criterionTypes.add(criterion.getClass().getName()));
assertTrue(criterionTypes.contains(NetProfitCriterion.class.getName()));
assertTrue(criterionTypes.contains(ReturnOverMaxDrawdownCriterion.class.getName()));
}
}
private BacktestExecutionResult createBacktestResult() {
BarSeries series = new MockBarSeriesBuilder().withData(100d, 120d, 90d, 140d, 115d, 130d).build();
List<Strategy> strategies = List.of(new BaseStrategy("Hold to finish", new FixedRule(0), new FixedRule(5)),
new BaseStrategy("Buy the dip", new FixedRule(2), new FixedRule(3)),
new BaseStrategy("Early loss", new FixedRule(1), new FixedRule(2)));
BacktestExecutor executor = new BacktestExecutor(series);
return executor.executeWithRuntimeReport(strategies, series.numFactory().numOf(50), Trade.TradeType.BUY);
}
}
@@ -0,0 +1,149 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.util.Objects;
import java.util.function.Supplier;
import org.junit.Test;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.ExecutionMatchPolicy;
import org.ta4j.core.Position;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.num.Num;
public class TradeFillRecordingExampleTest {
@Test
public void test() {
TradeFillRecordingExample.main(null);
}
@Test
public void streamingTradeFillsMatchGroupedTradeRecording() {
BaseTradingRecord streamingRecord = TradeFillRecordingExample.buildStreamingRecord();
BaseTradingRecord groupedTradeRecord = TradeFillRecordingExample.buildGroupedTradeRecord();
assertEquivalent(streamingRecord, groupedTradeRecord, "example parity");
assertEquals(4, streamingRecord.getTrades().size());
assertEquals(2, streamingRecord.getPositionCount());
assertEquals(0.41, streamingRecord.getRecordedTotalFees().doubleValue(), 1.0e-10);
assertEquals(29.59, TradeFillRecordingExample.closedNetProfit(streamingRecord).doubleValue(), 1.0e-10);
}
@Test
public void exampleProducesTwoClosedWinningPositions() {
BaseTradingRecord streamingRecord = TradeFillRecordingExample.buildStreamingRecord();
assertTrue(streamingRecord.isClosed());
assertEquals(0, streamingRecord.getOpenPositions().size());
assertEquals(2, streamingRecord.getPositions().size());
assertEquals(9.85, streamingRecord.getPositions().get(0).getProfit().doubleValue(), 1.0e-10);
assertEquals(19.74, streamingRecord.getPositions().get(1).getProfit().doubleValue(), 1.0e-10);
}
@Test
public void matchingPoliciesShowHowPartialExitsCloseDifferentLots() {
BaseTradingRecord fifoRecord = TradeFillRecordingExample.buildMatchingPolicyRecord(ExecutionMatchPolicy.FIFO);
BaseTradingRecord lifoRecord = TradeFillRecordingExample.buildMatchingPolicyRecord(ExecutionMatchPolicy.LIFO);
BaseTradingRecord averageCostRecord = TradeFillRecordingExample
.buildMatchingPolicyRecord(ExecutionMatchPolicy.AVG_COST);
BaseTradingRecord specificIdRecord = TradeFillRecordingExample
.buildMatchingPolicyRecord(ExecutionMatchPolicy.SPECIFIC_ID);
assertPosition(fifoRecord.getPositions().get(0), "lot-a", 100.0, 1.0, 20.0);
assertPosition(lifoRecord.getPositions().get(0), "lot-b", 106.0, 1.0, 14.0);
assertPosition(averageCostRecord.getPositions().get(0), null, 102.0, 1.0, 18.0);
assertPosition(specificIdRecord.getPositions().get(0), "lot-b", 106.0, 1.0, 14.0);
assertOpenExposure(fifoRecord, 2, 103.0);
assertOpenExposure(lifoRecord, 2, 100.0);
assertOpenExposure(averageCostRecord, 2, 102.0);
assertOpenExposure(specificIdRecord, 2, 100.0);
assertEquals(2, fifoRecord.getOpenPositions().size());
assertEquals(1, lifoRecord.getOpenPositions().size());
assertEquals(1, averageCostRecord.getOpenPositions().size());
assertEquals(1, specificIdRecord.getOpenPositions().size());
assertOpenLot(fifoRecord.getOpenPositions().get(0), "lot-a", 100.0, 1.0);
assertOpenLot(fifoRecord.getOpenPositions().get(1), "lot-b", 106.0, 1.0);
assertOpenLot(lifoRecord.getOpenPositions().get(0), "lot-a", 100.0, 2.0);
assertOpenLot(averageCostRecord.getOpenPositions().get(0), null, 102.0, 2.0);
assertOpenLot(specificIdRecord.getOpenPositions().get(0), "lot-a", 100.0, 2.0);
}
private static void assertEquivalent(TradingRecord expected, TradingRecord actual, String label) {
require(expected.getTrades().size() == actual.getTrades().size(), () -> label + ": trade count mismatch");
require(expected.getPositionCount() == actual.getPositionCount(), () -> label + ": position count mismatch");
assertNum(expected.getRecordedTotalFees(), actual.getRecordedTotalFees(), label + ": fee mismatch");
for (int i = 0; i < expected.getTrades().size(); i++) {
Trade left = expected.getTrades().get(i);
Trade right = actual.getTrades().get(i);
assertTrade(left, right, label + ": trade[" + i + "]");
}
for (int i = 0; i < expected.getPositions().size(); i++) {
Position left = expected.getPositions().get(i);
Position right = actual.getPositions().get(i);
assertTrade(left.getEntry(), right.getEntry(), label + ": position[" + i + "].entry");
assertTrade(left.getExit(), right.getExit(), label + ": position[" + i + "].exit");
assertNum(left.getProfit(), right.getProfit(), label + ": position[" + i + "].profit");
}
}
private static void assertTrade(Trade expected, Trade actual, String label) {
require(expected.getType() == actual.getType(), () -> label + ": type mismatch");
require(expected.getIndex() == actual.getIndex(), () -> label + ": index mismatch");
assertNum(expected.getPricePerAsset(), actual.getPricePerAsset(), label + ": price mismatch");
assertNum(expected.getAmount(), actual.getAmount(), label + ": amount mismatch");
assertNum(expected.getCost(), actual.getCost(), label + ": fee mismatch");
require(Objects.equals(expected.getOrderId(), actual.getOrderId()), () -> label + ": orderId mismatch");
require(Objects.equals(expected.getCorrelationId(), actual.getCorrelationId()),
() -> label + ": correlationId mismatch");
}
private static void assertPosition(Position position, String expectedCorrelationId, double expectedPrice,
double expectedAmount, double expectedProfit) {
require(Objects.equals(expectedCorrelationId, position.getEntry().getCorrelationId()),
() -> "Unexpected closed lot correlationId");
assertEquals(expectedPrice, position.getEntry().getPricePerAsset().doubleValue(), 1.0e-10);
assertEquals(expectedAmount, position.getEntry().getAmount().doubleValue(), 1.0e-10);
assertEquals(expectedProfit, position.getProfit().doubleValue(), 1.0e-10);
}
private static void assertOpenExposure(TradingRecord record, double expectedAmount,
double expectedAverageEntryPrice) {
assertEquals(expectedAmount, record.getCurrentPosition().amount().doubleValue(), 1.0e-10);
assertEquals(expectedAverageEntryPrice, record.getCurrentPosition().averageEntryPrice().doubleValue(), 1.0e-10);
}
private static void assertOpenLot(Position position, String expectedCorrelationId, double expectedPrice,
double expectedAmount) {
require(Objects.equals(expectedCorrelationId, position.getEntry().getCorrelationId()),
() -> "Unexpected open lot correlationId");
assertEquals(expectedPrice, position.averageEntryPrice().doubleValue(), 1.0e-10);
assertEquals(expectedAmount, position.amount().doubleValue(), 1.0e-10);
}
private static void assertNum(Num expected, Num actual, String message) {
if (expected == null || actual == null) {
require(Objects.equals(expected, actual), () -> message);
return;
}
require(expected.isEqual(actual), () -> message + " expected=" + expected + ", actual=" + actual);
}
private static void require(boolean condition, Supplier<String> messageSupplier) {
if (!condition) {
throw new IllegalStateException(messageSupplier.get());
}
}
}
@@ -0,0 +1,85 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.backtesting;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Position;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.SlippageExecutionModel;
import org.ta4j.core.backtest.TradeExecutionModel;
import org.ta4j.core.backtest.TradeOnCurrentCloseModel;
import org.ta4j.core.backtest.TradeOnNextOpenModel;
import org.ta4j.core.num.Num;
public class TradingRecordParityBacktestTest {
@Test
public void test() {
TradingRecordParityBacktest.main(null);
}
@Test
public void executionModelsDemonstrateTimingAndSlippageDifferences() {
BarSeries series = TradingRecordParityBacktest.createSeries();
Strategy strategy = TradingRecordParityBacktest.createStrategy();
TradingRecord nextOpenRecord = TradingRecordParityBacktest.runWithExecutionModel(series, strategy,
new TradeOnNextOpenModel());
TradingRecord currentCloseRecord = TradingRecordParityBacktest.runWithExecutionModel(series, strategy,
new TradeOnCurrentCloseModel());
TradingRecord slippageRecord = TradingRecordParityBacktest.runWithExecutionModel(series, strategy,
new SlippageExecutionModel(series.numFactory().numOf(0.01),
TradeExecutionModel.PriceSource.CURRENT_CLOSE));
Position nextOpenPosition = nextOpenRecord.getPositions().get(0);
Position currentClosePosition = currentCloseRecord.getPositions().get(0);
Position slippagePosition = slippageRecord.getPositions().get(0);
assertEquals(2, nextOpenPosition.getEntry().getIndex());
assertNumEquals(series.numFactory().numOf(109), nextOpenPosition.getEntry().getPricePerAsset());
assertEquals(4, nextOpenPosition.getExit().getIndex());
assertNumEquals(series.numFactory().numOf(99), nextOpenPosition.getExit().getPricePerAsset());
assertEquals(1, currentClosePosition.getEntry().getIndex());
assertNumEquals(series.numFactory().numOf(104), currentClosePosition.getEntry().getPricePerAsset());
assertEquals(3, currentClosePosition.getExit().getIndex());
assertNumEquals(series.numFactory().numOf(103), currentClosePosition.getExit().getPricePerAsset());
Num onePercent = series.numFactory().numOf(1.01);
Num ninetyNinePercent = series.numFactory().numOf(0.99);
assertNumEquals(series.numFactory().numOf(104).multipliedBy(onePercent),
slippagePosition.getEntry().getPricePerAsset());
assertNumEquals(series.numFactory().numOf(103).multipliedBy(ninetyNinePercent),
slippagePosition.getExit().getPricePerAsset());
assertTrue(currentClosePosition.getGrossProfit().isGreaterThan(nextOpenPosition.getGrossProfit()));
assertTrue(slippagePosition.getGrossProfit().isLessThan(currentClosePosition.getGrossProfit()));
}
@Test
public void currentCloseParityHoldsAcrossProvidedAndFactoryRecordFlows() {
BarSeries series = TradingRecordParityBacktest.createSeries();
Strategy strategy = TradingRecordParityBacktest.createStrategy();
TradingRecord defaultRecord = TradingRecordParityBacktest.runWithExecutionModel(series, strategy,
new TradeOnCurrentCloseModel());
TradingRecord providedRecord = TradingRecordParityBacktest.runWithProvidedRecord(series, strategy,
new TradeOnCurrentCloseModel());
TradingRecord factoryConfiguredRecord = TradingRecordParityBacktest.runWithFactoryConfiguredRecord(series,
strategy, new TradeOnCurrentCloseModel());
TradingRecordParityBacktest.assertEquivalent(defaultRecord, providedRecord, "provided record");
TradingRecordParityBacktest.assertEquivalent(defaultRecord, factoryConfiguredRecord,
"factory-configured record");
}
private static void assertNumEquals(Num expected, Num actual) {
assertTrue("expected=" + expected + " actual=" + actual, actual.isEqual(expected));
}
}
@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.barSeries;
import org.junit.Test;
public class BuildBarSeriesTest {
@Test
public void test() {
BuildBarSeries.main(null);
}
}
@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.bots;
import org.junit.Test;
public class TradingBotOnMovingBarSeriesTest {
@Test
public void test() throws InterruptedException {
TradingBotOnMovingBarSeries.main(null);
}
}
@@ -0,0 +1,276 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.AnalysisCriterion;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.criteria.NumberOfPositionsCriterion;
import org.ta4j.core.criteria.ExpectancyCriterion;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.num.Num;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link AnalysisCriterionIndicator}.
*/
class AnalysisCriterionIndicatorTest {
private BarSeries barSeries;
private TradingRecord tradingRecord;
@BeforeEach
void setUp() {
barSeries = ChartingTestFixtures.standardDailySeries();
tradingRecord = ChartingTestFixtures.completedTradeRecord(barSeries);
}
@Test
void testConstructorNullSeries() {
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
assertThrows(NullPointerException.class, () -> new AnalysisCriterionIndicator(null, criterion, tradingRecord),
"Should throw exception for null series");
}
@Test
void testConstructorNullCriterion() {
assertThrows(NullPointerException.class, () -> new AnalysisCriterionIndicator(barSeries, null, tradingRecord),
"Should throw exception for null criterion");
}
@Test
void testConstructorNullTradingRecord() {
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
assertThrows(NullPointerException.class, () -> new AnalysisCriterionIndicator(barSeries, criterion, null),
"Should throw exception for null trading record");
}
@Test
void testGetCountOfUnstableBars() {
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, tradingRecord);
assertEquals(0, indicator.getCountOfUnstableBars(), "Analysis criteria should have no unstable bars");
}
@Test
void testNumberOfPositionsCriterionCalculation() {
// Create a trading record with multiple completed positions
TradingRecord record = new BaseTradingRecord();
// First position: buy at index 1, sell at index 3
record.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
// Second position: buy at index 5, sell at index 7
record.enter(5, barSeries.getBar(5).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(7, barSeries.getBar(7).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// Before any trades
assertEquals(barSeries.numFactory().numOf(0), indicator.getValue(0), "Should be 0 positions before any trades");
// After first buy (position opened but not closed)
assertEquals(barSeries.numFactory().numOf(0), indicator.getValue(1),
"Should be 0 positions (position not yet closed)");
// After first position closed
assertEquals(barSeries.numFactory().numOf(1), indicator.getValue(3),
"Should be 1 position after first position closed");
// After second buy
assertEquals(barSeries.numFactory().numOf(1), indicator.getValue(5),
"Should still be 1 position (second position not yet closed)");
// After second position closed
assertEquals(barSeries.numFactory().numOf(2), indicator.getValue(7),
"Should be 2 positions after second position closed");
// At end
assertEquals(barSeries.numFactory().numOf(2), indicator.getValue(barSeries.getEndIndex()),
"Should be 2 positions at end");
}
@Test
void testPartialTradingRecordFiltering() {
// Create a trading record with trades at different indices
TradingRecord record = new BaseTradingRecord();
record.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
record.enter(5, barSeries.getBar(5).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(7, barSeries.getBar(7).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// At index 2, only the first buy should be included (no sell yet)
Num valueAt2 = indicator.getValue(2);
// Create a partial record manually to verify
TradingRecord partialAt2 = new BaseTradingRecord();
partialAt2.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
Num expectedAt2 = criterion.calculate(barSeries, partialAt2);
assertEquals(expectedAt2, valueAt2, "Value at index 2 should match partial record calculation");
// At index 4, first position should be complete, second not started
Num valueAt4 = indicator.getValue(4);
TradingRecord partialAt4 = new BaseTradingRecord();
partialAt4.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
partialAt4.exit(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
Num expectedAt4 = criterion.calculate(barSeries, partialAt4);
assertEquals(expectedAt4, valueAt4, "Value at index 4 should match partial record calculation");
}
@Test
void testEmptyTradingRecord() {
TradingRecord emptyRecord = new BaseTradingRecord();
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, emptyRecord);
// Should return the criterion value for an empty record at all indices
for (int i = barSeries.getBeginIndex(); i <= barSeries.getEndIndex(); i++) {
Num value = indicator.getValue(i);
Num expected = criterion.calculate(barSeries, emptyRecord);
assertEquals(expected, value, "Value at index " + i + " should match empty record calculation");
}
}
@Test
void testExpectancyCriterionCalculation() {
// Create a trading record with one completed position
TradingRecord record = new BaseTradingRecord();
record.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new ExpectancyCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// Before any trades
Num valueBefore = indicator.getValue(0);
TradingRecord emptyRecord = new BaseTradingRecord();
Num expectedBefore = criterion.calculate(barSeries, emptyRecord);
assertEquals(expectedBefore, valueBefore, "Expectancy before trades should match empty record");
// After position closed
Num valueAfter = indicator.getValue(3);
Num expectedAfter = criterion.calculate(barSeries, record);
assertEquals(expectedAfter, valueAfter, "Expectancy after position closed should match full record");
}
@Test
void testNetProfitCriterionCalculation() {
// Create a trading record with one completed position
TradingRecord record = new BaseTradingRecord();
record.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new NetProfitCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// Before any trades
Num valueBefore = indicator.getValue(0);
TradingRecord emptyRecord = new BaseTradingRecord();
Num expectedBefore = criterion.calculate(barSeries, emptyRecord);
assertEquals(expectedBefore, valueBefore, "Net profit before trades should be zero or match empty record");
// After position closed
Num valueAfter = indicator.getValue(3);
Num expectedAfter = criterion.calculate(barSeries, record);
assertEquals(expectedAfter, valueAfter, "Net profit after position closed should match full record");
}
@Test
void testCachingBehavior() {
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, tradingRecord);
// Get value multiple times - should return same result (cached)
Num value1 = indicator.getValue(3);
Num value2 = indicator.getValue(3);
Num value3 = indicator.getValue(3);
assertEquals(value1, value2, "Cached values should be equal");
assertEquals(value2, value3, "Cached values should be equal");
assertSame(value1, value2, "Cached values should be the same instance");
}
@Test
void testMultiplePositionsAtDifferentIndices() {
// Create a trading record with three completed positions
TradingRecord record = new BaseTradingRecord();
// Position 1: indices 0-2
record.enter(0, barSeries.getBar(0).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(2, barSeries.getBar(2).getClosePrice(), barSeries.numFactory().numOf(1));
// Position 2: indices 3-5
record.enter(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(5, barSeries.getBar(5).getClosePrice(), barSeries.numFactory().numOf(1));
// Position 3: indices 6-8
record.enter(6, barSeries.getBar(6).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(8, barSeries.getBar(8).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// Verify progressive counting
assertEquals(barSeries.numFactory().numOf(0), indicator.getValue(0), "Should be 0 positions at index 0");
assertEquals(barSeries.numFactory().numOf(1), indicator.getValue(2), "Should be 1 position after first closed");
assertEquals(barSeries.numFactory().numOf(1), indicator.getValue(3),
"Should still be 1 position (second not yet closed)");
assertEquals(barSeries.numFactory().numOf(2), indicator.getValue(5),
"Should be 2 positions after second closed");
assertEquals(barSeries.numFactory().numOf(3), indicator.getValue(8),
"Should be 3 positions after third closed");
}
@Test
void testOpenPositionNotCounted() {
// Create a trading record with one open position (not closed)
TradingRecord record = new BaseTradingRecord();
record.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
// No exit
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// Open positions should not be counted
Num value = indicator.getValue(barSeries.getEndIndex());
TradingRecord emptyRecord = new BaseTradingRecord();
Num expected = criterion.calculate(barSeries, emptyRecord);
assertEquals(expected, value, "Open position should not be counted in number of positions");
}
@Test
void testIndexBeforeFirstTrade() {
TradingRecord record = new BaseTradingRecord();
record.enter(5, barSeries.getBar(5).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(7, barSeries.getBar(7).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// At index 4, before any trades
Num value = indicator.getValue(4);
TradingRecord emptyRecord = new BaseTradingRecord();
Num expected = criterion.calculate(barSeries, emptyRecord);
assertEquals(expected, value, "Value before first trade should match empty record");
}
@Test
void testIndexAfterLastTrade() {
TradingRecord record = new BaseTradingRecord();
record.enter(1, barSeries.getBar(1).getClosePrice(), barSeries.numFactory().numOf(1));
record.exit(3, barSeries.getBar(3).getClosePrice(), barSeries.numFactory().numOf(1));
AnalysisCriterion criterion = new NumberOfPositionsCriterion();
AnalysisCriterionIndicator indicator = new AnalysisCriterionIndicator(barSeries, criterion, record);
// At index after last trade, should include all trades
Num valueAtEnd = indicator.getValue(barSeries.getEndIndex());
Num expectedAtEnd = criterion.calculate(barSeries, record);
assertEquals(expectedAtEnd, valueAtEnd, "Value after last trade should match full record");
}
}
@@ -0,0 +1,112 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.indicators.PriceChannel;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.num.Num;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link ChannelBoundaryIndicator}.
*/
class ChannelBoundaryIndicatorTest {
private BarSeries series;
@BeforeEach
void setUp() {
series = ChartingTestFixtures.standardDailySeries();
}
@Test
void passesThroughNumValuesFromDelegateIndicator() {
Num expected = series.numFactory().numOf(42);
ConstantNumIndicator delegate = new ConstantNumIndicator(series, expected, 3);
ChannelBoundaryIndicator indicator = new ChannelBoundaryIndicator(delegate, PriceChannel.Boundary.UPPER);
assertEquals(expected, indicator.getValue(series.getBeginIndex()));
assertEquals(3, indicator.getCountOfUnstableBars(), "Should delegate unstable bar count");
}
@Test
void extractsBoundaryValuesFromPriceChannelIndicator() {
Num upper = series.numFactory().numOf(15);
Num lower = series.numFactory().numOf(5);
Num median = series.numFactory().numOf(10);
PriceChannel channel = new TestChannel(upper, lower, median);
ConstantChannelIndicator delegate = new ConstantChannelIndicator(series, channel, 2);
ChannelBoundaryIndicator upperIndicator = new ChannelBoundaryIndicator(delegate, PriceChannel.Boundary.UPPER);
ChannelBoundaryIndicator lowerIndicator = new ChannelBoundaryIndicator(delegate, PriceChannel.Boundary.LOWER);
ChannelBoundaryIndicator medianIndicator = new ChannelBoundaryIndicator(delegate, PriceChannel.Boundary.MEDIAN);
assertEquals(upper, upperIndicator.getValue(series.getBeginIndex()));
assertEquals(lower, lowerIndicator.getValue(series.getBeginIndex()));
assertEquals(median, medianIndicator.getValue(series.getBeginIndex()));
assertEquals(2, upperIndicator.getCountOfUnstableBars(), "Should delegate unstable bar count");
}
private record TestChannel(Num upper, Num lower, Num median) implements PriceChannel {
}
private static final class ConstantNumIndicator implements Indicator<Num> {
private final Num value;
private final int unstableBars;
private final BarSeries series;
private ConstantNumIndicator(BarSeries series, Num value, int unstableBars) {
this.series = series;
this.value = value;
this.unstableBars = unstableBars;
}
@Override
public Num getValue(int index) {
return value;
}
@Override
public int getCountOfUnstableBars() {
return unstableBars;
}
@Override
public BarSeries getBarSeries() {
return series;
}
}
private static final class ConstantChannelIndicator implements Indicator<PriceChannel> {
private final PriceChannel channel;
private final int unstableBars;
private final BarSeries series;
private ConstantChannelIndicator(BarSeries series, PriceChannel channel, int unstableBars) {
this.series = series;
this.channel = channel;
this.unstableBars = unstableBars;
}
@Override
public PriceChannel getValue(int index) {
return channel;
}
@Override
public int getCountOfUnstableBars() {
return unstableBars;
}
@Override
public BarSeries getBarSeries() {
return series;
}
}
}
@@ -0,0 +1,165 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
import org.jfree.data.xy.DefaultOHLCDataset;
import org.jfree.data.xy.OHLCDataItem;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
/**
* Shared fixtures for charting tests. Relies on the {@code Mock*} utilities
* from {@code ta4j-core} to guarantee deterministic datasets.
*/
public final class ChartingTestFixtures {
private static final Instant START_TIME = Instant.EPOCH.plus(Duration.ofDays(1));
private static final Duration DAILY_PERIOD = Duration.ofDays(1);
private static final Duration HOURLY_PERIOD = Duration.ofHours(1);
private ChartingTestFixtures() {
throw new AssertionError("No instances");
}
public static BarSeries standardDailySeries() {
return linearSeries("Test Series", DAILY_PERIOD, 10, 100.0, 1.0, 1.0, 1000.0, 100.0);
}
public static BarSeries dailySeries(final String name) {
return linearSeries(name, DAILY_PERIOD, 10, 100.0, 1.0, 1.0, 1000.0, 100.0);
}
public static BarSeries hourlySeries(final String name) {
return linearSeries(name, HOURLY_PERIOD, 5, 100.0, 0.5, 0.5, 500.0, 50.0);
}
public static BarSeries dailySeriesWithWeekendGap(final String name) {
final var series = new MockBarSeriesBuilder().withName(name).build();
addBar(series, START_TIME, DAILY_PERIOD, 100.0, 102.0, 99.0, 101.0, 1000.0);
addBar(series, START_TIME.plus(Duration.ofDays(3)), DAILY_PERIOD, 101.0, 103.0, 100.0, 102.0, 1100.0);
return series;
}
public static BarSeries problematicSeries() {
final var series = new MockBarSeriesBuilder().withName("Problematic Series").build();
addBar(series, START_TIME, DAILY_PERIOD, 0.0, 0.0, 0.0, 0.0, 0.0);
return series;
}
public static BarSeries seriesWithSpecialChars() {
final var series = new MockBarSeriesBuilder().withName("Test:Series/With\\Special?Chars*<>|\"").build();
addBar(series, START_TIME, DAILY_PERIOD, 100.0, 105.0, 99.0, 104.0, 1000.0);
return series;
}
public static TradingRecord completedTradeRecord(final BarSeries series) {
final TradingRecord record = new BaseTradingRecord();
if (series.getBarCount() >= 6) {
final Trade buy = Trade.buyAt(2, series);
final Trade sell = Trade.sellAt(5, series);
record.enter(2, buy.getPricePerAsset(), buy.getAmount());
record.exit(5, sell.getPricePerAsset(), sell.getAmount());
}
return record;
}
public static TradingRecord emptyRecord() {
return new BaseTradingRecord();
}
public static TradingRecord openPositionRecord(final BarSeries series) {
final TradingRecord record = new BaseTradingRecord();
if (!series.isEmpty()) {
final Bar firstBar = series.getBar(series.getBeginIndex());
record.enter(0, firstBar.getClosePrice(), series.numFactory().numOf(1));
}
return record;
}
public static DefaultOHLCDataset singleCandleDataset(final boolean upCandle) {
final double close = upCandle ? 104.0 : 96.0;
return buildDataset("Test", List.of(ohlcItem(100.0, 105.0, 99.0, close, 1000.0, START_TIME)));
}
public static DefaultOHLCDataset candleDatasetWithZeros() {
return buildDataset("Test", List.of(ohlcItem(0.0, 0.0, 0.0, 0.0, 0.0, START_TIME)));
}
public static DefaultOHLCDataset linearOhlcDataset(final String name, final int count) {
final var items = IntStream.range(0, count).mapToObj(i -> {
final Instant endTime = START_TIME.plus(DAILY_PERIOD.multipliedBy(i));
final double open = 100.0 + i;
final double high = open + 5.0;
final double low = open - 1.0;
final double close = open + 4.0;
final double volume = 1000.0 + i * 100.0;
return ohlcItem(open, high, low, close, volume, endTime);
}).toList();
return buildDataset(name, items);
}
public static DefaultOHLCDataset seriesToDataset(final BarSeries barSeries) {
final var items = IntStream.rangeClosed(barSeries.getBeginIndex(), barSeries.getEndIndex()).mapToObj(i -> {
final Bar bar = barSeries.getBar(i);
return new OHLCDataItem(Date.from(bar.getEndTime()), bar.getOpenPrice().doubleValue(),
bar.getHighPrice().doubleValue(), bar.getLowPrice().doubleValue(),
bar.getClosePrice().doubleValue(), bar.getVolume().doubleValue());
}).toArray(OHLCDataItem[]::new);
final String datasetName = Objects.requireNonNullElse(barSeries.getName(), "Series");
return new DefaultOHLCDataset(datasetName, items);
}
private static BarSeries linearSeries(final String name, final Duration period, final int count,
final double baseOpen, final double openIncrement, final double closeOffset, final double baseVolume,
final double volumeIncrement) {
final var series = new MockBarSeriesBuilder().withName(name).build();
for (int i = 0; i < count; i++) {
final Instant endTime = START_TIME.plus(period.multipliedBy(i));
final double open = baseOpen + i * openIncrement;
final double high = open + 2.0;
final double low = open - 1.0;
final double close = open + closeOffset;
final double volume = baseVolume + i * volumeIncrement;
addBar(series, endTime, period, open, high, low, close, volume);
}
return series;
}
private static void addBar(final BarSeries series, final Instant endTime, final Duration period, final double open,
final double high, final double low, final double close, final double volume) {
series.barBuilder()
.timePeriod(period)
.endTime(endTime)
.openPrice(open)
.highPrice(high)
.lowPrice(low)
.closePrice(close)
.volume(volume)
.add();
}
private static DefaultOHLCDataset buildDataset(final String name, final List<OHLCDataItem> items) {
return new DefaultOHLCDataset(name, items.toArray(OHLCDataItem[]::new));
}
private static OHLCDataItem ohlcItem(final double open, final double high, final double low, final double close,
final double volume, final Instant endTime) {
return new OHLCDataItem(Date.from(endTime), open, high, low, close, volume);
}
}
@@ -0,0 +1,698 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.annotation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.awt.Color;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator.BarLabel;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator.LabelPlacement;
/**
* Comprehensive unit tests for {@link BarSeriesLabelIndicator}.
*/
class BarSeriesLabelIndicatorTest {
private BarSeries series;
private NumFactory numFactory;
@BeforeEach
void setUp() {
numFactory = org.ta4j.core.num.DecimalNumFactory.getInstance();
series = new MockBarSeriesBuilder().withNumFactory(numFactory)
.withData(100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0)
.build();
}
// ========== Constructor Tests ==========
@Test
void testConstructorWithValidInputs() {
List<BarLabel> labels = List.of(new BarLabel(0, numFactory.numOf(100.0), "Label 0", LabelPlacement.CENTER),
new BarLabel(5, numFactory.numOf(105.0), "Label 5", LabelPlacement.ABOVE));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
assertNotNull(indicator);
assertSame(series, indicator.getBarSeries());
assertEquals(2, indicator.labels().size());
}
@Test
void testConstructorThrowsWhenSeriesIsNull() {
List<BarLabel> labels = List.of(new BarLabel(0, numFactory.numOf(100.0), "Label", LabelPlacement.CENTER));
assertThrows(NullPointerException.class, () -> {
new BarSeriesLabelIndicator(null, labels);
});
}
@Test
void testConstructorThrowsWhenLabelsIsNull() {
assertThrows(NullPointerException.class, () -> {
new BarSeriesLabelIndicator(series, null);
});
}
@Test
void testConstructorFiltersNullLabels() {
List<BarLabel> labels = new ArrayList<>();
labels.add(new BarLabel(0, numFactory.numOf(100.0), "Label 0", LabelPlacement.CENTER));
labels.add(null);
labels.add(new BarLabel(5, numFactory.numOf(105.0), "Label 5", LabelPlacement.ABOVE));
labels.add(null);
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
assertEquals(2, indicator.labels().size());
assertEquals(0, indicator.labels().get(0).barIndex());
assertEquals(5, indicator.labels().get(1).barIndex());
}
@Test
void testConstructorWithEmptyLabels() {
List<BarLabel> emptyLabels = Collections.emptyList();
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, emptyLabels);
assertNotNull(indicator);
assertTrue(indicator.labels().isEmpty());
}
@Test
void testConstructorSortsLabelsByBarIndex() {
List<BarLabel> labels = new ArrayList<>();
labels.add(new BarLabel(5, numFactory.numOf(105.0), "Label 5", LabelPlacement.ABOVE));
labels.add(new BarLabel(2, numFactory.numOf(102.0), "Label 2", LabelPlacement.BELOW));
labels.add(new BarLabel(0, numFactory.numOf(100.0), "Label 0", LabelPlacement.CENTER));
labels.add(new BarLabel(8, numFactory.numOf(108.0), "Label 8", LabelPlacement.ABOVE));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> sortedLabels = indicator.labels();
assertEquals(4, sortedLabels.size());
assertEquals(0, sortedLabels.get(0).barIndex());
assertEquals(2, sortedLabels.get(1).barIndex());
assertEquals(5, sortedLabels.get(2).barIndex());
assertEquals(8, sortedLabels.get(3).barIndex());
}
@Test
void testConstructorHandlesDuplicateBarIndices() {
List<BarLabel> labels = new ArrayList<>();
labels.add(new BarLabel(3, numFactory.numOf(103.0), "First", LabelPlacement.CENTER));
labels.add(new BarLabel(5, numFactory.numOf(105.0), "Middle", LabelPlacement.ABOVE));
labels.add(new BarLabel(3, numFactory.numOf(303.0), "Second", LabelPlacement.BELOW)); // Duplicate index
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> resultLabels = indicator.labels();
// The labels list keeps all labels (including duplicates), but sorted
assertEquals(3, resultLabels.size());
assertEquals(3, resultLabels.get(0).barIndex());
assertEquals(3, resultLabels.get(1).barIndex());
assertEquals(5, resultLabels.get(2).barIndex());
// getValue() returns the last value for duplicate indices
assertEquals(303.0, indicator.getValue(3).doubleValue(), 0.001);
}
// ========== BarLabel Record Tests ==========
@Test
void testBarLabelCreationWithValidInputs() {
BarLabel label = new BarLabel(5, numFactory.numOf(105.0), "Test", LabelPlacement.ABOVE);
assertEquals(5, label.barIndex());
assertEquals(105.0, label.yValue().doubleValue(), 0.001);
assertEquals("Test", label.text());
assertEquals(LabelPlacement.ABOVE, label.placement());
assertNull(label.color());
}
@Test
void testBarLabelCreationRetainsExplicitColor() {
BarLabel label = new BarLabel(5, numFactory.numOf(105.0), "Test", LabelPlacement.ABOVE, Color.GREEN);
assertEquals(Color.GREEN, label.color());
assertEquals(1.0, label.fontScale());
}
@Test
void testBarLabelCreationRetainsExplicitFontScale() {
BarLabel label = new BarLabel(5, numFactory.numOf(105.0), "Test", LabelPlacement.ABOVE, Color.GREEN, 3.0);
assertEquals(Color.GREEN, label.color());
assertEquals(3.0, label.fontScale());
}
@Test
void testBarLabelThrowsWhenBarIndexIsNegative() {
assertThrows(IllegalArgumentException.class, () -> {
new BarLabel(-1, numFactory.numOf(100.0), "Test", LabelPlacement.CENTER);
});
}
@Test
void testBarLabelThrowsWhenYValueIsNull() {
assertThrows(NullPointerException.class, () -> {
new BarLabel(0, null, "Test", LabelPlacement.CENTER);
});
}
@Test
void testBarLabelThrowsWhenTextIsNull() {
assertThrows(NullPointerException.class, () -> {
new BarLabel(0, numFactory.numOf(100.0), null, LabelPlacement.CENTER);
});
}
@Test
void testBarLabelThrowsWhenPlacementIsNull() {
assertThrows(NullPointerException.class, () -> {
new BarLabel(0, numFactory.numOf(100.0), "Test", null);
});
}
@Test
void testBarLabelThrowsWhenFontScaleIsNotPositive() {
List<Double> invalidFontScales = List.of(-1.0, 0.0, Double.NaN, Double.NEGATIVE_INFINITY,
Double.POSITIVE_INFINITY);
for (Double invalidFontScale : invalidFontScales) {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> new BarLabel(0,
numFactory.numOf(100.0), "Test", LabelPlacement.CENTER, null, invalidFontScale));
assertEquals("fontScale must be positive and finite", thrown.getMessage());
}
}
@Test
void testBarLabelAcceptsZeroBarIndex() {
BarLabel label = new BarLabel(0, numFactory.numOf(100.0), "Test", LabelPlacement.CENTER);
assertEquals(0, label.barIndex());
}
@Test
void testBarLabelAcceptsEmptyText() {
BarLabel label = new BarLabel(0, numFactory.numOf(100.0), "", LabelPlacement.CENTER);
assertEquals("", label.text());
}
@Test
void testBarLabelAllPlacementValues() {
BarLabel above = new BarLabel(0, numFactory.numOf(100.0), "Above", LabelPlacement.ABOVE);
BarLabel below = new BarLabel(1, numFactory.numOf(101.0), "Below", LabelPlacement.BELOW);
BarLabel center = new BarLabel(2, numFactory.numOf(102.0), "Center", LabelPlacement.CENTER);
assertEquals(LabelPlacement.ABOVE, above.placement());
assertEquals(LabelPlacement.BELOW, below.placement());
assertEquals(LabelPlacement.CENTER, center.placement());
}
// ========== getValue() Tests ==========
@Test
void testGetValueReturnsYValueForLabeledIndex() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER),
new BarLabel(7, numFactory.numOf(200.0), "Label 7", LabelPlacement.ABOVE));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value3 = indicator.getValue(3);
assertEquals(150.0, value3.doubleValue(), 0.001);
Num value7 = indicator.getValue(7);
assertEquals(200.0, value7.doubleValue(), 0.001);
}
@Test
void testGetValueReturnsNaNForUnlabeledIndex() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value0 = indicator.getValue(0);
assertTrue(value0.isNaN());
Num value5 = indicator.getValue(5);
assertTrue(value5.isNaN());
Num value9 = indicator.getValue(9);
assertTrue(value9.isNaN());
}
@Test
void testGetValueReturnsNaNForIndexBeforeSeries() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
// Even if we query an index that doesn't exist in the series,
// we should get NaN if it's not labeled
Num value = indicator.getValue(-1);
assertTrue(value.isNaN());
}
@Test
void testGetValueReturnsNaNForIndexAfterSeries() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value = indicator.getValue(100);
assertTrue(value.isNaN());
}
@Test
void testGetValueWithEmptyLabels() {
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, Collections.emptyList());
for (int i = 0; i <= series.getEndIndex(); i++) {
Num value = indicator.getValue(i);
assertTrue(value.isNaN());
}
}
@Test
void testGetValueWithSingleLabel() {
List<BarLabel> labels = List.of(new BarLabel(5, numFactory.numOf(250.0), "Single", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value5 = indicator.getValue(5);
assertEquals(250.0, value5.doubleValue(), 0.001);
Num value0 = indicator.getValue(0);
assertTrue(value0.isNaN());
Num value9 = indicator.getValue(9);
assertTrue(value9.isNaN());
}
@Test
void testGetValueWithLabelsAtBoundaries() {
int beginIndex = series.getBeginIndex();
int endIndex = series.getEndIndex();
List<BarLabel> labels = List.of(
new BarLabel(beginIndex, numFactory.numOf(100.0), "Begin", LabelPlacement.CENTER),
new BarLabel(endIndex, numFactory.numOf(109.0), "End", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num beginValue = indicator.getValue(beginIndex);
assertEquals(100.0, beginValue.doubleValue(), 0.001);
Num endValue = indicator.getValue(endIndex);
assertEquals(109.0, endValue.doubleValue(), 0.001);
}
@Test
void testGetValueCachingBehavior() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value1 = indicator.getValue(3);
Num value2 = indicator.getValue(3);
Num value3 = indicator.getValue(3);
// Values should be equal (cached)
assertEquals(value1, value2);
assertEquals(value2, value3);
// Since it's a CachedIndicator, same instance should be returned
assertSame(value1, value2);
assertSame(value2, value3);
}
// ========== labels() Tests ==========
@Test
void testLabelsReturnsOrderedList() {
List<BarLabel> labels = new ArrayList<>();
labels.add(new BarLabel(8, numFactory.numOf(108.0), "Label 8", LabelPlacement.ABOVE));
labels.add(new BarLabel(1, numFactory.numOf(101.0), "Label 1", LabelPlacement.BELOW));
labels.add(new BarLabel(5, numFactory.numOf(105.0), "Label 5", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
assertEquals(3, result.size());
assertEquals(1, result.get(0).barIndex());
assertEquals(5, result.get(1).barIndex());
assertEquals(8, result.get(2).barIndex());
}
@Test
void testLabelsReturnsImmutableList() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
assertThrows(UnsupportedOperationException.class, () -> {
result.add(new BarLabel(5, numFactory.numOf(105.0), "New", LabelPlacement.ABOVE));
});
assertThrows(UnsupportedOperationException.class, () -> {
result.remove(0);
});
assertThrows(UnsupportedOperationException.class, () -> {
result.clear();
});
}
@Test
void testLabelsProtectsInternalRepresentation() {
// Create indicator with multiple labels
List<BarLabel> originalLabels = new ArrayList<>();
originalLabels.add(new BarLabel(2, numFactory.numOf(102.0), "Label 2", LabelPlacement.CENTER));
originalLabels.add(new BarLabel(5, numFactory.numOf(105.0), "Label 5", LabelPlacement.ABOVE));
originalLabels.add(new BarLabel(8, numFactory.numOf(108.0), "Label 8", LabelPlacement.BELOW));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, originalLabels);
// Get the list and verify initial state
List<BarLabel> returnedList1 = indicator.labels();
assertEquals(3, returnedList1.size());
assertEquals(2, returnedList1.get(0).barIndex());
assertEquals(5, returnedList1.get(1).barIndex());
assertEquals(8, returnedList1.get(2).barIndex());
// Verify getValue() works correctly
assertEquals(102.0, indicator.getValue(2).doubleValue(), 0.001);
assertEquals(105.0, indicator.getValue(5).doubleValue(), 0.001);
assertEquals(108.0, indicator.getValue(8).doubleValue(), 0.001);
// Attempt to modify the returned list - all should fail
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.add(new BarLabel(10, numFactory.numOf(110.0), "New Label", LabelPlacement.CENTER));
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.add(0, new BarLabel(1, numFactory.numOf(101.0), "Inserted", LabelPlacement.CENTER));
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.remove(0);
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.remove(returnedList1.get(0));
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.set(0, new BarLabel(99, numFactory.numOf(999.0), "Replaced", LabelPlacement.CENTER));
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.clear();
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.addAll(List.of(new BarLabel(10, numFactory.numOf(110.0), "New", LabelPlacement.CENTER)));
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.removeAll(List.of(returnedList1.get(0)));
});
assertThrows(UnsupportedOperationException.class, () -> {
returnedList1.retainAll(Collections.emptyList());
});
// Verify internal state is unchanged - get the list again
List<BarLabel> returnedList2 = indicator.labels();
assertEquals(3, returnedList2.size(), "Internal list size should remain unchanged");
assertEquals(2, returnedList2.get(0).barIndex(), "First label should be unchanged");
assertEquals(5, returnedList2.get(1).barIndex(), "Second label should be unchanged");
assertEquals(8, returnedList2.get(2).barIndex(), "Third label should be unchanged");
// Verify getValue() still works correctly - internal state is protected
assertEquals(102.0, indicator.getValue(2).doubleValue(), 0.001, "getValue(2) should still work");
assertEquals(105.0, indicator.getValue(5).doubleValue(), 0.001, "getValue(5) should still work");
assertEquals(108.0, indicator.getValue(8).doubleValue(), 0.001, "getValue(8) should still work");
// Verify unlabeled indices still return NaN
assertTrue(indicator.getValue(0).isNaN(), "Unlabeled index should return NaN");
assertTrue(indicator.getValue(9).isNaN(), "Unlabeled index should return NaN");
}
@Test
void testLabelsReturnsCachedInstance() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result1 = indicator.labels();
List<BarLabel> result2 = indicator.labels();
// Should return the same list instance (cached unmodifiable list)
assertSame(result1, result2, "Each call should return the same cached instance");
assertEquals(result1, result2, "Both should contain the same content");
assertEquals(result1.size(), result2.size(), "Both should have the same size");
}
@Test
void testLabelsWithEmptyList() {
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, Collections.emptyList());
List<BarLabel> result = indicator.labels();
assertNotNull(result);
assertTrue(result.isEmpty());
}
@Test
void testLabelsPreservesAllLabelProperties() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Test Label", LabelPlacement.ABOVE),
new BarLabel(7, numFactory.numOf(200.0), "Another Label", LabelPlacement.BELOW));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
assertEquals(2, result.size());
BarLabel label0 = result.get(0);
assertEquals(3, label0.barIndex());
assertEquals(150.0, label0.yValue().doubleValue(), 0.001);
assertEquals("Test Label", label0.text());
assertEquals(LabelPlacement.ABOVE, label0.placement());
BarLabel label1 = result.get(1);
assertEquals(7, label1.barIndex());
assertEquals(200.0, label1.yValue().doubleValue(), 0.001);
assertEquals("Another Label", label1.text());
assertEquals(LabelPlacement.BELOW, label1.placement());
}
// ========== getCountOfUnstableBars() Tests ==========
@Test
void testGetCountOfUnstableBarsReturnsZero() {
List<BarLabel> labels = List.of(new BarLabel(3, numFactory.numOf(150.0), "Label 3", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
assertEquals(0, indicator.getCountOfUnstableBars());
}
// ========== Integration Tests ==========
@Test
void testIndicatorWithManyLabels() {
List<BarLabel> labels = new ArrayList<>();
for (int i = 0; i <= series.getEndIndex(); i++) {
labels.add(new BarLabel(i, numFactory.numOf(100.0 + i), "Label " + i, LabelPlacement.CENTER));
}
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
assertEquals(series.getBarCount(), indicator.labels().size());
for (int i = 0; i <= series.getEndIndex(); i++) {
Num value = indicator.getValue(i);
assertEquals(100.0 + i, value.doubleValue(), 0.001);
}
}
@Test
void testIndicatorWithSparseLabels() {
List<BarLabel> labels = List.of(new BarLabel(0, numFactory.numOf(100.0), "Start", LabelPlacement.ABOVE),
new BarLabel(5, numFactory.numOf(105.0), "Middle", LabelPlacement.CENTER),
new BarLabel(9, numFactory.numOf(109.0), "End", LabelPlacement.BELOW));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
// Check labeled indices
assertEquals(100.0, indicator.getValue(0).doubleValue(), 0.001);
assertEquals(105.0, indicator.getValue(5).doubleValue(), 0.001);
assertEquals(109.0, indicator.getValue(9).doubleValue(), 0.001);
// Check unlabeled indices
assertTrue(indicator.getValue(1).isNaN());
assertTrue(indicator.getValue(2).isNaN());
assertTrue(indicator.getValue(3).isNaN());
assertTrue(indicator.getValue(4).isNaN());
assertTrue(indicator.getValue(6).isNaN());
assertTrue(indicator.getValue(7).isNaN());
assertTrue(indicator.getValue(8).isNaN());
}
@Test
void testIndicatorWithAllPlacementTypes() {
List<BarLabel> labels = List.of(new BarLabel(0, numFactory.numOf(100.0), "Above", LabelPlacement.ABOVE),
new BarLabel(1, numFactory.numOf(101.0), "Below", LabelPlacement.BELOW),
new BarLabel(2, numFactory.numOf(102.0), "Center", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
assertEquals(3, result.size());
assertEquals(LabelPlacement.ABOVE, result.get(0).placement());
assertEquals(LabelPlacement.BELOW, result.get(1).placement());
assertEquals(LabelPlacement.CENTER, result.get(2).placement());
}
@Test
void testIndicatorWithDuplicateIndicesKeepsLast() {
List<BarLabel> labels = new ArrayList<>();
labels.add(new BarLabel(5, numFactory.numOf(105.0), "First", LabelPlacement.ABOVE));
labels.add(new BarLabel(3, numFactory.numOf(103.0), "Second", LabelPlacement.BELOW));
labels.add(new BarLabel(5, numFactory.numOf(205.0), "Third", LabelPlacement.CENTER)); // Duplicate
labels.add(new BarLabel(7, numFactory.numOf(107.0), "Fourth", LabelPlacement.ABOVE));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
// The labels list keeps all labels (including duplicates), but sorted
assertEquals(4, result.size());
assertEquals(3, result.get(0).barIndex());
assertEquals(5, result.get(1).barIndex());
assertEquals(5, result.get(2).barIndex());
assertEquals(7, result.get(3).barIndex());
// Check that getValue returns the last value for duplicate indices
assertEquals(205.0, indicator.getValue(5).doubleValue(), 0.001);
// The labels list contains both labels for index 5, but getValue uses the map
// which keeps the last
List<BarLabel> labelsAt5 = result.stream().filter(l -> l.barIndex() == 5).toList();
assertEquals(2, labelsAt5.size());
assertEquals("First", labelsAt5.get(0).text());
assertEquals("Third", labelsAt5.get(1).text());
}
@Test
void testIndicatorWithNullLabelsFiltered() {
List<BarLabel> labels = new ArrayList<>();
labels.add(new BarLabel(0, numFactory.numOf(100.0), "Valid 1", LabelPlacement.CENTER));
labels.add(null);
labels.add(new BarLabel(5, numFactory.numOf(105.0), "Valid 2", LabelPlacement.ABOVE));
labels.add(null);
labels.add(null);
labels.add(new BarLabel(9, numFactory.numOf(109.0), "Valid 3", LabelPlacement.BELOW));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
assertEquals(3, result.size());
assertEquals(0, result.get(0).barIndex());
assertEquals(5, result.get(1).barIndex());
assertEquals(9, result.get(2).barIndex());
}
@Test
void testIndicatorWithUnsortedLabels() {
// Create labels in reverse order
List<BarLabel> labels = new ArrayList<>();
for (int i = series.getEndIndex(); i >= 0; i--) {
labels.add(new BarLabel(i, numFactory.numOf(100.0 + i), "Label " + i, LabelPlacement.CENTER));
}
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
List<BarLabel> result = indicator.labels();
assertEquals(series.getBarCount(), result.size());
// Verify they are sorted
for (int i = 0; i < result.size(); i++) {
assertEquals(i, result.get(i).barIndex());
}
}
@Test
void testIndicatorWithLargeBarIndex() {
// Test with a bar index that's larger than the series size
List<BarLabel> labels = List
.of(new BarLabel(100, numFactory.numOf(1000.0), "Large Index", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
// Should still work - the indicator doesn't validate bar index bounds
assertEquals(1, indicator.labels().size());
assertEquals(1000.0, indicator.getValue(100).doubleValue(), 0.001);
assertTrue(indicator.getValue(0).isNaN());
}
@Test
void testIndicatorWithZeroValue() {
List<BarLabel> labels = List.of(new BarLabel(5, numFactory.numOf(0.0), "Zero", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value = indicator.getValue(5);
assertEquals(0.0, value.doubleValue(), 0.001);
assertFalse(value.isNaN());
}
@Test
void testIndicatorWithNegativeValue() {
List<BarLabel> labels = List.of(new BarLabel(5, numFactory.numOf(-50.0), "Negative", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value = indicator.getValue(5);
assertEquals(-50.0, value.doubleValue(), 0.001);
assertFalse(value.isNaN());
}
@Test
void testIndicatorWithVeryLargeValue() {
List<BarLabel> labels = List.of(new BarLabel(5, numFactory.numOf(1e10), "Large", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value = indicator.getValue(5);
assertEquals(1e10, value.doubleValue(), 0.001);
}
@Test
void testIndicatorWithVerySmallValue() {
List<BarLabel> labels = List.of(new BarLabel(5, numFactory.numOf(1e-10), "Small", LabelPlacement.CENTER));
BarSeriesLabelIndicator indicator = new BarSeriesLabelIndicator(series, labels);
Num value = indicator.getValue(5);
assertEquals(1e-10, value.doubleValue(), 0.0001);
}
}
@@ -0,0 +1,965 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.builder;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.plot.CombinedDomainXYPlot;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.xy.StandardXYItemRenderer;
import org.jfree.chart.renderer.xy.XYDifferenceRenderer;
import org.jfree.chart.ui.Layer;
import org.jfree.data.time.TimeSeriesCollection;
import org.jfree.data.xy.OHLCDataset;
import org.jfree.data.xy.XYDataset;
import java.util.Collection;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.criteria.pnl.NetProfitCriterion;
import org.ta4j.core.indicators.RSIIndicator;
import org.ta4j.core.indicators.PriceChannel;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.NaN;
import org.ta4j.core.num.Num;
import java.awt.BasicStroke;
import java.awt.Color;
import static org.junit.jupiter.api.Assertions.*;
import ta4jexamples.charting.ChannelBoundaryIndicator;
import ta4jexamples.charting.ChartingTestFixtures;
import ta4jexamples.charting.compose.TradingChartFactory;
import ta4jexamples.charting.workflow.ChartWorkflow;
class ChartBuilderTest {
private ChartWorkflow chartWorkflow;
private BarSeries series;
private TradingRecord tradingRecord;
@BeforeEach
void setUp() {
chartWorkflow = new ChartWorkflow();
series = ChartingTestFixtures.standardDailySeries();
tradingRecord = ChartingTestFixtures.completedTradeRecord(series);
}
@Test
void constructorRejectsNullWorkflow() {
TradingChartFactory chartFactory = new TradingChartFactory();
assertThrows(NullPointerException.class, () -> new ChartBuilder(null, chartFactory));
}
@Test
void constructorRejectsNullFactory() {
assertThrows(NullPointerException.class, () -> new ChartBuilder(new ChartWorkflow(), null));
}
@Test
void buildsCandlestickChartWithIndicatorOverlay() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.CYAN)
.toChart();
assertNotNull(chart);
assertInstanceOf(CombinedDomainXYPlot.class, chart.getPlot());
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
assertEquals(1, combined.getSubplots().size());
XYPlot basePlot = combined.getSubplots().get(0);
// Candle dataset + overlay dataset
assertEquals(2, basePlot.getDatasetCount());
}
@Test
void barIndexTimeAxisModeBuildsNumberAxis() {
BarSeries gapSeries = ChartingTestFixtures.dailySeriesWithWeekendGap("Gap Series");
JFreeChart chart = chartWorkflow.builder()
.withTimeAxisMode(TimeAxisMode.BAR_INDEX)
.withSeries(gapSeries)
.toChart();
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
assertInstanceOf(NumberAxis.class, combined.getDomainAxis());
XYPlot basePlot = combined.getSubplots().get(0);
OHLCDataset dataset = (OHLCDataset) basePlot.getDataset(0);
assertEquals(gapSeries.getBeginIndex(), dataset.getXValue(0, 0), 0.0);
assertEquals(gapSeries.getBeginIndex() + 1, dataset.getXValue(0, 1), 0.0);
}
@Test
void withChannelOverlayAddsUpperMedianLowerOverlays() {
ConstantIndicator upper = constantIndicator(110, "upper");
ConstantIndicator median = constantIndicator(100, "median");
ConstantIndicator lower = constantIndicator(90, "lower");
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withChannelOverlay(upper, median, lower)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertEquals(5, basePlot.getDatasetCount(), "Channel overlays should add fill plus three datasets");
}
@Test
void withChannelOverlayAddsUpperAndLowerOverlays() {
ConstantIndicator upper = constantIndicator(110, "upper");
ConstantIndicator lower = constantIndicator(90, "lower");
JFreeChart chart = chartWorkflow.builder().withSeries(series).withChannelOverlay(upper, lower).toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertEquals(4, basePlot.getDatasetCount(), "Channel overlays should add fill plus two datasets");
}
@Test
void withChannelOverlayWrapsPriceChannelIndicator() {
ConstantChannelIndicator channelIndicator = new ConstantChannelIndicator(series, 110, 90, 100, "Test Channel");
JFreeChart chart = chartWorkflow.builder().withSeries(series).withChannelOverlay(channelIndicator).toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertEquals(5, basePlot.getDatasetCount(),
"Channel overlays should add fill plus upper, median, and lower datasets when wrapping a channel indicator");
}
@Test
void channelOverlayMedianDefaultsToDashedLowerOpacity() {
ConstantIndicator upper = constantIndicator(110, "upper");
ConstantIndicator median = constantIndicator(100, "median");
ConstantIndicator lower = constantIndicator(90, "lower");
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withChannelOverlay(upper, median, lower)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
int medianDatasetIndex = -1;
for (int i = 0; i < basePlot.getDatasetCount(); i++) {
if (basePlot.getDataset(i) instanceof TimeSeriesCollection collection && collection.getSeriesCount() > 0
&& "median".equals(collection.getSeriesKey(0))) {
medianDatasetIndex = i;
break;
}
}
assertTrue(medianDatasetIndex >= 0, "Median dataset should be present");
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(medianDatasetIndex);
BasicStroke stroke = (BasicStroke) renderer.getSeriesStroke(0);
assertNotNull(stroke.getDashArray(), "Median stroke should be dashed by default");
assertTrue(stroke.getLineWidth() < 1.2f, "Median stroke should be thinner than default channel lines");
}
@Test
void channelOverlayStylingUpdatesLinesAndFill() {
ConstantIndicator upper = constantIndicator(110, "upper");
ConstantIndicator lower = constantIndicator(90, "lower");
Color lineColor = new Color(0x607D8B);
Color fillColor = new Color(0x90A4AE);
float lineOpacity = 0.6f;
float fillOpacity = 0.2f;
float lineWidth = 2.2f;
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withChannelOverlay(upper, lower)
.withLineColor(lineColor)
.withOpacity(lineOpacity)
.withLineWidth(lineWidth)
.withFillColor(fillColor)
.withFillOpacity(fillOpacity)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
XYDifferenceRenderer fillRenderer = null;
int standardRendererCount = 0;
for (int i = 0; i < basePlot.getRendererCount(); i++) {
if (basePlot.getRenderer(i) instanceof XYDifferenceRenderer differenceRenderer) {
fillRenderer = differenceRenderer;
} else if (basePlot.getRenderer(i) instanceof StandardXYItemRenderer standardRenderer) {
Color paint = (Color) standardRenderer.getSeriesPaint(0);
assertEquals(lineColor.getRed(), paint.getRed(), "Channel line red should match");
assertEquals(lineColor.getGreen(), paint.getGreen(), "Channel line green should match");
assertEquals(lineColor.getBlue(), paint.getBlue(), "Channel line blue should match");
assertEquals(Math.round(lineOpacity * 255), paint.getAlpha(), "Channel line opacity should match");
BasicStroke stroke = (BasicStroke) standardRenderer.getSeriesStroke(0);
assertEquals(lineWidth, stroke.getLineWidth(), "Channel line width should match");
standardRendererCount++;
}
}
assertNotNull(fillRenderer, "Channel fill renderer should be present");
Color fillPaint = (Color) fillRenderer.getPositivePaint();
assertEquals(fillColor.getRed(), fillPaint.getRed(), "Channel fill red should match");
assertEquals(fillColor.getGreen(), fillPaint.getGreen(), "Channel fill green should match");
assertEquals(fillColor.getBlue(), fillPaint.getBlue(), "Channel fill blue should match");
assertEquals(Math.round(fillOpacity * 255), fillPaint.getAlpha(), "Channel fill opacity should match");
assertEquals(2, standardRendererCount, "Channel should render two line overlays");
}
@Test
void channelOverlaysRequireUpperAndLowerBoundaries() {
ConstantChannelIndicator channelIndicator = new ConstantChannelIndicator(series, 110, 90, 100,
"Partial Channel");
ChannelBoundaryIndicator upperBoundary = new ChannelBoundaryIndicator(channelIndicator,
PriceChannel.Boundary.UPPER);
ChartBuilder.ChartStage stage = chartWorkflow.builder().withSeries(series).withIndicatorOverlay(upperBoundary);
assertThrows(IllegalStateException.class, stage::toChart,
"Channel overlays should require both upper and lower boundaries before charting");
}
@Test
void channelOverlaysAllowUpperAndLowerBoundariesTogether() {
ConstantChannelIndicator channelIndicator = new ConstantChannelIndicator(series, 110, 90, 100, "Full Channel");
ChannelBoundaryIndicator upperBoundary = new ChannelBoundaryIndicator(channelIndicator,
PriceChannel.Boundary.UPPER);
ChannelBoundaryIndicator lowerBoundary = new ChannelBoundaryIndicator(channelIndicator,
PriceChannel.Boundary.LOWER);
ChartBuilder.ChartStage stage = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(upperBoundary)
.withIndicatorOverlay(lowerBoundary);
assertDoesNotThrow(stage::toChart, "Upper and lower boundaries should satisfy channel overlay validation");
}
@Test
void supportsIndicatorBaseChartsWithSubplot() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
JFreeChart chart = chartWorkflow.builder().withIndicator(closePrice).withSubChart(rsiIndicator).toChart();
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
assertEquals(2, combined.getSubplots().size(), "Indicator chart should contain base plot plus sub-plot");
}
@Test
void tradingRecordOverlayAddsMarkers() {
JFreeChart chart = chartWorkflow.builder().withSeries(series).withTradingRecordOverlay(tradingRecord).toChart();
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
XYPlot basePlot = combined.getSubplots().get(0);
assertTrue(basePlot.getRendererCount() >= 2,
"Trading record overlay should install an additional renderer for markers");
}
@Test
void overlayStylingAppliesCustomColorAndStroke() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.ORANGE)
.withLineWidth(3f)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
assertEquals(Color.ORANGE, renderer.getSeriesPaint(0));
BasicStroke stroke = (BasicStroke) renderer.getSeriesStroke(0);
assertEquals(3f, stroke.getLineWidth());
}
@Test
void withSubChartInvalidatesPreviousStage() {
ChartBuilder.ChartStage baseStage = chartWorkflow.builder().withSeries(series);
ChartBuilder.ChartStage subStage = baseStage.withSubChart(new ClosePriceIndicator(series));
assertThrows(IllegalStateException.class, baseStage::toChart,
"Parent stage should be unusable after withSubChart is invoked");
assertNotNull(subStage.toChart(), "Sub stage should still be able to build the chart");
}
@Test
void withHorizontalMarkerAddsReferenceLine() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0)
.toChart();
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
XYPlot indicatorPlot = combined.getSubplots().get(1);
Collection<?> rangeMarkers = indicatorPlot.getRangeMarkers(Layer.FOREGROUND);
assertNotNull(rangeMarkers, "Should have range markers");
assertFalse(rangeMarkers.isEmpty(), "Should have horizontal marker at 50");
}
@Test
void withHorizontalMarkerSupportsStyling() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
Color customColor = Color.RED;
float customWidth = 2.5f;
float customOpacity = 0.7f;
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0)
.withLineColor(customColor)
.withLineWidth(customWidth)
.withOpacity(customOpacity)
.toChart();
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
XYPlot indicatorPlot = combined.getSubplots().get(1);
Collection<?> rangeMarkers = indicatorPlot.getRangeMarkers(Layer.FOREGROUND);
assertFalse(rangeMarkers.isEmpty(), "Should have horizontal marker");
// Verify marker exists (styling is applied during rendering)
assertTrue(rangeMarkers.size() >= 1, "Should have at least one marker");
}
@Test
void withMultipleHorizontalMarkers() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(30.0)
.withHorizontalMarker(50.0)
.withHorizontalMarker(70.0)
.toChart();
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
XYPlot indicatorPlot = combined.getSubplots().get(1);
Collection<?> rangeMarkers = indicatorPlot.getRangeMarkers(Layer.FOREGROUND);
assertNotNull(rangeMarkers, "Should have range markers");
assertTrue(rangeMarkers.size() >= 3, "Should have at least 3 horizontal markers");
}
@Test
void withHorizontalMarkerRejectsInvalidLineWidth() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
ChartBuilder.StyledMarkerStage stage = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0);
assertThrows(IllegalArgumentException.class, () -> stage.withLineWidth(0.0f),
"Line width of 0 should be rejected");
assertThrows(IllegalArgumentException.class, () -> stage.withLineWidth(-1.0f),
"Negative line width should be rejected");
assertThrows(IllegalArgumentException.class, () -> stage.withLineWidth(0.05f),
"Line width of 0.05 should be rejected");
}
@Test
void withHorizontalMarkerRejectsInvalidOpacity() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
ChartBuilder.StyledMarkerStage stage = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0);
assertThrows(IllegalArgumentException.class, () -> stage.withOpacity(-0.1f),
"Negative opacity should be rejected");
assertThrows(IllegalArgumentException.class, () -> stage.withOpacity(1.1f),
"Opacity greater than 1.0 should be rejected");
}
@Test
void withHorizontalMarkerAcceptsValidOpacity() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
ChartBuilder.StyledMarkerStage stage = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0);
// These should not throw
assertDoesNotThrow(() -> stage.withOpacity(0.0f), "Opacity of 0.0 should be valid");
assertDoesNotThrow(() -> stage.withOpacity(0.5f), "Opacity of 0.5 should be valid");
assertDoesNotThrow(() -> stage.withOpacity(1.0f), "Opacity of 1.0 should be valid");
}
@Test
void withHorizontalMarkerRejectsNullColor() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
ChartBuilder.StyledMarkerStage stage = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0);
assertThrows(NullPointerException.class, () -> stage.withLineColor(null), "Null color should be rejected");
}
@Test
void withHorizontalMarkerChainingWorks() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
RSIIndicator rsiIndicator = new RSIIndicator(closePrice, 14);
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withSubChart(rsiIndicator)
.withHorizontalMarker(50.0)
.withLineColor(Color.BLUE)
.withLineWidth(2.0f)
.withOpacity(0.8f)
.withHorizontalMarker(30.0)
.withLineColor(Color.GREEN)
.toChart();
assertNotNull(chart, "Chart should be created with chained marker styling");
CombinedDomainXYPlot combined = (CombinedDomainXYPlot) chart.getPlot();
XYPlot indicatorPlot = combined.getSubplots().get(1);
Collection<?> rangeMarkers = indicatorPlot.getRangeMarkers(Layer.FOREGROUND);
assertTrue(rangeMarkers.size() >= 2, "Should have at least 2 markers with chained styling");
}
@Test
void terminalOperationConsumesBuilder() {
ChartBuilder.ChartStage stage = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord);
stage.toChart();
assertThrows(IllegalStateException.class, stage::toChart,
"All terminal operations should fail after the builder is consumed");
}
@Test
void exposesChartPlanForManualExecution() {
ChartPlan plan = chartWorkflow.builder().withSeries(series).toPlan();
JFreeChart chart = chartWorkflow.render(plan);
assertNotNull(chart, "Rendered chart should not be null");
}
@Test
void toPlanAlsoConsumesBuilder() {
ChartBuilder.ChartStage stage = chartWorkflow.builder().withSeries(series);
stage.toPlan();
assertThrows(IllegalStateException.class, stage::toChart,
"Invoking toPlan should consume the builder like other terminal operations");
}
@Test
void analysisCriterionOverlayCreatesSecondaryAxis() {
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withTradingRecordOverlay(tradingRecord)
.withAnalysisCriterionOverlay(new NetProfitCriterion(), tradingRecord)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertNotNull(basePlot.getRangeAxis(1), "Analysis overlays should create a secondary axis");
assertEquals("NetProfit", basePlot.getRangeAxis(1).getLabel(), "Secondary axis should reflect criterion label");
assertTrue(plotContainsSeries(basePlot, "NetProfit"),
"Criterion overlay should contribute a dataset with its label");
}
@Test
void tradingRecordOverlayIgnoredForIndicatorCharts() {
ConstantIndicator baseIndicator = constantIndicator(1, "base-indicator");
JFreeChart chart = chartWorkflow.builder()
.withIndicator(baseIndicator)
.withTradingRecordOverlay(tradingRecord)
.toChart();
XYPlot plot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertEquals(1, plot.getDatasetCount(),
"Indicator charts should ignore trading record overlays due to incompatible axes");
}
@Test
void rejectsOverlaysWhenBothAxesOccupied() {
ConstantIndicator baseIndicator = constantIndicator(5, "base");
ConstantIndicator near = constantIndicator(6, "near");
ConstantIndicator far = constantIndicator(500, "far");
ConstantIndicator rejected = constantIndicator(-500, "rejected");
ChartBuilder.ChartStage dualAxisStage = chartWorkflow.builder()
.withIndicator(baseIndicator)
.withIndicatorOverlay(near)
.withIndicatorOverlay(far);
JFreeChart chartBefore = dualAxisStage.toChart();
XYPlot plotBefore = ((CombinedDomainXYPlot) chartBefore.getPlot()).getSubplots().get(0);
int datasetCountBefore = plotBefore.getDatasetCount();
assertNotNull(plotBefore.getRangeAxis(1), "Secondary axis should be created for the far overlay");
ChartBuilder.StyledOverlayStage rejectedStage = chartWorkflow.builder()
.withIndicator(baseIndicator)
.withIndicatorOverlay(near)
.withIndicatorOverlay(far)
.withIndicatorOverlay(rejected);
assertThrows(IllegalStateException.class, () -> rejectedStage.withLineColor(Color.RED),
"Styling an invalid overlay should fail fast");
JFreeChart chart = rejectedStage.toChart();
XYPlot plot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertNotNull(plot.getRangeAxis(1), "Secondary axis should persist after rejecting the overlay");
assertEquals(datasetCountBefore, plot.getDatasetCount(),
"Rejected overlay must not mutate the dataset collection");
}
@Test
void overlayLineWidthValidation() {
ChartBuilder.StyledOverlayStage stage = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(new ClosePriceIndicator(series));
assertThrows(IllegalArgumentException.class, () -> stage.withLineWidth(0.01f),
"Line width must be positive and above the minimum threshold");
assertDoesNotThrow(stage::toChart);
}
@Test
void overlayStylingAppliesCustomOpacity() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
float opacity = 0.5f;
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.BLUE)
.withOpacity(opacity)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
Color paintColor = (Color) renderer.getSeriesPaint(0);
assertEquals(Color.BLUE.getRed(), paintColor.getRed());
assertEquals(Color.BLUE.getGreen(), paintColor.getGreen());
assertEquals(Color.BLUE.getBlue(), paintColor.getBlue());
assertEquals(Math.round(opacity * 255), paintColor.getAlpha(),
"Opacity should be applied to the color's alpha channel");
}
@Test
void overlayOpacityDefaultsToFullyOpaque() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.RED)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
Color paintColor = (Color) renderer.getSeriesPaint(0);
assertEquals(255, paintColor.getAlpha(), "Default opacity should be 1.0 (fully opaque)");
}
@Test
void overlayOpacityValidation() {
ChartBuilder.StyledOverlayStage stage = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(new ClosePriceIndicator(series));
assertThrows(IllegalArgumentException.class, () -> stage.withOpacity(-0.1f), "Opacity must be >= 0.0");
assertThrows(IllegalArgumentException.class, () -> stage.withOpacity(1.1f), "Opacity must be <= 1.0");
assertDoesNotThrow(() -> stage.withOpacity(0.0f), "Opacity 0.0 should be valid");
assertDoesNotThrow(() -> stage.withOpacity(1.0f), "Opacity 1.0 should be valid");
assertDoesNotThrow(() -> stage.withOpacity(0.5f), "Opacity 0.5 should be valid");
assertDoesNotThrow(stage::toChart);
}
@Test
void overlayOpacityCanBeChainedWithOtherStylingMethods() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
float opacity = 0.75f;
Color customColor = new Color(128, 0, 128); // Purple
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withOpacity(opacity)
.withLineColor(customColor)
.withLineWidth(2.5f)
.withConnectAcrossNaN(true)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
Color paintColor = (Color) renderer.getSeriesPaint(0);
assertEquals(customColor.getRed(), paintColor.getRed());
assertEquals(customColor.getGreen(), paintColor.getGreen());
assertEquals(customColor.getBlue(), paintColor.getBlue());
assertEquals(Math.round(opacity * 255), paintColor.getAlpha(),
"Opacity should be preserved when chained with other styling methods");
BasicStroke stroke = (BasicStroke) renderer.getSeriesStroke(0);
assertEquals(2.5f, stroke.getLineWidth(), "Line width should still be applied");
}
@Test
void overlayOpacityCanBeSetAfterOtherStylingMethods() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
float opacity = 0.6f;
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.CYAN)
.withLineWidth(3.0f)
.withOpacity(opacity)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
Color paintColor = (Color) renderer.getSeriesPaint(0);
assertEquals(Math.round(opacity * 255), paintColor.getAlpha(),
"Opacity should work when set after other styling methods");
}
@Test
void overlayOpacityEdgeCases() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
// Test fully transparent (0.0)
JFreeChart chartTransparent = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.GREEN)
.withOpacity(0.0f)
.toChart();
XYPlot basePlotTransparent = ((CombinedDomainXYPlot) chartTransparent.getPlot()).getSubplots().get(0);
StandardXYItemRenderer rendererTransparent = (StandardXYItemRenderer) basePlotTransparent.getRenderer(1);
Color paintColorTransparent = (Color) rendererTransparent.getSeriesPaint(0);
assertEquals(0, paintColorTransparent.getAlpha(), "Opacity 0.0 should result in fully transparent color");
// Test fully opaque (1.0)
JFreeChart chartOpaque = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.GREEN)
.withOpacity(1.0f)
.toChart();
XYPlot basePlotOpaque = ((CombinedDomainXYPlot) chartOpaque.getPlot()).getSubplots().get(0);
StandardXYItemRenderer rendererOpaque = (StandardXYItemRenderer) basePlotOpaque.getRenderer(1);
Color paintColorOpaque = (Color) rendererOpaque.getSeriesPaint(0);
assertEquals(255, paintColorOpaque.getAlpha(), "Opacity 1.0 should result in fully opaque color");
}
@Test
void withConnectAcrossNaNFalseCreatesMultipleSegmentsForNaNValues() {
IndicatorWithNaN indicator = new IndicatorWithNaN(series, new int[] { 2, 3, 4 });
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(indicator)
.withConnectAcrossNaN(false)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertTrue(dataset.getSeriesCount() > 1,
"When connectGaps is false, NaN values should create multiple series segments");
}
@Test
void withConnectAcrossNaNTrueCreatesSingleSegmentForNaNValues() {
IndicatorWithNaN indicator = new IndicatorWithNaN(series, new int[] { 2, 3, 4 });
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(indicator)
.withConnectAcrossNaN(true)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(1, dataset.getSeriesCount(),
"When connectGaps is true, NaN values should be skipped and non-NaN values connected in a single segment");
}
@Test
void withConnectAcrossNaNDefaultsToFalse() {
IndicatorWithNaN indicator = new IndicatorWithNaN(series, new int[] { 2, 3, 4 });
JFreeChart chart = chartWorkflow.builder().withSeries(series).withIndicatorOverlay(indicator).toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertTrue(dataset.getSeriesCount() > 1,
"By default (connectGaps not set), NaN values should create multiple series segments");
}
@Test
void withConnectAcrossNaNCanBeChainedWithOtherStyling() {
IndicatorWithNaN indicator = new IndicatorWithNaN(series, new int[] { 2, 3, 4 });
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(indicator)
.withLineColor(Color.MAGENTA)
.withLineWidth(2.5f)
.withConnectAcrossNaN(true)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(1, dataset.getSeriesCount(),
"withConnectAcrossNaN should work when chained with other styling methods");
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
assertEquals(Color.MAGENTA, renderer.getSeriesPaint(0), "Color should still be applied");
BasicStroke stroke = (BasicStroke) renderer.getSeriesStroke(0);
assertEquals(2.5f, stroke.getLineWidth(), "Line width should still be applied");
}
@Test
void withConnectAcrossNaNTrueSkipsNaNButConnectsValidValues() {
// Create indicator with valid values at indices 0,1, NaN at 2,3,4, then valid
// at 5,6
IndicatorWithNaN indicator = new IndicatorWithNaN(series, new int[] { 2, 3, 4 });
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(indicator)
.withConnectAcrossNaN(true)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(1, dataset.getSeriesCount(),
"All valid values should be in a single connected segment when connectGaps is true");
// Verify the segment contains values from before and after the NaN gap
org.jfree.data.time.TimeSeries timeSeries = dataset.getSeries(0);
assertTrue(timeSeries.getItemCount() > 0, "The connected segment should contain valid values");
}
@Test
void withConnectAcrossNaNFalseSplitsOnNaN() {
// Create indicator with valid values at indices 0,1, NaN at 2,3,4, then valid
// at 5,6
IndicatorWithNaN indicator = new IndicatorWithNaN(series, new int[] { 2, 3, 4 });
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(indicator)
.withConnectAcrossNaN(false)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(2, dataset.getSeriesCount(),
"NaN values should split the series into two segments (before and after the gap)");
}
@Test
void withLabelSetsCustomLabelInChartLegend() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
String customLabel = "Custom Close Price Label";
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLabel(customLabel)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(customLabel, dataset.getSeriesKey(0), "Custom label should appear in the chart legend");
}
@Test
void withLabelDefaultsToIndicatorToStringWhenNotSet() {
ConstantIndicator indicator = constantIndicator(100.0, "TestIndicator");
JFreeChart chart = chartWorkflow.builder().withSeries(series).withIndicatorOverlay(indicator).toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals("TestIndicator", dataset.getSeriesKey(0),
"When no label is set, should fall back to indicator.toString()");
}
@Test
void withLabelCanBeChainedWithOtherStylingMethods() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
String customLabel = "Styled Indicator";
Color customColor = new Color(128, 0, 128); // Purple color
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLabel(customLabel)
.withLineColor(customColor)
.withLineWidth(2.5f)
.withConnectAcrossNaN(true)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(customLabel, dataset.getSeriesKey(0),
"Label should be preserved when chained with other styling methods");
StandardXYItemRenderer renderer = (StandardXYItemRenderer) basePlot.getRenderer(1);
assertEquals(customColor, renderer.getSeriesPaint(0), "Color should still be applied");
BasicStroke stroke = (BasicStroke) renderer.getSeriesStroke(0);
assertEquals(2.5f, stroke.getLineWidth(), "Line width should still be applied");
}
@Test
void withLabelUsedForSecondaryAxisWhenApplicable() {
ConstantIndicator baseIndicator = constantIndicator(5, "base");
ConstantIndicator farIndicator = constantIndicator(500, "far-indicator");
String customLabel = "Custom Secondary Axis Label";
JFreeChart chart = chartWorkflow.builder()
.withIndicator(baseIndicator)
.withIndicatorOverlay(farIndicator)
.withLabel(customLabel)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
assertNotNull(basePlot.getRangeAxis(1), "Secondary axis should be created for the far overlay");
assertEquals(customLabel, basePlot.getRangeAxis(1).getLabel(),
"Custom label should be used for the secondary axis label");
assertTrue(plotContainsSeries(basePlot, customLabel), "Custom label should appear in the dataset");
}
@Test
void withLabelCanBeSetAfterOtherStylingMethods() {
ClosePriceIndicator closePrice = new ClosePriceIndicator(series);
String customLabel = "Label Set Last";
JFreeChart chart = chartWorkflow.builder()
.withSeries(series)
.withIndicatorOverlay(closePrice)
.withLineColor(Color.CYAN)
.withLineWidth(3.0f)
.withLabel(customLabel)
.toChart();
XYPlot basePlot = ((CombinedDomainXYPlot) chart.getPlot()).getSubplots().get(0);
TimeSeriesCollection dataset = (TimeSeriesCollection) basePlot.getDataset(1);
assertEquals(customLabel, dataset.getSeriesKey(0), "Label should work when set after other styling methods");
}
private ConstantIndicator constantIndicator(double value, String name) {
return new ConstantIndicator(series, value, name);
}
private boolean plotContainsSeries(XYPlot plot, String seriesName) {
for (int i = 0; i < plot.getDatasetCount(); i++) {
XYDataset dataset = plot.getDataset(i);
if (dataset instanceof TimeSeriesCollection collection) {
for (int j = 0; j < collection.getSeriesCount(); j++) {
if (seriesName.equals(collection.getSeriesKey(j))) {
return true;
}
}
}
}
return false;
}
private static final class ConstantIndicator implements Indicator<Num> {
private final Num value;
private final String name;
private final BarSeries series;
private ConstantIndicator(BarSeries series, double value, String name) {
this.series = series;
this.value = series.numFactory().numOf(value);
this.name = name;
}
@Override
public Num getValue(int index) {
return value;
}
@Override
public String toString() {
return name;
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
@Override
public BarSeries getBarSeries() {
return series;
}
}
/**
* Test indicator that returns NaN for specified indices and valid values for
* others.
*/
private static final class IndicatorWithNaN implements Indicator<Num> {
private final BarSeries series;
private final int[] nanIndices;
private final Num validValue;
IndicatorWithNaN(BarSeries series, int[] nanIndices) {
this.series = series;
this.nanIndices = nanIndices.clone();
this.validValue = series.numFactory().numOf(100.0);
}
@Override
public Num getValue(int index) {
for (int nanIndex : nanIndices) {
if (index == nanIndex) {
return NaN.NaN;
}
}
return validValue;
}
@Override
public String toString() {
return "TestIndicatorWithNaN";
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
@Override
public BarSeries getBarSeries() {
return series;
}
}
private record TestChannel(Num upper, Num lower, Num median) implements PriceChannel {
}
private static final class ConstantChannelIndicator implements Indicator<PriceChannel> {
private final BarSeries series;
private final PriceChannel channel;
private final String name;
private ConstantChannelIndicator(BarSeries series, double upper, double lower, double median, String name) {
this.series = series;
this.channel = new TestChannel(series.numFactory().numOf(upper), series.numFactory().numOf(lower),
series.numFactory().numOf(median));
this.name = name;
}
@Override
public PriceChannel getValue(int index) {
return channel;
}
@Override
public String toString() {
return name;
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
@Override
public BarSeries getBarSeries() {
return series;
}
}
}
@@ -0,0 +1,150 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.display;
import org.jfree.data.time.Minute;
import org.jfree.data.time.TimeSeries;
import org.jfree.data.time.TimeSeriesCollection;
import org.jfree.data.xy.DefaultOHLCDataset;
import org.jfree.data.xy.XYSeries;
import org.jfree.data.xy.XYSeriesCollection;
import org.junit.jupiter.api.Test;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.*;
import ta4jexamples.charting.ChartingTestFixtures;
/**
* Unit tests for {@link ChartDataExtractor}.
*/
class ChartDataExtractorTest {
private final ChartDataExtractor extractor = new ChartDataExtractor();
@Test
void testExtractDataTextFromTimeSeriesCollection() {
// Create a TimeSeriesCollection with test data
TimeSeries timeSeries = new TimeSeries("Test Indicator");
Date baseDate = new Date();
timeSeries.add(new Minute(baseDate), 100.0);
timeSeries.add(new Minute(new Date(baseDate.getTime() + 60000)), 101.5);
timeSeries.add(new Minute(new Date(baseDate.getTime() + 120000)), 102.0);
TimeSeriesCollection dataset = new TimeSeriesCollection();
dataset.addSeries(timeSeries);
// Test extraction from TimeSeriesCollection
String result = extractor.extractDataText(dataset, 0, 0);
assertNotNull(result, "Extracted text should not be null");
assertFalse(result.isEmpty(), "Extracted text should not be empty");
assertTrue(result.contains("Test Indicator"), "Extracted text should contain series name");
assertTrue(result.contains("100"), "Extracted text should contain value");
}
@Test
void testExtractDataTextFromTimeSeriesCollectionWithMultipleSeries() {
// Create a TimeSeriesCollection with multiple series
TimeSeries series1 = new TimeSeries("Series 1");
TimeSeries series2 = new TimeSeries("Series 2");
Date baseDate = new Date();
series1.add(new Minute(baseDate), 100.0);
series2.add(new Minute(baseDate), 200.0);
TimeSeriesCollection dataset = new TimeSeriesCollection();
dataset.addSeries(series1);
dataset.addSeries(series2);
// Test extraction from first series
String result1 = extractor.extractDataText(dataset, 0, 0);
assertNotNull(result1, "Extracted text from first series should not be null");
assertTrue(result1.contains("Series 1"), "Extracted text should contain first series name");
assertTrue(result1.contains("100"), "Extracted text should contain first series value");
// Test extraction from second series
String result2 = extractor.extractDataText(dataset, 1, 0);
assertNotNull(result2, "Extracted text from second series should not be null");
assertTrue(result2.contains("Series 2"), "Extracted text should contain second series name");
assertTrue(result2.contains("200"), "Extracted text should contain second series value");
}
@Test
void testExtractDataTextFromTimeSeriesCollectionHandlesInvalidIndex() {
TimeSeries timeSeries = new TimeSeries("Test Indicator");
Date baseDate = new Date();
timeSeries.add(new Minute(baseDate), 100.0);
TimeSeriesCollection dataset = new TimeSeriesCollection();
dataset.addSeries(timeSeries);
// Test with invalid item index (should handle gracefully)
String result = extractor.extractDataText(dataset, 0, 999);
// Should return null for invalid index
assertNull(result, "Should return null for invalid index");
}
@Test
void testExtractDataTextFromDefaultOHLCDataset() {
// Create a DefaultOHLCDataset
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(true);
// Test extraction from DefaultOHLCDataset
String result = extractor.extractDataText(dataset, 0, 0);
assertNotNull(result, "Extracted text should not be null");
assertFalse(result.isEmpty(), "Extracted text should not be empty");
assertTrue(result.contains("O:"), "Extracted text should contain Open price");
assertTrue(result.contains("H:"), "Extracted text should contain High price");
assertTrue(result.contains("L:"), "Extracted text should contain Low price");
assertTrue(result.contains("C:"), "Extracted text should contain Close price");
assertTrue(result.contains("V:"), "Extracted text should contain Volume");
}
@Test
void testExtractDataTextFromXYSeriesCollection() {
// Create an XYSeriesCollection
XYSeries series = new XYSeries("Test Series");
long baseTime = System.currentTimeMillis();
series.add(baseTime, 100.0);
series.add(baseTime + 60000, 101.5);
XYSeriesCollection dataset = new XYSeriesCollection();
dataset.addSeries(series);
// Test extraction from XYSeriesCollection
String result = extractor.extractDataText(dataset, 0, 0);
assertNotNull(result, "Extracted text should not be null");
assertFalse(result.isEmpty(), "Extracted text should not be empty");
assertTrue(result.contains("100"), "Extracted text should contain value");
assertTrue(result.contains("Date:"), "Extracted text should contain date label");
assertTrue(result.contains("Value:"), "Extracted text should contain value label");
}
@Test
void testExtractDataTextReturnsNullForEmptyDataset() {
// Create an empty XYSeriesCollection
XYSeriesCollection emptyDataset = new XYSeriesCollection();
// Test with empty dataset (no series)
String result = extractor.extractDataText(emptyDataset, 0, 0);
assertNull(result, "Should return null for empty dataset");
}
@Test
void testExtractDataTextReturnsNullForInvalidSeriesIndex() {
TimeSeries timeSeries = new TimeSeries("Test Indicator");
Date baseDate = new Date();
timeSeries.add(new Minute(baseDate), 100.0);
TimeSeriesCollection dataset = new TimeSeriesCollection();
dataset.addSeries(timeSeries);
// Test with invalid series index
String result = extractor.extractDataText(dataset, 999, 0);
assertNull(result, "Should return null for invalid series index");
}
}
@@ -0,0 +1,478 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.display;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.junit.Assume;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GraphicsEnvironment;
import java.util.UUID;
import javax.swing.JFrame;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link SwingChartDisplayer}.
*/
class SwingChartDisplayerTest {
private SwingChartDisplayer displayer;
@BeforeEach
void setUp() {
displayer = new SwingChartDisplayer();
// Clear any existing properties
System.clearProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY);
System.clearProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY);
// Always disable display in tests to prevent actual windows from being created
// which would call System.exit(0) when closed
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
}
@AfterEach
void tearDown() {
// Keep display disabled while frames are torn down.
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
Frame[] frames = Frame.getFrames();
for (Frame frame : frames) {
frame.dispose();
}
// Clean up properties
System.clearProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY);
System.clearProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY);
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
@Test
void testDefaultDisplayScale() {
double scale = displayer.resolveDisplayScale();
assertEquals(SwingChartDisplayer.DEFAULT_DISPLAY_SCALE, scale, 1e-9,
"Default scale should be " + SwingChartDisplayer.DEFAULT_DISPLAY_SCALE);
}
@Test
void testDisplayScaleFromValidProperty() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.5");
double scale = displayer.resolveDisplayScale();
assertEquals(0.5, scale, 1e-9, "Scale should reflect configured property");
}
@Test
void testDisplayScaleFromValidPropertyAtMax() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "1.0");
double scale = displayer.resolveDisplayScale();
assertEquals(1.0, scale, 1e-9, "Scale should accept max value of 1.0");
}
@Test
void testDisplayScaleFromValidPropertyNearMin() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.11");
double scale = displayer.resolveDisplayScale();
assertEquals(0.11, scale, 1e-9, "Scale should accept values just above 0.1");
}
@Test
void testDisplayScaleIgnoresValueBelowThreshold() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.05");
double scale = displayer.resolveDisplayScale();
assertEquals(SwingChartDisplayer.DEFAULT_DISPLAY_SCALE, scale, 1e-9, "Values <= 0.1 should be ignored");
}
@Test
void testDisplayScaleIgnoresValueAboveMax() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "2.0");
double scale = displayer.resolveDisplayScale();
assertEquals(SwingChartDisplayer.DEFAULT_DISPLAY_SCALE, scale, 1e-9, "Values > 1.0 should be ignored");
}
@Test
void testDisplayScaleHandlesInvalidPropertyValue() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "invalid");
double scale = displayer.resolveDisplayScale();
assertEquals(SwingChartDisplayer.DEFAULT_DISPLAY_SCALE, scale, 1e-9,
"Invalid property value should fall back to default");
}
@Test
void testDisplayScaleHandlesEmptyPropertyValue() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "");
double scale = displayer.resolveDisplayScale();
assertEquals(SwingChartDisplayer.DEFAULT_DISPLAY_SCALE, scale, 1e-9,
"Empty property should fall back to default");
}
@Test
void testDetermineDisplaySizeReturnsDimension() {
Dimension size = displayer.determineDisplaySize();
assertNotNull(size, "Display size should not be null");
assertTrue(size.width > 0, "Width should be positive");
assertTrue(size.height > 0, "Height should be positive");
}
@Test
void testDetermineDisplaySizeMeetsMinimumDimensions() {
Dimension size = displayer.determineDisplaySize();
assertTrue(size.width >= 800, "Width should meet minimum of 800");
assertTrue(size.height >= 600, "Height should meet minimum of 600");
}
@Test
void testDetermineDisplaySizeScalesCorrectly() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.5");
Dimension size1 = displayer.determineDisplaySize();
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.75");
Dimension size2 = displayer.determineDisplaySize();
assertTrue(size2.width >= size1.width, "Larger scale should produce larger width");
assertTrue(size2.height >= size1.height, "Larger scale should produce larger height");
}
@Test
void testDisplayWithNullChart() {
assertThrows(Exception.class, () -> displayer.display(null), "Display should throw exception for null chart");
}
@Test
void testDisplayCreatesNonFocusableWindowWhenEnabled() {
Assume.assumeFalse("Headless environment", GraphicsEnvironment.isHeadless());
String title = "Focusability Test " + UUID.randomUUID();
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
displayer.display(chart, title);
JFrame frame = findFrameByTitle(title);
assertNotNull(frame, "Expected frame created by public display API");
assertFalse(frame.getFocusableWindowState(), "Displayed chart frame should not be focusable");
// Keep cleanup deterministic in tests and avoid exit-on-close behavior.
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
frame.dispose();
}
/**
* Tests that display gracefully handles headless environments.
* <p>
* <b>Note:</b> This test is intentionally skipped when running in a
* non-headless environment (e.g., when a display is available). The test uses
* {@code Assume.assumeTrue()} to skip when a display is present, as it
* specifically tests behavior in headless environments where GUI operations
* should fail gracefully.
* <p>
* This skip is expected and intentional - the test validates headless
* environment handling, which only applies when no display is available.
*/
@Test
void testDisplayHandlesHeadlessEnvironment() {
// This test only runs in headless environments
Assume.assumeTrue("Test requires headless environment", GraphicsEnvironment.isHeadless());
// Clear the disable display property so we can test actual headless behavior
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
try {
// In headless environment, display should fail gracefully
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// This will throw HeadlessException in headless environment
// but we should handle it gracefully
assertThrows(Exception.class, () -> displayer.display(chart));
} finally {
// Restore the property for cleanup
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
}
}
@Test
void testDisplayRespectsDisableDisplayProperty() {
// Set property to disable display
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// Display should return immediately without showing a window
displayer.display(chart);
// If we get here without exception, the property worked
assertTrue(true, "Display should be disabled when property is set");
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
@Test
void testDetermineDisplaySizeWithCustomScale() {
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.8");
Dimension size = displayer.determineDisplaySize();
assertNotNull(size);
// Should use custom scale or fall back to defaults
}
@Test
void testDetermineDisplaySizeBoundsHandling() {
// Test with various property values to ensure bounds are respected
System.setProperty(SwingChartDisplayer.DISPLAY_SCALE_PROPERTY, "0.5");
Dimension size = displayer.determineDisplaySize();
assertTrue(size.width >= 800 && size.height >= 600, "Dimensions should respect minimum bounds");
}
@Test
void testResolveDisplaySizeUsesExplicitPreferredSize() {
Dimension requested = new Dimension(1440, 900);
Dimension resolved = displayer.resolveDisplaySize(requested);
assertEquals(requested, resolved);
assertNotSame(requested, resolved, "Resolved size should be a defensive copy");
}
@Test
void testResolveDisplaySizeRejectsNonPositivePreferredDimensions() {
assertThrows(IllegalArgumentException.class, () -> displayer.resolveDisplaySize(new Dimension(0, 900)));
assertThrows(IllegalArgumentException.class, () -> displayer.resolveDisplaySize(new Dimension(1440, -1)));
}
// ========== Mouseover functionality tests ==========
@Test
void testDefaultHoverDelay() {
int delay = displayer.resolveHoverDelay();
assertEquals(SwingChartDisplayer.DEFAULT_HOVER_DELAY_MS, delay,
"Default hover delay should be " + SwingChartDisplayer.DEFAULT_HOVER_DELAY_MS + "ms");
}
@Test
void testHoverDelayFromValidProperty() {
System.setProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY, "500");
int delay = displayer.resolveHoverDelay();
assertEquals(500, delay, "Hover delay should reflect configured property");
}
@Test
void testHoverDelayFromZeroProperty() {
System.setProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY, "0");
int delay = displayer.resolveHoverDelay();
assertEquals(0, delay, "Hover delay should accept zero value");
}
@Test
void testHoverDelayFromLargeProperty() {
System.setProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY, "5000");
int delay = displayer.resolveHoverDelay();
assertEquals(5000, delay, "Hover delay should accept large values");
}
@Test
void testHoverDelayIgnoresNegativeValue() {
System.setProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY, "-100");
int delay = displayer.resolveHoverDelay();
assertEquals(SwingChartDisplayer.DEFAULT_HOVER_DELAY_MS, delay,
"Negative values should be ignored and fall back to default");
}
@Test
void testHoverDelayHandlesInvalidPropertyValue() {
System.setProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY, "invalid");
int delay = displayer.resolveHoverDelay();
assertEquals(SwingChartDisplayer.DEFAULT_HOVER_DELAY_MS, delay,
"Invalid property value should fall back to default");
}
@Test
void testHoverDelayHandlesEmptyPropertyValue() {
System.setProperty(SwingChartDisplayer.HOVER_DELAY_PROPERTY, "");
int delay = displayer.resolveHoverDelay();
assertEquals(SwingChartDisplayer.DEFAULT_HOVER_DELAY_MS, delay, "Empty property should fall back to default");
}
@Test
void testChartMouseoverListenerCreation() {
javax.swing.JLabel infoLabel = new javax.swing.JLabel(" ");
int hoverDelay = 1000;
SwingChartDisplayer.ChartMouseoverListener listener = new SwingChartDisplayer.ChartMouseoverListener(infoLabel,
hoverDelay);
assertNotNull(listener, "Listener should be created successfully");
}
@Test
void testChartMouseoverListenerCreationWithDifferentDelays() {
javax.swing.JLabel infoLabel = new javax.swing.JLabel(" ");
// Test with different delay values
SwingChartDisplayer.ChartMouseoverListener listener1 = new SwingChartDisplayer.ChartMouseoverListener(infoLabel,
0);
assertNotNull(listener1, "Listener should be created with zero delay");
SwingChartDisplayer.ChartMouseoverListener listener2 = new SwingChartDisplayer.ChartMouseoverListener(infoLabel,
5000);
assertNotNull(listener2, "Listener should be created with large delay");
}
@Test
void testChartMouseoverListenerHandlesClick() {
javax.swing.JLabel infoLabel = new javax.swing.JLabel(" ");
SwingChartDisplayer.ChartMouseoverListener listener = new SwingChartDisplayer.ChartMouseoverListener(infoLabel,
1000);
// Verify listener was created
assertNotNull(listener, "Listener should be created successfully");
// The click handler should not throw - this tests the method signature
// Note: We can't easily test with actual ChartMouseEvent without complex
// mocking,
// but the method exists and is part of the ChartMouseListener interface
}
// ========== Window cascading functionality tests ==========
@Test
void testMultipleDisplaysHandleCascadingGracefully() {
// Set property to disable display to avoid actually showing windows
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart1 = ChartFactory.createLineChart("Test 1", "X", "Y", null);
JFreeChart chart2 = ChartFactory.createLineChart("Test 2", "X", "Y", null);
JFreeChart chart3 = ChartFactory.createLineChart("Test 3", "X", "Y", null);
// Multiple displays should not throw exceptions
// The cascading logic should handle positioning gracefully
assertDoesNotThrow(() -> {
displayer.display(chart1, "Window 1");
displayer.display(chart2, "Window 2");
displayer.display(chart3, "Window 3");
}, "Multiple displays should handle cascading without exceptions");
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
@Test
void testDisplayWithWindowTitleHandlesCascading() {
// Set property to disable display
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// Display with custom window title should work
assertDoesNotThrow(() -> displayer.display(chart, "Custom Window Title"),
"Display with window title should handle cascading gracefully");
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
@Test
void testDisplayHandlesCascadingInHeadlessEnvironment() {
// This test only runs in headless environments
Assume.assumeTrue("Test requires headless environment", GraphicsEnvironment.isHeadless());
// Clear the disable display property so we can test actual headless behavior
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// In headless environment, cascading logic should fail gracefully
// The exception handling in the cascading code should catch any errors
assertThrows(Exception.class, () -> displayer.display(chart, "Test Window"),
"Display should throw exception in headless environment, but cascading logic should be attempted");
} finally {
// Restore the property for cleanup
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
}
}
@Test
void testMultipleDisplaysWithDifferentTitles() {
// Set property to disable display
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// Multiple displays with different titles should all work
assertDoesNotThrow(() -> {
for (int i = 0; i < 5; i++) {
displayer.display(chart, "Window " + i);
}
}, "Multiple displays with different titles should handle cascading correctly");
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
// ========== Window tracking tests ==========
@Test
void testWindowTrackingWhenDisplayed() {
// Set property to disable actual display (works in both headless and
// non-headless)
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// Display should not throw
assertDoesNotThrow(() -> displayer.display(chart, "Test Window"),
"Display should track window without throwing");
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
@Test
void testMultipleWindowsTrackedIndependently() {
// Set property to disable actual display (works in both headless and
// non-headless)
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// Display multiple windows
assertDoesNotThrow(() -> {
displayer.display(chart, "Window 1");
displayer.display(chart, "Window 2");
displayer.display(chart, "Window 3");
}, "Multiple windows should be tracked independently");
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
@Test
void testWindowTrackingWithDisabledDisplay() {
// Set property to disable display
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
JFreeChart chart = ChartFactory.createLineChart("Test", "X", "Y", null);
// When display is disabled, no windows should be created or tracked
assertDoesNotThrow(() -> {
displayer.display(chart, "Test Window");
displayer.display(chart, "Test Window 2");
}, "Display with disabled property should not throw");
// Since display is disabled, no windows are created, so no exit behavior
// This test just verifies the code path doesn't crash
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
private JFrame findFrameByTitle(String title) {
Frame[] frames = Frame.getFrames();
for (Frame frame : frames) {
if (frame instanceof JFrame jFrame && title.equals(jFrame.getTitle())) {
return jFrame;
}
}
return null;
}
}
@@ -0,0 +1,256 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.renderer;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.xy.CandlestickRenderer;
import org.jfree.data.xy.DefaultOHLCDataset;
import org.junit.Test;
import java.awt.*;
import static org.junit.Assert.*;
import ta4jexamples.charting.ChartingTestFixtures;
/**
* Unit tests for {@link BaseCandleStickRenderer}.
*/
public class BaseCandleStickRendererTest {
@Test
public void testConstructor() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
assertNotNull("Renderer should not be null", renderer);
}
@Test
public void testGetItemPaintUpCandle() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create a chart with up candle data (close > open)
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(true);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Test up candle paint
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null", paint);
assertTrue("Paint should be green for up candle", paint instanceof Color);
}
@Test
public void testGetItemPaintDownCandle() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create a chart with down candle data (close < open)
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(false);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Test down candle paint
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null", paint);
assertTrue("Paint should be red for down candle", paint instanceof Color);
}
@Test
public void testGetItemPaintWithNullDataset() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create a chart with null dataset
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", null, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Should handle null dataset gracefully
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null even with null dataset", paint);
}
@Test
public void testGetItemPaintWithNonOHLCDataset() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create a chart with non-OHLC dataset
JFreeChart chart = ChartFactory.createXYLineChart("Test", "Time", "Price", null);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Should handle non-OHLC dataset gracefully
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null even with non-OHLC dataset", paint);
}
@Test
public void testGetItemPaintWithNullValues() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create a dataset with null values
DefaultOHLCDataset dataset = ChartingTestFixtures.candleDatasetWithZeros();
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Should handle null values gracefully
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null even with null values", paint);
}
@Test
public void testGetItemPaintWithInvalidIndices() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(true);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Test with invalid indices - should not throw exception
try {
Paint paint1 = renderer.getItemPaint(-1, 0);
Paint paint2 = renderer.getItemPaint(0, -1);
Paint paint3 = renderer.getItemPaint(100, 100);
// Verify paints are not null
assertNotNull("Paint for invalid row should not be null", paint1);
assertNotNull("Paint for invalid column should not be null", paint2);
assertNotNull("Paint for out of bounds should not be null", paint3);
} catch (Exception e) {
fail("Should not throw exception with invalid indices: " + e.getMessage());
}
}
@Test
public void testColorConstants() {
// Test that the color constants are accessible
// Note: These are private in the actual class, so we test them indirectly
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
assertNotNull("Renderer should be created successfully", renderer);
}
@Test
public void testInheritance() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Test that it extends CandlestickRenderer
assertTrue("Should extend CandlestickRenderer", renderer instanceof CandlestickRenderer);
}
@Test
public void testMultipleCalls() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(true);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
// Test multiple calls to getItemPaint
Paint paint1 = renderer.getItemPaint(0, 0);
Paint paint2 = renderer.getItemPaint(0, 0);
assertNotNull("First call should return non-null paint", paint1);
assertNotNull("Second call should return non-null paint", paint2);
assertEquals("Multiple calls should return same paint", paint1, paint2);
}
@Test
public void testUpCandleLogic() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create dataset where close > open (up candle)
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(true);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null", paint);
// The paint should be green for up candle
assertTrue("Should return a Color object", paint instanceof Color);
}
@Test
public void testDownCandleLogic() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create dataset where close < open (down candle)
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(false);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null", paint);
// The paint should be red for down candle
assertTrue("Should return a Color object", paint instanceof Color);
}
@Test
public void testUpCandleColorMatchesTradingView() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create dataset with up candle (close > open)
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(true);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null", paint);
assertTrue("Paint should be a Color", paint instanceof Color);
Color color = (Color) paint;
// TradingView's default bullish candle color: #26A69A (RGB: 38, 166, 154)
assertEquals("Up candle red component should match TradingView", 38, color.getRed());
assertEquals("Up candle green component should match TradingView", 166, color.getGreen());
assertEquals("Up candle blue component should match TradingView", 154, color.getBlue());
assertEquals("Up candle color should match DEFAULT_UP_COLOR", BaseCandleStickRenderer.DEFAULT_UP_COLOR, color);
}
@Test
public void testDownCandleColorMatchesTradingView() {
BaseCandleStickRenderer renderer = new BaseCandleStickRenderer();
// Create dataset with down candle (close < open)
DefaultOHLCDataset dataset = ChartingTestFixtures.singleCandleDataset(false);
JFreeChart chart = ChartFactory.createCandlestickChart("Test", "Time", "Price", dataset, true);
XYPlot plot = chart.getXYPlot();
plot.setRenderer(renderer);
Paint paint = renderer.getItemPaint(0, 0);
assertNotNull("Paint should not be null", paint);
assertTrue("Paint should be a Color", paint instanceof Color);
Color color = (Color) paint;
// TradingView's default bearish candle color: #EF5350 (RGB: 239, 83, 80)
assertEquals("Down candle red component should match TradingView", 239, color.getRed());
assertEquals("Down candle green component should match TradingView", 83, color.getGreen());
assertEquals("Down candle blue component should match TradingView", 80, color.getBlue());
assertEquals("Down candle color should match DEFAULT_DOWN_COLOR", BaseCandleStickRenderer.DEFAULT_DOWN_COLOR,
color);
}
@Test
public void testColorConstantsAreTradingViewColors() {
// Verify the color constants match TradingView's exact colors
Color upColor = BaseCandleStickRenderer.DEFAULT_UP_COLOR;
Color downColor = BaseCandleStickRenderer.DEFAULT_DOWN_COLOR;
// TradingView's default bullish candle color: #26A69A
assertEquals("DEFAULT_UP_COLOR red should be 38", 38, upColor.getRed());
assertEquals("DEFAULT_UP_COLOR green should be 166", 166, upColor.getGreen());
assertEquals("DEFAULT_UP_COLOR blue should be 154", 154, upColor.getBlue());
// TradingView's default bearish candle color: #EF5350
assertEquals("DEFAULT_DOWN_COLOR red should be 239", 239, downColor.getRed());
assertEquals("DEFAULT_DOWN_COLOR green should be 83", 83, downColor.getGreen());
assertEquals("DEFAULT_DOWN_COLOR blue should be 80", 80, downColor.getBlue());
}
}
@@ -0,0 +1,175 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.charting.storage;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import ta4jexamples.charting.ChartingTestFixtures;
/**
* Unit tests for {@link FileSystemChartStorage}.
*/
class FileSystemChartStorageTest {
private FileSystemChartStorage storage;
private BarSeries barSeries;
private JFreeChart chart;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
storage = new FileSystemChartStorage(tempDir);
barSeries = ChartingTestFixtures.standardDailySeries();
chart = ChartFactory.createCandlestickChart("Test Chart", "Date", "Price",
ChartingTestFixtures.seriesToDataset(barSeries), true);
}
@Test
void testConstructorWithNullPath() {
assertThrows(NullPointerException.class, () -> new FileSystemChartStorage(null));
}
@Test
void testSaveWithValidInput() {
Optional<Path> result = storage.save(chart, barSeries, "Test Strategy", 800, 600);
assertTrue(result.isPresent(), "Save should return a path");
assertTrue(Files.exists(result.get()), "Chart file should exist");
assertTrue(Files.isRegularFile(result.get()), "Saved path should be a file");
}
@Test
void testSaveCreatesDirectories() {
Optional<Path> result = storage.save(chart, barSeries, "Test Strategy", 800, 600);
assertTrue(result.isPresent());
Path parent = result.get().getParent();
assertTrue(Files.exists(parent), "Parent directories should be created");
assertTrue(Files.isDirectory(parent), "Parent should be a directory");
}
@Test
void testSaveWithNullChart() {
assertThrows(NullPointerException.class, () -> storage.save(null, barSeries, "Test Strategy", 800, 600));
}
@Test
void testSaveWithNullSeries() {
assertThrows(NullPointerException.class, () -> storage.save(chart, null, "Test Strategy", 800, 600));
}
@Test
void testPathSanitization() {
BarSeries seriesWithSpecialChars = ChartingTestFixtures.seriesWithSpecialChars();
Optional<Path> result = storage.save(chart, seriesWithSpecialChars, "Test:Strategy/Path", 800, 600);
assertTrue(result.isPresent());
// Note: path may contain file system separators, but not special chars in
// component names
// The path components themselves should be sanitized
String fileName = result.get().getFileName().toString();
assertFalse(fileName.contains(":"), "File name should not contain ':'");
assertFalse(fileName.contains("?"), "File name should not contain '?'");
assertFalse(fileName.contains("*"), "File name should not contain '*'");
assertFalse(fileName.contains("<") || fileName.contains(">"), "File name should not contain angle brackets");
}
@Test
void testPathSanitizationWithNullSeriesName() {
BarSeries seriesWithNullName = new BaseBarSeriesBuilder().build();
String chartTitle = "Test Strategy";
Optional<Path> result = storage.save(chart, seriesWithNullName, chartTitle, 800, 600);
assertTrue(result.isPresent());
assertTrue(result.get().toString().endsWith(chartTitle.replaceAll(" ", "_") + ".jpg"),
"Should use chart title for null series name");
}
@Test
void testPathSanitizationWithEmptySeriesName() {
BarSeries seriesWithEmptyName = new BaseBarSeriesBuilder().withName("").build();
String chartTitle = "Test Strategy";
Optional<Path> result = storage.save(chart, seriesWithEmptyName, chartTitle, 800, 600);
assertTrue(result.isPresent());
assertTrue(result.get().toString().endsWith(chartTitle.replaceAll(" ", "_") + ".jpg"),
"Should use chart title for empty series name");
}
@Test
void testPathSanitizationWhitespaceHandling() {
BarSeries seriesWithWhitespace = new BaseBarSeriesBuilder().withName("Series Name With Spaces").build();
Optional<Path> result = storage.save(chart, seriesWithWhitespace, "Strategy Name", 800, 600);
assertTrue(result.isPresent());
String pathString = result.get().toString();
// Whitespace should be replaced with underscores
assertFalse(pathString.contains(" "), "Multiple consecutive spaces should be collapsed");
}
@Test
void testPathSanitizationRemovesLeadingAndTrailingDots() {
BarSeries seriesWithDots = new BaseBarSeriesBuilder().withName("...Series...").build();
Optional<Path> result = storage.save(chart, seriesWithDots, "...Strategy...", 800, 600);
assertTrue(result.isPresent());
String pathString = result.get().toString();
assertFalse(pathString.contains("..."), "Leading and trailing dots should be removed");
}
@Test
void testSaveWithDifferentChartSizes() {
Optional<Path> result1 = storage.save(chart, barSeries, "Test Strategy", 1024, 768);
assertTrue(result1.isPresent(), "Should save with 1024x768");
Optional<Path> result2 = storage.save(chart, barSeries, "Test Strategy 2", 1920, 1080);
assertTrue(result2.isPresent(), "Should save with 1920x1080");
}
@Test
void testSaveWithNullChartTitleUsesSeriesName() {
Optional<Path> result = storage.save(chart, barSeries, null, 800, 600);
assertTrue(result.isPresent());
}
@Test
void testSaveWithLongChartTitle() {
String longTitle = "Test Strategy with a very long name that might cause issues "
+ "with path length limits on some file systems";
Optional<Path> result = storage.save(chart, barSeries, longTitle, 800, 600);
assertTrue(result.isPresent());
}
@Test
void testSaveIncludesSeriesPeriodDescription() {
Optional<Path> result = storage.save(chart, barSeries, "Test Strategy", 800, 600);
assertTrue(result.isPresent());
// Path should have multiple levels (series/period/title)
Path parentDir = result.get().getParent();
assertNotNull(parentDir, "Should have parent directory");
}
}
@@ -0,0 +1,216 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.Appender;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.WriterAppender;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.layout.PatternLayout;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import java.io.InputStream;
import java.io.StringWriter;
import java.time.Duration;
import java.time.Instant;
import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsNull.notNullValue;
import static org.junit.Assume.assumeThat;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the {@link BitStampCsvTradesFileBarSeriesDataSource} class.
*/
public class BitStampCSVTradesBarSeriesDataSourceTest {
private StringWriter logOutput;
private Appender appender;
@BeforeEach
public void setUp() {
logOutput = new StringWriter();
LoggerContext context = (LoggerContext) LogManager.getContext(false);
Configuration config = context.getConfiguration();
PatternLayout layout = PatternLayout.newBuilder().withPattern("%level %msg%n").build();
appender = WriterAppender.newBuilder().setTarget(logOutput).setLayout(layout).setName("TestAppender").build();
appender.start();
config.addAppender(appender);
org.apache.logging.log4j.core.Logger logger = (org.apache.logging.log4j.core.Logger) LogManager
.getLogger(BitStampCsvTradesFileBarSeriesDataSource.class);
logger.addAppender(appender);
logger.setLevel(org.apache.logging.log4j.Level.WARN);
}
@AfterEach
public void tearDown() {
if (appender != null) {
org.apache.logging.log4j.core.Logger logger = (org.apache.logging.log4j.core.Logger) LogManager
.getLogger(BitStampCsvTradesFileBarSeriesDataSource.class);
logger.removeAppender(appender);
appender.stop();
}
}
@Test
public void testMain() {
BitStampCsvTradesFileBarSeriesDataSource.main(null);
}
@Test
public void testLoadSeriesWithStandardNamingPattern() {
// Test loading Bitstamp BTC data using domain-driven interface with standard
// naming pattern
// Pattern: Bitstamp-{ticker}-{interval}-{startDate}_{endDate}.csv
String expectedFile = "Bitstamp-BTC-USD-PT5M-20131125_20131201.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
BarSeries series = dataSource.loadSeries("BTC-USD", Duration.ofMinutes(5), start, end);
assertNotNull(series, "Should load series using standard naming pattern with Bitstamp prefix");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
assertEquals(expectedFile, series.getName(), "Series name should match the filename with Bitstamp prefix");
}
@Test
public void testLoadSeriesWithDirectFilename() {
// Test loading by direct filename (backward compatibility)
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource
.loadBitstampSeries("Bitstamp-BTC-USD-PT5M-20131125_20131201.csv");
assertNotNull(series, "Should load series from direct filename with Bitstamp prefix");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
}
@Test
public void testLoadSeriesWithDefaultFile() {
// Test loading default Bitstamp file
BarSeries series = BitStampCsvTradesFileBarSeriesDataSource.loadBitstampSeries();
assertNotNull(series, "Should load default Bitstamp series");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
}
@Test
public void testLoadSeriesWithNonExistentTicker() {
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
BarSeries series = dataSource.loadSeries("NONEXISTENT-USD", Duration.ofMinutes(5), start, end);
assertNull(series, "Should return null for non-existent ticker");
}
@Test
public void testLoadSeriesWithInvalidDateRange() {
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-12-01T23:59:59Z");
Instant end = Instant.parse("2013-11-25T00:00:00Z"); // End before start
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("BTC-USD", Duration.ofMinutes(5), start, end);
}, "Should throw IllegalArgumentException when end date is before start date");
}
@Test
public void testLoadSeriesWithNullTicker() {
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries(null, Duration.ofMinutes(5), start, end);
}, "Should throw IllegalArgumentException for null ticker");
}
@Test
public void testLoadSeriesWithEmptyTicker() {
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("", Duration.ofMinutes(5), start, end);
}, "Should throw IllegalArgumentException for empty ticker");
}
@Test
public void testLoadSeriesWithInvalidInterval() {
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("BTC-USD", Duration.ZERO, start, end);
}, "Should throw IllegalArgumentException for zero interval");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("BTC-USD", Duration.ofSeconds(-1), start, end);
}, "Should throw IllegalArgumentException for negative interval");
}
@Test
public void testGetSourceName() {
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
assertEquals("Bitstamp", dataSource.getSourceName(), "Should return 'Bitstamp' as source name");
}
@Test
public void testGetSourceNameUsedInFileSearchPattern() {
// Verify that getSourceName() is used in file search patterns
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
String sourceName = dataSource.getSourceName();
assertFalse(sourceName.isEmpty(), "Source name should not be empty");
assertEquals("Bitstamp", sourceName, "Source name should be 'Bitstamp'");
// Test that file search uses the source name prefix
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
String expectedFile = "Bitstamp-BTC-USD-PT5M-20131125_20131201.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
BarSeries series = dataSource.loadSeries("BTC-USD", Duration.ofMinutes(5), start, end);
assertNotNull(series, "Should find file using source name prefix");
assertTrue(expectedFile.startsWith(sourceName + "-"), "Expected file should start with source name prefix");
}
@Test
public void testLoadSeriesWithMismatchedIntervalReturnsNull() {
// Test that when requesting a different interval than what's in the file,
// null is returned (file search won't find files with mismatched intervals).
//
// Note: The fix in filterAndAggregateSeries ensures that if a file is ever
// found but has a mismatched interval, a warning will be logged instead of
// failing silently. However, the current file search logic prevents this
// scenario from occurring through the public API, so we test the expected
// behavior (null return) rather than the warning.
String expectedFile = "Bitstamp-BTC-USD-PT5M-20131125_20131201.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
BitStampCsvTradesFileBarSeriesDataSource dataSource = new BitStampCsvTradesFileBarSeriesDataSource();
Instant start = Instant.parse("2013-11-25T00:00:00Z");
Instant end = Instant.parse("2013-12-01T23:59:59Z");
// First verify the file can be loaded with matching interval
BarSeries seriesWithMatchingInterval = dataSource.loadSeries("BTC-USD", Duration.ofMinutes(5), start, end);
assertNotNull(seriesWithMatchingInterval, "File should be loadable with matching 5-minute interval");
// Request 1-hour bars - file search won't find the 5-minute file, so returns
// null
BarSeries series = dataSource.loadSeries("BTC-USD", Duration.ofHours(1), start, end);
assertNull(series, "Should return null when no file matches the requested interval");
}
}
@@ -0,0 +1,221 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsNull.notNullValue;
import static org.junit.Assume.assumeThat;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the {@link CsvFileBarSeriesDataSource} class.
*/
public class CsvBarSeriesDataSourceTest {
@Test
public void testMain() {
CsvFileBarSeriesDataSource.main(null);
}
@Test
public void testLoadSeriesWithStandardNamingPattern() {
// Test loading AAPL data using domain-driven interface with standard naming
// pattern
// Pattern: {ticker}-{interval}-{startDate}_{endDate}.csv
String expectedFile = "AAPL-PT1D-20130102_20131231.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
BarSeries series = dataSource.loadSeries("AAPL", Duration.ofDays(1), start, end);
assertNotNull(series, "Should load series using standard naming pattern");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
assertEquals(expectedFile, series.getName(), "Series name should match the filename");
}
@Test
public void testLoadSeriesWithDirectFilename() {
// Test loading by direct filename (backward compatibility)
BarSeries series = CsvFileBarSeriesDataSource.loadCsvSeries("AAPL-PT1D-20130102_20131231.csv");
assertNotNull(series, "Should load series from direct filename");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
}
@Test
public void testLoadSeriesWithNonExistentTicker() {
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
BarSeries series = dataSource.loadSeries("NONEXISTENT", Duration.ofDays(1), start, end);
assertNull(series, "Should return null for non-existent ticker");
}
@Test
public void testLoadSeriesWithInvalidDateRange() {
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-12-31T23:59:59Z");
Instant end = Instant.parse("2013-01-02T00:00:00Z"); // End before start
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("AAPL", Duration.ofDays(1), start, end);
}, "Should throw IllegalArgumentException when end date is before start date");
}
@Test
public void testLoadSeriesWithNullTicker() {
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries(null, Duration.ofDays(1), start, end);
}, "Should throw IllegalArgumentException for null ticker");
}
@Test
public void testLoadSeriesWithEmptyTicker() {
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("", Duration.ofDays(1), start, end);
}, "Should throw IllegalArgumentException for empty ticker");
}
@Test
public void testLoadSeriesWithInvalidInterval() {
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("AAPL", Duration.ZERO, start, end);
}, "Should throw IllegalArgumentException for zero interval");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("AAPL", Duration.ofSeconds(-1), start, end);
}, "Should throw IllegalArgumentException for negative interval");
}
@Test
public void testGetSourceName() {
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
assertEquals("", dataSource.getSourceName(), "Should return empty string as CSV files don't use source prefix");
}
@Test
public void testGetSourceNameUsedInFileSearchPattern() {
// Verify that getSourceName() returns empty string and files don't use prefix
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
String sourceName = dataSource.getSourceName();
assertTrue(sourceName.isEmpty(), "Source name should be empty for generic CSV files");
// Test that file search works without source name prefix
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
String expectedFile = "AAPL-PT1D-20130102_20131231.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
BarSeries series = dataSource.loadSeries("AAPL", Duration.ofDays(1), start, end);
assertNotNull(series, "Should find file without source name prefix");
// Verify file doesn't start with a source prefix (like "Bitstamp-" or
// "YahooFinance-")
assertFalse(expectedFile.startsWith("Bitstamp-"), "Expected file should not start with Bitstamp prefix");
assertFalse(expectedFile.startsWith("YahooFinance-"),
"Expected file should not start with YahooFinance prefix");
assertTrue(expectedFile.startsWith("AAPL-"), "Expected file should start with ticker directly");
}
@Test
public void testLoadSeriesWithBroaderPatternWildcardInterval() {
// Test that broader pattern search works when exact interval doesn't match
// This exercises the wildcard pattern: AAPL-*-20130102_*.csv
// The file exists as AAPL-PT1D-20130102_20131231.csv
// Request with PT5M interval (which doesn't match) but same dates
// Should find the file via broader pattern by trying PT1D for the interval
// wildcard
String expectedFile = "AAPL-PT1D-20130102_20131231.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
// Request with PT5M interval (file has PT1D) - exact pattern won't match
// Broader pattern AAPL-*-20130102_*.csv should find it
BarSeries series = dataSource.loadSeries("AAPL", Duration.ofMinutes(5), start, end);
assertNotNull(series, "Should find file via broader pattern when interval doesn't match exactly");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
assertEquals(expectedFile, series.getName(), "Series name should match the found filename");
}
@Test
public void testLoadSeriesWithBroaderPatternWildcardEndDate() {
// Test that broader pattern search correctly handles multiple wildcards
// Pattern: AAPL-*-20130102_*.csv
// First wildcard should be replaced with interval (PT1D), second with end date
// This verifies the fix where both wildcards were being replaced with the same
// value
String expectedFile = "AAPL-PT1D-20130102_20131231.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
// Use a slightly different end date that won't match exact pattern
// but the broader pattern should still find the file
Instant end = Instant.parse("2013-12-31T12:00:00Z"); // Different time, same date
// Request with PT1H interval (file has PT1D) - exact pattern won't match
// Broader pattern should find it by trying PT1D for first wildcard and
// 20131231 for second wildcard (from end date parameter)
BarSeries series = dataSource.loadSeries("AAPL", Duration.ofHours(1), start, end);
assertNotNull(series,
"Should find file via broader pattern when exact pattern doesn't match, verifying wildcard fix");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
assertEquals(expectedFile, series.getName(), "Series name should match the found filename");
}
@Test
public void testLoadSeriesWithBroaderPatternDateOnlyFormat() {
// Test that broader pattern search works with date-only format
// Pattern: AAPL-*-20130102_*.csv (date-only version)
// This exercises the date-only broader pattern fallback
String expectedFile = "AAPL-PT1D-20130102_20131231.csv";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
CsvFileBarSeriesDataSource dataSource = new CsvFileBarSeriesDataSource();
Instant start = Instant.parse("2013-01-02T00:00:00Z");
Instant end = Instant.parse("2013-12-31T23:59:59Z");
// Request with PT1H interval (file has PT1D) - exact pattern won't match
// The date-only broader pattern AAPL-*-20130102_*.csv should find it
// by trying PT1D for first wildcard and 20131231 for second wildcard
BarSeries series = dataSource.loadSeries("AAPL", Duration.ofHours(1), start, end);
assertNotNull(series, "Should find file via date-only broader pattern when exact pattern doesn't match");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
assertEquals(expectedFile, series.getName(), "Series name should match the found filename");
}
}
@@ -0,0 +1,293 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsNull.notNullValue;
import static org.junit.Assume.assumeThat;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the {@link JsonFileBarSeriesDataSource} class.
* <p>
* This test class verifies the behavior of the
* {@code JsonFileBarSeriesDataSource} when loading bar series data from various
* JSON input streams, including valid Coinbase and Binance formatted data, as
* well as edge cases such as a null input stream.
* </p>
*/
public class JsonBarSeriesDataSourceTest {
@Test
public void testLoadCoinbaseInputStream() {
String jsonFile = "Coinbase-ETH-USD-PT1D-20241105_20251020.json";
InputStream inputStream = getClass().getClassLoader().getResourceAsStream(jsonFile);
assumeThat("File " + jsonFile + " does not exist", inputStream, is(notNullValue()));
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(inputStream);
assertNotNull(series, "BarSeries should be loaded successfully with deserializer");
assertTrue(series.getBarCount() > 0, "BarSeries should contain bars");
assertEquals("CoinbaseData", series.getName(), "Series name should be set correctly");
// Verify first bar data
var firstBar = series.getBar(0);
assertNotNull(firstBar, "First bar should not be null");
assertTrue(firstBar.getClosePrice().doubleValue() > 0, "Close price should be positive");
assertTrue(firstBar.getVolume().doubleValue() > 0, "Volume should be positive");
}
@Test
public void testLoadBinanceInputStream() {
String jsonFile = "Binance-ETH-USD-PT5M-20230313_20230315.json";
InputStream inputStream = getClass().getClassLoader().getResourceAsStream(jsonFile);
assumeThat("File " + jsonFile + " does not exist", inputStream, is(notNullValue()));
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(inputStream);
assertNotNull(series, "BarSeries should be loaded successfully");
assertTrue(series.getBarCount() > 0, "BarSeries should contain bars");
// Verify first bar data
var firstBar = series.getBar(0);
assertNotNull(firstBar, "First bar should not be null");
assertTrue(firstBar.getClosePrice().doubleValue() > 0, "Close price should be positive");
assertTrue(firstBar.getVolume().doubleValue() > 0, "Volume should be positive");
}
@Test
public void testLoadNullInputStream() {
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries((InputStream) null);
assertNull(series, "Should return null for null input stream");
}
@Test
public void testLoadSeriesFromValidFile() {
String jsonFile = "Coinbase-ETH-USD-PT1D-20241105_20251020.json";
String resourcePath = Objects.requireNonNull(getClass().getClassLoader().getResource(jsonFile)).getPath();
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resourcePath);
assertNotNull(series, "BarSeries should be loaded successfully from file");
assertTrue(series.getBarCount() > 0, "BarSeries should contain bars");
assertEquals("CoinbaseData", series.getName(), "Series name should be set correctly");
// Verify first bar data
var firstBar = series.getBar(0);
assertNotNull(firstBar, "First bar should not be null");
assertTrue(firstBar.getClosePrice().doubleValue() > 0, "Close price should be positive");
assertTrue(firstBar.getVolume().doubleValue() > 0, "Volume should be positive");
// Verify last bar data
var lastBar = series.getBar(series.getBarCount() - 1);
assertNotNull(lastBar, "Last bar should not be null");
assertTrue(lastBar.getClosePrice().doubleValue() > 0, "Last bar close price should be positive");
assertTrue(lastBar.getVolume().doubleValue() > 0, "Last bar volume should be positive");
}
@Test
public void testLoadSeriesFromNonExistentFile() {
String nonExistentPath = "non-existent-file.json";
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(nonExistentPath);
assertNull(series, "Should return null for non-existent file");
}
@Test
public void testLoadSeriesFromNullFilename() {
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries((String) null);
assertNull(series, "Should return null for null filename");
}
@Test
public void testLoadSeriesFromEmptyFilename() {
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries("");
assertNull(series, "Should return null for empty filename");
}
@Test
public void testLoadSeriesFromInvalidJsonFile() {
// Create a temporary file with invalid JSON content in temp/ directory
String invalidJsonContent = "invalid json content";
Path tempDir = Paths.get("temp");
Path tempFile = null;
try {
// Ensure temp directory exists
Files.createDirectories(tempDir);
// Create temp file in temp/ directory
tempFile = tempDir.resolve("invalid-" + System.currentTimeMillis() + ".json");
Files.write(tempFile, invalidJsonContent.getBytes());
BarSeries series = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(tempFile.toString());
assertNull(series, "Should return null for invalid JSON file");
} catch (Exception e) {
fail("Unexpected exception during test setup: " + e.getMessage());
} finally {
// Clean up temporary file
if (tempFile != null) {
try {
Files.deleteIfExists(tempFile);
} catch (Exception e) {
// Ignore cleanup errors
}
}
}
}
@Test
public void testLoadSeriesWithStandardNamingPatternCoinbase() {
// Test loading Coinbase data using domain-driven interface with standard naming
// pattern
// Pattern: {Exchange}-{ticker}-{interval}-{startDate}_{endDate}.json
String expectedFile = "Coinbase-ETH-USD-PT1D-20241105_20251020.json";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2024-11-05T00:00:00Z");
Instant end = Instant.parse("2025-10-20T23:59:59Z");
BarSeries series = dataSource.loadSeries("ETH-USD", Duration.ofDays(1), start, end);
assertNotNull(series, "Should load series using standard naming pattern with Coinbase prefix");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
assertEquals("CoinbaseData", series.getName(), "Series name should be set correctly");
}
@Test
public void testLoadSeriesWithStandardNamingPatternBinance() {
// Test loading Binance data using domain-driven interface with standard naming
// pattern
String expectedFile = "Binance-ETH-USD-PT5M-20230313_20230315.json";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2023-03-13T00:00:00Z");
Instant end = Instant.parse("2023-03-15T23:59:59Z");
BarSeries series = dataSource.loadSeries("ETH-USD", Duration.ofMinutes(5), start, end);
assertNotNull(series, "Should load series using standard naming pattern with Binance prefix");
assertTrue(series.getBarCount() > 0, "Series should contain bars");
}
@Test
public void testLoadSeriesWithNonExistentTicker() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2024-01-01T00:00:00Z");
Instant end = Instant.parse("2024-12-31T23:59:59Z");
BarSeries series = dataSource.loadSeries("NONEXISTENT-USD", Duration.ofDays(1), start, end);
assertNull(series, "Should return null for non-existent ticker");
}
@Test
public void testLoadSeriesWithInvalidDateRange() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2024-12-31T23:59:59Z");
Instant end = Instant.parse("2024-01-01T00:00:00Z"); // End before start
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("ETH-USD", Duration.ofDays(1), start, end);
}, "Should throw IllegalArgumentException when end date is before start date");
}
@Test
public void testLoadSeriesWithNullTicker() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2024-01-01T00:00:00Z");
Instant end = Instant.parse("2024-12-31T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries(null, Duration.ofDays(1), start, end);
}, "Should throw IllegalArgumentException for null ticker");
}
@Test
public void testLoadSeriesWithEmptyTicker() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2024-01-01T00:00:00Z");
Instant end = Instant.parse("2024-12-31T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("", Duration.ofDays(1), start, end);
}, "Should throw IllegalArgumentException for empty ticker");
}
@Test
public void testLoadSeriesWithInvalidInterval() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
Instant start = Instant.parse("2024-01-01T00:00:00Z");
Instant end = Instant.parse("2024-12-31T23:59:59Z");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("ETH-USD", Duration.ZERO, start, end);
}, "Should throw IllegalArgumentException for zero interval");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("ETH-USD", Duration.ofSeconds(-1), start, end);
}, "Should throw IllegalArgumentException for negative interval");
}
@Test
public void testLoadSeriesWithNullDates() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("ETH-USD", Duration.ofDays(1), null, Instant.now());
}, "Should throw IllegalArgumentException for null start date");
assertThrows(IllegalArgumentException.class, () -> {
dataSource.loadSeries("ETH-USD", Duration.ofDays(1), Instant.now(), null);
}, "Should throw IllegalArgumentException for null end date");
}
@Test
public void testGetSourceName() {
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
assertEquals("", dataSource.getSourceName(),
"Should return empty string as JSON files use exchange-specific prefixes (Coinbase-, Binance-)");
}
@Test
public void testGetSourceNameUsedInFileSearchPattern() {
// Verify that getSourceName() returns empty string and files use exchange
// prefixes
JsonFileBarSeriesDataSource dataSource = new JsonFileBarSeriesDataSource();
String sourceName = dataSource.getSourceName();
assertTrue(sourceName.isEmpty(),
"Source name should be empty for JSON files (exchange-specific prefixes used)");
// Test that file search works with exchange prefixes (not source name prefix)
Instant start = Instant.parse("2024-11-05T00:00:00Z");
Instant end = Instant.parse("2025-10-20T23:59:59Z");
String expectedFile = "Coinbase-ETH-USD-PT1D-20241105_20251020.json";
InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(expectedFile);
assumeThat("File " + expectedFile + " does not exist", resourceStream, is(notNullValue()));
BarSeries series = dataSource.loadSeries("ETH-USD", Duration.ofDays(1), start, end);
assertNotNull(series, "Should find file using exchange prefix (not source name prefix)");
assertTrue(expectedFile.startsWith("Coinbase-") || expectedFile.startsWith("Binance-"),
"Expected file should use exchange prefix, not source name prefix");
}
}
@@ -0,0 +1,86 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.json;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import java.time.Duration;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class AdaptiveBarSeriesTypeAdapterTest {
private final Gson gson = new GsonBuilder().registerTypeAdapter(BarSeries.class, new AdaptiveBarSeriesTypeAdapter())
.create();
@Test
void coinbaseCandlesUseActualInterval() {
String coinbaseJson = """
{
"candles": [
{"start":"1700000600","open":"2.0","high":"2.5","low":"1.8","close":"2.1","volume":"300"},
{"start":"1700000000","open":"1.0","high":"1.5","low":"0.9","close":"1.4","volume":"100"},
{"start":"1700000300","open":"1.4","high":"1.8","low":"1.2","close":"1.7","volume":"200"}
]
}
""";
BarSeries series = gson.fromJson(coinbaseJson, BarSeries.class);
assertNotNull(series, "Series should be created");
assertEquals(3, series.getBarCount(), "Three bars expected");
Duration expectedDuration = Duration.ofMinutes(5);
long[] expectedStartEpochSeconds = { 1700000000L, 1700000300L, 1700000600L };
for (int i = 0; i < series.getBarCount(); i++) {
var bar = series.getBar(i);
Instant expectedStart = Instant.ofEpochSecond(expectedStartEpochSeconds[i]);
Instant expectedEnd = expectedStart.plus(expectedDuration);
assertEquals(expectedDuration, bar.getTimePeriod(), "Bar " + i + " should preserve the 5-minute interval");
assertEquals(expectedStart, bar.getBeginTime(), "Bar " + i + " should begin at the candle start timestamp");
assertEquals(expectedEnd, bar.getEndTime(), "Bar " + i + " should end at start plus the detected interval");
}
}
@Test
void binanceCandlesUseActualInterval() {
String binanceJson = """
{
"name": "ETH/USD_PT5M@BinanceUS",
"ohlc": [
{"endTime":1700000600000,"openPrice":102.0,"highPrice":105.0,"lowPrice":101.0,"closePrice":104.0,"volume":20.0,"amount":0.1},
{"endTime":1700000000000,"openPrice":100.0,"highPrice":103.0,"lowPrice":99.0,"closePrice":102.0,"volume":10.0,"amount":0.05},
{"endTime":1700000300000,"openPrice":102.0,"highPrice":104.0,"lowPrice":101.0,"closePrice":103.0,"volume":15.0,"amount":0.08}
]
}
""";
BarSeries series = gson.fromJson(binanceJson, BarSeries.class);
assertNotNull(series, "Series should be created");
assertEquals("ETH/USD_PT5M@BinanceUS", series.getName(), "Series name should be preserved");
assertEquals(3, series.getBarCount(), "Three bars expected");
Duration expectedDuration = Duration.ofMinutes(5);
long[] expectedEndEpochMillis = { 1700000000000L, 1700000300000L, 1700000600000L };
for (int i = 0; i < series.getBarCount(); i++) {
var bar = series.getBar(i);
Instant expectedEnd = Instant.ofEpochMilli(expectedEndEpochMillis[i]);
Instant expectedStart = expectedEnd.minus(expectedDuration);
assertEquals(expectedDuration, bar.getTimePeriod(), "Bar " + i + " should preserve the 5-minute interval");
assertEquals(expectedStart, bar.getBeginTime(),
"Bar " + i + " should begin at end minus the detected interval");
assertEquals(expectedEnd, bar.getEndTime(), "Bar " + i + " should end at the candle timestamp");
}
}
}
@@ -0,0 +1,121 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.datasources.json;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.Appender;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.WriterAppender;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.LoggerConfig;
import org.apache.logging.log4j.core.layout.PatternLayout;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.utils.DeprecationNotifier;
import java.io.StringWriter;
import java.time.Duration;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SuppressWarnings({ "deprecation", "removal" })
class DeprecatedJsonDataSourcesNotifierTest {
private LoggerContext loggerContext;
private LoggerConfig loggerConfig;
private Level originalLevel;
private Appender appender;
private StringWriter logOutput;
@BeforeEach
void setUp() {
DeprecationNotifier.resetForTests();
loggerContext = (LoggerContext) LogManager.getContext(false);
Configuration config = loggerContext.getConfiguration();
loggerConfig = config.getLoggerConfig("org.ta4j.core.utils.DeprecationNotifier");
originalLevel = loggerConfig.getLevel();
logOutput = new StringWriter();
PatternLayout layout = PatternLayout.newBuilder().withPattern("%msg%n").build();
appender = WriterAppender.newBuilder()
.setTarget(logOutput)
.setLayout(layout)
.setName("DeprecatedJsonDataSourcesNotifierAppender")
.build();
appender.start();
loggerConfig.addAppender(appender, Level.WARN, null);
loggerConfig.setLevel(Level.WARN);
loggerContext.updateLoggers();
}
@AfterEach
void tearDown() {
if (loggerConfig != null && appender != null) {
loggerConfig.removeAppender(appender.getName());
appender.stop();
}
if (loggerConfig != null && originalLevel != null) {
loggerConfig.setLevel(originalLevel);
}
if (loggerContext != null) {
loggerContext.updateLoggers();
}
DeprecationNotifier.resetForTests();
}
@Test
void deprecatedJsonHelpersEmitMigrationWarnings() {
BarSeries series = buildSeries();
JsonBarsSerializer.loadSeries((java.io.InputStream) null);
GsonBarSeries.from(series);
GsonBarData.from(series.getBar(0));
String logContent = logOutput.toString();
assertTrue(logContent.contains("ta4jexamples.datasources.json.JsonBarsSerializer is deprecated"));
assertTrue(logContent.contains("ta4jexamples.datasources.json.GsonBarSeries is deprecated"));
assertTrue(logContent.contains("ta4jexamples.datasources.json.GsonBarData is deprecated"));
assertTrue(logContent.contains("Use ta4jexamples.datasources.json.JsonFileBarSeriesDataSource instead."));
assertTrue(logContent.contains("scheduled for removal in 0.24.0"));
assertEquals(1, countOccurrences(logContent, "ta4jexamples.datasources.json.JsonBarsSerializer is deprecated"));
assertEquals(1, countOccurrences(logContent, "ta4jexamples.datasources.json.GsonBarSeries is deprecated"));
assertEquals(1, countOccurrences(logContent, "ta4jexamples.datasources.json.GsonBarData is deprecated"));
}
private static BarSeries buildSeries() {
BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder().withName("deprecated-json-series");
BarSeries series = builder.build();
series.barBuilder()
.timePeriod(Duration.ofDays(1))
.endTime(Instant.parse("2024-01-01T00:00:00Z"))
.openPrice(100)
.highPrice(110)
.lowPrice(90)
.closePrice(105)
.volume(1000)
.amount(105000)
.add();
return series;
}
private static int countOccurrences(String text, String pattern) {
int count = 0;
int index = 0;
while ((index = text.indexOf(pattern, index)) >= 0) {
count++;
index += pattern.length();
}
return count;
}
}
@@ -0,0 +1,213 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.doc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* Regression tests for {@link ReadmeContentManager} line-ending behavior.
*
* <p>
* The README updater should preserve the dominant line separator already used
* in the target README file, regardless of source snippet line endings.
* </p>
*/
public class ReadmeContentManagerTest {
private static final String[] SNIPPET_IDS = { "ema-crossover", "rsi-strategy", "strategy-performance",
"advanced-strategy", "serialize-indicator", "serialize-rule", "serialize-strategy" };
@TempDir
private Path tempDir;
@Test
public void testUpdateReadmeSnippetsPreservesLfLineEndings() throws IOException {
String lineSeparator = "\n";
Path sourceFile = tempDir.resolve("ReadmeContentManager.java");
Path readmeFile = tempDir.resolve("README.md");
Files.writeString(sourceFile, buildSourceSnippets(lineSeparator), StandardCharsets.UTF_8);
Files.writeString(readmeFile, buildReadmeTemplate(lineSeparator), StandardCharsets.UTF_8);
boolean updated = ReadmeContentManager.updateReadmeSnippets(readmeFile, sourceFile);
assertTrue(updated);
String updatedContent = Files.readString(readmeFile, StandardCharsets.UTF_8);
LineEndingCounts counts = countLineEndings(updatedContent);
assertTrue(counts.lf() > 0);
assertEquals(0, counts.crlf());
assertTrue(updatedContent.contains(snippetBlock("ema-crossover", "ema_crossover", 1, lineSeparator)));
}
@Test
public void testUpdateReadmeSnippetsPreservesCrLfLineEndings() throws IOException {
String lineSeparator = "\r\n";
Path sourceFile = tempDir.resolve("ReadmeContentManager.java");
Path readmeFile = tempDir.resolve("README.md");
Files.writeString(sourceFile, buildSourceSnippets("\n"), StandardCharsets.UTF_8);
Files.writeString(readmeFile, buildReadmeTemplate(lineSeparator), StandardCharsets.UTF_8);
boolean updated = ReadmeContentManager.updateReadmeSnippets(readmeFile, sourceFile);
assertTrue(updated);
String updatedContent = Files.readString(readmeFile, StandardCharsets.UTF_8);
LineEndingCounts counts = countLineEndings(updatedContent);
assertTrue(counts.crlf() > 0);
assertEquals(0, counts.lf());
assertTrue(updatedContent.contains(snippetBlock("ema-crossover", "ema_crossover", 1, lineSeparator)));
}
@Test
public void testRepositoryJavaBaselineDocumentationAndWorkflowsStayAligned() throws IOException {
Path repositoryRoot = findRepositoryRoot();
String pom = readString(repositoryRoot.resolve("pom.xml"));
String readme = readString(repositoryRoot.resolve("README.md"));
String contributing = readString(repositoryRoot.resolve(".github").resolve("CONTRIBUTING.md"));
Map<String, String> expectedActionPins = Map.ofEntries(
Map.entry("actions/setup-java@", "actions/setup-java@v5"),
Map.entry("actions/checkout@", "actions/checkout@v6"), Map.entry("actions/cache@", "actions/cache@v5"),
Map.entry("actions/upload-artifact@", "actions/upload-artifact@v7"),
Map.entry("actions/github-script@", "actions/github-script@v9"),
Map.entry("softprops/action-gh-release@", "softprops/action-gh-release@v3"),
Map.entry("rhysd/actionlint@", "rhysd/actionlint@v1.7.12"));
List<String> forbiddenActionPins = List.of("actions/checkout@v5", "actions/cache@v4",
"actions/upload-artifact@v4", "actions/github-script@v8", "softprops/action-gh-release@v2",
"rhysd/actionlint@v1.7.9");
List<Path> setupJavaWorkflows = new ArrayList<>();
assertTrue(pom.contains("<maven.compiler.release>25</maven.compiler.release>"));
assertTrue(pom.contains("<requireJavaVersion>"));
assertTrue(pom.contains("<version>[25,)</version>"));
assertTrue(readme.contains("JDK-25%2B"));
assertTrue(readme.contains("Java 25+"));
assertFalse(readme.contains("./mvnw"));
assertFalse(readme.contains("mvnw.cmd"));
assertTrue(contributing.contains("Java 25+"));
try (Stream<Path> workflowPaths = Files.list(repositoryRoot.resolve(".github").resolve("workflows"))) {
workflowPaths.filter(path -> path.getFileName().toString().endsWith(".yml")).forEach(path -> {
String workflow = readString(path);
if (workflow.contains("actions/setup-java")) {
setupJavaWorkflows.add(path);
assertTrue(workflow.contains("java-version: 25"), path + " should set up Java 25");
assertFalse(workflow.contains("java-version: 21"), path + " should not set up Java 21");
}
expectedActionPins.forEach((actionPrefix, expectedPin) -> {
if (workflow.contains(actionPrefix)) {
assertTrue(workflow.contains(expectedPin), path + " should use " + expectedPin);
}
});
forbiddenActionPins.forEach((forbiddenPin) -> assertFalse(workflow.contains(forbiddenPin),
path + " should not pin " + forbiddenPin));
if (path.getFileName().toString().equals("github-release.yml")) {
assertTrue(workflow.contains("path: workflow-support"),
path + " should stage workflow support files separately from the release tag checkout");
assertTrue(workflow.contains("workflow-support/scripts/release/release_helpers.py"), path
+ " should validate artifacts with workflow support files, not the checked-out tag tree");
}
});
}
assertFalse(setupJavaWorkflows.isEmpty());
}
private static String buildSourceSnippets(String lineSeparator) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < SNIPPET_IDS.length; i++) {
String snippetId = SNIPPET_IDS[i];
String variableName = snippetId.replace('-', '_');
builder.append("// START_SNIPPET: ").append(snippetId).append(lineSeparator);
builder.append(" int ")
.append(variableName)
.append(" = ")
.append(i + 1)
.append(";")
.append(lineSeparator);
builder.append("// END_SNIPPET: ").append(snippetId).append(lineSeparator).append(lineSeparator);
}
return builder.toString();
}
private static String buildReadmeTemplate(String lineSeparator) {
StringBuilder builder = new StringBuilder();
builder.append("# README").append(lineSeparator).append(lineSeparator);
for (String snippetId : SNIPPET_IDS) {
builder.append("<!-- START_SNIPPET: ").append(snippetId).append(" -->").append(lineSeparator);
builder.append("```java").append(lineSeparator);
builder.append("old").append(lineSeparator);
builder.append("```").append(lineSeparator);
builder.append("<!-- END_SNIPPET: ")
.append(snippetId)
.append(" -->")
.append(lineSeparator)
.append(lineSeparator);
}
return builder.toString();
}
private static String snippetBlock(String snippetId, String variableName, int value, String lineSeparator) {
StringBuilder builder = new StringBuilder();
builder.append("<!-- START_SNIPPET: ").append(snippetId).append(" -->").append(lineSeparator);
builder.append("```java").append(lineSeparator);
builder.append("int ").append(variableName).append(" = ").append(value).append(";").append(lineSeparator);
builder.append("```").append(lineSeparator);
builder.append("<!-- END_SNIPPET: ").append(snippetId).append(" -->");
return builder.toString();
}
private static LineEndingCounts countLineEndings(String content) {
int crlf = 0;
int lf = 0;
for (int i = 0; i < content.length(); i++) {
if (content.charAt(i) == '\n') {
if (i > 0 && content.charAt(i - 1) == '\r') {
crlf++;
} else {
lf++;
}
}
}
return new LineEndingCounts(crlf, lf);
}
private static Path findRepositoryRoot() throws IOException {
Path current = Path.of("").toAbsolutePath();
Path candidate = current;
while (candidate != null) {
if (Files.exists(candidate.resolve("pom.xml")) && Files.exists(candidate.resolve("README.md"))
&& Files.isDirectory(candidate.resolve(".github"))) {
return candidate;
}
candidate = candidate.getParent();
}
throw new IOException("Unable to locate repository root from " + current);
}
private static String readString(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private record LineEndingCounts(int crlf, int lf) {
}
}
@@ -0,0 +1,623 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.doc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* Regression coverage for {@link RemovalReadyDeprecationScanner}.
*/
public class RemovalReadyDeprecationScannerTest {
@TempDir
private Path tempDir;
@Test
public void testMatchingSnapshotDetection() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/LegacyBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class LegacyBridge {
@Deprecated(since = "0.20.0", forRemoval = true)
public static void bridge() {
}
}
""");
writeJava("ta4j-examples/src/main/java/ta4jexamples/legacy/FutureBridge.java", """
package ta4jexamples.legacy;
/**
* @deprecated Scheduled for removal in 0.25.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class FutureBridge {
}
""");
JsonObject report = runScanner();
assertEquals(1, report.get("schemaVersion").getAsInt());
assertEquals("ta4j:deprecation-removal", report.get("automationNamespace").getAsString());
assertEquals("0.24.0-SNAPSHOT", report.get("snapshotVersion").getAsString());
assertEquals(2, report.get("findingCount").getAsInt());
assertEquals(1, report.get("issuePlanCount").getAsInt());
JsonObject plan = report.getAsJsonArray("issuePlans").get(0).getAsJsonObject();
assertEquals("grouped-file-removal", plan.get("planKind").getAsString());
assertEquals("ta4j-core", plan.get("module").getAsString());
assertEquals("ta4j-core/src/main/java/org/ta4j/core/legacy/LegacyBridge.java",
plan.get("filePath").getAsString());
assertEquals("Remove 0.24.0-ready deprecations in LegacyBridge.java", plan.get("issueTitle").getAsString());
assertFalse(plan.get("issueBody").getAsString().contains(":symbol:v1"));
JsonArray symbols = plan.getAsJsonArray("symbols");
assertEquals(
"ta4j:deprecation-removal:symbol:v1|0.24.0|ta4j-core|"
+ "ta4j-core/src/main/java/org/ta4j/core/legacy/LegacyBridge.java|class|LegacyBridge",
symbols.get(0).getAsJsonObject().get("trackingKey").getAsString());
assertEquals("LegacyBridge", symbols.get(0).getAsJsonObject().get("signature").getAsString());
assertEquals(
"ta4j:deprecation-removal:symbol:v1|0.24.0|ta4j-core|"
+ "ta4j-core/src/main/java/org/ta4j/core/legacy/LegacyBridge.java|method|bridge()",
symbols.get(1).getAsJsonObject().get("trackingKey").getAsString());
assertEquals("bridge()", symbols.get(1).getAsJsonObject().get("signature").getAsString());
assertSymbolNames(plan.getAsJsonArray("symbols"), "LegacyBridge", "bridge");
assertTrue(Files.readString(tempDir.resolve("report.md")).contains("LegacyBridge"));
}
@Test
public void testNotifierVersionDetection() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/NotifierBridge.java",
"""
package org.ta4j.core.legacy;
import org.ta4j.core.utils.DeprecationNotifier;
@Deprecated(since = "0.20.0", forRemoval = true)
public class NotifierBridge {
{
DeprecationNotifier.warnOnce(NotifierBridge.class, "org.ta4j.core.modern.NotifierBridge", "0.24.0");
}
}
""");
JsonObject report = runScanner();
assertEquals(1, report.get("findingCount").getAsInt());
JsonObject plan = report.getAsJsonArray("issuePlans").get(0).getAsJsonObject();
assertSymbolNames(plan.getAsJsonArray("symbols"), "NotifierBridge");
}
@Test
public void testMixedVersionsInOneFile() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/MixedRemovalBridge.java", """
package org.ta4j.core.legacy;
public class MixedRemovalBridge {
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public void removeNow() {
}
/**
* @deprecated Scheduled for removal in 0.25.0.
*/
@Deprecated(since = "0.21.0", forRemoval = true)
public void removeLater() {
}
}
""");
JsonObject report = runScanner();
assertEquals(1, report.get("findingCount").getAsInt());
JsonObject plan = report.getAsJsonArray("issuePlans").get(0).getAsJsonObject();
assertSymbolNames(plan.getAsJsonArray("symbols"), "removeNow");
assertFalse(Files.readString(tempDir.resolve("report.md")).contains("removeLater"));
}
@Test
public void testNextJavadocDoesNotLeakBackwards() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/AdjacentJavadocBridge.java", """
package org.ta4j.core.legacy;
public class AdjacentJavadocBridge {
@Deprecated(since = "0.20.0", forRemoval = true)
public void unscheduled() {
}
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.21.0", forRemoval = true)
public void removeNow() {
}
}
""");
JsonObject report = runScanner();
JsonObject plan = firstPlan(report);
assertSymbolNames(plan.getAsJsonArray("symbols"), "removeNow");
assertSymbolNames(report.getAsJsonArray("unscheduledSymbols"), "unscheduled");
}
@Test
public void testTypeInheritanceResetsBetweenTypes() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/LegacyBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class LegacyBridge {
@Deprecated(since = "0.20.0", forRemoval = true)
public void removeNow() {
}
}
@Deprecated(since = "0.21.0", forRemoval = true)
class UnscheduledBridge {
@Deprecated(since = "0.21.0", forRemoval = true)
void keepAround() {
}
}
""");
JsonObject report = runScanner();
assertEquals(2, report.get("findingCount").getAsInt());
JsonObject plan = firstPlan(report);
assertSymbolNames(plan.getAsJsonArray("symbols"), "LegacyBridge", "removeNow");
assertSymbolNames(report.getAsJsonArray("unscheduledSymbols"), "UnscheduledBridge", "keepAround");
}
@Test
public void testInitializedFieldIsClassifiedAsField() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/LegacyFieldHolder.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class LegacyFieldHolder {
@Deprecated(since = "0.20.0", forRemoval = true)
public static final LegacyFieldHolder LEGACY = createLegacy();
private static LegacyFieldHolder createLegacy() {
return new LegacyFieldHolder();
}
}
""");
JsonObject plan = firstPlan(runScanner());
JsonArray symbols = plan.getAsJsonArray("symbols");
JsonObject field = symbols.get(1).getAsJsonObject();
assertEquals("LEGACY", field.get("name").getAsString());
assertEquals("field", field.get("kind").getAsString());
assertFalse(Files.readString(tempDir.resolve("report.md")).contains("createLegacy"));
}
@Test
public void testTargetVersionIncludesOverdueFindingsWhenRequested() throws IOException {
writePom("<version>0.24.1-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/OverdueBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class OverdueBridge {
}
""");
JsonObject report = runScanner("--target-removal-version", "0.24.1", "--include-overdue");
assertEquals("0.24.1", report.get("removalVersion").getAsString());
assertEquals(1, report.get("overdueFindingCount").getAsInt());
assertEquals(1, report.get("findingCount").getAsInt());
JsonObject symbol = firstPlan(report).getAsJsonArray("symbols").get(0).getAsJsonObject();
assertEquals("overdue", symbol.get("status").getAsString());
}
@Test
public void testTargetVersionIncludesOverdueFindingsAcrossSemanticVersionJumps() throws IOException {
writePom("<version>1.2.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/MajorJumpBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.99.9.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class MajorJumpBridge {
}
""");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/MinorJumpBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 1.1.5.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class MinorJumpBridge {
}
""");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/CurrentBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 1.2.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class CurrentBridge {
}
""");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/FutureBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 2.0.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class FutureBridge {
}
""");
JsonObject report = runScanner("--target-removal-version", "1.2.0", "--include-overdue");
assertEquals(3, report.get("findingCount").getAsInt());
assertEquals(1, report.get("dueFindingCount").getAsInt());
assertEquals(2, report.get("overdueFindingCount").getAsInt());
assertEquals(1, report.get("futureFindingCount").getAsInt());
assertEquals(3, report.get("issuePlanCount").getAsInt());
List<String> statuses = report.getAsJsonArray("issuePlans")
.asList()
.stream()
.flatMap(plan -> plan.getAsJsonObject().getAsJsonArray("symbols").asList().stream())
.map(symbol -> symbol.getAsJsonObject().get("status").getAsString())
.toList();
assertEquals(List.of("due", "overdue", "overdue"), statuses);
}
@Test
public void testTargetVersionExcludesOverdueFindingsByDefault() throws IOException {
writePom("<version>0.24.1-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/OverdueBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class OverdueBridge {
}
""");
JsonObject report = runScanner("--target-removal-version", "0.24.1");
assertEquals(1, report.get("overdueFindingCount").getAsInt());
assertEquals(0, report.get("findingCount").getAsInt());
assertEquals(0, report.get("issuePlanCount").getAsInt());
}
@Test
public void testFailOnDueReturnsNonZeroAfterWritingReport() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/DueBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class DueBridge {
}
""");
Path outputJson = tempDir.resolve("report.json");
Path outputMarkdown = tempDir.resolve("report.md");
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
int exitCode = RemovalReadyDeprecationScanner.run(
scannerArgs(outputJson, outputMarkdown, "--target-removal-version", "0.24.0", "--fail-on-due"),
new PrintStream(new ByteArrayOutputStream()), new PrintStream(stderr));
assertEquals(2, exitCode);
assertTrue(Files.exists(outputJson));
assertTrue(Files.exists(outputMarkdown));
assertTrue(stderr.toString(StandardCharsets.UTF_8).contains("deprecation gate failed"));
}
@Test
public void testStdoutSummaryIncludesLifecycleCounts() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/DueBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class DueBridge {
}
""");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/FutureBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.25.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class FutureBridge {
}
""");
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
int exitCode = RemovalReadyDeprecationScanner.run(
scannerArgs(tempDir.resolve("report.json"), tempDir.resolve("report.md"), "--fail-on-due"),
new PrintStream(stdout), new PrintStream(new ByteArrayOutputStream()));
assertEquals(2, exitCode);
String summaryText = stdout.toString(StandardCharsets.UTF_8);
assertTrue(summaryText.startsWith("{\"schemaVersion\""));
JsonObject summary = JsonParser.parseString(summaryText).getAsJsonObject();
assertEquals(1, summary.get("schemaVersion").getAsInt());
assertEquals("ta4j:deprecation-removal", summary.get("automationNamespace").getAsString());
assertEquals(1, summary.get("findingCount").getAsInt());
assertEquals(1, summary.get("dueFindingCount").getAsInt());
assertEquals(1, summary.get("futureFindingCount").getAsInt());
assertEquals(1, summary.get("blockingFindingCount").getAsInt());
}
@Test
public void testUnscheduledForRemovalIsReportedButNotPlanned() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/UnscheduledBridge.java", """
package org.ta4j.core.legacy;
@Deprecated(
since = "0.20.0",
forRemoval = true
)
public class UnscheduledBridge {
}
""");
JsonObject report = runScanner();
assertEquals(0, report.get("issuePlanCount").getAsInt());
assertEquals(1, report.get("unscheduledFindingCount").getAsInt());
assertEquals(
"ta4j:deprecation-removal:symbol:v1|unscheduled|ta4j-core|"
+ "ta4j-core/src/main/java/org/ta4j/core/legacy/UnscheduledBridge.java|class|UnscheduledBridge",
report.getAsJsonArray("unscheduledSymbols").get(0).getAsJsonObject().get("trackingKey").getAsString());
assertSymbolNames(report.getAsJsonArray("unscheduledSymbols"), "UnscheduledBridge");
}
@Test
public void testMultilineAnnotationAndCommentFalsePositives() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/MultilineBridge.java", """
package org.ta4j.core.legacy;
public class MultilineBridge {
// @Deprecated(since = "0.20.0", forRemoval = true)
// Scheduled for removal in 0.24.0.
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(
since = "0.20.0",
forRemoval =
true
)
public void removeNow() {
}
}
""");
JsonObject report = runScanner();
assertEquals(1, report.get("findingCount").getAsInt());
assertSymbolNames(firstPlan(report).getAsJsonArray("symbols"), "removeNow");
}
@Test
public void testNestedDeprecatedTypeResetsInheritedVersions() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/NestedBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class NestedBridge {
@Deprecated(since = "0.20.0", forRemoval = true)
public void removeOuterMember() {
}
@Deprecated(since = "0.20.0", forRemoval = true)
static class UnscheduledNestedBridge {
@Deprecated(since = "0.20.0", forRemoval = true)
void keepNestedMember() {
}
}
}
""");
JsonObject report = runScanner();
assertEquals(2, report.get("findingCount").getAsInt());
assertEquals(2, report.get("unscheduledFindingCount").getAsInt());
assertSymbolNames(firstPlan(report).getAsJsonArray("symbols"), "NestedBridge", "removeOuterMember");
}
@Test
public void testIssuePlanIncludesVersionMarkerAndReplacementHint() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/ReplacementBridge.java",
"""
package org.ta4j.core.legacy;
import org.ta4j.core.utils.DeprecationNotifier;
/**
* @deprecated Use {@link ModernBridge}. Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class ReplacementBridge {
{
DeprecationNotifier.warnOnce(ReplacementBridge.class, "org.ta4j.core.legacy.ModernBridge", "0.24.0");
}
}
""");
JsonObject plan = firstPlan(runScanner());
assertTrue(plan.get("issueMarker").getAsString().contains("version=0.24.0"));
assertTrue(plan.get("issueBody").getAsString().contains("org.ta4j.core.legacy.ModernBridge"));
}
@Test
public void testWorktreePathPrefixDoesNotHideRepoSources() throws IOException {
Path repoRoot = tempDir.resolve(".agents/worktrees/scanner-checkout");
Files.createDirectories(repoRoot);
Files.writeString(repoRoot.resolve("pom.xml"), "<project><version>0.24.0-SNAPSHOT</version></project>",
StandardCharsets.UTF_8);
Path javaFile = repoRoot.resolve("ta4j-core/src/main/java/org/ta4j/core/legacy/WorktreeBridge.java");
Files.createDirectories(javaFile.getParent());
Files.writeString(javaFile, """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 0.24.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class WorktreeBridge {
}
""", StandardCharsets.UTF_8);
Path outputJson = repoRoot.resolve("report.json");
Path outputMarkdown = repoRoot.resolve("report.md");
int exitCode = RemovalReadyDeprecationScanner.run(
new String[] { "--repo-root", repoRoot.toString(), "--output-json", outputJson.toString(),
"--output-md", outputMarkdown.toString() },
new PrintStream(new ByteArrayOutputStream()), new PrintStream(new ByteArrayOutputStream()));
assertEquals(0, exitCode);
JsonObject report = JsonParser.parseString(Files.readString(outputJson)).getAsJsonObject();
assertEquals(1, report.get("findingCount").getAsInt());
assertTrue(Files.readString(outputMarkdown).contains("WorktreeBridge"));
}
@Test
public void testRejectsNonSnapshotVersion() throws IOException {
writePom("<version>0.24.0</version>");
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
int exitCode = RemovalReadyDeprecationScanner.run(
new String[] { "--repo-root", tempDir.toString(), "--output-json",
tempDir.resolve("report.json").toString(), "--output-md",
tempDir.resolve("report.md").toString() },
new PrintStream(new ByteArrayOutputStream()), new PrintStream(stderr));
assertEquals(1, exitCode);
assertTrue(stderr.toString(StandardCharsets.UTF_8).contains("expected a SNAPSHOT version"));
}
@Test
public void testRejectsOutOfRangeVersionComponentsWithClearError() throws IOException {
writePom("<version>0.24.0-SNAPSHOT</version>");
writeJava("ta4j-core/src/main/java/org/ta4j/core/legacy/LargeVersionBridge.java", """
package org.ta4j.core.legacy;
/**
* @deprecated Scheduled for removal in 999999999999.0.0.
*/
@Deprecated(since = "0.20.0", forRemoval = true)
public class LargeVersionBridge {
}
""");
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
int exitCode = RemovalReadyDeprecationScanner.run(scannerArgs(tempDir.resolve("report.json"),
tempDir.resolve("report.md"), "--target-removal-version", "0.24.0"),
new PrintStream(new ByteArrayOutputStream()), new PrintStream(stderr));
assertEquals(1, exitCode);
assertTrue(stderr.toString(StandardCharsets.UTF_8).contains("version components must fit in an integer"));
}
private JsonObject runScanner(String... extraArgs) throws IOException {
Path outputJson = tempDir.resolve("report.json");
Path outputMarkdown = tempDir.resolve("report.md");
int exitCode = RemovalReadyDeprecationScanner.run(scannerArgs(outputJson, outputMarkdown, extraArgs),
new PrintStream(new ByteArrayOutputStream()), new PrintStream(new ByteArrayOutputStream()));
assertEquals(0, exitCode);
return JsonParser.parseString(Files.readString(outputJson, StandardCharsets.UTF_8)).getAsJsonObject();
}
private String[] scannerArgs(Path outputJson, Path outputMarkdown, String... extraArgs) {
String[] baseArgs = { "--repo-root", tempDir.toString(), "--output-json", outputJson.toString(), "--output-md",
outputMarkdown.toString() };
String[] args = new String[baseArgs.length + extraArgs.length];
System.arraycopy(baseArgs, 0, args, 0, baseArgs.length);
System.arraycopy(extraArgs, 0, args, baseArgs.length, extraArgs.length);
return args;
}
private JsonObject firstPlan(JsonObject report) {
return report.getAsJsonArray("issuePlans").get(0).getAsJsonObject();
}
private void assertSymbolNames(JsonArray symbols, String... expectedNames) {
assertEquals(expectedNames.length, symbols.size());
for (int index = 0; index < expectedNames.length; index++) {
assertEquals(expectedNames[index], symbols.get(index).getAsJsonObject().get("name").getAsString());
}
}
private void writePom(String versionLine) throws IOException {
Files.writeString(tempDir.resolve("pom.xml"), "<project>" + System.lineSeparator() + " " + versionLine
+ System.lineSeparator() + "</project>" + System.lineSeparator(), StandardCharsets.UTF_8);
}
private void writeJava(String relativePath, String content) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, content, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,110 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.indicators;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
/**
* Regression coverage + opt-in perf harness for
* {@link CachedIndicatorBenchmark}.
*/
class CachedIndicatorBenchmarkTest {
private static final String BENCHMARK_PROPERTY = "ta4j.runBenchmarks";
private final CachedIndicatorBenchmark benchmark = new CachedIndicatorBenchmark();
@Test
void boundedEvictionScenarioProducesExpectedChecksums() {
int barCount = 64;
int maximumBarCountHint = 12;
CachedIndicatorBenchmark.ScenarioResult result = benchmark.runBoundedEvictionScenario(barCount,
maximumBarCountHint);
int endIndex = barCount - 1;
long expectedOperations = endIndex;
long expectedChecksum = expectedBoundedEvictionChecksum(endIndex);
assertEquals(expectedOperations, result.getOperations(), "Operations should match processed indices");
assertEquals(expectedChecksum, result.getChecksum(), "Checksum should reflect monotonic index values");
assertTrue(result.getThroughputOpsPerSecond() > 0d, "Throughput should be positive");
}
@Test
void concurrentCacheHitsScenarioReturnsStableCachedValue() {
int barCount = 32;
int threads = 2;
int readsPerThread = 500;
CachedIndicatorBenchmark.ScenarioResult result = benchmark.runConcurrentCacheHitsScenario(barCount, threads,
readsPerThread);
int hitIndex = Math.max(0, barCount - 2);
long expectedOperations = (long) threads * readsPerThread;
long expectedChecksum = expectedOperations * hitIndex;
assertEquals(expectedOperations, result.getOperations(), "All concurrent reads should be counted");
assertEquals(expectedChecksum, result.getChecksum(), "Cached indicator should always return the hit index");
assertTrue(result.getThroughputOpsPerSecond() > 0d, "Concurrent scenario should report throughput");
}
@Test
void lastBarHotReadsScenarioKeepsSmaStableAcrossHits() {
int barCount = 48;
int smaPeriod = 8;
int reads = 750;
CachedIndicatorBenchmark.ScenarioResult result = benchmark.runLastBarHotReadsScenario(barCount, smaPeriod,
reads);
var series = CachedIndicatorBenchmark.buildSeries(barCount);
var closePrice = new ClosePriceIndicator(series);
var sma = new SMAIndicator(closePrice, smaPeriod);
int endIndex = series.getEndIndex();
long expectedChecksum = (long) reads * sma.getValue(endIndex).hashCode();
assertEquals(reads, result.getOperations(), "Each SMA read should be counted");
assertEquals(expectedChecksum, result.getChecksum(), "Hot reads should return a stable cached SMA value");
assertTrue(result.getThroughputOpsPerSecond() > 0d, "Hot-read scenario should report throughput");
}
@Test
@Tag("benchmark")
@EnabledIfSystemProperty(named = BENCHMARK_PROPERTY, matches = "true")
void benchmarksRunWhenExplicitlyEnabled() throws Exception {
int threads = intProperty("ta4j.bench.cachedIndicator.threads", 8);
int batches = intProperty("ta4j.bench.cachedIndicator.batches", 1);
int evictionBars = intProperty("ta4j.bench.cachedIndicator.evictionBars", 50_000);
int cacheHitsPerThread = intProperty("ta4j.bench.cachedIndicator.cacheHitsPerThread", 100_000);
int lastBarReads = intProperty("ta4j.bench.cachedIndicator.lastBarReads", 100_000);
int maximumBarCountHint = intProperty("ta4j.bench.cachedIndicator.maxBarCountHint", 256);
int lastBarSmaPeriod = intProperty("ta4j.bench.cachedIndicator.lastBarSmaPeriod", 32);
CachedIndicatorBenchmark.main(new String[] { Integer.toString(threads), Integer.toString(batches),
Integer.toString(evictionBars), Integer.toString(cacheHitsPerThread), Integer.toString(lastBarReads),
Integer.toString(maximumBarCountHint), Integer.toString(lastBarSmaPeriod) });
}
private long expectedBoundedEvictionChecksum(int endIndex) {
// Sum of indices [0, endIndex)
return (long) (endIndex - 1) * endIndex / 2;
}
private static int intProperty(String key, int defaultValue) {
String value = System.getProperty(key);
if (value == null || value.isBlank()) {
return defaultValue;
}
return Integer.parseInt(value);
}
}
@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.logging;
import org.junit.Test;
public class StrategyExecutionLoggingTest {
@Test
public void test() {
StrategyExecutionLogging.main(null);
}
}
@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.num;
import org.junit.Test;
public class CompareNumTypesTest {
@Test
public void test() {
CompareNumTypes.main(null);
}
}
@@ -0,0 +1,105 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.rules;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.ta4j.core.rules.AndRule;
import org.ta4j.core.rules.FixedRule;
/**
* Regression coverage + opt-in perf harness for {@link RuleNameBenchmark}.
*/
class RuleNameBenchmarkTest {
private static final String BENCHMARK_PROPERTY = "ta4j.runBenchmarks";
@Test
void eagerAndRuleTouchesChildNamesOnceAndCachesResult() {
CountingRule left = new CountingRule("left");
CountingRule right = new CountingRule("right");
AndRule andRule = new AndRule(left, right);
assertEquals(1, left.nameRequests(), "AndRule constructor should read left child name exactly once");
assertEquals(1, right.nameRequests(), "AndRule constructor should read right child name exactly once");
assertEquals("AndRule(left,right)", andRule.getName(), "AndRule name should include both child names");
andRule.getName();
assertEquals(1, left.nameRequests(), "AndRule should not re-query child names once cached");
assertEquals(1, right.nameRequests(), "AndRule should not re-query child names once cached");
}
@Test
void lazyAndRuleDefersChildNameResolutionUntilGetName() {
CountingRule left = new CountingRule("lazyLeft");
CountingRule right = new CountingRule("lazyRight");
RuleNameBenchmark.LazyAndRule lazyAndRule = new RuleNameBenchmark.LazyAndRule(left, right);
assertEquals(0, left.nameRequests(), "LazyAndRule should not touch child names in constructor");
assertEquals(0, right.nameRequests(), "LazyAndRule should not touch child names in constructor");
assertEquals("LazyAndRule(lazyLeft,lazyRight)", lazyAndRule.getName(),
"LazyAndRule should build name lazily on first access");
assertEquals(1, left.nameRequests(), "LazyAndRule should resolve left name once when needed");
assertEquals(1, right.nameRequests(), "LazyAndRule should resolve right name once when needed");
lazyAndRule.getName();
assertEquals(1, left.nameRequests(), "LazyAndRule should not re-resolve after caching the name");
assertEquals(1, right.nameRequests(), "LazyAndRule should not re-resolve after caching the name");
}
@Test
void noNameLeakAndRuleAvoidsChildNameResolutionEntirely() {
CountingRule left = new CountingRule("ignoredLeft");
CountingRule right = new CountingRule("ignoredRight");
RuleNameBenchmark.NoNameLeakAndRule noLeakRule = new RuleNameBenchmark.NoNameLeakAndRule(left, right);
assertEquals("NoNameLeakAndRule(CountingRule,CountingRule)", noLeakRule.getName(),
"NoNameLeakAndRule should use child types without touching getName()");
assertEquals(0, left.nameRequests(), "Child names should not be accessed for no-leak variant");
assertEquals(0, right.nameRequests(), "Child names should not be accessed for no-leak variant");
}
@Test
@Tag("benchmark")
@EnabledIfSystemProperty(named = BENCHMARK_PROPERTY, matches = "true")
void benchmarksRunWhenExplicitlyEnabled() throws Exception {
int threads = Math.max(8, Runtime.getRuntime().availableProcessors());
RuleNameBenchmark.main(new String[] { Integer.toString(threads), "2", "20000" });
}
private static final class CountingRule extends FixedRule {
private final AtomicInteger nameRequests = new AtomicInteger();
private final String label;
private CountingRule(String label) {
super(0);
this.label = label;
setName(label);
}
@Override
public String getName() {
nameRequests.incrementAndGet();
return super.getName();
}
int nameRequests() {
return nameRequests.get();
}
@Override
public String toString() {
return label;
}
}
}
@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Test;
import ta4jexamples.charting.display.SwingChartDisplayer;
public class CCICorrectionStrategyTest {
@Test
public void test() {
// Disable chart display during tests to prevent windows from popping up
// This works in both headless and non-headless environments
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
CCICorrectionStrategy.main(null);
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
}
@@ -0,0 +1,314 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Before;
import org.junit.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.junit.Assert.*;
public class DayOfWeekStrategyTest {
private BarSeries series;
@Before
public void setUp() {
NumFactory numFactory = DecimalNumFactory.getInstance();
series = new MockBarSeriesBuilder().withNumFactory(numFactory).build();
// Create bars for each day of the week starting from Monday
// 2019-09-16 is a Monday
// Note: DateTimeIndicator default constructor uses Bar::getBeginTime
// We set beginTime explicitly to match the day we want to test
// Use Duration.between to calculate the exact timePeriod
Instant mondayBegin = Instant.parse("2019-09-16T00:00:00Z");
Instant mondayEnd = Instant.parse("2019-09-16T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(mondayBegin, mondayEnd))
.beginTime(mondayBegin)
.endTime(mondayEnd)
.closePrice(100d)
.add(); // Monday
Instant tuesdayBegin = Instant.parse("2019-09-17T00:00:00Z");
Instant tuesdayEnd = Instant.parse("2019-09-17T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(tuesdayBegin, tuesdayEnd))
.beginTime(tuesdayBegin)
.endTime(tuesdayEnd)
.closePrice(101d)
.add(); // Tuesday
Instant wednesdayBegin = Instant.parse("2019-09-18T00:00:00Z");
Instant wednesdayEnd = Instant.parse("2019-09-18T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(wednesdayBegin, wednesdayEnd))
.beginTime(wednesdayBegin)
.endTime(wednesdayEnd)
.closePrice(102d)
.add(); // Wednesday
Instant thursdayBegin = Instant.parse("2019-09-19T00:00:00Z");
Instant thursdayEnd = Instant.parse("2019-09-19T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(thursdayBegin, thursdayEnd))
.beginTime(thursdayBegin)
.endTime(thursdayEnd)
.closePrice(103d)
.add(); // Thursday
Instant fridayBegin = Instant.parse("2019-09-20T00:00:00Z");
Instant fridayEnd = Instant.parse("2019-09-20T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(fridayBegin, fridayEnd))
.beginTime(fridayBegin)
.endTime(fridayEnd)
.closePrice(104d)
.add(); // Friday
Instant saturdayBegin = Instant.parse("2019-09-21T00:00:00Z");
Instant saturdayEnd = Instant.parse("2019-09-21T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(saturdayBegin, saturdayEnd))
.beginTime(saturdayBegin)
.endTime(saturdayEnd)
.closePrice(105d)
.add(); // Saturday
Instant sundayBegin = Instant.parse("2019-09-22T00:00:00Z");
Instant sundayEnd = Instant.parse("2019-09-22T23:59:59Z");
series.barBuilder()
.timePeriod(Duration.between(sundayBegin, sundayEnd))
.beginTime(sundayBegin)
.endTime(sundayEnd)
.closePrice(106d)
.add(); // Sunday
}
@Test
public void testConstructorWithDayOfWeek() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, DayOfWeek.MONDAY, DayOfWeek.FRIDAY);
assertNotNull(strategy);
assertEquals("DayOfWeekStrategy_MONDAY_FRIDAY", strategy.getName());
assertEquals("{\"type\":\"NamedStrategy\",\"label\":\"DayOfWeekStrategy_MONDAY_FRIDAY\"}", strategy.toJson());
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
public void testConstructorWithStringParams() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, "MONDAY", "FRIDAY");
assertNotNull(strategy);
assertEquals("DayOfWeekStrategy_MONDAY_FRIDAY", strategy.getName());
}
@Test
public void testConstructorWithStringParamsAllDays() {
for (DayOfWeek entryDay : DayOfWeek.values()) {
for (DayOfWeek exitDay : DayOfWeek.values()) {
if (entryDay != exitDay) {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, entryDay.name(), exitDay.name());
assertNotNull(strategy);
assertEquals("DayOfWeekStrategy_" + entryDay + "_" + exitDay, strategy.getName());
}
}
}
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithNullParams() {
new DayOfWeekStrategy(series, (String[]) null);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithEmptyParams() {
new DayOfWeekStrategy(series);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInsufficientParams() {
new DayOfWeekStrategy(series, "MONDAY");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidEntryDay() {
new DayOfWeekStrategy(series, "INVALID_DAY", "FRIDAY");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidExitDay() {
new DayOfWeekStrategy(series, "MONDAY", "INVALID_DAY");
}
@Test
public void testConstructorWithCaseSensitiveDayNames() {
try {
new DayOfWeekStrategy(series, "monday", "friday");
fail("Expected IllegalArgumentException for lowercase day names");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry DayOfWeek value"));
}
}
@Test
public void testEntryRuleSatisfiedOnCorrectDay() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, DayOfWeek.MONDAY, DayOfWeek.FRIDAY);
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 0 is Monday - entry rule should be satisfied
assertTrue(strategy.getEntryRule().isSatisfied(0, tradingRecord));
// Index 1 is Tuesday - entry rule should not be satisfied
assertFalse(strategy.getEntryRule().isSatisfied(1, tradingRecord));
// Index 4 is Friday - entry rule should not be satisfied (it's exit day)
assertFalse(strategy.getEntryRule().isSatisfied(4, tradingRecord));
}
@Test
public void testExitRuleSatisfiedOnCorrectDay() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, DayOfWeek.MONDAY, DayOfWeek.FRIDAY);
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 0 is Monday - exit rule should not be satisfied
assertFalse(strategy.getExitRule().isSatisfied(0, tradingRecord));
// Index 4 is Friday - exit rule should be satisfied
assertTrue(strategy.getExitRule().isSatisfied(4, tradingRecord));
// Index 1 is Tuesday - exit rule should not be satisfied
assertFalse(strategy.getExitRule().isSatisfied(1, tradingRecord));
}
@Test
public void testBuildAllStrategyPermutations() {
List<Strategy> strategies = DayOfWeekStrategy.buildAllStrategyPermutations(series);
assertNotNull(strategies);
// Should have 7 * 6 = 42 strategies (all combinations except where entry ==
// exit)
assertEquals(42, strategies.size());
// Verify all strategies have unique names
long uniqueNames = strategies.stream().map(Strategy::getName).distinct().count();
assertEquals(42, uniqueNames);
// Verify all strategies are DayOfWeekStrategy instances
for (Strategy strategy : strategies) {
assertTrue(strategy instanceof DayOfWeekStrategy);
assertNotNull(strategy.getName());
assertTrue(strategy.getName().startsWith("DayOfWeekStrategy_"));
}
}
@Test
public void testBuildAllStrategyPermutationsNoDuplicateEntryExit() {
List<Strategy> strategies = DayOfWeekStrategy.buildAllStrategyPermutations(series);
// Verify no strategy has the same entry and exit day
for (Strategy strategy : strategies) {
String name = strategy.getName();
String[] parts = name.split("_");
assertNotEquals("Strategy should not have same entry and exit day: " + name, parts[1], parts[2]);
}
}
@Test
public void testStrategyNameFormat() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, DayOfWeek.TUESDAY, DayOfWeek.THURSDAY);
String name = strategy.getName();
assertTrue(name.startsWith("DayOfWeekStrategy_"));
assertTrue(name.contains("TUESDAY"));
assertTrue(name.contains("THURSDAY"));
assertEquals("DayOfWeekStrategy_TUESDAY_THURSDAY", name);
}
@Test
public void testStrategyWithWeekendDays() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY);
assertNotNull(strategy);
assertEquals("DayOfWeekStrategy_SATURDAY_SUNDAY", strategy.getName());
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 5 is Saturday
assertTrue(strategy.getEntryRule().isSatisfied(5, tradingRecord));
// Index 6 is Sunday
assertTrue(strategy.getExitRule().isSatisfied(6, tradingRecord));
}
@Test
public void testStrategyWithAllDayCombinations() {
for (DayOfWeek entryDay : DayOfWeek.values()) {
for (DayOfWeek exitDay : DayOfWeek.values()) {
if (entryDay != exitDay) {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, entryDay, exitDay);
assertNotNull(strategy);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
}
}
}
@Test
public void testStrategyRulesAreNotNull() {
DayOfWeekStrategy strategy = new DayOfWeekStrategy(series, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
public void testStrategyWithNullSeries() {
try {
new DayOfWeekStrategy(null, DayOfWeek.MONDAY, DayOfWeek.FRIDAY);
fail("Expected NullPointerException or IllegalArgumentException for null series");
} catch (Exception e) {
// Expected - either NullPointerException or IllegalArgumentException
assertTrue(e instanceof NullPointerException || e instanceof IllegalArgumentException);
}
}
@Test
public void testParseEntryDayOfWeekErrorMessage() {
try {
new DayOfWeekStrategy(series, "INVALID", "FRIDAY");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry DayOfWeek value"));
assertTrue(e.getMessage().contains("Valid values are:"));
}
}
@Test
public void testParseExitDayOfWeekErrorMessage() {
try {
new DayOfWeekStrategy(series, "MONDAY", "INVALID");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit DayOfWeek value"));
assertTrue(e.getMessage().contains("Valid values are:"));
}
}
@Test
public void testInsufficientParamsErrorMessage() {
try {
new DayOfWeekStrategy(series, "MONDAY");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("At least 2 parameters required"));
}
}
}
@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Test;
import ta4jexamples.charting.display.SwingChartDisplayer;
public class GlobalExtremaStrategyTest {
@Test
public void test() {
// Disable chart display during tests to prevent windows from popping up
// This works in both headless and non-headless environments
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
GlobalExtremaStrategy.main(null);
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
}
@@ -0,0 +1,296 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.ConcurrentBarSeries;
import org.ta4j.core.ConcurrentBarSeriesBuilder;
import org.ta4j.core.Indicator;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.indicators.elliott.ElliottConfidence;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottScenarioSet;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ScenarioType;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.Num;
import org.ta4j.core.num.NumFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HighRewardElliottWaveStrategyTest {
private ConcurrentBarSeries series;
private NumFactory numFactory;
@BeforeEach
void setUp() {
numFactory = DecimalNumFactory.getInstance();
series = new ConcurrentBarSeriesBuilder().withName("elliott-test").withNumFactory(numFactory).build();
Instant start = Instant.parse("2025-01-01T00:00:00Z");
for (int i = 0; i < 30; i++) {
BigDecimal close = BigDecimal.valueOf(100.0 + i);
addBar(series, start.plusSeconds(i * 60L), close);
}
}
@Test
void testDefaultLabelContainsDirectionAndDegree() {
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series);
String[] parts = strategy.getName().split("_");
assertEquals("HighRewardElliottWaveStrategy", parts[0]);
assertEquals("BULLISH", parts[1]);
assertEquals("PRIMARY", parts[2]);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
void testConstructorWithParamsBuildsExpectedLabel() {
String[] params = new String[] { "BULLISH", "PRIMARY", "0.7", "3", "1.5", "0.2", "5", "2", "50", "2", "4",
"0.2" };
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, params);
String[] parts = strategy.getName().split("_");
assertEquals("HighRewardElliottWaveStrategy", parts[0]);
assertEquals("BULLISH", parts[1]);
assertEquals("PRIMARY", parts[2]);
assertEquals("0.7", parts[3]);
assertEquals("3", parts[4]);
assertEquals("1.5", parts[5]);
assertEquals("0.2", parts[6]);
assertEquals("5", parts[7]);
assertEquals("2", parts[8]);
assertEquals("50", parts[9]);
assertEquals("2", parts[10]);
assertEquals("4", parts[11]);
assertEquals("0.2", parts[12]);
}
@Test
void testConstructorRejectsInvalidDirection() {
assertThrows(IllegalArgumentException.class, () -> new HighRewardElliottWaveStrategy(series, "SIDEWAYS",
"PRIMARY", "0.7", "3", "1.5", "0.2", "5", "2", "50", "2", "4", "0.2"));
}
@Test
void testConstructorRejectsWrongParamCount() {
assertThrows(IllegalArgumentException.class,
() -> new HighRewardElliottWaveStrategy(series, "BULLISH", "PRIMARY"));
}
@Test
void testEntryRuleSatisfiedForHighConfidenceImpulse() {
HighRewardElliottWaveStrategy.Config config = new HighRewardElliottWaveStrategy.Config(
HighRewardElliottWaveStrategy.SignalDirection.BULLISH, ElliottDegree.PRIMARY, 0.7, 3.0, 1.5, 0.2, 5, 2,
50.0, 2, 4, 0.2);
ElliottScenario scenario = buildScenario(numFactory.numOf(120), numFactory.numOf(200));
ElliottScenarioSet scenarioSet = buildScenarioSet(series, scenario);
Indicator<ElliottScenarioSet> indicator = new FixedScenarioIndicator(series, scenarioSet);
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, config, indicator);
TradingRecord record = new BaseTradingRecord();
assertTrue(strategy.getEntryRule().isSatisfied(series.getEndIndex(), record));
}
@Test
void testEntryRuleRejectedWhenRiskRewardTooLow() {
HighRewardElliottWaveStrategy.Config config = new HighRewardElliottWaveStrategy.Config(
HighRewardElliottWaveStrategy.SignalDirection.BULLISH, ElliottDegree.PRIMARY, 0.7, 3.0, 1.5, 0.2, 5, 2,
50.0, 2, 4, 0.2);
ElliottScenario scenario = buildScenario(numFactory.numOf(120), numFactory.numOf(140));
ElliottScenarioSet scenarioSet = buildScenarioSet(series, scenario);
Indicator<ElliottScenarioSet> indicator = new FixedScenarioIndicator(series, scenarioSet);
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, config, indicator);
TradingRecord record = new BaseTradingRecord();
assertFalse(strategy.getEntryRule().isSatisfied(series.getEndIndex(), record));
}
@Test
void testEntryRuleRejectedWhenNoScenario() {
HighRewardElliottWaveStrategy.Config config = new HighRewardElliottWaveStrategy.Config(
HighRewardElliottWaveStrategy.SignalDirection.BULLISH, ElliottDegree.PRIMARY, 0.7, 3.0, 1.5, 0.2, 5, 2,
50.0, 2, 4, 0.2);
ElliottScenarioSet scenarioSet = buildEmptyScenarioSet(series);
Indicator<ElliottScenarioSet> indicator = new FixedScenarioIndicator(series, scenarioSet);
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, config, indicator);
TradingRecord record = new BaseTradingRecord();
assertFalse(strategy.getEntryRule().isSatisfied(series.getEndIndex(), record));
}
@Test
void testExitRuleTriggersOnInvalidation() {
HighRewardElliottWaveStrategy.Config config = new HighRewardElliottWaveStrategy.Config(
HighRewardElliottWaveStrategy.SignalDirection.BULLISH, ElliottDegree.PRIMARY, 0.7, 3.0, 1.5, 0.2, 5, 2,
50.0, 2, 4, 0.2);
ElliottScenario scenario = buildScenario(numFactory.numOf(130), numFactory.numOf(200));
ElliottScenarioSet scenarioSet = buildScenarioSet(series, scenario);
Indicator<ElliottScenarioSet> indicator = new FixedScenarioIndicator(series, scenarioSet);
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, config, indicator);
BaseTradingRecord record = new BaseTradingRecord();
int entryIndex = series.getEndIndex() - 1;
record.enter(entryIndex);
assertTrue(strategy.getExitRule().isSatisfied(series.getEndIndex(), record));
}
@Test
void testExitRuleTriggersOnCorrectiveStopViolation() {
HighRewardElliottWaveStrategy.Config config = new HighRewardElliottWaveStrategy.Config(
HighRewardElliottWaveStrategy.SignalDirection.BULLISH, ElliottDegree.PRIMARY, 0.7, 3.0, 1.5, 0.2, 5, 2,
50.0, 2, 4, 0.2);
List<ElliottSwing> swings = List.of(
new ElliottSwing(0, 4, numFactory.numOf(100), numFactory.numOf(150), ElliottDegree.PRIMARY),
new ElliottSwing(4, 6, numFactory.numOf(150), numFactory.numOf(140), ElliottDegree.PRIMARY),
new ElliottSwing(6, 12, numFactory.numOf(140), numFactory.numOf(160), ElliottDegree.PRIMARY),
new ElliottSwing(12, 16, numFactory.numOf(160), numFactory.numOf(150), ElliottDegree.PRIMARY),
new ElliottSwing(16, 20, numFactory.numOf(150), numFactory.numOf(170), ElliottDegree.PRIMARY));
ElliottScenario scenario = ElliottScenario.builder()
.id("test-stop")
.currentPhase(ElliottPhase.WAVE3)
.swings(swings)
.confidence(buildConfidence(numFactory, 0.8))
.degree(ElliottDegree.PRIMARY)
.invalidationPrice(numFactory.numOf(120))
.primaryTarget(numFactory.numOf(200))
.fibonacciTargets(List.of(numFactory.numOf(200)))
.type(ScenarioType.IMPULSE)
.startIndex(0)
.build();
ElliottScenarioSet scenarioSet = buildScenarioSet(series, scenario);
Indicator<ElliottScenarioSet> indicator = new FixedScenarioIndicator(series, scenarioSet);
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, config, indicator);
BaseTradingRecord record = new BaseTradingRecord();
int entryIndex = series.getEndIndex() - 1;
record.enter(entryIndex);
assertTrue(strategy.getExitRule().isSatisfied(series.getEndIndex(), record));
}
@Test
void testExitRuleTriggersWhenNoScenario() {
HighRewardElliottWaveStrategy.Config config = new HighRewardElliottWaveStrategy.Config(
HighRewardElliottWaveStrategy.SignalDirection.BULLISH, ElliottDegree.PRIMARY, 0.7, 3.0, 1.5, 0.2, 5, 2,
50.0, 2, 4, 0.2);
ElliottScenarioSet scenarioSet = buildEmptyScenarioSet(series);
Indicator<ElliottScenarioSet> indicator = new FixedScenarioIndicator(series, scenarioSet);
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, config, indicator);
BaseTradingRecord record = new BaseTradingRecord();
int entryIndex = series.getEndIndex() - 1;
record.enter(entryIndex);
assertTrue(strategy.getExitRule().isSatisfied(series.getEndIndex(), record));
}
private ElliottScenario buildScenario(Num invalidation, Num target) {
List<ElliottSwing> swings = List.of(
new ElliottSwing(0, 4, numFactory.numOf(100), numFactory.numOf(120), ElliottDegree.PRIMARY),
new ElliottSwing(4, 6, numFactory.numOf(120), numFactory.numOf(110), ElliottDegree.PRIMARY),
new ElliottSwing(6, 12, numFactory.numOf(110), numFactory.numOf(145), ElliottDegree.PRIMARY),
new ElliottSwing(12, 16, numFactory.numOf(145), numFactory.numOf(130), ElliottDegree.PRIMARY),
new ElliottSwing(16, 20, numFactory.numOf(130), numFactory.numOf(160), ElliottDegree.PRIMARY));
ElliottConfidence confidence = buildConfidence(numFactory, 0.8);
return ElliottScenario.builder()
.id("test")
.currentPhase(ElliottPhase.WAVE3)
.swings(swings)
.confidence(confidence)
.degree(ElliottDegree.PRIMARY)
.invalidationPrice(invalidation)
.primaryTarget(target)
.fibonacciTargets(List.of(target))
.type(ScenarioType.IMPULSE)
.startIndex(0)
.build();
}
private ElliottScenarioSet buildScenarioSet(BarSeries series, ElliottScenario scenario) {
return ElliottScenarioSet.of(List.of(scenario), series.getEndIndex());
}
private ElliottScenarioSet buildEmptyScenarioSet(BarSeries series) {
return ElliottScenarioSet.empty(series.getEndIndex());
}
private ElliottConfidence buildConfidence(NumFactory factory, double overall) {
Num score = factory.numOf(overall);
return new ElliottConfidence(score, score, score, score, score, score, "test");
}
private static void addBar(ConcurrentBarSeries series, Instant start, BigDecimal close) {
Instant end = start.plusSeconds(60L);
BigDecimal high = close.add(new BigDecimal("0.5"));
BigDecimal low = close.subtract(new BigDecimal("0.5"));
series.addBar(buildBar(series, start, end, close, high, low, close));
}
private static Bar buildBar(ConcurrentBarSeries series, Instant start, Instant end, BigDecimal open,
BigDecimal high, BigDecimal low, BigDecimal close) {
return series.barBuilder()
.timePeriod(Duration.between(start, end))
.beginTime(start)
.endTime(end)
.openPrice(open)
.highPrice(high)
.lowPrice(low)
.closePrice(close)
.volume(new BigDecimal("1"))
.build();
}
private static final class FixedScenarioIndicator implements Indicator<ElliottScenarioSet> {
private final BarSeries series;
private final ElliottScenarioSet scenarioSet;
private FixedScenarioIndicator(BarSeries series, ElliottScenarioSet scenarioSet) {
this.series = series;
this.scenarioSet = scenarioSet;
}
@Override
public ElliottScenarioSet getValue(int index) {
return scenarioSet;
}
@Override
public int getCountOfUnstableBars() {
return 0;
}
@Override
public BarSeries getBarSeries() {
return series;
}
}
}
@@ -0,0 +1,319 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Before;
import org.junit.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.junit.Assert.*;
public class HourOfDayStrategyTest {
private BarSeries series;
@Before
public void setUp() {
NumFactory numFactory = DecimalNumFactory.getInstance();
series = new MockBarSeriesBuilder().withNumFactory(numFactory).build();
// Create bars for different hours of the day
// Use the same day (2019-09-16) but different hours
for (int hour = 0; hour < 24; hour++) {
Instant beginTime = Instant.parse("2019-09-16T" + String.format("%02d:00:00Z", hour));
Instant endTime = Instant.parse("2019-09-16T" + String.format("%02d:59:59Z", hour));
series.barBuilder()
.timePeriod(Duration.between(beginTime, endTime))
.beginTime(beginTime)
.endTime(endTime)
.closePrice(100d + hour)
.add();
}
}
@Test
public void testConstructorWithHours() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, 9, 17);
assertNotNull(strategy);
assertEquals("HourOfDayStrategy_9_17", strategy.getName());
assertEquals("{\"type\":\"NamedStrategy\",\"label\":\"HourOfDayStrategy_9_17\"}", strategy.toJson());
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
public void testConstructorWithStringParams() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, "9", "17");
assertNotNull(strategy);
assertEquals("HourOfDayStrategy_9_17", strategy.getName());
}
@Test
public void testConstructorWithStringParamsAllHours() {
for (int entryHour = 0; entryHour < 24; entryHour++) {
for (int exitHour = 0; exitHour < 24; exitHour++) {
if (entryHour != exitHour) {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, String.valueOf(entryHour),
String.valueOf(exitHour));
assertNotNull(strategy);
assertEquals("HourOfDayStrategy_" + entryHour + "_" + exitHour, strategy.getName());
}
}
}
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithNullParams() {
new HourOfDayStrategy(series, (String[]) null);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithEmptyParams() {
new HourOfDayStrategy(series);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInsufficientParams() {
new HourOfDayStrategy(series, "9");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidEntryHourNegative() {
new HourOfDayStrategy(series, "-1", "17");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidEntryHourTooLarge() {
new HourOfDayStrategy(series, "24", "17");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidExitHourNegative() {
new HourOfDayStrategy(series, "9", "-1");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidExitHourTooLarge() {
new HourOfDayStrategy(series, "9", "24");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithSameEntryAndExitHour() {
new HourOfDayStrategy(series, 12, 12);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithSameEntryAndExitHourString() {
new HourOfDayStrategy(series, "12", "12");
}
@Test
public void testConstructorWithNonNumericEntryHour() {
try {
new HourOfDayStrategy(series, "abc", "17");
fail("Expected IllegalArgumentException for non-numeric entry hour");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry hour value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-23"));
}
}
@Test
public void testConstructorWithNonNumericExitHour() {
try {
new HourOfDayStrategy(series, "9", "xyz");
fail("Expected IllegalArgumentException for non-numeric exit hour");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit hour value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-23"));
}
}
@Test
public void testEntryRuleSatisfiedOnCorrectHour() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, 9, 17);
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 9 is hour 9 - entry rule should be satisfied
assertTrue(strategy.getEntryRule().isSatisfied(9, tradingRecord));
// Index 10 is hour 10 - entry rule should not be satisfied
assertFalse(strategy.getEntryRule().isSatisfied(10, tradingRecord));
// Index 17 is hour 17 - entry rule should not be satisfied (it's exit hour)
assertFalse(strategy.getEntryRule().isSatisfied(17, tradingRecord));
}
@Test
public void testExitRuleSatisfiedOnCorrectHour() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, 9, 17);
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 9 is hour 9 - exit rule should not be satisfied
assertFalse(strategy.getExitRule().isSatisfied(9, tradingRecord));
// Index 17 is hour 17 - exit rule should be satisfied
assertTrue(strategy.getExitRule().isSatisfied(17, tradingRecord));
// Index 10 is hour 10 - exit rule should not be satisfied
assertFalse(strategy.getExitRule().isSatisfied(10, tradingRecord));
}
@Test
public void testBuildAllStrategyPermutations() {
List<Strategy> strategies = HourOfDayStrategy.buildAllStrategyPermutations(series);
assertNotNull(strategies);
// Should have 24 * 23 = 552 strategies (all combinations except where entry ==
// exit)
assertEquals(552, strategies.size());
// Verify all strategies have unique names
long uniqueNames = strategies.stream().map(Strategy::getName).distinct().count();
assertEquals(552, uniqueNames);
// Verify all strategies are HourOfDayStrategy instances
for (Strategy strategy : strategies) {
assertTrue(strategy instanceof HourOfDayStrategy);
assertNotNull(strategy.getName());
assertTrue(strategy.getName().startsWith("HourOfDayStrategy_"));
}
}
@Test
public void testBuildAllStrategyPermutationsNoDuplicateEntryExit() {
List<Strategy> strategies = HourOfDayStrategy.buildAllStrategyPermutations(series);
// Verify no strategy has the same entry and exit hour
for (Strategy strategy : strategies) {
String name = strategy.getName();
String[] parts = name.split("_");
assertNotEquals("Strategy should not have same entry and exit hour: " + name, parts[1], parts[2]);
}
}
@Test
public void testStrategyNameFormat() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, 10, 15);
String name = strategy.getName();
assertTrue(name.startsWith("HourOfDayStrategy_"));
assertTrue(name.contains("10"));
assertTrue(name.contains("15"));
assertEquals("HourOfDayStrategy_10_15", name);
}
@Test
public void testStrategyWithBoundaryHours() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, 0, 23);
assertNotNull(strategy);
assertEquals("HourOfDayStrategy_0_23", strategy.getName());
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 0 is hour 0
assertTrue(strategy.getEntryRule().isSatisfied(0, tradingRecord));
// Index 23 is hour 23
assertTrue(strategy.getExitRule().isSatisfied(23, tradingRecord));
}
@Test
public void testStrategyWithAllHourCombinations() {
for (int entryHour = 0; entryHour < 24; entryHour++) {
for (int exitHour = 0; exitHour < 24; exitHour++) {
if (entryHour != exitHour) {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, entryHour, exitHour);
assertNotNull(strategy);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
}
}
}
@Test
public void testStrategyRulesAreNotNull() {
HourOfDayStrategy strategy = new HourOfDayStrategy(series, 12, 18);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
public void testStrategyWithNullSeries() {
try {
new HourOfDayStrategy(null, 9, 17);
fail("Expected NullPointerException or IllegalArgumentException for null series");
} catch (Exception e) {
// Expected - either NullPointerException or IllegalArgumentException
assertTrue(e instanceof NullPointerException || e instanceof IllegalArgumentException);
}
}
@Test
public void testParseEntryHourErrorMessage() {
try {
new HourOfDayStrategy(series, "INVALID", "17");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry hour value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-23"));
}
}
@Test
public void testParseExitHourErrorMessage() {
try {
new HourOfDayStrategy(series, "9", "INVALID");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit hour value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-23"));
}
}
@Test
public void testInsufficientParamsErrorMessage() {
try {
new HourOfDayStrategy(series, "9");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("At least 2 parameters required"));
}
}
@Test
public void testEntryHourOutOfRangeErrorMessage() {
try {
new HourOfDayStrategy(series, "25", "17");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry hour value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-23"));
}
}
@Test
public void testExitHourOutOfRangeErrorMessage() {
try {
new HourOfDayStrategy(series, "9", "25");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit hour value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-23"));
}
}
}
@@ -0,0 +1,319 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Before;
import org.junit.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Strategy;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.mocks.MockBarSeriesBuilder;
import org.ta4j.core.num.DecimalNumFactory;
import org.ta4j.core.num.NumFactory;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.junit.Assert.*;
public class MinuteOfHourStrategyTest {
private BarSeries series;
@Before
public void setUp() {
NumFactory numFactory = DecimalNumFactory.getInstance();
series = new MockBarSeriesBuilder().withNumFactory(numFactory).build();
// Create bars for different minutes of the hour
// Use the same hour (12:00) but different minutes
for (int minute = 0; minute < 60; minute++) {
Instant beginTime = Instant.parse("2019-09-16T12:" + String.format("%02d:00Z", minute));
Instant endTime = Instant.parse("2019-09-16T12:" + String.format("%02d:59Z", minute));
series.barBuilder()
.timePeriod(Duration.between(beginTime, endTime))
.beginTime(beginTime)
.endTime(endTime)
.closePrice(100d + minute)
.add();
}
}
@Test
public void testConstructorWithMinutes() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, 15, 45);
assertNotNull(strategy);
assertEquals("MinuteOfHourStrategy_15_45", strategy.getName());
assertEquals("{\"type\":\"NamedStrategy\",\"label\":\"MinuteOfHourStrategy_15_45\"}", strategy.toJson());
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
public void testConstructorWithStringParams() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, "15", "45");
assertNotNull(strategy);
assertEquals("MinuteOfHourStrategy_15_45", strategy.getName());
}
@Test
public void testConstructorWithStringParamsAllMinutes() {
for (int entryMinute = 0; entryMinute < 60; entryMinute++) {
for (int exitMinute = 0; exitMinute < 60; exitMinute++) {
if (entryMinute != exitMinute) {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, String.valueOf(entryMinute),
String.valueOf(exitMinute));
assertNotNull(strategy);
assertEquals("MinuteOfHourStrategy_" + entryMinute + "_" + exitMinute, strategy.getName());
}
}
}
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithNullParams() {
new MinuteOfHourStrategy(series, (String[]) null);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithEmptyParams() {
new MinuteOfHourStrategy(series);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInsufficientParams() {
new MinuteOfHourStrategy(series, "15");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidEntryMinuteNegative() {
new MinuteOfHourStrategy(series, "-1", "45");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidEntryMinuteTooLarge() {
new MinuteOfHourStrategy(series, "60", "45");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidExitMinuteNegative() {
new MinuteOfHourStrategy(series, "15", "-1");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithInvalidExitMinuteTooLarge() {
new MinuteOfHourStrategy(series, "15", "60");
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithSameEntryAndExitMinute() {
new MinuteOfHourStrategy(series, 30, 30);
}
@Test(expected = IllegalArgumentException.class)
public void testConstructorWithSameEntryAndExitMinuteString() {
new MinuteOfHourStrategy(series, "30", "30");
}
@Test
public void testConstructorWithNonNumericEntryMinute() {
try {
new MinuteOfHourStrategy(series, "abc", "45");
fail("Expected IllegalArgumentException for non-numeric entry minute");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry minute value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-59"));
}
}
@Test
public void testConstructorWithNonNumericExitMinute() {
try {
new MinuteOfHourStrategy(series, "15", "xyz");
fail("Expected IllegalArgumentException for non-numeric exit minute");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit minute value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-59"));
}
}
@Test
public void testEntryRuleSatisfiedOnCorrectMinute() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, 15, 45);
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 15 is minute 15 - entry rule should be satisfied
assertTrue(strategy.getEntryRule().isSatisfied(15, tradingRecord));
// Index 20 is minute 20 - entry rule should not be satisfied
assertFalse(strategy.getEntryRule().isSatisfied(20, tradingRecord));
// Index 45 is minute 45 - entry rule should not be satisfied (it's exit minute)
assertFalse(strategy.getEntryRule().isSatisfied(45, tradingRecord));
}
@Test
public void testExitRuleSatisfiedOnCorrectMinute() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, 15, 45);
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 15 is minute 15 - exit rule should not be satisfied
assertFalse(strategy.getExitRule().isSatisfied(15, tradingRecord));
// Index 45 is minute 45 - exit rule should be satisfied
assertTrue(strategy.getExitRule().isSatisfied(45, tradingRecord));
// Index 20 is minute 20 - exit rule should not be satisfied
assertFalse(strategy.getExitRule().isSatisfied(20, tradingRecord));
}
@Test
public void testBuildAllStrategyPermutations() {
List<Strategy> strategies = MinuteOfHourStrategy.buildAllStrategyPermutations(series);
assertNotNull(strategies);
// Should have 60 * 59 = 3540 strategies (all combinations except where entry ==
// exit)
assertEquals(3540, strategies.size());
// Verify all strategies have unique names
long uniqueNames = strategies.stream().map(Strategy::getName).distinct().count();
assertEquals(3540, uniqueNames);
// Verify all strategies are MinuteOfHourStrategy instances
for (Strategy strategy : strategies) {
assertTrue(strategy instanceof MinuteOfHourStrategy);
assertNotNull(strategy.getName());
assertTrue(strategy.getName().startsWith("MinuteOfHourStrategy_"));
}
}
@Test
public void testBuildAllStrategyPermutationsNoDuplicateEntryExit() {
List<Strategy> strategies = MinuteOfHourStrategy.buildAllStrategyPermutations(series);
// Verify no strategy has the same entry and exit minute
for (Strategy strategy : strategies) {
String name = strategy.getName();
String[] parts = name.split("_");
assertNotEquals("Strategy should not have same entry and exit minute: " + name, parts[1], parts[2]);
}
}
@Test
public void testStrategyNameFormat() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, 20, 40);
String name = strategy.getName();
assertTrue(name.startsWith("MinuteOfHourStrategy_"));
assertTrue(name.contains("20"));
assertTrue(name.contains("40"));
assertEquals("MinuteOfHourStrategy_20_40", name);
}
@Test
public void testStrategyWithBoundaryMinutes() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, 0, 59);
assertNotNull(strategy);
assertEquals("MinuteOfHourStrategy_0_59", strategy.getName());
TradingRecord tradingRecord = new BaseTradingRecord();
// Index 0 is minute 0
assertTrue(strategy.getEntryRule().isSatisfied(0, tradingRecord));
// Index 59 is minute 59
assertTrue(strategy.getExitRule().isSatisfied(59, tradingRecord));
}
@Test
public void testStrategyWithAllMinuteCombinations() {
for (int entryMinute = 0; entryMinute < 60; entryMinute++) {
for (int exitMinute = 0; exitMinute < 60; exitMinute++) {
if (entryMinute != exitMinute) {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, entryMinute, exitMinute);
assertNotNull(strategy);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
}
}
}
@Test
public void testStrategyRulesAreNotNull() {
MinuteOfHourStrategy strategy = new MinuteOfHourStrategy(series, 20, 40);
assertNotNull(strategy.getEntryRule());
assertNotNull(strategy.getExitRule());
}
@Test
public void testStrategyWithNullSeries() {
try {
new MinuteOfHourStrategy(null, 15, 45);
fail("Expected NullPointerException or IllegalArgumentException for null series");
} catch (Exception e) {
// Expected - either NullPointerException or IllegalArgumentException
assertTrue(e instanceof NullPointerException || e instanceof IllegalArgumentException);
}
}
@Test
public void testParseEntryMinuteErrorMessage() {
try {
new MinuteOfHourStrategy(series, "INVALID", "45");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry minute value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-59"));
}
}
@Test
public void testParseExitMinuteErrorMessage() {
try {
new MinuteOfHourStrategy(series, "15", "INVALID");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit minute value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-59"));
}
}
@Test
public void testInsufficientParamsErrorMessage() {
try {
new MinuteOfHourStrategy(series, "15");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("At least 2 parameters required"));
}
}
@Test
public void testEntryMinuteOutOfRangeErrorMessage() {
try {
new MinuteOfHourStrategy(series, "60", "45");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid entry minute value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-59"));
}
}
@Test
public void testExitMinuteOutOfRangeErrorMessage() {
try {
new MinuteOfHourStrategy(series, "15", "60");
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Invalid exit minute value"));
assertTrue(e.getMessage().contains("Valid values are integers in the range 0-59"));
}
}
}
@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Test;
import ta4jexamples.charting.display.SwingChartDisplayer;
public class MovingMomentumStrategyTest {
@Test
public void test() {
// Disable chart display during tests to prevent windows from popping up
// This works in both headless and non-headless environments
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
MovingMomentumStrategy.main(null);
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
}
@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.strategies;
import org.junit.Test;
import ta4jexamples.charting.display.SwingChartDisplayer;
public class RSI2StrategyTest {
@Test
public void test() {
// Disable chart display during tests to prevent windows from popping up
// This works in both headless and non-headless environments
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
RSI2Strategy.main(null);
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
}
@@ -0,0 +1,21 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.walkforward;
import org.junit.Test;
import ta4jexamples.charting.display.SwingChartDisplayer;
public class WalkForwardTest {
@Test
public void test() {
// Disable chart display during tests to prevent windows from popping up
System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
try {
WalkForward.main(null);
} finally {
System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
}
}
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" monitorInterval="0">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<ThresholdFilter level="WARN" onMatch="ACCEPT" onMismatch="DENY"/>
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="ta4jexamples.charting" level="off" additivity="false"/>
<Logger name="ta4jexamples.datasources" level="off" additivity="false"/>
<Logger name="ta4jexamples.analysis" level="off" additivity="false"/>
<Logger name="ta4jexamples.rules" level="off" additivity="false"/>
<Logger name="ta4jexamples.indicators" level="off" additivity="false"/>
<Logger name="ta4jexamples.backtesting" level="off" additivity="false"/>
<Root level="warn">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
@@ -0,0 +1,5 @@
{
"registryId": "test-registry-missing-anchors",
"datasetResource": "test-dataset.csv",
"provenance": "unit test fixture"
}
@@ -0,0 +1,8 @@
{
"registryId": "test-registry-null-anchor",
"datasetResource": "test-dataset.csv",
"provenance": "unit test fixture",
"anchors": [
null
]
}