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