├── db └── .gitkeep ├── .vscode └── settings.json ├── public ├── robots.txt └── index.html ├── src ├── utils.js ├── setupTests.js ├── index.css ├── components │ ├── earning.js │ ├── lending.js │ └── status.js ├── index.js ├── app.js └── serviceWorker.js ├── server ├── db.js ├── config.js ├── utils.test.js ├── sync-funding-earning.js ├── scheduler.js ├── custom-config.example.js ├── strategy.test.js ├── index.js ├── strategy.js ├── submit-funding-offer.js ├── bitfinex.js └── utils.js ├── docker-compose.yml ├── Dockerfile ├── .gitignore ├── README.md └── package.json /db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function toPercentage(num) { 2 | return `${(num * 100).toFixed(2)}%`; 3 | } -------------------------------------------------------------------------------- /server/db.js: -------------------------------------------------------------------------------- 1 | const Datastore = require("nedb-promises"); 2 | const db = {}; 3 | 4 | db.earnings = Datastore.create("db/earning.db"); 5 | 6 | module.exports = db; 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | build: . 5 | command: yarn server:dev 6 | volumes: 7 | - .:/app 8 | ports: 9 | - "3001:3001" 10 | env_file: 11 | - .env -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv"); 2 | const customConfig = require("./custom-config"); 3 | dotenv.config(); 4 | 5 | module.exports = { 6 | ...customConfig, 7 | API_KEY: process.env.API_KEY, 8 | API_SECRET: process.env.API_SECRET 9 | }; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13.8.0-alpine 2 | LABEL maintainer="royal3501@gmail.com" 3 | 4 | RUN mkdir /app 5 | WORKDIR /app 6 | 7 | COPY package.json ./ 8 | COPY yarn.lock ./ 9 | 10 | RUN yarn install 11 | 12 | COPY . . 13 | 14 | EXPOSE 5000 15 | CMD ["yarn", "server"] -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /server/utils.test.js: -------------------------------------------------------------------------------- 1 | const { getPeriod } = require("./utils"); 2 | 3 | test("test peiord-rate mapping", () => { 4 | const rates = [0.0001, 0.0002, 0.00033, 0.0004, 0.0006, 0.0007, 0.001]; 5 | const expects = [2, 2, 3, 5, 10, 20, 30]; 6 | rates.forEach((rate, idx) => { 7 | expect(getPeriod(rate)).toBe(expects[idx]); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: black; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | server/custom-config.js 27 | 28 | db.json 29 | db/* 30 | !db/.gitkeep 31 | -------------------------------------------------------------------------------- /src/components/earning.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStyletron } from "baseui"; 3 | import { Table } from "baseui/table-semantic"; 4 | 5 | import moment from "moment"; 6 | 7 | function Earning({ earnings }) { 8 | const [css] = useStyletron(); 9 | if (earnings.length === 0) { 10 | return null; 11 | } 12 | return ( 13 |
14 | [ 17 | `$${l.amount.toFixed(4)}`, 18 | moment(l.mts).format("L"), 19 | moment(l.mts).fromNow() 20 | ])} 21 | /> 22 | 23 | ); 24 | } 25 | 26 | export default Earning; -------------------------------------------------------------------------------- /server/sync-funding-earning.js: -------------------------------------------------------------------------------- 1 | const bitfinext = require("./bitfinex"); 2 | const { getFundingEarning } = bitfinext; 3 | 4 | const db = require("./db"); 5 | 6 | async function main() { 7 | const earnings = (await getFundingEarning()).map(e => { 8 | e._id = e.id; 9 | delete e.id; 10 | return e; 11 | }); 12 | 13 | const last = await db.earnings.findOne().sort({ _id: -1 }); 14 | 15 | if (last === null) { 16 | db.earnings.insert(earnings); 17 | console.log(`Finished: ${earnings.length} record added.`); 18 | } else { 19 | const updated = earnings.filter(e => e._id > last._id); 20 | db.earnings.insert(updated); 21 | console.log(`Finished: ${updated.length} record added.`); 22 | } 23 | } 24 | 25 | module.exports = main; 26 | 27 | if (require.main === module) { 28 | main(); 29 | } 30 | -------------------------------------------------------------------------------- /server/scheduler.js: -------------------------------------------------------------------------------- 1 | const schedule = require("node-schedule"); 2 | const checkAndSubmitOffer = require("./submit-funding-offer"); 3 | const syncEarning = require("./sync-funding-earning"); 4 | const { toTime } = require("./utils"); 5 | 6 | module.exports = () => { 7 | console.log("start scheduler"); 8 | 9 | schedule.scheduleJob("*/3 * * * *", async function () { 10 | console.log(`${toTime()}: Check and submit funding offers automatically`); 11 | await checkAndSubmitOffer(); 12 | await checkAndSubmitOffer({ ccy: "UST" }); 13 | }); 14 | 15 | // TODO: the time might be set differently if you have non taipei timezone 16 | ["35 9 * * *", "40 9 * * *", "50 9 * * *"].forEach(rule => { 17 | schedule.scheduleJob(rule, function () { 18 | console.log(`${toTime()}: Sync Earning`); 19 | syncEarning(); 20 | }); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Client as Styletron } from "styletron-engine-atomic"; 4 | import { Provider as StyletronProvider } from "styletron-react"; 5 | import { DarkTheme, BaseProvider } from "baseui"; 6 | import "./index.css"; 7 | import App from "./app"; 8 | import * as serviceWorker from "./serviceWorker"; 9 | 10 | const engine = new Styletron(); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById("root") 19 | ); 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister(); 25 | -------------------------------------------------------------------------------- /server/custom-config.example.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Strategy: { 3 | splitEqually: { 4 | MIN_TO_LEND: 50, 5 | NUM_ALL_IN: 1100, 6 | SPLIT_UNIT: 1000, 7 | RATE_EXPECTED_OVER_AMOUNT: 50000 8 | }, 9 | splitPyramidally: { 10 | MIN_TO_LEND: 50, 11 | UP_BOUND_RATE: 0.001, // around 40% annual rate 12 | LOW_BOUND_RATE: 0.0001, // around 4% annual rate 13 | AMOUNT_GROW_EXP: 1.4, 14 | AMOUNT_INIT_MAP: [ 15 | [0.0007, 1200], 16 | [0.0006, 900], 17 | [0.0005, 700], 18 | [0.0004, 550], 19 | [0.0003, 400], 20 | [0.0002, 300] 21 | ], 22 | RATE_EXPECTED_OVER_AMOUNT: 10000 23 | } 24 | }, 25 | Rate: { 26 | EXPECTED_AMOUNT: 50000 27 | }, 28 | Period: { 29 | PERIOD_MAP: [ 30 | [0.3, 30], 31 | [0.25, 20], 32 | [0.2, 10], 33 | [0.15, 5], 34 | [0.12, 3] 35 | ] 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bitfinex Lending Bot 2 | The project is written in React (create-react-app) and nodejs (expressjs). 3 | 4 | Screen Shot 2020-03-14 at 8 51 21 PM 5 | 6 | 7 | ## Prerequisite 8 | yarn, docker, docker-compose 9 | 10 | ## Installation 11 | - Create a new file `.env` under the current directory and put your key, secret and timezone here. 12 | ``` 13 | API_KEY=xxx 14 | API_SECRET=xxx 15 | 16 | TZ=Asia/Taipei 17 | ``` 18 | 19 | - Copy `server/custom-config.example.js` to `server/custom-config.js`, we can playground with the numbers in this config. 20 | 21 | - Run `yarn` to install required packages 22 | 23 | ## Run the bot 24 | If you just want to start the bot and automatically lend your money out, you only need to start the backend service. 25 | It will check your remaining/submit funding offers every 3 minutes. 26 | 27 | ``` 28 | docker-compose up 29 | ``` 30 | 31 | ## Run the auto submit once 32 | Although the bot checks and submits offers regularly, you can run the script directly. 33 | 34 | ``` 35 | yarn auto-submit 36 | ``` 37 | 38 | ## Start the ui (optional) 39 | 40 | ``` 41 | yarn start 42 | ``` 43 | 44 | ### Deploy to your own server: 45 | 46 | api: `docker-compose up -d` and serve it in a proxy server such as nginx 47 | 48 | ui: `REACT_APP_API_URL='https://yourserverurl.com' yarn build` 49 | 50 | 51 | -------------------------------------------------------------------------------- /server/strategy.test.js: -------------------------------------------------------------------------------- 1 | const Stratege = require("./strategy"); 2 | 3 | test("test strategy: split normally", async () => { 4 | const ccy = "USD"; 5 | const rate = 0.0007; 6 | const avaliableBalance = 1500; 7 | const offers = Stratege.splitEqually(avaliableBalance, rate, ccy); 8 | 9 | expect(offers[0].amount).toBe(1000); 10 | expect(offers[0].period).toBe(20); 11 | expect(offers[0].rate).toBe(0.0007); 12 | expect(offers[1].amount).toBe(500); 13 | expect(offers[1].period).toBe(20); 14 | expect(offers[1].rate).toBe(0.0007); 15 | }); 16 | 17 | test("test strategy: split pyramidally", async () => { 18 | const ccy = "USD"; 19 | let rate, avaliableBalance, offers; 20 | 21 | rate = 0.0004; 22 | avaliableBalance = 3000; 23 | offers = Stratege.splitPyramidally(avaliableBalance, rate, ccy); 24 | 25 | expect(offers[0]).toEqual({ 26 | amount: 350, 27 | rate: 0.0004, 28 | period: 5, 29 | ccy: "USD" 30 | }); 31 | 32 | expect(offers[4]).toEqual({ 33 | amount: 725.76, 34 | rate: 0.0005178153086419753, 35 | period: 10, 36 | ccy: "USD" 37 | }); 38 | 39 | rate = 0.0015; 40 | offers = Stratege.splitPyramidally(avaliableBalance, rate, ccy); 41 | expect(offers[1]).toEqual({ 42 | amount: 1200, 43 | rate: 0.0015, 44 | period: 30, 45 | ccy: "USD" 46 | }); 47 | 48 | rate = 0.00005; 49 | offers = Stratege.splitPyramidally(avaliableBalance, rate, ccy); 50 | expect(offers[0]).toEqual({ 51 | amount: 100, 52 | rate: 0.00005, 53 | period: 2, 54 | ccy: "USD" 55 | }); 56 | expect(offers[2]).toEqual({ 57 | amount: 144, 58 | rate: 0.000060500000000000014, 59 | period: 2, 60 | ccy: "USD" 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 24 | Bitfinex Lending Bot 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitfinex-lending-bot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "baseui": "^9.105.0", 10 | "bfx-api-node-rest": "bitfinexcom/bfx-api-node-rest#f7c2d31", 11 | "bitfinex-api-node": "^4.0.10", 12 | "cors": "^2.8.5", 13 | "dotenv": "^8.2.0", 14 | "express": "^4.17.1", 15 | "lowdb": "^1.0.0", 16 | "moment": "^2.24.0", 17 | "nedb": "^1.8.0", 18 | "nedb-promises": "^4.0.1", 19 | "node-cache": "^5.1.0", 20 | "node-schedule": "^1.3.2", 21 | "react": "^16.12.0", 22 | "react-dom": "^16.12.0", 23 | "react-scripts": "3.4.0", 24 | "styletron-engine-atomic": "^1.4.6", 25 | "styletron-react": "^5.2.7" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject", 32 | "server": "node server/index", 33 | "server:dev": "nodemon --watch server server/index", 34 | "server:test": "jest \"server/.*\\.test\\.js\"", 35 | "auto-submit": "node server/submit-funding-offer", 36 | "auto-submit:ust": "node server/submit-funding-offer ust", 37 | "sync-earning": "node server/sync-funding-earning" 38 | }, 39 | "eslintConfig": { 40 | "extends": "react-app" 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "nodemon": "^2.0.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const cors = require("cors"); 3 | const NodeCache = require("node-cache"); 4 | 5 | const bitfinext = require("./bitfinex"); 6 | const { compoundInterest, getLowRate } = require("./utils"); 7 | const scheduler = require("./scheduler"); 8 | const db = require("./db"); 9 | 10 | const app = express(); 11 | const port = 3001; 12 | const cache = new NodeCache(); 13 | 14 | app.use(cors()); 15 | 16 | app.get("/api/data", async (req, res) => { 17 | const getDataByCurrency = async ccy => { 18 | const balance = await bitfinext.getBalance(ccy); 19 | const availableBalance = await bitfinext.getAvailableBalance(ccy); 20 | const lending = (await bitfinext.getCurrentLending(ccy)).map(l => ({ 21 | amount: l.amount, 22 | period: l.period, 23 | rate: compoundInterest(l.rate).toFixed(4), 24 | exp: l.time + l.period * 86400000 25 | })); 26 | 27 | const rate = compoundInterest(await getLowRate(ccy)).toFixed(4); 28 | 29 | // take only recently 30 days 30 | const day30diff = 30 * 24 * 3600 * 1000; 31 | const day30ago = Date.now() - day30diff; 32 | const earnings = await db.earnings 33 | .find({ 34 | mts: { $gt: day30ago }, 35 | currency: ccy 36 | }) 37 | .sort({ _id: -1 }); 38 | 39 | return { ccy, balance, availableBalance, lending, earnings, rate }; 40 | }; 41 | 42 | let data = cache.get("data"); 43 | if (data) { 44 | return res.status(200).json(data); 45 | } 46 | 47 | const usdData = await getDataByCurrency("USD"); 48 | const ustData = await getDataByCurrency("UST"); 49 | data = [usdData, ustData]; 50 | 51 | cache.set("data", data, 10); 52 | return res.status(200).json(data); 53 | }); 54 | 55 | app.listen(port, () => { 56 | console.log(`bitfinex lending bot api on port ${port}!`); 57 | scheduler(); 58 | }); 59 | -------------------------------------------------------------------------------- /server/strategy.js: -------------------------------------------------------------------------------- 1 | const { getPeriod, getRate, step } = require("./utils"); 2 | const { Strategy: config } = require("./config"); 3 | 4 | const splitEqually = async (avaliableBalance, ccy) => { 5 | const CONFIG = config.splitEqually; 6 | const MIN_TO_LEND = CONFIG.MIN_TO_LEND; 7 | const NUM_ALL_IN = CONFIG.NUM_ALL_IN; 8 | const SPLIT_UNIT = CONFIG.SPLIT_UNIT; 9 | const rate = await getRate(ccy, CONFIG.RATE_EXPECTED_OVER_AMOUNT); 10 | 11 | const amounts = []; 12 | while (avaliableBalance > NUM_ALL_IN) { 13 | amounts.push(SPLIT_UNIT); 14 | avaliableBalance -= SPLIT_UNIT; 15 | } 16 | 17 | if (avaliableBalance <= NUM_ALL_IN && avaliableBalance >= MIN_TO_LEND) { 18 | amounts.push(avaliableBalance); 19 | } 20 | 21 | const period = getPeriod(rate); 22 | return amounts.map(amount => ({ 23 | rate, 24 | amount, 25 | period, 26 | ccy 27 | })); 28 | }; 29 | 30 | function getDerivedRate(l, h, x) { 31 | x = Math.max(l, Math.min(h, x)); 32 | return 1 + (1 - (x - l) / (h - l)) * 0.1; 33 | } 34 | 35 | // default stratege 36 | const splitPyramidally = async (avaliableBalance, ccy) => { 37 | const CONFIG = config.splitPyramidally; 38 | const MIN_TO_LEND = CONFIG.MIN_TO_LEND; 39 | const UP_BOUND_RATE = CONFIG.UP_BOUND_RATE; 40 | const LOW_BOUND_RATE = CONFIG.LOW_BOUND_RATE; 41 | const offers = []; 42 | const baseRate = await getRate(ccy, CONFIG.RATE_EXPECTED_OVER_AMOUNT); 43 | let amountInit = step(CONFIG.AMOUNT_INIT_MAP, baseRate); 44 | let amount; 45 | let rate; 46 | let i = 0; 47 | 48 | while (avaliableBalance > MIN_TO_LEND) { 49 | amount = Math.min( 50 | avaliableBalance, 51 | amountInit * Math.pow(CONFIG.AMOUNT_GROW_EXP, i) 52 | ); 53 | amount = Math.floor(amount); 54 | rate = 55 | baseRate * 56 | Math.pow(getDerivedRate(LOW_BOUND_RATE, UP_BOUND_RATE, baseRate), i); 57 | 58 | offers.push({ 59 | amount, 60 | rate, 61 | period: getPeriod(rate), 62 | ccy 63 | }); 64 | avaliableBalance -= amount; 65 | i++; 66 | } 67 | 68 | return offers; 69 | }; 70 | 71 | module.exports = { 72 | splitEqually, 73 | splitPyramidally 74 | }; 75 | -------------------------------------------------------------------------------- /server/submit-funding-offer.js: -------------------------------------------------------------------------------- 1 | const bitfinext = require("./bitfinex"); 2 | const { 3 | getBalance, 4 | getAvailableBalance, 5 | getCurrentLending, 6 | cancelAllFundingOffers, 7 | submitFundingOffer 8 | } = bitfinext; 9 | const { 10 | readableLend, 11 | toTime, 12 | readableOffer, 13 | sleep, 14 | asyncForEach 15 | } = require("./utils"); 16 | const Stratege = require("./strategy"); 17 | 18 | async function getFundingOffers(avaliableBalance, ccy) { 19 | return Stratege.splitPyramidally(avaliableBalance, ccy); 20 | } 21 | 22 | function printStatus(balance, lending, offers) { 23 | console.log("========================================================="); 24 | const time = toTime(); 25 | console.log(`Time: ${time}`); 26 | console.log(`Balance: $${balance}`); 27 | console.log("Status:"); 28 | const items = lending.map(l => ({ 29 | ...readableLend(l), 30 | executed: true 31 | })); 32 | 33 | offers.forEach(o => { 34 | items.push({ 35 | ...readableOffer(o), 36 | exp: null, 37 | executed: false 38 | }); 39 | }); 40 | if (lending.length) { 41 | console.table(items); 42 | } 43 | } 44 | 45 | /* 46 | The bot currently only monitors and auto submit offers for USD. 47 | You need to operate USDt maually. 48 | */ 49 | async function main({ showDetail = false, ccy = "USD" } = {}) { 50 | await cancelAllFundingOffers(ccy); 51 | 52 | const balance = await getBalance(ccy); 53 | const lending = await getCurrentLending(ccy); 54 | const avaliableBalance = await getAvailableBalance(ccy); 55 | const offers = await getFundingOffers(avaliableBalance, ccy); 56 | 57 | // submit funding offer 58 | if (process.env.NODE_ENV === "development") { 59 | offers.forEach(offer => console.log(readableOffer(offer))); 60 | } else { 61 | asyncForEach(offers, async offer => { 62 | await submitFundingOffer(offer); 63 | await sleep(500); 64 | }); 65 | } 66 | 67 | if (showDetail) { 68 | printStatus(balance, lending, offers); 69 | } 70 | } 71 | 72 | module.exports = main; 73 | 74 | if (require.main === module) { 75 | let ccy = "USD"; 76 | if (process.argv.length > 2 && process.argv[2] === "ust") { 77 | ccy = "UST"; 78 | } 79 | main({ showDetail: true, ccy }); 80 | } 81 | -------------------------------------------------------------------------------- /src/components/lending.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStyletron } from "baseui"; 3 | import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; 4 | 5 | import moment from "moment"; 6 | 7 | import { toPercentage } from "../utils"; 8 | 9 | function Lending({ lending }) { 10 | const [css] = useStyletron(); 11 | const [sortColumn, setSortColumn] = React.useState(null); 12 | const [sortAsc, setSortAsc] = React.useState(true); 13 | const [data] = React.useState(lending); 14 | 15 | const sortedData = React.useMemo(() => { 16 | return data.slice().sort((a, b) => { 17 | const left = sortAsc ? a : b; 18 | const right = sortAsc ? b : a; 19 | const leftValue = String(left[sortColumn]); 20 | const rightValue = String(right[sortColumn]); 21 | return leftValue.localeCompare(rightValue, 'en', { 22 | numeric: true, 23 | sensitivity: 'base', 24 | }); 25 | }); 26 | }, [sortColumn, sortAsc, data]); 27 | 28 | function handleSort(id) { 29 | if (id === sortColumn) { 30 | setSortAsc(asc => !asc); 31 | } else { 32 | setSortColumn(id); 33 | setSortAsc(true); 34 | } 35 | } 36 | 37 | return ( 38 |
39 | 45 | 46 | {row => `$${row.amount.toFixed(2)}`} 47 | 48 | 53 | {row => row.period} 54 | 55 | 60 | {row => toPercentage(row.rate)} 61 | 62 | 67 | {row => moment(row.exp).fromNow()} 68 | 69 | 70 |
71 | ); 72 | } 73 | 74 | export default Lending; -------------------------------------------------------------------------------- /server/bitfinex.js: -------------------------------------------------------------------------------- 1 | const { RESTv2 } = require("bfx-api-node-rest"); 2 | const { FundingOffer } = require("bfx-api-node-models"); 3 | const config = require("./config"); 4 | 5 | const client = new RESTv2({ 6 | apiKey: config.API_KEY, 7 | apiSecret: config.API_SECRET, 8 | transform: true 9 | }); 10 | 11 | const DEFAULT_CCY = "USD"; 12 | 13 | async function getBalance(ccy = DEFAULT_CCY) { 14 | const wallets = await client.wallets(); 15 | const wallet = wallets.find(w => w.type === "funding" && w.currency === ccy); 16 | if (wallet) { 17 | return wallet.balance; 18 | } 19 | return 0; 20 | } 21 | 22 | async function getAvailableBalance(ccy = DEFAULT_CCY) { 23 | const balance = await client.calcAvailableBalance(`f${ccy}`, 0, 0, 'FUNDING'); 24 | return Math.abs(balance[0]); // not sure why the value is negative 25 | } 26 | 27 | async function getCurrentLending(ccy = DEFAULT_CCY) { 28 | // get current active lending 29 | return client.fundingCredits(`f${ccy}`).map(c => ({ 30 | amount: c.amount, 31 | rate: c.rate, 32 | period: c.period, 33 | time: c.mtsOpening 34 | })); 35 | } 36 | 37 | async function cancelAllFundingOffers(ccy = DEFAULT_CCY) { 38 | return client.cancelAllFundingOffers({ currency: ccy }); 39 | } 40 | 41 | async function submitFundingOffer({ 42 | rate, 43 | amount, 44 | period = 2, 45 | ccy = DEFAULT_CCY 46 | }) { 47 | return client.submitFundingOffer( 48 | new FundingOffer({ 49 | type: "LIMIT", 50 | symbol: `f${ccy}`, 51 | rate, 52 | amount, 53 | period 54 | }) 55 | ); 56 | } 57 | 58 | async function getFundingBook(ccy = DEFAULT_CCY) { 59 | const book = await client.orderBook(`f${ccy}`); 60 | return { 61 | request: book.filter(item => item[3] < 0), 62 | offer: book.filter(item => item[3] > 0) 63 | }; 64 | } 65 | 66 | async function getFundingEarning(ccy = null) { 67 | const ONE_DAY_IN_MS = 86400000; 68 | const now = Date.now(); 69 | const options = { category: 28 }; 70 | if (ccy) { 71 | options.ccy = ccy; 72 | } 73 | const res = await client.ledgers(options, now - ONE_DAY_IN_MS * 30, now, 500); 74 | 75 | const earnings = res 76 | .map(r => ({ 77 | id: r.id, 78 | currency: r.currency, 79 | amount: r.amount, 80 | balance: r.balance, 81 | mts: r.mts 82 | })) 83 | .reverse(); 84 | return earnings; 85 | } 86 | 87 | module.exports = { 88 | client, 89 | getBalance, 90 | getAvailableBalance, 91 | getCurrentLending, 92 | cancelAllFundingOffers, 93 | submitFundingOffer, 94 | getFundingBook, 95 | getFundingEarning 96 | }; 97 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useStyletron } from "baseui"; 3 | import { Spinner } from "baseui/spinner"; 4 | import { Tabs, Tab } from "baseui/tabs"; 5 | import moment from "moment"; 6 | 7 | import Status from './components/status'; 8 | import Lending from './components/lending'; 9 | import Earning from './components/earning'; 10 | 11 | import "moment/locale/zh-tw"; 12 | 13 | moment.locale("zh-tw"); 14 | 15 | const API_URL = process.env.REACT_APP_API_URL || "http://localhost:3001"; 16 | 17 | 18 | function App() { 19 | const [css, theme] = useStyletron(); 20 | const [currency, setCurrency] = React.useState(); 21 | const [data, setData] = React.useState(null); 22 | const [activeKey, setActiveKey] = React.useState("0"); 23 | 24 | React.useEffect(() => { 25 | async function fetchData() { 26 | const res = await fetch(`${API_URL}/api/data`).then(res => res.json()); 27 | if (!res || res.length === 0) { 28 | return; 29 | } 30 | 31 | const data = {}; 32 | res.forEach(ccyData => { 33 | data[ccyData.ccy] = ccyData; 34 | }); 35 | setCurrency(res[0].ccy); 36 | setData(data); 37 | } 38 | fetchData(); 39 | }, []); 40 | 41 | if (data === null) { 42 | return ( 43 |
50 | 51 |
52 | ); 53 | } 54 | 55 | const { balance, lending, availableBalance, earnings, rate } = data[currency]; 56 | 57 | return ( 58 |
66 | 74 |
79 | { 81 | setActiveKey(activeKey); 82 | }} 83 | activeKey={activeKey} 84 | > 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 |
94 | ); 95 | } 96 | 97 | export default App; 98 | -------------------------------------------------------------------------------- /server/utils.js: -------------------------------------------------------------------------------- 1 | const { getFundingBook } = require("./bitfinex"); 2 | const { Rate: rateConfig, Period: periodConfig } = require("./config"); 3 | 4 | function compoundInterest(rate) { 5 | return Math.pow(1 + rate, 365) - 1; 6 | } 7 | 8 | function toTime(arg) { 9 | const time = arg ? new Date(arg) : new Date(); 10 | return time.toLocaleString("en-us"); 11 | } 12 | 13 | function readableRate(rate) { 14 | return Number(compoundInterest(rate).toFixed(4)); 15 | } 16 | 17 | function readableLend(lend) { 18 | return { 19 | amount: Number(lend.amount.toFixed(2)), 20 | period: lend.period, 21 | rate: readableRate(lend.rate), 22 | exp: toTime(lend.time + lend.period * 86400000) 23 | }; 24 | } 25 | 26 | function readableOffer(offer) { 27 | return { 28 | amount: Number(offer.amount.toFixed(2)), 29 | period: offer.period, 30 | rate: readableRate(offer.rate) 31 | }; 32 | } 33 | 34 | function getPeriod(rate) { 35 | // TODO: dynamically decide the mapping 36 | const mapping = periodConfig.PERIOD_MAP; 37 | 38 | const annual_rate = compoundInterest(rate); 39 | for (let [r, p] of mapping) { 40 | if (annual_rate >= r) { 41 | return p; 42 | } 43 | } 44 | return 2; 45 | } 46 | 47 | async function getRate(ccy, expected_over_amount = 50000) { 48 | const RATE_OFFSET = 0.00000001; 49 | 50 | // get funding book 51 | const offers = (await getFundingBook(ccy)).offer; 52 | 53 | let total = 0; 54 | let idx = 0; 55 | for (; idx < offers.length; idx++) { 56 | total += offers[idx][3] * offers[idx][2]; 57 | if (total > expected_over_amount) { 58 | break; 59 | } 60 | } 61 | const rate = 62 | idx === offers.length ? offers[idx - 1][0] : offers[idx][0] - RATE_OFFSET; 63 | 64 | return rate; 65 | } 66 | 67 | async function getLowRate(ccy) { 68 | const offers = (await getFundingBook(ccy)).offer; 69 | 70 | return offers[0][0]; 71 | } 72 | 73 | function step(mapping, key) { 74 | for (let [k, v] of mapping) { 75 | if (key >= k) { 76 | return v; 77 | } 78 | } 79 | return mapping[mapping.length - 1][1]; 80 | } 81 | 82 | function sleep(ms) { 83 | return new Promise(resolve => setTimeout(resolve, ms)); 84 | } 85 | 86 | async function asyncForEach(array, callback) { 87 | for (let index = 0; index < array.length; index++) { 88 | await callback(array[index], index, array); 89 | } 90 | } 91 | 92 | module.exports = { 93 | toTime, 94 | compoundInterest, 95 | readableRate, 96 | readableLend, 97 | readableOffer, 98 | getPeriod, 99 | getRate, 100 | getLowRate, 101 | step, 102 | sleep, 103 | asyncForEach 104 | }; 105 | -------------------------------------------------------------------------------- /src/components/status.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useStyletron } from "baseui"; 3 | 4 | import { toPercentage } from "../utils"; 5 | 6 | function CurrencyToggle({ activeCurrency, onCurrencyChange }) { 7 | const [css, theme] = useStyletron(); 8 | 9 | const ccyStyle = ccy => { 10 | const style = { cursor: "pointer", fontSize: "30px" }; 11 | if (ccy === activeCurrency) { 12 | return { ...style, fontSize: "50px", color: theme.colors.primary300 }; 13 | } 14 | return style; 15 | }; 16 | 17 | return ( 18 |
29 |
onCurrencyChange("USD")} 32 | > 33 | USD 34 |
35 |
onCurrencyChange("UST")} 38 | > 39 | USDt 40 |
41 |
42 | ); 43 | } 44 | 45 | function StatusPanel({ title, value }) { 46 | const [css, theme] = useStyletron(); 47 | return ( 48 |
55 | 62 | {title} 63 | 64 | 71 | {value} 72 | 73 |
74 | ); 75 | } 76 | 77 | function Status({ 78 | balance, 79 | availableBalance, 80 | earnings, 81 | rate, 82 | currency, 83 | onCurrencyChange 84 | }) { 85 | const [css] = useStyletron(); 86 | 87 | if (balance === null) { 88 | return null; 89 | } 90 | 91 | const earning30 = earnings.reduce((total, c) => total + c.amount, 0); 92 | 93 | return ( 94 |
102 | 103 | 104 | 105 | 106 | 110 |
111 | ); 112 | } 113 | 114 | export default Status; -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | --------------------------------------------------------------------------------