Merge pull request #3890 from dmos62/dao-facts-and-figures-outlier-resistance

Improve readability of the daily burnt BSQ chart
This commit is contained in:
Christoph Atteneder 2020-01-23 15:18:46 +01:00 committed by GitHub
commit a29d4903a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 984 additions and 51 deletions

View File

@ -0,0 +1,82 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.common.util;
import java.util.DoubleSummaryStatistics;
/* Adds logic to DoubleSummaryStatistics for keeping track of sum of squares
* and computing population variance and population standard deviation.
* Kahan summation algorithm (for `getSumOfSquares`) sourced from the DoubleSummaryStatistics class.
* Incremental variance algorithm sourced from https://math.stackexchange.com/a/1379804/316756
*/
public class DoubleSummaryStatisticsWithStdDev extends DoubleSummaryStatistics {
private double sumOfSquares;
private double sumOfSquaresCompensation; // Low order bits of sum of squares
private double simpleSumOfSquares; // Used to compute right sum of squares for non-finite inputs
@Override
public void accept(double value) {
super.accept(value);
double valueSquared = value * value;
simpleSumOfSquares += valueSquared;
sumOfSquaresWithCompensation(valueSquared);
}
public void combine(DoubleSummaryStatisticsWithStdDev other) {
super.combine(other);
simpleSumOfSquares += other.simpleSumOfSquares;
sumOfSquaresWithCompensation(other.sumOfSquares);
sumOfSquaresWithCompensation(other.sumOfSquaresCompensation);
}
/* Incorporate a new squared double value using Kahan summation /
* compensated summation.
*/
private void sumOfSquaresWithCompensation(double valueSquared) {
double tmp = valueSquared - sumOfSquaresCompensation;
double velvel = sumOfSquares + tmp; // Little wolf of rounding error
sumOfSquaresCompensation = (velvel - sumOfSquares) - tmp;
sumOfSquares = velvel;
}
private double getSumOfSquares() {
// Better error bounds to add both terms as the final sum of squares
double tmp = sumOfSquares + sumOfSquaresCompensation;
if (Double.isNaN(tmp) && Double.isInfinite(simpleSumOfSquares))
// If the compensated sum of squares is spuriously NaN from
// accumulating one or more same-signed infinite values,
// return the correctly-signed infinity stored in
// simpleSumOfSquares.
return simpleSumOfSquares;
else
return tmp;
}
private double getVariance() {
double sumOfSquares = getSumOfSquares();
long count = getCount();
double mean = getAverage();
return (sumOfSquares / count) - (mean * mean);
}
public final double getStandardDeviation() {
double variance = getVariance();
return Math.sqrt(variance);
}
}

View File

@ -2252,6 +2252,8 @@ dao.factsAndFigures.supply.compRequestIssueAmount=BSQ issued for compensation re
dao.factsAndFigures.supply.reimbursementAmount=BSQ issued for reimbursement requests
dao.factsAndFigures.supply.burnt=BSQ burnt
dao.factsAndFigures.supply.burntMovingAverage=15 days moving average
dao.factsAndFigures.supply.burntZoomToInliers=Zoom to inliers
dao.factsAndFigures.supply.locked=Global state of locked BSQ
dao.factsAndFigures.supply.totalLockedUpAmount=Locked up in bonds

View File

@ -1621,6 +1621,17 @@ textfield */
-fx-stroke: -bs-buy;
}
/* The .chart-line-symbol rules change the color of the legend symbol */
#charts-dao .default-color0.chart-series-line { -fx-stroke: -bs-chart-dao-line1; }
#charts-dao .default-color0.chart-line-symbol { -fx-background-color: -bs-chart-dao-line1, -bs-background-color; }
#charts-dao .default-color1.chart-series-line { -fx-stroke: -bs-chart-dao-line2; }
#charts-dao .default-color1.chart-line-symbol { -fx-background-color: -bs-chart-dao-line2, -bs-background-color; }
#charts-dao .chart-series-line {
-fx-stroke-width: 1px;
}
#charts .default-color0.chart-series-area-fill {
-fx-fill: -bs-sell-transparent;
}

View File

@ -20,7 +20,9 @@ package bisq.desktop.main.dao.economy.supply;
import bisq.desktop.common.view.ActivatableView;
import bisq.desktop.common.view.FxmlView;
import bisq.desktop.components.TitledGroupBg;
import bisq.desktop.util.AxisInlierUtils;
import bisq.desktop.util.Layout;
import bisq.desktop.util.MovingAverageUtils;
import bisq.core.dao.DaoFacade;
import bisq.core.dao.state.DaoStateListener;
@ -39,18 +41,24 @@ import org.bitcoinj.core.Coin;
import javax.inject.Inject;
import javafx.scene.Node;
import javafx.scene.chart.AreaChart;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleButton;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.geometry.Insets;
import javafx.geometry.Side;
import javafx.collections.ListChangeListener;
import javafx.util.StringConverter;
import java.time.LocalDate;
@ -68,9 +76,15 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Spliterators.AbstractSpliterator;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static bisq.desktop.util.FormBuilder.addSlideToggleButton;
import static bisq.desktop.util.FormBuilder.addTitledGroupBg;
import static bisq.desktop.util.FormBuilder.addTopLabelReadOnlyTextField;
@ -92,7 +106,17 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
private TextField genesisIssueAmountTextField, compRequestIssueAmountTextField, reimbursementAmountTextField,
totalBurntFeeAmountTextField, totalLockedUpAmountTextField, totalUnlockingAmountTextField,
totalUnlockedAmountTextField, totalConfiscatedAmountTextField, totalAmountOfInvalidatedBsqTextField;
private XYChart.Series<Number, Number> seriesBSQIssued, seriesBSQBurnt;
private XYChart.Series<Number, Number> seriesBSQIssued, seriesBSQBurnt, seriesBSQBurntMA;
private ListChangeListener<XYChart.Data<Number, Number>> changeListenerBSQBurnt;
private NumberAxis yAxisBSQBurnt;
private ToggleButton zoomToInliersSlide;
private boolean isZoomingToInliers = false;
// Parameters for zooming to inliers; explanations in AxisInlierUtils.
private int chartMaxNumberOfTicks = 10;
private double chartPercentToTrim = 5;
private double chartHowManyStdDevsConstituteOutlier = 10;
private static final Map<String, TemporalAdjuster> ADJUSTERS = new HashMap<>();
@ -112,12 +136,12 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
@Override
public void initialize() {
ADJUSTERS.put(MONTH, TemporalAdjusters.firstDayOfMonth());
ADJUSTERS.put(DAY, TemporalAdjusters.ofDateAdjuster(d -> d));
createSupplyIncreasedInformation();
createSupplyReducedInformation();
createSupplyLockedInformation();
}
@ -125,12 +149,22 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
protected void activate() {
daoFacade.addBsqStateListener(this);
if (isZoomingToInliers) {
activateZoomingToInliers();
}
updateWithBsqBlockChainData();
activateButtons();
}
@Override
protected void deactivate() {
daoFacade.removeBsqStateListener(this);
deactivateZoomingToInliers();
deactivateButtons();
}
@ -163,7 +197,11 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
seriesBSQIssued = new XYChart.Series<>();
createChart(seriesBSQIssued, Res.get("dao.factsAndFigures.supply.issued"), "MMM uu");
var chart = createBSQIssuedChart(seriesBSQIssued);
var chartPane = wrapInChartPane(chart);
root.getChildren().add(chartPane);
}
private void createSupplyReducedInformation() {
@ -174,8 +212,16 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
totalAmountOfInvalidatedBsqTextField = addTopLabelReadOnlyTextField(root, gridRow, 1,
Res.get("dao.factsAndFigures.supply.invalidTxs"), Layout.FIRST_ROW_AND_GROUP_DISTANCE).second;
var buttonTitle = Res.get("dao.factsAndFigures.supply.burntZoomToInliers");
zoomToInliersSlide = addSlideToggleButton(root, ++gridRow, buttonTitle);
seriesBSQBurnt = new XYChart.Series<>();
createChart(seriesBSQBurnt, Res.get("dao.factsAndFigures.supply.burnt"), "d MMM");
seriesBSQBurntMA = new XYChart.Series<>();
var chart = createBSQBurntChart(seriesBSQBurnt, seriesBSQBurntMA);
var chartPane = wrapInChartPane(chart);
root.getChildren().add(chartPane);
}
private void createSupplyLockedInformation() {
@ -195,14 +241,88 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
Res.get("dao.factsAndFigures.supply.totalConfiscatedAmount")).second;
}
private void createChart(XYChart.Series<Number, Number> series, String seriesLabel, String datePattern) {
private Node createBSQIssuedChart(XYChart.Series<Number, Number> series) {
NumberAxis xAxis = new NumberAxis();
xAxis.setForceZeroInRange(false);
xAxis.setAutoRanging(true);
xAxis.setTickLabelGap(6);
xAxis.setTickMarkVisible(false);
xAxis.setMinorTickVisible(false);
xAxis.setTickLabelFormatter(new StringConverter<>() {
configureAxis(xAxis);
xAxis.setTickLabelFormatter(getTimestampTickLabelFormatter("MMM uu"));
NumberAxis yAxis = new NumberAxis();
configureYAxis(yAxis);
yAxis.setTickLabelFormatter(BSQPriceTickLabelFormatter);
AreaChart<Number, Number> chart = new AreaChart<>(xAxis, yAxis);
configureChart(chart);
series.setName(Res.get("dao.factsAndFigures.supply.issued"));
chart.getData().add(series);
return chart;
}
@SuppressWarnings("unchecked")
private Node createBSQBurntChart(
XYChart.Series<Number, Number> seriesBSQBurnt,
XYChart.Series<Number, Number> seriesBSQBurntMA
) {
Supplier<NumberAxis> makeXAxis = () -> {
NumberAxis xAxis = new NumberAxis();
configureAxis(xAxis);
xAxis.setTickLabelFormatter(getTimestampTickLabelFormatter("d MMM"));
return xAxis;
};
Supplier<NumberAxis> makeYAxis = () -> {
NumberAxis yAxis = new NumberAxis();
configureYAxis(yAxis);
yAxis.setTickLabelFormatter(BSQPriceTickLabelFormatter);
return yAxis;
};
seriesBSQBurnt.setName(Res.get("dao.factsAndFigures.supply.burnt"));
var burntMALabel = Res.get("dao.factsAndFigures.supply.burntMovingAverage");
seriesBSQBurntMA.setName(burntMALabel);
var yAxis = makeYAxis.get();
initializeChangeListener(yAxis);
var chart = new LineChart<>(makeXAxis.get(), yAxis);
chart.getData().addAll(seriesBSQBurnt, seriesBSQBurntMA);
configureChart(chart);
chart.setCreateSymbols(false);
chart.setLegendVisible(true);
return chart;
}
private void initializeChangeListener(NumberAxis axis) {
// Keep a class-scope reference. Needed for switching between inliers-only and full chart.
yAxisBSQBurnt = axis;
changeListenerBSQBurnt = AxisInlierUtils.getListenerThatZoomsToInliers(
yAxisBSQBurnt, chartMaxNumberOfTicks, chartPercentToTrim, chartHowManyStdDevsConstituteOutlier);
}
private void configureYAxis(NumberAxis axis) {
configureAxis(axis);
axis.setForceZeroInRange(true);
axis.setTickLabelGap(5);
axis.setSide(Side.RIGHT);
}
private void configureAxis(NumberAxis axis) {
axis.setForceZeroInRange(false);
axis.setAutoRanging(true);
axis.setTickMarkVisible(false);
axis.setMinorTickVisible(false);
axis.setTickLabelGap(6);
}
private StringConverter<Number> getTimestampTickLabelFormatter(String datePattern) {
return new StringConverter<>() {
@Override
public String toString(Number timestamp) {
LocalDateTime localDateTime = LocalDateTime.ofEpochSecond(timestamp.longValue(),
@ -214,54 +334,48 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
public Number fromString(String string) {
return 0;
}
});
};
}
NumberAxis yAxis = new NumberAxis();
yAxis.setForceZeroInRange(false);
yAxis.setSide(Side.RIGHT);
yAxis.setAutoRanging(true);
yAxis.setTickMarkVisible(false);
yAxis.setMinorTickVisible(false);
yAxis.setTickLabelGap(5);
yAxis.setTickLabelFormatter(new StringConverter<>() {
@Override
public String toString(Number marketPrice) {
return bsqFormatter.formatBSQSatoshisWithCode(marketPrice.longValue());
}
private StringConverter<Number> BSQPriceTickLabelFormatter =
new StringConverter<>() {
@Override
public String toString(Number marketPrice) {
return bsqFormatter.formatBSQSatoshisWithCode(marketPrice.longValue());
}
@Override
public Number fromString(String string) {
return 0;
}
});
@Override
public Number fromString(String string) {
return 0;
}
};
series.setName(seriesLabel);
AreaChart<Number, Number> chart = new AreaChart<>(xAxis, yAxis);
private <X, Y> void configureChart(XYChart<X, Y> chart) {
chart.setLegendVisible(false);
chart.setAnimated(false);
chart.setId("charts-dao");
chart.setMinHeight(250);
chart.setPrefHeight(250);
chart.setCreateSymbols(true);
chart.setPadding(new Insets(0));
chart.getData().add(series);
chart.setMinHeight(300);
chart.setPrefHeight(300);
chart.setPadding(new Insets(0));
}
private Pane wrapInChartPane(Node child) {
AnchorPane chartPane = new AnchorPane();
chartPane.getStyleClass().add("chart-pane");
AnchorPane.setTopAnchor(chart, 15d);
AnchorPane.setBottomAnchor(chart, 10d);
AnchorPane.setLeftAnchor(chart, 25d);
AnchorPane.setRightAnchor(chart, 10d);
AnchorPane.setTopAnchor(child, 15d);
AnchorPane.setBottomAnchor(child, 10d);
AnchorPane.setLeftAnchor(child, 25d);
AnchorPane.setRightAnchor(child, 10d);
chartPane.getChildren().add(chart);
chartPane.getChildren().add(child);
GridPane.setColumnSpan(chartPane, 2);
GridPane.setRowIndex(chartPane, ++gridRow);
GridPane.setMargin(chartPane, new Insets(10, 0, 0, 0));
root.getChildren().add(chartPane);
return chartPane;
}
private void updateWithBsqBlockChainData() {
@ -288,11 +402,16 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
String minusSign = totalAmountOfInvalidatedBsq.isPositive() ? "-" : "";
totalAmountOfInvalidatedBsqTextField.setText(minusSign + bsqFormatter.formatAmountWithGroupSeparatorAndCode(totalAmountOfInvalidatedBsq));
updateCharts();
updateChartSeries();
}
private void updateCharts() {
seriesBSQIssued.getData().clear();
private void updateChartSeries() {
var updatedBurntBsq = updateBSQBurnt();
updateBSQBurntMA(updatedBurntBsq);
updateBSQIssued();
}
private List<XYChart.Data<Number, Number>> updateBSQBurnt() {
seriesBSQBurnt.getData().clear();
Set<Tx> burntTxs = new HashSet<>(daoStateService.getBurntFeeTxs());
@ -306,16 +425,61 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
List<XYChart.Data<Number, Number>> updatedBurntBsq = burntBsqByDay.keySet().stream()
.map(date -> {
ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.systemDefault());
return new XYChart.Data<Number, Number>(zonedDateTime.toInstant().getEpochSecond(), burntBsqByDay.get(date)
.stream()
.mapToDouble(Tx::getBurntBsq)
.sum()
return new XYChart.Data<Number, Number>(
zonedDateTime.toInstant().getEpochSecond(),
burntBsqByDay.get(date)
.stream()
.mapToDouble(Tx::getBurntBsq)
.sum()
);
})
.collect(Collectors.toList());
seriesBSQBurnt.getData().setAll(updatedBurntBsq);
return updatedBurntBsq;
}
private void updateBSQBurntMA(List<XYChart.Data<Number, Number>> updatedBurntBsq) {
seriesBSQBurntMA.getData().clear();
Comparator<Number> compareXChronology =
Comparator.comparingInt(Number::intValue);
Comparator<XYChart.Data<Number, Number>> compareXyDataChronology =
(xyData1, xyData2) ->
compareXChronology.compare(
xyData1.getXValue(),
xyData2.getXValue());
var sortedUpdatedBurntBsq = updatedBurntBsq
.stream()
.sorted(compareXyDataChronology)
.collect(Collectors.toList());
var burntBsqXValues = sortedUpdatedBurntBsq.stream().map(XYChart.Data::getXValue);
var burntBsqYValues = sortedUpdatedBurntBsq.stream().map(XYChart.Data::getYValue);
var maPeriod = 15;
var burntBsqMAYValues =
MovingAverageUtils.simpleMovingAverage(
burntBsqYValues,
maPeriod);
BiFunction<Number, Double, XYChart.Data<Number, Number>> xyToXyData =
XYChart.Data::new;
List<XYChart.Data<Number, Number>> burntBsqMA =
zip(burntBsqXValues, burntBsqMAYValues, xyToXyData)
.filter(xyData -> Double.isFinite(xyData.getYValue().doubleValue()))
.collect(Collectors.toList());
seriesBSQBurntMA.getData().setAll(burntBsqMA);
}
private void updateBSQIssued() {
seriesBSQIssued.getData().clear();
Stream<Issuance> bsqByCompensation = daoStateService.getIssuanceSet(IssuanceType.COMPENSATION).stream()
.sorted(Comparator.comparing(Issuance::getChainHeight));
@ -338,5 +502,77 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
seriesBSQIssued.getData().setAll(updatedAddedBSQ);
}
}
private void activateButtons() {
zoomToInliersSlide.setSelected(isZoomingToInliers);
zoomToInliersSlide.setOnAction(e -> handleZoomToInliersSlide(!isZoomingToInliers));
}
private void deactivateButtons() {
zoomToInliersSlide.setOnAction(null);
}
private void handleZoomToInliersSlide(boolean shouldActivate) {
isZoomingToInliers = !isZoomingToInliers;
if (shouldActivate) {
activateZoomingToInliers();
} else {
deactivateZoomingToInliers();
}
}
private void activateZoomingToInliers() {
seriesBSQBurnt.getData().addListener(changeListenerBSQBurnt);
// Initial zoom has to be triggered manually; otherwise, it
// would be triggered only on a change event in the series
triggerZoomToInliers();
}
private void deactivateZoomingToInliers() {
seriesBSQBurnt.getData().removeListener(changeListenerBSQBurnt);
// Reactivate automatic ranging
yAxisBSQBurnt.autoRangingProperty().set(true);
}
private void triggerZoomToInliers() {
var xyValues = seriesBSQBurnt.getData();
AxisInlierUtils.zoomToInliers(
yAxisBSQBurnt,
xyValues,
chartMaxNumberOfTicks,
chartPercentToTrim,
chartHowManyStdDevsConstituteOutlier
);
}
// When Guava version is bumped to at least 21.0,
// can be replaced with com.google.common.collect.Streams.zip
public static <L, R, T> Stream<T> zip(
Stream<L> leftStream,
Stream<R> rightStream,
BiFunction<L, R, T> combiner
) {
var lefts = leftStream.spliterator();
var rights = rightStream.spliterator();
var spliterator =
new AbstractSpliterator<T>(
Long.min(
lefts.estimateSize(),
rights.estimateSize()
),
lefts.characteristics() & rights.characteristics()
) {
@Override
public boolean tryAdvance(Consumer<? super T> action) {
return lefts.tryAdvance(
left -> rights.tryAdvance(
right -> action.accept(combiner.apply(left, right))
)
);
}
};
return StreamSupport.stream(spliterator, false);
}
}

View File

@ -128,6 +128,10 @@
-bs-chart-tick: rgba(255, 255, 255, 0.7);
-bs-chart-lines: rgba(0, 0, 0, 0.3);
-bs-white: white;
/* dao chart colors */
-bs-chart-dao-line1: -bs-color-green-5;
-bs-chart-dao-line2: -bs-color-blue-2;
}
/* list view */

View File

@ -101,4 +101,8 @@
-fx-default-button: derive(-fx-accent, 95%);
-bs-progress-bar-track: #e0e0e0;
-bs-white: white;
/* dao chart colors */
-bs-chart-dao-line1: -bs-color-green-3;
-bs-chart-dao-line2: -bs-color-blue-5;
}

View File

@ -0,0 +1,310 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.desktop.util;
import bisq.common.util.DoubleSummaryStatisticsWithStdDev;
import bisq.common.util.Tuple2;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import java.util.Collections;
import java.util.DoubleSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;
public class AxisInlierUtils {
/* Returns a ListChangeListener that is meant to be attached to an
* ObservableList. On event, it triggers a recalculation of a provided
* axis' range so as to zoom in on inliers.
*/
public static ListChangeListener<XYChart.Data<Number, Number>> getListenerThatZoomsToInliers(
NumberAxis axis,
int maxNumberOfTicks,
double percentToTrim,
double howManyStdDevsConstituteOutlier
) {
return change -> {
boolean axisHasBeenInitialized = axis != null;
if (axisHasBeenInitialized) {
zoomToInliers(
axis,
change.getList(),
maxNumberOfTicks,
percentToTrim,
howManyStdDevsConstituteOutlier
);
}
};
}
/* Applies the inlier range to the axis bounds and sets an appropriate tick-unit.
* The methods describing the arguments passed here are `computeReferenceTickUnit`,
* `trim`, and `computeInlierThreshold`.
*/
public static void zoomToInliers(
NumberAxis yAxis,
ObservableList<? extends XYChart.Data<Number, Number>> xyValues,
int maxNumberOfTicks,
double percentToTrim,
double howManyStdDevsConstituteOutlier
) {
List<Double> yValues = extractYValues(xyValues);
if (yValues.size() < 3) {
// with less than 3 elements, there is no meaningful inlier analysis
return;
}
Tuple2<Double, Double> inlierRange =
findInlierRange(yValues, percentToTrim, howManyStdDevsConstituteOutlier);
applyRange(yAxis, maxNumberOfTicks, inlierRange);
}
private static List<Double> extractYValues(ObservableList<? extends XYChart.Data<Number, Number>> xyValues) {
return xyValues
.stream()
.map(xyData -> (double) xyData.getYValue())
.collect(Collectors.toList());
}
/* Finds the minimum and maximum inlier values. The returned values may be NaN.
* See `computeInlierThreshold` for the definition of inlier.
*/
private static Tuple2<Double, Double> findInlierRange(
List<Double> yValues,
double percentToTrim,
double howManyStdDevsConstituteOutlier
) {
Tuple2<Double, Double> inlierThreshold =
computeInlierThreshold(yValues, percentToTrim, howManyStdDevsConstituteOutlier);
DoubleSummaryStatistics inlierStatistics =
yValues
.stream()
.filter(y -> withinBounds(inlierThreshold, y))
.mapToDouble(Double::doubleValue)
.summaryStatistics();
var inlierMin = inlierStatistics.getMin();
var inlierMax = inlierStatistics.getMax();
return new Tuple2<>(inlierMin, inlierMax);
}
private static boolean withinBounds(Tuple2<Double, Double> bounds, double number) {
var lowerBound = bounds.first;
var upperBound = bounds.second;
return (lowerBound <= number) && (number <= upperBound);
}
/* Computes the lower and upper inlier thresholds. A point lying outside
* these thresholds is considered an outlier, and a point lying within
* is considered an inlier.
* The thresholds are found by trimming the dataset (see method `trim`),
* then adding or subtracting a multiple of its (trimmed) standard
* deviation from its (trimmed) mean.
*/
private static Tuple2<Double, Double> computeInlierThreshold(
List<Double> numbers, double percentToTrim, double howManyStdDevsConstituteOutlier
) {
if (howManyStdDevsConstituteOutlier <= 0) {
throw new IllegalArgumentException(
"howManyStdDevsConstituteOutlier should be a positive number");
}
List<Double> trimmed = trim(percentToTrim, numbers);
DoubleSummaryStatisticsWithStdDev summaryStatistics =
trimmed.stream()
.collect(
DoubleSummaryStatisticsWithStdDev::new,
DoubleSummaryStatisticsWithStdDev::accept,
DoubleSummaryStatisticsWithStdDev::combine);
double mean = summaryStatistics.getAverage();
double stdDev = summaryStatistics.getStandardDeviation();
var inlierLowerThreshold = mean - (stdDev * howManyStdDevsConstituteOutlier);
var inlierUpperThreshold = mean + (stdDev * howManyStdDevsConstituteOutlier);
return new Tuple2<>(inlierLowerThreshold, inlierUpperThreshold);
}
/* Sorts the data and discards given percentage from the left and right sides each.
* E.g. 5% trim implies a total of 10% (2x 5%) of elements discarded.
* Used in calculating trimmed mean (and in turn trimmed standard deviation),
* which is more robust to outliers than a simple mean.
*/
private static List<Double> trim(double percentToTrim, List<Double> numbers) {
var minPercentToTrim = 0;
var maxPercentToTrim = 50;
if (minPercentToTrim > percentToTrim || percentToTrim > maxPercentToTrim) {
throw new IllegalArgumentException(
String.format(
"The percentage of data points to trim must be in the range [%d,%d].",
minPercentToTrim, maxPercentToTrim));
}
var totalPercentTrim = percentToTrim * 2;
if (totalPercentTrim == 0) {
return numbers;
}
if (totalPercentTrim == 100) {
return Collections.emptyList();
}
if (numbers.isEmpty()) {
return numbers;
}
var count = numbers.size();
int countToDropFromEachSide = (int) Math.round((count / 100d) * percentToTrim); // visada >= 0?
if (countToDropFromEachSide == 0) {
return numbers;
}
var sorted = numbers.stream().sorted();
var oneSideTrimmed = sorted.skip(countToDropFromEachSide);
// Here, having already trimmed the left-side, we are implicitly trimming
// the right-side by specifying a limit to the stream's length.
// An explicit right-side drop/trim/skip is not supported by the Stream API.
var countAfterTrim = count - (countToDropFromEachSide * 2); // visada > 0? ir <= count?
var bothSidesTrimmed = oneSideTrimmed.limit(countAfterTrim);
return bothSidesTrimmed.collect(Collectors.toList());
}
/* On the given axis, sets the provided lower and upper bounds, and
* computes an appropriate major tick unit (distance between major ticks in data-space).
* External computation of tick unit is necessary, because JavaFX doesn't support automatic
* tick unit computation when axis bounds are set manually.
*/
private static void applyRange(NumberAxis axis, int maxNumberOfTicks, Tuple2<Double, Double> bounds) {
var boundsWidth = getBoundsWidth(bounds);
if (boundsWidth < 0) {
throw new IllegalArgumentException(
"The lower bound must be a smaller number than the upper bound");
}
if (boundsWidth == 0 || Double.isNaN(boundsWidth)) {
// less than 2 unique data-points: recalculating axis range doesn't make sense
return;
}
axis.setAutoRanging(false);
var lowerBound = bounds.first;
var upperBound = bounds.second;
// If one of the ends of the range weren't zero,
// additional logic would be needed to make ticks "round".
// Of course, many, if not most, charts benefit from having 0 on the axis.
if (lowerBound > 0) {
lowerBound = 0d;
} else if (upperBound < 0) {
upperBound = 0d;
}
axis.setLowerBound(lowerBound);
axis.setUpperBound(upperBound);
var referenceTickUnit = computeReferenceTickUnit(maxNumberOfTicks, bounds);
var tickUnit = computeTickUnit(referenceTickUnit);
axis.setTickUnit(tickUnit);
}
/* Uses bounds and maximum number of major ticks to find a reference tick unit
* for the `computeTickUnit` method. The reference tick unit is later used as a
* starting point for tick unit's search.
* The rationale behind dividing the range/domain/width of an axis by maximum number
* of ticks is that it yields a good number of ticks, but they are not "well rounded",
* hence the next step of computing the actual tick unit.
* `maxNumberOfTicks` specifies how many subdivisions (major tick units) an axis
* should have at most. The final number of subdivisions, after `computeTickUnit`,
* usually will be lower, but never higher.
*/
private static double computeReferenceTickUnit(int maxNumberOfTicks, Tuple2<Double, Double> bounds) {
if (maxNumberOfTicks <= 0) {
throw new IllegalArgumentException("maxNumberOfTicks must be a positive number");
}
var width = getBoundsWidth(bounds);
return width / maxNumberOfTicks;
}
/* Extracted from cern.extjfx.chart.DefaultTickUnitSupplier (licensed Apache 2.0).
* Original description below; note that the `multipliers` vector is hardcoded in the method to the default value
* used in the source class:
*
* Computes tick unit using the following formula: tickUnit = M*10^E, where M is one of the multipliers specified in
* the constructor and E is an exponent of 10. Both M and E are selected so that the calculated unit is the smallest
* (closest to the zero) value that is greater than or equal to the reference tick unit.
*
* For example with multipliers [1, 2, 5], the method will give the following results:
*
* computeTickUnit(0.01) returns 0.01
* computeTickUnit(0.42) returns 0.5
* computeTickUnit(1.73) returns 2
* computeTickUnit(5) returns 5
* computeTickUnit(27) returns 50
*
* @param referenceTickUnit the reference tick unit, must be a positive number
*/
private static double computeTickUnit(double referenceTickUnit) {
if (referenceTickUnit <= 0) {
throw new IllegalArgumentException("The reference tick unit must be a positive number");
}
// Default multipliers vector extracted from the source class.
double[] multipliers = {1d, 2.5, 5d};
int BASE = 10;
int exp = (int) Math.floor(Math.log10(referenceTickUnit));
double factor = referenceTickUnit / Math.pow(BASE, exp);
double multiplier = 0;
int lastIndex = multipliers.length - 1;
if (factor > multipliers[lastIndex]) {
exp++;
multiplier = multipliers[0];
} else {
for (int i = lastIndex; i >= 0; i--) {
if (factor <= multipliers[i]) {
multiplier = multipliers[i];
} else {
break;
}
}
}
return multiplier * Math.pow(BASE, exp);
}
private static double getBoundsWidth(Tuple2<Double, Double> bounds) {
var lowerBound = bounds.first;
var upperBound = bounds.second;
return Math.abs(upperBound - lowerBound);
}
}

View File

@ -0,0 +1,128 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.desktop.util;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.Objects;
import java.util.Queue;
import java.util.Spliterator;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
public class MovingAverageUtils {
/* With period 2, on an input of [1,2,3,4], should return [Double.NaN, 1.5, 2.5, 3.5].
*
* In case of the source stream having too few elements to compute a moving average
* (as a function of the provided period), the returned stream will (only) contain
* a sequence of (period - 1) NaNs. Otherwise, the resulting stream is prepadded with
* these NaNs. See `prependLagCompensation` for details.
*/
public static Stream<Double> simpleMovingAverage(Stream<Number> source, int period) {
if (period < 1) {
throw new IllegalArgumentException("Simple moving average period must be a positive number.");
}
var windows = SlidingWindowSpliterator.windowed(source, period);
Stream<Double> averages =
windows.map(window ->
window
.mapToDouble(Number::doubleValue)
.summaryStatistics()
.getAverage()
);
return prependLagCompensation(averages, period);
}
/* Given a period of for example 3, prepends a sequence of 2 NaNs.
* In this way the returned stream has the same length as the input stream,
* and the index of a given average matches the index of the last element
* of a sequence of data points from which the average was computed,
* Provided there were enough data points in the input stream to compute
* the moving average (see next paragraph).
*
* Unfortunately, if there are too little data points to calculate the
* moving average, this will return a stream with more elements, that are
* all NaNs, than the input stream contained. This is due to the inherent
* laziness of streams: we cannot check the relevant streams' sizes
* without destroying them, so we cannot make the prepadding adaptive.
* The exact number of NaNs returned in this case is `period - 1`.
*/
private static Stream<Double> prependLagCompensation(Stream<Double> averages, int period) {
var lag = period - 1;
var lagCompensation = Collections.nCopies(lag, Double.NaN).stream();
return Stream.concat(lagCompensation, averages);
}
static class SlidingWindowSpliterator<T> implements Spliterator<Stream<T>> {
static <T> Stream<Stream<T>> windowed(Stream<T> source, int windowSize) {
return StreamSupport.stream(new SlidingWindowSpliterator<>(source, windowSize), false);
}
private final Queue<T> buffer;
private final Iterator<T> sourceIterator;
private final int windowSize;
private SlidingWindowSpliterator(Stream<T> source, int windowSize) {
this.buffer = new ArrayDeque<>(windowSize);
this.sourceIterator = Objects.requireNonNull(source).iterator();
this.windowSize = windowSize;
}
@Override
public boolean tryAdvance(Consumer<? super Stream<T>> action) {
if (windowSize < 1) {
return false;
}
while (sourceIterator.hasNext()) {
buffer.add(sourceIterator.next());
if (buffer.size() == windowSize) {
action.accept(Arrays.stream((T[]) buffer.toArray(new Object[0])));
buffer.poll();
return true;
}
}
return false;
}
@Override
public Spliterator<Stream<T>> trySplit() {
return null;
}
@Override
public long estimateSize() {
return Long.MAX_VALUE;
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
}

View File

@ -0,0 +1,156 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.desktop.util;
import java.util.Arrays;
import java.util.stream.Stream;
import org.junit.Assert;
import org.junit.Test;
public class MovingAverageUtilsTest {
private static final int NAN = -99;
private static int[] calcMA(int period, int[] input) {
System.out.println("Input:");
System.out.println(Arrays.toString(input));
Stream<Number> streamInput =
Arrays
.stream(input)
.boxed()
.map(x -> x == NAN ? Double.NaN : x);
int[] output = MovingAverageUtils
.simpleMovingAverage(streamInput, period)
.mapToInt(x -> Double.isFinite(x) ? (int) Math.round(x) : NAN)
.toArray();
System.out.println("Output:");
System.out.println(Arrays.toString(output));
return output;
}
private static void testMA(int period, int[] input, int[] expected) {
var output = calcMA(period, input);
Assert.assertArrayEquals(output, expected);
}
@Test
public void normalConditions() {
testMA(
2,
new int[]{10, 20, 30, 40},
new int[]{NAN, 15, 25, 35}
);
}
@Test
public void inputContainsNaNs0() {
testMA(
2,
new int[]{NAN, 20, 30, 40},
new int[]{NAN, NAN, 25, 35}
);
}
@Test
public void inputContainsNaNs1() {
testMA(
2,
new int[]{10, NAN, 30, 40},
new int[]{NAN, NAN, NAN, 35}
);
}
@Test
public void inputContainsNaNs2() {
testMA(
2,
new int[]{10, NAN, NAN, 40},
new int[]{NAN, NAN, NAN, NAN}
);
}
@Test
public void inputContainsNaNs3() {
testMA(
2,
new int[]{10, NAN, 30, NAN, 40},
new int[]{NAN, NAN, NAN, NAN, NAN}
);
}
@Test
public void nonsensicalPeriod0() {
testMA(
1,
new int[]{10, 20},
new int[]{10, 20}
);
}
@Test(expected = IllegalArgumentException.class)
public void nonsensicalPeriod1() {
var impossible = new int[]{};
testMA(
0,
new int[]{10, 20},
impossible
);
}
@Test(expected = IllegalArgumentException.class)
public void nonsensicalPeriod2() {
var impossible = new int[]{};
testMA(
-1,
new int[]{10, 20},
impossible
);
}
@Test
public void tooLittleData0() {
testMA(
3,
new int[]{},
new int[]{NAN, NAN}
);
}
@Test
public void tooLittleData1() {
testMA(
3,
new int[]{10},
new int[]{NAN, NAN}
);
}
@Test
public void tooLittleData2() {
testMA(
3,
new int[]{10, 20},
new int[]{NAN, NAN}
);
}
}