├── .node-version ├── .eslintignore ├── .gitignore ├── .env.defaults.json ├── .editorconfig ├── package.json ├── env.js ├── numbers.js ├── .eslintrc.json ├── license ├── readme.md └── buy.js /.node-version: -------------------------------------------------------------------------------- 1 | 8.4.0 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env.json 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.env.defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "FROM": "USD", 3 | "TO": "BTC", 4 | "MIN": 2000, 5 | "MAX": 8000 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitfin", 3 | "version": "2.0.1", 4 | "description": "Finance utility for Bitstamp", 5 | "author": "Nicolás Bevacqua (https://ponyfoo.com/)", 6 | "license": "MIT", 7 | "scripts": { 8 | "lint": "eslint ." 9 | }, 10 | "dependencies": { 11 | "nconf": "0.8.4", 12 | "request": "2.81.0" 13 | }, 14 | "devDependencies": { 15 | "babel-eslint": "7.2.3", 16 | "eslint": "4.6.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | const nconf = require('nconf') 2 | 3 | nconf.use('memory') 4 | nconf.argv() 5 | nconf.env() 6 | 7 | nconf.file('local', '.env.json') 8 | nconf.file('defaults', '.env.defaults.json') 9 | 10 | if (module.parent) { 11 | module.exports = accessor 12 | } else { 13 | print() 14 | } 15 | 16 | function accessor(key, value) { 17 | if (arguments.length === 2) { 18 | return nconf.set(key, value) 19 | } 20 | return nconf.get(key) 21 | } 22 | 23 | function print() { 24 | const argv = process.argv 25 | const prop = argv.length > 2 ? argv.pop() : false 26 | const unsafeConf = prop ? accessor(prop) : accessor() 27 | const conf = unsafeConf || {} 28 | 29 | // eslint-disable-next-line no-console 30 | console.log(JSON.stringify(conf, null, 2)) 31 | } 32 | -------------------------------------------------------------------------------- /numbers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function flip(text) { 4 | return text.split('').reverse().join('') 5 | } 6 | 7 | function toMoney(value, { precision = 2, currency = '$' } = {}) { 8 | const fixed = value.toFixed(precision) 9 | const [ integer, decimal ] = fixed.split('.') 10 | 11 | if (integer.startsWith('-')) { 12 | return `-${ moneyFormat(integer.slice(1)) }` 13 | } 14 | return moneyFormat(integer) 15 | 16 | function moneyFormat(integerPart) { 17 | return `${ prettyInt(integerPart) }.${ decimal } ${ currency }` 18 | } 19 | } 20 | 21 | function prettyInt(value) { 22 | const valueString = value.toString() 23 | const negative = valueString.startsWith('-') 24 | const absolute = negative ? valueString.slice(1) : valueString 25 | const sign = negative ? '-' : '' 26 | const rsplitter = /.{1,3}/g 27 | const bits = flip(flip(absolute).match(rsplitter).join(',')) 28 | return `${ sign }${ bits }` 29 | } 30 | 31 | module.exports = { toMoney, prettyInt } 32 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "env": { 5 | "node": true, 6 | "commonjs": true, 7 | "es6": true 8 | }, 9 | "rules": { 10 | "strict": 0, 11 | "indent": ["error", 2, { "SwitchCase": 1 }], 12 | "linebreak-style": ["error", "unix"], 13 | "quotes": ["error", "single"], 14 | "semi": ["error", "never"], 15 | "space-before-function-paren": ["error", { 16 | "named": "never", 17 | "anonymous": "always", 18 | "asyncArrow": "always" 19 | }], 20 | "array-callback-return": "error", 21 | "curly": "error", 22 | "eqeqeq": "error", 23 | "handle-callback-err": "error", 24 | "global-require": "error", 25 | "no-console": 0, 26 | "no-constant-condition": 0, 27 | "no-else-return": "error", 28 | "no-prototype-builtins": "error", 29 | "no-unsafe-negation": "error", 30 | "no-shadow": ["error", { "allow": ["next", "done", "err"] }], 31 | "no-shadow-restricted-names": "error" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2017 Nicolas Bevacqua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # bitfin 2 | 3 | Utility to manage your Bitstamp account. You may put these scripts into cron jobs to do DCA (Dollar-Cost Averaging) on your cryptocurrency investments. 4 | 5 | # setup 6 | 7 | You'll need to provide the following environment variables. The API key pair can be created under the Security tab in your Bitstamp account. 8 | 9 | - `BITSTAMP_ACCOUNT_ID` 10 | - `BITSTAMP_API_KEY` 11 | - `BITSTAMP_API_SECRET` 12 | 13 | You can opt to place these in a JSON document named `.env.json` at the root of this repository's directory. 14 | 15 | # `buy.js` 16 | 17 | The following command will attempt to place a single market order to purchase USD $ 100 worth of BTC. 18 | 19 | ``` 20 | node buy 100 21 | ``` 22 | 23 | You can choose the currency you want to purchase by changing the `TO` environment variable. The following command will attempt to buy USD $ 100 worth of LTC. 24 | 25 | ``` 26 | TO=LTC node buy 50 27 | ``` 28 | 29 | You can choose the currency you want to spend using `FROM`. The following command will attempt to buy 0.5 BTC worth of ETH. 30 | 31 | ``` 32 | FROM=BTC TO=ETH node buy 0.5 33 | ``` 34 | 35 | You can define limits on how much you'd like to spend. The following command attempts to buy USD $ 500 worth of ETH, provided the market price for 1 ETH is somewhere between USD $ 200 and USD $ 800. 36 | 37 | ``` 38 | TO=ETH MIN=200 MAX=800 node buy 500 39 | ``` 40 | 41 | The `TO`, `FROM`, `MIN`, and `MAX` variables can be placed in `.env.json` or in `.env.defaults.json`. 42 | 43 | # variables 44 | 45 | Environment Variable | Description | Default Value 46 | ----------------------|----------------------------------------|-------------------- 47 | `BITSTAMP_ACCOUNT_ID` | Your Bitstamp account ID | `undefined` 48 | `BITSTAMP_API_KEY` | Your Bitstamp API key | `undefined` 49 | `BITSTAMP_API_SECRET` | Your Bitstamp API secret | `undefined` 50 | `FROM` | Currency you want to spend | `'USD'` 51 | `TO` | Currency you want to purchase | `'BTC'` 52 | `MIN` | Minimum acceptable market asking price | `2000` 53 | `MAX` | Maximum acceptable market asking price | `8000` 54 | 55 | # license 56 | 57 | mit 58 | -------------------------------------------------------------------------------- /buy.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const querystring = require('querystring') 3 | const request = require('request') 4 | const env = require('./env') 5 | const { toMoney } = require('./numbers') 6 | const accountId = env('BITSTAMP_ACCOUNT_ID') 7 | const apiKey = env('BITSTAMP_API_KEY') 8 | const apiSecret = env('BITSTAMP_API_SECRET') 9 | const source = env('FROM') 10 | const target = env('TO') 11 | const min = env('MIN') 12 | const max = env('MAX') 13 | const argv = process.argv.slice(2) 14 | const [ sourceAmountRaw ] = argv 15 | const sourceAmount = parseFloat(sourceAmountRaw) 16 | const marketPair = `${ target.toLowerCase() }${ source.toLowerCase() }` 17 | const targetUpper = target.toUpperCase() 18 | const sourceUpper = source.toUpperCase() 19 | 20 | main().catch(bail) 21 | 22 | async function main() { 23 | if (!accountId) { 24 | return Promise.reject(`Please enter your Bitstamp account ID.`) 25 | } 26 | 27 | if (!apiKey) { 28 | return Promise.reject(`Please enter your Bitstamp API key.`) 29 | } 30 | 31 | if (!apiSecret) { 32 | return Promise.reject(`Please enter your Bitstamp API secret.`) 33 | } 34 | 35 | if (!sourceAmount) { 36 | return Promise.reject(`Please indicate ${ targetUpper } amount you want to spend: \`node buy $AMOUNT\`.`) 37 | } 38 | 39 | const askingPrice = await getAskingPrice() 40 | const askingPriceMoney = toMoney(parseFloat(askingPrice), { currency: sourceUpper }) 41 | const amountInTarget = Number.parseFloat((sourceAmount / askingPrice).toFixed(8)) 42 | const amountInTargetMoney = toMoney(amountInTarget, { currency: targetUpper, precision: 8 }) 43 | 44 | console.log(`Asking price is ${ askingPriceMoney }.`) 45 | console.log(`Will buy ${ amountInTargetMoney } (equivalent to ${ toMoney(sourceAmount, { currency: sourceUpper }) } at ${ askingPriceMoney } per ${ toMoney(1, { currency: targetUpper }) }).`) 46 | 47 | await buyCurrency(amountInTarget) 48 | 49 | console.log('Buy order complete.') 50 | } 51 | 52 | async function getAskingPrice() { 53 | const { status, body } = await r2({ url: `https://www.bitstamp.net/api/v2/ticker/${ marketPair }/` }) 54 | 55 | if (status !== 200) { 56 | return Promise.reject(`Failed to read asking price (${ targetUpper }).`) 57 | } 58 | 59 | if (body.status === 'error') { 60 | return Promise.reject(`Failed to buy ${ targetUpper }:\n${ JSON.stringify(body.reason, null, 2) }`) 61 | } 62 | 63 | const { ask } = body 64 | const askFloat = parseFloat(ask) 65 | const askMoney = toMoney(askFloat, { currency: sourceUpper }) 66 | 67 | if (askFloat < min) { 68 | return Promise.reject(`Asking price (${ targetUpper }) is too low: ${ askMoney }.`) 69 | } 70 | 71 | if (askFloat > max) { 72 | return Promise.reject(`Asking price (${ targetUpper }) is too high: ${ askMoney }.`) 73 | } 74 | 75 | return askFloat 76 | } 77 | 78 | async function buyCurrency(amount) { 79 | const nonce = new Date().valueOf() 80 | const signature = getSignature(nonce) 81 | const form = { 82 | key: apiKey, 83 | nonce, 84 | signature, 85 | amount 86 | } 87 | 88 | const url = `https://www.bitstamp.net/api/v2/buy/market/${ marketPair }/` 89 | const { status, body } = await r2({ 90 | method: 'POST', 91 | url, 92 | form 93 | }) 94 | 95 | if (status !== 200) { 96 | return Promise.reject(`Failed to buy ${ targetUpper }.`) 97 | } 98 | 99 | if (body.status === 'error') { 100 | return Promise.reject(`Failed to buy ${ targetUpper }${ parseBitstampError(body.reason) }`) 101 | } 102 | } 103 | 104 | function parseBitstampError(reason) { 105 | const reasons = Object 106 | .keys(reason) 107 | .reduce((reasons, key) => [ ...reasons, ...reason[key] ], []) 108 | 109 | if (reasons.length === 0) { 110 | return ' (internal server error).' 111 | } 112 | 113 | return ':\n - ' + reasons.join('\n - ') 114 | } 115 | 116 | function getSignature(nonce) { 117 | const message = nonce + accountId + apiKey 118 | const hmac = crypto.createHmac('sha256', new Buffer(apiSecret, 'utf8')) 119 | const hash = hmac.update(message).digest('hex').toUpperCase() 120 | return hash 121 | } 122 | 123 | function r2(options) { 124 | return new Promise((resolve, reject) => { 125 | request({ json: true, ...options }, (error, response, body) => { 126 | if (error) { 127 | reject(error) 128 | return 129 | } 130 | 131 | resolve({ status: response.statusCode, body }) 132 | }) 133 | }) 134 | } 135 | 136 | function bail(reason) { 137 | console.error(reason) 138 | process.exit(1) 139 | } 140 | --------------------------------------------------------------------------------