First commit

This commit is contained in:
microwavedcola1 2021-09-08 07:00:59 +02:00
commit 37fcaa8bcb
17 changed files with 3542 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

5
.husky/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run pretty-quick --staged
npm run lint

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"search.exclude": {
".git": true,
"node_modules": true
},
"files.exclude": {
".git": true,
"node_modules": true
}
}

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "mango-v3-service",
"version": "0.0.1",
"description": "REST API for trading against serum & mango markets",
"main": "./src/server.ts",
"scripts": {
"dev": "ts-node ./src/server.ts",
"lint": "tslint -p tsconfig.json -c tslint.json",
"prepare": "husky install",
"pretty-quick": "pretty-quick",
"test": "jest"
},
"author": "microwavedcola1",
"license": "MIT",
"dependencies": {
"@blockworks-foundation/mango-client": "^3.0.13",
"body-parser": "^1.19.0",
"envalid": "^7.2.1",
"express": "^4.17.1",
"lodash": "^4.17.21",
"loglevel": "^1.7.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^2.0.4",
"@types/express": "^4.17.13",
"husky": "^7.0.2",
"prettier": "^2.3.2",
"prettier-plugin-organize-imports": "^2.3.3",
"pretty-quick": "^3.1.1",
"ts-node": "^10.2.1",
"tslint-config-prettier": "^1.18.0",
"typescript": "^4.4.2"
}
}

190
src/account.controller.ts Normal file
View File

@ -0,0 +1,190 @@
import {
getMarketByPublicKey,
PerpMarket,
ZERO_BN,
} from "@blockworks-foundation/mango-client";
import BN from "bn.js";
import Controller from "controller.interface";
import { NextFunction, Request, Response, Router } from "express";
import MangoSimpleClient from "mango.simple.client";
class AccountController implements Controller {
public path = "/positions";
public router = Router();
constructor(public mangoMarkets: MangoSimpleClient) {
this.initializeRoutes();
}
private initializeRoutes() {
this.router.get(this.path, this.getPositions);
}
private getPositions = async (
request: Request,
response: Response,
next: NextFunction
) => {
const groupConfig = this.mangoMarkets.mangoGroupConfig;
const mangoGroup = this.mangoMarkets.mangoGroup;
const mangoAccount = await this.mangoMarkets.mangoAccount.reload(
this.mangoMarkets.connection,
this.mangoMarkets.mangoGroup.dexProgramId
);
const mangoCache = await this.mangoMarkets.mangoGroup.loadCache(
this.mangoMarkets.connection
);
const allMarkets = await this.mangoMarkets.getAllMarkets();
const mangoAccountFills = await this.mangoMarkets.getAllFills(true);
const perpAccounts = mangoAccount
? groupConfig.perpMarkets.map((m) => {
return {
perpAccount: mangoAccount.perpAccounts[m.marketIndex],
marketIndex: m.marketIndex,
};
})
: [];
const filteredPerpAccounts = perpAccounts.filter(
({ perpAccount }) => !perpAccount.basePosition.eq(new BN(0))
);
const postionDtos = filteredPerpAccounts.map(
({ perpAccount, marketIndex }, index) => {
const perpMarketInfo =
this.mangoMarkets.mangoGroup.perpMarkets[marketIndex];
const marketConfig = getMarketByPublicKey(
groupConfig,
perpMarketInfo.perpMarket
);
const perpMarket = allMarkets[
perpMarketInfo.perpMarket.toBase58()
] as PerpMarket;
const perpTradeHistory = mangoAccountFills.filter(
(t) => t.marketName === marketConfig.name
);
let breakEvenPrice;
try {
breakEvenPrice = perpAccount.getBreakEvenPrice(
mangoAccount,
perpMarket,
perpTradeHistory
);
} catch (e) {
breakEvenPrice = null;
}
const pnl =
breakEvenPrice !== null
? perpMarket.baseLotsToNumber(perpAccount.basePosition) *
(this.mangoMarkets.mangoGroup
.getPrice(marketIndex, mangoCache)
.toNumber() -
parseFloat(breakEvenPrice))
: null;
let entryPrice;
try {
entryPrice = perpAccount.getAverageOpenPrice(
mangoAccount,
perpMarket,
perpTradeHistory
);
} catch {
entryPrice = 0;
}
return {
cost: Math.abs(
perpMarket.baseLotsToNumber(perpAccount.basePosition) *
mangoGroup.getPrice(marketIndex, mangoCache).toNumber()
),
cumulativeBuySize: undefined,
cumulativeSellSize: undefined,
entryPrice: entryPrice,
estimatedLiquidationPrice: undefined,
future: marketConfig.baseSymbol,
initialMarginRequirement: undefined,
longOrderSize: undefined,
maintenanceMarginRequirement: undefined,
netSize: undefined,
openSize: undefined,
realizedPnl: undefined,
recentAverageOpenPrice: undefined,
recentBreakEvenPrice: breakEvenPrice,
recentPnl: pnl,
shortOrderSize: undefined,
side: perpAccount.basePosition.gt(ZERO_BN) ? "long" : "short",
size: Math.abs(perpMarket.baseLotsToNumber(perpAccount.basePosition)),
unrealizedPnl: pnl,
collateralUsed: undefined,
} as PositionDto;
}
);
response.send({ success: true, result: postionDtos } as PositionsDto);
};
}
export default AccountController;
/// Dtos
// e.g.
// {
// "success": true,
// "result": [
// {
// "cost": -31.7906,
// "cumulativeBuySize": 1.2,
// "cumulativeSellSize": 0.0,
// "entryPrice": 138.22,
// "estimatedLiquidationPrice": 152.1,
// "future": "ETH-PERP",
// "initialMarginRequirement": 0.1,
// "longOrderSize": 1744.55,
// "maintenanceMarginRequirement": 0.04,
// "netSize": -0.23,
// "openSize": 1744.32,
// "realizedPnl": 3.39441714,
// "recentAverageOpenPrice": 135.31,
// "recentBreakEvenPrice": 135.31,
// "recentPnl": 3.1134,
// "shortOrderSize": 1732.09,
// "side": "sell",
// "size": 0.23,
// "unrealizedPnl": 0,
// "collateralUsed": 3.17906
// }
// ]
// }
interface PositionsDto {
success: boolean;
result: PositionDto[];
}
interface PositionDto {
cost: number;
cumulativeBuySize: number;
cumulativeSellSize: number;
entryPrice: number;
estimatedLiquidationPrice: number;
future: "ETH-PERP";
initialMarginRequirement: number;
longOrderSize: number;
maintenanceMarginRequirement: number;
netSize: number;
openSize: number;
realizedPnl: number;
recentAverageOpenPrice: number;
recentBreakEvenPrice: number;
recentPnl: number;
shortOrderSize: number;
side: string;
size: number;
unrealizedPnl: number;
collateralUsed: number;
}

49
src/app.ts Normal file
View File

@ -0,0 +1,49 @@
import AccountController from "./account.controller";
import MangoSimpleClient from "./mango.simple.client";
import MarketsController from "./markets.controller";
import OrdersController from "./orders.controller";
import WalletController from "./wallet.controller";
import Controller from "controller.interface";
import express from "express";
import log from "loglevel";
const bodyParser = require("body-parser");
class App {
public app: express.Application;
public mangoMarkets: MangoSimpleClient;
constructor() {
this.app = express();
MangoSimpleClient.create().then((mangoSimpleClient) => {
this.mangoMarkets = mangoSimpleClient;
this.app.use(bodyParser.json({ limit: "50mb" }));
this.initializeControllers([
new WalletController(this.mangoMarkets),
new OrdersController(this.mangoMarkets),
new MarketsController(this.mangoMarkets),
new AccountController(this.mangoMarkets),
]);
});
}
private initializeControllers(controllers: Controller[]) {
controllers.forEach((controller) => {
this.app.use("/", controller.router);
});
}
public listen() {
this.app.listen(process.env.PORT, () => {
log.info(`App listening on the port ${process.env.PORT}`);
});
}
public getServer() {
return this.app;
}
}
export default App;

View File

@ -0,0 +1,8 @@
import { Router } from "express";
interface Controller {
path: string;
router: Router;
}
export default Controller;

442
src/mango.simple.client.ts Normal file
View File

@ -0,0 +1,442 @@
import { zipDict } from "./utils";
import {
Config,
getAllMarkets,
getMarketByPublicKey,
getMultipleAccounts,
GroupConfig,
MangoClient,
MangoGroup,
PerpMarketLayout,
MarketConfig,
MangoAccount,
PerpMarket,
BookSide,
BookSideLayout,
PerpOrder,
getMarketByBaseSymbolAndKind,
} from "@blockworks-foundation/mango-client";
import { Market, Orderbook } from "@project-serum/serum";
import { Order } from "@project-serum/serum/lib/market";
import { Account, Commitment, Connection } from "@solana/web3.js";
import { AccountInfo, PublicKey } from "@solana/web3.js";
import fs from "fs";
import os from "os";
import { OrderInfo } from "types";
class MangoSimpleClient {
constructor(
public mangoGroupConfig: GroupConfig,
public connection: Connection,
public client: MangoClient,
// todo: load/reload/cache markets often
public mangoGroup: MangoGroup,
public owner: Account,
// todo: load/reload/cache mangoAccount and various load* methods often
public mangoAccount: MangoAccount
) {}
static async create() {
const groupName = process.env.GROUP || "devnet.1";
const mangoGroupConfig: GroupConfig = Config.ids().groups.filter(
(group) => group.name == groupName
)[0];
const connection = new Connection(
process.env.CLUSTER_URL || "https://api.devnet.solana.com",
"processed" as Commitment
);
const mangoClient = new MangoClient(
connection,
mangoGroupConfig.mangoProgramId
);
const mangoGroup = await mangoClient.getMangoGroup(
mangoGroupConfig.publicKey
);
const user = new Account(
JSON.parse(
process.env.KEYPAIR ||
fs.readFileSync(
os.homedir() + "/.config/solana/mainnet.json",
"utf-8"
)
)
);
const mangoAccounts = await mangoClient.getMangoAccountsForOwner(
mangoGroup,
user.publicKey
);
if (!mangoAccounts.length) {
throw new Error(`No mango account found ${user.publicKey.toBase58()}`);
}
const sortedMangoAccounts = mangoAccounts
.slice()
.sort((a, b) =>
a.publicKey.toBase58() > b.publicKey.toBase58() ? 1 : -1
);
return new MangoSimpleClient(
// todo: these things might get stale as time goes
mangoGroupConfig,
connection,
mangoClient,
mangoGroup,
user,
sortedMangoAccounts[0]
);
}
///
public async getAllMarkets(
onlyMarket?: string
): Promise<Partial<Record<string, Market | PerpMarket>>> {
let allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
let allMarketPks = allMarketConfigs.map((m) => m.publicKey);
if (onlyMarket !== undefined) {
allMarketConfigs = allMarketConfigs.filter(
(marketConfig) => marketConfig.name === onlyMarket
);
allMarketPks = allMarketConfigs.map((m) => m.publicKey);
}
const allMarketAccountInfos = await getMultipleAccounts(
this.connection,
allMarketPks
);
const allMarketAccounts = allMarketConfigs.map((config, i) => {
if (config.kind == "spot") {
const decoded = Market.getLayout(
this.mangoGroupConfig.mangoProgramId
).decode(allMarketAccountInfos[i].accountInfo.data);
return new Market(
decoded,
config.baseDecimals,
config.quoteDecimals,
undefined,
this.mangoGroupConfig.serumProgramId
);
}
if (config.kind == "perp") {
const decoded = PerpMarketLayout.decode(
allMarketAccountInfos[i].accountInfo.data
);
return new PerpMarket(
config.publicKey,
config.baseDecimals,
config.quoteDecimals,
decoded
);
}
});
return zipDict(
allMarketPks.map((pk) => pk.toBase58()),
allMarketAccounts
);
}
public async getAllBidsAndAsks(
filterForMangoAccount: boolean = false,
onlyMarket?: string
): Promise<OrderInfo[][]> {
this.mangoAccount.loadOpenOrders(
this.connection,
new PublicKey(this.mangoGroupConfig.serumProgramId)
);
let allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
let allMarketPks = allMarketConfigs.map((m) => m.publicKey);
if (onlyMarket !== undefined) {
allMarketConfigs = allMarketConfigs.filter(
(marketConfig) => marketConfig.name === onlyMarket
);
allMarketPks = allMarketConfigs.map((m) => m.publicKey);
}
const allBidsAndAsksPks = allMarketConfigs
.map((m) => [m.bidsKey, m.asksKey])
.flat();
const allBidsAndAsksAccountInfos = await getMultipleAccounts(
this.connection,
allBidsAndAsksPks
);
const accountInfos: { [key: string]: AccountInfo<Buffer> } = {};
allBidsAndAsksAccountInfos.forEach(
({ publicKey, context, accountInfo }) => {
accountInfos[publicKey.toBase58()] = accountInfo;
}
);
const markets = await this.getAllMarkets(onlyMarket);
return Object.entries(markets).map(([address, market]) => {
const marketConfig = getMarketByPublicKey(this.mangoGroupConfig, address);
if (market instanceof Market) {
return this.parseSpotOrders(
market,
marketConfig,
accountInfos,
filterForMangoAccount ? this.mangoAccount : undefined
);
} else if (market instanceof PerpMarket) {
return this.parsePerpOpenOrders(
market,
marketConfig,
accountInfos,
filterForMangoAccount ? this.mangoAccount : undefined
);
}
});
}
public async getAllFills(
filterForMangoAccount: boolean = false
): Promise<any[]> {
let allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
const allMarkets = await this.getAllMarkets();
let mangoAccountFills: any[] = [];
allMarketConfigs.map((config, i) => {
if (config.kind == "spot") {
const openOrdersAccount =
this.mangoAccount.spotOpenOrdersAccounts[config.marketIndex];
const mangoAccountFills_ = allMarkets[config.publicKey.toBase58()]
// todo: what if we want to fetch the 10001 position?
.loadFills(this.connection, 10000)
.then((fills) => {
if (filterForMangoAccount) {
fills = fills.filter((fill) => {
return openOrdersAccount?.publicKey
? fill.openOrders.equals(openOrdersAccount?.publicKey)
: false;
});
}
return fills.map((fill) => ({ ...fill, marketName: config.name }));
});
mangoAccountFills = mangoAccountFills.concat(mangoAccountFills_);
}
if (config.kind == "perp") {
const mangoAccountFills_ = allMarkets[config.publicKey.toBase58()]
.loadFills(this.connection)
.then((fills) => {
if (filterForMangoAccount) {
fills = fills.filter(
(fill) =>
fill.taker.equals(this.mangoAccount.publicKey) ||
fill.maker.equals(this.mangoAccount.publicKey)
);
}
return fills.map((fill) => ({ ...fill, marketName: config.name }));
});
mangoAccountFills = mangoAccountFills.concat(mangoAccountFills_);
}
});
return mangoAccountFills;
}
public async placeOrder(
market: string,
type: "market" | "limit",
side: "buy" | "sell",
quantity: number,
price?: number,
orderType: "ioc" | "postOnly" | "limit" = "limit"
): Promise<void> {
if (type === "market") {
throw new Error("Not implemented!");
}
if (market.includes("PERP")) {
const perpMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
market.split("/")[0],
"perp"
);
const perpMarket = await this.mangoGroup.loadPerpMarket(
this.connection,
perpMarketConfig.marketIndex,
perpMarketConfig.baseDecimals,
perpMarketConfig.quoteDecimals
);
await this.client.placePerpOrder(
this.mangoGroup,
this.mangoAccount,
this.mangoGroup.mangoCache,
perpMarket,
this.owner,
side,
price,
quantity,
orderType
);
} else {
const spotMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
market.split("/")[0],
"spot"
);
const spotMarket = await Market.load(
this.connection,
spotMarketConfig.publicKey,
undefined,
this.mangoGroupConfig.serumProgramId
);
await this.client.placeSpotOrder(
this.mangoGroup,
this.mangoAccount,
this.mangoGroup.mangoCache,
spotMarket,
this.owner,
side,
price,
quantity,
orderType
);
}
}
public async cancelAllOrders(): Promise<void> {
const orders = await (await this.getAllBidsAndAsks(true)).flat();
// todo: this would fetch a market for every call, cache markets
const orderInfo = Promise.all(
orders.map((orderInfo) => this.cancelOrder(orderInfo))
);
}
public async cancelOrderByOrderId(orderId: string): Promise<void> {
const orders = await (await this.getAllBidsAndAsks(true)).flat();
const orderInfo = orders.filter(
(orderInfo) => orderInfo.order.orderId.toNumber().toString() === orderId
)[0];
await this.cancelOrder(orderInfo);
}
public async cancelOrderByClientId(clientId: string): Promise<void> {
const orders = await (await this.getAllBidsAndAsks(true)).flat();
const orderInfo = orders.filter(
(orderInfo) => orderInfo.order.clientId.toNumber().toString() === clientId
)[0];
await this.cancelOrder(orderInfo);
}
///
parseSpotOrders(
market: Market,
config: MarketConfig,
accountInfos: { [key: string]: AccountInfo<Buffer> },
mangoAccount?: MangoAccount
): OrderInfo[] {
const openOrders = mangoAccount.spotOpenOrdersAccounts[config.marketIndex];
if (!openOrders) return [];
const bidData = accountInfos[market["_decoded"].bids.toBase58()]?.data;
const askData = accountInfos[market["_decoded"].asks.toBase58()]?.data;
const bidOrderBook =
market && bidData ? Orderbook.decode(market, bidData) : ([] as Order[]);
const askOrderBook =
market && askData ? Orderbook.decode(market, askData) : ([] as Order[]);
let openOrdersForMarket = [...bidOrderBook, ...askOrderBook];
if (mangoAccount !== undefined) {
openOrdersForMarket = openOrdersForMarket.filter((o) =>
o.openOrdersAddress.equals(openOrders.address)
);
}
return openOrdersForMarket.map<OrderInfo>((order) => ({
order,
market: { account: market, config: config },
}));
}
parsePerpOpenOrders(
market: PerpMarket,
config: MarketConfig,
accountInfos: { [key: string]: AccountInfo<Buffer> },
mangoAccount?: MangoAccount
): OrderInfo[] {
const bidData = accountInfos[market.bids.toBase58()]?.data;
const askData = accountInfos[market.asks.toBase58()]?.data;
const bidOrderBook =
market && bidData
? new BookSide(market.bids, market, BookSideLayout.decode(bidData))
: ([] as PerpOrder[]);
const askOrderBook =
market && askData
? new BookSide(market.asks, market, BookSideLayout.decode(askData))
: ([] as PerpOrder[]);
let openOrdersForMarket = [...bidOrderBook, ...askOrderBook];
if (mangoAccount !== undefined) {
openOrdersForMarket = openOrdersForMarket.filter((o) =>
o.owner.equals(mangoAccount.publicKey)
);
}
return openOrdersForMarket.map<OrderInfo>((order) => ({
order,
market: { account: market, config: config },
}));
}
async cancelOrder(orderInfo: OrderInfo) {
if (orderInfo.market.config.kind === "perp") {
const perpMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
orderInfo.market.config.baseSymbol,
"perp"
);
const perpMarket = await this.mangoGroup.loadPerpMarket(
this.connection,
perpMarketConfig.marketIndex,
perpMarketConfig.baseDecimals,
perpMarketConfig.quoteDecimals
);
await this.client.cancelPerpOrder(
this.mangoGroup,
this.mangoAccount,
this.owner,
perpMarket,
orderInfo.order as PerpOrder
);
} else {
const spotMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
orderInfo.market.config.baseSymbol,
"spot"
);
const spotMarket = await Market.load(
this.connection,
spotMarketConfig.publicKey,
undefined,
this.mangoGroupConfig.serumProgramId
);
await this.client.cancelSpotOrder(
this.mangoGroup,
this.mangoAccount,
this.owner,
spotMarket,
orderInfo.order as Order
);
}
}
}
export default MangoSimpleClient;

364
src/markets.controller.ts Normal file
View File

@ -0,0 +1,364 @@
import Controller from "./controller.interface";
import MangoSimpleClient from "./mango.simple.client";
import {
BookSide,
BookSideLayout,
getAllMarkets,
MarketConfig,
PerpMarket,
PerpOrder,
} from "@blockworks-foundation/mango-client";
import { Market, Orderbook } from "@project-serum/serum";
import { Order } from "@project-serum/serum/lib/market";
import { AccountInfo } from "@solana/web3.js";
import { NextFunction, Request, Response, Router } from "express";
class MarketsController implements Controller {
public path = "/markets";
public router = Router();
constructor(public mangoMarkets: MangoSimpleClient) {
this.initializeRoutes();
}
private initializeRoutes() {
// GET /markets
this.router.get(this.path, this.getMarkets);
// todo GET /markets/{market_name}
// GET /markets/{market_name}/orderbook?depth={depth}
this.router.get(`${this.path}/:market_name/orderbook`, this.getOrderBook);
// GET /markets/{market_name}/trades
this.router.get(`${this.path}/:market_name/trades`, this.getTrades);
// GET /markets/{market_name}/candles?resolution={resolution}&start_time={start_time}&end_time={end_time}
this.router.get(`${this.path}/:market_name/candles`, this.getCandles);
}
private getMarkets = async (
request: Request,
response: Response,
next: NextFunction
) => {
let allMarketConfigs = getAllMarkets(this.mangoMarkets.mangoGroupConfig);
const marketDtos = [];
for (const marketConfig of allMarketConfigs) {
marketDtos.push({
name: marketConfig.name,
baseCurrency: marketConfig.baseSymbol,
quoteCurrency: "USDC",
quoteVolume24h: await getVolumeForMarket(marketConfig),
change1h: undefined,
change24h: undefined,
changeBod: undefined,
highLeverageFeeExempt: undefined,
minProvideSize: undefined,
type: marketConfig.name.includes("PERP") ? "futures" : "spot",
underlying: marketConfig.baseSymbol,
enabled: undefined,
ask: undefined,
bid: undefined,
last: undefined,
postOnly: undefined,
price: undefined,
priceIncrement: undefined,
sizeIncrement: undefined,
restricted: undefined,
volumeUsd24h: await getVolumeForMarket(marketConfig),
} as MarketDto);
}
response.send({ success: true, result: marketDtos } as MarketsDto);
};
private getOrderBook = async (
request: Request,
response: Response,
next: NextFunction
) => {
let marketName = request.params.market_name;
const depth = Number(request.query.depth) || 20;
const ordersInfo = await this.mangoMarkets.getAllBidsAndAsks(
false,
marketName
);
let bids = ordersInfo
.flat()
.filter((orderInfo) => orderInfo.order.side == "buy")
.sort((b1, b2) => b2.order.price - b1.order.price);
let asks = ordersInfo
.flat()
.filter((orderInfo) => orderInfo.order.side == "sell")
.sort((a1, a2) => a1.order.price - a2.order.price);
response.send({
success: true,
result: {
asks: asks
.slice(0, depth)
.map((ask) => [ask.order.price, ask.order.size]),
bids: bids
.slice(0, depth)
.map((bid) => [bid.order.price, bid.order.size]),
},
} as OrdersDto);
};
private getTrades = async (
request: Request,
response: Response,
next: NextFunction
) => {
const allMarketConfigs = getAllMarkets(this.mangoMarkets.mangoGroupConfig);
const marketName = request.params.market_name;
const marketPk = allMarketConfigs
.filter((marketConfig) => marketConfig.name === marketName)[0]
.publicKey.toBase58();
const tradesResponse = await fetch(
`https://serum-history.herokuapp.com/trades/address/${marketPk}`
);
const parsedTradesResponse = tradesResponse.json() as any;
const tradeDtos = parsedTradesResponse["data"].map((trade: any) => {
return {
id: trade["orderId"],
liquidation: undefined,
price: trade["price"],
side: trade["side"],
size: trade["size"],
time: new Date(trade["time"]),
} as TradeDto;
});
response.send({ success: true, result: tradeDtos } as TradesDto);
};
private getCandles = async (
request: Request,
response: Response,
next: NextFunction
) => {
const marketName = request.params.market_name;
const resolution = request.query.resolution;
const fromEpochS = request.query.start_time;
const toEpochS = request.query.end_time;
const historyResponse = await fetch(
`https://serum-history.herokuapp.com/tv/history` +
`?symbol=${marketName}&resolution=${resolution}` +
`&from=${fromEpochS}&to=${toEpochS}`
);
const { time, open, high, low, close, volume } =
(await historyResponse.json()) as any;
const ohlcvDtos: OhlcvDto[] = [];
for (let i = 0; i < time.length; i++) {
ohlcvDtos.push({
time: time[i],
open: open[i],
high: high[i],
low: low[i],
close: close[i],
volume: volume[i],
} as OhlcvDto);
}
response.send({ success: true, result: ohlcvDtos } as OhlcvsDto);
};
}
export default MarketsController;
/// helper functions
export async function getVolumeForMarket(marketConfig: MarketConfig) {
const perpVolume = await fetch(
`https://event-history-api.herokuapp.com/stats/perps/${marketConfig.publicKey.toString()}`
);
const parsedPerpVolume = await perpVolume.json();
return parsedPerpVolume?.data?.volume;
}
export function parseSpotOrders(
market: Market,
config: MarketConfig,
accountInfos: { [key: string]: AccountInfo<Buffer> }
): { bids: Order[]; asks: Order[] } {
const bidData = accountInfos[market["_decoded"].bids.toBase58()]?.data;
const askData = accountInfos[market["_decoded"].asks.toBase58()]?.data;
const bidOrderBook =
market && bidData ? Orderbook.decode(market, bidData) : ([] as Order[]);
const askOrderBook =
market && askData ? Orderbook.decode(market, askData) : ([] as Order[]);
return { bids: [...bidOrderBook], asks: [...askOrderBook] };
}
export function parsePerpOpenOrders(
market: PerpMarket,
config: MarketConfig,
accountInfos: { [key: string]: AccountInfo<Buffer> }
): { bids: PerpOrder[]; asks: PerpOrder[] } {
const bidData = accountInfos[market.bids.toBase58()]?.data;
const askData = accountInfos[market.asks.toBase58()]?.data;
const bidOrderBook =
market && bidData
? new BookSide(market.bids, market, BookSideLayout.decode(bidData))
: ([] as PerpOrder[]);
const askOrderBook =
market && askData
? new BookSide(market.asks, market, BookSideLayout.decode(askData))
: ([] as PerpOrder[]);
return { bids: [...bidOrderBook], asks: [...askOrderBook] };
}
/// Dtos
// e.g.
// {
// "success": true,
// "result": [
// {
// "name": "BTC-0628",
// "baseCurrency": null,
// "quoteCurrency": null,
// "quoteVolume24h": 28914.76,
// "change1h": 0.012,
// "change24h": 0.0299,
// "changeBod": 0.0156,
// "highLeverageFeeExempt": false,
// "minProvideSize": 0.001,
// "type": "future",
// "underlying": "BTC",
// "enabled": true,
// "ask": 3949.25,
// "bid": 3949,
// "last": 3949.00,
// "postOnly": false,
// "price": 10579.52,
// "priceIncrement": 0.25,
// "sizeIncrement": 0.0001,
// "restricted": false,
// "volumeUsd24h": 28914.76
// }
// ]
// }
interface MarketsDto {
success: boolean;
result: MarketDto[];
}
interface MarketDto {
name: string;
baseCurrency: string;
quoteCurrency: string;
quoteVolume24h: number;
change1h: number;
change24h: number;
changeBod: number;
highLeverageFeeExempt: boolean;
minProvideSize: number;
type: string;
underlying: string;
enabled: boolean;
ask: number;
bid: number;
last: number;
postOnly: boolean;
price: number;
priceIncrement: number;
sizeIncrement: number;
restricted: boolean;
volumeUsd24h: number;
}
// e.g.
// {
// "success": true,
// "result": {
// "asks": [
// [
// 4114.25,
// 6.263
// ]
// ],
// "bids": [
// [
// 4112.25,
// 49.29
// ]
// ]
// }
// }
interface OrdersDto {
success: boolean;
result: {
asks: number[][];
bids: number[][];
};
}
// e.g.
// {
// "success": true,
// "result": [
// {
// "id": 3855995,
// "liquidation": false,
// "price": 3857.75,
// "side": "buy",
// "size": 0.111,
// "time": "2019-03-20T18:16:23.397991+00:00"
// }
// ]
// }
interface TradesDto {
success: boolean;
result: TradeDto[];
}
interface TradeDto {
id: string;
liquidation: boolean;
price: number;
side: string;
size: number;
time: Date;
}
// e.g.
// {
// "success": true,
// "result": [
// {
// "close": 11055.25,
// "high": 11089.0,
// "low": 11043.5,
// "open": 11059.25,
// "startTime": "2019-06-24T17:15:00+00:00",
// "volume": 464193.95725
// }
// ]
// }
interface OhlcvsDto {
success: boolean;
result: OhlcvDto[];
}
interface OhlcvDto {
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}

215
src/orders.controller.ts Normal file
View File

@ -0,0 +1,215 @@
import Controller from "./controller.interface";
import MangoSimpleClient from "./mango.simple.client";
import { OrderInfo } from "./types";
import { PerpOrder } from "@blockworks-foundation/mango-client";
import { Order } from "@project-serum/serum/lib/market";
import { NextFunction, Request, Response, Router } from "express";
class OrdersController implements Controller {
public path = "/orders";
public router = Router();
constructor(public mangoSimpleClient: MangoSimpleClient) {
this.initializeRoutes();
}
private initializeRoutes() {
// GET /orders?market={market_name}
this.router.get(this.path, this.getOpenOrders);
// POST /orders
this.router.post(this.path, this.placeOrder);
// DELETE /orders
this.router.delete(this.path, this.cancelAllOrders);
// DELETE /orders/{order_id}
this.router.delete(`${this.path}/:order_id`, this.cancelOrderByOrderId);
// DELETE /orders/by_client_id/{client_order_id}
this.router.delete(
`${this.path}/by_client_id/:client_id`,
this.cancelOrderByClientId
);
}
private getOpenOrders = async (
request: Request,
response: Response,
next: NextFunction
) => {
const openOrders = await this.mangoSimpleClient.getAllBidsAndAsks(
true,
request.query.market ? String(request.query.market) : undefined
);
const orderDtos = openOrders.flat().map((orderInfo: OrderInfo) => {
if ("bestInitial" in orderInfo.order) {
const order_ = orderInfo.order as PerpOrder;
return {
createdAt: new Date(order_.timestamp.toNumber()),
filledSize: undefined,
future: orderInfo.market.config.name,
id: order_.orderId.toString(),
market: orderInfo.market.config.name,
price: order_.price,
avgFillPrice: undefined,
remainingSize: undefined,
side: order_.side,
size: order_.size,
status: "open",
type: undefined,
reduceOnly: undefined,
ioc: undefined,
postOnly: undefined,
clientId:
order_.clientId && order_.clientId.toString() !== "0"
? order_.clientId.toString()
: undefined,
} as OrderDto;
}
const order_ = orderInfo.order as Order;
return {
createdAt: undefined,
filledSize: undefined,
future: orderInfo.market.config.name,
id: order_.orderId.toString(),
market: orderInfo.market.config.name,
price: order_.price,
avgFillPrice: undefined,
remainingSize: undefined,
side: order_.side,
size: order_.size,
status: "open",
type: undefined,
reduceOnly: undefined,
ioc: undefined,
postOnly: undefined,
clientId:
order_.clientId && order_.clientId.toString() !== "0"
? order_.clientId.toString()
: undefined,
} as OrderDto;
});
response.send({ success: true, result: orderDtos } as OrdersDto);
};
private placeOrder = async (
request: Request,
response: Response,
next: NextFunction
) => {
const placeOrderDto = <PlaceOrderDto>request.body;
await this.mangoSimpleClient.placeOrder(
placeOrderDto.market,
placeOrderDto.type,
placeOrderDto.side,
placeOrderDto.size,
placeOrderDto.price,
placeOrderDto.ioc ? "ioc" : placeOrderDto.postOnly ? "postOnly" : "limit"
);
};
private cancelAllOrders = async (
request: Request,
response: Response,
next: NextFunction
) => {
await this.mangoSimpleClient.cancelAllOrders();
};
private cancelOrderByOrderId = async (
request: Request,
response: Response,
next: NextFunction
) => {
let order_id = request.params.order_id;
await this.mangoSimpleClient.cancelOrderByOrderId(order_id);
};
private cancelOrderByClientId = async (
request: Request,
response: Response,
next: NextFunction
) => {
let client_id = request.params.client_id;
await this.mangoSimpleClient.cancelOrderByClientId(client_id);
};
}
export default OrdersController;
/// Dtos
// e.g.
// {
// "success": true,
// "result": [
// {
// "createdAt": "2019-03-05T09:56:55.728933+00:00",
// "filledSize": 10,
// "future": "XRP-PERP",
// "id": 9596912,
// "market": "XRP-PERP",
// "price": 0.306525,
// "avgFillPrice": 0.306526,
// "remainingSize": 31421,
// "side": "sell",
// "size": 31431,
// "status": "open",
// "type": "limit",
// "reduceOnly": false,
// "ioc": false,
// "postOnly": false,
// "clientId": null
// }
// ]
// }
interface OrdersDto {
success: boolean;
result: OrderDto[];
}
interface OrderDto {
createdAt: Date;
filledSize: number;
future: string;
id: string;
market: string;
price: number;
avgFillPrice: number;
remainingSize: number;
side: string;
size: number;
status: string;
type: string;
reduceOnly: boolean;
ioc: boolean;
postOnly: boolean;
clientId: null;
}
// e.g.
// {
// "market": "XRP-PERP",
// "side": "sell",
// "price": 0.306525,
// "type": "limit",
// "size": 31431.0,
// "reduceOnly": false,
// "ioc": false,
// "postOnly": false,
// "clientId": null
// }
interface PlaceOrderDto {
market: string;
side: "sell" | "buy";
price: number;
type: "limit" | "market";
size: number;
reduceOnly: boolean;
ioc: boolean;
postOnly: boolean;
clientId: string;
}

10
src/server.ts Normal file
View File

@ -0,0 +1,10 @@
import App from "./app";
import { cleanEnv, port } from "envalid";
cleanEnv(process.env, {
PORT: port({ default: 3000 }),
});
const app = new App();
app.listen();

31
src/types.ts Normal file
View File

@ -0,0 +1,31 @@
import {
MarketConfig,
PerpMarket,
PerpOrder,
} from "@blockworks-foundation/mango-client";
import { I80F48 } from "@blockworks-foundation/mango-client/lib/src/fixednum";
import { Market, OpenOrders } from "@project-serum/serum";
import { Order } from "@project-serum/serum/lib/market";
interface BalancesBase {
key: string;
symbol: string;
wallet?: number | null | undefined;
orders?: number | null | undefined;
openOrders?: OpenOrders | null | undefined;
unsettled?: number | null | undefined;
}
export interface Balances extends BalancesBase {
deposits?: I80F48 | null | undefined;
borrows?: I80F48 | null | undefined;
net?: I80F48 | null | undefined;
value?: I80F48 | null | undefined;
depositRate?: I80F48 | null | undefined;
borrowRate?: I80F48 | null | undefined;
}
export type OrderInfo = {
order: Order | PerpOrder;
market: { account: Market | PerpMarket; config: MarketConfig };
};

15
src/utils.ts Normal file
View File

@ -0,0 +1,15 @@
import { I80F48 } from "@blockworks-foundation/mango-client/lib/src/fixednum";
export const i80f48ToPercent = (value: I80F48) =>
value.mul(I80F48.fromNumber(100));
export function zipDict<K extends string | number | symbol, V>(
keys: K[],
values: V[]
): Partial<Record<K, V>> {
const result: Partial<Record<K, V>> = {};
keys.forEach((key, index) => {
result[key] = values[index];
});
return result;
}

243
src/wallet.controller.ts Normal file
View File

@ -0,0 +1,243 @@
import { Balances } from "./types";
import { i80f48ToPercent } from "./utils";
import {
I80F48,
getTokenBySymbol,
nativeI80F48ToUi,
nativeToUi,
QUOTE_INDEX,
} from "@blockworks-foundation/mango-client";
import Controller from "controller.interface";
import { NextFunction, Request, Response, Router } from "express";
import { sumBy } from "lodash";
import MangoSimpleClient from "mango.simple.client";
class WalletController implements Controller {
public path = "/wallet";
public router = Router();
constructor(public mangoMarkets: MangoSimpleClient) {
this.initializeRoutes();
}
private initializeRoutes() {
this.router.get(`${this.path}/balances`, this.getBalances);
}
private getBalances = async (
request: Request,
response: Response,
next: NextFunction
) => {
// local copies of mango objects
const mangoGroupConfig = this.mangoMarkets.mangoGroupConfig;
const mangoGroup = this.mangoMarkets.mangoGroup;
// (re)load things which we want fresh
const mangoAccount = await this.mangoMarkets.mangoAccount.reload(
this.mangoMarkets.connection,
this.mangoMarkets.mangoGroup.dexProgramId
);
const mangoCache = await mangoGroup.loadCache(this.mangoMarkets.connection);
await mangoGroup.loadRootBanks(this.mangoMarkets.connection);
////// copy pasta block from mango-ui-v3
const balances: Balances[][] = new Array();
for (const {
marketIndex,
baseSymbol,
name,
} of mangoGroupConfig.spotMarkets) {
if (!mangoAccount || !mangoGroup) {
response.send([]);
}
const openOrders: any = mangoAccount.spotOpenOrdersAccounts[marketIndex];
const quoteCurrencyIndex = QUOTE_INDEX;
let nativeBaseFree = 0;
let nativeQuoteFree = 0;
let nativeBaseLocked = 0;
let nativeQuoteLocked = 0;
if (openOrders) {
nativeBaseFree = openOrders.baseTokenFree.toNumber();
nativeQuoteFree = openOrders.quoteTokenFree
.add(openOrders["referrerRebatesAccrued"])
.toNumber();
nativeBaseLocked = openOrders.baseTokenTotal
.sub(openOrders.baseTokenFree)
.toNumber();
nativeQuoteLocked = openOrders.quoteTokenTotal
.sub(openOrders.quoteTokenFree)
.toNumber();
}
const tokenIndex = marketIndex;
const net = (nativeBaseLocked: number, tokenIndex: number) => {
const amount = mangoAccount
.getUiDeposit(
mangoCache.rootBankCache[tokenIndex],
mangoGroup,
tokenIndex
)
.add(
nativeI80F48ToUi(
I80F48.fromNumber(nativeBaseLocked),
mangoGroup.tokens[tokenIndex].decimals
).sub(
mangoAccount.getUiBorrow(
mangoCache.rootBankCache[tokenIndex],
mangoGroup,
tokenIndex
)
)
);
return amount;
};
const value = (nativeBaseLocked: number, tokenIndex: number) => {
const amount = mangoGroup
.getPrice(tokenIndex, mangoCache)
.mul(net(nativeBaseLocked, tokenIndex));
return amount;
};
const marketPair = [
{
market: null as null,
key: `${name}`,
symbol: baseSymbol,
deposits: mangoAccount.getUiDeposit(
mangoCache.rootBankCache[tokenIndex],
mangoGroup,
tokenIndex
),
borrows: mangoAccount.getUiBorrow(
mangoCache.rootBankCache[tokenIndex],
mangoGroup,
tokenIndex
),
orders: nativeToUi(
nativeBaseLocked,
mangoGroup.tokens[tokenIndex].decimals
),
unsettled: nativeToUi(
nativeBaseFree,
mangoGroup.tokens[tokenIndex].decimals
),
net: net(nativeBaseLocked, tokenIndex),
value: value(nativeBaseLocked, tokenIndex),
depositRate: i80f48ToPercent(mangoGroup.getDepositRate(tokenIndex)),
borrowRate: i80f48ToPercent(mangoGroup.getBorrowRate(tokenIndex)),
},
{
market: null as null,
key: `${name}`,
symbol: mangoGroupConfig.quoteSymbol,
deposits: mangoAccount.getUiDeposit(
mangoCache.rootBankCache[quoteCurrencyIndex],
mangoGroup,
quoteCurrencyIndex
),
borrows: mangoAccount.getUiBorrow(
mangoCache.rootBankCache[quoteCurrencyIndex],
mangoGroup,
quoteCurrencyIndex
),
orders: nativeToUi(
nativeQuoteLocked,
mangoGroup.tokens[quoteCurrencyIndex].decimals
),
unsettled: nativeToUi(
nativeQuoteFree,
mangoGroup.tokens[quoteCurrencyIndex].decimals
),
net: net(nativeQuoteLocked, quoteCurrencyIndex),
value: value(nativeQuoteLocked, quoteCurrencyIndex),
depositRate: i80f48ToPercent(mangoGroup.getDepositRate(tokenIndex)),
borrowRate: i80f48ToPercent(mangoGroup.getBorrowRate(tokenIndex)),
},
];
balances.push(marketPair);
}
const baseBalances = balances.map((b) => b[0]);
const quoteBalances = balances.map((b) => b[1]);
const quoteMeta = quoteBalances[0];
const quoteInOrders = sumBy(quoteBalances, "orders");
const unsettled = sumBy(quoteBalances, "unsettled");
const net: I80F48 = quoteMeta.deposits
.add(I80F48.fromNumber(unsettled))
.sub(quoteMeta.borrows)
.add(I80F48.fromNumber(quoteInOrders));
const token = getTokenBySymbol(mangoGroupConfig, quoteMeta.symbol);
const tokenIndex = mangoGroup.getTokenIndex(token.mintKey);
const value = net.mul(mangoGroup.getPrice(tokenIndex, mangoCache));
////// end of copy pasta block from mango-ui-v3
// append balances for base symbols
const balanceDtos = baseBalances.map((baseBalance) => {
return {
coin: baseBalance.key,
free: baseBalance.deposits.toNumber(),
spotBorrow: baseBalance.borrows.toNumber(),
total: baseBalance.net.toNumber(),
usdValue: baseBalance.value.toNumber(),
availableWithoutBorrow: baseBalance.net
.sub(baseBalance.borrows)
.toNumber(),
} as BalanceDto;
});
// append balance for quote symbol
balanceDtos.push({
coin: this.mangoMarkets.mangoGroupConfig.quoteSymbol,
free: quoteMeta.deposits.toNumber(),
spotBorrow: quoteMeta.borrows.toNumber(),
total: net.toNumber(),
usdValue: value.toNumber(),
availableWithoutBorrow: net.sub(quoteMeta.borrows).toNumber(),
});
response.send({ success: true, result: balanceDtos } as BalancesDto);
};
}
export default WalletController;
/// Dtos
// e.g.
// {
// "success": true,
// "result": [
// {
// "coin": "USDTBEAR",
// "free": 2320.2,
// "spotBorrow": 0.0,
// "total": 2340.2,
// "usdValue": 2340.2,
// "availableWithoutBorrow": 2320.2
// }
// ]
// }
interface BalancesDto {
success: boolean;
result: BalanceDto[];
}
interface BalanceDto {
coin: string;
free: number;
spotBorrow: number;
total: number;
usdValue: number;
availableWithoutBorrow: number;
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"alwaysStrict": true,
"baseUrl": "./src",
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"noImplicitAny": true,
"outDir": "./dist",
"sourceMap": true,
"target": "ES2020"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

3
tslint.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["tslint:latest", "tslint-config-prettier"]
}

1905
yarn.lock Normal file

File diff suppressed because it is too large Load Diff