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,844 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import java.math.RoundingMode;
import java.math.BigDecimal;
import java.io.IOException;
import java.util.Optional;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.indicators.elliott.ElliottInvalidationIndicator;
import org.ta4j.core.indicators.elliott.ElliottConfluenceIndicator;
import org.ta4j.core.indicators.elliott.ElliottChannelIndicator;
import org.ta4j.core.indicators.elliott.ElliottRatio.RatioType;
import org.ta4j.core.indicators.elliott.ElliottPhaseIndicator;
import org.ta4j.core.indicators.elliott.ElliottRatioIndicator;
import org.ta4j.core.indicators.elliott.ElliottSwingMetadata;
import org.ta4j.core.indicators.elliott.ElliottScenarioSet;
import org.ta4j.core.indicators.elliott.ElliottConfidence;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottChannel;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottRatio;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ScenarioType;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.walkforward.ElliottWaveWalkForwardProfiles;
import org.ta4j.core.num.Num;
import ta4jexamples.charting.workflow.ChartWorkflow;
import ta4jexamples.charting.builder.ChartPlan;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.ExclusionStrategy;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.JsonToken;
import com.google.gson.FieldAttributes;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.Gson;
/**
* Domain model capturing all Elliott Wave analysis results currently logged via
* {@link ElliottWaveIndicatorSuiteDemo#logBaseCaseScenario(ElliottScenario)}
* and {@link ElliottWaveIndicatorSuiteDemo#logAlternativeScenarios(List)}.
* <p>
* This class provides structured access to analysis results including swing
* snapshots, phase information, ratio and channel data, confluence scores,
* scenario summaries, trend bias, and detailed base case and alternative
* scenario information.
* <p>
* The class contains pre-rendered chart images (PNG format, base64-encoded) for
* all scenarios, allowing charts to be viewed without requiring external data
* or chart generation libraries. Charts are embedded as base64-encoded PNG byte
* arrays, making them directly viewable in browsers, reports, or any system
* that supports PNG images.
* <p>
* The class is serializable to JSON using Gson, providing a structured
* representation suitable for storage, transmission, or further processing.
*
* @see ElliottWaveIndicatorSuiteDemo
* @since 0.22.4
*/
public record ElliottWaveAnalysisReport(ElliottDegree degree, int endIndex, SwingSnapshot swingSnapshot,
LatestAnalysis latestAnalysis, ScenarioSummary scenarioSummary, ElliottTrendBias trendBias,
ProbabilityCalibration probabilityCalibration, OutlookGate outlookGate, BaseCaseScenario baseCase,
List<AlternativeScenario> alternatives, String baseCaseChartImage, List<String> alternativeChartImages) {
private static final Logger LOG = LogManager.getLogger(ElliottWaveAnalysisReport.class);
private static final double SCENARIO_TYPE_OVERLAP_WEIGHT = 0.3;
private static final double CONSENSUS_ADJUSTMENT_WEIGHT = 0.4;
private static final double DIRECTION_OVERLAP_WEIGHT = 0.2;
private static final double PHASE_OVERLAP_WEIGHT = 0.5;
private static final double MIN_CONFIDENCE_CONTRAST_EXPONENT = 1.5;
private static final double MAX_CONFIDENCE_CONTRAST_EXPONENT = 6.0;
private static final double CONFIDENCE_SPREAD_TARGET = 0.25;
private static final String CALIBRATION_METHOD = "centered_shrinkage_renormalized";
private static final String CALIBRATION_PROFILE = "wf-baseline-minute-f2-h2l2-max25-sw0__k1-200-65-320";
private static final double CALIBRATION_BASE_SHRINK_FACTOR = 0.72;
private static final double CALIBRATION_STRONG_CONSENSUS_BONUS = 0.08;
private static final double CALIBRATION_DIRECTIONAL_CONSENSUS_BONUS = 0.06;
private static final double CALIBRATION_WEAK_TREND_PENALTY = 0.06;
private static final double CALIBRATION_MIN_SHRINK_FACTOR = 0.45;
private static final double CALIBRATION_MAX_SHRINK_FACTOR = 0.95;
private static final double OUTLOOK_MIN_TOP_PROBABILITY = 0.30;
private static final double OUTLOOK_MIN_TOP_TWO_SPREAD = 0.03;
private static final double OUTLOOK_MIN_TOP_THREE_SPREAD = 0.08;
private static final double OUTLOOK_MIN_TREND_STRENGTH = 0.15;
private static final double CALIBRATION_EPSILON = 1.0e-12;
private static final TypeAdapter<Double> NULLING_DOUBLE_ADAPTER = new TypeAdapter<>() {
@Override
public void write(JsonWriter out, Double value) throws IOException {
if (value == null || value.isNaN() || value.isInfinite()) {
out.nullValue();
return;
}
out.value(value);
}
@Override
public Double read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
return in.nextDouble();
}
};
/**
* Creates an analysis result from the current analysis state.
*
* @param degree the Elliott wave degree used
* @param swingMetadata swing metadata snapshot
* @param phaseIndicator phase indicator (for latest phase and
* confirmation flags)
* @param ratioIndicator ratio indicator (for latest ratio)
* @param channelIndicator channel indicator (for latest channel)
* @param confluenceIndicator confluence indicator (for latest confluence)
* @param invalidationIndicator invalidation indicator (for latest invalidation)
* @param scenarioSet scenario set (for scenario summary and
* scenarios)
* @param endIndex index at which to evaluate indicators
* @param baseCaseChartPlan chart plan for base case scenario (optional)
* @param alternativeChartPlans chart plans for alternative scenarios
* @return analysis result capturing all logged data and chart images
*/
public static ElliottWaveAnalysisReport from(ElliottDegree degree, ElliottSwingMetadata swingMetadata,
ElliottPhaseIndicator phaseIndicator, ElliottRatioIndicator ratioIndicator,
ElliottChannelIndicator channelIndicator, ElliottConfluenceIndicator confluenceIndicator,
ElliottInvalidationIndicator invalidationIndicator, ElliottScenarioSet scenarioSet, int endIndex,
Optional<ChartPlan> baseCaseChartPlan, List<ChartPlan> alternativeChartPlans) {
Objects.requireNonNull(degree, "degree");
Objects.requireNonNull(swingMetadata, "swingMetadata");
Objects.requireNonNull(phaseIndicator, "phaseIndicator");
Objects.requireNonNull(ratioIndicator, "ratioIndicator");
Objects.requireNonNull(channelIndicator, "channelIndicator");
Objects.requireNonNull(confluenceIndicator, "confluenceIndicator");
Objects.requireNonNull(invalidationIndicator, "invalidationIndicator");
Objects.requireNonNull(scenarioSet, "scenarioSet");
Objects.requireNonNull(baseCaseChartPlan, "baseCaseChartPlan");
Objects.requireNonNull(alternativeChartPlans, "alternativeChartPlans");
SwingSnapshot snapshot = SwingSnapshot.from(swingMetadata);
LatestAnalysis latest = LatestAnalysis.from(phaseIndicator, ratioIndicator, channelIndicator,
confluenceIndicator, invalidationIndicator, endIndex);
ScenarioSummary summary = ScenarioSummary.from(scenarioSet);
ElliottTrendBias trendBias = scenarioSet.trendBias();
Map<String, Double> scenarioProbabilities = computeScenarioProbabilities(scenarioSet);
CalibrationResult calibrationResult = calibrateScenarioProbabilities(scenarioProbabilities, summary, trendBias);
Map<String, Double> calibratedProbabilities = calibrationResult.calibratedProbabilities();
ProbabilityCalibration probabilityCalibration = calibrationResult.calibration();
OutlookGate outlookGate = OutlookGate.from(scenarioProbabilities, calibratedProbabilities, summary, trendBias);
BaseCaseScenario baseCase = scenarioSet.base()
.map(scenario -> BaseCaseScenario.from(scenario, scenarioProbabilities.getOrDefault(scenario.id(), 0.0),
calibratedProbabilities.getOrDefault(scenario.id(), 0.0)))
.orElse(null);
List<AlternativeScenario> alternatives = scenarioSet.alternatives()
.stream()
.map(scenario -> AlternativeScenario.from(scenario,
scenarioProbabilities.getOrDefault(scenario.id(), 0.0),
calibratedProbabilities.getOrDefault(scenario.id(), 0.0)))
.toList();
ChartWorkflow chartWorkflow = new ChartWorkflow();
String baseCaseChartImage = baseCaseChartPlan.map(plan -> encodeChartAsBase64(chartWorkflow, plan))
.orElse(null);
List<String> alternativeChartImages = alternativeChartPlans.stream()
.map(plan -> encodeChartAsBase64(chartWorkflow, plan))
.toList();
return new ElliottWaveAnalysisReport(degree, endIndex, snapshot, latest, summary, trendBias,
probabilityCalibration, outlookGate, baseCase, alternatives, baseCaseChartImage,
alternativeChartImages);
}
/**
* Serializes this analysis result to JSON using Gson.
* <p>
* By default, chart image data is excluded to keep the JSON size manageable.
* Use {@link #toJson(boolean)} with {@code true} to include chart images.
*
* @return JSON representation of the analysis result (without chart images)
*/
public String toJson() {
return toJson(false);
}
/**
* Serializes this analysis result to JSON using Gson.
*
* @param includeChartData if {@code true}, includes base64-encoded PNG chart
* images; if {@code false}, excludes chart images to
* reduce JSON size
* @return JSON representation of the analysis result
*/
public String toJson(boolean includeChartData) {
GsonBuilder builder = new GsonBuilder().setPrettyPrinting()
.serializeNulls()
.registerTypeAdapter(Double.class, NULLING_DOUBLE_ADAPTER)
.registerTypeAdapter(Double.TYPE, NULLING_DOUBLE_ADAPTER);
if (!includeChartData) {
builder.setExclusionStrategies(new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes f) {
return "baseCaseChartImage".equals(f.getName()) || "alternativeChartImages".equals(f.getName());
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return false;
}
});
}
Gson gson = builder.create();
return gson.toJson(this);
}
/**
* Swing snapshot data capturing the current state of detected swings.
*
* @param valid whether the snapshot contains valid prices for each swing
* @param swings number of swings represented
* @param high highest price touched by any swing
* @param low lowest price touched by any swing
*/
public record SwingSnapshot(boolean valid, int swings, double high, double low) {
static SwingSnapshot from(ElliottSwingMetadata metadata) {
return new SwingSnapshot(metadata.isValid(), metadata.size(), safeDoubleValue(metadata.highestPrice()),
safeDoubleValue(metadata.lowestPrice()));
}
}
/**
* Latest analysis results at the evaluation index.
*
* @param phase current Elliott phase
* @param impulseConfirmed whether impulse waves are confirmed
* @param correctiveConfirmed whether corrective waves are confirmed
* @param ratioType type of ratio relationship (retracement/extension)
* @param ratioValue measured ratio value
* @param channel channel data (null if invalid)
* @param confluenceScore confluence score
* @param confluent whether confluence threshold is met
* @param invalidation whether current price action invalidates the wave
* count
*/
public record LatestAnalysis(ElliottPhase phase, boolean impulseConfirmed, boolean correctiveConfirmed,
RatioType ratioType, double ratioValue, ChannelData channel, double confluenceScore, boolean confluent,
boolean invalidation) {
static LatestAnalysis from(ElliottPhaseIndicator phaseIndicator, ElliottRatioIndicator ratioIndicator,
ElliottChannelIndicator channelIndicator, ElliottConfluenceIndicator confluenceIndicator,
ElliottInvalidationIndicator invalidationIndicator, int endIndex) {
ElliottPhase phase = phaseIndicator.getValue(endIndex);
boolean impulseConfirmed = phaseIndicator.isImpulseConfirmed(endIndex);
boolean correctiveConfirmed = phaseIndicator.isCorrectiveConfirmed(endIndex);
ElliottRatio ratio = ratioIndicator.getValue(endIndex);
RatioType ratioType = ratio != null ? ratio.type() : RatioType.NONE;
double ratioValue = ratio != null ? safeDoubleValue(ratio.value()) : Double.NaN;
ElliottChannel channel = channelIndicator.getValue(endIndex);
ChannelData channelData = channel != null && channel.isValid() ? ChannelData.from(channel) : null;
Num confluenceNum = confluenceIndicator.getValue(endIndex);
double confluenceScore = safeDoubleValue(confluenceNum);
boolean confluent = confluenceIndicator.isConfluent(endIndex);
boolean invalidation = invalidationIndicator.getValue(endIndex);
return new LatestAnalysis(phase, impulseConfirmed, correctiveConfirmed, ratioType, ratioValue, channelData,
confluenceScore, confluent, invalidation);
}
}
/**
* Channel boundary data.
*
* @param valid whether the channel is valid
* @param upper expected resistance boundary
* @param lower expected support boundary
* @param median arithmetic midline between upper and lower bounds
*/
public record ChannelData(boolean valid, double upper, double lower, double median) {
static ChannelData from(ElliottChannel channel) {
return new ChannelData(channel.isValid(), safeDoubleValue(channel.upper()),
safeDoubleValue(channel.lower()), safeDoubleValue(channel.median()));
}
}
/**
* Scenario summary across all scenarios.
*
* @param summary human-readable summary describing scenario
* distribution
* @param strongConsensus whether there is strong consensus (single
* high-confidence scenario or large spread)
* @param consensusPhase agreed-upon phase if all high-confidence scenarios
* match, otherwise NONE
*/
public record ScenarioSummary(String summary, boolean strongConsensus, ElliottPhase consensusPhase) {
static ScenarioSummary from(ElliottScenarioSet scenarioSet) {
return new ScenarioSummary(scenarioSet.summary(), scenarioSet.hasStrongConsensus(),
scenarioSet.consensus());
}
}
/**
* Metadata describing how probabilities were calibrated for this analysis run.
*
* @param profile calibration profile id sourced from walk-forward tuning
* @param method calibration transform identifier
* @param shrinkFactor shrink factor applied to centered scenario probabilities
*/
public record ProbabilityCalibration(String profile, String method, double shrinkFactor) {
static ProbabilityCalibration from(double shrinkFactor) {
final String profile = CALIBRATION_PROFILE + "|cfg="
+ ElliottWaveWalkForwardProfiles.baselineConfig().configHash();
return new ProbabilityCalibration(profile, CALIBRATION_METHOD, shrinkFactor);
}
}
/**
* Directional outlook gate status.
*
* <p>
* The gate blocks directional publication when scenario probabilities are too
* crowded or consensus signals are weak.
*
* @param eligible whether directional outlook can be published
* @param outlookLabel directional label or {@code NEUTRAL}
* @param reason concise gate decision explanation
* @param topScenarioProbability top raw scenario probability
* @param calibratedTopProbability top calibrated scenario probability
* @param topTwoSpread spread between top-1 and top-2 raw
* probabilities
* @param topThreeSpread spread between top-1 and top-3 raw
* probabilities
* @param strongConsensus whether strong scenario consensus is present
* @param directionalConsensus whether directional consensus is present
* @param trendStrength trend bias strength (0.0-1.0)
*/
public record OutlookGate(boolean eligible, String outlookLabel, String reason, double topScenarioProbability,
double calibratedTopProbability, double topTwoSpread, double topThreeSpread, boolean strongConsensus,
boolean directionalConsensus, double trendStrength) {
static OutlookGate from(Map<String, Double> rawProbabilities, Map<String, Double> calibratedProbabilities,
ScenarioSummary summary, ElliottTrendBias trendBias) {
if (rawProbabilities == null || rawProbabilities.isEmpty()) {
return new OutlookGate(false, "NEUTRAL", "No scenarios available", Double.NaN, Double.NaN, Double.NaN,
Double.NaN, false, false, Double.NaN);
}
final List<Double> rawSorted = rawProbabilities.values()
.stream()
.filter(Double::isFinite)
.sorted((a, b) -> Double.compare(b, a))
.toList();
final List<Double> calibratedSorted = calibratedProbabilities.values()
.stream()
.filter(Double::isFinite)
.sorted((a, b) -> Double.compare(b, a))
.toList();
if (rawSorted.isEmpty()) {
return new OutlookGate(false, "NEUTRAL", "Scenario probabilities were not finite", Double.NaN,
Double.NaN, Double.NaN, Double.NaN, false, false, Double.NaN);
}
final double top = rawSorted.get(0);
final double topTwoSpread = rawSorted.size() > 1 ? top - rawSorted.get(1) : top;
final double topThreeSpread = rawSorted.size() > 2 ? top - rawSorted.get(2) : topTwoSpread;
final double calibratedTop = calibratedSorted.isEmpty() ? Double.NaN : calibratedSorted.get(0);
final boolean strongConsensus = summary != null && summary.strongConsensus();
final boolean directionalConsensus = trendBias != null && trendBias.consensus();
final boolean knownTrend = trendBias != null && !trendBias.isUnknown() && !trendBias.isNeutral();
final double trendStrength = trendBias == null ? Double.NaN : trendBias.strength();
final boolean strongTrend = Double.isFinite(trendStrength) && trendStrength >= OUTLOOK_MIN_TREND_STRENGTH;
final boolean topProbabilityPass = top >= OUTLOOK_MIN_TOP_PROBABILITY;
final boolean spreadPass = topTwoSpread >= OUTLOOK_MIN_TOP_TWO_SPREAD
&& topThreeSpread >= OUTLOOK_MIN_TOP_THREE_SPREAD;
final boolean eligible = strongConsensus && directionalConsensus && knownTrend && strongTrend
&& topProbabilityPass && spreadPass;
final String label = eligible ? trendBias.direction().name() : "NEUTRAL";
final String reason;
if (eligible) {
reason = "Directional outlook passed consensus and spread gates";
} else if (!strongConsensus) {
reason = "Strong scenario consensus not established";
} else if (!directionalConsensus || !knownTrend) {
reason = "Directional consensus is weak or trend is neutral";
} else if (!strongTrend) {
reason = "Trend strength is below baseline threshold";
} else if (!topProbabilityPass) {
reason = "Top scenario probability is below publication threshold";
} else {
reason = "Top scenarios are too close; low-conviction outlook";
}
return new OutlookGate(eligible, label, reason, top, calibratedTop, topTwoSpread, topThreeSpread,
strongConsensus, directionalConsensus, trendStrength);
}
}
/**
* Internal result container for calibrated probabilities and calibration
* metadata.
*/
private record CalibrationResult(Map<String, Double> calibratedProbabilities, ProbabilityCalibration calibration) {
}
/**
* Base case scenario details.
*
* @param currentPhase the phase this scenario assigns to current price
* action
* @param type pattern type classification
* @param overallConfidence overall confidence percentage (0-100)
* @param scenarioProbability raw scenario probability ratio (0.0-1.0)
* @param calibratedProbability walk-forward calibrated scenario probability
* ratio (0.0-1.0)
* @param confidenceLevel confidence level (HIGH, MEDIUM, or LOW)
* @param fibonacciScore Fibonacci proximity score as percentage (0-100)
* @param timeScore time proportion score as percentage (0-100)
* @param alternationScore alternation quality score as percentage (0-100)
* @param channelScore channel adherence score as percentage (0-100)
* @param completenessScore structure completeness score as percentage
* (0-100)
* @param primaryReason human-readable description of dominant factor
* @param weakestFactor description of the weakest scoring factor
* @param direction direction (BULLISH or BEARISH)
* @param invalidationPrice price level that would invalidate this count
* @param primaryTarget primary Fibonacci projection target
* @param swings swing sequence for building wave labels
*/
public record BaseCaseScenario(ElliottPhase currentPhase, ScenarioType type, double overallConfidence,
@JsonAdapter(ScenarioProbabilityAdapter.class) double scenarioProbability,
@JsonAdapter(ScenarioProbabilityAdapter.class) double calibratedProbability, String confidenceLevel,
double fibonacciScore, double timeScore, double alternationScore, double channelScore,
double completenessScore, String primaryReason, String weakestFactor, String direction,
double invalidationPrice, double primaryTarget, List<SwingData> swings) {
static BaseCaseScenario from(ElliottScenario scenario, double scenarioProbability,
double calibratedProbability) {
ElliottConfidence confidence = scenario.confidence();
double overallConfidence = confidence.asPercentage();
String confidenceLevel = confidence.isHighConfidence() ? "HIGH"
: confidence.isLowConfidence() ? "LOW" : "MEDIUM";
double fibonacciScore = safeDoubleValue(confidence.fibonacciScore()) * 100.0;
double timeScore = safeDoubleValue(confidence.timeProportionScore()) * 100.0;
double alternationScore = safeDoubleValue(confidence.alternationScore()) * 100.0;
double channelScore = safeDoubleValue(confidence.channelScore()) * 100.0;
double completenessScore = safeDoubleValue(confidence.completenessScore()) * 100.0;
String direction = scenario.isBullish() ? "BULLISH" : "BEARISH";
double invalidationPrice = safeDoubleValue(scenario.invalidationPrice());
double primaryTarget = safeDoubleValue(scenario.primaryTarget());
List<SwingData> swings = scenario.swings().stream().map(SwingData::from).toList();
return new BaseCaseScenario(scenario.currentPhase(), scenario.type(), overallConfidence,
scenarioProbability, calibratedProbability, confidenceLevel, fibonacciScore, timeScore,
alternationScore, channelScore, completenessScore, confidence.primaryReason(),
confidence.weakestFactor(), direction, invalidationPrice, primaryTarget, swings);
}
}
/**
* Alternative scenario details.
*
* @param currentPhase the phase this scenario assigns to current price
* action
* @param type pattern type classification
* @param confidencePercent overall confidence percentage (0-100)
* @param scenarioProbability raw scenario probability ratio (0.0-1.0)
* @param calibratedProbability walk-forward calibrated scenario probability
* ratio (0.0-1.0)
* @param swings swing sequence for building wave labels
*/
public record AlternativeScenario(ElliottPhase currentPhase, ScenarioType type, double confidencePercent,
@JsonAdapter(ScenarioProbabilityAdapter.class) double scenarioProbability,
@JsonAdapter(ScenarioProbabilityAdapter.class) double calibratedProbability, List<SwingData> swings) {
static AlternativeScenario from(ElliottScenario scenario, double scenarioProbability,
double calibratedProbability) {
List<SwingData> swings = scenario.swings().stream().map(SwingData::from).toList();
return new AlternativeScenario(scenario.currentPhase(), scenario.type(),
scenario.confidence().asPercentage(), scenarioProbability, calibratedProbability, swings);
}
}
/**
* Swing data for building wave labels.
*
* @param fromIndex starting bar index
* @param toIndex ending bar index
* @param fromPrice starting price
* @param toPrice ending price
* @param isRising whether the swing is rising
*/
public record SwingData(int fromIndex, int toIndex, double fromPrice, double toPrice, boolean isRising) {
static SwingData from(ElliottSwing swing) {
return new SwingData(swing.fromIndex(), swing.toIndex(), safeDoubleValue(swing.fromPrice()),
safeDoubleValue(swing.toPrice()), swing.isRising());
}
}
/**
* Computes scenario probabilities by applying adaptive contrast to confidence
* scores, then tilting toward overlapping consensus factors (phase, type, and
* direction).
*
* @param scenarioSet scenario set to evaluate
* @return scenario probability ratios keyed by scenario id
*/
static Map<String, Double> computeScenarioProbabilities(ElliottScenarioSet scenarioSet) {
Objects.requireNonNull(scenarioSet, "scenarioSet");
List<ElliottScenario> scenarios = scenarioSet.all();
if (scenarios.isEmpty()) {
return Map.of();
}
EnumMap<ElliottPhase, Integer> phaseCounts = new EnumMap<>(ElliottPhase.class);
EnumMap<ScenarioType, Integer> typeCounts = new EnumMap<>(ScenarioType.class);
double minConfidence = Double.POSITIVE_INFINITY;
double maxConfidence = Double.NEGATIVE_INFINITY;
int scenarioCount = scenarios.size();
double[] confidenceScores = new double[scenarioCount];
int knownPhaseCount = 0;
int knownTypeCount = 0;
int bullishCount = 0;
int bearishCount = 0;
int knownDirectionCount = 0;
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
ElliottPhase phase = scenario.currentPhase();
if (phase != ElliottPhase.NONE) {
phaseCounts.merge(phase, 1, Integer::sum);
knownPhaseCount++;
}
ScenarioType type = scenario.type();
if (type != ScenarioType.UNKNOWN) {
typeCounts.merge(type, 1, Integer::sum);
knownTypeCount++;
}
if (scenario.hasKnownDirection()) {
knownDirectionCount++;
if (scenario.isBullish()) {
bullishCount++;
} else {
bearishCount++;
}
}
double confidence = safeScoreValue(scenario.confidenceScore());
confidenceScores[i] = confidence;
minConfidence = Math.min(minConfidence, confidence);
maxConfidence = Math.max(maxConfidence, confidence);
}
double[] baseWeights = new double[scenarioCount];
double totalConfidence = 0.0;
double contrastExponent = confidenceContrastExponent(minConfidence, maxConfidence, scenarioCount);
for (int i = 0; i < scenarioCount; i++) {
double confidence = confidenceScores[i];
double contrasted = applyConfidenceContrast(confidence, contrastExponent);
baseWeights[i] = contrasted;
totalConfidence += contrasted;
}
if (totalConfidence > 0.0) {
for (int i = 0; i < scenarioCount; i++) {
baseWeights[i] /= totalConfidence;
}
} else {
double equalWeight = 1.0 / scenarioCount;
for (int i = 0; i < scenarioCount; i++) {
baseWeights[i] = equalWeight;
}
}
double[] overlapScores = new double[scenarioCount];
double overlapTotal = 0.0;
int overlapCount = 0;
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
double overlapScore = overlapScoreForScenario(scenario, phaseCounts, knownPhaseCount, typeCounts,
knownTypeCount, bullishCount, bearishCount, knownDirectionCount);
overlapScores[i] = overlapScore;
if (overlapScore > 0.0) {
overlapTotal += overlapScore;
overlapCount++;
}
}
double averageOverlap = overlapCount > 0 ? overlapTotal / overlapCount : 0.0;
double[] adjustedWeights = new double[scenarioCount];
double adjustedTotal = 0.0;
for (int i = 0; i < scenarioCount; i++) {
double overlapScore = overlapScores[i];
double multiplier = overlapScore > 0.0
? 1.0 + (CONSENSUS_ADJUSTMENT_WEIGHT * (overlapScore - averageOverlap))
: 1.0;
double adjustedWeight = baseWeights[i] * multiplier;
adjustedWeights[i] = adjustedWeight;
adjustedTotal += adjustedWeight;
}
Map<String, Double> probabilities = new HashMap<>();
if (adjustedTotal <= 0.0) {
double fallback = 1.0 / scenarioCount;
for (ElliottScenario scenario : scenarios) {
probabilities.put(scenario.id(), fallback);
}
return Map.copyOf(probabilities);
}
for (int i = 0; i < scenarioCount; i++) {
ElliottScenario scenario = scenarios.get(i);
double probability = adjustedWeights[i] / adjustedTotal;
probabilities.put(scenario.id(), probability);
}
return Map.copyOf(probabilities);
}
/**
* Applies walk-forward-informed probability calibration using centered
* shrinkage followed by renormalization.
*
* <p>
* The transform shrinks probabilities toward the uniform prior to reduce
* over-confident tails while preserving ordering signals from the raw scenario
* model.
*/
private static CalibrationResult calibrateScenarioProbabilities(Map<String, Double> rawProbabilities,
ScenarioSummary summary, ElliottTrendBias trendBias) {
if (rawProbabilities == null || rawProbabilities.isEmpty()) {
ProbabilityCalibration calibration = ProbabilityCalibration.from(CALIBRATION_BASE_SHRINK_FACTOR);
return new CalibrationResult(Map.of(), calibration);
}
Map<String, Double> normalizedRaw = normalizeProbabilityMap(rawProbabilities);
if (normalizedRaw.isEmpty()) {
ProbabilityCalibration calibration = ProbabilityCalibration.from(CALIBRATION_BASE_SHRINK_FACTOR);
return new CalibrationResult(Map.of(), calibration);
}
double shrinkFactor = CALIBRATION_BASE_SHRINK_FACTOR;
if (summary != null && summary.strongConsensus()) {
shrinkFactor += CALIBRATION_STRONG_CONSENSUS_BONUS;
}
if (trendBias != null && trendBias.consensus()) {
shrinkFactor += CALIBRATION_DIRECTIONAL_CONSENSUS_BONUS;
}
if (trendBias == null || trendBias.isUnknown() || trendBias.isNeutral()
|| !Double.isFinite(trendBias.strength()) || trendBias.strength() < OUTLOOK_MIN_TREND_STRENGTH) {
shrinkFactor -= CALIBRATION_WEAK_TREND_PENALTY;
}
shrinkFactor = clamp(shrinkFactor, CALIBRATION_MIN_SHRINK_FACTOR, CALIBRATION_MAX_SHRINK_FACTOR);
int scenarioCount = normalizedRaw.size();
double uniformPrior = 1.0 / scenarioCount;
Map<String, Double> centered = new LinkedHashMap<>();
for (Map.Entry<String, Double> entry : normalizedRaw.entrySet()) {
double raw = entry.getValue();
double calibrated = uniformPrior + (shrinkFactor * (raw - uniformPrior));
centered.put(entry.getKey(), Math.max(CALIBRATION_EPSILON, calibrated));
}
Map<String, Double> calibratedProbabilities = normalizeProbabilityMap(centered);
ProbabilityCalibration calibration = ProbabilityCalibration.from(shrinkFactor);
return new CalibrationResult(calibratedProbabilities, calibration);
}
private static double overlapScoreForScenario(ElliottScenario scenario, EnumMap<ElliottPhase, Integer> phaseCounts,
int knownPhaseCount, EnumMap<ScenarioType, Integer> typeCounts, int knownTypeCount, int bullishCount,
int bearishCount, int knownDirectionCount) {
double weightedSum = 0.0;
double weightTotal = 0.0;
ElliottPhase phase = scenario.currentPhase();
if (phase != ElliottPhase.NONE && knownPhaseCount > 0) {
weightedSum += PHASE_OVERLAP_WEIGHT * (phaseCounts.getOrDefault(phase, 0) / (double) knownPhaseCount);
weightTotal += PHASE_OVERLAP_WEIGHT;
}
ScenarioType type = scenario.type();
if (type != ScenarioType.UNKNOWN && knownTypeCount > 0) {
weightedSum += SCENARIO_TYPE_OVERLAP_WEIGHT * (typeCounts.getOrDefault(type, 0) / (double) knownTypeCount);
weightTotal += SCENARIO_TYPE_OVERLAP_WEIGHT;
}
if (scenario.hasKnownDirection() && knownDirectionCount > 0) {
double directionOverlap = scenario.isBullish() ? (double) bullishCount / knownDirectionCount
: (double) bearishCount / knownDirectionCount;
weightedSum += DIRECTION_OVERLAP_WEIGHT * directionOverlap;
weightTotal += DIRECTION_OVERLAP_WEIGHT;
}
if (weightTotal <= 0.0) {
return 0.0;
}
return weightedSum / weightTotal;
}
private static double confidenceContrastExponent(double minConfidence, double maxConfidence, int scenarioCount) {
if (scenarioCount <= 1) {
return 1.0;
}
double spread = maxConfidence - minConfidence;
if (spread <= 0.0) {
return MAX_CONFIDENCE_CONTRAST_EXPONENT;
}
double normalizedSpread = Math.min(1.0, spread / CONFIDENCE_SPREAD_TARGET);
return MIN_CONFIDENCE_CONTRAST_EXPONENT
+ (MAX_CONFIDENCE_CONTRAST_EXPONENT - MIN_CONFIDENCE_CONTRAST_EXPONENT) * (1.0 - normalizedSpread);
}
private static double applyConfidenceContrast(double confidence, double exponent) {
if (confidence <= 0.0) {
return 0.0;
}
if (exponent <= 1.0) {
return confidence;
}
return Math.pow(confidence, exponent);
}
private static Map<String, Double> normalizeProbabilityMap(Map<String, Double> probabilities) {
if (probabilities == null || probabilities.isEmpty()) {
return Map.of();
}
Map<String, Double> finitePositive = new LinkedHashMap<>();
double total = 0.0;
for (Map.Entry<String, Double> entry : probabilities.entrySet()) {
if (entry.getKey() == null) {
continue;
}
Double raw = entry.getValue();
if (raw == null || !Double.isFinite(raw)) {
continue;
}
double sanitized = Math.max(0.0, raw.doubleValue());
if (sanitized <= 0.0) {
continue;
}
finitePositive.put(entry.getKey(), sanitized);
total += sanitized;
}
if (finitePositive.isEmpty() || total <= CALIBRATION_EPSILON) {
return Map.of();
}
Map<String, Double> normalized = new LinkedHashMap<>();
for (Map.Entry<String, Double> entry : finitePositive.entrySet()) {
normalized.put(entry.getKey(), entry.getValue() / total);
}
return Map.copyOf(normalized);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
/**
* Encodes a chart plan as a base64-encoded PNG image string.
*
* @param chartWorkflow the chart workflow for rendering
* @param chartPlan the chart plan to encode
* @return base64-encoded PNG image string, or null if encoding fails
*/
private static String encodeChartAsBase64(ChartWorkflow chartWorkflow, ChartPlan chartPlan) {
try {
byte[] pngBytes = chartWorkflow.getChartAsByteArray(chartWorkflow.render(chartPlan));
return Base64.getEncoder().encodeToString(pngBytes);
} catch (Exception ex) {
LOG.warn("Chart encoding failed for chart plan {}: {}", chartPlan, ex.getMessage(), ex);
return null;
}
}
/**
* Safely converts a Num to a confidence score, treating invalid values as zero.
*
* @param num the numeric value to convert
* @return double value, or 0.0 if null or invalid
*/
private static double safeScoreValue(Num num) {
if (num == null || !Num.isValid(num)) {
return 0.0;
}
return num.doubleValue();
}
private static double roundScenarioProbability(double value) {
return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue();
}
/**
* Safely converts a Num to double, handling null and NaN cases.
*
* @param num the numeric value to convert
* @return double value, or NaN if null or invalid
*/
private static double safeDoubleValue(Num num) {
if (num == null || !Num.isValid(num)) {
return Double.NaN;
}
return num.doubleValue();
}
private static final class ScenarioProbabilityAdapter extends TypeAdapter<Double> {
@Override
public void write(JsonWriter out, Double value) throws IOException {
if (value == null || value.isNaN() || value.isInfinite()) {
out.nullValue();
return;
}
out.value(roundScenarioProbability(value));
}
@Override
public Double read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
return in.nextDouble();
}
}
}
@@ -0,0 +1,254 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveBtcMacroCycleDemo;
/**
* Consolidated preset launcher for Elliott Wave demos.
*
* <p>
* This entry point replaces multiple thin wrappers by supporting:
* <ul>
* <li>Ossified presets mapped to bundled datasets</li>
* <li>Live runs where users can specify any ticker</li>
* </ul>
*
* <p>
* Usage:
* <ul>
* <li>{@code ossified <btc|eth|sp500>}</li>
* <li>{@code live <Coinbase|YahooFinance> <ticker> [barDuration] [lookbackDays] [degree]}</li>
* </ul>
*
* <p>
* Examples:
* <ul>
* <li>{@code ossified btc}</li>
* <li>{@code live Coinbase BTC-USD PT1D 1825}</li>
* <li>{@code live YahooFinance AAPL PT1D 1460 PRIMARY}</li>
* </ul>
*
* @since 0.22.4
*/
public class ElliottWavePresetDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWavePresetDemo.class);
private static final String DEFAULT_BAR_DURATION = "PT1D";
private static final long DEFAULT_LOOKBACK_DAYS = 1825L;
private static final Path DEFAULT_BTC_MACRO_CHART_DIRECTORY = Path.of("temp", "charts");
/**
* Runs a preset Elliott Wave demo.
*
* @param args command-line arguments; see class-level usage documentation
*/
public static void main(String[] args) {
if (args == null || args.length == 0) {
logUsage();
return;
}
final String mode = normalize(args[0]);
switch (mode) {
case "ossified":
runOssified(args);
return;
case "live":
runLive(args);
return;
default:
LOG.error("Unknown mode '{}'. Expected 'ossified' or 'live'.", args[0]);
logUsage();
}
}
private static void runOssified(String[] args) {
if (args.length < 2) {
LOG.error("Missing ossified preset. Expected one of: btc, eth, sp500");
logUsage();
return;
}
Optional<OssifiedPreset> preset = OssifiedPreset.fromToken(args[1]);
if (preset.isEmpty()) {
LOG.error("Unknown ossified preset '{}'. Expected one of: btc, eth, sp500", args[1]);
logUsage();
return;
}
OssifiedPreset selected = preset.orElseThrow();
ElliottWaveIndicatorSuiteDemo.runOssifiedResource(ElliottWavePresetDemo.class, selected.resource(),
selected.seriesName(), selected.degreeOverride().orElse(null));
}
private static void runLive(String[] args) {
if (args.length < 3) {
LOG.error("Live mode expects at least dataSource and ticker");
logUsage();
return;
}
final String dataSource = Objects.requireNonNull(args[1], "dataSource");
final String ticker = Objects.requireNonNull(args[2], "ticker");
final String barDuration = args.length > 3 ? args[3] : DEFAULT_BAR_DURATION;
long lookbackDays = DEFAULT_LOOKBACK_DAYS;
if (args.length > 4) {
try {
lookbackDays = Long.parseLong(args[4]);
} catch (NumberFormatException ex) {
LOG.error("Invalid lookbackDays '{}': expected a positive integer", args[4]);
return;
}
}
if (lookbackDays <= 0) {
LOG.error("Invalid lookbackDays '{}': must be > 0", lookbackDays);
return;
}
ElliottDegree degree = null;
if (args.length > 5) {
try {
degree = ElliottDegree.valueOf(args[5].trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
LOG.error("Invalid degree '{}'", args[5]);
return;
}
}
if (shouldUseBtcMacroPreset(ticker, barDuration)) {
if (degree != null) {
LOG.info(
"Ignoring explicit degree '{}' for BTC daily macro preset; the validated macro profile selects its own structural interpretation.",
degree);
}
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofDays(lookbackDays));
Duration parsedDuration = parseBarDuration(barDuration);
BarSeries series = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(dataSource, ticker,
parsedDuration, startTime, endTime);
if (series == null || series.isEmpty()) {
LOG.error("Unable to load live BTC series for macro preset from {} {} {}", dataSource, ticker,
barDuration);
return;
}
ElliottWaveBtcMacroCycleDemo.runLivePreset(series, DEFAULT_BTC_MACRO_CHART_DIRECTORY);
return;
}
String[] suiteArgs = buildLiveSuiteArgs(dataSource, ticker, barDuration, lookbackDays, Instant.now(), degree);
ElliottWaveIndicatorSuiteDemo.main(suiteArgs);
}
static boolean shouldUseBtcMacroPreset(String ticker, String barDuration) {
if (ticker == null || barDuration == null) {
return false;
}
String normalizedTicker = ticker.trim().toUpperCase(Locale.ROOT);
String normalizedDuration = barDuration.trim().toUpperCase(Locale.ROOT);
return "BTC-USD".equals(normalizedTicker)
&& ("PT1D".equals(normalizedDuration) || "PT24H".equals(normalizedDuration));
}
static String[] buildLiveSuiteArgs(String dataSource, String ticker, String barDuration, long lookbackDays,
Instant endTime, ElliottDegree degree) {
Objects.requireNonNull(dataSource, "dataSource");
Objects.requireNonNull(ticker, "ticker");
Objects.requireNonNull(barDuration, "barDuration");
Objects.requireNonNull(endTime, "endTime");
if (lookbackDays <= 0) {
throw new IllegalArgumentException("lookbackDays must be > 0");
}
Instant startTime = endTime.minus(Duration.ofDays(lookbackDays));
if (degree == null) {
return new String[] { dataSource, ticker, barDuration, Long.toString(startTime.getEpochSecond()),
Long.toString(endTime.getEpochSecond()) };
}
return new String[] { dataSource, ticker, barDuration, degree.name(), Long.toString(startTime.getEpochSecond()),
Long.toString(endTime.getEpochSecond()) };
}
private static void logUsage() {
LOG.info("Usage:");
LOG.info(" ossified <btc|eth|sp500>");
LOG.info(" live <Coinbase|YahooFinance> <ticker> [barDuration] [lookbackDays] [degree]");
}
private static Duration parseBarDuration(String barDuration) {
Objects.requireNonNull(barDuration, "barDuration");
String normalized = barDuration.trim().toUpperCase(Locale.ROOT);
if (normalized.startsWith("PT") && normalized.endsWith("D")) {
final String dayString = normalized.substring(2, normalized.length() - 1);
try {
final int days = Integer.parseInt(dayString);
return Duration.ofHours(days * 24L);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid day count in barDuration: " + barDuration, e);
}
}
return Duration.parse(normalized);
}
private static String normalize(String value) {
if (value == null) {
return "";
}
return value.trim().toLowerCase(Locale.ROOT);
}
private enum OssifiedPreset {
BTC("Coinbase-BTC-USD-PT1D-20230616_20231011.json", "BTC-USD_PT1D@Coinbase (ossified)", Optional.empty()),
ETH("Coinbase-ETH-USD-PT1D-20241105_20251020.json", "ETH-USD_PT1D@Coinbase (ossified)", Optional.empty()),
SP500("YahooFinance-SP500-PT1D-20230616_20231011.json", "^GSPC_PT1D@YahooFinance (ossified)", Optional.empty());
private final String resource;
private final String seriesName;
private final Optional<ElliottDegree> degreeOverride;
OssifiedPreset(String resource, String seriesName, Optional<ElliottDegree> degreeOverride) {
this.resource = resource;
this.seriesName = seriesName;
this.degreeOverride = degreeOverride;
}
static Optional<OssifiedPreset> fromToken(String token) {
if (token == null) {
return Optional.empty();
}
String normalized = token.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "btc" -> Optional.of(BTC);
case "eth" -> Optional.of(ETH);
case "sp500", "s&p500", "gspc", "^gspc" -> Optional.of(SP500);
default -> Optional.empty();
};
}
String resource() {
return resource;
}
String seriesName() {
return seriesName;
}
Optional<ElliottDegree> degreeOverride() {
return degreeOverride;
}
}
}
@@ -0,0 +1,418 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import com.google.gson.Gson;
/**
* Loads and resolves the BTC anchor registry used by the CF-17 anchor-aware
* calibration study.
*
* <p>
* The registry stores broad calendar windows plus provenance. Resolution is
* deterministic: each anchor window is collapsed to the highest high or lowest
* low inside the local ossified BTC dataset, then the resolved anchors are
* partitioned chronologically into validation and holdout segments.
*
* <p>
* These committed windows are the canonical BTC daily validation set for the
* CF-17 macro study.
* {@link ElliottWaveAnchorCalibrationHarness#defaultBitcoinAnchors(BarSeries)}
* preserves that contract by translating the distance from the resolved
* extremum to each window edge into {@code toleranceBefore} and
* {@code toleranceAfter}, so acceptable match windows stay pinned to the
* registry instead of drifting via runtime heuristics.
*
* @since 0.22.7
*/
final class ElliottWaveAnchorRegistry {
static final String DEFAULT_RESOURCE = "/ta4jexamples/analysis/elliottwave/backtest/BTC-anchor-registry-v2.json";
private static final Gson GSON = new Gson();
private final String registryId;
private final String datasetResource;
private final String provenance;
private final List<AnchorSpec> anchors;
private ElliottWaveAnchorRegistry(String registryId, String datasetResource, String provenance,
List<AnchorSpec> anchors) {
this.registryId = requireText(registryId, "registryId");
this.datasetResource = requireText(datasetResource, "datasetResource");
this.provenance = requireText(provenance, "provenance");
if (anchors == null || anchors.isEmpty()) {
throw new IllegalArgumentException("anchors must not be empty");
}
this.anchors = anchors.stream().sorted(Comparator.comparing(AnchorSpec::windowStart)).toList();
}
/**
* Loads the registry from the given classpath resource.
*
* @param resource classpath resource path
* @return parsed registry
* @since 0.22.7
*/
static ElliottWaveAnchorRegistry load(String resource) {
String normalized = resource != null && resource.startsWith("/") ? resource : "/" + resource;
try (InputStream stream = ElliottWaveAnchorRegistry.class.getResourceAsStream(normalized)) {
if (stream == null) {
throw new IllegalStateException("Missing anchor registry resource: " + normalized);
}
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
RegistryDocument document = GSON.fromJson(reader, RegistryDocument.class);
Objects.requireNonNull(document, "document");
List<RegistryAnchor> rawAnchors = document.anchors();
if (rawAnchors == null) {
throw new IllegalArgumentException("Anchor registry " + normalized + " is missing \"anchors\"");
}
List<AnchorSpec> anchorSpecs = new ArrayList<>(rawAnchors.size());
for (int index = 0; index < rawAnchors.size(); index++) {
RegistryAnchor anchor = rawAnchors.get(index);
if (anchor == null) {
throw new IllegalArgumentException(
"Anchor registry " + normalized + " contains null anchor at index " + index);
}
anchorSpecs.add(anchor.toSpec());
}
return new ElliottWaveAnchorRegistry(document.registryId(), document.datasetResource(),
document.provenance(), anchorSpecs);
}
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new IllegalStateException("Failed to load anchor registry: " + normalized, ex);
}
}
/**
* Resolves every stored anchor window against the supplied series and assigns
* the trailing anchors to holdout.
*
* <p>
* Resolution never expands or contracts the committed calendar windows. The
* JSON window bounds remain authoritative; the selected bar is simply the local
* extremum inside each stored range.
*
* @param series BTC series used for resolution
* @param holdoutCount trailing resolved anchors reserved for holdout
* @return resolved anchors in chronological order
* @since 0.22.7
*/
List<ResolvedAnchor> resolve(BarSeries series, int holdoutCount) {
Objects.requireNonNull(series, "series");
List<ResolvedAnchor> resolved = new ArrayList<>(anchors.size());
for (AnchorSpec anchor : anchors) {
resolved.add(resolve(series, anchor));
}
resolved.sort(Comparator.comparing(ResolvedAnchor::resolvedTime));
if (holdoutCount < 0 || holdoutCount > resolved.size()) {
throw new IllegalArgumentException("holdoutCount must be between 0 and " + resolved.size());
}
int validationCutoff = resolved.size() - holdoutCount;
List<ResolvedAnchor> partitioned = new ArrayList<>(resolved.size());
for (int i = 0; i < resolved.size(); i++) {
AnchorPartition partition = i < validationCutoff ? AnchorPartition.VALIDATION : AnchorPartition.HOLDOUT;
partitioned.add(resolved.get(i).withPartition(partition));
}
return List.copyOf(partitioned);
}
/**
* @return registry identifier
* @since 0.22.7
*/
String registryId() {
return registryId;
}
/**
* @return backing BTC dataset resource name
* @since 0.22.7
*/
String datasetResource() {
return datasetResource;
}
/**
* @return provenance summary
* @since 0.22.7
*/
String provenance() {
return provenance;
}
/**
* @return stored anchor specs
* @since 0.22.7
*/
List<AnchorSpec> anchors() {
return anchors;
}
private static ResolvedAnchor resolve(BarSeries series, AnchorSpec anchor) {
long midpointEpochMillis = anchor.windowStart().toEpochMilli()
+ ((anchor.windowEnd().toEpochMilli() - anchor.windowStart().toEpochMilli()) / 2L);
int bestIndex = -1;
double bestPrice = anchor.kind() == AnchorKind.TOP ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
long bestDistance = Long.MAX_VALUE;
boolean foundBarInWindow = false;
boolean foundValidPrice = false;
for (int index = series.getBeginIndex(); index <= series.getEndIndex(); index++) {
Bar bar = series.getBar(index);
Instant endTime = bar.getEndTime();
if (endTime.isBefore(anchor.windowStart()) || endTime.isAfter(anchor.windowEnd())) {
continue;
}
foundBarInWindow = true;
double candidatePrice = priceFor(bar, anchor.kind());
if (!Double.isFinite(candidatePrice)) {
continue;
}
foundValidPrice = true;
long candidateDistance = Math.abs(endTime.toEpochMilli() - midpointEpochMillis);
if (shouldReplaceBest(anchor.kind(), candidatePrice, bestPrice, candidateDistance, bestDistance, index,
bestIndex)) {
bestIndex = index;
bestPrice = candidatePrice;
bestDistance = candidateDistance;
}
}
if (bestIndex < 0) {
if (!foundBarInWindow) {
throw new IllegalStateException("No bars found inside anchor window " + anchor.id());
}
if (!foundValidPrice) {
throw new IllegalStateException("Bars found inside anchor window " + anchor.id() + " but no finite "
+ anchor.kind() + " prices were available");
}
throw new IllegalStateException("Failed to resolve anchor window " + anchor.id());
}
return new ResolvedAnchor(anchor, bestIndex, series.getBar(bestIndex).getEndTime(), bestPrice,
AnchorPartition.VALIDATION);
}
private static boolean better(AnchorKind kind, double candidatePrice, double bestPrice) {
if (kind == AnchorKind.TOP) {
return candidatePrice > bestPrice;
}
return candidatePrice < bestPrice;
}
private static boolean shouldReplaceBest(AnchorKind kind, double candidatePrice, double bestPrice,
long candidateDistance, long bestDistance, int candidateIndex, int bestIndex) {
return bestIndex < 0 || better(kind, candidatePrice, bestPrice)
|| (same(candidatePrice, bestPrice) && (candidateDistance < bestDistance
|| (candidateDistance == bestDistance && candidateIndex < bestIndex)));
}
private static boolean same(double left, double right) {
return Double.compare(left, right) == 0;
}
private static double priceFor(Bar bar, AnchorKind kind) {
Objects.requireNonNull(bar, "bar");
double value = kind == AnchorKind.TOP ? bar.getHighPrice().doubleValue() : bar.getLowPrice().doubleValue();
if (Double.isNaN(value) || Double.isInfinite(value)) {
value = bar.getClosePrice().doubleValue();
}
return value;
}
private static String requireText(String value, String field) {
Objects.requireNonNull(value, field);
if (value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
private static AnchorKind parseKind(String kind, String anchorId) {
String normalizedKind = requireText(kind, "kind");
try {
return AnchorKind.valueOf(normalizedKind);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unknown anchor kind '" + normalizedKind + "' for anchor " + anchorId,
ex);
}
}
private static Instant parseInstant(String value, String field, String anchorId) {
String normalizedValue = requireText(value, field);
try {
return Instant.parse(normalizedValue);
} catch (DateTimeParseException ex) {
throw new IllegalArgumentException("Invalid " + field + " '" + normalizedValue + "' for anchor " + anchorId,
ex);
}
}
private static ElliottPhase parsePhase(String phase, String anchorId) {
String normalizedPhase = requireText(phase, "phase");
try {
return ElliottPhase.valueOf(normalizedPhase);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException(
"Unknown expected phase '" + normalizedPhase + "' for anchor " + anchorId, ex);
}
}
/**
* Anchor direction used when resolving local extrema.
*
* @since 0.22.7
*/
enum AnchorKind {
TOP, BOTTOM
}
/**
* Chronological anchor partition used for validation-versus-holdout reporting.
*
* @since 0.22.7
*/
enum AnchorPartition {
VALIDATION, HOLDOUT
}
/**
* Immutable anchor specification loaded from the registry file.
*
* @param id stable anchor identifier
* @param label human-readable label
* @param kind anchor direction
* @param windowStart inclusive window start
* @param windowEnd inclusive window end
* @param expectedPhases acceptable Elliott phases for a hit
* @param source provenance string
* @param notes optional note
* @since 0.22.7
*/
record AnchorSpec(String id, String label, AnchorKind kind, Instant windowStart, Instant windowEnd,
Set<ElliottPhase> expectedPhases, String source, String notes) {
/**
* Creates a validated anchor specification.
*/
AnchorSpec {
id = requireText(id, "id");
label = requireText(label, "label");
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(windowStart, "windowStart");
Objects.requireNonNull(windowEnd, "windowEnd");
if (windowEnd.isBefore(windowStart)) {
throw new IllegalArgumentException("windowEnd must not be before windowStart");
}
expectedPhases = expectedPhases == null || expectedPhases.isEmpty() ? EnumSet.noneOf(ElliottPhase.class)
: Collections.unmodifiableSet(EnumSet.copyOf(expectedPhases));
if (expectedPhases.isEmpty()) {
throw new IllegalArgumentException("expectedPhases must not be empty");
}
source = requireText(source, "source");
notes = notes == null ? "" : notes.trim();
}
}
/**
* Concrete anchor resolved against the BTC series.
*
* @param spec original anchor specification
* @param decisionIndex resolved series index
* @param resolvedTime resolved bar end time
* @param resolvedPrice resolved bar high/low used for matching
* @param partition validation or holdout
* @since 0.22.7
*/
record ResolvedAnchor(AnchorSpec spec, int decisionIndex, Instant resolvedTime, double resolvedPrice,
AnchorPartition partition) {
/**
* Creates a validated resolved anchor.
*/
ResolvedAnchor {
Objects.requireNonNull(spec, "spec");
if (decisionIndex < 0) {
throw new IllegalArgumentException("decisionIndex must be >= 0");
}
Objects.requireNonNull(resolvedTime, "resolvedTime");
Objects.requireNonNull(partition, "partition");
if (Double.isNaN(resolvedPrice) || Double.isInfinite(resolvedPrice)) {
throw new IllegalArgumentException("resolvedPrice must be finite");
}
}
/**
* Creates a copy with a different partition.
*
* @param newPartition new partition
* @return copied resolved anchor
* @since 0.22.7
*/
ResolvedAnchor withPartition(AnchorPartition newPartition) {
return new ResolvedAnchor(spec, decisionIndex, resolvedTime, resolvedPrice, newPartition);
}
}
/**
* Raw registry document loaded through Gson.
*
* @param registryId registry identifier
* @param datasetResource dataset resource name
* @param provenance provenance summary
* @param anchors raw anchors
*/
private record RegistryDocument(String registryId, String datasetResource, String provenance,
List<RegistryAnchor> anchors) {
}
/**
* Raw anchor entry loaded through Gson.
*
* @param id stable anchor identifier
* @param label human-readable label
* @param kind anchor direction
* @param windowStart inclusive window start in ISO-8601 form
* @param windowEnd inclusive window end in ISO-8601 form
* @param expectedPhases accepted Elliott phases as enum names
* @param source provenance string
* @param notes optional note
*/
private record RegistryAnchor(String id, String label, String kind, String windowStart, String windowEnd,
List<String> expectedPhases, String source, String notes) {
private AnchorSpec toSpec() {
Set<ElliottPhase> phases = EnumSet.noneOf(ElliottPhase.class);
for (String phase : Objects.requireNonNull(expectedPhases, "expectedPhases")) {
phases.add(parsePhase(phase, id));
}
return new AnchorSpec(id, label, parseKind(kind, id), parseInstant(windowStart, "windowStart", id),
parseInstant(windowEnd, "windowEnd", id), phases, source, notes);
}
}
}
@@ -0,0 +1,201 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.awt.Color;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jfree.chart.JFreeChart;
import org.ta4j.core.BarSeries;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.CurrentCycleSummary;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.DirectionalCycleSummary;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.HypothesisResult;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.MacroStudy;
import ta4jexamples.analysis.elliottwave.backtest.ElliottWaveMacroCycleDemo.ProfileScoreSummary;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* BTC-specific wrapper around the generic macro-cycle demo.
*
* <p>
* The generic demo now owns both the historical macro study and the live
* current-cycle reporting flow. This wrapper exists to keep the fixed BTC
* dataset entry points, the locked BTC anchor truth set, and the canonical BTC
* chart/summary filenames stable for users and regression tests.
*
* @since 0.22.4
*/
public final class ElliottWaveBtcMacroCycleDemo {
static final String RESULT_PREFIX = "EW_BTC_MACRO_DEMO: ";
static final String LIVE_RESULT_PREFIX = "EW_BTC_LIVE_MACRO: ";
static final Path DEFAULT_CHART_DIRECTORY = Path.of("temp", "charts");
static final String DEFAULT_CHART_FILE_NAME = "elliott-wave-btc-macro-cycles";
static final String DEFAULT_SUMMARY_FILE_NAME = "elliott-wave-btc-macro-cycles-summary.json";
static final String DEFAULT_LIVE_CHART_FILE_NAME = "elliott-wave-btc-live-macro-current-cycle";
static final String DEFAULT_LIVE_SUMMARY_FILE_NAME = "elliott-wave-btc-live-macro-current-cycle-summary.json";
static final int DEFAULT_CHART_WIDTH = 3840;
static final int DEFAULT_CHART_HEIGHT = 2160;
static final int MIN_CORE_SEGMENT_SCENARIOS = 1000;
static final int MAX_CORE_ANCHOR_DRIFT_BARS = 3;
static final double DEFAULT_ACCEPTED_SEGMENT_SCORE = 0.64;
static final int LABEL_CLUSTER_BAR_GAP = 18;
static final Color BULLISH_LEG_COLOR = new Color(0x66BB6A);
static final Color BEARISH_LEG_COLOR = new Color(0xEF5350);
static final Color BULLISH_WAVE_COLOR = new Color(0x81C784);
static final Color BEARISH_WAVE_COLOR = new Color(0xE57373);
static final Color BULLISH_CANDIDATE_COLOR = new Color(0xC8E6C9);
static final Color BEARISH_CANDIDATE_COLOR = new Color(0xFFCDD2);
static final Color ANCHOR_OVERLAY_COLOR = new Color(0xCFD8DC);
static final double WAVE_LABEL_FONT_SCALE = 3.0;
static final double EPSILON = 1e-9;
private static final Logger LOG = LogManager.getLogger(ElliottWaveBtcMacroCycleDemo.class);
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String BTC_LIVE_HISTORICAL_STATUS = "BTC macro profile prevalidated from historical cycle truth set";
private ElliottWaveBtcMacroCycleDemo() {
}
/**
* Runs the BTC macro-cycle study and logs the resulting JSON summary.
*
* @param args unused
*/
public static void main(final String[] args) {
final DemoReport report = generateReport(DEFAULT_CHART_DIRECTORY);
LOG.info("{}{}", RESULT_PREFIX, report.toJson());
}
/**
* Runs the series-native live BTC macro preset on the supplied series and logs
* the resulting JSON summary.
*
* @param series live or loaded BTC series to analyze
* @param chartDirectory directory for the saved current-cycle chart and JSON
* summary
* @since 0.22.4
*/
public static void runLivePreset(final BarSeries series, final Path chartDirectory) {
ElliottWaveMacroCycleDemo.runLivePreset(series, chartDirectory, DEFAULT_LIVE_CHART_FILE_NAME,
DEFAULT_LIVE_SUMMARY_FILE_NAME, "btc-usd", BTC_LIVE_HISTORICAL_STATUS);
}
static DemoReport generateReport(final Path chartDirectory) {
final BarSeries series = requireSeries(ElliottWaveAnchorCalibrationHarness.BTC_RESOURCE,
ElliottWaveAnchorCalibrationHarness.BTC_SERIES_NAME);
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry = ElliottWaveAnchorCalibrationHarness
.defaultBitcoinAnchors(series);
return DemoReport.from(ElliottWaveMacroCycleDemo.generateHistoricalReport(series, registry, chartDirectory));
}
static DemoReport generateReport(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry, final Path chartDirectory) {
return DemoReport.from(ElliottWaveMacroCycleDemo.generateHistoricalReport(series, registry, chartDirectory));
}
static LivePresetReport generateLivePresetReport(final BarSeries series, final Path chartDirectory) {
return LivePresetReport.from(ElliottWaveMacroCycleDemo.generateLivePresetReport(series, chartDirectory,
DEFAULT_LIVE_CHART_FILE_NAME, DEFAULT_LIVE_SUMMARY_FILE_NAME, BTC_LIVE_HISTORICAL_STATUS));
}
static Optional<Path> saveMacroCycleChart(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry, final Path chartDirectory) {
return ElliottWaveMacroCycleDemo.saveHistoricalChart(series, registry, chartDirectory);
}
static JFreeChart renderMacroCycleChart(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry) {
return ElliottWaveMacroCycleDemo.renderHistoricalChart(series, registry);
}
static JFreeChart renderMacroCycleChart(final BarSeries series, final MacroStudy study) {
return ElliottWaveMacroCycleDemo.renderHistoricalChart(series, study);
}
static MacroStudy evaluateMacroStudy(final BarSeries series,
final ElliottWaveAnchorCalibrationHarness.AnchorRegistry registry) {
return ElliottWaveMacroCycleDemo.evaluateMacroStudy(series, registry);
}
private static BarSeries requireSeries(final String resource, final String seriesName) {
final BarSeries series = OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveBtcMacroCycleDemo.class,
resource, seriesName, LOG);
if (series == null) {
throw new IllegalStateException("Unable to load required resource " + resource);
}
return series;
}
record DemoReport(String registryVersion, String datasetResource, String baselineProfileId,
String selectedProfileId, String selectedHypothesisId, boolean historicalFitPassed,
String harnessDecisionRationale, String chartPath, String summaryPath, String structureSource,
List<ProfileScoreSummary> profileScores, List<DirectionalCycleSummary> cycles,
List<HypothesisResult> hypotheses, CurrentCycleSummary currentCycle) {
DemoReport {
Objects.requireNonNull(registryVersion, "registryVersion");
Objects.requireNonNull(datasetResource, "datasetResource");
Objects.requireNonNull(baselineProfileId, "baselineProfileId");
Objects.requireNonNull(selectedProfileId, "selectedProfileId");
Objects.requireNonNull(selectedHypothesisId, "selectedHypothesisId");
Objects.requireNonNull(harnessDecisionRationale, "harnessDecisionRationale");
Objects.requireNonNull(chartPath, "chartPath");
Objects.requireNonNull(summaryPath, "summaryPath");
Objects.requireNonNull(structureSource, "structureSource");
profileScores = profileScores == null ? List.of() : List.copyOf(profileScores);
cycles = cycles == null ? List.of() : List.copyOf(cycles);
hypotheses = hypotheses == null ? List.of() : List.copyOf(hypotheses);
Objects.requireNonNull(currentCycle, "currentCycle");
}
String toJson() {
return GSON.toJson(this);
}
static DemoReport from(final ElliottWaveMacroCycleDemo.DemoReport report) {
return new DemoReport(report.registryVersion(), report.datasetResource(), report.baselineProfileId(),
report.selectedProfileId(), report.selectedHypothesisId(), report.historicalFitPassed(),
report.harnessDecisionRationale(), report.chartPath(), report.summaryPath(),
report.structureSource(), report.profileScores(), report.cycles(), report.hypotheses(),
report.currentCycle());
}
}
record LivePresetReport(String seriesName, String startTimeUtc, String latestTimeUtc, String selectedProfileId,
String selectedHypothesisId, String chartPath, String summaryPath, String structureSource,
CurrentCycleSummary currentCycle) {
LivePresetReport {
Objects.requireNonNull(seriesName, "seriesName");
Objects.requireNonNull(startTimeUtc, "startTimeUtc");
Objects.requireNonNull(latestTimeUtc, "latestTimeUtc");
Objects.requireNonNull(selectedProfileId, "selectedProfileId");
Objects.requireNonNull(selectedHypothesisId, "selectedHypothesisId");
Objects.requireNonNull(chartPath, "chartPath");
Objects.requireNonNull(summaryPath, "summaryPath");
Objects.requireNonNull(structureSource, "structureSource");
Objects.requireNonNull(currentCycle, "currentCycle");
}
String toJson() {
return GSON.toJson(this);
}
static LivePresetReport from(final ElliottWaveMacroCycleDemo.LivePresetReport report) {
return new LivePresetReport(report.seriesName(), report.startTimeUtc(), report.latestTimeUtc(),
report.selectedProfileId(), report.selectedHypothesisId(), report.chartPath(), report.summaryPath(),
report.structureSource(), report.currentCycle());
}
}
}
@@ -0,0 +1,234 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottLogicProfile;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
/**
* Infers broad macro-cycle anchors directly from a series-wide Elliott swing
* set.
*
* <p>
* This detector is the anchor-free path for {@link ElliottWaveMacroCycleDemo}.
* It runs the same full-history core analysis profile used by the BTC macro
* demo, then collapses the processed swing pivots into major cycle turns by
* keeping only all-time-high peaks that are followed by severe drawdowns before
* the next higher high. The detector keeps the deepest corrective low inside
* that regime, then rejects short-lived crashes that never mature into broad
* macro cycles. The resulting top/bottom chain is converted into an
* {@link ElliottWaveAnchorCalibrationHarness.AnchorRegistry} so the existing
* historical chart and JSON report flow can be reused unchanged.
*
* <p>
* The heuristic is intentionally simple:
* <ul>
* <li>build a full-history orthodox swing map with no curated anchors</li>
* <li>track each new all-time-high pivot</li>
* <li>keep only peaks that lead to a material trough before the next higher
* high</li>
* </ul>
*
* <p>
* For BTC full-history runs this naturally recovers the committed 2011, 2013,
* 2017, and 2021 macro tops plus their following corrective lows closely enough
* to compare against the truth-set registry.
*
* @since 0.22.7
*/
final class ElliottWaveMacroCycleDetector {
private static final String INFERRED_REGISTRY_VERSION = "inferred-macro-cycle-anchors-v1";
private static final int HOLDOUT_ANCHOR_COUNT = 2;
private static final double MIN_MACRO_DRAWDOWN_FRACTION = 0.55;
private static final Duration MIN_MACRO_SPAN = Duration.ofDays(120);
private static final Duration DEFAULT_TOLERANCE = Duration.ofDays(45);
private ElliottWaveMacroCycleDetector() {
}
/**
* Infers a macro-cycle anchor registry for the supplied series.
*
* @param series series to analyze
* @return inferred anchor registry
* @since 0.22.7
*/
static ElliottWaveAnchorCalibrationHarness.AnchorRegistry inferAnchorRegistry(final BarSeries series) {
Objects.requireNonNull(series, "series");
final ElliottWaveAnalysisResult analysis = buildRunner().analyze(series);
final ElliottAnalysisResult baseAnalysis = analysis.analysisFor(ElliottDegree.MINOR)
.orElseThrow(() -> new IllegalStateException("Missing base-degree MINOR analysis"))
.analysis();
final List<Pivot> pivots = toPivots(series, baseAnalysis.rawSwings());
final List<MacroDrawdown> macroDrawdowns = detectMacroDrawdowns(pivots);
if (macroDrawdowns.isEmpty()) {
throw new IllegalStateException("Unable to infer macro-cycle anchors from processed swings");
}
return new ElliottWaveAnchorCalibrationHarness.AnchorRegistry(INFERRED_REGISTRY_VERSION,
datasetResource(series), inferredProvenance(series), toAnchors(macroDrawdowns));
}
private static ElliottWaveAnalysisRunner buildRunner() {
return ElliottWaveAnalysisRunner.builder()
.degree(ElliottDegree.MINOR)
.logicProfile(ElliottLogicProfile.ORTHODOX_CLASSICAL)
.maxScenarios(ElliottLogicProfile.ORTHODOX_CLASSICAL.maxScenarios())
.minConfidence(0.0)
.seriesSelector((inputSeries, ignoredDegree) -> inputSeries)
.build();
}
private static List<Pivot> toPivots(final BarSeries series, final List<ElliottSwing> processedSwings) {
if (processedSwings == null || processedSwings.isEmpty()) {
return List.of();
}
final List<Pivot> pivots = new ArrayList<>(processedSwings.size() + 1);
final ElliottSwing firstSwing = processedSwings.getFirst();
pivots.add(new Pivot(firstSwing.fromIndex(), series.getBar(firstSwing.fromIndex()).getEndTime(),
firstSwing.fromPrice().doubleValue(), !firstSwing.isRising()));
for (final ElliottSwing swing : processedSwings) {
pivots.add(new Pivot(swing.toIndex(), series.getBar(swing.toIndex()).getEndTime(),
swing.toPrice().doubleValue(), swing.isRising()));
}
return List.copyOf(pivots);
}
private static List<MacroDrawdown> detectMacroDrawdowns(final List<Pivot> pivots) {
final List<Pivot> allTimeHighs = new ArrayList<>();
double bestHigh = Double.NEGATIVE_INFINITY;
for (final Pivot pivot : pivots) {
if (!pivot.high() || pivot.price() <= bestHigh) {
continue;
}
allTimeHighs.add(pivot);
bestHigh = pivot.price();
}
final List<MacroDrawdown> drawdowns = new ArrayList<>();
for (int index = 0; index < allTimeHighs.size(); index++) {
final Pivot top = allTimeHighs.get(index);
final Instant nextHigherHighTime = index + 1 < allTimeHighs.size() ? allTimeHighs.get(index + 1).at()
: null;
Pivot trough = lowestLowAfter(top, pivots, nextHigherHighTime);
if (trough == null) {
continue;
}
final double drawdownFraction = (top.price() - trough.price()) / top.price();
final Duration span = Duration.between(top.at(), trough.at());
if (drawdownFraction >= MIN_MACRO_DRAWDOWN_FRACTION && span.compareTo(MIN_MACRO_SPAN) >= 0) {
drawdowns.add(new MacroDrawdown(top, trough, drawdownFraction));
}
}
return List.copyOf(drawdowns);
}
private static Pivot lowestLowAfter(final Pivot top, final List<Pivot> pivots, final Instant nextHigherHighTime) {
Pivot trough = null;
for (final Pivot pivot : pivots) {
if (pivot.at().isBefore(top.at()) || pivot.at().equals(top.at()) || pivot.high()) {
continue;
}
if (nextHigherHighTime != null && !pivot.at().isBefore(nextHigherHighTime)) {
break;
}
if (trough == null || pivot.price() < trough.price()) {
trough = pivot;
}
}
return trough;
}
private static List<ElliottWaveAnchorCalibrationHarness.Anchor> toAnchors(
final List<MacroDrawdown> macroDrawdowns) {
final int anchorCount = macroDrawdowns.size() * 2;
final int validationCutoff = Math.max(0, anchorCount - HOLDOUT_ANCHOR_COUNT);
final List<ElliottWaveAnchorCalibrationHarness.Anchor> anchors = new ArrayList<>(anchorCount);
int anchorIndex = 0;
for (int index = 0; index < macroDrawdowns.size(); index++) {
final MacroDrawdown drawdown = macroDrawdowns.get(index);
anchors.add(toAnchor(index, drawdown.top(), ElliottWaveAnchorCalibrationHarness.AnchorType.TOP,
partitionFor(anchorIndex, validationCutoff)));
anchorIndex++;
anchors.add(toAnchor(index, drawdown.trough(), ElliottWaveAnchorCalibrationHarness.AnchorType.BOTTOM,
partitionFor(anchorIndex, validationCutoff)));
anchorIndex++;
}
return List.copyOf(anchors);
}
private static ElliottWaveAnchorCalibrationHarness.Anchor toAnchor(final int sequence, final Pivot pivot,
final ElliottWaveAnchorCalibrationHarness.AnchorType type,
final ElliottWaveAnchorRegistry.AnchorPartition partition) {
final Set<ElliottPhase> expectedPhases = type == ElliottWaveAnchorCalibrationHarness.AnchorType.TOP
? EnumSet.of(ElliottPhase.WAVE5)
: EnumSet.of(ElliottPhase.CORRECTIVE_C);
final String direction = type == ElliottWaveAnchorCalibrationHarness.AnchorType.TOP ? "top" : "bottom";
return new ElliottWaveAnchorCalibrationHarness.Anchor("inferred-" + direction + "-" + (sequence + 1), type,
pivot.at(), DEFAULT_TOLERANCE, DEFAULT_TOLERANCE, expectedPhases, partition,
"Inferred from full-history orthodox Elliott swings and macro drawdown turns.");
}
private static ElliottWaveAnchorRegistry.AnchorPartition partitionFor(int anchorIndex, int validationCutoff) {
return anchorIndex < validationCutoff ? ElliottWaveAnchorRegistry.AnchorPartition.VALIDATION
: ElliottWaveAnchorRegistry.AnchorPartition.HOLDOUT;
}
private static String datasetResource(final BarSeries series) {
final String name = series.getName();
return name == null || name.isBlank() ? "in-memory-series" : name;
}
private static String inferredProvenance(final BarSeries series) {
return "Inferred from full-history orthodox Elliott swing pivots for " + datasetResource(series)
+ " without a curated anchor registry.";
}
private record Pivot(int index, Instant at, double price, boolean high) {
private Pivot {
Objects.requireNonNull(at, "at");
if (index < 0) {
throw new IllegalArgumentException("index must be >= 0");
}
if (!Double.isFinite(price)) {
throw new IllegalArgumentException("price must be finite");
}
}
}
private record MacroDrawdown(Pivot top, Pivot trough, double drawdownFraction) {
private MacroDrawdown {
Objects.requireNonNull(top, "top");
Objects.requireNonNull(trough, "trough");
if (!top.high()) {
throw new IllegalArgumentException("top must be a high pivot");
}
if (trough.high()) {
throw new IllegalArgumentException("trough must be a low pivot");
}
if (trough.index() <= top.index()) {
throw new IllegalArgumentException("trough must follow top");
}
if (!Double.isFinite(drawdownFraction) || drawdownFraction <= 0.0) {
throw new IllegalArgumentException("drawdownFraction must be positive and finite");
}
}
}
}
@@ -0,0 +1,374 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.time.format.DateTimeFormatter;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwingCompressor;
import org.ta4j.core.indicators.elliott.ElliottTrendBias;
import org.ta4j.core.indicators.elliott.ElliottTrendBiasIndicator;
import org.ta4j.core.indicators.elliott.ElliottWaveFacade;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.CsvFileBarSeriesDataSource;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Backtests and walk-forward tests Elliott Wave trend bias predictions using
* ossified datasets.
*
* <p>
* This demo evaluates whether the trend bias derived from Elliott Wave
* scenarios correctly predicts the direction of price action over a fixed
* lookahead horizon. It runs a full-history backtest and a rolling walk-forward
* evaluation over multiple instruments.
*
* @since 0.22.2
*/
public class ElliottWaveTrendBacktest {
private static final Logger LOG = LogManager.getLogger(ElliottWaveTrendBacktest.class);
private static final double DEFAULT_FIB_TOLERANCE = 0.25;
private static final int DEFAULT_LOOKAHEAD_BARS = 20;
private static final double DEFAULT_MIN_STRENGTH = 0.20;
private static final int DEFAULT_WALK_FORWARD_WINDOW = 180;
private static final int DEFAULT_WALK_FORWARD_STEP = 60;
/**
* Runs the trend-bias backtest and walk-forward evaluation demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
List<DatasetSpec> datasets = List.of(
DatasetSpec.json("Coinbase-ETH-USD-PT1D-20160517_20251028.json", "ETH-USD PT1D (Coinbase)",
ElliottDegree.PRIMARY),
DatasetSpec.csv("AAPL-PT1D-20130102_20131231.csv", "AAPL PT1D (Yahoo)", ElliottDegree.MINOR));
for (DatasetSpec dataset : datasets) {
BarSeries series = dataset.loadSeries();
if (series == null || series.isEmpty()) {
LOG.warn("Dataset {} could not be loaded or is empty.", dataset.label());
continue;
}
LOG.info("=== Elliott Trend Bias Backtest: {} ===", dataset.label());
LOG.info("Bars: {} | Range: {}", series.getBarCount(), describeRange(series));
ElliottWaveFacade facade = buildFacade(series, dataset.degree());
ElliottTrendBiasIndicator trendBiasIndicator = facade.trendBias();
TrendAccuracy backtest = evaluateWindow(series, trendBiasIndicator, series.getBeginIndex(),
series.getEndIndex(), DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH);
logAccuracy("Backtest", backtest, DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH);
runWalkForward(series, trendBiasIndicator, DEFAULT_LOOKAHEAD_BARS, DEFAULT_MIN_STRENGTH,
DEFAULT_WALK_FORWARD_WINDOW, DEFAULT_WALK_FORWARD_STEP);
}
}
/**
* Builds an Elliott Wave facade with zigzag swings and compression.
*
* @param series bar series to analyze
* @param degree Elliott wave degree to use
* @return configured Elliott Wave facade
*/
private static ElliottWaveFacade buildFacade(BarSeries series, ElliottDegree degree) {
ElliottSwingCompressor compressor = new ElliottSwingCompressor(series);
return ElliottWaveFacade.zigZag(series, degree, Optional.of(series.numFactory().numOf(DEFAULT_FIB_TOLERANCE)),
Optional.of(compressor));
}
/**
* Evaluates directional accuracy across a contiguous bar window.
*
* @param series bar series being evaluated
* @param trendBiasIndicator indicator supplying trend bias values
* @param startIndex first index to evaluate
* @param endIndex last index to evaluate
* @param lookaheadBars bars to look ahead for validation
* @param minStrength minimum bias strength to include
* @return aggregated accuracy statistics
*/
private static TrendAccuracy evaluateWindow(BarSeries series, ElliottTrendBiasIndicator trendBiasIndicator,
int startIndex, int endIndex, int lookaheadBars, double minStrength) {
int effectiveStart = Math.max(startIndex, series.getBeginIndex());
int effectiveEnd = Math.min(endIndex, series.getEndIndex());
int unstableBars = trendBiasIndicator.getCountOfUnstableBars();
effectiveStart = Math.max(effectiveStart, unstableBars);
int lastEvaluationIndex = effectiveEnd - Math.max(1, lookaheadBars);
if (lastEvaluationIndex < effectiveStart) {
return TrendAccuracy.empty();
}
int totalPredictions = 0;
int correctPredictions = 0;
int bullishPredictions = 0;
int bullishCorrect = 0;
int bearishPredictions = 0;
int bearishCorrect = 0;
int skipped = 0;
for (int i = effectiveStart; i <= lastEvaluationIndex; i++) {
ElliottTrendBias bias = trendBiasIndicator.getValue(i);
if (bias == null || bias.isUnknown() || bias.isNeutral()) {
skipped++;
continue;
}
double strength = bias.strength();
if (Double.isNaN(strength) || strength < minStrength) {
skipped++;
continue;
}
Num currentClose = series.getBar(i).getClosePrice();
Num futureClose = series.getBar(i + lookaheadBars).getClosePrice();
if (Num.isNaNOrNull(currentClose) || Num.isNaNOrNull(futureClose)) {
skipped++;
continue;
}
boolean bullish = bias.isBullish();
boolean correct = bullish ? futureClose.isGreaterThan(currentClose) : futureClose.isLessThan(currentClose);
totalPredictions++;
if (bullish) {
bullishPredictions++;
if (correct) {
bullishCorrect++;
}
} else {
bearishPredictions++;
if (correct) {
bearishCorrect++;
}
}
if (correct) {
correctPredictions++;
}
}
return new TrendAccuracy(totalPredictions, correctPredictions, bullishPredictions, bullishCorrect,
bearishPredictions, bearishCorrect, skipped);
}
/**
* Runs a rolling walk-forward evaluation across the series.
*
* @param series bar series being evaluated
* @param trendBiasIndicator indicator supplying trend bias values
* @param lookaheadBars bars to look ahead for validation
* @param minStrength minimum bias strength to include
* @param windowSize rolling window size in bars
* @param stepSize step size between windows in bars
*/
private static void runWalkForward(BarSeries series, ElliottTrendBiasIndicator trendBiasIndicator,
int lookaheadBars, double minStrength, int windowSize, int stepSize) {
int startIndex = Math.max(series.getBeginIndex(), trendBiasIndicator.getCountOfUnstableBars());
int endIndex = series.getEndIndex();
int availableBars = endIndex - startIndex + 1;
if (availableBars <= 0) {
LOG.info("Walk-forward skipped: insufficient bars after unstable period.");
return;
}
int effectiveWindow = Math.min(windowSize, availableBars);
int effectiveStep = Math.max(1, stepSize);
LOG.info("Walk-forward windows: size={} bars, step={} bars", effectiveWindow, effectiveStep);
for (int windowStart = startIndex; windowStart + effectiveWindow
- 1 <= endIndex; windowStart += effectiveStep) {
int windowEnd = windowStart + effectiveWindow - 1;
TrendAccuracy accuracy = evaluateWindow(series, trendBiasIndicator, windowStart, windowEnd, lookaheadBars,
minStrength);
String windowRange = describeRange(series, windowStart, windowEnd);
LOG.info("Walk-forward {} -> {}", windowRange, formatAccuracy(accuracy, lookaheadBars, minStrength));
}
}
/**
* Logs summary accuracy output.
*
* @param label label for the output
* @param accuracy aggregated accuracy statistics
* @param lookaheadBars lookahead window used in bars
* @param minStrength minimum bias strength used for inclusion
*/
private static void logAccuracy(String label, TrendAccuracy accuracy, int lookaheadBars, double minStrength) {
LOG.info("{} -> {}", label, formatAccuracy(accuracy, lookaheadBars, minStrength));
}
/**
* Formats accuracy statistics into a single summary string.
*
* @param accuracy aggregated accuracy statistics
* @param lookaheadBars lookahead window used in bars
* @param minStrength minimum bias strength used for inclusion
* @return formatted summary string
*/
private static String formatAccuracy(TrendAccuracy accuracy, int lookaheadBars, double minStrength) {
String overall = formatPercent(accuracy.accuracy());
String bullish = formatPercent(accuracy.bullishAccuracy());
String bearish = formatPercent(accuracy.bearishAccuracy());
return String.format(
"lookahead=%d bars | minStrength=%.2f | predictions=%d | accuracy=%s | bullish=%s | bearish=%s | skipped=%d",
lookaheadBars, minStrength, accuracy.totalPredictions(), overall, bullish, bearish,
accuracy.skippedPredictions());
}
/**
* Formats a ratio as a percentage string.
*
* @param value ratio value (0.0-1.0)
* @return formatted percentage string, or {@code n/a} if undefined
*/
private static String formatPercent(double value) {
if (Double.isNaN(value)) {
return "n/a";
}
return String.format("%.1f%%", value * 100.0);
}
/**
* Describes the full range of the series in ISO date form.
*
* @param series bar series to describe
* @return formatted date range string
*/
private static String describeRange(BarSeries series) {
return describeRange(series, series.getBeginIndex(), series.getEndIndex());
}
/**
* Describes a subset of the series in ISO date form.
*
* @param series bar series to describe
* @param startIndex start bar index
* @param endIndex end bar index
* @return formatted date range string
*/
private static String describeRange(BarSeries series, int startIndex, int endIndex) {
DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE;
String start = series.getBar(startIndex).getEndTime().atZone(ZoneOffset.UTC).toLocalDate().format(formatter);
String end = series.getBar(endIndex).getEndTime().atZone(ZoneOffset.UTC).toLocalDate().format(formatter);
return start + " -> " + end;
}
/**
* Dataset metadata for backtesting inputs.
*
* @param resource classpath resource identifier
* @param label label for logging output
* @param degree Elliott wave degree to analyze
* @param sourceType data source kind
*/
private record DatasetSpec(String resource, String label, ElliottDegree degree, DataSourceType sourceType) {
/**
* Loads the bar series for this dataset.
*
* @return loaded series, or {@code null} if unavailable
*/
BarSeries loadSeries() {
if (sourceType == DataSourceType.JSON) {
return JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(resource);
}
return new CsvFileBarSeriesDataSource().loadSeries(resource);
}
/**
* Builds a JSON-backed dataset spec.
*
* @param resource resource path
* @param label label for logging
* @param degree Elliott wave degree
* @return dataset spec
*/
static DatasetSpec json(String resource, String label, ElliottDegree degree) {
return new DatasetSpec(resource, label, degree, DataSourceType.JSON);
}
/**
* Builds a CSV-backed dataset spec.
*
* @param resource resource path
* @param label label for logging
* @param degree Elliott wave degree
* @return dataset spec
*/
static DatasetSpec csv(String resource, String label, ElliottDegree degree) {
return new DatasetSpec(resource, label, degree, DataSourceType.CSV);
}
}
private enum DataSourceType {
JSON, CSV
}
/**
* Aggregated trend-bias prediction accuracy metrics.
*
* @param totalPredictions total evaluated predictions
* @param correctPredictions total correct predictions
* @param bullishPredictions total bullish predictions
* @param bullishCorrect correct bullish predictions
* @param bearishPredictions total bearish predictions
* @param bearishCorrect correct bearish predictions
* @param skippedPredictions skipped predictions
*/
private record TrendAccuracy(int totalPredictions, int correctPredictions, int bullishPredictions,
int bullishCorrect, int bearishPredictions, int bearishCorrect, int skippedPredictions) {
/**
* @return empty accuracy metrics
*/
static TrendAccuracy empty() {
return new TrendAccuracy(0, 0, 0, 0, 0, 0, 0);
}
/**
* @return overall accuracy ratio
*/
double accuracy() {
return ratio(correctPredictions, totalPredictions);
}
/**
* @return bullish-only accuracy ratio
*/
double bullishAccuracy() {
return ratio(bullishCorrect, bullishPredictions);
}
/**
* @return bearish-only accuracy ratio
*/
double bearishAccuracy() {
return ratio(bearishCorrect, bearishPredictions);
}
/**
* Computes a safe ratio, returning NaN when the denominator is zero.
*
* @param numerator numerator value
* @param denominator denominator value
* @return ratio or NaN
*/
private double ratio(int numerator, int denominator) {
return denominator > 0 ? (double) numerator / denominator : Double.NaN;
}
}
}
@@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.backtest;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Trade;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.backtest.BarSeriesManager;
import org.ta4j.core.criteria.ExpectancyCriterion;
import org.ta4j.core.criteria.PositionsRatioCriterion;
import org.ta4j.core.criteria.drawdown.MaximumDrawdownCriterion;
import org.ta4j.core.criteria.pnl.GrossProfitLossRatioCriterion;
import org.ta4j.core.criteria.pnl.GrossReturnCriterion;
import org.ta4j.core.criteria.pnl.NetProfitLossCriterion;
import org.ta4j.core.num.Num;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import ta4jexamples.strategies.HighRewardElliottWaveStrategy;
/**
* Backtests the high-reward Elliott Wave strategy using ossified datasets.
*
* @since 0.22.2
*/
public class HighRewardElliottWaveBacktest {
private static final Logger LOG = LogManager.getLogger(HighRewardElliottWaveBacktest.class);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-ETH-USD-PT4H-20160518_20251028.json";
private static final List<StrategySpec> DEFAULT_SPECS = List.of(StrategySpec.defaultSpec(),
StrategySpec.relaxedSpec(), StrategySpec.exploratorySpec(), StrategySpec.strictSpec());
/**
* Runs the backtest demo.
*
* @param args command-line arguments (optional: override dataset resource)
*/
public static void main(String[] args) {
String resource = args.length > 0 ? args[0] : DEFAULT_OHLCV_RESOURCE;
BarSeries series = loadSeries(resource);
if (series == null || series.isEmpty()) {
LOG.error("No data available for backtest: {}", resource);
return;
}
LOG.info("High-reward Elliott Wave backtest: {}", resource);
LOG.info("Bars: {} | Range: {}", series.getBarCount(), describeRange(series));
for (StrategySpec spec : DEFAULT_SPECS) {
runBacktest(series, spec);
}
}
private static void runBacktest(BarSeries series, StrategySpec spec) {
HighRewardElliottWaveStrategy strategy = new HighRewardElliottWaveStrategy(series, spec.toParameters());
BarSeriesManager manager = new BarSeriesManager(series);
TradingRecord record = manager.run(strategy);
Num netProfit = new NetProfitLossCriterion().calculate(series, record);
Num grossReturn = new GrossReturnCriterion().calculate(series, record);
Num profitFactor = new GrossProfitLossRatioCriterion().calculate(series, record);
Num maxDrawdown = new MaximumDrawdownCriterion().calculate(series, record);
Num winRate = PositionsRatioCriterion.WinningPositionsRatioCriterion().calculate(series, record);
Num expectancy = new ExpectancyCriterion().calculate(series, record);
LOG.info("Spec: {}", spec.label());
LOG.info(
"Trades: {} | WinRate: {} | Net PnL: {} | Gross Return: {} | Profit Factor: {} | Max DD: {} | Expectancy: {}",
record.getPositionCount(), formatPercent(winRate), netProfit, grossReturn, profitFactor, maxDrawdown,
expectancy);
logTrades(series, record);
}
private static String formatPercent(Num ratio) {
if (ratio == null || ratio.isNaN()) {
return "n/a";
}
return String.format("%.1f%%", ratio.doubleValue() * 100.0);
}
private static BarSeries loadSeries(String resource) {
try (InputStream stream = HighRewardElliottWaveBacktest.class.getClassLoader().getResourceAsStream(resource)) {
if (stream == null) {
LOG.error("Missing resource: {}", resource);
return null;
}
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
if (loaded == null) {
LOG.error("Failed to load resource: {}", resource);
return null;
}
BarSeries series = new BaseBarSeriesBuilder().withName(resource).build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
LOG.error("Failed to load dataset: {}", ex.getMessage(), ex);
return null;
}
}
private static String describeRange(BarSeries series) {
String start = DATE_FORMATTER.format(series.getBar(series.getBeginIndex()).getEndTime());
String end = DATE_FORMATTER.format(series.getBar(series.getEndIndex()).getEndTime());
return start + " -> " + end;
}
private static void logTrades(BarSeries series, TradingRecord record) {
if (record == null || record.getPositionCount() == 0) {
return;
}
record.getPositions().stream().filter(position -> !position.isOpened()).forEach(position -> {
Trade entry = position.getEntry();
Trade exit = position.getExit();
String entryTime = DATE_FORMATTER.format(series.getBar(entry.getIndex()).getEndTime());
String exitTime = DATE_FORMATTER.format(series.getBar(exit.getIndex()).getEndTime());
LOG.info("Trade: entry={}@{} ({}), exit={}@{} ({}), profit={}", entry.getIndex(), entry.getPricePerAsset(),
entryTime, exit.getIndex(), exit.getPricePerAsset(), exitTime, position.getProfit());
});
}
private record StrategySpec(String direction, String degree, double minConfidence, double minRiskReward,
double minAlternationRatio, double minTrendBiasStrength, int trendSmaPeriod, int rsiPeriod,
double rsiThreshold, int macdFastPeriod, int macdSlowPeriod, double minRelativeSwing) {
static StrategySpec defaultSpec() {
return new StrategySpec("BULLISH", "PRIMARY", 0.35, 2.0, 1.50, 0.10, 100, 14, 50.0, 12, 26, 0.10);
}
static StrategySpec relaxedSpec() {
return new StrategySpec("BULLISH", "MINOR", 0.30, 1.8, 1.05, 0.05, 80, 14, 48.0, 12, 26, 0.08);
}
static StrategySpec exploratorySpec() {
return new StrategySpec("BULLISH", "MINOR", 0.25, 1.5, 1.00, 0.00, 80, 14, 45.0, 12, 26, 0.05);
}
static StrategySpec strictSpec() {
return new StrategySpec("BULLISH", "PRIMARY", 0.50, 2.5, 1.40, 0.20, 200, 14, 50.0, 12, 26, 0.15);
}
String[] toParameters() {
return new String[] { direction, degree, format(minConfidence), format(minRiskReward),
format(minAlternationRatio), format(minTrendBiasStrength), String.valueOf(trendSmaPeriod),
String.valueOf(rsiPeriod), format(rsiThreshold), String.valueOf(macdFastPeriod),
String.valueOf(macdSlowPeriod), format(minRelativeSwing) };
}
String label() {
return String.format("dir=%s deg=%s conf=%s rr=%s alt=%s bias=%s swing=%s", direction, degree,
format(minConfidence), format(minRiskReward), format(minAlternationRatio),
format(minTrendBiasStrength), format(minRelativeSwing));
}
private static String format(double value) {
return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
}
}
@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.confidence.ConfidenceFactorResult;
import org.ta4j.core.indicators.elliott.confidence.ElliottConfidenceBreakdown;
import org.ta4j.core.indicators.elliott.swing.AdaptiveZigZagConfig;
import org.ta4j.core.indicators.elliott.swing.CompositeSwingDetector;
import org.ta4j.core.indicators.elliott.swing.MinMagnitudeSwingFilter;
import org.ta4j.core.indicators.elliott.swing.SwingDetector;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates adaptive ZigZag and composite swing detection for Elliott Wave
* analysis.
*
* @since 0.22.2
*/
public class ElliottWaveAdaptiveSwingAnalysis {
private static final Logger LOG = LogManager.getLogger(ElliottWaveAdaptiveSwingAnalysis.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
/**
* Runs the adaptive swing analysis demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
BarSeries series = loadSeries();
if (series == null || series.isEmpty()) {
LOG.error("No data available for adaptive swing analysis");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
AdaptiveZigZagConfig config = new AdaptiveZigZagConfig(14, 1.0, 0.0, 0.0, 3);
SwingDetector detector = SwingDetectors.composite(CompositeSwingDetector.Policy.OR, SwingDetectors.fractal(5),
SwingDetectors.adaptiveZigZag(config));
ElliottWaveAnalysisRunner analyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(detector)
.swingFilter(new MinMagnitudeSwingFilter(0.2))
.build();
ElliottWaveAnalysisResult analysisResult = analyzer.analyze(series);
ElliottAnalysisResult result = analysisResult.analysisFor(baseDegree).orElseThrow().analysis();
LOG.info("Adaptive swing analysis complete. Trend bias: {}", result.trendBias().direction());
result.scenarios().base().ifPresent(base -> logScenario("BASE", base, result));
List<ElliottScenario> alternatives = result.scenarios().alternatives();
for (int i = 0; i < Math.min(2, alternatives.size()); i++) {
logScenario("ALT " + (i + 1), alternatives.get(i), result);
}
}
/**
* Logs scenario-level confidence breakdown details.
*
* @param label scenario label
* @param scenario scenario to log
* @param result analysis result containing factor breakdowns
*/
private static void logScenario(String label, ElliottScenario scenario, ElliottAnalysisResult result) {
LOG.info("{} SCENARIO: {} ({}) - confidence={}%%", label, scenario.currentPhase(), scenario.type(),
String.format("%.1f", scenario.confidence().asPercentage()));
ElliottConfidenceBreakdown breakdown = result.breakdownFor(scenario).orElse(null);
if (breakdown == null) {
return;
}
for (ConfidenceFactorResult factor : breakdown.factors()) {
LOG.info(" Factor: {} score={} weight={} diagnostics={}", factor.name(),
String.format("%.2f", factor.score().doubleValue()), String.format("%.2f", factor.weight()),
factor.diagnostics());
}
}
/**
* Loads the ossified BTC-USD dataset from classpath resources.
*
* @return loaded bar series, or {@code null} if unavailable
*/
private static BarSeries loadSeries() {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveAdaptiveSwingAnalysis.class,
DEFAULT_OHLCV_RESOURCE, "BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import java.util.List;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.swing.AdaptiveZigZagConfig;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates multi-degree Elliott Wave analysis, validating scenarios across
* neighboring degrees and re-ranking base-degree outcomes.
*
* <p>
* This demo uses an ossified BTC-USD dataset from classpath resources and runs
* an auto-selected base-degree analysis, plus one supporting degree higher and
* lower.
*
* @since 0.22.4
*/
public class ElliottWaveMultiDegreeAnalysisDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWaveMultiDegreeAnalysisDemo.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231020.json";
public static void main(String[] args) {
BarSeries series = loadSeries(DEFAULT_OHLCV_RESOURCE);
if (series == null || series.isEmpty()) {
LOG.error("No series available for multi-degree Elliott Wave analysis");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
ElliottWaveAnalysisRunner analyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.adaptiveZigZag(new AdaptiveZigZagConfig(14, 1.0, 0.0, 0.0, 1)))
.build();
ElliottWaveAnalysisResult result = analyzer.analyze(series);
LOG.info("Auto-selected base degree: {}", baseDegree);
for (ElliottWaveAnalysisResult.DegreeAnalysis analysis : result.analyses()) {
LOG.info("{} Degree {}: bars={} duration={} historyFit={} trendBias={}", series.getName(),
analysis.degree(), analysis.barCount(), analysis.barDuration(),
String.format("%.2f", analysis.historyFitScore()), analysis.analysis().trendBias().direction());
analysis.analysis().scenarios().base().ifPresent(base -> logScenario(" Base", base));
}
Optional<ElliottWaveAnalysisResult.BaseScenarioAssessment> recommended = result.recommendedScenario();
if (recommended.isEmpty()) {
LOG.warn("No base-degree scenarios were produced");
return;
}
ElliottWaveAnalysisResult.BaseScenarioAssessment assessment = recommended.orElseThrow();
ElliottScenario scenario = assessment.scenario();
LOG.info("Recommended base scenario: id={} phase={} type={} confidence={} crossDegree={} composite={}",
scenario.id(), scenario.currentPhase(), scenario.type(),
String.format("%.2f", assessment.confidenceScore()),
String.format("%.2f", assessment.crossDegreeScore()),
String.format("%.2f", assessment.compositeScore()));
List<ElliottWaveAnalysisResult.SupportingScenarioMatch> matches = assessment.supportingMatches();
for (ElliottWaveAnalysisResult.SupportingScenarioMatch match : matches) {
LOG.info(" Match {}: scenarioId={} compat={} weightedCompat={} historyFit={}", match.degree(),
match.scenarioId(), String.format("%.2f", match.compatibilityScore()),
String.format("%.2f", match.weightedCompatibility()),
String.format("%.2f", match.historyFitScore()));
}
for (String note : result.notes()) {
LOG.info("Note: {}", note);
}
}
private static void logScenario(final String label, final ElliottScenario scenario) {
LOG.info("{} scenarioId={} phase={} type={} confidence={} direction={}", label, scenario.id(),
scenario.currentPhase(), scenario.type(), String.format("%.1f", scenario.confidence().asPercentage()),
scenario.hasKnownDirection() ? (scenario.isBullish() ? "bullish" : "bearish") : "unknown");
}
private static BarSeries loadSeries(final String resource) {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWaveMultiDegreeAnalysisDemo.class, resource,
"BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,100 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.demo;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BarSeries;
import org.ta4j.core.indicators.elliott.ElliottAnalysisResult;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisRunner;
import org.ta4j.core.indicators.elliott.ElliottWaveAnalysisResult;
import org.ta4j.core.indicators.elliott.confidence.ConfidenceProfiles;
import org.ta4j.core.indicators.elliott.swing.SwingDetectors;
import ta4jexamples.analysis.elliottwave.ElliottWaveIndicatorSuiteDemo;
import ta4jexamples.analysis.elliottwave.support.OssifiedElliottWaveSeriesLoader;
/**
* Demonstrates how pattern-specific confidence profiles influence scenario
* ranking.
*
* @since 0.22.2
*/
public class ElliottWavePatternProfileDemo {
private static final Logger LOG = LogManager.getLogger(ElliottWavePatternProfileDemo.class);
private static final String DEFAULT_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
/**
* Runs the pattern profile comparison demo.
*
* @param args command-line arguments (unused)
*/
public static void main(String[] args) {
BarSeries series = loadSeries();
if (series == null || series.isEmpty()) {
LOG.error("No data available for pattern profile demo");
return;
}
ElliottDegree baseDegree = ElliottWaveIndicatorSuiteDemo.autoSelectDegree(series);
ElliottWaveAnalysisRunner defaultAnalyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.fractal(5))
.confidenceModelFactory(ConfidenceProfiles::defaultModel)
.build();
ElliottWaveAnalysisRunner patternAwareAnalyzer = ElliottWaveAnalysisRunner.builder()
.degree(baseDegree)
.higherDegrees(1)
.lowerDegrees(1)
.swingDetector(SwingDetectors.fractal(5))
.confidenceModelFactory(ConfidenceProfiles::patternAwareModel)
.build();
ElliottWaveAnalysisResult defaultSnapshot = defaultAnalyzer.analyze(series);
ElliottWaveAnalysisResult patternSnapshot = patternAwareAnalyzer.analyze(series);
ElliottAnalysisResult defaultResult = defaultSnapshot.analysisFor(baseDegree)
.orElseThrow(() -> new IllegalStateException("No base-degree analysis in default snapshot"))
.analysis();
ElliottAnalysisResult patternResult = patternSnapshot.analysisFor(baseDegree)
.orElseThrow(() -> new IllegalStateException("No base-degree analysis in pattern-aware snapshot"))
.analysis();
logBaseScenario("Default profile", defaultResult);
logBaseScenario("Pattern-aware profile", patternResult);
}
/**
* Logs the base scenario for the provided analysis result.
*
* @param label label for the output
* @param result analysis result to inspect
*/
private static void logBaseScenario(String label, ElliottAnalysisResult result) {
ElliottScenario base = result.scenarios().base().orElse(null);
if (base == null) {
LOG.info("{}: No scenarios available", label);
return;
}
LOG.info("{}: {} ({}) confidence={}%%", label, base.currentPhase(), base.type(),
String.format("%.1f", base.confidence().asPercentage()));
}
/**
* Loads the ossified BTC-USD dataset from classpath resources.
*
* @return loaded bar series, or {@code null} if unavailable
*/
private static BarSeries loadSeries() {
return OssifiedElliottWaveSeriesLoader.loadSeries(ElliottWavePatternProfileDemo.class, DEFAULT_OHLCV_RESOURCE,
"BTC-USD_PT1D@Coinbase (ossified)", LOG);
}
}
@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: MIT
*/
package ta4jexamples.analysis.elliottwave.support;
import java.io.InputStream;
import java.util.Objects;
import org.apache.logging.log4j.Logger;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;
import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
/**
* Utility for loading ossified Elliott Wave demo series from classpath JSON
* resources.
*
* @since 0.22.4
*/
public final class OssifiedElliottWaveSeriesLoader {
/**
* Utility class.
*/
private OssifiedElliottWaveSeriesLoader() {
}
/**
* Loads an ossified classpath dataset into a detached {@link BarSeries} with a
* caller-provided display name.
*
* @param resourceOwner class used to resolve classpath resources
* @param resource classpath resource path
* @param seriesName name assigned to the returned series
* @param logger logger used for diagnostics
* @return loaded series, or {@code null} when loading fails
*/
public static BarSeries loadSeries(final Class<?> resourceOwner, final String resource, final String seriesName,
final Logger logger) {
Objects.requireNonNull(resourceOwner, "resourceOwner");
Objects.requireNonNull(resource, "resource");
Objects.requireNonNull(seriesName, "seriesName");
Objects.requireNonNull(logger, "logger");
String normalizedResource = resource.startsWith("/") ? resource : "/" + resource;
try (InputStream stream = resourceOwner.getResourceAsStream(normalizedResource)) {
if (stream == null) {
logger.error("Missing resource: {}", resource);
return null;
}
BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
if (loaded == null) {
logger.error("Failed to load resource: {}", resource);
return null;
}
BarSeries series = new BaseBarSeriesBuilder().withName(seriesName).build();
for (int i = 0; i < loaded.getBarCount(); i++) {
series.addBar(loaded.getBar(i));
}
return series;
} catch (Exception ex) {
logger.error("Failed to load dataset from {}: {}", resource, ex.getMessage(), ex);
return null;
}
}
}