├── .vscode └── settings.json ├── src ├── index.js ├── index.css ├── router.js ├── react-orderbook │ ├── util.js │ ├── Orderbook │ │ ├── BottomBar.js │ │ ├── histRender.js │ │ ├── Orderbook.js │ │ ├── render.js │ │ └── paperRender.js │ ├── index.js │ └── calc.js └── routes │ └── IndexPage.js ├── .gitignore ├── .roadhogrc ├── LICENSE ├── notes.txt ├── package.json ├── public ├── index.html └── about.html ├── .eslintrc └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": false 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import dva from 'dva'; 2 | 3 | // 1. Initialize 4 | const app = dva(); 5 | 6 | // 4. Router 7 | app.router(require('./router')); 8 | 9 | // 5. Start 10 | app.start('#root'); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # production 7 | /dist 8 | 9 | # misc 10 | .DS_Store 11 | npm-debug.log* 12 | yarn.lock 13 | -------------------------------------------------------------------------------- /.roadhogrc: -------------------------------------------------------------------------------- 1 | { 2 | "entry": "src/index.js", 3 | "env": { 4 | "development": { 5 | "extraBabelPlugins": [ 6 | "dva-hmr", 7 | "transform-runtime" 8 | ] 9 | }, 10 | "production": { 11 | "extraBabelPlugins": [ 12 | "transform-runtime" 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #080808; 3 | color: #ababab; 4 | } 5 | 6 | #obWrapper { 7 | width: 250px; 8 | background-color: #eee; 9 | } 10 | 11 | #paperCanvas { 12 | border: 1px solid #ccc; 13 | display: inline; 14 | margin-right: -100%; 15 | background-color: rgba(0, 0, 0, 0); 16 | } 17 | 18 | #nativeCanvas { 19 | border: 1px solid #ccc; 20 | display: inline; 21 | background-color: rgba(0, 0, 0, 0); 22 | } 23 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Router, Route } from 'dva/router'; 4 | import IndexPage from './routes/IndexPage'; 5 | 6 | const RouterConfig = ({ history }) => ( 7 | 8 | 9 | 10 | ); 11 | 12 | RouterConfig.propTypes = { 13 | history: PropTypes.any.isRequired, 14 | }; 15 | 16 | export default RouterConfig; 17 | -------------------------------------------------------------------------------- /src/react-orderbook/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Misc. functions and constants used in multiple parts of the application 3 | */ 4 | 5 | import PropTypes from 'prop-types'; 6 | 7 | export const ChangeShape = { 8 | modificiation: PropTypes.shape({ 9 | price: PropTypes.string.isRequired, 10 | newAmount: PropTypes.string.isRequired, 11 | isBid: PropTypes.bool.isRequired, 12 | }), 13 | removal: PropTypes.shape({ 14 | price: PropTypes.string.isRequire, 15 | isBid: PropTypes.bool.isRequired, 16 | }), 17 | newTrade: PropTypes.shape({ 18 | price: PropTypes.string.isRequired, 19 | amountTraded: PropTypes.string.isRequired, 20 | wasBidFilled: PropTypes.bool.isRequired, 21 | }), 22 | }; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Casey Primozic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | ========= 2 | Poloniex DOM Website Visualizer 3 | ========= 4 | 5 | The idea of this project is to create a website that vizualizes the Poloniex order book using the public API, includes some tools that may be useful to the colorful altcoin trading community, and slap a few ads on it to maybe add a couple bucks to our monthly passive income stream. 6 | 7 | I'd also like to create a nice JS-based DOM viewer in the form of a React component that can be used with any kind of generic DOM data. The end goal of this is to include it in Tickgrinder. 8 | 9 | ========== 10 | ADDITIONAL FUNCTIONALITY 11 | ========== 12 | 13 | - I'd love to see a "whalewatch" feature where you can filter out orders that are smaller than a certain amount or, even better, add addional markers to orders that exceed a certain value. 14 | - Even better, since it's impossible to really reduce the size of a position once it's been created on Polo, keep track of where the whales are and the size of their initial order 15 | - Maybe even have a functionality to keep track of the sizes of individual changes in the orders so you can track which people are getting in/out of a certain price level 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "roadhog server", 5 | "build": "roadhog build", 6 | "lint": "eslint --ext .js src test" 7 | }, 8 | "engines": { 9 | "install-node": "6.9.2" 10 | }, 11 | "dependencies": { 12 | "chroma-js": "1.2.2", 13 | "dva": "^1.2.1", 14 | "dva-loading": "^0.2.0", 15 | "lodash": "4.17.19", 16 | "material-ui": "0.17.1", 17 | "paper": "0.10.3", 18 | "react": "^15.4.0", 19 | "react-dom": "^15.4.0", 20 | "react-tap-event-plugin": "*" 21 | }, 22 | "devDependencies": { 23 | "babel-runtime": "^6.9.2", 24 | "babel-cli": "*", 25 | "babel-core": "*", 26 | "babel-eslint": "^7.1.1", 27 | "babel-plugin-transform-flow-strip-types": "*", 28 | "babel-preset-es2015": "*", 29 | "babel-preset-stage-3": "*", 30 | "babel-register": "*", 31 | "babel-plugin-transform-react-jsx": "*", 32 | "babel-plugin-dva-hmr": "^0.3.2", 33 | "babel-plugin-transform-runtime": "^6.9.0", 34 | "eslint": "^3.12.2", 35 | "eslint-config-airbnb": "^13.0.0", 36 | "eslint-plugin-flowtype": "^2.50.0", 37 | "eslint-plugin-import": "^2.2.0", 38 | "eslint-plugin-jsx-a11y": "^2.2.3", 39 | "eslint-plugin-react": "^6.8.0", 40 | "expect": "^1.20.2", 41 | "husky": "^0.12.0", 42 | "redbox-react": "^1.3.2", 43 | "roadhog": "^0.5.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Poloniex Orderbook Visualizer 7 | 8 | 9 | 10 | 60 | 61 | 62 | 63 |
64 | 65 | 66 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "flowtype/boolean-style": [2, "boolean"], 4 | "flowtype/define-flow-type": 1, 5 | "flowtype/delimiter-dangle": [2, "always-multiline"], 6 | "flowtype/generic-spacing": [2, "never"], 7 | "flowtype/no-primitive-constructor-types": 2, 8 | "flowtype/object-type-delimiter": [2, "comma"], 9 | "flowtype/require-valid-file-annotation": 2, 10 | "flowtype/semi": [2, "always"], 11 | "flowtype/space-before-generic-bracket": [2, "never"], 12 | "flowtype/type-id-match": [2, "[A-Z][a-z0-9]+"], 13 | "flowtype/union-intersection-spacing": [2, "always"], 14 | "flowtype/use-flow-type": 1, 15 | "flowtype/valid-syntax": 1, 16 | "indent": [2, 2, { "SwitchCase": 1 }], 17 | "quotes": [2, "single", { "avoidEscape": true }], 18 | "react/display-name": 0, 19 | "react/forbid-component-props": 0, 20 | "react/forbid-prop-types": 0, 21 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 22 | "react/jsx-indent": [1, 2], 23 | "react/jsx-indent-props": [1, 2], 24 | "react/jsx-max-props-per-line": [1, { "maximum": 3, "when": "multiline" }], 25 | "react/jsx-handler-names": 0, 26 | "react/jsx-sort-props": 0, 27 | "react/jsx-no-bind": 0, 28 | "react/no-multi-comp": 0, 29 | "react/no-set-state": 0, 30 | "react/sort-prop-types": 0, 31 | "linebreak-style": [2, "unix"], 32 | "semi": [2, "always"], 33 | "comma-dangle": [2, "only-multiline"], 34 | "no-console": 0, 35 | "no-unused-vars": [2, { "vars": "all", "args": "none" }], 36 | "no-multiple-empty-lines": [2, { "max": 1 }], 37 | "no-var": "error", 38 | "prefer-const": [ 39 | "error", 40 | { 41 | "destructuring": "any", 42 | "ignoreReadBeforeAssign": false 43 | } 44 | ] 45 | }, 46 | "env": { 47 | "es6": true, 48 | "browser": true 49 | }, 50 | "extends": ["eslint:recommended", "plugin:react/all"], 51 | "parser": "babel-eslint", 52 | "parserOptions": { 53 | "sourceType": "module", 54 | "ecmaFeatures": { 55 | "experimentalObjectRestSpread": true, 56 | "jsx": true 57 | }, 58 | "ecmaVersion": 7 59 | }, 60 | "plugins": ["flowtype", "react"], 61 | "globals": { 62 | "require": true, 63 | "module": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cryptoviz 2 | 3 | ![](https://tokei.rs/b1/github/Ameobea/cryptoviz) 4 | ![](https://tokei.rs/b1/github/Ameobea/cryptoviz?category=files) 5 | 6 | Cryptoviz is an orderbook visualization tool for the Poloniex exchange. It provides a dynamic Depth-Of-Market (DOM) view that provides a much higher level of detail than traditionally used visualizations such as candlestick charts. You can use this tool yourself on [Cryptoviz](https://cryptoviz.net/). 7 | 8 | *Example of CryptoViz Interface:* 9 | ![](https://ameo.link/u/4do.png) 10 | 11 | ## Overview 12 | 13 | CryptoViz uses the Poloniex WebSocket API to receive live order data directly from the exchange. Internally, it maintains a copy of the full orderbook including the levels where volume lies and the amount of volume at those levels. These volume levels are then grouped together into bands and displayed on a canvas. 14 | 15 | In addition to showing volume levels, the tool also draws trades and renders indicators varying in size according to how large the trade was. 16 | 17 | ### Cool Technical Details 18 | Cryptoviz makes use of the [PaperJS](http://paperjs.org/) library to render the visualization UI and the trade lines/indicators. The trade lines are rendered on a separate canvas that is displayed beneath the trade lines canvas for performance reasons. 19 | 20 | We avoid re-rendering the entire canvas every update by making use of the fact that the left-side of the chart stays constant with new volume bands only ever being added to the right side. In cases where we overrun the canvas, zoom, or change currencies, all received events are re-played in sequence to rebuild the visualization from scratch. 21 | 22 | The site itself makes light use of React and the [DvaJS](https://github.com/dvajs/dva) framework as well as Material Design for the interface at the bottom. 23 | 24 | ## Development 25 | Setting up the dev environmnent for this project is easy! All you really have to do is clone the repository, install dependencies with `npm install` or `yarn`, and run `npm start` to launch the local development server. There is no configuration options that need to be set or data sources to configure; the tool pulls its data directly from the public Poloniex API. 26 | 27 | ## Contributing 28 | I'd love to work with you to add a feature, fix a bug, or implement Cryptoviz in your own project! If you'd like to make a change or find an issue that needs fixing, please open an issue. 29 | -------------------------------------------------------------------------------- /src/react-orderbook/Orderbook/BottomBar.js: -------------------------------------------------------------------------------- 1 | //! Settings and information displayed under the visualization 2 | 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import SelectField from 'material-ui/SelectField'; 6 | import Slider from 'material-ui/Slider'; 7 | import MenuItem from 'material-ui/MenuItem'; 8 | 9 | class BottomBar extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | 13 | this.handleCurrencySelect = this.handleCurrencySelect.bind(this); 14 | this.handleGranularitySelect = this.handleGranularitySelect.bind(this); 15 | this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this); 16 | this.updateHoveredGranularity = this.updateHoveredGranularity.bind(this); 17 | 18 | this.state = { 19 | selectedCurrency: 'BTC_ETH', 20 | selectedColorScheme: 'Blue Moon', 21 | }; 22 | } 23 | 24 | handleCurrencySelect(e, i, value) { 25 | this.setState({ selectedCurrency: value }); 26 | if (value != this.state.selectedCurrency) this.props.onSettingChange({ currency: value }); 27 | } 28 | 29 | updateHoveredGranularity(e, newValue) { 30 | this.setState({ hoveredGranularity: newValue }); 31 | } 32 | 33 | handleGranularitySelect() { 34 | let newGranularity; 35 | switch (this.state.hoveredGranularity) { 36 | case 0.0: 37 | newGranularity = 15; 38 | break; 39 | case 0.1: 40 | newGranularity = 20; 41 | break; 42 | case 0.2: 43 | newGranularity = 30; 44 | break; 45 | case 0.3: 46 | newGranularity = 50; 47 | break; 48 | case 0.4: 49 | newGranularity = 75; 50 | break; 51 | case 0.5: 52 | newGranularity = 100; 53 | break; 54 | case 0.6: 55 | newGranularity = 135; 56 | break; 57 | case 0.7: 58 | newGranularity = 175; 59 | break; 60 | case 0.8: 61 | newGranularity = 300; 62 | break; 63 | case 0.9: 64 | newGranularity = 350; 65 | break; 66 | case 1.0: 67 | newGranularity = 500; 68 | break; 69 | } 70 | 71 | this.props.onSettingChange({ priceGranularity: newGranularity }); 72 | } 73 | 74 | handleColorSchemeChange(e, i, newSchemeName) { 75 | if (newSchemeName != this.state.selectedColorScheme) { 76 | this.props.onSettingChange({ colorScheme: newSchemeName }); 77 | this.setState({ selectedColorScheme: newSchemeName }); 78 | } 79 | } 80 | 81 | render() { 82 | const { currencies, colorSchemeNames } = this.props; 83 | 84 | const currencyItems = currencies.map(currency => ( 85 | 86 | )); 87 | const colorSchemeItems = colorSchemeNames.map(name => ( 88 | 89 | )); 90 | 91 | return ( 92 |
93 | 94 | 95 | 96 | 107 | 108 | 119 | 120 | 131 | 132 | 139 | 140 | 141 |
97 |
98 | 103 | {currencyItems} 104 | 105 |
106 |
109 |
110 |

{'Price Level Granularity'}

111 | 117 |
118 |
121 |
122 | 127 | {colorSchemeItems} 128 | 129 |
130 |
133 |

134 | 135 | {'How to Use'} 136 | 137 |

138 |
142 | 143 | 148 |
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 |
91 | 103 |
104 | ); 105 | } 106 | } 107 | 108 | OrderbookVisualizer.propTypes = { 109 | bookModificationCallbackExecutor: PropTypes.func.isRequired, 110 | bookRemovalCallbackExecutor: PropTypes.func.isRequired, 111 | currencies: PropTypes.arrayOf(PropTypes.string).isRequired, 112 | initialBook: PropTypes.object.isRequired, 113 | initialTimestamp: PropTypes.number.isRequired, 114 | maxPrice: PropTypes.string.isRequired, 115 | minPrice: PropTypes.string.isRequired, 116 | newTradeCallbackExecutor: PropTypes.func.isRequired, 117 | onCurrencyChange: PropTypes.func.isRequired, 118 | orderbookCanvasHeight: PropTypes.number, 119 | orderbookCanvasWidth: PropTypes.number, 120 | pricePrecision: PropTypes.number.isRequired, 121 | }; 122 | 123 | const body = document.body; 124 | const html = document.documentElement; 125 | 126 | const height = Math.max( 127 | body.scrollHeight, 128 | body.offsetHeight, 129 | html.clientHeight, 130 | html.scrollHeight, 131 | html.offsetHeight 132 | ); 133 | 134 | OrderbookVisualizer.defaultProps = { 135 | orderbookCanvasHeight: 0.86 * height, 136 | orderbookCanvasWidth: document.getElementsByTagName('body')[0].offsetWidth, 137 | }; 138 | 139 | export default OrderbookVisualizer; 140 | -------------------------------------------------------------------------------- /public/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | About CryptoViz 7 | 8 | 39 | 40 | 41 |
42 |

Using CryptoViz

43 |

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 | { 210 | this.nativeCanvas = canvas; 211 | }} 212 | style={{ marginRight: '-100%' }} 213 | width={this.vizState.canvasWidth} 214 | /> 215 | 216 | {/* 217 | PaperJS mutates the canvas DOM object directly, which really makes React unhappy. It seems to do this by injecting in styles 218 | rather than changing attributes themselves, so I've switched both the height and width into the style rather than using them 219 | as attributes in order to fix an issue where this screwed up the canvas badly. 220 | */} 221 | { 224 | this.paperCanvas = canvas; 225 | }} 226 | style={{ 227 | marginLeft: '-100%', 228 | height: this.vizState.canvasHeight, 229 | width: this.vizState.canvasWidth, 230 | }} 231 | /> 232 |
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 | --------------------------------------------------------------------------------