149 | );
150 | }
151 | }
152 |
153 | BottomBar.propTypes = {
154 | colorSchemeNames: PropTypes.arrayOf(PropTypes.string).isRequired,
155 | currencies: PropTypes.arrayOf(PropTypes.string).isRequired,
156 | onSettingChange: PropTypes.func.isRequired,
157 | };
158 |
159 | export default BottomBar;
160 |
--------------------------------------------------------------------------------
/src/react-orderbook/Orderbook/histRender.js:
--------------------------------------------------------------------------------
1 | //! Functions for rendering historical data rather than live streaming data.
2 | // @flow
3 |
4 | import * as _ from 'lodash';
5 | import * as chroma from 'chroma-js';
6 |
7 | import { getInitialBandValues, getBandIndex } from '../calc';
8 | import { renderInitial, drawBand, drawBands } from './render';
9 | import { reRenderTrades, updateTextInfo, renderScales } from './paperRender';
10 |
11 | /**
12 | * Given a set of historical price level updates and trade data as well as the settings for the visualization's current
13 | * display settings, re-renders all visible historical bands.
14 | */
15 | export const histRender = (vizState, canvas, recalcMaxBandValues) => {
16 | vizState.histRendering = true;
17 | // return;
18 | // re-render the background to overwrite up all previous price bands
19 | renderInitial(vizState, canvas);
20 |
21 | // find the price levels at the beginning of the visible time window by filtering the list of price level updates
22 | // there isn't a need to sort them by timestamp because they should already be sorted
23 | const initialPriceLevels = {};
24 | vizState.priceLevelUpdates
25 | .filter(levelUpdate => levelUpdate.timestamp <= vizState.minTimestamp)
26 | .forEach(({ price, volume, isBid }) => {
27 | initialPriceLevels[price] = {
28 | volume: volume,
29 | isBid: isBid,
30 | };
31 | });
32 | const curPriceLevels = _.cloneDeep(initialPriceLevels);
33 |
34 | // set up the initial active bands using the generated initial price levels
35 | const initialBandValues = getInitialBandValues(
36 | vizState.minTimestamp,
37 | curPriceLevels,
38 | vizState.minPrice,
39 | vizState.maxPrice,
40 | vizState.priceGranularity,
41 | vizState.pricePrecision
42 | );
43 | vizState.activeBands = _.cloneDeep(initialBandValues);
44 |
45 | // if a setting has changed causing us to need to re-calculate max band values, do so.
46 | if (recalcMaxBandValues) {
47 | renderScales(vizState);
48 | // and create a variable to hold the max band volume of the current simulated price update
49 | let maxVisibleBandVolume = +_.maxBy(initialBandValues, 'volume').volume;
50 |
51 | _.each(vizState.priceLevelUpdates, ({ price, volume, timestamp, isBid }) => {
52 | // ignore level updates already taken into account and stop when we reach off-the-chart timestamps
53 | if (timestamp <= vizState.minTimestamp) {
54 | return;
55 | } else if (timestamp > vizState.maxTimestamp) {
56 | return false;
57 | }
58 |
59 | const volumeDiff = curPriceLevels[price] ? +volume - +curPriceLevels[price].volume : +volume;
60 | curPriceLevels[price] = { volume: volume, isBid: isBid };
61 | const bandIndex = getBandIndex(vizState, price);
62 | if (bandIndex >= 0 && bandIndex < vizState.priceGranularity) {
63 | const activeBand = vizState.activeBands[bandIndex];
64 | const rawVolume = +activeBand.volume + volumeDiff;
65 | const fixedVolume = rawVolume.toFixed(vizState.pricePrecision);
66 | activeBand.volume = fixedVolume;
67 |
68 | // if it broke the max visible volume record, update that as well.
69 | if (rawVolume > maxVisibleBandVolume) {
70 | maxVisibleBandVolume = rawVolume;
71 | }
72 | }
73 | });
74 |
75 | // set both the current and max visible band volumes to vizState
76 | vizState.maxVisibleBandVolume = maxVisibleBandVolume.toFixed(vizState.pricePrecision);
77 |
78 | // generate a new color scaler function
79 | vizState.scaleColor = chroma
80 | .scale(vizState.colorScheme)
81 | .mode('lch')
82 | .domain([0, +maxVisibleBandVolume]);
83 |
84 | // reset the active band values before continuing with normal hist render
85 | vizState.activeBands = _.cloneDeep(initialBandValues);
86 | }
87 |
88 | // loop through all of the visible price updates, drawing bands and updating the book as we go
89 | let curTimestamp;
90 | // how many ms across a pixel is
91 | const pixelWidth = (vizState.maxTimestamp - vizState.minTimestamp) / vizState.canvasWidth;
92 | _.each(vizState.priceLevelUpdates, ({ price, volume, timestamp, isBid }) => {
93 | // ignore level updates already taken into account and off-the-chart timestamps
94 | if (timestamp <= vizState.minTimestamp) {
95 | return;
96 | } else if (timestamp > vizState.maxTimestamp) {
97 | return false;
98 | }
99 |
100 | const volumeDiff = initialPriceLevels[price]
101 | ? +volume - +initialPriceLevels[price].volume
102 | : +volume;
103 |
104 | // update the price level to reflect the update
105 | initialPriceLevels[price] = {
106 | volume: volume,
107 | isBid: isBid,
108 | };
109 |
110 | // draw the band between the last update for the band and the current timestamp if its visible
111 | const bandIndex = getBandIndex(vizState, price);
112 | if (bandIndex >= 0 && bandIndex < vizState.priceGranularity) {
113 | const activeBand = vizState.activeBands[bandIndex];
114 |
115 | // if the band's length is less than a pixel, don't bother drawing it but still update volume.
116 | if (timestamp - activeBand.startTimestamp > pixelWidth) {
117 | activeBand.endTimestamp = timestamp;
118 | drawBand(vizState, activeBand, bandIndex, canvas.getContext('2d'));
119 | activeBand.startTimestamp = vizState.activeBands[bandIndex].endTimestamp;
120 | }
121 |
122 | // update the band volume and end timestamp to reflect this update
123 | const rawVolume = +activeBand.volume + volumeDiff;
124 | activeBand.volume = rawVolume.toFixed(vizState.pricePrecision);
125 | }
126 |
127 | // update the most recent timestamp
128 | curTimestamp = timestamp;
129 | });
130 |
131 | // update the postions of the trade markers
132 | reRenderTrades(vizState);
133 |
134 | // update displayed price information
135 | updateTextInfo(vizState);
136 |
137 | // finally, draw all the bands to be updated with the most recent prices
138 | drawBands(vizState, curTimestamp, canvas);
139 |
140 | vizState.histRendering = false;
141 | };
142 |
--------------------------------------------------------------------------------
/src/react-orderbook/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A set of React components used to render interactive orderbook visualizations for limit orderbook data
3 | */
4 |
5 | import React from 'react';
6 | import PropTypes from 'prop-types';
7 |
8 | import _ from 'lodash';
9 |
10 | import Orderbook from './Orderbook/Orderbook';
11 |
12 | /**
13 | * The parent component for the orderbook analysis visualizations. Contains variables that keeps track of the current state
14 | * of the orderbook, the history of all modifications, removals, and trades that have occured, and pass this information and
15 | * events to the child components.
16 | *
17 | * For all timestamps provided to this component, they should be formatted as unix timestamps with ms precision.
18 | *
19 | * @param bookModificationCallbackExecutor {func} - A function that will be called when the visualization is ready. It will be
20 | * provided one argument that is a function that should be called every time an order is added to the orderbook or
21 | * the volume at a certain price level changes to a different non-zero value.
22 | * @param bookRemovalCallbackExecutor {func} - A function that will be called when the visualization is ready. It will be
23 | * provided one argument that is a function that should be called every time all orders at a certain price level
24 | * are completely removed, meaning that no more bids or asks exists at that level.
25 | * @param newTradeCallbackExecutor {func} - A function that will be called when the visualization is ready. It will be provided
26 | * with one argument that is a function that should be called every time an order is filled.
27 | * @param canvasHeight {number} - The height of the returned canvas objects in pixels
28 | * @param canvasWidth {number} - The width of the returned canvas objects in pixels
29 | * @param initialBook {[{price: number, volume: number, isBid: bool}]} - A snapshot of the orderbook before any updates or
30 | * changes are sent to the callback functions.
31 | * @param initialTimestamp {number} - The timestamp that the `initialBook` was taken at as unix timestmap ms precision
32 | */
33 | class OrderbookVisualizer extends React.Component {
34 | constructor(props) {
35 | super(props);
36 |
37 | this.handleBookModification = this.handleBookModification.bind(this);
38 | this.handleBookRemoval = this.handleBookRemoval.bind(this);
39 | this.handleNewTrade = this.handleNewTrade.bind(this);
40 | this.handleCurrencyChange = this.handleCurrencyChange.bind(this);
41 |
42 | this.state = {
43 | // map the array of objects to a K:V object matching price:volume at that price level
44 | curBook: props.initialBook, // the latest version of the order book containing all live buy/sell limit orders
45 | latestChange: {}, // the most recent change that has occured in the orderbook
46 | initialBook: props.initialBook,
47 | initialTimestamp: this.props.initialTimestamp,
48 | curTimestamp: this.props.initialTimestamp,
49 | };
50 | }
51 |
52 | componentDidMount() {
53 | // register the callback callers to start receiving book updates
54 | this.props.bookModificationCallbackExecutor(this.handleBookModification);
55 | this.props.bookRemovalCallbackExecutor(this.handleBookRemoval);
56 | this.props.newTradeCallbackExecutor(this.handleNewTrade);
57 | }
58 |
59 | componentWillReceiveProps(nextProps) {
60 | if (!_.isEqual(nextProps.initialBook, this.props.initialBook)) {
61 | // currency has changed; reset all internal state and re-initialize component
62 | this.setState({ initialBook: nextProps.initialBook });
63 | }
64 | }
65 |
66 | handleBookModification(change: {
67 | modification: { price: number, newAmount: number, isBid: boolean },
68 | timestamp: number,
69 | }) {
70 | this.setState({ latestChange: change });
71 | }
72 |
73 | handleBookRemoval(change: { removal: { price: number, isBid: boolean }, timestamp: number }) {
74 | this.setState({ latestChange: change });
75 | }
76 |
77 | handleNewTrade(change: {
78 | newTrade: { price: number, amountRemaining: number, wasBidFilled: boolean },
79 | timestamp: number,
80 | }) {
81 | this.setState({ latestChange: change });
82 | }
83 |
84 | handleCurrencyChange(newCurrency) {
85 | this.props.onCurrencyChange(newCurrency);
86 | }
87 |
88 | render() {
89 | return (
90 |
CryptoViz is a tool that allows you to visualize the orderbook for all the cryptocurrencies traded on the Poloniex exchange.
44 |
45 |
What am I looking at?
46 |
CryptoViz provides you a Depth-of-Market (DOM) view into the market. It allows you to see where buy and sell orders lie, how large they are, and when they get removed/filled. This enables you to clearly see things such as buy and sell walls, large orders sweeping the book, and support/resistance levels where large amounts of volume are traded.
47 |
48 |
The Limit Orderbook
49 |
A core concept of modern financial exchanges is the limit order book. It contains pending buy and sell orders from all market participants. Orders that provide the best price (buyers willing to pay the most and sellers willing to take the least) are said to be at the "top" of the book. It's these orders that are filled first by market trades.
50 |
51 |
CryptoViz allows you to see deep into the book and provides a dynamic view of where traders are willing to enter and exit the market. It shows how these regions shift and change over time as well as highlights market events such as large block buys and sells. This data can give you insights into the way other market participants are interacting with the market.
52 |
53 |
Price Bands
54 |
The price bands are the horizontal lines spanning the visualization. Each band is a collection of the total amount of orders between two different price levels. They are scaled in color according to how much volume is on the book at that level. To see what prices a band contains, hover over it with your mouse and look in the top right of the visualization.
55 |
56 |
57 |
Trade Lines and Indicators
58 |
The red and blue lines and circles indicate trades that took place. Red means that an existing buy order was filled and blue means that an existing sell order was filled. The size of the circle corresponds to the amount of volume that was traded. The lines connect trades of the same type, showing how the price people trade at changes over time.
59 |
60 |
61 |
Zooming
62 |
By default, the visualization zooms to an optimal level to show the most critical price levels. To zoom in on a certain area, simply drag and select a rectangle on the visualization. This will zoom into the selected region. Click the "Reset Zoom" button to restore the view to default.
63 |
64 |
Interpreting the Data
65 |
Using this tool, it's possible to draw conclusions about the behaviour of other traders in the market. There are certain patterns that identify different trading actions and can give you clues about what the market may do in the future.
66 |
67 |
Buy/Sell Walls
68 |
When large blocks of buy or sell orders are grouped together at a price level, they can often act as support or resistance that the price bounces off of. If the price approaches one of these walls and there is insufficient momentum to break through it, the price often drifts back in the direction it came from. However, if there is sufficient momentum to fill the orders that make up the wall, it is common for prices to continue to move on through to new levels. As shown in the image below, large buy orders are filled which break the walls and drive the price lower.
69 |
70 |
71 |
Descending/Ascending Price Levels
72 |
When traders (or trading robots) compete with each other to have the best price for their orders and so be filled first, it's common to see areas where the spread grows smaller as orders are cancelled and re-entered at a better price to undercut other traders. This can be an indicator of a desire for traders to enter or exit the market and influence the price in the process.
73 |
74 |
75 |
It's important to keep in mind that all of these patterns can be invalidated by one large set of orders. These aren't as much indicators as they are hints at the mood of the market participants. Although they can be useful in determining market sentiment, careful consideration should be taken before trading using them.
76 |
77 |
Disclaimer
78 |
This tool is not designed to be a trading strategy or to provide any trading advice or recommendations. Due to the nature of cryptocurrencies and trading in general, markets are very volatile and difficult to predict. No data displayed by the visualization, written on this page, or relayed in any other manner through this tool should be construed as trading advice. CryptoViz and CryptoViz's creators do not guarantee the accuracy, timeliness, or precision of the data it displays. We provide no warranty, make no promises, and only want to provide a tool you may find useful in understanding the cryptocurrency markets better. If you have any questions about these terms or the tool, please contact me at me@ameo.link.
79 |
80 |
All data used in this visualization comes from the Poloniex exchange. We do not make use of any data not publicly provided on the Poloniex website and do not claim to provide any advantage not available elsewhere. I'd like to express a big thank-you to them for making their data freely available for things like this tool.
81 |
82 |
83 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/src/react-orderbook/calc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility functions used for calculating values used for rendering the visualization
3 | */
4 |
5 | // @flow
6 |
7 | import _ from 'lodash';
8 |
9 | type Orderbook = { [key: number]: { volume: number, isBid: boolean } };
10 |
11 | /**
12 | * Given informatoin about the size and zoom of the visualization, calculates the X and Y positions of a certain
13 | * price/timestamp point on the canvas. Returns a result like `{x: 882.12312, y: 299.399201}`.
14 | */
15 | export const getPixelPosition = (
16 | minPrice: number,
17 | maxPrice: number,
18 | minTime: number,
19 | maxTime: number,
20 | canvasHeight: number,
21 | canvasWidth: number,
22 | timestamp: number,
23 | price: number
24 | ): { x: number, y: number } => {
25 | const x = ((timestamp - minTime) / (maxTime - minTime)) * canvasWidth;
26 | const y = canvasHeight - ((price - minPrice) / (maxPrice - minPrice)) * canvasHeight;
27 | return { x: x + 60, y: y };
28 | };
29 |
30 | /**
31 | * Given a timestamp, returns its pixel position.
32 | */
33 | export const getPixelX = (vizState, timestamp) =>
34 | ((timestamp - vizState.minTimestamp) / (vizState.maxTimestamp - vizState.minTimestamp)) *
35 | vizState.canvasWidth +
36 | 60;
37 |
38 | /**
39 | * Given a price, returns its pixel position
40 | */
41 | export const getPixelY = (vizState, price) =>
42 | vizState.canvasHeight -
43 | ((+price - vizState.minPrice) / (vizState.maxPrice - vizState.minPrice)) * vizState.canvasHeight;
44 |
45 | type vizState = {
46 | minPrice: number,
47 | maxPrice: number,
48 | minTimestamp: number,
49 | maxTimestamp: number,
50 | canvasHeight: number,
51 | canvasWidth: number,
52 | };
53 |
54 | /**
55 | * Wrapper export function around `getPixelPosition` that gets settings from `vizState`
56 | */
57 | export const gpp = (
58 | { minPrice, maxPrice, minTimestamp, maxTimestamp, canvasHeight, canvasWidth }: vizState,
59 | timestamp: number,
60 | price: number
61 | ) =>
62 | getPixelPosition(
63 | +minPrice,
64 | +maxPrice,
65 | minTimestamp,
66 | maxTimestamp,
67 | canvasHeight,
68 | canvasWidth,
69 | timestamp,
70 | +price
71 | );
72 |
73 | /**
74 | * Given an image of the initial orderbook, returns an array of `BandDef`s that contain the initial volumes for each band
75 | */
76 | export const getInitialBandValues = (
77 | initialTimestamp: number,
78 | initialBook: Orderbook,
79 | minVisiblePrice: number,
80 | maxVisiblePrice: number,
81 | priceGranularity: number,
82 | pricePrecision: number
83 | ): Array => {
84 | // const prices = getPricesFromBook(initialBook, pricePrecision);
85 | const prices = Object.keys(initialBook);
86 |
87 | // price range between the bottom and top of each band
88 | const bands = _.range(0, priceGranularity).map(i => ({
89 | startTimestamp: initialTimestamp,
90 | endTimestamp: initialTimestamp,
91 | volume: '0',
92 | }));
93 |
94 | let curBandIndex = 0;
95 |
96 | prices.forEach(price => {
97 | curBandIndex = getBandIndex(
98 | { maxPrice: maxVisiblePrice, minPrice: minVisiblePrice, priceGranularity },
99 | price
100 | );
101 |
102 | if (curBandIndex >= 0 && curBandIndex < priceGranularity) {
103 | const rawVolume = +bands[curBandIndex].volume + +initialBook[price].volume;
104 | bands[curBandIndex].volume = rawVolume.toFixed(pricePrecision);
105 | }
106 | });
107 |
108 | return bands;
109 | };
110 |
111 | /**
112 | * Given an image of an orderbook as a HashMap, calculates the current best offer on both the bid and ask side.
113 | * @return {{bestBid: number, bestAsk: number}} - The current top-of-book bid and ask
114 | */
115 | export const getTopOfBook = (
116 | book: Orderbook,
117 | pricePrecision: number
118 | ): { bestBid: number, bestAsk: number } => {
119 | const prices = Object.keys(book);
120 |
121 | for (let i = 0; i < prices.length; i++) {
122 | if (!book[prices[i]].isBid) {
123 | return { bestBid: prices[i - 1], bestAsk: prices[i] };
124 | }
125 | }
126 |
127 | console.error('Finished looping book in `getTopOfBook` and reached end of loop!');
128 | };
129 |
130 | /**
131 | * Given an image of the initial orderbook and the range of visible prices, finds the maximum amount of volume
132 | * located in one band to be used for shading the other bands.
133 | */
134 | export const getMaxVisibleBandVolume = (
135 | vizState,
136 | book: Orderbook,
137 | minVisibleFixedPrice: string,
138 | maxVisibleFixedPrice: string,
139 | priceGranularity: number,
140 | pricePrecision: number
141 | ): string => {
142 | const minVisiblePrice = parseFloat(minVisibleFixedPrice);
143 | const maxVisiblePrice = parseFloat(maxVisibleFixedPrice);
144 | const allPrices = Object.keys(book)
145 | .map(parseFloat)
146 | .sort((a, b) => a - b);
147 | const visiblePrices = allPrices.filter(
148 | price => price >= minVisiblePrice && price <= maxVisiblePrice
149 | );
150 |
151 | let curBandIndex = 0;
152 | let curBandVolume = 0;
153 | let maxBandVolume = 0;
154 | visiblePrices.forEach(price => {
155 | // if this price is outside of the current band, change band index, reset counts, and determine new band index
156 | const newBandIndex = getBandIndex(vizState, price);
157 | if (newBandIndex >= vizState.priceGranularity) {
158 | return false;
159 | }
160 |
161 | if (newBandIndex > curBandIndex) {
162 | if (curBandVolume > maxBandVolume) {
163 | maxBandVolume = curBandVolume;
164 | }
165 | curBandVolume = 0;
166 | curBandIndex = newBandIndex;
167 | }
168 |
169 | curBandVolume += +book[price.toFixed(pricePrecision)].volume;
170 | });
171 |
172 | if (curBandVolume > maxBandVolume) {
173 | maxBandVolume = curBandVolume;
174 | }
175 |
176 | return maxBandVolume.toFixed(pricePrecision);
177 | };
178 |
179 | /**
180 | * Given a price level and information about the visualization's current zoom level, calculates the index of the
181 | * band that the price level is a part of.
182 | */
183 | export const getBandIndex = (
184 | vizState: { maxPrice: number, minPrice: number, priceGranularity: number },
185 | price: number
186 | ): number => {
187 | // price range between the bottom and top of each band
188 | const bandPriceSpan = (+vizState.maxPrice - +vizState.minPrice) / vizState.priceGranularity;
189 |
190 | if (price == vizState.maxPrice) {
191 | return vizState.priceGranularity - 1;
192 | } else {
193 | return Math.floor((+price - +vizState.minPrice) / bandPriceSpan);
194 | }
195 | };
196 |
197 | export const getTimestampFromPixel = (vizState, x) => {
198 | const timeRange = +vizState.maxTimestamp - +vizState.minTimestamp;
199 | return ((x - 60) / vizState.canvasWidth) * timeRange + +vizState.minTimestamp;
200 | };
201 |
202 | export const getPriceFromPixel = (vizState, y) => {
203 | const priceRange = +vizState.maxPrice - +vizState.minPrice;
204 | const percent = 1 - y / vizState.canvasHeight;
205 | return percent * priceRange + +vizState.minPrice;
206 | };
207 |
--------------------------------------------------------------------------------
/src/react-orderbook/Orderbook/Orderbook.js:
--------------------------------------------------------------------------------
1 | //! An interactive limit orderbook visualization showing the locations of limit orders, trade executions, and price action.
2 | // @flow
3 |
4 | import React from 'react';
5 | import PropTypes from 'prop-types';
6 | import _ from 'lodash';
7 | import paper from 'paper';
8 | import chroma from 'chroma-js';
9 |
10 | import { ChangeShape } from '../util';
11 | import { getMaxVisibleBandVolume, getInitialBandValues } from '../calc';
12 | import { renderInitial, renderUpdate } from './render';
13 | import { histRender } from './histRender';
14 | import { initPaperCanvas, resetZoom } from './paperRender';
15 | import BottomBar from './BottomBar';
16 |
17 | const colorSchemes = {
18 | 'Blue Moon': ['#141414', '#7cbeff'],
19 | 'Candy Floss': ['#141414', '#f53dff'],
20 | 'Deep Sea': ['#141414', '#389dff'],
21 | Pumpkin: ['#141414', '#ff9232'],
22 | Chalkboard: ['#030303', '#ffffff'],
23 | Heat: ['#fff7ec', '#fc8d59', '#7f0000'],
24 | };
25 |
26 | class Orderbook extends React.Component {
27 | constructor(props) {
28 | super(props);
29 |
30 | this.handleSettingChange = this.handleSettingChange.bind(this);
31 | this.initState = this.initState.bind(this);
32 |
33 | this.vizState = {
34 | // zoom settings
35 | timeScale: 1000 * 20, // how much time to display on the viz in ms
36 | minTimestamp: null,
37 | maxTimestamp: null,
38 | minPrice: null,
39 | maxPrice: null,
40 | priceGranularity: 100, // the number of destinct price levels to mark on the visualization
41 | timeGranuality: 1000, // the min number of ms that can exist as a distinct unit
42 | maxVisibleBandVolume: null, // the max level a band has ever been at in the current zoom
43 | manualZoom: false, // if true, then we shouldn't re-adjust the zoom level
44 | // duplicated settings from props
45 | canvasHeight: props.canvasHeight,
46 | canvasWidth: props.canvasWidth,
47 | pricePrecision: props.pricePrecision,
48 | nativeCanvas: null,
49 | // visual settings
50 | colorScheme: ['#141414', '#7cbeff'],
51 | backgroundColor: '#141414',
52 | textColor: '#dbe8ff',
53 | maxTradeMarketRadius: 10,
54 | // rendering state
55 | activeBands: null, // Array
56 | activePrices: null, // { [key: number]: BandDef }
57 | priceLevelUpdates: [], // Array<{price: number, volume: number, timestamp: number, isBid: boolean}>
58 | trades: [], // Array<{timestamp: number, price: number, amountTraded: number}>
59 | maxBandVolumeChanges: [], // every time the max visible band volume changes, it's recorded here.
60 | askTradeLineExtended: false,
61 | bidTradeLineExtended: false,
62 | hoveredX: 0,
63 | hoveredY: 0,
64 | histRendering: false, // set to true during historical renders to try to avoid race conditions
65 | // bestBid: null,
66 | // bestBidChanges: [],
67 | // bestAsk: null,
68 | // bestAskChanges: [],
69 | maxRenderedTrade: 0,
70 | };
71 | }
72 |
73 | componentWillMount() {
74 | this.initState(this.props);
75 | }
76 |
77 | componentDidMount() {
78 | renderInitial(this.vizState, this.nativeCanvas);
79 | this.vizState.nativeCanvas = this.nativeCanvas;
80 |
81 | // initialize the PaperJS environment on the internal canvas
82 | this.vizState.paperscope = new paper.PaperScope();
83 | this.vizState.paperscope.setup(this.paperCanvas);
84 |
85 | initPaperCanvas(this.vizState);
86 | }
87 |
88 | componentWillReceiveProps(nextProps) {
89 | if (!_.isEqual(nextProps.change, this.props.change)) {
90 | // if we've got a new update, render it
91 | if (this.vizState.histRendering) console.error(nextProps.change);
92 | renderUpdate(this.vizState, nextProps.change, this.nativeCanvas);
93 | } else if (!_.isEqual(nextProps.initialBook, this.props.initialBook)) {
94 | // currency has changed; reset all internal state and re-initialize component
95 | console.log('Reinitializing component state with new initial book...');
96 | this.initState(nextProps);
97 |
98 | console.log('re-rendering canvas...');
99 | renderInitial(this.vizState, this.nativeCanvas);
100 |
101 | // initialize the PaperJS environment on the internal canvas
102 | this.vizState.paperscope = new paper.PaperScope();
103 | this.vizState.paperscope.setup(this.paperCanvas);
104 | initPaperCanvas(this.vizState);
105 |
106 | // clear old trades from previous currency and reset zoom to default for the new currency
107 | this.vizState.trades = [];
108 | resetZoom(this.vizState);
109 |
110 | // Work around strange bug in Paper.JS causing canvas scaling to increase every time that
111 | // the visualization updates for a new currency
112 | const pixelRatio = this.vizState.paperscope.project._view._pixelRatio;
113 | const ctx = this.vizState.paperscope.project._view._context;
114 | ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
115 | }
116 | }
117 |
118 | shouldComponentUpdate(nextProps) {
119 | return (
120 | nextProps.canvasHeight !== this.props.canvasHeight ||
121 | nextProps.canvasWidth !== this.props.canvasWidth ||
122 | !_.isEqual(nextProps.initialBook, this.props.initialBook)
123 | );
124 | }
125 |
126 | initState(props) {
127 | // calculate initial zoom levels given the starting orderbook
128 | this.vizState.minTimestamp = props.initialTimestamp;
129 | this.vizState.maxTimestamp = props.initialTimestamp + this.vizState.timeScale;
130 | this.vizState.minPrice = props.minPrice;
131 | this.vizState.maxPrice = props.maxPrice;
132 | this.vizState.initialMinPrice = props.minPrice;
133 | this.vizState.initialMaxPrice = props.maxPrice;
134 | this.vizState.maxVisibleBandVolume = getMaxVisibleBandVolume(
135 | this.vizState,
136 | props.initialBook,
137 | props.minPrice,
138 | props.maxPrice,
139 | this.vizState.priceGranularity,
140 | this.vizState.pricePrecision
141 | );
142 | this.vizState.latestMaxVolumeChange = this.vizState.maxVisibleBandVolume;
143 | this.vizState.askTradeLineExtended = false;
144 | this.vizState.bidTradeLineExtended = false;
145 |
146 | // calculate color scheme and set up chroma.js color scale function
147 | this.vizState.scaleColor = chroma
148 | .scale(this.vizState.colorScheme)
149 | .mode('lch')
150 | .domain([0, +this.vizState.maxVisibleBandVolume]);
151 |
152 | // populate the active prices from the initial book image
153 | this.vizState.activePrices = props.initialBook;
154 |
155 | // get the initial top-of-book bid and ask prices
156 | // const {bestBid, bestAsk} = getTopOfBook(this.vizState.activePrices, this.vizState.pricePrecision);
157 | // this.vizState.bestBid = bestBid;
158 | // this.vizState.bestAsk = bestAsk;
159 |
160 | // create the initial band values using the initial book image
161 | this.vizState.activeBands = getInitialBandValues(
162 | props.initialTimestamp,
163 | props.initialBook,
164 | props.minPrice,
165 | props.maxPrice,
166 | this.vizState.priceGranularity,
167 | this.vizState.pricePrecision
168 | );
169 |
170 | // set up the price level updates with the initial prices
171 | const priceLevelUpdates = [];
172 | _.each(this.vizState.activePrices, (value, price) => {
173 | priceLevelUpdates.push({
174 | price: price,
175 | timestamp: props.initialTimestamp,
176 | volume: value.volume,
177 | isBid: value.isBid,
178 | });
179 | });
180 | this.vizState.priceLevelUpdates = priceLevelUpdates;
181 | }
182 |
183 | handleSettingChange(setting) {
184 | if (setting.currency) {
185 | this.props.onCurrencyChange(setting.currency);
186 | } else if (setting.priceGranularity) {
187 | this.vizState.priceGranularity = setting.priceGranularity;
188 | renderInitial(this.vizState, this.nativeCanvas);
189 | histRender(this.vizState, this.nativeCanvas, true);
190 | } else if (setting.colorScheme) {
191 | this.vizState.colorScheme = colorSchemes[setting.colorScheme];
192 | this.vizState.backgroundColor = colorSchemes[setting.colorScheme][0];
193 | this.vizState.scaleColor = chroma
194 | .scale(this.vizState.colorScheme)
195 | .mode('lch')
196 | .domain([0, +this.vizState.maxVisibleBandVolume]);
197 | renderInitial(this.vizState, this.nativeCanvas);
198 | histRender(this.vizState, this.nativeCanvas);
199 | }
200 | }
201 |
202 | render() {
203 | return (
204 |
205 |
206 |
233 |
234 |
240 |
241 | );
242 | }
243 | }
244 |
245 | Orderbook.propTypes = {
246 | currencies: PropTypes.arrayOf(PropTypes.string).isRequired,
247 | canvasHeight: PropTypes.number,
248 | canvasWidth: PropTypes.number,
249 | change: PropTypes.shape(ChangeShape).isRequired,
250 | initialBook: PropTypes.object.isRequired,
251 | initialTimestamp: PropTypes.number.isRequired,
252 | maxPrice: PropTypes.string.isRequired,
253 | minPrice: PropTypes.string.isRequired,
254 | onCurrencyChange: PropTypes.func.isRequired,
255 | pricePrecision: PropTypes.number.isRequired,
256 | };
257 |
258 | Orderbook.defaultProps = {
259 | canvasHeight: 600,
260 | canvasWidth: 900,
261 | };
262 |
263 | export default Orderbook;
264 |
--------------------------------------------------------------------------------
/src/routes/IndexPage.js:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 | import _ from 'lodash';
6 |
7 | import injectTapEventPlugin from 'react-tap-event-plugin';
8 | injectTapEventPlugin();
9 | import darkBaseTheme from 'material-ui/styles/baseThemes/darkBaseTheme';
10 | import getMuiTheme from 'material-ui/styles/getMuiTheme';
11 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
12 |
13 | import OrderbookVisualizer from '../react-orderbook/index';
14 |
15 | class IndexPage extends React.Component {
16 | constructor(props) {
17 | super(props);
18 |
19 | // function for handling the result of the HTTP request for the list of currencies
20 | let handleCurrencies = currencyDefinitions => {
21 | const activeSymbols = _.filter(Object.keys(currencyDefinitions), symbol => {
22 | return !currencyDefinitions[symbol].delisted && !currencyDefinitions[symbol].frozen;
23 | });
24 | const zippedCurrencies = _.zipObject(
25 | activeSymbols,
26 | activeSymbols.map(symbol => currencyDefinitions[symbol])
27 | );
28 | delete zippedCurrencies['BTC'];
29 | delete zippedCurrencies['USDT'];
30 |
31 | this.setState({ currencies: zippedCurrencies });
32 | if (activeSymbols.includes('BTC_ETH')) {
33 | return 'BTC_ETH';
34 | } else if (activeSymbols.includes('BTC_XMR')) {
35 | return 'BTC_XMR';
36 | } else {
37 | return activeSymbols[0];
38 | }
39 | };
40 | handleCurrencies = handleCurrencies.bind(this);
41 |
42 | this.wsSubscribe = this.wsSubscribe.bind(this);
43 | this.handleBook = this.handleBook.bind(this);
44 | this.handleTrades = this.handleTrades.bind(this);
45 | this.handleConnOpen = this.handleConnOpen.bind(this);
46 | this.processBookUpdate = this.processBookUpdate.bind(this);
47 | this.handleWsMsg = this.handleWsMsg.bind(this);
48 | this.initCurrency = this.initCurrency.bind(this);
49 |
50 | // const currenciesUrl = 'https://poloniex.com/public?command=returnCurrencies';
51 | const tickerUrl = 'https://poloniex.com/public?command=returnTicker';
52 | fetch(tickerUrl)
53 | .then(res => res.json())
54 | .then(handleCurrencies)
55 | .catch(console.error)
56 | .then(this.initCurrency)
57 | .catch(console.error);
58 |
59 | // bind callback executors
60 | this.bookModificationExecutor = this.bookModificationExecutor.bind(this);
61 | this.bookRemovalExecutor = this.bookRemovalExecutor.bind(this);
62 | this.newTradeExecutor = this.newTradeExecutor.bind(this);
63 | this.handleCurrencyChange = this.handleCurrencyChange.bind(this);
64 |
65 | // set up noop functions for the callbacks until the proper ones are sent over from the inner visualization
66 | this.modificationCallback = () => console.warn('Dummy modification callback called!');
67 | this.removalCallback = () => console.warn('Dummy removal callback called!');
68 | this.newTradeCallback = () => console.warn('Dummy newTrade callback called!');
69 |
70 | this.state = {
71 | currencies: {},
72 | initialBook: null,
73 | maxPrice: null,
74 | minPrice: null,
75 | };
76 | }
77 |
78 | // function that's called to populate starting data about a currency for the visualization and initialize the viz
79 | initCurrency(currency) {
80 | this.currency = currency;
81 | // fetch a list of recent trades for determining price range to show in the visualizations
82 | this.setState({ selectedCurrency: currency });
83 | const tradesUrl = `https://poloniex.com/public?command=returnTradeHistory¤cyPair=${currency}`;
84 | fetch(tradesUrl)
85 | .then(res => res.json())
86 | .then(this.handleTrades)
87 | .catch(console.error);
88 |
89 | // // fetch an image of the initial orderbook from the HTTP API
90 | // const bookUrl = `https://poloniex.com/public?command=returnOrderBook¤cyPair=${currency}&depth=1000000000`;
91 | // fetch(bookUrl)
92 | // .then(res => res.json())
93 | // .then(this.handleBook).catch(console.error);
94 |
95 | // initialize WS connection to Poloniex servers and open the connection
96 | this.connection = new WebSocket('wss://api2.poloniex.com');
97 | this.connection['subscriptions'] = {};
98 | this.connection.onopen = this.handleConnOpen(currency, this.wsSubscribe);
99 | this.lastSeq = 0;
100 | this.buffer = [];
101 | this.connection.onmessage = this.handleWsMsg;
102 | }
103 |
104 | // function for handling the result of the HTTP request for recent trades used to determine starting price zoom levels
105 | handleTrades(tradeHistory) {
106 | const minRate = _.minBy(tradeHistory, 'rate').rate;
107 | const maxRate = _.maxBy(tradeHistory, 'rate').rate;
108 |
109 | this.setState({ minPrice: minRate * 0.995, maxPrice: maxRate * 1.005 });
110 | }
111 |
112 | // function for handling the result of the HTTP request for the initial orderbook
113 | handleBook(parsedRes) {
114 | const mapPrices = (isBid, pricePrecision) => ([price, volume]) => ({
115 | price,
116 | volume: volume.toFixed(pricePrecision),
117 | isBid,
118 | });
119 |
120 | const bids = parsedRes.bids.map(mapPrices(true, this.props.pricePrecision));
121 | const asks = parsedRes.asks.map(mapPrices(false, this.props.pricePrecision));
122 | const initialBook = _.concat(bids, asks);
123 |
124 | // insert the initial book into the component's state
125 | console.log('setting initial book');
126 | this.setState({ initialBook });
127 | }
128 |
129 | // returns a function that is called once the websocket has established a connection;
130 | // subscribes to price channels and handles new messages
131 | handleConnOpen(currency, wsSubscribe) {
132 | const conn = this.connection;
133 |
134 | return function(e) {
135 | console.log('Connection to Poloniex API open.');
136 |
137 | wsSubscribe(currency);
138 | // trollbox: 1001
139 | // wsSubscribe(1001);
140 | conn['keepAlive'] = setInterval(() => {
141 | try {
142 | conn.send('.');
143 | } catch (err) {
144 | console.error(err);
145 | }
146 | }, 60000);
147 | };
148 | }
149 |
150 | // function for parsing the messages received from the websocket connection and sending their data to where it needs to go
151 | handleWsMsg(e) {
152 | if (e.data.length === 0) {
153 | return;
154 | }
155 |
156 | const msg = JSON.parse(e.data);
157 | if (msg[1] === 1) {
158 | return (e.target.subscriptions[msg[0]] = true);
159 | }
160 |
161 | switch (msg[0]) {
162 | // message is an orderbook update
163 | case this.currencyChannel: {
164 | // make sure that this order is in sequence
165 | let seq = msg[1];
166 | if (seq === this.lastSeq + 1) {
167 | // message is properly sequenced and we should process it
168 | this.lastSeq = seq;
169 | // process each of the individual updates in this message
170 | this.lastUpdate = msg;
171 | // console.log(msg[2]);
172 | msg[2].forEach(this.processBookUpdate);
173 | // if there's a buffer to process, drain it until we encounter another gap or fully empty it
174 | while (this.buffer[seq + 1]) {
175 | // process all of the contained updates in the buffered message
176 | this.buffer[seq + 1][2].forEach(update => {
177 | console.log(`Processing buffered update with seq ${seq + 1}`);
178 | this.processBookUpdate(update);
179 | });
180 | seq += 1;
181 | }
182 | this.buffer = [];
183 | } else if (seq === this.lastSeq) {
184 | // is probably a duplicate or a heartbeat message, but make sure
185 | if (this.lastUpdate && !_.isEqual(this.lastUpdate, msg)) {
186 | console.error(
187 | `Same sequence number but different messages: ${JSON.stringify(
188 | this.lastUpdate
189 | )} != ${JSON.stringify(msg)}`
190 | );
191 | }
192 | } else if (seq < this.lastSeq) {
193 | console.error(
194 | `sequence number ${seq} was less than we expected and we don't have a buffer for it`
195 | );
196 | } else if (seq > this.lastSeq + 1) {
197 | if (this.lastSeq + 10 < seq) {
198 | // there's still a chance we may still eventually receive the out-of-order message, so wait for it
199 | console.log(
200 | `Received out-of-sequence message with seq ${seq} (expected ${this.lastSeq +
201 | 1}); buffering it.`
202 | );
203 | this.buffer[seq] = msg;
204 | } else {
205 | console.error('Lost message.'); // TODO
206 | }
207 | }
208 | this.lastUpdate = msg;
209 | break;
210 | }
211 |
212 | default:
213 | if (msg[0] > 0 && msg[0] < 1000) {
214 | if (msg[2][0][0] == 'i') {
215 | const orderbook = msg[2][0][1];
216 | if (orderbook.currencyPair != this.currency) {
217 | console.error(
218 | `Expected symbol ${this.currency} but received data for ${orderbook.currencyPair}`
219 | );
220 | } else {
221 | this.currencyChannel = msg[0];
222 | const mergedBook = {};
223 | Object.keys(orderbook.orderBook[0]).forEach(price => {
224 | mergedBook[price] = { volume: orderbook.orderBook[0][price], isBid: false };
225 | });
226 | Object.keys(orderbook.orderBook[1]).forEach(price => {
227 | mergedBook[price] = { volume: orderbook.orderBook[1][price], isBid: true };
228 | });
229 | this.setState({ initialBook: mergedBook });
230 | this.lastSeq = msg[1];
231 | }
232 | break;
233 | }
234 | }
235 | break;
236 | }
237 | }
238 |
239 | // utility function used to subscribe to a websocket channel
240 | wsSubscribe(channel) {
241 | if (this.connection.readyState === 1) {
242 | const subCommand = { channel: channel, command: 'subscribe' };
243 | this.connection.send(JSON.stringify(subCommand));
244 | } else {
245 | console.error("Websocket is not yet ready; can't subscribe to channel!");
246 | }
247 | }
248 |
249 | processBookUpdate(update) {
250 | // console.log(update);
251 | if (update[0] == 'o') {
252 | // update is an orderbook update, so either execute the modification or removal callback
253 | if (update[3] === '0.00000000') {
254 | // is a removal
255 | this.removalCallback({
256 | timestamp: _.now(),
257 | removal: {
258 | price: update[2],
259 | isBid: !!update[1],
260 | },
261 | });
262 | } else {
263 | // is a modification
264 | this.modificationCallback({
265 | timestamp: _.now(),
266 | modification: {
267 | price: update[2],
268 | newAmount: update[3],
269 | isBid: !!update[1],
270 | },
271 | });
272 | }
273 | } else if (update[0] == 't') {
274 | // update is a new trade
275 | this.newTradeCallback({
276 | timestamp: _.now(),
277 | newTrade: {
278 | price: update[3],
279 | amountTraded: update[4],
280 | wasBidFilled: !!update[2],
281 | },
282 | });
283 | } else {
284 | console.warn(`Received unhandled update type: ${JSON.stringify(update)}`);
285 | }
286 | }
287 |
288 | bookModificationExecutor(callback) {
289 | this.modificationCallback = callback;
290 | }
291 |
292 | bookRemovalExecutor(callback) {
293 | this.removalCallback = callback;
294 | }
295 |
296 | newTradeExecutor(callback) {
297 | this.newTradeCallback = callback;
298 | }
299 |
300 | handleCurrencyChange(newCurrency) {
301 | // disable old websocket to avoid sequence number resetting while we're reinitializing state
302 | clearInterval(this.connection.keepAlive);
303 | this.connection.close();
304 | this.initCurrency(newCurrency);
305 | }
306 |
307 | render() {
308 | if (!this.state.initialBook || !this.state.minPrice || !this.state.maxPrice) {
309 | return
{'Loading...'}
;
310 | }
311 |
312 | return (
313 |
314 |
326 |
327 | );
328 | }
329 | }
330 |
331 | IndexPage.propTypes = { pricePrecision: PropTypes.number };
332 |
333 | IndexPage.defaultProps = { pricePrecision: 8 };
334 |
335 | export default IndexPage;
336 |
--------------------------------------------------------------------------------
/src/react-orderbook/Orderbook/render.js:
--------------------------------------------------------------------------------
1 | //! Functions for rendering the visualization's components on the canvas
2 | // @flow
3 |
4 | const chroma = require('chroma-js');
5 |
6 | import { gpp, getBandIndex } from '../calc';
7 | import { histRender } from './histRender';
8 | import {
9 | renderOrderNotification,
10 | renderTradeNotification,
11 | extendTradeLines,
12 | updateTextInfo,
13 | } from './paperRender';
14 |
15 | type Orderbook = { [price: number]: { volume: number, isBid: boolean } };
16 | type BandDef = { startTimestamp: number, endTimestamp: number, volume: number, isBid: ?boolean };
17 |
18 | /**
19 | * Given the component's vizState and a reference to the canvas, renders the initial view of the orderbook given
20 | * the visualization.
21 | */
22 | export const renderInitial = (
23 | vizState: { curBook: Orderbook, canvasHeight: number, canvasWidth: number },
24 | canvas: any
25 | ) => {
26 | // fill in the background
27 | const ctx = canvas.getContext('2d');
28 | ctx.fillStyle = vizState.backgroundColor;
29 | ctx.fillRect(0, 0, vizState.canvasWidth, vizState.canvasHeight);
30 | };
31 |
32 | /**
33 | * Given a change to the orderbook, updates the visualization according to what changed.
34 | */
35 | export const renderUpdate = (
36 | vizState: {
37 | activeBands: Array,
38 | activePrices: { [price: number]: { volume: number, isBid: boolean } },
39 | priceLevelUpdates: Array<{ price: number, volume: number, timestamp: number, isBid: boolean }>,
40 | minPrice: number,
41 | maxVisibleBandVolume: number,
42 | priceGranularity: number,
43 | timeGranularity: number,
44 | timeScale: number,
45 | trades: Array<{ timestamp: number, price: number, amountTraded: number }>,
46 | },
47 | change,
48 | canvas
49 | ) => {
50 | const timestamp = change.timestamp;
51 | let volumeDiff: number, fixedPrice, isBid;
52 |
53 | // determine the price level and how much the volume at the update's price level changed
54 | if (change.modification) {
55 | fixedPrice = change.modification.price;
56 | isBid = change.modification.isBid;
57 |
58 | if (vizState.activePrices[fixedPrice]) {
59 | volumeDiff = +change.modification.newAmount - +vizState.activePrices[fixedPrice].volume;
60 | } else {
61 | volumeDiff = +change.modification.newAmount;
62 | }
63 |
64 | renderOrderNotification(volumeDiff, timestamp, vizState.maxVisibleBandVolume);
65 | } else if (change.removal) {
66 | fixedPrice = change.removal.price;
67 | isBid = change.removal.isBid;
68 |
69 | if (vizState.activePrices[fixedPrice]) {
70 | volumeDiff = -+vizState.activePrices[fixedPrice].volume;
71 | } else {
72 | console.warn(
73 | `All orders removed at price level ${fixedPrice} but we had no volume level there before!`
74 | );
75 | volumeDiff = 0;
76 | }
77 |
78 | renderOrderNotification(volumeDiff, timestamp, vizState.maxVisibleBandVolume);
79 | } else if (change.newTrade) {
80 | fixedPrice = change.newTrade.price;
81 | isBid = change.newTrade.wasBidFilled;
82 |
83 | volumeDiff = 0;
84 | // look through the book and see if there are any impossible orders, removing them if there are.
85 | // const fixedPrices = Object.keys(vizState.activePrices);
86 | // _.each(fixedPrices, otherFixedPrice => {
87 | // const activePrice = vizState.activePrices[otherFixedPrice];
88 | // if(activePrice.isBid && isBid && (+otherFixedPrice > +fixedPrice) && +activePrice.volume > 0) {
89 | // console.warn(`Impossible bid in book at ${otherFixedPrice} with volume ${activePrice.volume}; There was just a filled bid at ${fixedPrice}.`);
90 | // // reduce the band's volume as well if it's visible
91 | // // const bandIx = getBandIndex(vizState, otherFixedPrice);
92 | // // if(bandIx >= 0 && bandIx < vizState.priceGranularity) {
93 | // // let rawVolume = +vizState.activeBands[bandIx].volume - +activePrice.volume;
94 | // // if(rawVolume < 0) {
95 | // // rawVolume = 0;
96 | // // }
97 | // // vizState.activeBands[bandIx].volume = rawVolume.toFixed(vizState.pricePrecision);
98 | // // }
99 | // // activePrice.volume = '0';
100 | // } else if(!activePrice.isBid && !isBid && (+otherFixedPrice < +fixedPrice) && +activePrice.volume > 0) {
101 | // console.warn(`Impossible ask in book at ${otherFixedPrice} with volume ${activePrice.volume}; There was just an ask filled at ${fixedPrice}.`);
102 | // // reduce the band's volume as well if it's visible
103 | // // const bandIx = getBandIndex(vizState, otherFixedPrice);
104 | // // if(bandIx >= 0 && bandIx < vizState.priceGranularity) {
105 | // // let rawVolume = +vizState.activeBands[bandIx].volume - +activePrice.volume;
106 | // // if(rawVolume < 0) {
107 | // // rawVolume = 0;
108 | // // }
109 | // // vizState.activeBands[bandIx].volume = rawVolume.toFixed(vizState.pricePrecision);
110 | // // }
111 | // // activePrice.volume = '0';
112 | // }
113 | // });
114 |
115 | vizState.trades.push({
116 | timestamp,
117 | volume: change.newTrade.amountTraded,
118 | isBid,
119 | price: fixedPrice,
120 | });
121 | renderTradeNotification(vizState, fixedPrice, change.newTrade.amountTraded, timestamp, isBid);
122 | }
123 |
124 | // extend the trade lines to the right if it's a price level modification
125 | if (!change.newTrade) {
126 | extendTradeLines(vizState, timestamp);
127 | }
128 |
129 | // update displayed price information
130 | updateTextInfo(vizState);
131 |
132 | const price = +fixedPrice;
133 |
134 | // determine the index of the band in which this price update lies
135 | const curBandIndex = getBandIndex(vizState, price);
136 |
137 | let newPriceVolume =
138 | volumeDiff +
139 | parseFloat(vizState.activePrices[fixedPrice] ? vizState.activePrices[fixedPrice].volume : 0);
140 | if (newPriceVolume < 0) {
141 | newPriceVolume = 0;
142 | console.warn(`Negative new volume at price ${price}`);
143 | }
144 |
145 | // update the price level
146 | vizState.activePrices[fixedPrice] = {
147 | volume: newPriceVolume.toFixed(vizState.pricePrecision),
148 | isBid,
149 | };
150 |
151 | // add this price update to the list of price level updates to be used for re-rendering
152 | vizState.priceLevelUpdates.push({
153 | price: fixedPrice,
154 | timestamp,
155 | volume: newPriceVolume.toFixed(vizState.pricePrecision),
156 | isBid,
157 | });
158 |
159 | // draw the old band if it is currently visible. If not, draw all the other bands and exit.
160 | const activeBand = vizState.activeBands[curBandIndex];
161 | if (
162 | curBandIndex >= 0 &&
163 | curBandIndex < vizState.priceGranularity &&
164 | timestamp < vizState.maxTimestamp
165 | ) {
166 | activeBand.endTimestamp = timestamp;
167 | drawBand(vizState, activeBand, curBandIndex, canvas.getContext('2d'));
168 | } else {
169 | return drawBands(vizState, timestamp, canvas);
170 | }
171 |
172 | activeBand.startTimestamp = timestamp;
173 | // update the volume level and end timestamp of the band to reflect this modification
174 | const rawVolume = +activeBand.volume + volumeDiff;
175 | activeBand.volume = rawVolume.toFixed(vizState.pricePrecision);
176 | if (activeBand.volume < 0) {
177 | activeBand.volume = (0).toFixed(vizState.pricePrecision);
178 | console.warn(`sub-zero new band volume at band level ${curBandIndex}`);
179 | }
180 | activeBand.endTimestamp = timestamp;
181 |
182 | if (curBandIndex >= 0 && curBandIndex < vizState.priceGranularity) {
183 | const newVolume = +vizState.activeBands[curBandIndex].volume;
184 |
185 | // if we broke the max visible value record, re-render the entire viz with a different shading based on the new max volume
186 | if (newVolume > +vizState.maxVisibleBandVolume && !vizState.manualZoom) {
187 | vizState.maxVisibleBandVolume = newVolume.toFixed(vizState.pricePrecision);
188 | vizState.scaleColor = chroma
189 | .scale(vizState.colorScheme)
190 | .mode('lch')
191 | .domain([0, newVolume]);
192 | histRender(vizState, canvas);
193 | }
194 | }
195 |
196 | // // if this modification took all the volume at a price level, update the best bid/ask
197 | // if(newPriceVolume === 0 /*&& fixedPrice == vizState.bestBid || fixedPrice == vizState.bestAsk*/) {
198 | // updateBestBidAsk(vizState, timestamp, isBid);
199 | // // if this modification adds volume at a level better than the current best bid/ask, update as well
200 | // } else if((isBid && price > vizState.bestBid) || (!isBid && price < vizState.bestAsk)) {
201 | // updateBestBidAsk(vizState, timestamp, isBid);
202 | // }
203 |
204 | // if auto-zoom adjust is on and the trade is very close to being off the screen, adjust visible price levels
205 | if (!vizState.manualZoom && change.newTrade) {
206 | if (change.newTrade.price >= 0.995 * vizState.maxPrice) {
207 | vizState.maxPrice = (vizState.maxPrice * 1.003).toFixed(vizState.pricePrecision);
208 | console.log(`Setting max visible price to ${vizState.maxPrice} in response to a edge trade.`);
209 | return histRender(vizState, canvas, true);
210 | } else if (change.newTrade.price <= 1.005 * vizState.minPrice) {
211 | vizState.minPrice = (vizState.minPrice * 0.997).toFixed(vizState.pricePrecision);
212 | console.log(`Setting min visible price to ${vizState.minPrice} in response to a edge trade.`);
213 | return histRender(vizState, canvas, true);
214 | }
215 | }
216 |
217 | // if we've come very near to or crossed the right side of the canvas with this update, re-draw the viz with a larger view
218 | const timeRange = vizState.maxTimestamp - vizState.minTimestamp;
219 | if (timestamp > vizState.minTimestamp + 0.95 * timeRange && !vizState.manualZoom) {
220 | vizState.maxTimestamp += 0.2 * (vizState.maxTimestamp - vizState.minTimestamp);
221 | return histRender(vizState, canvas);
222 | }
223 |
224 | // update the visualization and re-draw all active bands.
225 | drawBands(vizState, timestamp, canvas);
226 | };
227 |
228 | // /**
229 | // * When a order removal or trade wipes out all the volume at a price level, re-calculate the best bid and ask.
230 | // */
231 | // function updateBestBidAsk(vizState, timestamp, isBid: boolean) {
232 | // const allFixedPrices = getPricesFromBook(vizState.activePrices, vizState.pricePrecision);
233 | // if(isBid) {
234 | // const thisSideFixedPrices = _.filter(allFixedPrices, fixedPrice => {
235 | // return +vizState.activePrices[fixedPrice].volume > 0 && vizState.activePrices[fixedPrice].isBid;
236 | // });
237 | // vizState.bestBid = _.maxBy(thisSideFixedPrices, parseFloat);
238 | // vizState.bestBidChanges.push({timestamp: timestamp, price: vizState.bestBid});
239 | // } else {
240 | // const thisSideFixedPrices = _.filter(allFixedPrices, fixedPrice => {
241 | // return +vizState.activePrices[fixedPrice].volume > 0 && !vizState.activePrices[fixedPrice].isBid;
242 | // });
243 | // vizState.bestAsk = _.minBy(thisSideFixedPrices, parseFloat);
244 | // vizState.bestAskChanges.push({timestmap: timestamp, price: vizState.bestAsk});
245 | // }
246 | // console.log(`Updated best ${isBid ? 'bid' : 'ask'} to ${isBid ? vizState.bestBid : vizState.bestAsk}.`);
247 | // }
248 |
249 | /**
250 | * Draws all active bands on the visualization.
251 | */
252 | export const drawBands = (vizState, curTimestamp, canvas) => {
253 | if (curTimestamp > vizState.maxTimestamp) {
254 | return;
255 | }
256 |
257 | // draw all the active bands and add a small bit of extra time at the right side so new bands are immediately visible
258 | const ctx = canvas.getContext('2d');
259 | vizState.activeBands.forEach((band: BandDef, i: number) => {
260 | band.endTimestamp = curTimestamp;
261 | if (band.volume != '0') {
262 | // render the band, subtracting the index from the total number of bands because the coordinates are reversed on the canvas
263 | drawBand(vizState, band, i, ctx);
264 | }
265 | });
266 | };
267 |
268 | /**
269 | * Draws a volume band on the visualization with the specified dimensions. It calculates the correct shading value for the band
270 | * by comparing its volume to the volume of other visible bands in the visualization.
271 | * @param {number} index - The band's index from the top of the page
272 | */
273 | export const drawBand = (
274 | vizState,
275 | band: { startTimestamp: number, endTimestamp: number },
276 | index: number,
277 | ctx
278 | ) => {
279 | const bandPriceSpan = (+vizState.maxPrice - +vizState.minPrice) / vizState.priceGranularity;
280 | const lowPrice = index * bandPriceSpan + +vizState.minPrice;
281 | const highPrice = lowPrice + bandPriceSpan;
282 | const topLeftCoords = gpp(vizState, band.startTimestamp, highPrice);
283 | const bottomRightCoords = gpp(vizState, band.endTimestamp, lowPrice);
284 |
285 | ctx.fillStyle = getBandColor(band, vizState.maxVisibleBandVolume, vizState.scaleColor);
286 | ctx.fillRect(
287 | Math.ceil(topLeftCoords.x),
288 | Math.floor(topLeftCoords.y),
289 | Math.ceil(bottomRightCoords.x - topLeftCoords.x),
290 | bottomRightCoords.y - topLeftCoords.y
291 | );
292 | };
293 |
294 | /**
295 | * Given a band's density, the maximum visible density on the visualization, and the visualization's style settings,
296 | * determines the background color of a volume band and returns it.
297 | */
298 | const getBandColor = (band, maxVisibleVolume: string, scaleColor) => scaleColor(+band.volume).hex();
299 |
--------------------------------------------------------------------------------
/src/react-orderbook/Orderbook/paperRender.js:
--------------------------------------------------------------------------------
1 | //! Functions for rendering the PaperJS parts of the visualization on the second canvas
2 | /* eslint no-unused-vars: 'off' */
3 |
4 | const _ = require('lodash');
5 |
6 | import {
7 | gpp,
8 | getPixelX,
9 | getPixelY,
10 | getTimestampFromPixel,
11 | getPriceFromPixel,
12 | getBandIndex,
13 | } from '../calc';
14 | import { histRender } from './histRender';
15 |
16 | /**
17 | * Renders in the price and time scales for the visualization
18 | */
19 | export const renderScales = vizState => {
20 | const { Color, Path, Point, PointText } = vizState.paperscope;
21 | // remove any pre-existing price lines first
22 | vizState.paperscope.project.activeLayer.children
23 | .filter(
24 | item => item.name && (item.name.includes('priceLine_') || item.name.includes('levelText_'))
25 | )
26 | .forEach(item => item.remove());
27 |
28 | // draw a line on the left side of the visualization to serve as the price axis
29 | const axisLine = new Path({
30 | segments: [new Point(60, 0), new Point(60, vizState.canvasHeight)],
31 | strokeColor: vizState.textColor,
32 | });
33 |
34 | // Draw to draw one price label every 50 pixels. Have them be inline with bands.
35 | const labelCount = Math.floor((vizState.canvasHeight - 1) / 50);
36 | const bandPriceSpan = (+vizState.maxPrice - +vizState.minPrice) / vizState.priceGranularity;
37 | const bandPixelHeight = vizState.canvasHeight / vizState.priceGranularity;
38 | // how many price bands between each labeled price level
39 | const levelSpacing = Math.ceil(50 / bandPixelHeight);
40 | const totalLevels = Math.floor(vizState.canvasHeight / levelSpacing);
41 | let curLevel = vizState.priceGranularity;
42 | while (curLevel > 0) {
43 | // determine the raw price of where we'd like to place the band
44 | const rawPrice = +vizState.minPrice + bandPriceSpan * curLevel;
45 | // find the pixel value of the bottom of this price band
46 | const bandBottomPixel = vizState.canvasHeight - bandPixelHeight * curLevel;
47 | // write the price level at that point
48 | const levelText = new PointText(new Point(0, bandBottomPixel));
49 | levelText.fontSize = '10px';
50 | levelText.fillColor = vizState.textColor;
51 | levelText.content = rawPrice.toFixed(vizState.pricePrecision);
52 | levelText.name = `levelText_${bandBottomPixel}`;
53 | // draw a light line across the chart at that level
54 | const priceLine = new Path({
55 | name: `priceLine_${curLevel}`,
56 | segments: [new Point(60, bandBottomPixel), new Point(vizState.canvasWidth, bandBottomPixel)],
57 | strokeColor: new Color(0, 188, 212, 0.72),
58 | strokeWidth: 0.5,
59 | });
60 |
61 | curLevel -= levelSpacing;
62 | }
63 | };
64 |
65 | /**
66 | * Adds a new trade to the visualization, connecting the line between it and previous trades (if they exist).
67 | */
68 | export const renderNewTrade = () => {
69 | // TODO
70 | };
71 |
72 | /**
73 | * Displays a transitive notification of an order placement, modification, or removal on the visualization. The intensity of the
74 | * displayed notification is scaled according to the size of the modification in comparison to the rest of the visible book.
75 | */
76 | export const renderOrderNotification = () => {
77 | // TODO
78 | };
79 |
80 | /**
81 | * Returns an array of all rendered path elements of the paperscope that are trade markers.
82 | */
83 | export const getTradeNotifications = paperscope =>
84 | paperscope.project.activeLayer.children.filter(item => item.name && item.name.includes('trade-'));
85 |
86 | /**
87 | * Sets up some initial state for the paper canvas.
88 | */
89 | export const initPaperCanvas = vizState => {
90 | const { Color, Path, Point, PointText } = vizState.paperscope;
91 |
92 | vizState.paperscope.activate();
93 | // create two paths that will draw price lines
94 | const bidTradeLine = new Path({
95 | segments: [],
96 | selected: false,
97 | });
98 | bidTradeLine.name = 'bidTradeLine';
99 | bidTradeLine.strokeColor = 'blue'; // TODO: Make config option
100 | bidTradeLine.data.pointMeta = []; // create a space to hold price/timestamp data of trades to be used for re-scaling
101 |
102 | const askTradeLine = new Path({
103 | segments: [],
104 | selected: false,
105 | });
106 | askTradeLine.name = 'askTradeLine';
107 | askTradeLine.strokeColor = 'red'; // TODO: Make config option
108 | askTradeLine.data.pointMeta = []; // create a space to hold price/timestamp data of trades to be used for re-scaling
109 |
110 | // set up a crosshair to show currently hovered price/timestamp and display information about it
111 | const verticalCrosshair = new Path({
112 | name: 'verticalCrosshair',
113 | segments: [new Point(0, 0), new Point(0, vizState.canvasHeight)],
114 | strokeColor: new Color(0, 188, 212, 0.22),
115 | strokeWidth: 0.5,
116 | });
117 | const horizontalCrosshair = new Path({
118 | name: 'horizontalCrosshair',
119 | segments: [new Point(0, 0), new Point(vizState.canvasWidth, 0)],
120 | strokeColor: new Color(0, 188, 212, 0.22),
121 | strokeWidth: 0.5,
122 | });
123 |
124 | // create area to display currently hovered price, timestamp, and volume
125 | const timestampText = new PointText(new Point(vizState.canvasWidth - 150, 10));
126 | timestampText.fillColor = vizState.textColor;
127 | timestampText.name = 'timestampText';
128 | timestampText.fontSize = '12px';
129 | const priceRangeText = new PointText(new Point(vizState.canvasWidth - 150, 25));
130 | priceRangeText.fillColor = vizState.textColor;
131 | priceRangeText.name = 'priceRangeText';
132 | priceRangeText.fontSize = '12px';
133 | const curVolumeText = new PointText(new Point(vizState.canvasWidth - 150, 40));
134 | curVolumeText.fillColor = vizState.textColor;
135 | curVolumeText.name = 'curVolumeText';
136 | curVolumeText.fontSize = '12px';
137 |
138 | // set up mouse movement listener to move crosshair and update data
139 | vizState.paperscope.project.view.onMouseMove = e => {
140 | const { x, y } = e.point;
141 | vizState.hoveredX = x;
142 | vizState.hoveredY = y;
143 | updateTextInfo(vizState);
144 | };
145 |
146 | // start creating the bounding rectangle
147 | vizState.paperscope.project.view.onMouseDown = e => {
148 | vizState.firstZoomRectangleCorner = e.point;
149 | vizState.zoomRectangle = new Path.Rectangle(e.point, e.point);
150 | vizState.zoomRectangle.fillColor = new Color(200, 200, 200, 0.4);
151 | };
152 |
153 | // set up the zoom rectangle handler
154 | vizState.paperscope.project.view.onMouseDrag = e => {
155 | vizState.zoomRectangle.remove();
156 | vizState.zoomRectangle = new Path.Rectangle(vizState.firstZoomRectangleCorner, e.point);
157 | vizState.zoomRectangle.fillColor = new Color(200, 200, 200, 0.4);
158 | };
159 |
160 | // zoom into the selected region when the mouse is released
161 | vizState.paperscope.project.view.onMouseUp = e => {
162 | zoomToRectangle(vizState, e.point);
163 | };
164 |
165 | // draw the axis and price scales
166 | renderScales(vizState);
167 | };
168 |
169 | /**
170 | * Zooms into the area selected by the user
171 | */
172 | export const zoomToRectangle = (vizState, finalPoint) => {
173 | if (!vizState.zoomRectangle) {
174 | return;
175 | }
176 | vizState.zoomRectangle.remove();
177 | vizState.zoomRectangle = null;
178 |
179 | // ignore extremely tiny/accidental zooms
180 | const xDiff = vizState.firstZoomRectangleCorner.x - finalPoint.x;
181 | const yDiff = vizState.firstZoomRectangleCorner.y - finalPoint.y;
182 | if (Math.abs(xDiff) <= 3 || Math.abs(yDiff) <= 3) return;
183 |
184 | const startPrice = getPriceFromPixel(vizState, vizState.firstZoomRectangleCorner.y);
185 | const startTime = getTimestampFromPixel(vizState, vizState.firstZoomRectangleCorner.x);
186 | const endPrice = getPriceFromPixel(vizState, finalPoint.y);
187 | const endTime = getTimestampFromPixel(vizState, finalPoint.x);
188 |
189 | if (startPrice > endPrice) {
190 | vizState.minPrice = endPrice.toFixed(vizState.pricePrecision);
191 | vizState.maxPrice = startPrice.toFixed(vizState.pricePrecision);
192 | } else {
193 | vizState.maxPrice = endPrice.toFixed(vizState.pricePrecision);
194 | vizState.minPrice = startPrice.toFixed(vizState.pricePrecision);
195 | }
196 |
197 | if (startTime > endTime) {
198 | vizState.minTimestamp = endTime;
199 | vizState.maxTimestamp = startTime;
200 | } else {
201 | vizState.maxTimestamp = endTime;
202 | vizState.minTimestamp = startTime;
203 | }
204 |
205 | vizState.manualZoom = true;
206 | if (!vizState.resetButtom) drawResetZoomButton(vizState);
207 | histRender(vizState, vizState.nativeCanvas, true);
208 | };
209 |
210 | /**
211 | * Creates a `Reset Zoom` button at the top-left of the visualization that can be used to reset the zoom back to default
212 | */
213 | export const drawResetZoomButton = vizState => {
214 | if (vizState.resetButton) return;
215 | const { Color, Path, Point, PointText } = vizState.paperscope;
216 |
217 | vizState.resetButton = new Path.Rectangle(new Point(70, 20), new Point(147, 40));
218 | vizState.resetButton.fillColor = new Color(200, 200, 200, 0.22);
219 | vizState.resetButton.onMouseDown = e => {
220 | resetZoom(vizState);
221 | };
222 |
223 | vizState.resetText = new PointText(new Point(75, 35));
224 | vizState.resetText.onMouseDown = e => {
225 | resetZoom(vizState);
226 | };
227 | vizState.resetText.fillColor = vizState.textColor;
228 | vizState.resetText.name = 'priceRangeText';
229 | vizState.resetText.fontSize = '12px';
230 | vizState.resetText.content = 'Reset Zoom';
231 | };
232 |
233 | /**
234 | * Re-calculates optimal zoom levels and re-renders them into the visualization
235 | */
236 | export const resetZoom = vizState => {
237 | if (vizState.resetButton) {
238 | vizState.resetButton.remove();
239 | vizState.resetText.remove();
240 | }
241 | vizState.resetButton = null;
242 | vizState.resetText = null;
243 |
244 | vizState.minTimestamp = _.first(vizState.priceLevelUpdates).timestamp;
245 | vizState.maxTimestamp = _.last(vizState.priceLevelUpdates).timestamp + 10 * 1000;
246 | if (vizState.trades.length > 0) {
247 | vizState.minPrice = _.minBy(vizState.trades, trade => +trade.price).price * 0.995;
248 | vizState.maxPrice = _.maxBy(vizState.trades, trade => +trade.price).price * 1.005;
249 | } else {
250 | vizState.minPrice = vizState.initialMinPrice;
251 | vizState.maxPrice = vizState.initialMaxPrice;
252 | }
253 | vizState.manualZoom = false;
254 |
255 | histRender(vizState, vizState.nativeCanvas, true);
256 | };
257 |
258 | /**
259 | * Updates the displayed price, timestamp, and volume information in the top-right corner of the visualization
260 | */
261 | export const updateTextInfo = vizState => {
262 | const x = vizState.hoveredX;
263 | const y = vizState.hoveredY;
264 | const timestamp = getTimestampFromPixel(vizState, x);
265 | const price = getPriceFromPixel(vizState, y);
266 |
267 | // update crosshair data
268 | const verticalSegments =
269 | vizState.paperscope.project.activeLayer.children['verticalCrosshair'].segments;
270 | verticalSegments[0].point.x = x;
271 | verticalSegments[1].point.x = x;
272 |
273 | const horizontalSegments =
274 | vizState.paperscope.project.activeLayer.children['horizontalCrosshair'].segments;
275 | horizontalSegments[0].point.y = y;
276 | horizontalSegments[1].point.y = y;
277 |
278 | // update text fields
279 | vizState.paperscope.project.activeLayer.children['timestampText'].content = new Date(timestamp)
280 | .toString()
281 | .split(' ')[4];
282 | const bandPriceSpan = (+vizState.maxPrice - +vizState.minPrice) / vizState.priceGranularity;
283 | const hoveredBandIndex = getBandIndex(vizState, price);
284 | const bandBottomPrice = +vizState.minPrice + bandPriceSpan * hoveredBandIndex;
285 | const bandTopPrice = bandBottomPrice + bandPriceSpan;
286 | vizState.paperscope.project.activeLayer.children[
287 | 'priceRangeText'
288 | ].content = `${bandBottomPrice.toFixed(8)} - ${bandTopPrice.toFixed(8)}`;
289 | vizState.paperscope.project.activeLayer.children['curVolumeText'].content =
290 | vizState.activeBands[hoveredBandIndex].volume;
291 | };
292 |
293 | /**
294 | * Draws a marker on the visualizaiton indicating that a trade took place, its bid/ask status, and its size.
295 | * Also updates the trade lines.
296 | */
297 | export const renderTradeNotification = (vizState, fixedPrice, amountTraded, timestamp, isBid) => {
298 | vizState.paperscope.activate();
299 | // if the size of this trade is a new high, we need to re-scale all the old markers
300 | if (+amountTraded > +vizState.maxRenderedTrade) {
301 | const sizeDiff = vizState.maxRenderedTrade / amountTraded;
302 | const tradeNotifications = getTradeNotifications(vizState.paperscope);
303 | tradeNotifications.forEach(item => item.scale(sizeDiff));
304 |
305 | vizState.maxRenderedTrade = amountTraded;
306 | }
307 |
308 | const { x, y } = gpp(vizState, timestamp, fixedPrice);
309 | const priceLine =
310 | vizState.paperscope.project.activeLayer.children[isBid ? 'bidTradeLine' : 'askTradeLine'];
311 |
312 | // draw an additional point to keep the price line squared if this isn't the first point
313 | if (priceLine.data.pointMeta.length !== 0) {
314 | const lastPrice = _.last(priceLine.data.pointMeta).price;
315 | const point = new vizState.paperscope.Point(x, getPixelY(vizState, lastPrice));
316 | priceLine.data.pointMeta.push({ timestamp, price: lastPrice });
317 | priceLine.add(point);
318 | }
319 |
320 | // add the new trade to its corresponding line
321 | const point = new vizState.paperscope.Point(x, y);
322 | priceLine.add(point);
323 | priceLine.data.pointMeta.push({ timestamp, price: fixedPrice });
324 |
325 | const radius = (amountTraded / vizState.maxRenderedTrade) * vizState.maxTradeMarketRadius;
326 | // don't bother drawing it if its diameter is less than a pixel
327 | if (radius < 0.5) {
328 | return;
329 | }
330 |
331 | const notification = new vizState.paperscope.Path.Circle(
332 | new vizState.paperscope.Point(x, y),
333 | radius
334 | );
335 | notification.name = `trade-${timestamp}_${fixedPrice}`;
336 | notification.fillColor = isBid ? 'blue' : 'red';
337 | // print out information about the trade when hovered
338 | notification.onMouseEnter = e => {
339 | renderTradeHover(vizState, e.point, e.target.area, e.target.name);
340 | };
341 | // and remove it when unhovered
342 | notification.onMouseLeave = e => {
343 | hideTradeHover(vizState);
344 | };
345 |
346 | // reset the status of the point line extension
347 | if (isBid) {
348 | vizState.bidTradeLineExtended = false;
349 | } else {
350 | vizState.askTradeLineExtended = false;
351 | }
352 | };
353 |
354 | /**
355 | * Displays an info box containing data about the currently hovered trade notification.
356 | */
357 | export const renderTradeHover = (vizState, { x, y }, area, name) => {
358 | const { Point, PointText } = vizState.paperscope;
359 | // determine the start location of the notification
360 | let displayX, displayY;
361 | if (x > 160) {
362 | displayX = x - 50;
363 | } else {
364 | displayX = x + 25;
365 | }
366 |
367 | if (y > 50) {
368 | displayY = y - 25;
369 | } else {
370 | displayY = y + 25;
371 | }
372 |
373 | const volumeText = new PointText(new Point(displayX, displayY));
374 | const volume =
375 | (Math.sqrt(area / Math.PI) / vizState.maxTradeMarketRadius) * vizState.maxRenderedTrade;
376 | volumeText.content = '~ ' + volume.toFixed(vizState.pricePrecision);
377 | volumeText.fontSize = '11px';
378 | volumeText.fillColor = vizState.textColor;
379 | volumeText.name = 'volumeText';
380 |
381 | // [timestamp, fixedPrice]
382 | const split = name.split('-')[1].split('_');
383 | const timeText = new PointText(new Point(displayX, displayY - 15));
384 | timeText.content = new Date(+split[0]).toString().split(' ')[4];
385 | timeText.fontSize = '11px';
386 | timeText.fillColor = vizState.textColor;
387 | timeText.name = 'timeText';
388 | };
389 |
390 | /**
391 | * Removes the displayed information about the previously hovered trade notification.
392 | */
393 | export const hideTradeHover = vizState => {
394 | const item = vizState.paperscope.project.activeLayer.children['volumeText'];
395 | if (item) {
396 | vizState.paperscope.project.activeLayer.children['timeText'].remove();
397 | item.remove();
398 | }
399 | };
400 |
401 | /**
402 | * Triggered every price update. In order to keep the trade lines from crisscrossing, extend them out every price update.
403 | * If an extension point has already been drawn, modifies its position rather than drawing another one to keep things clean.
404 | */
405 | export const extendTradeLines = (vizState, timestamp) => {
406 | const bidLine = vizState.paperscope.project.activeLayer.children['bidTradeLine'];
407 | const askLine = vizState.paperscope.project.activeLayer.children['askTradeLine'];
408 |
409 | if (vizState.bidTradeLineExtended) {
410 | // already have a reference point, so find it for each of the lines and alter its position
411 | bidLine.segments[bidLine.segments.length - 1].point.x = getPixelX(vizState, timestamp);
412 | bidLine.segments[bidLine.segments.length - 1].point.y = getPixelY(
413 | vizState,
414 | _.last(bidLine.data.pointMeta).price
415 | );
416 | } else if (bidLine.data.pointMeta.length > 0) {
417 | // we have no reference point, so add a new one for each of the lines using the price of the last trade point
418 | const lastPrice = _.last(bidLine.data.pointMeta).price;
419 | const { x, y } = gpp(vizState, timestamp, lastPrice);
420 | bidLine.data.pointMeta.push({ timestamp: timestamp, price: lastPrice });
421 | bidLine.add(new vizState.paperscope.Point(x, y));
422 |
423 | // make sure to remember that we added this reference point for next time
424 | vizState.bidTradeLineExtended = true;
425 | }
426 |
427 | if (vizState.askTradeLineExtended) {
428 | askLine.segments[askLine.segments.length - 1].point.x = getPixelX(vizState, timestamp);
429 | askLine.segments[askLine.segments.length - 1].point.y = getPixelY(
430 | vizState,
431 | _.last(askLine.data.pointMeta).price
432 | );
433 | } else if (askLine.data.pointMeta.length > 0) {
434 | const lastPrice = _.last(askLine.data.pointMeta).price;
435 | const { x, y } = gpp(vizState, timestamp, lastPrice);
436 | askLine.data.pointMeta.push({ timestamp: timestamp, price: lastPrice });
437 | askLine.add(new vizState.paperscope.Point(x, y));
438 |
439 | vizState.askTradeLineExtended = true;
440 | }
441 | };
442 |
443 | /**
444 | * Moves all of the currently drawn trade markers to their proper locations based on the current `vizState`.
445 | */
446 | export const reRenderTrades = vizState => {
447 | vizState.paperscope.activate();
448 |
449 | // hide any previously visible trade notification since it's likely no longer hovered
450 | hideTradeHover(vizState);
451 |
452 | // move all of the circular trade markers
453 | getTradeNotifications(vizState.paperscope).forEach(item => {
454 | // get the timestamp and price out of the item's name
455 | const split = item.name.split('-')[1].split('_');
456 | const { x, y } = gpp(vizState, +split[0], split[1]);
457 | item.position = new vizState.paperscope.Point(x, y);
458 | });
459 |
460 | // move all of the points of the price line as well
461 | const bidLine = vizState.paperscope.project.activeLayer.children['bidTradeLine'];
462 | const askLine = vizState.paperscope.project.activeLayer.children['askTradeLine'];
463 |
464 | bidLine.segments.forEach((segment, i) => {
465 | const { timestamp, price } = bidLine.data.pointMeta[i];
466 | const { x, y } = gpp(vizState, timestamp, price);
467 | segment.point.x = x;
468 | segment.point.y = y;
469 | });
470 | askLine.segments.forEach((segment, i) => {
471 | const { timestamp, price } = askLine.data.pointMeta[i];
472 | const { x, y } = gpp(vizState, timestamp, price);
473 | segment.point.x = x;
474 | segment.point.y = y;
475 | });
476 | };
477 |
478 | /**
479 | * Updates the new top-of-book bid or ask price // TODO
480 | */
481 | export const renderNewBestPrice = vizState => {
482 | // TODO?
483 | };
484 |
--------------------------------------------------------------------------------