├── .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 | react-trading-ui 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 ? ) } 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | { showSizeBar ? ) } 33 | 34 | 35 |
: null } 23 | { headerLabels.map((label, i) => {label}
: null } 32 | { headerLabels.map((label, i) => {label}
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 | --------------------------------------------------------------------------------