├── 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 |
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 | You need to enable JavaScript to run this app.
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 |
--------------------------------------------------------------------------------