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..1dc722b00
--- /dev/null
+++ b/chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/DefaultFinancialAxis.java
@@ -0,0 +1,657 @@
+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;
+
+/**
+ * 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 millisecond from timestamp
+ * - And overridden {@link #calculateNewScale(double, double, double)}
+ *
+ * It was decided to replicate {@link DefaultNumericAxis} instead of extending it because
+ * {@link #getDisplayPositionImpl(double)} and {@link #getValueForDisplayImpl(double)}
+ * were private and cannot be overridden.
+ *
+ * It was also decided to use {@link OhlcvDataSet} instead of {@link List} to reuse
+ * the retrieve dates using double as indices.
+ *
+ * For situations when an {@link OhlcvDataSet} might have missing datetime item will need
+ * to be handle separately.
+ *
+ * TODO: 1. Handle log time scale? Not sure has any practical value or not. Gaps may not
+ * be relevant if really needed.
+ *
+ *
+ * @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 = calculateRawTickUnitFromRange(getMin(), getMax(), numOfTickMarks);
+ 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 = calculateRawTickUnitFromRange(min, max, numOfTickMarks);
+
+ // 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;
+ 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++;
+ }
+ }
+ }
+
+ @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 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;
+ 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/plugins/OhlcvTooltip.java b/chartfx-chart/src/main/java/io/fair_acc/chartfx/plugins/OhlcvTooltip.java
new file mode 100644
index 000000000..999f2a654
--- /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.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 xyChart)) {
+ return Optional.empty();
+ }
+
+ 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 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) {
+ // 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
+ }
+
+ // This is targeted for OhlcvDataSet
+ if (dataset instanceof GridDataSet) {
+ return Stream.empty(); // Not relevantTODO: 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)))
+ .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..78b37e2dc
--- /dev/null
+++ b/chartfx-samples/src/main/java/io/fair_acc/sample/financial/AbstractBasicFinancialNoGapApplication.java
@@ -0,0 +1,368 @@
+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 axisFormatter) {
+ 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 realtimeDataSet) {
+ realtimeDataSet.setUpdatePeriod(updatePeriod.getValue());
+ timerActivated = true;
+ }
+ }
+
+ private void stopTimer() {
+ if (timerActivated && ohlcvDataSet instanceof SimpleOhlcvReplayDataSet realtimeDataSet) {
+ timerActivated = false;
+ 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);
+ }
+}