goldenChat base source add
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting;
|
||||
|
||||
import org.ta4j.core.AnalysisCriterion;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseTradingRecord;
|
||||
import org.ta4j.core.Trade;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.indicators.CachedIndicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* An indicator that visualizes an {@link AnalysisCriterion} over time by
|
||||
* calculating the criterion value at each bar index using a partial trading
|
||||
* record.
|
||||
*
|
||||
* <p>
|
||||
* For each bar index, this indicator creates a partial trading record
|
||||
* containing only positions that have been entered (and optionally closed) up
|
||||
* to that index, then calculates the criterion value for that partial record.
|
||||
* This allows analysis criteria to be visualized as time series on charts.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public class AnalysisCriterionIndicator extends CachedIndicator<Num> {
|
||||
|
||||
private final AnalysisCriterion criterion;
|
||||
private final TradingRecord fullTradingRecord;
|
||||
private final List<Trade> allTrades;
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* Constructs an AnalysisCriterionIndicator.
|
||||
*
|
||||
* @param series the bar series
|
||||
* @param criterion the analysis criterion to visualize
|
||||
* @param tradingRecord the full trading record
|
||||
* @throws NullPointerException if any parameter is null
|
||||
*/
|
||||
public AnalysisCriterionIndicator(BarSeries series, AnalysisCriterion criterion, TradingRecord tradingRecord) {
|
||||
super(series);
|
||||
this.criterion = Objects.requireNonNull(criterion, "Criterion cannot be null");
|
||||
this.fullTradingRecord = Objects.requireNonNull(tradingRecord, "Trading record cannot be null");
|
||||
this.allTrades = new ArrayList<>(tradingRecord.getTrades());
|
||||
this.label = deriveLabel(criterion);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Num calculate(int index) {
|
||||
// Create a partial trading record with trades up to this index
|
||||
TradingRecord partialRecord = createPartialTradingRecord(index);
|
||||
|
||||
// Calculate criterion value for the partial record
|
||||
return criterion.calculate(getBarSeries(), partialRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCountOfUnstableBars() {
|
||||
// Analysis criteria don't have unstable bars - they calculate from trading
|
||||
// records
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a partial trading record containing only trades that have occurred up
|
||||
* to the specified index.
|
||||
*
|
||||
* @param upToIndex the maximum bar index to include
|
||||
* @return a partial trading record
|
||||
*/
|
||||
private TradingRecord createPartialTradingRecord(int upToIndex) {
|
||||
// Filter trades where trade index <= upToIndex
|
||||
List<Trade> partialTrades = new ArrayList<>();
|
||||
for (Trade trade : allTrades) {
|
||||
if (trade.getIndex() <= upToIndex) {
|
||||
partialTrades.add(trade);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new trading record from filtered trades
|
||||
if (partialTrades.isEmpty()) {
|
||||
// Return empty record with same cost models and starting type
|
||||
return new BaseTradingRecord(fullTradingRecord.getStartingType(),
|
||||
fullTradingRecord.getTransactionCostModel(), fullTradingRecord.getHoldingCostModel());
|
||||
}
|
||||
|
||||
Trade[] tradesArray = partialTrades.toArray(new Trade[0]);
|
||||
return new BaseTradingRecord(fullTradingRecord.getTransactionCostModel(),
|
||||
fullTradingRecord.getHoldingCostModel(), tradesArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
|
||||
private static String deriveLabel(AnalysisCriterion criterion) {
|
||||
String simpleName = criterion.getClass().getSimpleName();
|
||||
if (simpleName.endsWith("Criterion") && simpleName.length() > "Criterion".length()) {
|
||||
simpleName = simpleName.substring(0, simpleName.length() - "Criterion".length());
|
||||
}
|
||||
return simpleName.isEmpty() ? criterion.getClass().getSimpleName() : simpleName;
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.ta4j.core.indicators.CachedIndicator;
|
||||
import org.ta4j.core.indicators.PriceChannel;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.num.NaN;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
/**
|
||||
* Wraps channel boundary values for chart overlays, extracting the selected
|
||||
* line from a {@link PriceChannel} when available.
|
||||
*
|
||||
* <p>
|
||||
* When the wrapped indicator yields {@link Num} values, this indicator forwards
|
||||
* them unchanged. When the wrapped indicator yields {@link PriceChannel}
|
||||
* values, it extracts the configured boundary (upper, lower, or median).
|
||||
* </p>
|
||||
*
|
||||
* @since 0.22.0
|
||||
*/
|
||||
public final class ChannelBoundaryIndicator extends CachedIndicator<Num> {
|
||||
|
||||
private final PriceChannel.Boundary boundary;
|
||||
private final Indicator<?> indicator;
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* Constructs a channel boundary indicator.
|
||||
*
|
||||
* @param indicator the indicator providing boundary values or channels
|
||||
* @param boundary the boundary role to extract or label
|
||||
* @throws IllegalArgumentException if the indicator is not attached to a bar
|
||||
* series
|
||||
* @throws NullPointerException if indicator or boundary is null
|
||||
*/
|
||||
public ChannelBoundaryIndicator(Indicator<?> indicator, PriceChannel.Boundary boundary) {
|
||||
this(indicator, boundary, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a channel boundary indicator with a custom label.
|
||||
*
|
||||
* @param indicator the indicator providing boundary values or channels
|
||||
* @param boundary the boundary role to extract or label
|
||||
* @param label the label to expose in legends (uses a default if blank)
|
||||
* @throws IllegalArgumentException if the indicator is not attached to a bar
|
||||
* series
|
||||
* @throws NullPointerException if indicator or boundary is null
|
||||
*/
|
||||
public ChannelBoundaryIndicator(Indicator<?> indicator, PriceChannel.Boundary boundary, String label) {
|
||||
super(requireSeries(indicator));
|
||||
this.indicator = indicator;
|
||||
this.boundary = Objects.requireNonNull(boundary, "Boundary cannot be null");
|
||||
this.label = resolveLabel(label, indicator, boundary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the boundary role configured for this indicator.
|
||||
*
|
||||
* @return the channel boundary role
|
||||
*/
|
||||
public PriceChannel.Boundary boundary() {
|
||||
return boundary;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Num calculate(int index) {
|
||||
Object value = indicator.getValue(index);
|
||||
if (value == null) {
|
||||
return NaN.NaN;
|
||||
}
|
||||
if (value instanceof Num num) {
|
||||
return num;
|
||||
}
|
||||
if (value instanceof PriceChannel channel) {
|
||||
return switch (boundary) {
|
||||
case UPPER -> channel.upper();
|
||||
case LOWER -> channel.lower();
|
||||
case MEDIAN -> channel.median();
|
||||
};
|
||||
}
|
||||
throw new IllegalStateException("ChannelBoundaryIndicator expects Num or PriceChannel values, but received "
|
||||
+ value.getClass().getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCountOfUnstableBars() {
|
||||
return indicator.getCountOfUnstableBars();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
|
||||
private static BarSeries requireSeries(Indicator<?> indicator) {
|
||||
Objects.requireNonNull(indicator, "Indicator cannot be null");
|
||||
BarSeries series = indicator.getBarSeries();
|
||||
if (series == null) {
|
||||
throw new IllegalArgumentException("Indicator " + indicator + " is not attached to a BarSeries");
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private static String resolveLabel(String label, Indicator<?> indicator, PriceChannel.Boundary boundary) {
|
||||
if (label != null && !label.isBlank()) {
|
||||
return label;
|
||||
}
|
||||
String baseLabel = indicator.toString();
|
||||
String boundaryLabel = boundary.label();
|
||||
if (baseLabel == null || baseLabel.isBlank()) {
|
||||
return boundaryLabel;
|
||||
}
|
||||
return baseLabel + " (" + boundaryLabel + ")";
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.annotation;
|
||||
|
||||
import static org.ta4j.core.num.NaN.NaN;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.indicators.CachedIndicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
/**
|
||||
* Indicator overlay that exposes sparse bar-index labels for chart annotations.
|
||||
*
|
||||
* <p>
|
||||
* The primary {@link #getValue(int)} output returns the label Y-value
|
||||
* (typically a price) at labeled indices and {@code NaN} elsewhere. Chart
|
||||
* renderers can additionally consume {@link #labels()} to attach text
|
||||
* annotations at the labeled indices.
|
||||
*/
|
||||
public class BarSeriesLabelIndicator extends CachedIndicator<Num> {
|
||||
|
||||
public enum LabelPlacement {
|
||||
ABOVE, BELOW, CENTER
|
||||
}
|
||||
|
||||
public record BarLabel(int barIndex, Num yValue, String text, LabelPlacement placement, Color color,
|
||||
double fontScale) {
|
||||
|
||||
public BarLabel(int barIndex, Num yValue, String text, LabelPlacement placement) {
|
||||
this(barIndex, yValue, text, placement, null, 1.0);
|
||||
}
|
||||
|
||||
public BarLabel(int barIndex, Num yValue, String text, LabelPlacement placement, Color color) {
|
||||
this(barIndex, yValue, text, placement, color, 1.0);
|
||||
}
|
||||
|
||||
public BarLabel {
|
||||
if (barIndex < 0) {
|
||||
throw new IllegalArgumentException("barIndex must be non-negative");
|
||||
}
|
||||
Objects.requireNonNull(yValue, "yValue");
|
||||
Objects.requireNonNull(text, "text");
|
||||
Objects.requireNonNull(placement, "placement");
|
||||
if (!Double.isFinite(fontScale) || fontScale <= 0.0) {
|
||||
throw new IllegalArgumentException("fontScale must be positive and finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<Integer, BarLabel> labelsByIndex;
|
||||
private final List<BarLabel> labels;
|
||||
|
||||
public BarSeriesLabelIndicator(final BarSeries series, final List<BarLabel> labels) {
|
||||
super(Objects.requireNonNull(series, "series"));
|
||||
Objects.requireNonNull(labels, "labels");
|
||||
this.labels = labels.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(Comparator.comparingInt(BarLabel::barIndex))
|
||||
.collect(Collectors.toUnmodifiableList());
|
||||
this.labelsByIndex = this.labels.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(BarLabel::barIndex, label -> label, (left, right) -> {
|
||||
// Keep the last (right) value for duplicate indices
|
||||
return right;
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Num calculate(final int index) {
|
||||
final BarLabel label = labelsByIndex.get(index);
|
||||
return label != null ? label.yValue() : NaN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCountOfUnstableBars() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ordered, immutable label list
|
||||
*/
|
||||
public List<BarLabel> labels() {
|
||||
return List.copyOf(this.labels);
|
||||
}
|
||||
}
|
||||
+1938
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.builder;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
/**
|
||||
* Immutable context describing a chart definition alongside its shared
|
||||
* metadata.
|
||||
*
|
||||
* @since 0.22.2
|
||||
*/
|
||||
public record ChartContext(ChartBuilder.ChartDefinition definition, ChartBuilder.ChartDefinitionMetadata metadata) {
|
||||
|
||||
public ChartContext {
|
||||
Objects.requireNonNull(definition, "Chart definition cannot be null");
|
||||
Objects.requireNonNull(metadata, "Chart metadata cannot be null");
|
||||
}
|
||||
|
||||
public static ChartContext from(ChartBuilder.ChartDefinition definition) {
|
||||
Objects.requireNonNull(definition, "Chart definition cannot be null");
|
||||
return new ChartContext(definition, definition.metadata());
|
||||
}
|
||||
|
||||
public BarSeries domainSeries() {
|
||||
return metadata.domainSeries();
|
||||
}
|
||||
|
||||
public String title() {
|
||||
return metadata.title();
|
||||
}
|
||||
|
||||
public TimeAxisMode timeAxisMode() {
|
||||
return metadata.timeAxisMode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.builder;
|
||||
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import ta4jexamples.charting.builder.ChartBuilder.ChartDefinitionMetadata;
|
||||
|
||||
/**
|
||||
* Immutable plan describing a chart composition along with shared metadata.
|
||||
*/
|
||||
public final class ChartPlan {
|
||||
|
||||
private final ChartContext context;
|
||||
|
||||
ChartPlan(ChartBuilder.ChartDefinition definition) {
|
||||
this.context = ChartContext.from(definition);
|
||||
}
|
||||
|
||||
public ChartBuilder.ChartDefinition definition() {
|
||||
return context.definition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata for this chart plan.
|
||||
*
|
||||
* @return the chart metadata
|
||||
* @since 0.22.2
|
||||
*/
|
||||
public ChartDefinitionMetadata metadata() {
|
||||
return context.metadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chart context containing definition and metadata.
|
||||
*
|
||||
* @return the chart context
|
||||
* @since 0.22.2
|
||||
*/
|
||||
public ChartContext context() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the primary domain series for this chart plan.
|
||||
*
|
||||
* @return the primary series
|
||||
*/
|
||||
public BarSeries primarySeries() {
|
||||
return context.domainSeries();
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.builder;
|
||||
|
||||
/**
|
||||
* Controls how charts interpret gaps between bars on the domain axis.
|
||||
*
|
||||
* <p>
|
||||
* Use {@link #REAL_TIME} to preserve actual time gaps (weekends, holidays, or
|
||||
* missing bars). Use {@link #BAR_INDEX} to compress the domain axis so bars are
|
||||
* evenly spaced by index, eliminating visual gaps while keeping the underlying
|
||||
* data unchanged.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.22.2
|
||||
*/
|
||||
public enum TimeAxisMode {
|
||||
|
||||
/**
|
||||
* Plot bars using their real timestamps. Missing bars appear as gaps on the
|
||||
* time axis.
|
||||
*/
|
||||
REAL_TIME,
|
||||
|
||||
/**
|
||||
* Plot bars using their index positions, producing evenly spaced candles and
|
||||
* removing time gaps (e.g., weekends, holidays).
|
||||
*/
|
||||
BAR_INDEX
|
||||
}
|
||||
+1741
File diff suppressed because it is too large
Load Diff
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.display;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.jfree.data.time.TimeSeries;
|
||||
import org.jfree.data.time.TimeSeriesCollection;
|
||||
import org.jfree.data.xy.DefaultOHLCDataset;
|
||||
import org.jfree.data.xy.XYDataset;
|
||||
import org.jfree.data.xy.XYSeries;
|
||||
import org.jfree.data.xy.XYSeriesCollection;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Extracts formatted text data from chart datasets for display in mouseover
|
||||
* tooltips.
|
||||
* <p>
|
||||
* This class handles different dataset types (OHLC, TimeSeries, XYSeries) and
|
||||
* formats them appropriately for display. It is designed to be testable
|
||||
* independently of the chart display infrastructure.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public final class ChartDataExtractor {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(ChartDataExtractor.class);
|
||||
|
||||
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
/**
|
||||
* Extracts formatted text representation of data from a chart dataset at the
|
||||
* specified series and item indices.
|
||||
*
|
||||
* @param dataset the dataset to extract data from
|
||||
* @param seriesIndex the index of the series within the dataset
|
||||
* @param itemIndex the index of the item within the series
|
||||
* @return formatted text string, or null if extraction fails or data is not
|
||||
* available
|
||||
*/
|
||||
public String extractDataText(XYDataset dataset, int seriesIndex, int itemIndex) {
|
||||
if (dataset instanceof DefaultOHLCDataset ohlcDataset) {
|
||||
return extractOHLCData(ohlcDataset, seriesIndex, itemIndex);
|
||||
} else if (dataset instanceof TimeSeriesCollection timeSeriesCollection) {
|
||||
return extractTimeSeriesData(timeSeriesCollection, seriesIndex, itemIndex);
|
||||
} else if (dataset instanceof XYSeriesCollection xyCollection) {
|
||||
return extractXYSeriesData(xyCollection, seriesIndex, itemIndex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractOHLCData(DefaultOHLCDataset dataset, int seriesIndex, int itemIndex) {
|
||||
try {
|
||||
double xValue = dataset.getXValue(seriesIndex, itemIndex);
|
||||
double open = dataset.getOpenValue(seriesIndex, itemIndex);
|
||||
double high = dataset.getHighValue(seriesIndex, itemIndex);
|
||||
double low = dataset.getLowValue(seriesIndex, itemIndex);
|
||||
double close = dataset.getCloseValue(seriesIndex, itemIndex);
|
||||
double volume = dataset.getVolumeValue(seriesIndex, itemIndex);
|
||||
|
||||
DecimalFormat priceFormat = new DecimalFormat("#,##0.00###");
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT);
|
||||
return String.format("Date: %s | O: %s | H: %s | L: %s | C: %s | V: %s",
|
||||
dateFormat.format(new Date((long) xValue)), priceFormat.format(open), priceFormat.format(high),
|
||||
priceFormat.format(low), priceFormat.format(close), priceFormat.format(volume));
|
||||
} catch (Exception ex) {
|
||||
LOG.debug("Error extracting OHLC data", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractTimeSeriesData(TimeSeriesCollection dataset, int seriesIndex, int itemIndex) {
|
||||
try {
|
||||
TimeSeries timeSeries = dataset.getSeries(seriesIndex);
|
||||
if (timeSeries != null && itemIndex < timeSeries.getItemCount()) {
|
||||
org.jfree.data.time.TimeSeriesDataItem dataItem = timeSeries.getDataItem(itemIndex);
|
||||
String seriesName = timeSeries.getKey().toString();
|
||||
String dateStr = dataItem.getPeriod().toString();
|
||||
double value = dataItem.getValue().doubleValue();
|
||||
DecimalFormat valueFormat = new DecimalFormat("#,##0.00###");
|
||||
return String.format("%s: %s | Value: %s", seriesName, dateStr, valueFormat.format(value));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LOG.debug("Error extracting TimeSeries data", ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractXYSeriesData(XYSeriesCollection dataset, int seriesIndex, int itemIndex) {
|
||||
try {
|
||||
XYSeries series = dataset.getSeries(seriesIndex);
|
||||
if (series != null && itemIndex < series.getItemCount()) {
|
||||
double x = series.getX(itemIndex).doubleValue();
|
||||
double y = series.getY(itemIndex).doubleValue();
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT);
|
||||
DecimalFormat valueFormat = new DecimalFormat("#,##0.00###");
|
||||
return String.format("Date: %s | Value: %s", dateFormat.format(new Date((long) x)),
|
||||
valueFormat.format(y));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LOG.debug("Error extracting indicator data", ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.display;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.jfree.chart.JFreeChart;
|
||||
|
||||
import java.awt.Dimension;
|
||||
|
||||
/**
|
||||
* Display strategy for {@link JFreeChart} instances.
|
||||
*
|
||||
* <p>
|
||||
* Implementations are responsible for presenting a chart to the user. The
|
||||
* default implementation is {@link SwingChartDisplayer}, which renders a chart
|
||||
* in a Swing {@code ApplicationFrame}.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public interface ChartDisplayer {
|
||||
|
||||
/**
|
||||
* Presents the provided chart to the user.
|
||||
*
|
||||
* @param chart the chart to display
|
||||
* @since 0.19
|
||||
*/
|
||||
void display(JFreeChart chart);
|
||||
|
||||
/**
|
||||
* Presents the provided chart to the user with a custom window title.
|
||||
*
|
||||
* @param chart the chart to display
|
||||
* @param windowTitle the title for the window/frame
|
||||
* @since 0.19
|
||||
*/
|
||||
void display(JFreeChart chart, String windowTitle);
|
||||
|
||||
/**
|
||||
* Presents the provided chart to the user with a custom window title and an
|
||||
* explicit preferred display size.
|
||||
*
|
||||
* <p>
|
||||
* Implementations may ignore the preferred size when they cannot honor it. The
|
||||
* default behavior logs that the hint was ignored, then delegates to the
|
||||
* existing title-aware overload so current implementations remain
|
||||
* source-compatible.
|
||||
* </p>
|
||||
*
|
||||
* @param chart the chart to display
|
||||
* @param windowTitle the title for the window/frame
|
||||
* @param preferredSize the preferred display size
|
||||
* @since 0.22.7
|
||||
*/
|
||||
default void display(JFreeChart chart, String windowTitle, Dimension preferredSize) {
|
||||
if (preferredSize != null) {
|
||||
LogManager.getLogger(ChartDisplayer.class)
|
||||
.debug("Chart displayer {} ignored preferredSize {} because it does not override the size-aware display overload.",
|
||||
getClass().getName(), preferredSize);
|
||||
}
|
||||
if (windowTitle != null && !windowTitle.trim().isEmpty()) {
|
||||
display(chart, windowTitle);
|
||||
} else {
|
||||
display(chart);
|
||||
}
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.display;
|
||||
|
||||
import org.jfree.chart.ChartMouseEvent;
|
||||
import org.jfree.chart.ChartMouseListener;
|
||||
import org.jfree.chart.ChartPanel;
|
||||
import org.jfree.chart.JFreeChart;
|
||||
import org.jfree.chart.entity.ChartEntity;
|
||||
import org.jfree.chart.entity.XYItemEntity;
|
||||
import org.jfree.data.xy.XYDataset;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.HeadlessException;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.awt.event.WindowListener;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.Timer;
|
||||
import javax.swing.event.AncestorEvent;
|
||||
import javax.swing.event.AncestorListener;
|
||||
|
||||
/**
|
||||
* Swing-based {@link ChartDisplayer} that renders charts in a {@link JFrame}.
|
||||
*
|
||||
* <p>
|
||||
* This implementation displays charts in a Swing window with zoom and pan
|
||||
* capabilities. By default it auto-sizes the chart to 80% of the available
|
||||
* display bounds, while still allowing callers to supply an explicit preferred
|
||||
* size for display-specific use cases. Each window closes independently using
|
||||
* {@link JFrame#DISPOSE_ON_CLOSE} to prevent closing one window from affecting
|
||||
* others. When all chart windows are closed, the program automatically exits.
|
||||
* Windows are also configured as non-focusable so chart rendering in automated
|
||||
* runs does not steal desktop focus.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public final class SwingChartDisplayer implements ChartDisplayer {
|
||||
|
||||
/**
|
||||
* System property key for chart display scale configuration.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
static final String DISPLAY_SCALE_PROPERTY = "ta4j.chart.displayScale";
|
||||
|
||||
/**
|
||||
* System property key for mouseover hover delay in milliseconds.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
static final String HOVER_DELAY_PROPERTY = "ta4j.chart.hoverDelay";
|
||||
|
||||
/**
|
||||
* System property key to disable chart display (useful for automated tests).
|
||||
* When set to "true", charts will not be displayed and the display method will
|
||||
* return immediately without creating any windows.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public static final String DISABLE_DISPLAY_PROPERTY = "ta4j.chart.disableDisplay";
|
||||
|
||||
/**
|
||||
* Default chart display scale.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
static final double DEFAULT_DISPLAY_SCALE = 0.80;
|
||||
private static final int DEFAULT_DISPLAY_WIDTH = 1920;
|
||||
private static final int DEFAULT_DISPLAY_HEIGHT = 1200;
|
||||
private static final int MIN_DISPLAY_WIDTH = 800;
|
||||
private static final int MIN_DISPLAY_HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Default mouseover hover delay in milliseconds.
|
||||
* <p>
|
||||
* Set to 500ms to align with UX best practices. Nielsen Norman Group recommends
|
||||
* 300-500ms, and Microsoft uses 500ms for tooltips. This prevents accidental
|
||||
* tooltip activations when users move their cursor across the chart.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
static final int DEFAULT_HOVER_DELAY_MS = 500;
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(SwingChartDisplayer.class);
|
||||
|
||||
/**
|
||||
* Static counter to track window positions for cascading multiple chart
|
||||
* windows.
|
||||
*/
|
||||
private static int windowCounter = 0;
|
||||
private static final int CASCADE_OFFSET_X = 30;
|
||||
private static final int CASCADE_OFFSET_Y = 30;
|
||||
|
||||
/**
|
||||
* Set of all open chart windows. Used to track when all windows are closed so
|
||||
* the program can exit.
|
||||
*/
|
||||
private static final Set<JFrame> openWindows = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Override
|
||||
public void display(JFreeChart chart) {
|
||||
display(chart, "Ta4j-examples", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void display(JFreeChart chart, String windowTitle) {
|
||||
display(chart, windowTitle, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void display(JFreeChart chart, String windowTitle, Dimension preferredSize) {
|
||||
// Validate input parameter
|
||||
if (chart == null) {
|
||||
throw new IllegalArgumentException("Chart cannot be null");
|
||||
}
|
||||
|
||||
// Check if display is disabled via system property (useful for automated tests)
|
||||
if (isDisplayDisabled()) {
|
||||
LOG.debug("Chart display is disabled via system property {}", DISABLE_DISPLAY_PROPERTY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize and deserialize the chart to create a deep copy that prevents
|
||||
// ChartPanel from modifying the original
|
||||
JFreeChart chartClone;
|
||||
try {
|
||||
chartClone = deepCopyChart(chart);
|
||||
} catch (Exception e) {
|
||||
LOG.debug("Failed to deep copy chart, falling back to shallow clone", e);
|
||||
try {
|
||||
chartClone = (JFreeChart) chart.clone();
|
||||
} catch (CloneNotSupportedException cloneEx) {
|
||||
LOG.debug("Failed to clone chart, using original chart for display", cloneEx);
|
||||
chartClone = chart;
|
||||
}
|
||||
}
|
||||
ChartPanel panel = new ChartPanel(chartClone);
|
||||
panel.setFillZoomRectangle(true);
|
||||
panel.setMouseWheelEnabled(true);
|
||||
panel.setDomainZoomable(true);
|
||||
panel.setDisplayToolTips(false);
|
||||
panel.setPreferredSize(resolveDisplaySize(preferredSize));
|
||||
|
||||
// Create info panel for mouseover data
|
||||
JLabel infoLabel = new JLabel(" ");
|
||||
infoLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
|
||||
infoLabel.setForeground(Color.LIGHT_GRAY);
|
||||
infoLabel.setBackground(Color.BLACK);
|
||||
infoLabel.setOpaque(true);
|
||||
infoLabel.setBorder(javax.swing.BorderFactory.createEmptyBorder(4, 8, 4, 8));
|
||||
|
||||
// Create container panel with chart and info label
|
||||
JPanel containerPanel = new JPanel(new BorderLayout());
|
||||
containerPanel.add(panel, BorderLayout.CENTER);
|
||||
containerPanel.add(infoLabel, BorderLayout.NORTH);
|
||||
|
||||
// Add mouseover listener
|
||||
int hoverDelay = resolveHoverDelay();
|
||||
ChartMouseoverListener mouseoverListener = new ChartMouseoverListener(infoLabel, hoverDelay);
|
||||
panel.addChartMouseListener(mouseoverListener);
|
||||
|
||||
// Add ancestor listener to cleanup timer when component is removed
|
||||
panel.addAncestorListener(new AncestorListener() {
|
||||
@Override
|
||||
public void ancestorAdded(AncestorEvent event) {
|
||||
// No action needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ancestorRemoved(AncestorEvent event) {
|
||||
mouseoverListener.disposeHoverTimer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ancestorMoved(AncestorEvent event) {
|
||||
// No action needed
|
||||
}
|
||||
});
|
||||
|
||||
String title = windowTitle != null && !windowTitle.trim().isEmpty() ? windowTitle : "Ta4j-examples";
|
||||
// Use JFrame instead of ApplicationFrame to avoid EXIT_ON_CLOSE behavior
|
||||
// ApplicationFrame sets EXIT_ON_CLOSE which closes all windows
|
||||
JFrame frame = new JFrame(title);
|
||||
frame.setContentPane(containerPanel);
|
||||
frame.pack();
|
||||
frame.setAlwaysOnTop(false);
|
||||
frame.setAutoRequestFocus(false);
|
||||
frame.setFocusableWindowState(false);
|
||||
// Set to DISPOSE_ON_CLOSE so closing one window doesn't close all windows
|
||||
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
|
||||
|
||||
// Track this window and add listener to exit when all windows are closed
|
||||
openWindows.add(frame);
|
||||
frame.addWindowListener(new WindowListener() {
|
||||
@Override
|
||||
public void windowOpened(WindowEvent e) {
|
||||
// No action needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowClosing(WindowEvent e) {
|
||||
// No action needed - DISPOSE_ON_CLOSE handles the closing
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowClosed(WindowEvent e) {
|
||||
openWindows.remove(frame);
|
||||
// If all windows are closed, exit the program
|
||||
if (openWindows.isEmpty()) {
|
||||
if (isDisplayDisabled()) {
|
||||
LOG.debug("All chart windows closed while display is disabled; skipping System.exit(0)");
|
||||
} else {
|
||||
LOG.debug("All chart windows closed, exiting program");
|
||||
System.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowIconified(WindowEvent e) {
|
||||
// No action needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowDeiconified(WindowEvent e) {
|
||||
// No action needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowActivated(WindowEvent e) {
|
||||
// No action needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowDeactivated(WindowEvent e) {
|
||||
// No action needed
|
||||
}
|
||||
});
|
||||
|
||||
// Cascade windows by offsetting each new window
|
||||
int windowIndex = windowCounter++;
|
||||
try {
|
||||
Rectangle screenBounds = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||
if (screenBounds != null) {
|
||||
int x = screenBounds.x + (windowIndex * CASCADE_OFFSET_X);
|
||||
int y = screenBounds.y + (windowIndex * CASCADE_OFFSET_Y);
|
||||
// Ensure window stays within screen bounds
|
||||
Dimension frameSize = frame.getSize();
|
||||
if (x + frameSize.width > screenBounds.width) {
|
||||
x = screenBounds.x + ((windowIndex % 10) * CASCADE_OFFSET_X);
|
||||
}
|
||||
if (y + frameSize.height > screenBounds.height) {
|
||||
y = screenBounds.y + ((windowIndex % 10) * CASCADE_OFFSET_Y);
|
||||
}
|
||||
frame.setLocation(x, y);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LOG.debug("Unable to set window position for cascading, using default", ex);
|
||||
}
|
||||
|
||||
frame.setVisible(true);
|
||||
}
|
||||
|
||||
Dimension determineDisplaySize() {
|
||||
return resolveDisplaySize(null);
|
||||
}
|
||||
|
||||
Dimension resolveDisplaySize(Dimension preferredSize) {
|
||||
if (preferredSize != null) {
|
||||
if (preferredSize.width <= 0 || preferredSize.height <= 0) {
|
||||
throw new IllegalArgumentException("Preferred display size must be positive");
|
||||
}
|
||||
return new Dimension(preferredSize);
|
||||
}
|
||||
|
||||
double displayScale = resolveDisplayScale();
|
||||
|
||||
try {
|
||||
Rectangle bounds = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||
if (bounds != null && bounds.getWidth() > 0 && bounds.getHeight() > 0) {
|
||||
int width = (int) Math.round(bounds.getWidth() * displayScale);
|
||||
int height = (int) Math.round(bounds.getHeight() * displayScale);
|
||||
width = Math.max(MIN_DISPLAY_WIDTH, width);
|
||||
height = Math.max(MIN_DISPLAY_HEIGHT, height);
|
||||
return new Dimension(width, height);
|
||||
}
|
||||
} catch (HeadlessException headlessEx) {
|
||||
LOG.debug("Headless environment detected while determining chart display size", headlessEx);
|
||||
} catch (Exception ex) {
|
||||
LOG.warn("Unable to determine screen bounds for chart display size", ex);
|
||||
}
|
||||
|
||||
int fallbackWidth = (int) Math.round(DEFAULT_DISPLAY_WIDTH * displayScale);
|
||||
int fallbackHeight = (int) Math.round(DEFAULT_DISPLAY_HEIGHT * displayScale);
|
||||
fallbackWidth = Math.max(MIN_DISPLAY_WIDTH, fallbackWidth);
|
||||
fallbackHeight = Math.max(MIN_DISPLAY_HEIGHT, fallbackHeight);
|
||||
return new Dimension(fallbackWidth, fallbackHeight);
|
||||
}
|
||||
|
||||
double resolveDisplayScale() {
|
||||
String configuredScale = System.getProperty(DISPLAY_SCALE_PROPERTY);
|
||||
if (configuredScale != null) {
|
||||
try {
|
||||
double parsedValue = Double.parseDouble(configuredScale);
|
||||
if (parsedValue > 0.1 && parsedValue <= 1.0) {
|
||||
return parsedValue;
|
||||
}
|
||||
LOG.debug("Ignoring display scale property {} outside accepted range (0.1, 1.0]: {}",
|
||||
DISPLAY_SCALE_PROPERTY, configuredScale);
|
||||
} catch (NumberFormatException numberFormatException) {
|
||||
LOG.debug("Unable to parse display scale property {} value: {}", DISPLAY_SCALE_PROPERTY,
|
||||
configuredScale, numberFormatException);
|
||||
}
|
||||
}
|
||||
return DEFAULT_DISPLAY_SCALE;
|
||||
}
|
||||
|
||||
int resolveHoverDelay() {
|
||||
String configuredDelay = System.getProperty(HOVER_DELAY_PROPERTY);
|
||||
if (configuredDelay != null) {
|
||||
try {
|
||||
int parsedValue = Integer.parseInt(configuredDelay);
|
||||
if (parsedValue >= 0) {
|
||||
return parsedValue;
|
||||
}
|
||||
LOG.debug("Ignoring hover delay property {} with negative value: {}", HOVER_DELAY_PROPERTY,
|
||||
configuredDelay);
|
||||
} catch (NumberFormatException numberFormatException) {
|
||||
LOG.debug("Unable to parse hover delay property {} value: {}", HOVER_DELAY_PROPERTY, configuredDelay,
|
||||
numberFormatException);
|
||||
}
|
||||
}
|
||||
return DEFAULT_HOVER_DELAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if chart display is disabled via system property.
|
||||
*
|
||||
* @return true if display is disabled, false otherwise
|
||||
*/
|
||||
boolean isDisplayDisabled() {
|
||||
String disableDisplay = System.getProperty(DISABLE_DISPLAY_PROPERTY);
|
||||
return "true".equalsIgnoreCase(disableDisplay);
|
||||
}
|
||||
|
||||
private JFreeChart deepCopyChart(JFreeChart chart) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
|
||||
oos.writeObject(chart);
|
||||
}
|
||||
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
|
||||
return (JFreeChart) ois.readObject();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse listener that displays OHLC data for candles and indicator values on
|
||||
* mouseover.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
static class ChartMouseoverListener implements ChartMouseListener {
|
||||
|
||||
private final JLabel infoLabel;
|
||||
private final int hoverDelay;
|
||||
private final ChartDataExtractor dataExtractor;
|
||||
private Timer hoverTimer;
|
||||
private String lastDisplayedText;
|
||||
private ChartMouseEvent lastEvent;
|
||||
|
||||
ChartMouseoverListener(JLabel infoLabel, int hoverDelay) {
|
||||
this(infoLabel, hoverDelay, new ChartDataExtractor());
|
||||
}
|
||||
|
||||
ChartMouseoverListener(JLabel infoLabel, int hoverDelay, ChartDataExtractor dataExtractor) {
|
||||
this.infoLabel = infoLabel;
|
||||
this.hoverDelay = hoverDelay;
|
||||
this.dataExtractor = dataExtractor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void chartMouseClicked(ChartMouseEvent event) {
|
||||
// No action on click
|
||||
}
|
||||
|
||||
@Override
|
||||
public void chartMouseMoved(ChartMouseEvent event) {
|
||||
// Cancel any pending timer
|
||||
if (hoverTimer != null) {
|
||||
hoverTimer.stop();
|
||||
hoverTimer = null;
|
||||
}
|
||||
|
||||
// Clear display immediately when mouse moves
|
||||
if (lastDisplayedText != null) {
|
||||
infoLabel.setText(" ");
|
||||
lastDisplayedText = null;
|
||||
}
|
||||
|
||||
// Store the event for later use
|
||||
lastEvent = event;
|
||||
|
||||
// Start new timer to show data after delay
|
||||
hoverTimer = new Timer(hoverDelay, new ActionListener() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
displayMouseoverData(lastEvent);
|
||||
}
|
||||
});
|
||||
hoverTimer.setRepeats(false);
|
||||
hoverTimer.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the hover timer and clears all references to prevent memory leaks when
|
||||
* the component is disposed or the listener is removed.
|
||||
*/
|
||||
void disposeHoverTimer() {
|
||||
if (hoverTimer != null) {
|
||||
hoverTimer.stop();
|
||||
hoverTimer = null;
|
||||
}
|
||||
lastEvent = null;
|
||||
lastDisplayedText = null;
|
||||
}
|
||||
|
||||
private void displayMouseoverData(ChartMouseEvent event) {
|
||||
try {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChartEntity entity = event.getEntity();
|
||||
if (entity instanceof XYItemEntity xyItemEntity) {
|
||||
XYDataset dataset = xyItemEntity.getDataset();
|
||||
int seriesIndex = xyItemEntity.getSeriesIndex();
|
||||
int itemIndex = xyItemEntity.getItem();
|
||||
|
||||
String displayText = dataExtractor.extractDataText(dataset, seriesIndex, itemIndex);
|
||||
if (displayText != null && !displayText.isEmpty()) {
|
||||
infoLabel.setText(displayText);
|
||||
lastDisplayedText = displayText;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LOG.debug("Error displaying mouseover data", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.renderer;
|
||||
|
||||
import org.jfree.chart.renderer.xy.CandlestickRenderer;
|
||||
import org.jfree.data.xy.OHLCDataset;
|
||||
import org.jfree.data.xy.XYDataset;
|
||||
|
||||
import java.awt.*;
|
||||
|
||||
/**
|
||||
* Custom candlestick renderer that provides distinct colors for up and down
|
||||
* candles.
|
||||
*
|
||||
* <p>
|
||||
* This renderer extends the standard JFreeChart CandlestickRenderer to provide
|
||||
* custom coloring based on whether a candle represents a price increase (up) or
|
||||
* decrease (down). Up candles are colored green, down candles are colored red.
|
||||
* </p>
|
||||
*
|
||||
* @see CandlestickRenderer
|
||||
* @since 0.19
|
||||
*/
|
||||
public class BaseCandleStickRenderer extends CandlestickRenderer {
|
||||
|
||||
/**
|
||||
* Color for up candles (close > open). Matches TradingView's default bullish
|
||||
* candle color.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public static final Color DEFAULT_UP_COLOR = new Color(0x26A69A);
|
||||
|
||||
/**
|
||||
* Color for down candles (close {@literal <} open). Matches TradingView's
|
||||
* default bearish candle color.
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public static final Color DEFAULT_DOWN_COLOR = new Color(0xEF5350);
|
||||
|
||||
/**
|
||||
* Returns the paint color for the specified item based on whether it's an up or
|
||||
* down candle.
|
||||
*
|
||||
* @param row the row (series) index
|
||||
* @param column the column (item) index
|
||||
* @return the paint color for the item
|
||||
* @since 0.19
|
||||
*/
|
||||
@Override
|
||||
public Paint getItemPaint(int row, int column) {
|
||||
XYDataset dataset = getPlot().getDataset();
|
||||
if (!(dataset instanceof OHLCDataset highLowData)) {
|
||||
return super.getItemPaint(row, column);
|
||||
}
|
||||
|
||||
// Check for valid indices
|
||||
if (row < 0 || row >= highLowData.getSeriesCount() || column < 0 || column >= highLowData.getItemCount(row)) {
|
||||
return new Color(128, 128, 128); // Return neutral gray for invalid indices
|
||||
}
|
||||
|
||||
Number yOpen = highLowData.getOpen(row, column);
|
||||
Number yClose = highLowData.getClose(row, column);
|
||||
|
||||
if (yOpen == null || yClose == null) {
|
||||
return super.getItemPaint(row, column);
|
||||
}
|
||||
|
||||
boolean isUpCandle = yClose.doubleValue() > yOpen.doubleValue();
|
||||
|
||||
return isUpCandle ? DEFAULT_UP_COLOR : DEFAULT_DOWN_COLOR;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.storage;
|
||||
|
||||
import org.jfree.chart.JFreeChart;
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Strategy for persisting charts.
|
||||
*
|
||||
* <p>
|
||||
* Implementations are responsible for saving chart images to a storage system.
|
||||
* The default implementation {@link FileSystemChartStorage} saves charts as
|
||||
* JPEG files to the filesystem.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public interface ChartStorage {
|
||||
|
||||
/**
|
||||
* Persists the provided chart and returns the destination path if the operation
|
||||
* succeeds.
|
||||
*
|
||||
* @param chart the chart to persist
|
||||
* @param series the originating bar series
|
||||
* @param chartTitle the descriptive chart title
|
||||
* @param width target image width
|
||||
* @param height target image height
|
||||
* @return the optional path to the persisted image
|
||||
* @since 0.19
|
||||
*/
|
||||
Optional<Path> save(JFreeChart chart, BarSeries series, String chartTitle, int width, int height);
|
||||
|
||||
/**
|
||||
* Creates a storage strategy that performs no persistence.
|
||||
*
|
||||
* @return a no-op storage strategy
|
||||
* @since 0.19
|
||||
*/
|
||||
static ChartStorage noOp() {
|
||||
return (chart, series, chartTitle, width, height) -> Optional.empty();
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
package ta4jexamples.charting.storage;
|
||||
|
||||
import org.jfree.chart.ChartUtils;
|
||||
import org.jfree.chart.JFreeChart;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Persists charts to the filesystem as JPEG images.
|
||||
*
|
||||
* <p>
|
||||
* This storage implementation saves charts directly to a configurable root
|
||||
* directory. When a chart title is provided (non-null and non-empty), it is
|
||||
* used as the filename (sanitized and with .jpg extension). Otherwise,
|
||||
* filenames are automatically generated using the format:
|
||||
* {@code <sanitized bar series name>_<start date>_to_<end date>_<current datetime>.jpg}.
|
||||
* Bar series start and end dates are formatted as dates only (without time
|
||||
* portion). Filenames are sanitized to ensure filesystem compatibility.
|
||||
* </p>
|
||||
*
|
||||
* @since 0.19
|
||||
*/
|
||||
public final class FileSystemChartStorage implements ChartStorage {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(FileSystemChartStorage.class);
|
||||
|
||||
private final Path rootDirectory;
|
||||
|
||||
public FileSystemChartStorage(Path rootDirectory) {
|
||||
Objects.requireNonNull(rootDirectory, "Root directory must be provided");
|
||||
this.rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Path> save(JFreeChart chart, BarSeries series, String chartTitle, int width, int height) {
|
||||
Objects.requireNonNull(chart, "Chart cannot be null");
|
||||
Objects.requireNonNull(series, "Series cannot be null");
|
||||
|
||||
Path targetPath = (chartTitle != null && !chartTitle.trim().isEmpty()) ? buildSavePath(series, chartTitle)
|
||||
: buildSavePath(series);
|
||||
try {
|
||||
Files.createDirectories(targetPath.getParent());
|
||||
ChartUtils.saveChartAsJPEG(targetPath.toFile(), chart, width, height);
|
||||
LOG.debug("Saved chart to {}", targetPath.toAbsolutePath());
|
||||
return Optional.of(targetPath.toAbsolutePath());
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Failed to save chart {} to {}", chartTitle, targetPath, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Path buildSavePath(BarSeries series) {
|
||||
String sanitizedSeriesName = sanitizePathComponent(series.getName());
|
||||
|
||||
// Get start and end dates (date only, no time)
|
||||
String startDate = "unknown";
|
||||
String endDate = "unknown";
|
||||
if (!series.isEmpty()) {
|
||||
startDate = formatDateOnly(series.getFirstBar().getEndTime());
|
||||
endDate = formatDateOnly(series.getLastBar().getEndTime());
|
||||
}
|
||||
|
||||
// Get current datetime for filename
|
||||
String currentDateTime = ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
||||
|
||||
// Build filename: <sanitized bar series name>_<start date>_to_<end
|
||||
// date>_<current datetime>.jpg
|
||||
String filename = String.format("%s_%s_to_%s_%s.jpg", sanitizedSeriesName, startDate, endDate, currentDateTime);
|
||||
|
||||
return rootDirectory.resolve(filename);
|
||||
}
|
||||
|
||||
private Path buildSavePath(BarSeries series, String filename) {
|
||||
String sanitizedFilename = sanitizePathComponent(filename);
|
||||
return rootDirectory.resolve(sanitizedFilename + ".jpg");
|
||||
}
|
||||
|
||||
private String formatDateOnly(Instant instant) {
|
||||
LocalDate date = instant.atZone(ZoneOffset.UTC).toLocalDate();
|
||||
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
|
||||
private String sanitizePathComponent(String component) {
|
||||
if (component == null || component.trim().isEmpty()) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return component.replace(":", "-")
|
||||
.replace("/", "_")
|
||||
.replace("\\", "_")
|
||||
.replace("?", "_")
|
||||
.replace("*", "_")
|
||||
.replace("<", "(")
|
||||
.replace(">", ")")
|
||||
.replace("|", "_")
|
||||
.replace("\"", "")
|
||||
.trim()
|
||||
.replaceAll("^\\.+|\\.+$", "")
|
||||
.replaceAll("\\s+", "_");
|
||||
}
|
||||
}
|
||||
+1075
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user