goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,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;
}
}
@@ -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 + ")";
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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+", "_");
}
}