From 59f5f5f2c4e745bb91de9a5521a611bbecc520bb Mon Sep 17 00:00:00 2001 From: lc Date: Thu, 19 Jun 2025 21:52:50 +0800 Subject: [PATCH 1/3] Example of no time gap financial chart. --- .../axes/spi/DefaultFinancialAxis.java | 653 ++++++++++++++++++ .../chartfx/axes/spi/DefaultNumericAxis.java | 6 +- .../chartfx/plugins/OhlcvTooltip.java | 331 +++++++++ ...bstractBasicFinancialNoGapApplication.java | 373 ++++++++++ .../FinancialNoGapCandlestickSample.java | 108 +++ 5 files changed, 1468 insertions(+), 3 deletions(-) create mode 100644 chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java create mode 100644 chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java create mode 100644 chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java create mode 100644 chartfx-samples/src/main/java/io/fair_acc/sample/financial/FinancialNoGapCandlestickSample.java diff --git a/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java new file mode 100644 index 000000000..ea184cf3d --- /dev/null +++ b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java @@ -0,0 +1,653 @@ +package io.fair_acc.chartfx.axes.spi; + +import io.fair_acc.chartfx.axes.*; +import io.fair_acc.chartfx.axes.spi.transforms.DefaultAxisTransform; +import io.fair_acc.chartfx.axes.spi.transforms.LogarithmicAxisTransform; +import io.fair_acc.chartfx.axes.spi.transforms.LogarithmicTimeAxisTransform; +import io.fair_acc.chartfx.utils.PropUtil; +import io.fair_acc.dataset.spi.fastutil.DoubleArrayList; +import io.fair_acc.dataset.spi.financial.OhlcvDataSet; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleBooleanProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.*; +import java.util.Date; + +/** + * A axis class that plots a range of dates with major tick marks every TODO:"tickUnit". + * To be consistent with the library, it was decided to use Date instead of the more modern LocalDateTime. + *

+ * Compared to the {@link DefaultNumericAxis} this one changes + *

+ * + * @author lacgit + */ +public class DefaultFinancialAxis extends AbstractAxis implements Axis { + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultFinancialAxis.class); + public static final double DEFAULT_LOG_MIN_VALUE = 1e-6; + private static final int DEFAULT_RANGE_LENGTH = 2; + private double offset; + private final transient Cache cache = new Cache(); + private final transient DefaultAxisTransform linearTransform = new DefaultAxisTransform(this); + private final transient LogarithmicAxisTransform logTransform = new LogarithmicAxisTransform(this); + private final transient LogarithmicTimeAxisTransform logTimeTransform = new LogarithmicTimeAxisTransform(this); + private transient AxisTransform axisTransform = linearTransform; + protected boolean isUpdating; + + private final transient BooleanProperty forceZeroInRange = PropUtil.createBooleanProperty(this, "forceZeroInRange", false, invalidateAxisRange); + + protected boolean isLogAxis = false; // internal use (for performance reason + + private final transient BooleanProperty logAxis = new SimpleBooleanProperty(this, "logAxis", isLogAxis); + { + logAxis.addListener((bean, oldVal, newVal) -> { + isLogAxis = newVal; + if (isLogAxis) { + if (DefaultFinancialAxis.this.isTimeAxis()) { + axisTransform = logTimeTransform; + setMinorTickCount(0); + } else { + axisTransform = logTransform; + setMinorTickCount(AbstractAxisParameter.DEFAULT_MINOR_TICK_COUNT); + } + if (getMin() <= 0) { + isUpdating = true; + setMin(DefaultFinancialAxis.DEFAULT_LOG_MIN_VALUE); + isUpdating = false; + } + } else { + axisTransform = linearTransform; + if (DefaultFinancialAxis.this.isTimeAxis()) { + setMinorTickCount(0); + } else { + setMinorTickCount(AbstractAxisParameter.DEFAULT_MINOR_TICK_COUNT); + } + } + + invalidateAxisRange.run(); + }); + } + + private OhlcvDataSet ohlcvDataSet; + + /** + * Creates an {@link #autoRangingProperty() auto-ranging} Axis. + */ + public DefaultFinancialAxis() { + this("axis label", 0.0, 0.0, 5.0); + } + + /** + * Creates a {@link #autoRangingProperty() non-auto-ranging} Axis with the given upper bound, lower bound and tick + * unit. + * + * @param lowerBound the {@link #minProperty() lower bound} of the axis + * @param upperBound the {@link #maxProperty() upper bound} of the axis + * @param tickUnit the tick unit, i.e. space between tick marks + */ + public DefaultFinancialAxis(final double lowerBound, final double upperBound, final double tickUnit) { + this(null, lowerBound, upperBound, tickUnit); + } + + /** + * Creates an {@link #autoRangingProperty() auto-ranging} Axis. + * + * @param axisLabel the axis {@link #nameProperty() label} + */ + public DefaultFinancialAxis(final String axisLabel) { + this(axisLabel, 0.0, 0.0, 5.0); + } + + /** + * Create a {@link #autoRangingProperty() non-auto-ranging} Axis with the given upper bound, lower bound and tick + * unit. + * + * @param axisLabel the axis {@link #nameProperty() label} + * @param lowerBound the {@link #minProperty() lower bound} of the axis + * @param upperBound the {@link #maxProperty() upper bound} of the axis + * @param tickUnit the tick unit, i.e. space between tick marks + */ + public DefaultFinancialAxis(final String axisLabel, final double lowerBound, final double upperBound, + final double tickUnit) { + super(lowerBound, upperBound); + this.setName(axisLabel); + if (lowerBound >= upperBound) { + setAutoRanging(true); + } + setTickUnit(tickUnit); + setMinorTickCount(AbstractAxisParameter.DEFAULT_MINOR_TICK_COUNT); + setOverlapPolicy(AxisLabelOverlapPolicy.DO_NOTHING); + + isUpdating = false; + } + + /** + * Creates an {@link #autoRangingProperty() auto-ranging} Axis. + * + * @param axisLabel the axis {@link #nameProperty() label} + * @param unit the unit of the axis axis {@link #unitProperty() label} + */ + public DefaultFinancialAxis(final String axisLabel, final String unit, final OhlcvDataSet ohlcvDataSet) { + this(axisLabel, 0.0, 0.0, 5.0); + this.ohlcvDataSet = ohlcvDataSet; + setUnit(unit); + } + + /** + * Computes the preferred tick unit based on the upper/lower bounds and the length of the axis in screen + * coordinates. + * + * @param axisLength the length in screen coordinates + * @return the tick unit + */ + @Override + public double computePreferredTickUnit(final double axisLength) { + final double labelSize = getTickLabelFont().getSize() * 2; + final int numOfFittingLabels = (int) Math.floor(axisLength / labelSize); + final int numOfTickMarks = Math.max(Math.min(numOfFittingLabels, getMaxMajorTickLabelCount()), 2); + double rawTickUnit = (getMax() - getMin()) / numOfTickMarks; + if (rawTickUnit == 0 || Double.isNaN(rawTickUnit)) { + rawTickUnit = 1e-3; // TODO: remove this hack (eventually) ;-) + } + return computeTickUnit(rawTickUnit); + } + + /** + * When {@code true} zero is always included in the visible range. This only has effect if + * {@link #autoRangingProperty() auto-ranging} is on. + * + * @return forceZeroInRange property + */ + public BooleanProperty forceZeroInRangeProperty() { + return forceZeroInRange; + } + + /** + * Gets the transformation (linear, logarithmic, etc) applied to the values of this axis. + * + * @return the axis transformation + */ + @Override + public AxisTransform getAxisTransform() { + return axisTransform; + } + + /** + * Get the display position along this axis for a given value. If the value is not in the current range, the + * returned value will be an extrapolation of the display position. -- cached double optimised version (shaves of + * 50% on delays) + * + * @param value The data value to work out display position for + * @return display position + */ + @Override + public double getDisplayPosition(final double value) { + if (isInvertedAxis) { + return offset - getDisplayPositionImpl(value); + } + return getDisplayPositionImpl(value); + } + + /** + * Returns the value of the {@link #logarithmBaseProperty()}. + * + * @return base of the logarithm + */ + public double getLogarithmBase() { + return logarithmBaseProperty().get(); + } + + /** + * @return the log axis Type @see LogAxisType + */ + @Override + public LogAxisType getLogAxisType() { + if (isLogAxis) { + return LogAxisType.LOG10_SCALE; + } + return LogAxisType.LINEAR_SCALE; + } + + /** + * Get the data value for the given display position on this axis. If the axis is a CategoryAxis this will be the + * nearest value. -- cached double optimised version (shaves of 50% on delays) + * + * @param displayPosition A pixel position on this axis + * @return the nearest data value to the given pixel position or null if not on axis; + */ + @Override + public double getValueForDisplay(final double displayPosition) { + if (isInvertedAxis) { + return getValueForDisplayImpl(offset - displayPosition); + } + return getValueForDisplayImpl(displayPosition); + } + + @Override + protected double calculateNewScale(final double length, final double lowerBound, final double upperBound) { + double dLowIndex = ohlcvDataSet.getXIndex(lowerBound); + double dUpIndex = ohlcvDataSet.getXIndex(upperBound); + final double range = dUpIndex - dLowIndex; + final double scale = (range == 0) ? length : length / range; + if (scale == 0) { + return -1; // covers inf range input + } + return getSide().isVertical() ? -scale : scale; + } + + /** + * Get the display position of the zero line along this axis. + * + * @return display position or Double.NaN if zero is not in current range; + */ + @Override + public double getZeroPosition() { + if (isLogAxis) { + return getDisplayPosition(cache.localCurrentLowerBound); + } + + if (0 < cache.localCurrentLowerBound || 0 > cache.localCurrentUpperBound) { + return Double.NaN; + } + + return getDisplayPosition(cache.localCurrentLowerBound); + } + + /** + * Returns the value of the {@link #forceZeroInRangeProperty()}. + * + * @return value of the forceZeroInRange property + */ + public boolean isForceZeroInRange() { + return forceZeroInRangeProperty().getValue(); + } + + /** + * Returns the value of the {@link #logAxisProperty()}. + * + * @return value of the logAxis property + */ + @Override + public boolean isLogAxis() { + return isLogAxis; + } + + /** + * Checks if the given value is plottable on this axis + * + * @param value The value to check if its on axis + * @return true if the given value is plottable on this axis + */ + @Override + public boolean isValueOnAxis(final double value) { + return value >= getMin() && value <= getMax(); + } + + /** + * Base of the logarithm used by the axis, must be grater than 1. + *

+ * Default value: 10 + *

+ * + * @return base of the logarithm + */ + public DoubleProperty logarithmBaseProperty() { + return logTransform.logarithmBaseProperty(); + } + + /** + * When {@code true} axis is being a log-axis (default = false) + * + * @see #getLogAxisType for more infomation + * @return logAxis property + */ + public BooleanProperty logAxisProperty() { + return logAxis; + } + + @Override + public void requestAxisLayout() { + if (isUpdating) { + return; + } + + super.requestAxisLayout(); + } + + /** + * Sets the value of the {@link #forceZeroInRangeProperty()}. + * + * @param value if {@code true}, zero is always included in the visible range + */ + public void setForceZeroInRange(final boolean value) { + forceZeroInRangeProperty().setValue(value); + } + + /** + * Sets value of the {@link #logarithmBaseProperty()}. + * + * @param value base of the logarithm, value > 1 + */ + public void setLogarithmBase(final double value) { + logarithmBaseProperty().set(value); + invalidateAxisRange.run(); + } + + /** + * Sets the value of the {@link #logAxisProperty()}. + * + * @param value if {@code true}, log axis is drawn + */ + public void setLogAxis(final boolean value) { + isLogAxis = value; + logAxis.set(value); + } + + private AxisRange computeRangeImpl(final double min, final double max, final double axisLength, + final double labelSize) { + final int numOfFittingLabels = (int) Math.floor(axisLength / labelSize); + final int numOfTickMarks = Math.max(Math.min(numOfFittingLabels, getMaxMajorTickLabelCount()), 2); + + double rawTickUnit = (max - min) / numOfTickMarks; + if (rawTickUnit == 0 || Double.isNaN(rawTickUnit)) { + rawTickUnit = 1e-3; // TODO: remove hack + } + + // double tickUnitRounded = Double.MIN_VALUE; // TODO check if not '-Double.MAX_VALUE' + final double tickUnitRounded = computeTickUnit(rawTickUnit); + final boolean round = (isAutoRanging() || isAutoGrowRanging()) && isAutoRangeRounding(); + final double minRounded = round ? axisTransform.getRoundedMinimumRange(min) : min; + final double maxRounded = round ? axisTransform.getRoundedMaximumRange(max) : max; + final double newScale = calculateNewScale(axisLength, minRounded, maxRounded); + return new AxisRange(minRounded, maxRounded, axisLength, newScale, tickUnitRounded); + } + + private double getDisplayPositionImpl(final double value) { + if (isLogAxis) { + final double valueLogOffset = axisTransform.forward(value) - cache.lowerBoundLog; + + if (cache.isVerticalAxis) { + return cache.axisLength - valueLogOffset * cache.logScaleLengthInv; + } + return valueLogOffset * cache.logScaleLengthInv; + } + + double dIndex = ohlcvDataSet.getXIndex(value); + + // default case: linear axis computation (dependent variables are being cached for performance reasons) + // return cache.localOffset + (value - cache.localCurrentLowerBound) * cache.localScale; + return cache.localOffset2 + dIndex * cache.localScale; + } + + private double getValueForDisplayImpl(final double displayPosition) { + if (isLogAxis) { + if (cache.isVerticalAxis) { + final double length = cache.axisLength; + return axisTransform.backward(cache.lowerBoundLog + (length - displayPosition) / length * cache.logScaleLength); + } + return axisTransform.backward(cache.lowerBoundLog + displayPosition / cache.axisLength * cache.logScaleLength); + } + + int index = (int)(cache.localCurrentLowerIndex + ((displayPosition - cache.localOffset) / cache.localScale)); + if (index<0) { + index = 0; + } + if (index>=ohlcvDataSet.getDataCount()) { + index = ohlcvDataSet.getDataCount()-1; + } + return ohlcvDataSet.getItem(index).getTimeStamp().getTime()/1000.0; + } + + @Override + protected AxisRange autoRange(final double minValue, final double maxValue, final double length, + final double labelSize) { + double min = minValue > 0 && isForceZeroInRange() ? 0 : minValue; + if (isLogAxis && minValue <= 0) { + min = DefaultFinancialAxis.DEFAULT_LOG_MIN_VALUE; + isUpdating = true; + // TODO: check w.r.t. inverted axis (lower <-> upper bound exchange) + setMin(DefaultFinancialAxis.DEFAULT_LOG_MIN_VALUE); + isUpdating = false; + } + final double max = maxValue < 0 && isForceZeroInRange() ? 0 : maxValue; + final double padding = DefaultFinancialAxis.getEffectiveRange(min, max) * getAutoRangePadding(); + final double paddingScale = 1.0 + getAutoRangePadding(); + final double paddedMin = isLogAxis ? minValue / paddingScale + : DefaultFinancialAxis.clampBoundToZero(min - padding, min); + final double paddedMax = isLogAxis ? maxValue * paddingScale + : DefaultFinancialAxis.clampBoundToZero(max + padding, max); + + return computeRange(paddedMin, paddedMax, length, labelSize); + } + + @Override + protected void calculateMajorTickValues(final AxisRange axisRange, DoubleArrayList tickValues) { + if (isLogAxis) { + if (axisRange.getLowerBound() >= axisRange.getUpperBound()) { + tickValues.add(axisRange.getLowerBound()); + return; + } + double exp = Math.ceil(axisTransform.forward(axisRange.getLowerBound())); + for (double tickValue = axisTransform.backward(exp); tickValue <= axisRange.getUpperBound(); + tickValue = axisTransform.backward(++exp)) { + tickValues.add(tickValue); + } + return; + } + + if (axisRange.getLowerBound() == axisRange.getUpperBound() || axisRange.getTickUnit() <= 0) { + tickValues.add(axisRange.getLowerBound()); + return; + } + + final double firstTick = DefaultFinancialAxis.computeFirstMajorTick(axisRange.getLowerBound(), + axisRange.getTickUnit()); + if (firstTick + axisRange.getTickUnit() == firstTick) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().log("major ticks numerically not resolvable"); + } + return; + } + + final int maxTickCount = getMaxMajorTickLabelCount(); + for (double major = firstTick; (major <= axisRange.getUpperBound() && tickValues.size() <= maxTickCount); major += axisRange.getTickUnit()) { + if (tickValues.size() > getMaxMajorTickLabelCount()) { + break; + } + tickValues.add(major); + } + } + + @Override + protected void calculateMinorTickValues(DoubleArrayList newMinorTickMarks) { + if (getMinorTickCount() <= 0 || getTickUnit() <= 0) { + return; + } + + final double lowerBound = getMin(); + final double upperBound = getMax(); + final double majorUnit = getTickUnit(); + final int maxTickCount = getMaxMajorTickLabelCount(); + final int maxMinorTickCount = getMaxMajorTickLabelCount() * getMinorTickCount(); + + if (isLogAxis) { + double exp = Math.floor(axisTransform.forward(lowerBound)); + int majorTickCount = 0; + for (double majorTick = axisTransform.backward(exp); (majorTick < upperBound && majorTickCount <= maxTickCount); majorTick = axisTransform.backward(++exp)) { + final double nextMajorTick = axisTransform.backward(exp + 1); + final double minorUnit = (nextMajorTick - majorTick) / getMinorTickCount(); + for (double minorTick = majorTick + minorUnit; (minorTick < nextMajorTick && newMinorTickMarks.size() < maxMinorTickCount); minorTick += minorUnit) { + if (minorTick == majorTick) { + // minor ticks numerically not possible + break; + } + if (minorTick >= lowerBound && minorTick <= upperBound) { + newMinorTickMarks.add(minorTick); + } + } + majorTickCount++; + } + } else { + final double firstMajorTick = DefaultFinancialAxis.computeFirstMajorTick(lowerBound, majorUnit); + final double minorUnit = majorUnit / getMinorTickCount(); + int majorTickCount = 0; + for (double majorTick = firstMajorTick - majorUnit; (majorTick < upperBound && majorTickCount <= maxTickCount); majorTick += majorUnit) { + if (majorTick + majorUnit == majorTick) { + // major ticks numerically not resolvable + break; + } + final double nextMajorTick = majorTick + majorUnit; + for (double minorTick = majorTick + minorUnit; (minorTick < nextMajorTick && newMinorTickMarks.size() < maxMinorTickCount); minorTick += minorUnit) { + if (minorTick == majorTick) { + // minor ticks numerically not possible + break; + } + if (minorTick >= lowerBound && minorTick <= upperBound) { + newMinorTickMarks.add(minorTick); + } + } + majorTickCount++; + } + } + return; + } + + @Override + protected AxisRange computeRange(final double min, final double max, final double axisLength, + final double labelSize) { + double minValue = min; + double maxValue = max; + if (isLogAxis) { + if ((isAutoRanging() || isAutoGrowRanging()) && isAutoRangeRounding()) { + minValue = axisTransform.getRoundedMinimumRange(minValue); + maxValue = axisTransform.getRoundedMaximumRange(maxValue); + } + final double newScale = calculateNewScale(axisLength, minValue, maxValue); + return new AxisRange(minValue, maxValue, axisLength, newScale, getTickUnit()); + } + + if (maxValue - minValue == 0) { + final double padding = getAutoRangePadding() < 0 ? 0.0 : getAutoRangePadding(); + final double paddedRange = DefaultFinancialAxis.getEffectiveRange(minValue, maxValue) * padding; + minValue = minValue - paddedRange / 2; + maxValue = maxValue + paddedRange / 2; + } + + return computeRangeImpl(minValue, maxValue, axisLength, labelSize); + } + + protected double computeTickUnit(final double rawTickUnit) { + final TickUnitSupplier unitSupplier = getAxisLabelFormatter().getTickUnitSupplier(); + if (unitSupplier == null) { + throw new IllegalStateException("class defaults not properly initialised"); + } + final double majorUnit = unitSupplier.computeTickUnit(rawTickUnit); + if (majorUnit <= 0) { + throw new IllegalArgumentException("The " + unitSupplier.getClass().getName() + + " computed illegal unit value [" + majorUnit + "] for argument " + rawTickUnit); + } + return majorUnit; + } + + @Override + public void updateCachedTransforms() { + super.updateCachedTransforms(); + if (cache == null) { // lgtm [java/useless-null-check] NOPMD NOSONAR -- called from static initializer + return; + } + cache.updateCachedAxisVariables(); + } + + private static double computeFirstMajorTick(final double lowerBound, final double tickUnit) { + return Math.ceil(lowerBound / tickUnit) * tickUnit; + } + + /** + * If padding pushed the bound above or below zero - stick it to zero. + * + * @param paddedBound padded version of bound + * @param bound computed raw version of bound + * @return clamped value + */ + protected static double clampBoundToZero(final double paddedBound, final double bound) { + if (paddedBound < 0 && bound >= 0 || paddedBound > 0 && bound <= 0) { + return 0; + } + return paddedBound; + } + + protected static double getEffectiveRange(final double min, final double max) { + double effectiveRange = max - min; + if (effectiveRange == 0) { + effectiveRange = min == 0 ? DefaultFinancialAxis.DEFAULT_RANGE_LENGTH : Math.abs(min); + } + return effectiveRange; + } + + protected class Cache { + protected double localScale; + protected double localCurrentLowerBound; + protected double localCurrentUpperBound; + protected double localCurrentLowerIndex; + protected double localCurrentUpperIndex; + protected double localOffset; + protected double localOffset2; + protected double upperBoundLog; + protected double lowerBoundLog; + protected double logScaleLength; + protected double logScaleLengthInv; + protected boolean isVerticalAxis; + protected double axisLength; + + private void updateCachedAxisVariables() { + axisLength = getLength(); + localCurrentLowerBound = DefaultFinancialAxis.super.getMin(); + localCurrentUpperBound = DefaultFinancialAxis.super.getMax(); + localCurrentLowerIndex = ohlcvDataSet.getXIndex(localCurrentLowerBound); + localCurrentUpperIndex = ohlcvDataSet.getXIndex(localCurrentUpperBound); + + upperBoundLog = axisTransform.forward(getMax()); + lowerBoundLog = axisTransform.forward(getMin()); + logScaleLength = upperBoundLog - lowerBoundLog; + + logScaleLengthInv = 1.0 / logScaleLength; + + localScale = scaleProperty().get(); + // zero position of dates is the first date in the array. + // scaling and offsets etc needs to be based on indices instead of time. + final double zero = (0 - localCurrentLowerIndex) * localScale; + localOffset = zero + localCurrentLowerIndex * localScale; + localOffset2 = localOffset - cache.localCurrentLowerIndex * cache.localScale; + + if (getSide() != null) { + isVerticalAxis = getSide().isVertical(); + } + + logScaleLengthInv = axisLength / logScaleLength; + offset = axisLength; + + offset = isVerticalAxis ? getHeight() : getWidth(); + } + } +} diff --git a/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultNumericAxis.java b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultNumericAxis.java index b2eede066..ad2c47e52 100644 --- a/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultNumericAxis.java +++ b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultNumericAxis.java @@ -425,7 +425,7 @@ protected void calculateMajorTickValues(final AxisRange axisRange, DoubleArrayLi return; } - final double firstTick = DefaultNumericAxis.computeFistMajorTick(axisRange.getLowerBound(), + final double firstTick = DefaultNumericAxis.computeFirstMajorTick(axisRange.getLowerBound(), axisRange.getTickUnit()); if (firstTick + axisRange.getTickUnit() == firstTick) { if (LOGGER.isDebugEnabled()) { @@ -473,7 +473,7 @@ protected void calculateMinorTickValues(DoubleArrayList newMinorTickMarks) { majorTickCount++; } } else { - final double firstMajorTick = DefaultNumericAxis.computeFistMajorTick(lowerBound, majorUnit); + final double firstMajorTick = DefaultNumericAxis.computeFirstMajorTick(lowerBound, majorUnit); final double minorUnit = majorUnit / getMinorTickCount(); int majorTickCount = 0; for (double majorTick = firstMajorTick - majorUnit; (majorTick < upperBound && majorTickCount <= maxTickCount); majorTick += majorUnit) { @@ -543,7 +543,7 @@ public void updateCachedTransforms() { cache.updateCachedAxisVariables(); } - private static double computeFistMajorTick(final double lowerBound, final double tickUnit) { + private static double computeFirstMajorTick(final double lowerBound, final double tickUnit) { return Math.ceil(lowerBound / tickUnit) * tickUnit; } diff --git a/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java b/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java new file mode 100644 index 000000000..832698e1d --- /dev/null +++ b/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2016 European Organisation for Nuclear Research (CERN), All Rights Reserved. + */ +package io.fair_acc.chartfx.plugins; + +import io.fair_acc.chartfx.Chart; +import io.fair_acc.chartfx.XYChart; +import io.fair_acc.chartfx.axes.Axis; +import io.fair_acc.chartfx.renderer.Renderer; +import io.fair_acc.chartfx.renderer.spi.ErrorDataSetRenderer; +import io.fair_acc.dataset.DataSet; +import io.fair_acc.dataset.GridDataSet; +import io.fair_acc.dataset.spi.utils.Tuple; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.collections.ObservableList; +import javafx.event.EventHandler; +import javafx.geometry.Bounds; +import javafx.geometry.Point2D; +import javafx.scene.control.Label; +import javafx.scene.input.MouseEvent; +import javafx.util.StringConverter; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +/** + * Direct adopt and modified from {@link DataPointTooltip} + * + * @author lacgit + */ +public class OhlcvTooltip extends AbstractDataFormattingPlugin { + /** + * Name of the CSS class of the tool tip label. + */ + public static final String STYLE_CLASS_LABEL = "chart-datapoint-tooltip-label"; + + /** + * The default distance between the data point coordinates and mouse cursor that triggers showing the tool tip + * label. + */ + public static final int DEFAULT_PICKING_DISTANCE = 5; + + private static final int LABEL_X_OFFSET = 15; + private static final int LABEL_Y_OFFSET = 5; + + private final Label label = new Label(); + + private final DoubleProperty pickingDistance = new SimpleDoubleProperty(this, "pickingDistance", OhlcvTooltip.DEFAULT_PICKING_DISTANCE) { + @Override + protected void invalidated() { + if (get() <= 0) { + throw new IllegalArgumentException("The " + getName() + " must be a positive value"); + } + } + }; + + private final EventHandler mouseMoveHandler = this::updateToolTip; + + /** + * Creates a new instance of DataPointTooltip class with {{@link #pickingDistanceProperty() picking distance} + * initialized to {@value #DEFAULT_PICKING_DISTANCE}. + */ + public OhlcvTooltip() { + label.getStyleClass().add(OhlcvTooltip.STYLE_CLASS_LABEL); + label.setWrapText(true); + label.setMinWidth(0); + label.setManaged(false); + setXValueFormatter(new StringConverter() { + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss"); + @Override + public String toString(Number number) { + if (number == null) return ""; + return dateFormat.format(new Date(number.longValue()*1000)); + } + + @Override + public Number fromString(String s) { + return null; + } + }); + setYValueFormatter(new StringConverter() { + @Override + public String toString(Number number) { + return String.format("%,d%n", number.intValue()); + } + + @Override + public Number fromString(String s) { + return null; + } + }); + registerInputEventHandler(MouseEvent.MOUSE_MOVED, mouseMoveHandler); + } + + /** + * Creates a new instance of DataPointTooltip class. + * + * @param pickingDistance the initial value for the {@link #pickingDistanceProperty() pickingDistance} property + */ + public OhlcvTooltip(final double pickingDistance) { + this(); + setPickingDistance(pickingDistance); + } + + protected Optional findDataPoint(final MouseEvent event, final Bounds plotAreaBounds) { + if (!plotAreaBounds.contains(event.getX(), event.getY())) { + return Optional.empty(); + } + + final Point2D mouseLocation = getLocationInPlotArea(event); + + return findNearestDataPointWithinPickingDistance(mouseLocation); + } + + protected Optional findNearestDataPointWithinPickingDistance(final Point2D mouseLocation) { + final Chart chart = getChart(); + if (!(chart instanceof XYChart)) { + return Optional.empty(); + } + + final XYChart xyChart = (XYChart) chart; + final ObservableList xyChartDatasets = xyChart.getDatasets(); + return xyChart.getRenderers().stream() // for all renderers + .flatMap(renderer -> Stream.of(renderer.getDatasets(), xyChartDatasets) // + .flatMap(List::stream) // combine global and renderer specific Datasets + .flatMap(dataset -> getPointsCloseToCursor(dataset, renderer, mouseLocation))) // get points in range of cursor + .reduce((p1, p2) -> p1.distanceFromMouse <= p2.distanceFromMouse ? p1 : p2); // find closest point, tie-breaking in favor of earlier data sets to match rendering order + } + + protected Stream getPointsCloseToCursor(final DataSet dataset, final Renderer renderer, final Point2D mouseLocation) { + // Get Axes for the Renderer + final Axis xAxis = findXAxis(renderer); + final Axis yAxis = findYAxis(renderer); + if (xAxis == null || yAxis == null) { + return Stream.empty(); // ignore this renderer because there are no valid axes available + } + + if (dataset instanceof GridDataSet) { + return Stream.empty(); // TODO: correct impl for grid data sets + } + + return dataset.lock().readLockGuard(() -> { + int minIdx = 0; + int maxIdx = dataset.getDataCount(); + + if (isDataSorted(renderer)) { + // get the screen x coordinates and dataset indices between which points can be in picking distance + final double xMin = xAxis.getValueForDisplay(mouseLocation.getX() - getPickingDistance()); + final double xMax = xAxis.getValueForDisplay(mouseLocation.getX() + getPickingDistance()); + + minIdx = Math.max(0, dataset.getIndex(DataSet.DIM_X, xMin) - 1); + maxIdx = Math.min(dataset.getDataCount(), dataset.getIndex(DataSet.DIM_X, xMax) + 1); + } + + return IntStream.range(minIdx, maxIdx) // loop over all candidate points + .mapToObj(i -> getDataPointFromDataSet(renderer, dataset, xAxis, yAxis, mouseLocation, i)) // get points with distance to mouse + .filter(p -> p.distanceFromMouse <= getPickingDistance()) // filter out points which are too far away + .map(dataPoint -> dataPoint.withFormattedLabel(formatLabel(dataPoint))) + .collect(Collectors.toList()) // Realize list so that calculations are done within the data set lock + .stream(); + }); + } + + private boolean isDataSorted(final Renderer renderer) { + return renderer instanceof ErrorDataSetRenderer && ((ErrorDataSetRenderer) renderer).isAssumeSortedData(); + } + + private Axis findYAxis(final Renderer renderer) { + return renderer.getAxes().stream().filter(ax -> ax.getSide().isVertical()).findFirst().orElse(null); + } + + private Axis findXAxis(final Renderer renderer) { + return renderer.getAxes().stream().filter(ax -> ax.getSide().isHorizontal()).findFirst().orElse(null); + } + + protected DataPoint getDataPointFromDataSet(final Renderer renderer, final DataSet dataset, final Axis xAxis, final Axis yAxis, final Point2D mouseLocation, final int index) { + final double xValue = dataset.get(DataSet.DIM_X, index); + final double yValue = dataset.get(DataSet.DIM_Y, index); + + final double displayPositionX = xAxis.getDisplayPosition(xValue); + final double displayPositionY = yAxis.getDisplayPosition(yValue); + final double distanceFromMouseLocation = new Point2D(displayPositionX, displayPositionY).distance(mouseLocation); + + final String dataLabelSafe = getDataLabelSafe(dataset, index); + + return new DataPoint( // + renderer, // + xValue, // + yValue, // + dataLabelSafe, // + distanceFromMouseLocation); + } + + protected String formatDataPoint(final DataPoint dataPoint) { + return formatData(dataPoint.renderer, new Tuple<>(dataPoint.x, dataPoint.y)); + } + + protected String formatLabel(DataPoint dataPoint) { + return String.format("'%s'%n%s", dataPoint.label, formatDataPoint(dataPoint)); + } + + protected String getDataLabelSafe(final DataSet dataSet, final int index) { + String labelString = dataSet.getDataLabel(index); + if (labelString == null) { + return String.format("%s [%d]", dataSet.getName(), index); + } + return labelString; + } + + /** + * Returns the value of the {@link #pickingDistanceProperty()}. + * + * @return the current picking distance + */ + public final double getPickingDistance() { + return pickingDistanceProperty().get(); + } + + /** + * Distance of the mouse cursor from the data point (expressed in display units) that should trigger showing the + * tool tip. By default initialized to {@value #DEFAULT_PICKING_DISTANCE}. + * + * @return the picking distance property + */ + public final DoubleProperty pickingDistanceProperty() { + return pickingDistance; + } + + /** + * Sets the value of {@link #pickingDistanceProperty()}. + * + * @param distance the new picking distance + */ + public final void setPickingDistance(final double distance) { + pickingDistanceProperty().set(distance); + } + + protected void updateLabel(final MouseEvent event, final Bounds plotAreaBounds, final DataPoint dataPoint) { + label.setText(dataPoint.formattedLabel); + final double mouseX = event.getX(); + final double spaceLeft = mouseX - plotAreaBounds.getMinX(); + final double spaceRight = plotAreaBounds.getWidth() - spaceLeft; + double width = label.prefWidth(-1); + boolean atSide = true; // set to false if we cannot print the tooltip beside the point + + double xLocation; + if (spaceRight >= width + LABEL_X_OFFSET) { // place to right if enough space + xLocation = mouseX + OhlcvTooltip.LABEL_X_OFFSET; + } else if (spaceLeft >= width + LABEL_X_OFFSET) { // place left if enough space + xLocation = mouseX - OhlcvTooltip.LABEL_X_OFFSET - width; + } else if (width < plotAreaBounds.getWidth()) { + xLocation = spaceLeft > spaceRight ? plotAreaBounds.getMaxX() - width : plotAreaBounds.getMinX(); + atSide = false; + } else { + width = plotAreaBounds.getWidth(); + xLocation = plotAreaBounds.getMinX(); + atSide = false; + } + + final double mouseY = event.getY(); + final double spaceTop = mouseY - plotAreaBounds.getMinY(); + final double spaceBottom = plotAreaBounds.getHeight() - spaceTop; + double height = label.prefHeight(width); + + double yLocation; + if (height < spaceBottom) { + yLocation = mouseY + OhlcvTooltip.LABEL_Y_OFFSET; + } else if (height < spaceTop) { + yLocation = mouseY - OhlcvTooltip.LABEL_Y_OFFSET - height; + } else if (atSide && height < plotAreaBounds.getHeight()) { + yLocation = spaceTop < spaceBottom ? plotAreaBounds.getMaxY() - height : plotAreaBounds.getMinY(); + } else if (atSide) { + yLocation = plotAreaBounds.getMinY(); + height = plotAreaBounds.getHeight(); + } else if (spaceBottom > spaceTop) { + yLocation = mouseY + OhlcvTooltip.LABEL_Y_OFFSET; + height = spaceBottom - LABEL_Y_OFFSET; + } else { + yLocation = plotAreaBounds.getMinY(); + height = spaceTop - LABEL_Y_OFFSET; + } + label.resizeRelocate(xLocation, yLocation, width, height); + } + + private void updateToolTip(final MouseEvent event) { + final Bounds plotAreaBounds = getChart().getPlotArea().getBoundsInLocal(); + final Optional dataPoint = findDataPoint(event, plotAreaBounds); + + if (dataPoint.isEmpty()) { + getChartChildren().remove(label); + return; + } + updateLabel(event, plotAreaBounds, dataPoint.get()); + if (!getChartChildren().contains(label)) { + getChartChildren().add(label); + label.requestLayout(); + } + } + + public static class DataPoint { + public final Renderer renderer; + public final double x; + public final double y; + public final String label; + public final String formattedLabel; // may be empty + public final double distanceFromMouse; + + public DataPoint(Renderer renderer, double x, double y, String label, double distanceFromMouse, String formattedLabel) { + this.renderer = renderer; + this.x = x; + this.y = y; + this.label = label; + this.distanceFromMouse = distanceFromMouse; + this.formattedLabel = formattedLabel; + } + + public DataPoint(Renderer renderer, double x, double y, String label, double distanceFromMouse) { + this(renderer, x, y, label, distanceFromMouse, ""); + } + + public DataPoint withFormattedLabel(String formattedLabel) { + return new DataPoint(renderer, x, y, formattedLabel, distanceFromMouse, formattedLabel); + } + } +} diff --git a/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java new file mode 100644 index 000000000..58a5d65e2 --- /dev/null +++ b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java @@ -0,0 +1,373 @@ +package io.fair_acc.sample.financial; + +import static io.fair_acc.chartfx.ui.ProfilerInfoBox.DebugLevel.VERSION; + +import java.io.IOException; +import java.text.ParseException; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Map; + +import io.fair_acc.chartfx.axes.spi.format.DefaultTimeFormatter; +import io.fair_acc.chartfx.plugins.*; +import io.fair_acc.chartfx.utils.NumberFormatterImpl; +import io.fair_acc.sample.financial.service.SimpleOhlcvDailyParser; +import javafx.application.Application; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.stage.Stage; + +import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fair_acc.chartfx.Chart; +import io.fair_acc.chartfx.XYChart; +import io.fair_acc.chartfx.axes.AxisLabelOverlapPolicy; +import io.fair_acc.chartfx.axes.AxisMode; +import io.fair_acc.chartfx.axes.spi.DefaultNumericAxis; +import io.fair_acc.chartfx.renderer.spi.financial.AbstractFinancialRenderer; +import io.fair_acc.chartfx.renderer.spi.financial.FinancialTheme; +import io.fair_acc.chartfx.ui.ProfilerInfoBox; +import io.fair_acc.chartfx.ui.geometry.Side; +import io.fair_acc.dataset.spi.DefaultDataSet; +import io.fair_acc.dataset.spi.financial.OhlcvDataSet; +import io.fair_acc.dataset.spi.financial.api.ohlcv.IOhlcv; +import io.fair_acc.dataset.spi.financial.api.ohlcv.IOhlcvItem; +import io.fair_acc.dataset.utils.ProcessingProfiler; +import io.fair_acc.sample.chart.ChartSample; +import io.fair_acc.sample.financial.dos.Interval; +import io.fair_acc.sample.financial.service.CalendarUtils; +import io.fair_acc.chartfx.axes.spi.DefaultFinancialAxis; +import io.fair_acc.sample.financial.service.SimpleOhlcvReplayDataSet; +import io.fair_acc.sample.financial.service.SimpleOhlcvReplayDataSet.DataInput; +import io.fair_acc.sample.financial.service.consolidate.OhlcvConsolidationAddon; +import io.fair_acc.sample.financial.service.period.IntradayPeriod; + +/** + * Direct adopt and modified from {@link AbstractBasicFinancialApplication}. + * It uses {@link DefaultFinancialAxis} instead of {@link DefaultNumericAxis}. + * + * @author lacgit + */ +public abstract class AbstractBasicFinancialNoGapApplication extends ChartSample { + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractBasicFinancialNoGapApplication.class); + + protected int prefChartWidth = 640; // 1024 + protected int prefChartHeight = 480; // 768 + protected int prefSceneWidth = 1920; + protected int prefSceneHeight = 1080; + + private final double UPDATE_PERIOD = 10.0; // replay multiple + protected int DEBUG_UPDATE_RATE = 500; + + protected String title; // application title + protected FinancialTheme theme = FinancialTheme.Sand; + + protected String resource = "@ES-[TF1D]"; + protected String datePattern = "MM/dd/yyyy"; + protected String timeRange = "2020/01/24 0:00-2020/11/12 0:00"; + protected int zoneOffsetHr = 2; + + protected String replayFrom; + protected IntradayPeriod period; + protected OhlcvDataSet ohlcvDataSet; + protected Map consolidationAddons; + + private final Spinner updatePeriod = new Spinner<>(1.0, 500.0, UPDATE_PERIOD, 1.0); + private final CheckBox localRange = new CheckBox("auto-y"); + + private boolean timerActivated = false; + + //@Override + // public void start(final Stage primaryStage) { + // ProcessingProfiler.setVerboseOutputState(true); + // ProcessingProfiler.setLoggerOutputState(true); + // ProcessingProfiler.setDebugState(false); + + // long startTime = ProcessingProfiler.getTimeStamp(); + // ProcessingProfiler.getTimeDiff(startTime, "adding data to chart"); + // startTime = ProcessingProfiler.getTimeStamp(); + + // configureApp(); + // Scene scene = prepareScene(); + // ProcessingProfiler.getTimeDiff(startTime, "adding chart into StackPane"); + + // startTime = ProcessingProfiler.getTimeStamp(); + // primaryStage.setTitle(this.getClass().getSimpleName()); + // primaryStage.setScene(scene); + // primaryStage.setOnCloseRequest(this::closeDemo); + // primaryStage.show(); + // ProcessingProfiler.getTimeDiff(startTime, "for showing"); + + // // ensure correct state after restart demo + // stopTimer(); + //} + + protected void configureApp() { + // configure shared variables for application sample tests + } + + // protected void closeDemo(final WindowEvent evt) { + // if (evt.getEventType().equals(WindowEvent.WINDOW_CLOSE_REQUEST) && LOGGER.isInfoEnabled()) { + // LOGGER.atInfo().log("requested demo to shut down"); + // } + // stopTimer(); + // Platform.exit(); + // } + + protected ToolBar getTestToolBar(Chart chart, AbstractFinancialRenderer renderer, boolean replaySupport) { + ToolBar testVariableToolBar = new ToolBar(); + localRange.setSelected(renderer.computeLocalRange()); + localRange.setTooltip(new Tooltip("select for auto-adjusting min/max the y-axis (prices)")); + localRange.selectedProperty().bindBidirectional(renderer.computeLocalRangeProperty()); + localRange.selectedProperty().addListener((ch, old, selection) -> { + for (ChartPlugin plugin : chart.getPlugins()) { + if (plugin instanceof Zoomer) { + ((Zoomer) plugin).setAxisMode(selection ? AxisMode.X : AxisMode.XY); + } + } + chart.invalidate(); + }); + + Button periodicTimer = null; + if (replaySupport) { + // repetitively generate new data + periodicTimer = new Button("replay"); + periodicTimer.setTooltip(new Tooltip("replay instrument data in realtime")); + periodicTimer.setOnAction(evt -> { + pauseResumeTimer(); + }); + + updatePeriod.valueProperty().addListener((ch, o, n) -> updateTimer()); + updatePeriod.setEditable(true); + updatePeriod.setPrefWidth(80); + } + + final ProfilerInfoBox profilerInfoBox = new ProfilerInfoBox(DEBUG_UPDATE_RATE); + profilerInfoBox.setDebugLevel(VERSION); + + final Pane spacer = new Pane(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + if (replaySupport) { + testVariableToolBar.getItems().addAll(localRange, periodicTimer, updatePeriod, new Label("[multiply]"), spacer, profilerInfoBox); + } else { + testVariableToolBar.getItems().addAll(localRange, spacer, profilerInfoBox); + } + + return testVariableToolBar; + } + + /** + * Prepare charts to the root. + * + * @return prepared scene for sample app + */ + public Node getChartPanel(Stage stage) { + // show all default financial color schemes + final var root = new FlowPane(); + root.setAlignment(Pos.CENTER); + Arrays.stream(FinancialTheme.values()) + .map(this::getDefaultFinancialTestChart) + .forEach(root.getChildren()::add); + return root; + } + + /** + * Default financial chart configuration + * + * @param theme defines theme which has to be used for sample app + */ + protected Chart getDefaultFinancialTestChart(final FinancialTheme theme) { + // load datasets + DefaultDataSet indiSet = null; + if (resource.startsWith("REALTIME")) { + try { + Interval timeRangeInt = CalendarUtils.createByDateTimeInterval(timeRange); + Interval ttInt = CalendarUtils.createByTimeInterval(timeRange); + Calendar replayFromCal = CalendarUtils.createByDateTime(replayFrom); + ohlcvDataSet = new SimpleOhlcvReplayDataSet( + DataInput.valueOf(resource.substring("REALTIME-".length())), + period, + timeRangeInt, + ttInt, + replayFromCal, + consolidationAddons); + } catch (ParseException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } else { + ohlcvDataSet = new OhlcvDataSet(resource); + indiSet = new DefaultDataSet("MA(24)"); + try { + loadTestData(resource, ohlcvDataSet, indiSet); + } catch (IOException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + // prepare axis + final DefaultFinancialAxis xAxis1 = new DefaultFinancialAxis("time", "iso", ohlcvDataSet); + xAxis1.setOverlapPolicy(AxisLabelOverlapPolicy.SKIP_ALT); + xAxis1.setAutoRangeRounding(false); + xAxis1.setTimeAxis(true); + xAxis1.setAxisLabelFormatter(new DefaultTimeFormatter()); + + // set localised time offset + if (xAxis1.isTimeAxis() && xAxis1.getAxisLabelFormatter() instanceof DefaultTimeFormatter) { + final DefaultTimeFormatter axisFormatter = (DefaultTimeFormatter) xAxis1.getAxisLabelFormatter(); + axisFormatter.setTimeZoneOffset(ZoneOffset.ofHoursMinutes(zoneOffsetHr, 0)); + } + + // category axis support tests + // final CategoryAxis xAxis = new CategoryAxis("time [iso]"); + // xAxis.setTickLabelRotation(90); + // xAxis.setOverlapPolicy(AxisLabelOverlapPolicy.SKIP_ALT); + + final DefaultNumericAxis yAxis1 = new DefaultNumericAxis("price", "points"); + + // prepare chart structure + final XYChart chart = new XYChart(xAxis1, yAxis1); + chart.setTitle(theme.name()); + chart.setLegendVisible(true); + chart.setPrefSize(prefChartWidth, prefChartHeight); + // set them false to make the plot faster + chart.setAnimated(false); + + // prepare plugins + chart.getPlugins().add(new Zoomer(AxisMode.X)); + chart.getPlugins().add(new EditAxis()); + chart.getPlugins().add(new OhlcvTooltip()); + + // basic chart financial structure style + chart.getGridRenderer().setDrawOnTop(false); + yAxis1.setAutoRangeRounding(true); + yAxis1.setSide(Side.RIGHT); + yAxis1.setTickLabelFormatter(new NumberFormatterImpl()); + + // prepare financial renderers + prepareRenderers(chart, ohlcvDataSet, indiSet); + + // apply color scheme + theme.applyPseudoClasses(chart); + + // zoom to specific time range + if (timeRange != null) { + showPredefinedTimeRange(timeRange, ohlcvDataSet, xAxis1, yAxis1); + } + + return chart; + } + + /** + * Show required part of the OHLC resource + * + * @param dateIntervalPattern from to pattern for time range + * @param ohlcvDataSet domain object with filled ohlcv data + * @param xaxis X-axis for settings + * @param yaxis Y-axis for settings + */ + protected void showPredefinedTimeRange(String dateIntervalPattern, OhlcvDataSet ohlcvDataSet, + DefaultFinancialAxis xaxis, DefaultNumericAxis yaxis) { + try { + Interval fromTo = CalendarUtils.createByDateTimeInterval(dateIntervalPattern); + double fromTime = fromTo.from.getTime().getTime() / 1000.0; + double toTime = fromTo.to.getTime().getTime() / 1000.0; + + int fromIdx = ohlcvDataSet.getXIndex(fromTime); + int toIdx = ohlcvDataSet.getXIndex(toTime); + double min = Double.MAX_VALUE; + double max = Double.MIN_VALUE; + for (int i = fromIdx; i <= toIdx; i++) { + IOhlcvItem ohlcvItem = ohlcvDataSet.getItem(i); + if (max < ohlcvItem.getHigh()) { + max = ohlcvItem.getHigh(); + } + if (min > ohlcvItem.getLow()) { + min = ohlcvItem.getLow(); + } + } + xaxis.set(fromTime, toTime); + yaxis.set(min, max); + + xaxis.setAutoRanging(false); + yaxis.setAutoRanging(false); + + } catch (ParseException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + /** + * Load OHLC structures and indi calc + * + * @param data required data + * @param dataSet dataset which will be filled by this data + * @param indiSet example of indicator calculation + * @throws IOException if loading fails + */ + protected void loadTestData(String data, final OhlcvDataSet dataSet, DefaultDataSet indiSet) throws IOException { + final long startTime = ProcessingProfiler.getTimeStamp(); + + IOhlcv ohlcv = new SimpleOhlcvDailyParser().getContinuousOHLCV(data); + dataSet.setData(ohlcv); + + DescriptiveStatistics stats = new DescriptiveStatistics(24); + for (IOhlcvItem ohlcvItem : ohlcv) { + double timestamp = ohlcvItem.getTimeStamp().getTime() / 1000.0; + stats.addValue(ohlcvItem.getClose()); + indiSet.add(timestamp, stats.getMean()); + } + ProcessingProfiler.getTimeDiff(startTime, "adding data into DataSet"); + } + + /** + * Create and apply renderers + * + * @param chart for applying renderers + */ + protected abstract void prepareRenderers(XYChart chart, OhlcvDataSet ohlcvDataSet, DefaultDataSet indiSet); + + //--------- replay support --------- + + private void pauseResumeTimer() { + if (!timerActivated) { + startTimer(); + } else if (ohlcvDataSet instanceof SimpleOhlcvReplayDataSet) { + ((SimpleOhlcvReplayDataSet) ohlcvDataSet).pauseResume(); + } + } + + private void updateTimer() { + if (timerActivated) { + startTimer(); + } + } + + private void startTimer() { + if (ohlcvDataSet instanceof SimpleOhlcvReplayDataSet) { + SimpleOhlcvReplayDataSet realtimeDataSet = (SimpleOhlcvReplayDataSet) ohlcvDataSet; + realtimeDataSet.setUpdatePeriod(updatePeriod.getValue()); + timerActivated = true; + } + } + + private void stopTimer() { + if (timerActivated && ohlcvDataSet instanceof SimpleOhlcvReplayDataSet) { + timerActivated = false; + SimpleOhlcvReplayDataSet realtimeDataSet = (SimpleOhlcvReplayDataSet) ohlcvDataSet; + realtimeDataSet.stop(); + } + } + + /** + * @param args the command line arguments + */ + public static void main(final String[] args) { + Application.launch(args); + } +} diff --git a/chartfx-samples/src/main/java/io/fair_acc/sample/financial/FinancialNoGapCandlestickSample.java b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/FinancialNoGapCandlestickSample.java new file mode 100644 index 000000000..a7cca6cbb --- /dev/null +++ b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/FinancialNoGapCandlestickSample.java @@ -0,0 +1,108 @@ +package io.fair_acc.sample.financial; + +import java.util.Calendar; + +import javafx.application.Application; +import javafx.scene.Node; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; + +import io.fair_acc.chartfx.XYChart; +import io.fair_acc.chartfx.renderer.ErrorStyle; +import io.fair_acc.chartfx.renderer.spi.ErrorDataSetRenderer; +import io.fair_acc.chartfx.renderer.spi.financial.AbstractFinancialRenderer; +import io.fair_acc.chartfx.renderer.spi.financial.CandleStickRenderer; +import io.fair_acc.chartfx.renderer.spi.financial.FinancialTheme; +import io.fair_acc.dataset.spi.DefaultDataSet; +import io.fair_acc.dataset.spi.financial.OhlcvDataSet; +import io.fair_acc.dataset.spi.financial.api.attrs.AttributeKey; +import io.fair_acc.dataset.spi.financial.api.ohlcv.IOhlcvItem; + +/** + * Direct adopt and modified from {@link FinancialAdvancedCandlestickSample} + * + * @author lacgit + */ +public class FinancialNoGapCandlestickSample extends AbstractBasicFinancialNoGapApplication { + public static final AttributeKey MARK_BAR = AttributeKey.create(Boolean.class, "MARK_BAR"); + + /** + * Prepare charts to the root. + */ + @Override + public Node getChartPanel(Stage stage) { + + final var chart = getDefaultFinancialTestChart(FinancialTheme.Clearlook); + final AbstractFinancialRenderer renderer = (AbstractFinancialRenderer) chart.getRenderers().get(0); + + // prepare top financial toolbar + var testVariableToolBar = getTestToolBar(chart, renderer, false); + + var root = new VBox(); + VBox.setVgrow(chart, Priority.SOMETIMES); + root.getChildren().addAll(testVariableToolBar, chart); + + return root; + } + + protected void prepareRenderers(XYChart chart, OhlcvDataSet ohlcvDataSet, DefaultDataSet indiSet) { + // create and apply renderers + var candleStickRenderer = new CandleStickRenderer(true); + candleStickRenderer.getDatasets().addAll(ohlcvDataSet); + + var avgRenderer = new ErrorDataSetRenderer(); + avgRenderer.setDrawMarker(false); + avgRenderer.setErrorStyle(ErrorStyle.NONE); + avgRenderer.getDatasets().addAll(indiSet); + + chart.getRenderers().clear(); + chart.getRenderers().add(candleStickRenderer); + chart.getRenderers().add(avgRenderer); + + //------------------------------------------ + // Example of extension possibilities + + // PaintBar Service Usage + candleStickRenderer.setPaintBarMarker(d -> d.ohlcvItem != null && (d.ohlcvItem.getOpen() - d.ohlcvItem.getClose() > 100.0) ? Color.MAGENTA : null); + + // PaintAfter Extension Point Usage + // select every friday with yellow square point in the middle of candle + var cal = Calendar.getInstance(); // set this up however you need it. + for (IOhlcvItem ohlcvItem : ohlcvDataSet) { + cal.setTime(ohlcvItem.getTimeStamp()); + int day = cal.get(Calendar.DAY_OF_WEEK); + if (day == Calendar.FRIDAY) { + ohlcvItem.getAddonOrCreate().setAttribute(MARK_BAR, true); + } + } + + // example of extension point PaintAfter - Paint yellow square if the bar is selected by addon model attribute + candleStickRenderer.addPaintAfterEp(d -> { + if (d.ohlcvItem == null || d.ohlcvItem.getAddon() == null) { + return; + } + // addon extension with MARK BAR settings + if (Boolean.TRUE.equals(d.ohlcvItem.getAddon().getAttribute(MARK_BAR, false))) { + double yy; + if (d.ohlcvItem.getClose() > d.ohlcvItem.getOpen()) { + yy = d.yClose - (d.yClose - d.yOpen) / 2; + d.gc.setFill(Color.CRIMSON); + } else { + yy = d.yOpen - (d.yOpen - d.yClose) / 2; + d.gc.setFill(Color.YELLOW); + } + final double rectCorr = d.barWidthHalf / 2.0; + d.gc.fillRect(d.xCenter - rectCorr, yy - rectCorr, rectCorr * 2.0, rectCorr * 2.0); + } + }); + } + + /** + * @param args the command line arguments + */ + public static void main(final String[] args) { + Application.launch(args); + } +} From e1e925c22a431b3f3eb44c54657607077510d889 Mon Sep 17 00:00:00 2001 From: lc Date: Tue, 8 Jul 2025 23:13:22 +0800 Subject: [PATCH 2/3] Fine tune for CI warnings. --- .../axes/spi/DefaultFinancialAxis.java | 28 +++++++++++-------- ...bstractBasicFinancialNoGapApplication.java | 13 +++------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java index ea184cf3d..1dc722b00 100644 --- a/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java +++ b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java @@ -17,14 +17,14 @@ import java.util.Date; /** - * A axis class that plots a range of dates with major tick marks every TODO:"tickUnit". + * An axis class that plots a range of dates with major tick marks every "tickUnit". * To be consistent with the library, it was decided to use Date instead of the more modern LocalDateTime. *

* Compared to the {@link DefaultNumericAxis} this one changes *

    *
  • {@link #getDisplayPositionImpl(double)}, and
  • *
  • {@link #getValueForDisplayImpl(double)} using the index to
  • - *
  • {@link #ohlcvDataSet} instead of the milli-seconds from timestamp
  • + *
  • {@link #ohlcvDataSet} instead of the millisecond from timestamp
  • *
  • And overridden {@link #calculateNewScale(double, double, double)}
  • * * It was decided to replicate {@link DefaultNumericAxis} instead of extending it because @@ -166,10 +166,7 @@ public double computePreferredTickUnit(final double axisLength) { final double labelSize = getTickLabelFont().getSize() * 2; final int numOfFittingLabels = (int) Math.floor(axisLength / labelSize); final int numOfTickMarks = Math.max(Math.min(numOfFittingLabels, getMaxMajorTickLabelCount()), 2); - double rawTickUnit = (getMax() - getMin()) / numOfTickMarks; - if (rawTickUnit == 0 || Double.isNaN(rawTickUnit)) { - rawTickUnit = 1e-3; // TODO: remove this hack (eventually) ;-) - } + double rawTickUnit = calculateRawTickUnitFromRange(getMin(), getMax(), numOfTickMarks); return computeTickUnit(rawTickUnit); } @@ -369,12 +366,11 @@ private AxisRange computeRangeImpl(final double min, final double max, final dou final int numOfFittingLabels = (int) Math.floor(axisLength / labelSize); final int numOfTickMarks = Math.max(Math.min(numOfFittingLabels, getMaxMajorTickLabelCount()), 2); - double rawTickUnit = (max - min) / numOfTickMarks; - if (rawTickUnit == 0 || Double.isNaN(rawTickUnit)) { - rawTickUnit = 1e-3; // TODO: remove hack - } + double rawTickUnit = calculateRawTickUnitFromRange(min, max, numOfTickMarks); - // double tickUnitRounded = Double.MIN_VALUE; // TODO check if not '-Double.MAX_VALUE' + // practically not relevant to financial time + // check if not '-Double.MAX_VALUE' + // double tickUnitRounded = Double.MIN_VALUE; final double tickUnitRounded = computeTickUnit(rawTickUnit); final boolean round = (isAutoRanging() || isAutoGrowRanging()) && isAutoRangeRounding(); final double minRounded = round ? axisTransform.getRoundedMinimumRange(min) : min; @@ -530,7 +526,6 @@ protected void calculateMinorTickValues(DoubleArrayList newMinorTickMarks) { majorTickCount++; } } - return; } @Override @@ -605,6 +600,15 @@ protected static double getEffectiveRange(final double min, final double max) { return effectiveRange; } + protected static double calculateRawTickUnitFromRange(double min, double max, int numOfTickMarks) { + double rawTickUnit = (max - min) / numOfTickMarks; + if (rawTickUnit == 0 || Double.isNaN(rawTickUnit)) { + // practically for financial time, use millisecond as the minimal tick unit + rawTickUnit = 1e-3; + } + return rawTickUnit; + } + protected class Cache { protected double localScale; protected double localCurrentLowerBound; diff --git a/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java index 58a5d65e2..78b37e2dc 100644 --- a/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java +++ b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java @@ -138,9 +138,7 @@ protected ToolBar getTestToolBar(Chart chart, AbstractFinancialRenderer rende // repetitively generate new data periodicTimer = new Button("replay"); periodicTimer.setTooltip(new Tooltip("replay instrument data in realtime")); - periodicTimer.setOnAction(evt -> { - pauseResumeTimer(); - }); + periodicTimer.setOnAction(evt -> pauseResumeTimer()); updatePeriod.valueProperty().addListener((ch, o, n) -> updateTimer()); updatePeriod.setEditable(true); @@ -218,8 +216,7 @@ protected Chart getDefaultFinancialTestChart(final FinancialTheme theme) { xAxis1.setAxisLabelFormatter(new DefaultTimeFormatter()); // set localised time offset - if (xAxis1.isTimeAxis() && xAxis1.getAxisLabelFormatter() instanceof DefaultTimeFormatter) { - final DefaultTimeFormatter axisFormatter = (DefaultTimeFormatter) xAxis1.getAxisLabelFormatter(); + if (xAxis1.isTimeAxis() && xAxis1.getAxisLabelFormatter() instanceof DefaultTimeFormatter axisFormatter) { axisFormatter.setTimeZoneOffset(ZoneOffset.ofHoursMinutes(zoneOffsetHr, 0)); } @@ -349,17 +346,15 @@ private void updateTimer() { } private void startTimer() { - if (ohlcvDataSet instanceof SimpleOhlcvReplayDataSet) { - SimpleOhlcvReplayDataSet realtimeDataSet = (SimpleOhlcvReplayDataSet) ohlcvDataSet; + if (ohlcvDataSet instanceof SimpleOhlcvReplayDataSet realtimeDataSet) { realtimeDataSet.setUpdatePeriod(updatePeriod.getValue()); timerActivated = true; } } private void stopTimer() { - if (timerActivated && ohlcvDataSet instanceof SimpleOhlcvReplayDataSet) { + if (timerActivated && ohlcvDataSet instanceof SimpleOhlcvReplayDataSet realtimeDataSet) { timerActivated = false; - SimpleOhlcvReplayDataSet realtimeDataSet = (SimpleOhlcvReplayDataSet) ohlcvDataSet; realtimeDataSet.stop(); } } From 97373272574f219789e9250527d1983a3633a905 Mon Sep 17 00:00:00 2001 From: lc Date: Thu, 10 Jul 2025 10:14:01 +0800 Subject: [PATCH 3/3] Fine tune for CI warnings. --- .../chartfx/plugins/OhlcvTooltip.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java b/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java index 832698e1d..999f2a654 100644 --- a/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java +++ b/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java @@ -25,7 +25,6 @@ import java.util.Date; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -71,12 +70,13 @@ public OhlcvTooltip() { label.setWrapText(true); label.setMinWidth(0); label.setManaged(false); - setXValueFormatter(new StringConverter() { + setXValueFormatter(new StringConverter<>() { private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss"); + @Override public String toString(Number number) { if (number == null) return ""; - return dateFormat.format(new Date(number.longValue()*1000)); + return dateFormat.format(new Date(number.longValue() * 1000)); } @Override @@ -84,7 +84,7 @@ public Number fromString(String s) { return null; } }); - setYValueFormatter(new StringConverter() { + setYValueFormatter(new StringConverter<>() { @Override public String toString(Number number) { return String.format("%,d%n", number.intValue()); @@ -120,17 +120,16 @@ protected Optional findDataPoint(final MouseEvent event, final Bounds protected Optional findNearestDataPointWithinPickingDistance(final Point2D mouseLocation) { final Chart chart = getChart(); - if (!(chart instanceof XYChart)) { + if (!(chart instanceof XYChart xyChart)) { return Optional.empty(); } - final XYChart xyChart = (XYChart) chart; final ObservableList xyChartDatasets = xyChart.getDatasets(); return xyChart.getRenderers().stream() // for all renderers .flatMap(renderer -> Stream.of(renderer.getDatasets(), xyChartDatasets) // .flatMap(List::stream) // combine global and renderer specific Datasets .flatMap(dataset -> getPointsCloseToCursor(dataset, renderer, mouseLocation))) // get points in range of cursor - .reduce((p1, p2) -> p1.distanceFromMouse <= p2.distanceFromMouse ? p1 : p2); // find closest point, tie-breaking in favor of earlier data sets to match rendering order + .reduce((p1, p2) -> p1.distanceFromMouse <= p2.distanceFromMouse ? p1 : p2); // find the closest point, tie-breaking in favor of earlier data sets to match rendering order } protected Stream getPointsCloseToCursor(final DataSet dataset, final Renderer renderer, final Point2D mouseLocation) { @@ -141,8 +140,9 @@ protected Stream getPointsCloseToCursor(final DataSet dataset, final return Stream.empty(); // ignore this renderer because there are no valid axes available } + // This is targeted for OhlcvDataSet if (dataset instanceof GridDataSet) { - return Stream.empty(); // TODO: correct impl for grid data sets + return Stream.empty(); // Not relevantTODO: correct impl for grid data sets } return dataset.lock().readLockGuard(() -> { @@ -162,7 +162,7 @@ protected Stream getPointsCloseToCursor(final DataSet dataset, final .mapToObj(i -> getDataPointFromDataSet(renderer, dataset, xAxis, yAxis, mouseLocation, i)) // get points with distance to mouse .filter(p -> p.distanceFromMouse <= getPickingDistance()) // filter out points which are too far away .map(dataPoint -> dataPoint.withFormattedLabel(formatLabel(dataPoint))) - .collect(Collectors.toList()) // Realize list so that calculations are done within the data set lock + .toList() // Realize list so that calculations are done within the data set lock .stream(); }); } @@ -224,7 +224,7 @@ public final double getPickingDistance() { /** * Distance of the mouse cursor from the data point (expressed in display units) that should trigger showing the - * tool tip. By default initialized to {@value #DEFAULT_PICKING_DISTANCE}. + * tool tip. By default, initialized to {@value #DEFAULT_PICKING_DISTANCE}. * * @return the picking distance property */