diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..374993d --- /dev/null +++ b/.babelrc @@ -0,0 +1,22 @@ +{ + "presets": ["es2015", "stage-0", "react"], + "plugins": ["add-module-exports"], + "env": { + "production": { + "presets": ["react-optimize"], + "plugins": [ + "transform-remove-console", + "transform-remove-debugger", + "dev-expression" + ] + }, + "development": { + "presets": ["react-hmre"], + }, + "test": { + "plugins": [ + ["webpack-loaders", { "config": "webpack.config.node.js", "verbose": false }] + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1addde2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{json,js,jsx,html,css}] +indent_style = space +indent_size = 2 + +[.eslintrc] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a9b203a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +main.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..c270cfc --- /dev/null +++ b/.eslintrc @@ -0,0 +1,27 @@ +{ + "parser": "babel-eslint", + "extends": "airbnb", + "env": { + "browser": true, + "mocha": true, + "node": true + }, + "rules": { + "semi": 0, + "max-len": 0, + "consistent-return": 0, + "comma-dangle": 0, + "no-use-before-define": 0, + "object-curly-spacing": 0, + "import/no-unresolved": [2, { ignore: ['electron'] }], + "react/prefer-es6-class": 0, + "react/prefer-stateless-function": 0, + }, + "plugins": [ + "import", + "react" + ], + "settings": { + "import/resolver": "webpack" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..177a2d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store + +# App packaged +dist +release +main.js +main.js.map diff --git a/.jsbeautifyrc b/.jsbeautifyrc new file mode 100644 index 0000000..2c5f016 --- /dev/null +++ b/.jsbeautifyrc @@ -0,0 +1,47 @@ +{ + "html": { + "allowed_file_extensions": ["htm", "html", "xhtml", "shtml", "xml", "svg"], + "brace_style": "end-expand", + "end_with_newline": false, + "indent_char": " ", + "indent_handlebars": false, + "indent_inner_html": false, + "indent_scripts": "keep", + "indent_size": 2, + "max_preserve_newlines": 0, + "preserve_newlines": true, + "unformatted": ["a", "span", "img", "code", "pre", "sub", "sup", "em", "strong", "b", "i", "u", "strike", "big", "small", "pre", "h1", "h2", "h3", "h4", "h5", "h6"], + "wrap_line_length": 0 + }, + "css": { + "allowed_file_extensions": ["css", "scss", "sass", "less"], + "end_with_newline": false, + "indent_char": " ", + "indent_size": 2, + "newline_between_rules": true, + "selector_separator": " ", + "selector_separator_newline": true + }, + "js": { + "allowed_file_extensions": ["js", "json", "jshintrc", "jsbeautifyrc", "jsx"], + "brace_style": "collapse", + "break_chained_methods": false, + "e4x": true, + "end_with_newline": true, + "indent_char": " ", + "indent_level": 0, + "indent_size": 2, + "indent_with_tabs": false, + "jslint_happy": false, + "keep_array_indentation": false, + "keep_function_indentation": false, + "max_preserve_newlines": 2, + "preserve_newlines": true, + "space_after_anon_function": false, + "space_before_conditional": true, + "space_in_empty_paren": false, + "space_in_paren": false, + "unescape_strings": false, + "wrap_line_length": 0 + } +} diff --git a/.tern-project b/.tern-project new file mode 100644 index 0000000..b525ef6 --- /dev/null +++ b/.tern-project @@ -0,0 +1,15 @@ +{ + "ecmaVersion": 6, + "libs": [], + "plugins": { + "complete_strings": {}, + "node": {}, + "lint": {}, + "requirejs": {}, + "modules": {}, + "es_modules": {}, + "doc_comment": { + "fullDocs": true + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8792bc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015-present C. T. Lin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/app/App.global.scss b/app/App.global.scss new file mode 100644 index 0000000..43a9e52 --- /dev/null +++ b/app/App.global.scss @@ -0,0 +1,38 @@ +$primary-color: darkgray; + +@import '~font-awesome/css/font-awesome.min.css'; + +* { + -webkit-user-select: none; + cursor: default; +} + +html, body, #root { + display: flex; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +body { + color: #5a5a5a; + font-family: Helvetica; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + tbody { + tr { + border-bottom: 1px solid #eee; + + td { + padding: 8px 16px; + } + } + } +} diff --git a/app/App.js b/app/App.js new file mode 100644 index 0000000..194445a --- /dev/null +++ b/app/App.js @@ -0,0 +1,29 @@ +import React from 'react' +import PureRendererMixin from 'react-addons-pure-render-mixin' +import NavigationController from './NavigationController' + +import './App.global.scss' + +import RootLayout from './RootLayout' + +export default React.createClass({ + propTypes: { + settings: React.PropTypes.object.isRequired, + watcher: React.PropTypes.object.isRequired, + trader: React.PropTypes.object.isRequired + }, + + mixins: [PureRendererMixin], + + renderScene(route, navigation) { + return + }, + + render() { + const initialRoute = {component: RootLayout, props: this.props} + + return ( + + ) + } +}) diff --git a/app/BalanceListView.js b/app/BalanceListView.js new file mode 100644 index 0000000..2098af6 --- /dev/null +++ b/app/BalanceListView.js @@ -0,0 +1,58 @@ +import React from 'react' +import PureRendererMixin from 'react-addons-pure-render-mixin' +import math from 'mathjs' + +import {Pane, Body, Header} from './Pane' + +export default React.createClass({ + propTypes: { + trader: React.PropTypes.any.isRequired, + watcher: React.PropTypes.any.isRequired, + }, + + mixins: [PureRendererMixin], + + getInitialState() { + return { + balances: this.props.trader.balances, + } + }, + + componentWillMount() { + this.props.trader.on('balances', this.onBalances) + }, + + componentWillUnmount() { + this.props.trader.off('balances', this.onBalances) + }, + + onBalances(balances) { + this.setState({balances}) + }, + + renderBalance(balance, currency) { + return ( + + {currency} + {balance.toFixed(8)} + + ) + }, + + render() { + const balances = this.state.balances.filter((balance) => balance > 0.0) + + return ( + +
Balances
+ + + + {balances.map(this.renderBalance).toArray()} + +
+ +
+ ) + } +}) diff --git a/app/Blink.js b/app/Blink.js new file mode 100644 index 0000000..193144f --- /dev/null +++ b/app/Blink.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PureRendererMixin from 'react-addons-pure-render-mixin' +import _ from 'underscore' + +import styles from './Blink.scss' + +export default React.createClass({ + propTypes: { + children: React.PropTypes.any + }, + + mixins: [PureRendererMixin], + + componentWillUpdate(nextProps) { + if (!_.isEqual(nextProps.children, this.props.children)) { + this.refs.target.classList.add(styles.in) + setTimeout(() => { + if (this.refs.target) { + this.refs.target.classList.remove(styles.in) + } + }, 500) + } + }, + + render() { + return
+ } +}) diff --git a/app/Blink.scss b/app/Blink.scss new file mode 100644 index 0000000..77b9464 --- /dev/null +++ b/app/Blink.scss @@ -0,0 +1,4 @@ +.in { + opacity: 0; + transition: all 500ms ease-in-out; +} diff --git a/app/Chart.js b/app/Chart.js new file mode 100644 index 0000000..b49e47a --- /dev/null +++ b/app/Chart.js @@ -0,0 +1,92 @@ +import React from 'react' +import ReStock from 'react-stockcharts' +import {format} from 'd3' + +const {ChartCanvas, Chart, EventCapture} = ReStock; + +const { + CandlestickSeries, + BarSeries, + LineSeries, + BollingerSeries +} = ReStock.series; + +const {discontinuousTimeScaleProvider} = ReStock.scale; +const {CrossHairCursor, MouseCoordinateY, CurrentCoordinate} = ReStock.coordinates; +const {EdgeIndicator} = ReStock.coordinates; +const {TooltipContainer, OHLCTooltip, MovingAverageTooltip, BollingerBandTooltip} = ReStock.tooltip; +const {XAxis, YAxis} = ReStock.axes; +const {ema, bollingerBand} = ReStock.indicator; +const {fitWidth} = ReStock.helper; + +export default fitWidth(React.createClass({ + propTypes: { + data: React.PropTypes.array.isRequired, + seriesName: React.PropTypes.string.isRequired, + width: React.PropTypes.number.isRequired + }, + + render() { + const {data, width} = this.props; + const margin = { + left: 70, + right: 70, + top: 20, + bottom: 30 + }; + + const gridWidth = width - margin.left - margin.right; + + const yGrid = {innerTickSize: -1 * gridWidth, tickStrokeOpacity: 0.2 } + + const ema20 = ema() + .id(0) + .windowSize(20) + .merge((d, c) => { d.ema20 = c }) + .accessor(d => d.ema20); + + const ema50 = ema().id(2) + .windowSize(50) + .merge((d, c) => { d.ema50 = c }) + .accessor(d => d.ema50) + + const bb = bollingerBand() + + return ( + d.date} xScaleProvider={discontinuousTimeScaleProvider} > + [d.high, d.low]} padding={{ top: 10, bottom: 20 }}> + + + + + + + d.close > d.open ? '#6BA583' : '#DB0000'} fill={d => d.close > d.open ? '#6BA583' : '#DB0000'} /> + + + + + + + + + + d.close} fill={d => d.close > d.open ? '#6BA583' : '#DB0000'} /> + + + d.volume} height={100} origin={(w, h) => [0, h - 100]}> + + d.volume} fill={d => d.close > d.open ? '#6BA583' : '#DB0000'} /> + + + + + + + + + + + ); + } +})) diff --git a/app/CurrencyPairListItem.js b/app/CurrencyPairListItem.js new file mode 100644 index 0000000..73bf68b --- /dev/null +++ b/app/CurrencyPairListItem.js @@ -0,0 +1,121 @@ +import React from 'react' +import {findDOMNode} from 'react-dom' +import PureRendererMixin from 'react-addons-pure-render-mixin' +import {Sparklines, SparklinesLine} from 'react-sparklines' +import {ProgressCircle} from 'react-desktop/macOs' + +import {Row, Column, Gutter, Spacer} from './Layout' +import FlexibleLayout from './FlexibleLayout' +import Blink from './Blink' +import Star from './Star' + +export default React.createClass({ + propTypes: { + watcher: React.PropTypes.object.isRequired, + trader: React.PropTypes.object.isRequired, + currencyPair: React.PropTypes.object.isRequired, + isStarred: React.PropTypes.bool, + onClick: React.PropTypes.func.isRequired, + onToggle: React.PropTypes.func.isRequired + }, + + mixins: [PureRendererMixin], + + getDefaultProps() { + return { + isStarred: false + } + }, + + getInitialState() { + return { + isLoading: true, + data: [] + } + }, + + componentWillMount() { + this.fetchSparklineData() + this.interval = setInterval(this.fetchSparklineData, 5000) + }, + + componentWillUnmount() { + clearInterval(this.interval) + }, + + onClick(e) { + if (e.target !== findDOMNode(this.refs.star)) { + this.props.onClick(this.props.currencyPair) + } + }, + + onToggle() { + this.props.onToggle(this.props.currencyPair) + }, + + fetchSparklineData() { + this.props.watcher.fetchChartData(this.props.currencyPair.key, 60 * 60 * 24).then((data) => { + this.setState({isLoading: false, data: data.map(d => d.close)}) + }) + }, + + renderSparkline(layout) { + if (this.state.isLoading) { + return
+ } + + return ( + + + + ) + }, + + render() { + const {currencyPair, trader} = this.props + const {name, currency, market, last, percentChange} = currencyPair + const color = percentChange == 0 ? 'black' : (percentChange > 0 ? 'green' : 'red') + const trendSymbol = percentChange > 0 ? '+' : '' + + return ( + + + + + + {name} + + + + + {last.toFixed(8)} + + + + + {trendSymbol}{percentChange.toFixed(2)}% + + + + + + {currency} + {market} + + + + {trader.getAvailableBalance(currency).toFixed(8)} + {trader.getAvailableBalance(market).toFixed(8)} + + + + + + + + + + + ) + } +}) diff --git a/app/CurrencyPairListView.js b/app/CurrencyPairListView.js new file mode 100644 index 0000000..6a8fc96 --- /dev/null +++ b/app/CurrencyPairListView.js @@ -0,0 +1,104 @@ +import React from 'react' +import PureRendererMixin from 'react-addons-pure-render-mixin' +import Immutable from 'immutable' +import firstBy from 'thenby' + +import Star from './Star' +import CurrencyPairView from './CurrencyPairView' +import CurrencyPairListItem from './CurrencyPairListItem' +import {Pane, Body, Header} from './Pane' +import {Gutter} from './Layout' + +export default React.createClass({ + propTypes: { + watcher: React.PropTypes.object.isRequired, + settings: React.PropTypes.object.isRequired, + trader: React.PropTypes.object.isRequired, + navigation: React.PropTypes.object + }, + + mixins: [PureRendererMixin], + + getInitialState() { + return { + filter: '', + shouldShowOnlyStarred: this.props.settings.get('shouldShowOnlyStarred', false), + starredCurrencyPairs: Immutable.Set(this.props.settings.get('starredCurrencyPairs', [])), + tickers: this.props.watcher.tickers, + } + }, + + componentWillMount() { + this.props.watcher.on('tickers', this.onTickers) + }, + + componentWillUnmount() { + this.props.watcher.off('tickers', this.onTickers) + }, + + onTickers(tickers) { + this.setState({tickers}) + }, + + onToggleShouldShowOnlyStarred(value) { + this.props.settings.set('shouldShowOnlyStarred', value) + this.setState({shouldShowOnlyStarred: value}) + }, + + onChangeFilter(e) { + this.setState({filter: e.target.value}) + }, + + onToggleCurrencyPair(currencyPair) { + const {starredCurrencyPairs} = this.state + let newStarredCurrencyPairs + + if (starredCurrencyPairs.includes(currencyPair.key)) { + newStarredCurrencyPairs = starredCurrencyPairs.delete(currencyPair.key) + } else { + newStarredCurrencyPairs = starredCurrencyPairs.add(currencyPair.key) + } + + this.props.settings.set('starredCurrencyPairs', newStarredCurrencyPairs.toJS()) + this.setState({starredCurrencyPairs: newStarredCurrencyPairs}) + }, + + onClickRow(currencyPair) { + this.props.navigation.push({component: CurrencyPairView, props: {currencyPair}}) + }, + + renderCurrencyPair(currencyPair) { + return ( + + ) + }, + + render() { + const {trader} = this.props + const filter = this.state.filter.toLocaleLowerCase() + const matches = this.state.tickers + .filter(p => this.state.shouldShowOnlyStarred ? this.state.starredCurrencyPairs.includes(p.key) : true) + .filter(p => this.state.filter.length > 0 ? p.key.toLocaleLowerCase().indexOf(filter) > -1 : true) + + return ( + +
+ + + +
+ + {matches.sort(firstBy('percentChange', -1)).map(this.renderCurrencyPair).toArray()} + +
+ ) + } +}) diff --git a/app/CurrencyPairView.js b/app/CurrencyPairView.js new file mode 100644 index 0000000..6d015e6 --- /dev/null +++ b/app/CurrencyPairView.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PureRendererMixin from 'react-addons-pure-render-mixin' +import plnx from 'plnx'; +import LoadingView from './LoadingView' +import Chart from './Chart' +import {Pane, Header, Body} from './Pane' + +export default React.createClass({ + propTypes: { + currencyPair: React.PropTypes.object.isRequired, + navigation: React.PropTypes.object + }, + + mixins: [PureRendererMixin], + + getInitialState() { + return { + isLoading: true, + data: [] + } + }, + + componentWillMount() { + const params = { + currencyPair: this.props.currencyPair.key, + start: Math.floor((Date.now() / 1000) - 60 * 60 * 24 * 7), + end: Math.floor(Date.now() / 1000), + period: 14400 + } + + plnx.returnChartData(params, (err, data) => { + if (err) { throw err } + this.setState({ + isLoading: false, + data: data.map((d) => { + d.date = new Date(d.date * 1000) + return d + }) + }) + }) + }, + + onClickBack() { + this.props.navigation.pop() + }, + + renderChart() { + if (this.state.isLoading) { + return + } + return + }, + + render() { + return ( + +
+ + {this.props.currencyPair.name} +
+ {this.renderChart()} +
+ ); + } +}) diff --git a/app/FlexibleLayout.js b/app/FlexibleLayout.js new file mode 100644 index 0000000..a6b285b --- /dev/null +++ b/app/FlexibleLayout.js @@ -0,0 +1,45 @@ +import React from 'react'; +import {findDOMNode} from 'react-dom'; + +export default React.createClass({ + propTypes: { + render: React.PropTypes.func.isRequired + }, + + getInitialState() { + return {} + }, + + componentDidMount() { + this.updateLayout() + window.addEventListener('resize', this.updateLayout); + }, + + componentWillUnmount() { + window.removeEventListener('resize', this.updateLayout); + }, + + updateLayout() { + this.setState({ + isUpdating: true + }, () => { + requestAnimationFrame(() => { + const el = findDOMNode(this) + if (el) { + this.setState({ + isUpdating: false, + layout: { + width: el.parentNode.clientWidth, + height: el.parentNode.clientHeight, + } + }) + } + }) + }) + }, + + render() { + if (!this.state.layout || this.state.isUpdating) { return