Cex style api (#15)

* a simple cex style client #14

* add empty line

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* fixes from code review

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* make margin account pk mandatory

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* load attached keypair

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* try actions for yarn test

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* refactor

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* more refactoring

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* export simple client

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* remove testing code

Signed-off-by: feierabend654 <feierabend654@gmail.com>

* detect and throw error

Signed-off-by: feierabend654 <feierabend654@gmail.com>

Co-authored-by: Maximilian Schneider <mail@maximilianschneider.net>
This commit is contained in:
feierabend654 2021-06-05 16:23:20 +02:00 committed by GitHub
parent 6eaf842b9d
commit 3a2dbc889c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1020 additions and 2 deletions

13
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,13 @@
name: CI
on: [push]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14.x'
- run: yarn install
- run: yarn test-simple-client

View File

@ -22,6 +22,7 @@
"prepare": "run-s clean build",
"shell": "node -e \"$(< shell)\" -i --experimental-repl-await",
"test": "mocha -r ts-node/register tests/Stateless.test.ts --timeout 0",
"test-simple-client": "mocha -r ts-node/register tests/simpleclient.test.ts --timeout 0",
"test:build": "run-s build",
"test:lint": "eslint src",
"test:unit": "jest",

View File

@ -1,6 +1,7 @@
import IDS from './ids.json';
export { IDS }
export { MangoClient, MangoGroup, MarginAccount, tokenToDecimals } from './client';
export { SimpleClient } from './simpleclient';
export { MangoIndexLayout, MarginAccountLayout, MangoGroupLayout } from './layout';
export * from './layout';
export * from './utils';

699
src/simpleclient.ts Normal file
View File

@ -0,0 +1,699 @@
import { MangoClient, MangoGroup, MarginAccount } from './client';
import IDS from './ids.json';
import {
Account,
Commitment,
Connection,
PublicKey,
TransactionSignature,
} from '@solana/web3.js';
import os from 'os';
import fs from 'fs';
import { Market, OpenOrders, Orderbook } from '@project-serum/serum';
import { Order } from '@project-serum/serum/lib/market';
import { ceilToDecimal, groupBy, nativeToUi } from './utils';
import BN from 'bn.js';
import fetch from 'node-fetch';
// github issue - https://github.com/blockworks-foundation/mango-client-ts/issues/14
type TokenSymbol = string;
type SpotMarketSymbol = string;
type OpenOrdersAsString = string;
interface OpenOrderForPnl {
nativeQuantityReleased: number;
nativeQuantityPaid: number;
side: 'sell' | 'buy';
size: number;
openOrders: OpenOrdersAsString;
}
export class MarketBalance {
constructor(
public baseTokenSymbol: string,
public orders: number,
public unsettled: number,
public quoteTokenSymbol: string,
public quoteOrders: number,
public quoteUnsettled: number,
) {}
}
export class MarginAccountBalance {
constructor(
public tokenSymbol: string,
public deposited: number,
public borrowed: number,
) {}
}
export class Balance {
constructor(
public marginAccountPublicKey: string,
public marginAccountBalances: MarginAccountBalance[],
public marketBalances: MarketBalance[],
) {}
}
export class FetchMarketSymbol {
constructor(public symbol: string) {}
}
export class FetchMarket {
constructor(public symbols: FetchMarketSymbol[]) {}
}
export class Ticker {
constructor(
public symbol: string,
public price: number,
public timeMs: number,
) {}
}
export class Ohlcv {
constructor(
public timeS: number,
public open: number,
public high: number,
public low: number,
public close: number,
public volume: number,
) {}
}
type Resolution =
| '1'
| '3'
| '5'
| '15'
| '30'
| '60'
| '120'
| '180'
| '240'
| '1D';
class EmptyOrderBookError extends Error {
constructor(message) {
super(message);
this.name = 'EmptyOrderBookError';
}
}
/**
* a simpler more cex-style client with sensible (hopefully ;)) defaults
*/
export class SimpleClient {
private constructor(
private client: MangoClient,
private connection: Connection,
private programId: PublicKey,
private dexProgramId: PublicKey,
private mangoGroup: MangoGroup,
private markets: Market[],
private mangoGroupTokenMappings: Map<TokenSymbol, PublicKey>,
private spotMarketMappings: Map<SpotMarketSymbol, PublicKey>,
private payer: Account,
private marginAccountPk: string,
) {}
public static async create(marginAccountPk: string) {
const cluster = process.env.CLUSTER || 'mainnet-beta';
const mangoGroupName = 'BTC_ETH_SOL_SRM_USDC';
const clusterIds = IDS[cluster];
const programId = new PublicKey(IDS[cluster].mango_program_id);
const dexProgramId = new PublicKey(clusterIds.dex_program_id);
const mangoGroupIds = clusterIds.mango_groups[mangoGroupName];
// connection
const connection = new Connection(
IDS.cluster_urls[cluster],
'processed' as Commitment,
);
// client
const client = new MangoClient();
// payer
const keyPairPath =
process.env.KEYPAIR || os.homedir() + '/.config/solana/id.json';
const payer = new Account(
JSON.parse(fs.readFileSync(keyPairPath, 'utf-8')),
);
// mangoGroup
const mangoGroupPk = new PublicKey(
clusterIds.mango_groups[mangoGroupName].mango_group_pk,
);
const mangoGroup = await client.getMangoGroup(connection, mangoGroupPk);
// markets
const markets = await Promise.all(
mangoGroup.spotMarkets.map((pk) =>
Market.load(
connection,
pk,
{ skipPreflight: true, commitment: 'singleGossip' },
dexProgramId,
),
),
);
// token mappings
const mangoGroupTokenMappings = new Map<TokenSymbol, PublicKey>();
const mangoGroupSymbols: [string, string][] = Object.entries(
mangoGroupIds.symbols,
);
for (const [tokenName, tokenMint] of mangoGroupSymbols) {
mangoGroupTokenMappings[tokenName] = new PublicKey(tokenMint);
}
// market mappings
const mangoGroupSportMarketMappings = new Map<
SpotMarketSymbol,
PublicKey
>();
const mangoGroupSpotMarketSymbols: [SpotMarketSymbol, string][] =
Object.entries(mangoGroupIds.spot_market_symbols);
for (const [spotMarketSymbol, address] of mangoGroupSpotMarketSymbols) {
mangoGroupSportMarketMappings[spotMarketSymbol] = new PublicKey(address);
}
return new SimpleClient(
client,
connection,
programId,
dexProgramId,
mangoGroup,
markets,
mangoGroupTokenMappings,
mangoGroupSportMarketMappings,
payer,
marginAccountPk,
);
}
/// private
private getMarketForSymbol(marketSymbol: SpotMarketSymbol): Market {
if (Object.keys(this.spotMarketMappings).indexOf(marketSymbol) === -1) {
throw new Error(`unknown spot market ${marketSymbol}`);
}
const marketAddress = this.spotMarketMappings[marketSymbol];
const market = this.markets.find((market) =>
market.publicKey.equals(marketAddress),
);
if (market === undefined) {
throw new Error(`market not found for ${market}`);
}
return market;
}
private async getMarginAccountForOwner(): Promise<MarginAccount> {
return await this.client.getMarginAccount(
this.connection,
new PublicKey(this.marginAccountPk),
this.dexProgramId,
);
}
private async getOpenOrdersAccountForSymbol(
marketSymbol: SpotMarketSymbol,
): Promise<OpenOrders | undefined> {
const market = this.getMarketForSymbol(marketSymbol);
const marketIndex = this.mangoGroup.getMarketIndex(market!);
const marginAccount = await this.getMarginAccountForOwner();
return marginAccount.openOrdersAccounts[marketIndex];
}
private async cancelOrder(
marginAccount: MarginAccount,
market: Market,
order: Order,
): Promise<TransactionSignature> {
return this.client.cancelOrder(
this.connection,
this.programId,
this.mangoGroup,
marginAccount,
this.payer,
market,
order,
);
}
private async cancelOrdersForMarginAccount(
marginAccount: MarginAccount,
symbol?: SpotMarketSymbol,
clientId?: string,
) {
let orders;
let market;
if (symbol === undefined) {
for (const spotMarketSymbol of Object.keys(this.spotMarketMappings)) {
market = this.getMarketForSymbol(spotMarketSymbol);
orders = await this.getOpenOrders(spotMarketSymbol);
await orders.map((order) =>
this.cancelOrder(marginAccount, market, order),
);
}
return;
}
market = this.getMarketForSymbol(symbol!);
orders = await this.getOpenOrders(symbol!);
// note: clientId could not even belong to his margin account
// in that case ordersToCancel would be empty
const ordersToCancel =
clientId !== undefined
? orders.filter((o) => o.clientId.toString() === clientId)
: orders;
await Promise.all(
ordersToCancel.map((order) =>
this.cancelOrder(marginAccount, market, order),
),
);
}
/// public
async placeOrder(
symbol: SpotMarketSymbol,
type: 'market' | 'limit',
side: 'buy' | 'sell',
quantity: number,
price?: number,
orderType?: 'ioc' | 'postOnly' | 'limit',
): Promise<string> {
if (!symbol.trim()) {
throw new Error(`invalid symbol ${symbol}`);
}
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error(`invalid quantity ${quantity}`);
}
if ((type === 'limit' && !Number.isFinite(price)) || price! <= 0) {
throw new Error(`invalid price ${price}`);
}
if (type === 'market') {
const orderBook = await this.getOrderBook(symbol);
let acc = 0;
let selectedOrder;
if (orderBook.length === 0) {
throw new EmptyOrderBookError(
'Empty order book encountered when placing a market order!',
);
}
for (const order of orderBook) {
acc += order.size;
if (acc >= quantity) {
selectedOrder = order;
break;
}
}
if (side === 'buy') {
price = selectedOrder.price * 1.05;
} else {
price = selectedOrder.price * 0.95;
}
}
const market = this.getMarketForSymbol(symbol);
const marginAccount = await this.getMarginAccountForOwner();
const clientId = new BN(Date.now());
orderType = orderType === undefined ? 'limit' : orderType;
await this.client.placeOrder(
this.connection,
this.programId,
this.mangoGroup,
marginAccount,
market,
this.payer,
side,
price!,
quantity,
orderType,
clientId,
);
return clientId.toString();
}
async getOpenOrders(
symbol: SpotMarketSymbol,
clientId?: string,
): Promise<Order[]> {
const openOrderAccount = await this.getOpenOrdersAccountForSymbol(symbol);
if (openOrderAccount === undefined) {
return [];
}
let orders: Order[] = await this.getOrderBook(symbol);
orders = orders.filter((o) =>
openOrderAccount.address.equals(o.openOrdersAddress),
);
if (clientId) {
return orders.filter(
(o) => o.clientId && o.clientId.toString() === clientId,
);
}
return orders;
}
async cancelOrders(symbol?: SpotMarketSymbol, clientId?: string) {
const marginAccount = await this.getMarginAccountForOwner();
await this.cancelOrdersForMarginAccount(marginAccount, symbol, clientId);
}
async getTradeHistory(symbol: SpotMarketSymbol): Promise<OpenOrderForPnl[]> {
if (!symbol.trim()) {
throw new Error(`invalid symbol ${symbol}`);
}
const openOrdersAccount = await this.getOpenOrdersAccountForSymbol(symbol);
if (openOrdersAccount === undefined) {
return [];
}
// e.g. https://stark-fjord-45757.herokuapp.com/trades/open_orders/G5rZ4Qfv5SxpJegVng5FuZftDrJkzLkxQUNjEXuoczX5
// {
// "id": 2267328,
// "loadTimestamp": "2021-04-28T03:36:20.573Z",
// "address": "C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4",
// "programId": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin",
// "baseCurrency": "BTC",
// "quoteCurrency": "USDT",
// "fill": true,
// "out": false,
// "bid": false,
// "maker": false,
// "openOrderSlot": "6",
// "feeTier": "0",
// "nativeQuantityReleased": "93207112",
// "nativeQuantityPaid": "1700",
// "nativeFeeOrRebate": "205508",
// "orderId": "9555524110645989995606320",
// "openOrders": "G5rZ4Qfv5SxpJegVng5FuZftDrJkzLkxQUNjEXuoczX5",
// "clientOrderId": "0",
// "uuid": "0040cdbdb0667fd5f75c2538e4097c5090e7b15d8cf9a5e7db7a54c3c212d27a",
// "source": "1",
// "baseTokenDecimals": 6,
// "quoteTokenDecimals": 6,
// "side": "sell",
// "price": 54948.6,
// "feeCost": 0.205508,
// "size": 0.0017
// }
const response = await fetch(
`https://stark-fjord-45757.herokuapp.com/trades/open_orders/${openOrdersAccount.address.toBase58()}`,
);
const parsedResponse = await response.json();
const trades: OpenOrderForPnl[] = parsedResponse?.data
? parsedResponse.data
: [];
return trades
.filter((trade) =>
openOrdersAccount.address.equals(new PublicKey(trade.openOrders)),
)
.map((trade) => ({ ...trade, marketName: symbol }));
}
/**
* returns balances, simple and mango specific details
*/
async getBalance(): Promise<Balance> {
const marginAccount = await this.getMarginAccountForOwner();
const marginAccountBalances = Object.keys(this.mangoGroupTokenMappings).map(
(tokenSymbol) => {
const tokenIndex = this.mangoGroup.getTokenIndex(
this.mangoGroupTokenMappings[tokenSymbol],
);
const decimals = this.mangoGroup.mintDecimals[tokenIndex];
const uiDeposit = marginAccount.getUiDeposit(
this.mangoGroup,
tokenIndex,
);
const uiDepositDisplay = ceilToDecimal(uiDeposit, decimals);
const uiBorrow = marginAccount.getUiBorrow(this.mangoGroup, tokenIndex);
const uiBorrowDisplay = ceilToDecimal(uiBorrow, decimals);
return new MarginAccountBalance(
tokenSymbol,
uiDepositDisplay,
uiBorrowDisplay,
);
},
);
const marketBalances = this.mangoGroup.spotMarkets.map((marketPk) => {
const market = this.markets.find((market) =>
market.publicKey.equals(marketPk),
);
if (market === undefined) {
throw new Error(`market for ${marketPk.toBase58()} not found`);
}
const token = Object.entries(this.mangoGroupTokenMappings).find(
(entry) => {
return entry[1].equals(market.baseMintAddress);
},
);
const tokenIndex = this.mangoGroup.getTokenIndex(market.baseMintAddress);
const openOrders: OpenOrders =
marginAccount.openOrdersAccounts[tokenIndex]!;
const nativeBaseFree = openOrders?.baseTokenFree || new BN(0);
const nativeBaseLocked = openOrders
? openOrders.baseTokenTotal.sub(nativeBaseFree)
: new BN(0);
const nativeBaseUnsettled = openOrders?.baseTokenFree || new BN(0);
const orders = nativeToUi(
nativeBaseLocked.toNumber(),
this.mangoGroup.mintDecimals[tokenIndex],
);
const unsettled = nativeToUi(
nativeBaseUnsettled.toNumber(),
this.mangoGroup.mintDecimals[tokenIndex],
);
const quoteToken = Object.entries(this.mangoGroupTokenMappings).find(
(entry) => {
return entry[1].equals(market.quoteMintAddress);
},
);
const quoteCurrencyIndex = this.mangoGroup.getTokenIndex(
market.quoteMintAddress,
);
const nativeQuoteFree = openOrders?.quoteTokenFree || new BN(0);
const nativeQuoteLocked = openOrders
? openOrders!.quoteTokenTotal.sub(nativeQuoteFree)
: new BN(0);
const nativeQuoteUnsettled = openOrders?.quoteTokenFree || new BN(0);
const ordersQuote = nativeToUi(
nativeQuoteLocked.toNumber(),
this.mangoGroup.mintDecimals[quoteCurrencyIndex],
);
const unsettledQuote = nativeToUi(
nativeQuoteUnsettled.toNumber(),
this.mangoGroup.mintDecimals[quoteCurrencyIndex],
);
return new MarketBalance(
token![0],
orders,
unsettled,
quoteToken![0],
ordersQuote,
unsettledQuote,
);
});
return new Balance(
marginAccount.publicKey.toBase58(),
marginAccountBalances,
marketBalances,
);
}
async getPnl() {
// grab trade history
let tradeHistory: OpenOrderForPnl[] = [];
for (const [spotMarketSymbol, unused] of Object.entries(
this.spotMarketMappings,
)) {
const tradeHistoryForSymbol = await this.getTradeHistory(
spotMarketSymbol,
);
tradeHistory = tradeHistory.concat(tradeHistoryForSymbol);
}
const profitAndLoss = {};
// compute profit and loss for all markets
const groupedTrades = groupBy(tradeHistory, (trade) => trade.marketName);
groupedTrades.forEach((val, key) => {
profitAndLoss[key] = val.reduce(
(acc, current) =>
(current.side === 'sell' ? current.size * -1 : current.size) + acc,
0,
);
});
// compute profit and loss for usdc
const totalNativeUsdc = tradeHistory.reduce((acc, current) => {
const usdcAmount =
current.side === 'sell'
? current.nativeQuantityReleased
: current.nativeQuantityPaid * -1;
return usdcAmount + acc;
}, 0);
(profitAndLoss as any).USDC = nativeToUi(
totalNativeUsdc,
this.mangoGroup.mintDecimals[2],
);
// compute final pnl
let total = 0;
const prices = await this.mangoGroup.getPrices(this.connection);
const assetIndex = {
'BTC/USDC': 0,
'BTC/WUSDC': 0,
'ETH/USDC': 1,
'ETH/WUSDC': 1,
'SOL/USDC': 1,
'SOL/WUSDC': 1,
'SRM/USDC': 1,
'SRM/WUSDC': 1,
USDC: 2,
WUSDC: 2,
};
for (const assetName of Object.keys(profitAndLoss)) {
total = total + profitAndLoss[assetName] * prices[assetIndex[assetName]];
}
return total.toFixed(2);
}
/**
* returns available markets
*/
async getMarkets(): Promise<FetchMarket> {
const fetchMarketSymbols = Object.keys(this.spotMarketMappings).map(
(spotMarketSymbol) => new FetchMarketSymbol(spotMarketSymbol),
);
return new FetchMarket(fetchMarketSymbols);
}
/**
* returns tickers i.e. symbol, closing price, time of closing price
*/
async getTickers(symbol?: SpotMarketSymbol): Promise<Ticker[]> {
let ohlcvs;
let latestOhlcv;
const to = Date.now();
// use a sufficiently large window to ensure that we get data back
const toMinus20Mins = to - 20 * 60 * 1000;
const oneMinute = '1';
if (symbol === undefined) {
const tickers: Ticker[] = [];
for (const zymbol of Object.keys(this.spotMarketMappings)) {
ohlcvs = await this.getOhlcv(zymbol, oneMinute, toMinus20Mins, to);
latestOhlcv = ohlcvs[ohlcvs.length - 1];
tickers.push(
new Ticker(zymbol, latestOhlcv.close, latestOhlcv.timeS * 1000),
);
}
return tickers;
}
ohlcvs = await this.getOhlcv(symbol, oneMinute, toMinus20Mins, to);
latestOhlcv = ohlcvs[ohlcvs.length - 1];
return [new Ticker(symbol, latestOhlcv.close, latestOhlcv.timeS * 1000)];
}
async getOrderBook(symbol: SpotMarketSymbol): Promise<Order[]> {
const market = this.getMarketForSymbol(symbol);
const bidData = (await this.connection.getAccountInfo(market.bidsAddress))
?.data;
const bidOrderBook = bidData
? Orderbook.decode(market, Buffer.from(bidData))
: [];
const askData = (await this.connection.getAccountInfo(market.asksAddress))
?.data;
const askOrderBook = askData
? Orderbook.decode(market, Buffer.from(askData))
: [];
return [...bidOrderBook, ...askOrderBook];
}
/**
* returns ohlcv in ascending order for time
*/
async getOhlcv(
spotMarketSymbol: SpotMarketSymbol,
resolution: Resolution,
fromEpochMs: number,
toEpochMs: number,
): Promise<Ohlcv[]> {
const response = await fetch(
`https://serum-history.herokuapp.com/tv/history` +
`?symbol=${spotMarketSymbol}&resolution=${resolution}` +
`&from=${fromEpochMs / 1000}&to=${toEpochMs / 1000}`,
);
const { t, o, h, l, c, v } = await response.json();
const ohlcvs: Ohlcv[] = [];
for (let i = 0; i < t.length; i++) {
ohlcvs.push(new Ohlcv(t[i], o[i], h[i], l[i], c[i], v[i]));
}
return ohlcvs;
}
// async debug() {
// const marginAccountForOwner = await this.getMarginAccountForOwner();
// console.log(
// `margin account - ${marginAccountForOwner.publicKey.toBase58()}`,
// );
//
// const balance = await this.getBalance();
// balance.marginAccountBalances.map((bal) => {
// console.log(
// ` - balance for ${bal.tokenSymbol}, deposited ${bal.deposited}, borrowed ${bal.borrowed}`,
// );
// });
//
// for (const symbol of Object.keys(this.spotMarketMappings)) {
// const openOrdersAccountForSymbol =
// await this.getOpenOrdersAccountForSymbol(symbol);
// if (openOrdersAccountForSymbol === undefined) {
// continue;
// }
// console.log(
// ` - symbol ${symbol}, open orders account ${openOrdersAccountForSymbol?.publicKey.toBase58()}`,
// );
//
// const openOrders = await this.getOpenOrders(symbol);
// if (openOrders === undefined) {
// continue;
// }
// for (const order of openOrders) {
// console.log(` - orderId ${order.orderId}, clientId ${order.clientId}`);
// }
// }
// }
}

View File

@ -126,7 +126,7 @@ export async function awaitTransactionSignatureConfirmation(
if (!result) {
// console.log('REST null result for', txid, result);
} else if (result.err) {
console.log('REST error for', txid, result);
console.log('REST error for', txid, result.err);
done = true;
reject(result.err);
} else if (!(result.confirmations || confirmLevels.includes(result.confirmationStatus))) {
@ -384,3 +384,29 @@ export function decodeRecentEvents(
return { header, nodes };
}
export function groupBy(list, keyGetter) {
const map = new Map()
list.forEach((item) => {
const key = keyGetter(item)
const collection = map.get(key)
if (!collection) {
map.set(key, [item])
} else {
collection.push(item)
}
})
return map
}
export function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined;
}
export function ceilToDecimal(value: number, decimals: number | undefined | null) {
return decimals
? Math.ceil(value * 10 ** decimals) / 10 ** decimals
: Math.ceil(value);
}

1
tests/id.json.bak Normal file
View File

@ -0,0 +1 @@
[250,253,108,223,245,245,148,32,18,101,60,90,40,180,51,28,249,204,1,97,208,157,54,7,97,16,14,74,203,208,123,80,32,70,167,192,19,246,135,63,173,62,228,103,149,244,108,120,196,226,78,63,164,107,250,88,118,206,50,193,213,137,249,82]

265
tests/simpleclient.test.ts Normal file
View File

@ -0,0 +1,265 @@
import { Balance, SimpleClient } from '../src/simpleclient';
import { Account, Connection, PublicKey } from '@solana/web3.js';
import {
createMangoGroupSymbolMappings,
createTokenAccountWithBalance,
createWalletAndRequestAirdrop,
getOrderSizeAndPrice,
performSingleDepositOrWithdrawal,
} from './test_utils';
import { MangoClient } from '../src';
import IDS from '../src/ids.json';
import { Market } from '@project-serum/serum';
import os from 'os';
import fs from 'fs';
import expect from 'expect';
process.env.CLUSTER = 'devnet';
process.env.KEYPAIR = __dirname + '/' + 'id.json.bak';
const marketSymbol = 'BTC/USDC';
const lowPriceThatDoesntTrigger = 1;
const marginAccountPk = 'tBhXVv9JVJL8tApoBxEcKEgs7Ngd1FgdYGwBTarN4Ux';
const keyFilePath =
process.env.KEYPAIR || os.homedir() + '/.config/solana/id.json';
describe('test simple client', async () => {
(!fs.existsSync(keyFilePath) ? it : it.skip)(
'boostrap wallet and funds for trading',
async () => {
async function getSpotMarketDetails(
mangoGroupSpotMarket: any,
): Promise<any> {
const [spotMarketSymbol, spotMarketAddress] = mangoGroupSpotMarket;
const [baseSymbol, quoteSymbol] = spotMarketSymbol.split('/');
const spotMarket = await Market.load(
connection,
new PublicKey(spotMarketAddress),
{ skipPreflight: true, commitment: 'singleGossip' },
dexProgramId,
);
return { spotMarket, baseSymbol, quoteSymbol };
}
// 1. mango specific setup
const cluster = 'devnet';
const clusterIds = IDS[cluster];
const dexProgramId = new PublicKey(clusterIds.dex_program_id);
const connection = new Connection(
IDS.cluster_urls[cluster],
'singleGossip',
);
const client = new MangoClient();
const mangoProgramId = new PublicKey(clusterIds.mango_program_id);
const mangoGroupName = 'BTC_ETH_SOL_SRM_USDC';
const mangoGroupIds = clusterIds.mango_groups[mangoGroupName];
const mangoGroupSpotMarkets: [string, string][] = Object.entries(
mangoGroupIds.spot_market_symbols,
);
const mangoGroupPk = new PublicKey(mangoGroupIds.mango_group_pk);
const mangoGroup = await client.getMangoGroup(connection, mangoGroupPk);
const mangoGroupTokenMappings = await createMangoGroupSymbolMappings(
connection,
mangoGroupIds,
);
// 2. create wallet
let owner = await createWalletAndRequestAirdrop(connection, 100);
fs.writeFileSync(keyFilePath, `[${owner.secretKey.toString()}]`);
owner = new Account(JSON.parse(fs.readFileSync(keyFilePath, 'utf-8')));
// 3. get margin accounts, if none exist then init one
const prices = await mangoGroup.getPrices(connection);
let marginAccounts = await client.getMarginAccountsForOwner(
connection,
mangoProgramId,
mangoGroup,
owner,
);
marginAccounts.sort((a, b) =>
a.computeValue(mangoGroup, prices) > b.computeValue(mangoGroup, prices)
? -1
: 1,
);
if (marginAccounts.length === 0) {
await client.initMarginAccount(
connection,
mangoProgramId,
mangoGroup,
owner,
);
marginAccounts = await client.getMarginAccountsForOwner(
connection,
mangoProgramId,
mangoGroup,
owner,
);
}
// 4. deposit usdc
await createTokenAccountWithBalance(
connection,
owner,
'USDC',
mangoGroupTokenMappings,
clusterIds.faucets,
100_000,
);
await performSingleDepositOrWithdrawal(
connection,
owner,
client,
mangoGroup,
mangoProgramId,
'USDC',
mangoGroupTokenMappings,
marginAccounts[0],
'deposit',
100_000,
);
// 5. deposit base currency for each spot market
for (const mangoGroupSpotMarket of mangoGroupSpotMarkets) {
const { spotMarket, baseSymbol, quoteSymbol } =
await getSpotMarketDetails(mangoGroupSpotMarket);
const [orderSize, orderPrice, _] = await getOrderSizeAndPrice(
connection,
spotMarket,
mangoGroupTokenMappings,
baseSymbol,
quoteSymbol,
'buy',
);
const neededQuoteAmount = orderPrice * orderSize;
const neededQuoteAmountForAllTrades = neededQuoteAmount * 128;
await createTokenAccountWithBalance(
connection,
owner,
baseSymbol,
mangoGroupTokenMappings,
clusterIds.faucets,
neededQuoteAmountForAllTrades,
);
await performSingleDepositOrWithdrawal(
connection,
owner,
client,
mangoGroup,
mangoProgramId,
baseSymbol,
mangoGroupTokenMappings,
marginAccounts[0],
'deposit',
neededQuoteAmountForAllTrades,
);
}
},
);
async function cleanup(sc: SimpleClient) {
await sc.cancelOrders();
}
(process.env.CLUSTER === 'devnet' ? it : it.skip)(
'clean slate for devnet',
async () => {
const sc = await SimpleClient.create(marginAccountPk);
await cleanup(sc);
const orders = await sc.getOpenOrders(marketSymbol);
expect(orders.length).toBe(0);
},
);
it('test place order', async () => {
const sc = await SimpleClient.create(marginAccountPk);
const ordersBeforePlacement = await sc.getOpenOrders(marketSymbol);
expect(ordersBeforePlacement.length).toBe(0);
await sc.placeOrder(
marketSymbol,
'limit',
'buy',
0.0001,
lowPriceThatDoesntTrigger,
);
await sc.placeOrder(
marketSymbol,
'limit',
'buy',
0.0002,
lowPriceThatDoesntTrigger,
);
const ordersAfterPlacement = await sc.getOpenOrders(marketSymbol);
expect(ordersAfterPlacement.length).toBe(2);
await cleanup(sc);
});
//
it('test cancel a specific order', async () => {
const sc = await SimpleClient.create(marginAccountPk);
const txClientId = await sc.placeOrder(
marketSymbol,
'limit',
'buy',
0.0001,
lowPriceThatDoesntTrigger,
);
const ordersBeforeCancellation = await sc.getOpenOrders(marketSymbol);
await sc.cancelOrders(marketSymbol, txClientId);
const ordersAfterCancellation = await sc.getOpenOrders(marketSymbol);
expect(ordersAfterCancellation.length).toBe(
ordersBeforeCancellation.length - 1,
);
});
it('test balances', async () => {
const sc = await SimpleClient.create(marginAccountPk);
const balance: Balance = await sc.getBalance();
balance.marginAccountBalances.map((marginAccountBalance) => {
expect(marginAccountBalance.deposited).toBeGreaterThan(0);
});
});
(process.env.CLUSTER === 'mainnet-beta' ? it : it.skip)(
'test trade history',
async () => {
const sc = await SimpleClient.create(marginAccountPk);
const tradeHistory = await sc.getTradeHistory(marketSymbol);
expect(tradeHistory.length).toBeGreaterThan(0);
},
);
(process.env.CLUSTER === 'mainnet-beta' ? it : it.skip)(
'test tickers',
async () => {
const sc = await SimpleClient.create(marginAccountPk);
const tickers = await sc.getTickers();
expect(tickers.length).toBeGreaterThan(0);
},
);
(process.env.CLUSTER === 'mainnet-beta' ? it : it.skip)(
'test ohlcv',
async () => {
const sc = await SimpleClient.create(marginAccountPk);
const to = Date.now();
const yday = to - 24 * 60 * 60 * 1000;
const ohlcvs = await sc.getOhlcv(marketSymbol, '1D', yday, to);
expect(ohlcvs.length).toBeGreaterThan(0);
},
);
(process.env.CLUSTER === 'mainnet-beta' ? it : it.skip)(
'test pnl',
async () => {
const sc = await SimpleClient.create(marginAccountPk);
const pnl = await sc.getPnl();
expect(pnl).toBeGreaterThan(0);
},
);
});

View File

@ -10,7 +10,10 @@
"noImplicitAny": false,
"resolveJsonModule": true,
"sourceMap": true,
"jsx": "react"
"jsx": "react",
"lib": [
"dom"
]
},
"include": ["./tests/**/*", "./src/**/*"],
"exclude": ["./tests/**/*.test.js", "./src/**/*.test.js", "node_modules", "**/node_modules"]

9
tslint.json Normal file
View File

@ -0,0 +1,9 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {},
"rulesDirectory": []
}