├── .babelrc
├── .gitignore
├── .npmignore
├── DepthChart.js
├── OrderBook.js
├── PriceChart.js
├── README.md
├── TradeHistory.js
├── demo
├── app
│ ├── Root.js
│ ├── api.js
│ ├── constants
│ │ ├── actionCreators.js
│ │ ├── actionTypes.js
│ │ └── selectors.js
│ ├── containers
│ │ ├── RealTimeOrderBook.js
│ │ └── RealTimeTradeHistory.js
│ ├── index.js
│ ├── reducers
│ │ ├── book.js
│ │ ├── error.js
│ │ ├── feedStatus.js
│ │ ├── index.js
│ │ └── trades.js
│ ├── sagas
│ │ ├── bookSaga.js
│ │ ├── feedSaga.js
│ │ ├── index.js
│ │ └── tradesSaga.js
│ ├── store
│ │ ├── index.js
│ │ └── initialState.js
│ └── utils
│ │ ├── collect.js
│ │ ├── messages.js
│ │ └── serialize.js
├── favicon.ico
├── index.html
└── index.js
├── docs
├── GettingStarted.md
├── OrderBook.md
└── TradeHistory.md
├── package.json
├── src
├── DepthChart.js
├── OrderBook.js
├── PriceChart.js
├── TradeHistory.js
├── components
│ ├── PrettyPosition.js
│ ├── PrettyPrice.js
│ ├── PrettySize.js
│ ├── PrettyTimeStamp.js
│ ├── Spinner.js
│ ├── Spread.js
│ ├── TradingUIContentWrapper.js
│ ├── TradingUIHeader.js
│ ├── TradingUIOrder.js
│ ├── TradingUIOrderSizeBar.js
│ ├── TradingUIOrderTable.js
│ ├── TradingUIParent.js
│ ├── TradingUIScrollingContent.js
│ ├── TradingUIStickyContent.js
│ ├── TradingUITableHead.js
│ └── TradingUITableHeading.js
├── defaults
│ ├── colors.js
│ ├── getters.js
│ └── util.js
└── index.js
├── test
├── App.js
└── index.test.js
├── webpack.config.babel.js
├── webpack.server.babel.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | presets: ["es2015", "react"],
3 | plugins: [
4 | "ramda",
5 | "styled-jsx/babel",
6 | "glamorous-displayname",
7 | "transform-object-rest-spread"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.gitignore.io/api/node
2 |
3 | ### Node ###
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 |
11 | # Runtime data
12 | pids
13 | *.pid
14 | *.seed
15 | *.pid.lock
16 |
17 | # Directory for instrumented libs generated by jscoverage/JSCover
18 | lib-cov
19 |
20 | # Coverage directory used by tools like istanbul
21 | coverage
22 |
23 | # nyc test coverage
24 | .nyc_output
25 |
26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
27 | .grunt
28 |
29 | # Bower dependency directory (https://bower.io/)
30 | bower_components
31 |
32 | # node-waf configuration
33 | .lock-wscript
34 |
35 | # Compiled binary addons (http://nodejs.org/api/addons.html)
36 | build/Release
37 |
38 | # Dependency directories
39 | node_modules/
40 | jspm_packages/
41 |
42 | # Typescript v1 declaration files
43 | typings/
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 |
63 | # lib & dist
64 | lib
65 | dist
66 |
67 | # End of https://www.gitignore.io/api/node
68 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .*
2 | src
3 | test
4 | demo
5 | *.log
6 |
--------------------------------------------------------------------------------
/DepthChart.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/DepthChart.js')
2 |
--------------------------------------------------------------------------------
/OrderBook.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/OrderBook.js')
2 |
--------------------------------------------------------------------------------
/PriceChart.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/PriceChart.js')
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Trading UI
2 | A component library for trading applications.
3 |
4 |
5 |
6 | ### Disclaimer:
7 | There are some real performance issues with this right now and it is likely going to freeze up if you are trying to maintain a full order book in real-time.
8 | For level books or for just polling a full order book it should be more stable.
9 |
10 | ## Documentation
11 | * [Getting Started](/docs/GettingStarted.md)
12 | * [Order Book](/docs/OrderBook.md)
13 | * [Trade History](/docs/TradeHistory.md)
14 | * ~~[Depth Chart](/docs/DepthChart.md)~~
15 | * ~~[Price Chart](/docs/PriceChart.md)~~
16 |
17 | ## Demo & Examples
18 | Live demo: _coming soon..._
19 |
20 | To run the example locally, clone this repo and run:
21 | ```terminal
22 | $ npm install
23 | $ npm run start
24 | ```
25 | Then visit [localhost:3000](http://localhost:3000) in your browser.
26 |
27 | ## Installation
28 | Using npm:
29 | `$ npm install --save react-trading-ui`
30 |
31 | Using yarn
32 | `$ yarn add react-trading-ui`
33 |
34 | ## Usage
35 | ```javascript
36 | import {OrderBook, TradeHistory} from 'react-trading-ui'
37 | import {connect} from 'react-redux'
38 |
39 | const MyApp = ({book, trades}) => (
40 |
41 |
42 |
43 |
44 | )
45 |
46 | export default connect(
47 | state => ({
48 | book: state.book,
49 | trades: state.trades
50 | })
51 | )(MyApp)
52 | ```
53 |
54 | ## Contributing
55 | 1. Fork it!
56 | 2. Create your feature branch: `git checkout -b my-new-feature`
57 | 3. Commit your changes: `git commit -m 'add some feature'`
58 | 4. Push to the branch: `git push origin my-new-feature`
59 | 5. Submit a pull request!
60 |
61 | ## Acknowledgements
62 | * [@coinbase](https://github.com/coinbase)
63 |
--------------------------------------------------------------------------------
/TradeHistory.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/TradeHistory.js')
2 |
--------------------------------------------------------------------------------
/demo/app/Root.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import collect from './utils/collect'
4 | import RealTimeOrderBook from './containers/RealTimeOrderBook'
5 | // import RealTimeTradeHistory from './containers/RealTimeTradeHistory'
6 |
7 | class Root extends React.Component {
8 | componentWillMount () {
9 | this.props.connectFeed()
10 | }
11 |
12 | render () {
13 | return (
14 |
15 |
16 | {/* */}
17 |
22 |
23 | )
24 | }
25 | }
26 |
27 | export default collect(
28 | [['actions', 'connectFeed']]
29 | )(Root)
30 |
--------------------------------------------------------------------------------
/demo/app/api.js:
--------------------------------------------------------------------------------
1 | import serialize from './utils/serialize'
2 |
3 | const baseUrl = 'https://api.gdax.com/products'
4 | const feedUrl = 'wss://ws-feed.gdax.com'
5 |
6 | export const fetchBook = ({productId, ...params}) =>
7 | window.fetch(`${baseUrl}/${productId}/book?${serialize(params)}`)
8 | .then(resp => resp.json())
9 |
10 | export const fetchTrades = ({productId, ...params}) =>
11 | window.fetch(`${baseUrl}/${productId}/trades?${serialize(params)}`)
12 | .then(resp => resp.json())
13 |
14 | export const createFeed = () =>
15 | new window.WebSocket(feedUrl)
16 |
--------------------------------------------------------------------------------
/demo/app/constants/actionCreators.js:
--------------------------------------------------------------------------------
1 | import types from './actionTypes'
2 |
3 | export const fetchBook = (productId = 'ETH-USD', level = 3) => ({
4 | type: types.BOOK_REQUEST,
5 | payload: { productId, level }
6 | })
7 |
8 | export const fetchTrades = (productId = 'ETH-USD') => ({
9 | type: types.TRADES_REQUEST,
10 | payload: { productId }
11 | })
12 |
13 | export const connectFeed = (productIds = ['ETH-USD']) => ({
14 | type: types.FEED_CONNECT,
15 | payload: { productIds }
16 | })
17 |
--------------------------------------------------------------------------------
/demo/app/constants/actionTypes.js:
--------------------------------------------------------------------------------
1 | import scopeTypes from 'scope-types'
2 |
3 | const appTypes = [
4 | 'BOOK_REQUEST',
5 | 'BOOK_SUCCESS',
6 | 'BOOK_FAILURE',
7 | 'TRADES_REQUEST',
8 | 'TRADES_SUCCESS',
9 | 'TRADES_FAILURE',
10 | 'FEED_CONNECT'
11 | ]
12 |
13 | const feedTypes = [
14 | 'SOCKET_OPEN',
15 | 'SOCKET_CLOSE',
16 | 'SOCKET_ERROR',
17 | 'SOCKET_MESSAGE'
18 | ]
19 |
20 | export default {
21 | ...scopeTypes(appTypes),
22 | ...scopeTypes(feedTypes, 'Feed')
23 | }
24 |
--------------------------------------------------------------------------------
/demo/app/constants/selectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 |
3 | export const selectError = state => state.get('error')
4 |
5 | export const selectBook = state => state.get('book')
6 | export const selectAsks = createSelector(
7 | selectBook,
8 | book => book.get('asks').toArray()
9 | )
10 | export const selectBids = createSelector(
11 | selectBook,
12 | book => book.get('bids').toArray().reverse()
13 | )
14 |
15 | export const selectTrades = state => state.get('trades')
16 | export const selectFeedStatus = state => state.get('feedStatus')
17 |
--------------------------------------------------------------------------------
/demo/app/containers/RealTimeOrderBook.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import collect from '../utils/collect'
4 | import OrderBook from '../../../src/OrderBook'
5 | import colors from '../../../src/defaults/colors'
6 | import {selectAsks, selectBids} from '../constants/selectors'
7 |
8 | const orderBookStyles = {
9 | maxWidth: 'calc(100% - 5px)',
10 | borderStyle: 'solid',
11 | borderColor: colors.parentHoverBackground,
12 | borderWidth: '0 5px 0 0'
13 | }
14 |
15 | class RealTimeOrderBook extends React.Component {
16 | componentWillMount () {
17 | this.props.fetchBook()
18 | }
19 |
20 | render () {
21 | return (
22 | entry.get('price')}
27 | getSize={entry => entry
28 | .get('orderSizesById')
29 | .reduce((totalSize, orderSize) => totalSize + Number(orderSize), 0)
30 | }
31 | onClickOrder={order => console.log(order)}
32 | style={orderBookStyles}
33 | />
34 | )
35 | }
36 | }
37 |
38 | export default collect(
39 | [selectAsks, 'asks'],
40 | [selectBids, 'bids'],
41 | [['actions', 'fetchBook']]
42 | )(RealTimeOrderBook)
43 |
--------------------------------------------------------------------------------
/demo/app/containers/RealTimeTradeHistory.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import collect from '../utils/collect'
4 | import TradeHistory from '../../../src/TradeHistory'
5 | import {selectTrades} from '../constants/selectors'
6 |
7 | class RealTimeTradeHistory extends React.Component {
8 | componentWillMount () {
9 | this.props.fetchTrades()
10 | }
11 |
12 | render () {
13 | return (
14 | order.time}
17 | style={{
18 | right: 0,
19 | maxWidth: '50%',
20 | position: 'absolute'
21 | }}
22 | />
23 | )
24 | }
25 | }
26 |
27 | export default collect(
28 | [selectTrades, 'trades'],
29 | [['actions', 'fetchTrades']]
30 | )(RealTimeTradeHistory)
31 |
--------------------------------------------------------------------------------
/demo/app/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Provider} from 'react-redux'
3 |
4 | import store from './store'
5 | import Root from './Root'
6 |
7 | const App = () => (
8 |
9 |
10 |
11 | )
12 |
13 | export default App
14 |
--------------------------------------------------------------------------------
/demo/app/reducers/book.js:
--------------------------------------------------------------------------------
1 | import types from '../constants/actionTypes'
2 | import initialState from '../store/initialState'
3 | import { selectBook } from '../constants/selectors'
4 | import * as messages from '../utils/messages'
5 |
6 | const defaultState = selectBook(initialState)
7 |
8 | /* Book Balancing Logic:
9 | * https://docs.gdax.com/#real-time-order-book
10 | */
11 | export default (state = defaultState, action) => {
12 | switch (action.type) {
13 | case types.BOOK_SUCCESS :
14 | return messages.applyQueuedMessages(state, action.payload)
15 |
16 | case types.SOCKET_MESSAGE :
17 | return state.get('hasReceivedSnapshot')
18 | ? messages.applyMessage(state, action.payload)
19 | : messages.queueMessage(state, action.payload)
20 |
21 | default:
22 | return state
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/demo/app/reducers/error.js:
--------------------------------------------------------------------------------
1 | import types from '../constants/actionTypes'
2 | import initialState from '../store/initialState'
3 | import { selectError } from '../constants/selectors'
4 |
5 | const defaultState = selectError(initialState)
6 |
7 | export default (state = defaultState, action) => {
8 | switch (action.type) {
9 | case types.BOOK_FAILURE :
10 | return action.payload
11 |
12 | case types.TRADES_FAILURE :
13 | return action.payload
14 |
15 | case types.SOCKET_ERROR :
16 | return action.payload
17 |
18 | default:
19 | return state
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/demo/app/reducers/feedStatus.js:
--------------------------------------------------------------------------------
1 | import types from '../constants/actionTypes'
2 | import initialState from '../store/initialState'
3 | import { selectFeedStatus } from '../constants/selectors'
4 |
5 | const defaultState = selectFeedStatus(initialState)
6 |
7 | export default (state = defaultState, action) => {
8 | switch (action.type) {
9 | case types.FEED_CONNECT : return 0
10 | case types.SOCKET_OPEN : return 1
11 | case types.SOCKET_CLOSE : return 3
12 | default : return state
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/demo/app/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux-immutable'
2 |
3 | import error from './error'
4 | import book from './book'
5 | // import trades from './trades'
6 | import feedStatus from './feedStatus'
7 |
8 | export default combineReducers({
9 | error,
10 | book,
11 | // trades,
12 | feedStatus
13 | })
14 |
--------------------------------------------------------------------------------
/demo/app/reducers/trades.js:
--------------------------------------------------------------------------------
1 | import { pick } from 'ramda'
2 | import { List } from 'immutable'
3 |
4 | import types from '../constants/actionTypes'
5 | import initialState from '../store/initialState'
6 | import { selectTrades } from '../constants/selectors'
7 |
8 | const defaultState = selectTrades(initialState)
9 | const sanitizeMessage = pick([
10 | 'time',
11 | 'price',
12 | 'size',
13 | 'side'
14 | ])
15 |
16 | export default (state = defaultState, action) => {
17 | switch (action.type) {
18 | // case types.TRADES_SUCCESS :
19 | // return List(action.payload)
20 | //
21 | // case types.SOCKET_MESSAGE :
22 | // return action.payload.type === 'match'
23 | // ? state.unshift(sanitizeMessage(action.payload))
24 | // : state
25 |
26 | default:
27 | return state
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/demo/app/sagas/bookSaga.js:
--------------------------------------------------------------------------------
1 | import { call, put } from 'redux-saga/effects'
2 | import { fetchBook } from '../api'
3 | import types from '../constants/actionTypes'
4 |
5 | export default function * (action) {
6 | try {
7 | const book = yield call(fetchBook, action.payload)
8 | yield put({type: types.BOOK_SUCCESS, payload: book})
9 | } catch (err) {
10 | yield put({type: types.BOOK_FAILURE, payload: err.message})
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/demo/app/sagas/feedSaga.js:
--------------------------------------------------------------------------------
1 | import { eventChannel } from 'redux-saga'
2 | import { call, take, put } from 'redux-saga/effects'
3 | import { createFeed } from '../api'
4 | import types from '../constants/actionTypes'
5 |
6 | const connectFeed = ({productIds}) => eventChannel(
7 | emitter => {
8 | // subscribe
9 | const feed = createFeed()
10 |
11 | feed.onopen = e => {
12 | emitter({
13 | type: types.SOCKET_OPEN
14 | })
15 | feed.send(JSON.stringify({
16 | type: 'subscribe',
17 | product_ids: productIds
18 | }))
19 | }
20 |
21 | feed.onclose = e => emitter({
22 | type: types.SOCKET_CLOSE
23 | })
24 |
25 | feed.onerror = err => emitter({
26 | type: types.SOCKET_ERROR,
27 | payload: err
28 | })
29 |
30 | feed.onmessage = e => emitter({
31 | type: types.SOCKET_MESSAGE,
32 | payload: JSON.parse(e.data)
33 | })
34 |
35 | // unsubscribe
36 | return feed.close
37 | }
38 | )
39 |
40 | export default function * (action) {
41 | const channel = yield call(connectFeed, action.payload)
42 |
43 | while (true) {
44 | const action = yield take(channel)
45 | yield put(action)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/demo/app/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { takeEvery } from 'redux-saga/effects'
2 | import types from '../constants/actionTypes'
3 | import feedSaga from './feedSaga'
4 | import bookSaga from './bookSaga'
5 | // import tradesSaga from './tradesSaga'
6 |
7 | export default function * () {
8 | yield takeEvery(types.FEED_CONNECT, feedSaga)
9 | yield takeEvery(types.BOOK_REQUEST, bookSaga)
10 | // yield takeEvery(types.TRADES_REQUEST, tradesSaga)
11 | }
12 |
--------------------------------------------------------------------------------
/demo/app/sagas/tradesSaga.js:
--------------------------------------------------------------------------------
1 | import { call, put } from 'redux-saga/effects'
2 | import { fetchTrades } from '../api'
3 | import types from '../constants/actionTypes'
4 |
5 | export default function * (action) {
6 | try {
7 | const trades = yield call(fetchTrades, action.payload)
8 | yield put({type: types.TRADES_SUCCESS, payload: trades})
9 | } catch (err) {
10 | yield put({type: types.TRADES_FAILURE, payload: err.message})
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/demo/app/store/index.js:
--------------------------------------------------------------------------------
1 | import immutable from 'immutable'
2 | import createSagaMiddleware from 'redux-saga'
3 | import {createStore, applyMiddleware} from 'redux'
4 | import {createLogger} from 'redux-logger'
5 | import {composeWithDevTools} from 'redux-devtools-extension'
6 |
7 | import rootReducer from '../reducers'
8 | import initialState from './initialState'
9 | import rootSaga from '../sagas'
10 | import types from '../constants/actionTypes'
11 | import * as actionCreators from '../constants/actionCreators'
12 |
13 | // middleware helpers
14 | const typeRegEx = /Symbol\((.*?)\)/
15 | const stateSanitizer = state => state.toJS()
16 | const actionSanitizer = action => ({
17 | ...action,
18 | type: typeof action.type === 'symbol'
19 | ? String(action.type).match(typeRegEx)[1]
20 | : action.type
21 | })
22 | const predicate = (state, action) =>
23 | (action.payload && action.payload.type === 'match') ||
24 | action.type !== types.SOCKET_MESSAGE
25 |
26 | // use redux devtools enhancer
27 | const composeEnhancers = composeWithDevTools({
28 | predicate,
29 | actionCreators,
30 | actionSanitizer,
31 | serialize: { immutable },
32 | actionsBlackList: types.SOCKET_MESSAGE
33 | })
34 |
35 | // create and apply middlewares
36 | const loggerMiddleware = createLogger({
37 | predicate,
38 | diff: true,
39 | collapsed: true,
40 | stateTransformer: stateSanitizer,
41 | actionTransformer: actionSanitizer
42 | })
43 | const sagaMiddleware = createSagaMiddleware()
44 | const middleware = composeEnhancers(
45 | applyMiddleware(loggerMiddleware, sagaMiddleware)
46 | // , additional enhancers...
47 | )
48 |
49 | // create the store
50 | const store = createStore(rootReducer, initialState, middleware)
51 |
52 | // run sagas
53 | sagaMiddleware.run(rootSaga)
54 |
55 | export default store
56 |
--------------------------------------------------------------------------------
/demo/app/store/initialState.js:
--------------------------------------------------------------------------------
1 | import { Map, List, SortedMap } from 'immutable-sorted'
2 |
3 | const comparePrice = (currKey, nextKey) => {
4 | const currPrice = Number(currKey)
5 | const nextPrice = Number(nextKey)
6 | if (currPrice < nextPrice) return -1
7 | else if (currPrice > nextPrice) return 1
8 | else return 0
9 | }
10 |
11 | export default Map({
12 | error: '',
13 | book: Map({
14 | asks: SortedMap([], comparePrice),
15 | bids: SortedMap([], comparePrice),
16 | hasReceivedSnapshot: false,
17 | queuedMessages: List([])
18 | }),
19 | // trades: List([]),
20 | feedStatus: 3 // 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED
21 | })
22 |
--------------------------------------------------------------------------------
/demo/app/utils/collect.js:
--------------------------------------------------------------------------------
1 | import {createCollect} from 'react-redux-collect/immutable'
2 | import * as actionCreators from '../constants/actionCreators'
3 |
4 | export default createCollect(actionCreators)
5 |
--------------------------------------------------------------------------------
/demo/app/utils/messages.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable eqeqeq, camelcase */
2 | import { Map } from 'immutable-sorted'
3 |
4 | const getSnapshotOrderPrice = order => order[0]
5 | const getSnapshotOrderSize = order => order[1]
6 | const getSnapshotOrderId = order => order[2]
7 |
8 | const getEntriesKey = side => side === 'buy' ? 'bids' : 'asks'
9 | const isMarketOrder = message => message.price == null
10 | const isEmptyEntry = entry => (
11 | !(entry
12 | .get('orderSizesById')
13 | .reduce((totalSize, orderSize) => totalSize + Number(orderSize), 0)
14 | )
15 | )
16 |
17 | const keyPathFactory = ({side, price, order_id, maker_order_id}) => (
18 | [getEntriesKey(side), price, 'orderSizesById', order_id || maker_order_id]
19 | )
20 |
21 | const bookEntryFactory = order => Map({
22 | price: getSnapshotOrderPrice(order)
23 | })
24 |
25 | const bookEntryUpdaterFactory = order => entry => (
26 | entry
27 | .set('price', getSnapshotOrderPrice(order))
28 | .setIn(
29 | ['orderSizesById', getSnapshotOrderId(order)],
30 | getSnapshotOrderSize(order)
31 | )
32 | )
33 |
34 | const notSetValueFactory = message => Map({
35 | price: message.price,
36 | orderSizesById: Map()
37 | })
38 |
39 | const updaterFactory = (message, sizeChange) => entry => (
40 | entry
41 | .set('price', message.price)
42 | .updateIn(
43 | ['orderSizesById', message.order_id || message.maker_order_id],
44 | size => String(Number(size) + Number(sizeChange))
45 | )
46 | )
47 |
48 | const seedBook = payload => prevBook => payload.reduce(
49 | (newBook, snapshotOrder) => {
50 | const key = getSnapshotOrderPrice(snapshotOrder)
51 | const notSetValue = bookEntryFactory(snapshotOrder)
52 | const updater = bookEntryUpdaterFactory(snapshotOrder)
53 | return newBook.update(key, notSetValue, updater)
54 | }, prevBook
55 | )
56 |
57 | // https://docs.gdax.com/#open
58 | const applyOpenMessage = (state, message) => {
59 | const keyPath = keyPathFactory(message)
60 | const notSetValue = notSetValueFactory(message)
61 | return state.setIn(keyPath, notSetValue, message.remaining_size)
62 | }
63 |
64 | // https://docs.gdax.com/#done
65 | const applyDoneMessage = (state, message) => {
66 | if (isMarketOrder(message)) return state
67 | const keyPath = keyPathFactory(message)
68 | const notSetValue = notSetValueFactory(message)
69 | const resultState = message.remaining_size == 0
70 | ? state.deleteIn(keyPath)
71 | : state.setIn(keyPath, notSetValue, message.remaining_size)
72 | const resultEntry = resultState.get(message.price) || notSetValue
73 | return isEmptyEntry(resultEntry)
74 | ? state.delete(message.price)
75 | : resultState
76 | }
77 |
78 | // https://docs.gdax.com/#match
79 | const applyMatchMessage = (state, message) => {
80 | const key = message.price
81 | const keyPath = keyPathFactory(message)
82 | const notSetValue = notSetValueFactory(message)
83 | const updater = updaterFactory(message, -message.size)
84 | const resultState = message.size == state.getIn(keyPath)
85 | ? state.deleteIn(keyPath)
86 | : state.update(key, Map(), updater)
87 | const resultEntry = resultState.get(message.price) || notSetValue
88 | return isEmptyEntry(resultEntry)
89 | ? state.delete(message.price)
90 | : resultState
91 | }
92 |
93 | // https://docs.gdax.com/#change
94 | const applyChangeMessage = (state, message) => {
95 | if (isMarketOrder(message)) return state
96 | const key = message.price
97 | // const notSetValue = notSetValueFactory(message)
98 | const updater = updaterFactory(message, message.new_size - message.old_size)
99 | return state.update(key, updater)
100 | }
101 |
102 | export const applyMessage = (state, message) => {
103 | switch (message.type) {
104 | case 'open' : return applyOpenMessage(state, message)
105 | case 'done' : return applyDoneMessage(state, message)
106 | case 'match' : return applyMatchMessage(state, message)
107 | case 'change' : return applyChangeMessage(state, message)
108 | default : return state
109 | }
110 | }
111 |
112 | export const applyQueuedMessages = (state, {asks, bids, sequence}) => {
113 | const messages = state
114 | .get('queuedMessages')
115 | .filter(m => m.sequence > sequence)
116 |
117 | const book = state
118 | .update('asks', seedBook(asks))
119 | .update('bids', seedBook(bids))
120 |
121 | return messages
122 | .reduce(applyMessage, book)
123 | .set('hasReceivedSnapshot', true)
124 | .update('queuedMessages', queue => queue.clear())
125 | }
126 |
127 | export const queueMessage = (state, message) => (
128 | state.update(
129 | 'queuedMessages',
130 | queue => queue.unshift(message)
131 | )
132 | )
133 |
--------------------------------------------------------------------------------
/demo/app/utils/serialize.js:
--------------------------------------------------------------------------------
1 | export default params =>
2 | Object.keys(params)
3 | .map(key => `${key}=${params[key]}`)
4 | .join('&')
5 |
--------------------------------------------------------------------------------
/demo/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/youfoundron/react-trading-ui/cfac37ae1a294d102d70678d126ec69cddae5a56/demo/favicon.ico
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Trading UI
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/demo/index.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill'
2 | import React from 'react'
3 | import ReactDOM from 'react-dom'
4 | import App from './app'
5 |
6 | ReactDOM.render(, document.getElementById('root'))
7 |
--------------------------------------------------------------------------------
/docs/GettingStarted.md:
--------------------------------------------------------------------------------
1 | # Getting Started with `react-trading-ui`
2 | `react-trading-ui` primarily consists of four components:
3 | 1. [OrderBook](/docs/OrderBook.md)
4 | 2. [TradeHistory](/docs/TradeHistory.md)
5 | 3. ~~[DepthChart](/docs/DepthChart.md)~~
6 | 4. ~~[PriceChart](/docs/PriceChart.md)~~
7 |
8 | ## Import Guide
9 | Each component is a named export of `react-trading-ui`.
10 | If you wish to require a component individually, import from any of:
11 | `react-trading-ui/OrderBook`
12 | `react-trading-ui/TradeHistory`
13 | `react-trading-ui/DepthChart`
14 | `react-trading-ui/PriceChart`
15 |
16 | ## Component Props
17 | Refer to individual component documentation for expected props.
18 |
--------------------------------------------------------------------------------
/docs/OrderBook.md:
--------------------------------------------------------------------------------
1 | # OrderBook
2 | The OrderBook component is used to list a product's buy and sell orders, organized by price level.
3 |
4 | ## Properties
5 | | Property | Type | Description |
6 | | --- | --- | --- |
7 | | `asks` | array | Array of sell side orders. |
8 | | `bids` | array | Array of buy side orders. |
9 | | `depth` | number | Number of orders to show on either side of the spread. |
10 | | `onClickOrder` | function | Function called when an order is clicked, first arg is the order object, second arg is the side ("buy", "sell"). |
11 | | `headerText` | string | Text for the component header. |
12 | | `spreadText` | string | Text for the spread. |
13 | | `sizeLabel` | string | Text to label the size column of the book. |
14 | | `priceLabel` | string | Text to label the price column of the book. |
15 | | `positionLabel` | string | Text to label the position column of the book. |
16 | | `sizeFormat` | string | [Numeral.js](http://numeraljs.com/#format) format string for an order's size value. |
17 | | `priceFormat` | string | [Numeral.js](http://numeraljs.com/#format) format string for an order's price value. |
18 | | `positionFormat` | string | [Numeral.js](http://numeraljs.com/#format) format string for an order's position value. |
19 | | `spreadFormat` | string | [Numeral.js](http://numeraljs.com/#format) format string for the book's spread value. |
20 | | `getSize` | function | Function to get the size value from an order. |
21 | | `getPrice` | function | Function to get the price value from an order. |
22 | | `getPosition` | function | Function to get the position value from an order. |
23 | | `sizeRenderer` | function | Function that renders the size of an order. |
24 | | `priceRenderer` | function | Function that renders the price of an order. |
25 | | `positionRenderer` | function | Function that renders the position of an order. |
26 | | `showSizeBar` | boolean | Flag for whether or not to show the size bar. |
27 | | `sizeBarMaxSize` | number | Market size value for which the size bar is at full width. |
28 | | `sizeBarMaxWidth` | number | Pixel value for the max width of the size bar. |
29 | | `sizeBarUnitSize` | number | Quantity of market size value for which the size bar increments in width. |
30 |
--------------------------------------------------------------------------------
/docs/TradeHistory.md:
--------------------------------------------------------------------------------
1 | # TradeHistory
2 | The TradeHistory component is used to list a product's most recent trades.
3 |
4 | ## Properties
5 | | Property | Type | Description |
6 | | --- | --- | --- |
7 | | `trades` | array | Array of trades. |
8 | | `length` | number | Number of trades to show. |
9 | | `onClickTrade` | function | Function called when a trade is clicked, first arg is the trade object, second arg is the side ("buy", "sell"). |
10 | | `headerText` | string | Text for the component header. |
11 | | `sizeLabel` | string | Text to label the size column of the trade list. |
12 | | `priceLabel` | string | Text to label the price column of the trade list. |
13 | | `timeStampLabel` | string | Text to label the timeStamp column of the trade list. |
14 | | `sizeFormat` | string | [Numeral.js](http://numeraljs.com/#format) format string for a trade's size value. |
15 | | `priceFormat` | string | [Numeral.js](http://numeraljs.com/#format) format string for a trade's price value. |
16 | | `timeStampFormat` | string | [Moment.js](https://momentjs.com/docs/#/displaying/format/) format string for a trade's timeStamp value. |
17 | | `getSize` | function | Function to get the size value from a trade. |
18 | | `getPrice` | function | Function to get the price value from a trade. |
19 | | `getTimeStamp` | function | Function to get the timeStamp value from a trade. |
20 | | `sizeRenderer` | function | Function that renders the size of a trade. |
21 | | `priceRenderer` | function | Function that renders the price of a trade. |
22 | | `timeStampRenderer` | function | Function that renders the timeStamp of a trade. |
23 | | `showSizeBar` | boolean | Flag for whether or not to show the size bar. |
24 | | `sizeBarMaxSize` | number | Market size value for which the size bar is at full width. |
25 | | `sizeBarMaxWidth` | number | Pixel value for the max width of the size bar. |
26 | | `sizeBarUnitSize` | number | Quantity of market size value for which the size bar increments in width. |
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-trading-ui",
3 | "version": "0.1.5",
4 | "description": "A set of React components for trading applications",
5 | "keywords": [
6 | "react",
7 | "order book",
8 | "component",
9 | "trading",
10 | "market",
11 | "depth",
12 | "chart"
13 | ],
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/rongierlach/react-trading-ui.git"
17 | },
18 | "main": "lib/index.js",
19 | "license": "GPL-3.0",
20 | "scripts": {
21 | "start": "webpack-dev-server -d --hot --inline --port 3000 --config webpack.server.babel.js",
22 | "build": "webpack",
23 | "clean": "rimraf lib",
24 | "prepublish": "npm run clean && npm run build",
25 | "commit": "git cz",
26 | "lint": "standard | snazzy",
27 | "test": "jest"
28 | },
29 | "pre-commit": [
30 | "lint"
31 | ],
32 | "dependencies": {
33 | "glamor": "^2.20.24",
34 | "glamorous": "^3.13.1",
35 | "moment": "^2.18.1",
36 | "numeral": "^2.0.6",
37 | "prop-types": "^15.5.8",
38 | "ramda": "^0.23.0",
39 | "react": "^15.5.4",
40 | "react-dom": "^15.5.4"
41 | },
42 | "devDependencies": {
43 | "babel-eslint": "^7.2.3",
44 | "babel-jest": "^19.0.0",
45 | "babel-loader": "^7.0.0",
46 | "babel-plugin-glamorous-displayname": "^1.1.0",
47 | "babel-plugin-ramda": "^1.2.0",
48 | "babel-plugin-transform-object-rest-spread": "^6.23.0",
49 | "babel-polyfill": "^6.23.0",
50 | "babel-preset-es2015": "^6.24.1",
51 | "babel-preset-react": "^6.24.1",
52 | "babel-register": "^6.24.1",
53 | "commitizen": "^2.9.6",
54 | "cz-conventional-changelog": "^2.0.0",
55 | "enzyme": "^2.8.2",
56 | "file-loader": "^0.11.1",
57 | "immutable": "4.0.0-rc.2",
58 | "immutable-sorted": "^0.1.0",
59 | "immutable-sorted-map": "^0.3.0",
60 | "jest": "^19.0.2",
61 | "pre-commit": "^1.2.2",
62 | "react-addons-test-utils": "^15.5.1",
63 | "react-hot-loader": "^1.3.1",
64 | "react-redux": "^5.0.4",
65 | "react-redux-collect": "^0.2.0",
66 | "react-test-renderer": "^15.5.4",
67 | "redux": "^3.6.0",
68 | "redux-devtools-extension": "^2.13.2",
69 | "redux-immutable": "^4.0.0",
70 | "redux-logger": "^3.0.1",
71 | "redux-saga": "^0.15.3",
72 | "reselect": "^3.0.0",
73 | "rimraf": "^2.6.1",
74 | "scope-types": "^1.1.2",
75 | "snazzy": "^7.0.0",
76 | "standard": "^10.0.2",
77 | "styled-jsx": "^0.5.7",
78 | "webpack": "^2.4.1",
79 | "webpack-dev-server": "^2.4.5",
80 | "webpack-make-rule": "^2.0.0"
81 | },
82 | "peerDependencies": {},
83 | "config": {
84 | "commitizen": {
85 | "path": "cz-conventional-changelog"
86 | }
87 | },
88 | "standard": {
89 | "parser": "babel-eslint",
90 | "globals": [
91 | "it",
92 | "expect",
93 | "describe"
94 | ]
95 | },
96 | "jest": {
97 | "verbose": true
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/DepthChart.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | class DepthChart extends React.Component {
5 | render () {
6 | return (
7 |
8 | depth chart
9 |
10 | )
11 | }
12 | }
13 |
14 | DepthChart.propTypes = {
15 | asks: PropTypes.array,
16 | bids: PropTypes.array
17 | }
18 |
19 | DepthChart.defaultProps = {
20 | asks: [],
21 | bids: []
22 | }
23 |
24 | export default DepthChart
25 |
--------------------------------------------------------------------------------
/src/OrderBook.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda'
2 | import React from 'react'
3 | import ReactDOM from 'react-dom'
4 | import PropTypes from 'prop-types'
5 |
6 | // Defaults
7 | import * as util from './defaults/util'
8 | import * as getters from './defaults/getters'
9 |
10 | // Components
11 | import TradingUIParent from './components/TradingUIParent'
12 | import TradingUIHeader from './components/TradingUIHeader'
13 | import TradingUIContentWrapper from './components/TradingUIContentWrapper'
14 | import TradingUIStickyContent from './components/TradingUIStickyContent'
15 | import TradingUITableHead from './components/TradingUITableHead'
16 | import TradingUITableHeading from './components/TradingUITableHeading'
17 | import TradingUIScrollingContent from './components/TradingUIScrollingContent'
18 | import TradingUIOrderTable from './components/TradingUIOrderTable'
19 | import TradingUIOrder from './components/TradingUIOrder'
20 | import PrettySize from './components/PrettySize'
21 | import PrettyPrice from './components/PrettyPrice'
22 | import PrettyPosition from './components/PrettyPosition'
23 | import Spread from './components/Spread'
24 | import Spinner from './components/Spinner'
25 |
26 | // Normalize Array to have first and last methods
27 | Array.prototype.first = function () { return this[0] } // eslint-disable-line no-extend-native
28 | Array.prototype.last = function () { return this[this.length - 1] } // eslint-disable-line no-extend-native
29 |
30 | const unsafePropNames = [
31 | 'asks', 'bids', 'depth', 'headerText', 'spreadText', 'showSizeBar',
32 | 'sizeLabel', 'priceLabel', 'positionLabel', 'onClickOrder',
33 | 'sizeBarMaxWidth', 'sizeBarMaxSize', 'sizeBarUnitSize',
34 | 'getSize', 'getPrice', 'getPosition',
35 | 'sizeFormat', 'priceFormat', 'positionFormat', 'spreadFormat',
36 | 'renderSize', 'renderPrice', 'renderPosition'
37 | ]
38 |
39 | class OrderBook extends React.Component {
40 | constructor (props, context) {
41 | super(props, context)
42 | this.state = { hasOrders: false, hasCentered: false }
43 | this.scroller = null
44 | this.centerSpread = this.centerSpread.bind(this)
45 | this.centerSpreadOnResize = this.centerSpreadOnResize.bind(this)
46 | window.addEventListener('resize', this.centerSpreadOnResize)
47 | }
48 |
49 | componentWillUpdate (nextProps, nextState) {
50 | if (!nextState.hasOrders && util.hasReceivedOrderBook(nextProps)) {
51 | return this.setState({hasOrders: true})
52 | }
53 | if (this.scroller && nextState.hasOrders && !nextState.hasCentered) {
54 | return this.setState({hasCentered: true}, this.centerSpread)
55 | }
56 | }
57 |
58 | componentWillUnmount () {
59 | window.removeEventListener('resize', this.centerSpreadOnResize)
60 | }
61 |
62 | centerSpread () {
63 | this.scroller.scrollTop = (this.scroller.scrollHeight - this.scroller.clientHeight) / 2
64 | }
65 |
66 | centerSpreadOnResize () {
67 | if (!this.state.hasCentered) {
68 | return this.centerSpread()
69 | }
70 | }
71 |
72 | render () {
73 | const {
74 | asks, bids, depth, headerText, spreadText, showSizeBar,
75 | sizeLabel, priceLabel, positionLabel, onClickOrder,
76 | sizeBarMaxWidth, sizeBarMaxSize, sizeBarUnitSize,
77 | getSize, getPrice, getPosition,
78 | sizeFormat, priceFormat, positionFormat, spreadFormat,
79 | renderSize, renderPrice, renderPosition
80 | } = this.props
81 | const safeProps = R.omit(unsafePropNames, this.props)
82 | const visibleAsks = asks.slice(0, depth).reverse()
83 | const visibleBids = bids.slice(0, depth)
84 | const spread = this.state.hasOrders ? getPrice(visibleAsks.last()) - getPrice(visibleBids.first()) : undefined
85 | const dataConfigs = [
86 | {propName: 'size', format: sizeFormat, getter: getSize, renderer: renderSize},
87 | {propName: 'price', format: priceFormat, getter: getPrice, renderer: renderPrice},
88 | {propName: 'position', format: positionFormat, getter: getPosition, renderer: renderPosition}
89 | ]
90 | return (
91 |
92 | {/* UI HEADER */}
93 | {headerText}
94 |
95 |
96 | {/* TABLE COLUMN HEADERS */}
97 |
98 | {showSizeBar ? : null}
99 | {sizeLabel}
100 | {priceLabel}
101 | {positionLabel}
102 |
103 |
104 | { this.scroller = ReactDOM.findDOMNode(c) }} >
105 | {/* ASKS TABLE */}
106 |
111 | {visibleAsks.map(order =>
112 |
124 | )}
125 |
126 | {/* SPREAD MARKER */}
127 |
134 | {/* BIDS TABLE */}
135 |
140 | {visibleBids.map(order =>
141 |
153 | )}
154 |
155 | {/* LOADING SPINNER */}
156 |
157 |
158 |
159 |
160 | )
161 | }
162 | }
163 |
164 | OrderBook.propTypes = {
165 | asks: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
166 | bids: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
167 | depth: PropTypes.number,
168 | sizeBarMaxWidth: PropTypes.number,
169 | sizeBarMaxSize: PropTypes.number,
170 | sizeBarUnitSize: PropTypes.number,
171 | showSizeBar: PropTypes.bool,
172 | headerText: PropTypes.string,
173 | spreadText: PropTypes.string,
174 | sizeLabel: PropTypes.string,
175 | priceLabel: PropTypes.string,
176 | positionLabel: PropTypes.string,
177 | getSize: PropTypes.func,
178 | getPrice: PropTypes.func,
179 | getPosition: PropTypes.func,
180 | sizeFormat: PropTypes.string,
181 | priceFormat: PropTypes.string,
182 | positionFormat: PropTypes.string,
183 | spreadFormat: PropTypes.string,
184 | renderSize: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
185 | renderPrice: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
186 | renderPosition: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
187 | onClickOrder: PropTypes.func
188 | }
189 |
190 | OrderBook.defaultProps = {
191 | asks: [],
192 | bids: [],
193 | depth: Infinity,
194 | showSizeBar: true,
195 | sizeBarMaxWidth: 50,
196 | sizeBarMaxSize: 1000,
197 | sizeBarUnitSize: 50,
198 | headerText: 'Order Book',
199 | spreadText: 'USD SPREAD',
200 | sizeLabel: 'Market Size',
201 | priceLabel: 'Price (USD)',
202 | positionLabel: 'My Position',
203 | getSize: getters.getSize,
204 | getPrice: getters.getPrice,
205 | getPosition: getters.getPosition,
206 | sizeFormat: '0.00000000',
207 | priceFormat: '00.00',
208 | positionFormat: '0.00000000',
209 | spreadFormat: '0.00',
210 | renderSize: PrettySize,
211 | renderPrice: PrettyPrice,
212 | renderPosition: PrettyPosition
213 | }
214 |
215 | export default OrderBook
216 |
--------------------------------------------------------------------------------
/src/PriceChart.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | class PriceChart extends React.Component {
5 | render () {
6 | return (
7 |
8 | price chart
9 |
10 | )
11 | }
12 | }
13 |
14 | PriceChart.propTypes = {
15 | snapshots: []
16 | }
17 |
18 | PriceChart.defaultProps = {
19 | snapshots: PropTypes.array
20 | }
21 |
22 | export default PriceChart
23 |
--------------------------------------------------------------------------------
/src/TradeHistory.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda'
2 | import React from 'react'
3 | import PropTypes from 'prop-types'
4 |
5 | import * as util from './defaults/util'
6 | import * as getters from './defaults/getters'
7 |
8 | import TradingUIParent from './components/TradingUIParent'
9 | import TradingUIHeader from './components/TradingUIHeader'
10 | import TradingUIContentWrapper from './components/TradingUIContentWrapper'
11 | import TradingUIStickyContent from './components/TradingUIStickyContent'
12 | import TradingUITableHead from './components/TradingUITableHead'
13 | import TradingUITableHeading from './components/TradingUITableHeading'
14 | import TradingUIScrollingContent from './components/TradingUIScrollingContent'
15 | import TradingUIOrderTable from './components/TradingUIOrderTable'
16 | import TradingUIOrder from './components/TradingUIOrder'
17 | import PrettySize from './components/PrettySize'
18 | import PrettyPrice from './components/PrettyPrice'
19 | import PrettyTimeStamp from './components/PrettyTimeStamp'
20 | import Spinner from './components/Spinner'
21 |
22 | const unsafePropNames = [
23 | 'trades', 'length', 'headerText', 'showSizeBar',
24 | 'sizeLabel', 'priceLabel', 'timeStampLabel', 'onClickTrade',
25 | 'sizeBarMaxWidth', 'sizeBarMaxSize', 'sizeBarUnitSize',
26 | 'getSize', 'getSide', 'getPrice', 'getTimeStamp',
27 | 'sizeFormat', 'priceFormat', 'timeStampFormat',
28 | 'renderSize', 'renderPrice', 'renderTimeStamp'
29 | ]
30 |
31 | class TradeHistory extends React.Component {
32 | render () {
33 | const {
34 | trades, length, headerText, showSizeBar,
35 | sizeLabel, priceLabel, timeStampLabel, onClickTrade,
36 | sizeBarMaxWidth, sizeBarMaxSize, sizeBarUnitSize,
37 | getSize, getSide, getPrice, getTimeStamp,
38 | sizeFormat, priceFormat, timeStampFormat,
39 | renderSize, renderPrice, renderTimeStamp
40 | } = this.props
41 | const safeProps = R.omit(unsafePropNames, this.props)
42 | const hasTrades = util.hasReceivedTrades(this.props)
43 | const visibleTrades = trades.slice(0, length)
44 | const dataConfigs = [
45 | {propName: 'size', format: sizeFormat, getter: getSize, renderer: renderSize},
46 | {propName: 'price', format: priceFormat, getter: getPrice, renderer: renderPrice},
47 | {propName: 'timeStamp', format: timeStampFormat, getter: getTimeStamp, renderer: renderTimeStamp}
48 | ]
49 | return (
50 |
51 | {/* UI HEADER */}
52 | {headerText}
53 |
54 |
55 | {/* TABLE COLUMN HEADERS */}
56 |
57 | {showSizeBar ? : null}
58 | {sizeLabel}
59 | {priceLabel}
60 | {timeStampLabel}
61 |
62 |
63 |
64 | {/* TRADE TABLE */}
65 |
70 | {visibleTrades.map((trade, i) =>
71 |
83 | )}
84 |
85 |
86 |
87 |
88 |
89 | )
90 | }
91 | }
92 |
93 | TradeHistory.propTypes = {
94 | trades: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
95 | length: PropTypes.number,
96 | sizeBarMaxWidth: PropTypes.number,
97 | sizeBarMaxSize: PropTypes.number,
98 | sizeBarUnitSize: PropTypes.number,
99 | showSizeBar: PropTypes.bool,
100 | headerText: PropTypes.string,
101 | sizeLabel: PropTypes.string,
102 | priceLabel: PropTypes.string,
103 | timeStampLabel: PropTypes.string,
104 | getSize: PropTypes.func,
105 | getSide: PropTypes.func,
106 | getPrice: PropTypes.func,
107 | getTimeStamp: PropTypes.func,
108 | sizeFormat: PropTypes.string,
109 | priceFormat: PropTypes.string,
110 | timeStampFormat: PropTypes.string,
111 | renderSize: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
112 | renderPrice: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
113 | renderTimeStamp: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
114 | onClickTrade: PropTypes.func,
115 | onScrollBottom: PropTypes.func
116 | }
117 |
118 | TradeHistory.defaultProps = {
119 | trades: [],
120 | length: Infinity,
121 | sizeBarMaxWidth: 50,
122 | sizeBarMaxSize: 1000,
123 | sizeBarUnitSize: 50,
124 | showSizeBar: true,
125 | headerText: 'Trades',
126 | sizeLabel: 'Trade Size',
127 | priceLabel: 'Price (USD)',
128 | timeStampLabel: 'Time',
129 | getSize: getters.getSize,
130 | getSide: getters.getSide,
131 | getPrice: getters.getPrice,
132 | getTimeStamp: getters.getTimeStamp,
133 | sizeFormat: '0.00000000',
134 | priceFormat: '00.00',
135 | timeStampFormat: 'HH:mm:ss',
136 | renderSize: PrettySize,
137 | renderPrice: PrettyPrice,
138 | renderTimeStamp: PrettyTimeStamp
139 | }
140 |
141 | export default TradeHistory
142 |
--------------------------------------------------------------------------------
/src/components/PrettyPosition.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import glamorous from 'glamorous'
3 |
4 | import colors from '../defaults/colors'
5 | import PrettySize from './PrettySize'
6 |
7 | const MedContrast = glamorous.span({
8 | color: colors.textMedContrast
9 | })
10 |
11 | const PrettyPosition = ({position, format, side}) =>
12 | position
13 | ?
14 | : -
15 |
16 | export default PrettyPosition
17 |
--------------------------------------------------------------------------------
/src/components/PrettyPrice.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import Numeral from 'numeral'
4 | import glamorous from 'glamorous'
5 |
6 | import colors from '../defaults/colors'
7 |
8 | const SidePrimary = glamorous.span(({side}) => ({
9 | color: side === 'buy' ? colors.buyPrimary : colors.sellPrimary
10 | }))
11 |
12 | const SideSecondary = glamorous.span(({side}) => ({
13 | color: side === 'buy' ? colors.buySecondary : colors.sellSecondary
14 | }))
15 |
16 | const NoSidePrimary = glamorous.span({
17 | color: colors.textMedContrast
18 | })
19 |
20 | const NoSideSecondary = glamorous.span({
21 | color: colors.textLowContrast
22 | })
23 |
24 | const PrettyPrice = ({price = 0, format, side}) => {
25 | const formattedSize = Numeral(price).format(format)
26 | // get digit arrays before and after decimal
27 | const [
28 | digitsBeforeDecimal,
29 | digitsAfterDecimal
30 | ] = formattedSize.split('.').map(str => str.split(''))
31 | // return colorized version of price
32 | return side
33 | ?
34 | {digitsBeforeDecimal}.
35 | {digitsAfterDecimal}
36 |
37 | :
38 | {digitsBeforeDecimal}.
39 | {digitsAfterDecimal}
40 |
41 | }
42 |
43 | PrettyPrice.propTypes = {
44 | price: PropTypes.number,
45 | format: PropTypes.string,
46 | side: PropTypes.oneOf(['buy', 'sell'])
47 | }
48 |
49 | PrettyPrice.defaultProps = {
50 | price: 0,
51 | format: '00.00'
52 | }
53 |
54 | export default PrettyPrice
55 |
--------------------------------------------------------------------------------
/src/components/PrettySize.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import Numeral from 'numeral'
4 | import glamorous from 'glamorous'
5 |
6 | import colors from '../defaults/colors'
7 | import {countTrailingZeroes} from '../defaults/util'
8 |
9 | const LowContrast = glamorous.span({
10 | color: colors.textLowContrast
11 | })
12 |
13 | const MedContrast = glamorous.span({
14 | color: colors.textMedContrast
15 | })
16 |
17 | const HighContrast = glamorous.span({
18 | color: colors.textHighContrast
19 | })
20 |
21 | const PrettySize = ({size = 0, format, side}) => {
22 | const formattedSize = Numeral(size).format(format)
23 | if (size === 0) return {formattedSize}
24 | // count trailing zeroes
25 | const numTrailingZeroes = countTrailingZeroes(formattedSize)
26 | // get digit arrays before and after decimal
27 | const [
28 | digitsBeforeDecimal,
29 | digitsAfterDecimal = []
30 | ] = formattedSize.split('.').map(str => str.split(''))
31 | // splice trailing zeroes into seperate array
32 | const trailingZeroes = digitsAfterDecimal.splice(
33 | digitsAfterDecimal.length - numTrailingZeroes
34 | )
35 | // return colorized version of size
36 | return [
37 | {digitsBeforeDecimal}.,
38 | {digitsAfterDecimal},
39 | {trailingZeroes}
40 | ]
41 | }
42 |
43 | PrettySize.propTypes = {
44 | size: PropTypes.number,
45 | format: PropTypes.string,
46 | side: PropTypes.oneOf(['buy', 'sell'])
47 | }
48 |
49 | export default PrettySize
50 |
--------------------------------------------------------------------------------
/src/components/PrettyTimeStamp.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import Moment from 'moment'
4 | import glamorous from 'glamorous'
5 |
6 | import colors from '../defaults/colors'
7 |
8 | const ThinLowContrast = glamorous.span({
9 | color: colors.textMedContrast,
10 | fontWeight: 400
11 | })
12 |
13 | const PrettyTimeStamp = ({time, format}) =>
14 |
15 | { format ? Moment(time).format(format) : time }
16 |
17 |
18 | PrettyTimeStamp.propTypes = {
19 | time: PropTypes.number,
20 | format: PropTypes.string
21 | }
22 |
23 | export default PrettyTimeStamp
24 |
--------------------------------------------------------------------------------
/src/components/Spinner.js:
--------------------------------------------------------------------------------
1 | import { css } from 'glamor'
2 | import glamorous from 'glamorous'
3 |
4 | import colors from '../defaults/colors'
5 |
6 | const loadingAnimation = css.keyframes({
7 | '0%, 80%, 100%': {
8 | boxShadow: '0 0',
9 | height: '4em'
10 | },
11 | '40%': {
12 | boxShadow: '0 -2em',
13 | height: '5em'
14 | }
15 | })
16 |
17 | const Spinner = glamorous.div({
18 | top: 0,
19 | left: 0,
20 | right: 0,
21 | bottom: 0,
22 | margin: 'auto',
23 | position: 'absolute',
24 | color: colors.textHighContrast,
25 | textIndent: '-9999em',
26 | fontSize: '11px',
27 | transform: 'translateZ(0)',
28 | '&, &::before, &::after': {
29 | width: '1em',
30 | height: '4em',
31 | background: colors.textHighContrast,
32 | animation: `${loadingAnimation} 1s infinite ease-in-out`
33 | },
34 | '&': {
35 | animationDelay: '-0.16s'
36 | },
37 | '&::before, &::after': {
38 | position: 'absolute',
39 | top: 0,
40 | content: '""'
41 | },
42 | '&::before': {
43 | left: '-1.5em',
44 | animationDelay: '-0.32s'
45 | },
46 | '&::after': {
47 | left: '1.5em'
48 | }
49 | }, ({hide}) => ({
50 | display: hide ? 'none' : 'static'
51 | }))
52 |
53 | export default Spinner
54 |
--------------------------------------------------------------------------------
/src/components/Spread.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import glamorous from 'glamorous'
4 |
5 | import colors from '../defaults/colors'
6 | import PrettyPrice from './PrettyPrice'
7 |
8 | const SpreadDiv = glamorous.div({
9 | textAlign: 'center',
10 | padding: '.3em 0',
11 | color: colors.textMedContrast,
12 | borderWidth: '.5px 0',
13 | borderColor: colors.textMedContrast,
14 | borderStyle: 'solid',
15 | fontWeight: 700,
16 | fontSize: '.8em',
17 | '.hide': { display: 'none' },
18 | ':hover': { cursor: 'pointer' }
19 | })
20 |
21 | const Spread = ({spread, label, format, ...props}) => (
22 |
23 |
24 | -
25 | {label}
26 |
27 | )
28 |
29 | Spread.propTypes = {
30 | spread: PropTypes.number,
31 | format: PropTypes.string,
32 | spreadText: PropTypes.string
33 | }
34 |
35 | Spread.defaultProps = {
36 | spread: 0,
37 | format: '0.00',
38 | spreadText: 'SPREAD'
39 | }
40 |
41 | export default Spread
42 |
--------------------------------------------------------------------------------
/src/components/TradingUIContentWrapper.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import glamorous from 'glamorous'
3 |
4 | const TradingUIContentWrapper = glamorous.div({
5 | left: 0,
6 | right: 0,
7 | width: '100%',
8 | height: '100%',
9 | overflow: 'hidden',
10 | position: 'absolute'
11 | })
12 |
13 | TradingUIContentWrapper.propTypes = {
14 | children: PropTypes.node.isRequired
15 | }
16 |
17 | export default TradingUIContentWrapper
18 |
--------------------------------------------------------------------------------
/src/components/TradingUIHeader.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import glamorous from 'glamorous'
3 |
4 | import colors from '../defaults/colors'
5 |
6 | const TradingUIHeader = glamorous.header({
7 | fontSize: '1.5rem',
8 | padding: '.8rem',
9 | textTransform: 'uppercase',
10 | background: colors.headerBackground,
11 | position: 'relative',
12 | fontWeight: 'bold'
13 | })
14 |
15 | TradingUIHeader.propTypes = {
16 | children: PropTypes.node.isRequired
17 | }
18 |
19 | export default TradingUIHeader
20 |
--------------------------------------------------------------------------------
/src/components/TradingUIOrder.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import glamorous from 'glamorous'
4 |
5 | import colors from '../defaults/colors'
6 | import TradingUIOrderSizeBar from './TradingUIOrderSizeBar'
7 |
8 | const TradingUIOrderTableRow = glamorous.tr({
9 | fontSize: '.8em',
10 | fontWeight: 700,
11 | ':hover': {
12 | cursor: 'pointer',
13 | background: colors.parentHoverBackground
14 | }
15 | })
16 |
17 | const TradingUIOrderTableData = glamorous.td({
18 | textAlign: 'right',
19 | transform: 'translateX(-10%)'
20 | })
21 |
22 | const TradingUIOrder = ({showSizeBar, side, size, sizeBarMaxWidth, sizeBarMaxSize, sizeBarUnitSize, onClick, order, dataConfigs, ...props}) => {
23 | return (
24 | {
25 | e.preventDefault()
26 | onClick(order, side)
27 | }}>
28 |
29 | {dataConfigs.map(({propName = 'data', format, getter, renderer}, i) =>
30 |
31 | {renderer({ side, format, [propName]: getter(order) })}
32 |
33 | )}
34 |
35 | )
36 | }
37 |
38 | TradingUIOrder.propTypes = {
39 | side: PropTypes.oneOf(['buy', 'sell']),
40 | showSizeBar: PropTypes.bool,
41 | dataConfigs: PropTypes.arrayOf(PropTypes.object)
42 | }
43 |
44 | TradingUIOrder.defaultProps = {
45 | side: 'buy',
46 | showSizeBar: true,
47 | dataConfigs: []
48 | }
49 |
50 | export default TradingUIOrder
51 |
--------------------------------------------------------------------------------
/src/components/TradingUIOrderSizeBar.js:
--------------------------------------------------------------------------------
1 | import glamorous from 'glamorous'
2 | import colors from '../defaults/colors'
3 |
4 | const TradingUIOrderSizeBar = glamorous.td({
5 | padding: 0,
6 | boxSizing: 'border-box'
7 | }, ({showSizeBar, side, size, sizeBarMaxWidth, sizeBarMaxSize, sizeBarUnitSize}) => {
8 | const totalUnits = sizeBarMaxSize / sizeBarUnitSize
9 | const numUnits = Math.floor(size / sizeBarUnitSize)
10 | const percentSize = numUnits >= totalUnits ? 1 : numUnits / totalUnits
11 | const sizeBarWidth = percentSize * sizeBarMaxWidth
12 | return {
13 | width: sizeBarMaxWidth,
14 | borderColor: side === 'buy' ? colors.buySecondary : colors.sellSecondary,
15 | borderStyle: showSizeBar ? 'solid' : 'none',
16 | borderWidth: `0 0 0 ${1 + sizeBarWidth}px`
17 | }
18 | })
19 |
20 | export default TradingUIOrderSizeBar
21 |
--------------------------------------------------------------------------------
/src/components/TradingUIOrderTable.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import glamorous from 'glamorous'
4 |
5 | const Table = glamorous.table({
6 | width: '100%',
7 | borderCollapse: 'collapse'
8 | })
9 |
10 | const HiddenTableHead = glamorous.thead({
11 | visibility: 'hidden'
12 | })
13 |
14 | const HiddenTableFoot = glamorous.tfoot({
15 | visibility: 'hidden'
16 | })
17 |
18 | const TradingUIOrderTable = ({showSizeBar, headerLabels, children, ...props}) => (
19 |
20 |
21 |
22 | { showSizeBar ? | : null }
23 | { headerLabels.map((label, i) => {label} | ) }
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 | { showSizeBar ? | : null }
32 | { headerLabels.map((label, i) => {label} | ) }
33 |
34 |
35 |
36 | )
37 |
38 | TradingUIOrderTable.propTypes = {
39 | children: PropTypes.node,
40 | showSizeBar: PropTypes.bool,
41 | headerLabels: PropTypes.array
42 | }
43 |
44 | TradingUIOrderTable.defaultProps = {
45 | showSizeBar: true,
46 | headerLabels: []
47 | }
48 |
49 | export default TradingUIOrderTable
50 |
--------------------------------------------------------------------------------
/src/components/TradingUIParent.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import glamorous from 'glamorous'
3 |
4 | import colors from '../defaults/colors'
5 |
6 | const TradingUIParent = glamorous.div({
7 | width: '100%',
8 | height: '100%',
9 | overflow: 'hidden',
10 | position: 'absolute',
11 | color: colors.textHighContrast,
12 | background: colors.parentBackground
13 | })
14 |
15 | TradingUIParent.propTypes = {
16 | children: PropTypes.node.isRequired
17 | }
18 |
19 | export default TradingUIParent
20 |
--------------------------------------------------------------------------------
/src/components/TradingUIScrollingContent.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import glamorous from 'glamorous'
4 |
5 | const Container = glamorous.div({
6 | width: '100%',
7 | height: '100%',
8 | position: 'relative'
9 | })
10 |
11 | const Scrollable = glamorous.div({
12 | width: '100%',
13 | height: '100%'
14 | })
15 |
16 | const Scroller = glamorous.div({
17 | width: '100%',
18 | height: '100%',
19 | overflow: 'scroll',
20 | position: 'absolute',
21 | '&::-webkit-scrollbar': { display: 'none' }
22 | })
23 |
24 | const TradingUIScrollingContent = ({scrollerRef, children, ...props}) => (
25 |
26 | ;
27 |
28 | {children}
29 |
30 |
31 |
32 | )
33 |
34 | TradingUIScrollingContent.propTypes = {
35 | children: PropTypes.node.isRequired
36 | }
37 |
38 | export default TradingUIScrollingContent
39 |
--------------------------------------------------------------------------------
/src/components/TradingUIStickyContent.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import glamorous from 'glamorous'
3 |
4 | import colors from '../defaults/colors'
5 |
6 | const TradingUIStickyContent = glamorous.div({
7 | top: 0,
8 | left: 0,
9 | zIndex: 1,
10 | width: '100%',
11 | position: 'absolute',
12 | background: colors.parentBackground
13 | })
14 |
15 | TradingUIStickyContent.propTypes = {
16 | children: PropTypes.node.isRequired
17 | }
18 |
19 | export default TradingUIStickyContent
20 |
--------------------------------------------------------------------------------
/src/components/TradingUITableHead.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import glamorous from 'glamorous'
4 |
5 | import colors from '../defaults/colors'
6 |
7 | const FullWidthTable = glamorous.table({
8 | width: '100%',
9 | zIndex: 1,
10 | fontSize: '.8em',
11 | color: colors.textMedContrast,
12 | background: colors.parentBackground,
13 | borderBottom: `.5px solid ${colors.textMedContrast}`
14 | })
15 |
16 | const TradingUITableHead = ({children, ...props}) => (
17 |
18 |
19 |
20 | {children}
21 |
22 |
23 |
24 | )
25 |
26 | TradingUITableHead.propTypes = {
27 | children: PropTypes.node.isRequired
28 | }
29 |
30 | export default TradingUITableHead
31 |
--------------------------------------------------------------------------------
/src/components/TradingUITableHeading.js:
--------------------------------------------------------------------------------
1 | import glamorous from 'glamorous'
2 |
3 | const TradingUITableHeading = glamorous.th({
4 | fontWeight: 400,
5 | textAlign: 'right',
6 | transform: 'translateX(-10%)'
7 | })
8 |
9 | export default TradingUITableHeading
10 |
--------------------------------------------------------------------------------
/src/defaults/colors.js:
--------------------------------------------------------------------------------
1 | export default {
2 | buyPrimary: '#4DA53C', // green
3 | buySecondary: '#437944', // muted green
4 |
5 | sellPrimary: '#FF6939', // orange
6 | sellSecondary: '#7F4332', // muted orange
7 |
8 | textHighContrast: '#CED2D5', // near white
9 | textMedContrast: '#7E878C', // light grey
10 | textLowContrast: '#3B464E', // normal grey
11 |
12 | headerBackground: '#2F3D45', // med-light grey
13 | parentBackground: '#1E2B34', // dark grey
14 | parentHoverBackground: '#15232C' // darkest grey
15 | }
16 |
--------------------------------------------------------------------------------
/src/defaults/getters.js:
--------------------------------------------------------------------------------
1 | export const getSize = data => data.size
2 | export const getPrice = data => data.price
3 | export const getPosition = data => data.position
4 | export const getTimeStamp = data => data.time
5 | export const getSide = data => data.side
6 |
--------------------------------------------------------------------------------
/src/defaults/util.js:
--------------------------------------------------------------------------------
1 | const isArrayOrObject = coll =>
2 | Array.isArray(coll) || typeof coll === 'object'
3 |
4 | const lengthOrSize = coll =>
5 | coll.length || coll.size
6 |
7 | export const hasReceivedOrderBook = ({ bids, asks }) =>
8 | isArrayOrObject(bids) &&
9 | isArrayOrObject(asks) &&
10 | lengthOrSize(bids) > 0 &&
11 | lengthOrSize(asks) > 0
12 |
13 | export const hasReceivedTrades = ({ trades }) =>
14 | isArrayOrObject(trades) && lengthOrSize(trades) > 0
15 |
16 | export const countTrailingZeroes = numString => {
17 | let numZeroes = 0
18 | for (let digit of numString.split('').reverse()) {
19 | if (digit == 0) ++numZeroes // eslint-disable-line eqeqeq
20 | else return numZeroes
21 | }
22 | return numZeroes
23 | }
24 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import OrderBook from './OrderBook'
2 | import DepthChart from './DepthChart'
3 | import PriceChart from './PriceChart'
4 | import TradeHistory from './TradeHistory'
5 |
6 | export {
7 | OrderBook,
8 | DepthChart,
9 | PriceChart,
10 | TradeHistory
11 | }
12 |
--------------------------------------------------------------------------------
/test/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const App = () => (
4 |
5 | Hello World
6 |
7 | )
8 |
9 | export default App
10 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { mount } from 'enzyme'
3 | import App from './App'
4 |
5 | describe('Test Suite', () => {
6 | it('Has at least one test', () => {
7 | const wrapper = mount()
8 |
9 | expect(wrapper.text()).toEqual('Hello World')
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { readdir } from 'fs'
3 | import makeRule from 'webpack-make-rule'
4 |
5 | const srcDir = path.resolve(__dirname, 'src')
6 | const libDir = path.resolve(__dirname, 'lib')
7 |
8 | const entryReducer = fileNames =>
9 | fileNames.reduce((entries, fileName) => {
10 | const {base, name, ext} = path.parse(fileName)
11 | if (ext) entries[name] = path.resolve(srcDir, base)
12 | return entries
13 | }, {})
14 |
15 | const dynamicEntry = () => new Promise(
16 | (resolve, reject) =>
17 | readdir(srcDir, (err, files) => err
18 | ? reject(err)
19 | : resolve(entryReducer(files))
20 | )
21 | )
22 |
23 | export default {
24 | entry: dynamicEntry,
25 | output: {
26 | path: libDir,
27 | filename: '[name].js'
28 | },
29 | module: {
30 | rules: [
31 | makeRule(/\.jsx?$/, 'babel-loader')
32 | ]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/webpack.server.babel.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import makeRule from 'webpack-make-rule'
3 |
4 | const demoDir = path.resolve(__dirname, 'demo')
5 | const demoDist = path.resolve(demoDir, 'dist')
6 |
7 | export default {
8 | entry: {
9 | app: path.resolve(demoDir, 'index.js'),
10 | html: path.resolve(demoDir, 'index.html'),
11 | favicon: path.resolve(demoDir, 'favicon.ico')
12 | },
13 | output: {
14 | path: demoDist,
15 | filename: '[name].js'
16 | },
17 | module: {
18 | rules: [
19 | makeRule(/\.(html|ico)$/, 'file-loader?name=[name].[ext]'),
20 | makeRule(/\.jsx?$/, ['react-hot-loader', 'babel-loader'])
21 | ]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------