diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..884cef6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 707b1ec..cb28d09 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index d63dc9c..65d1e17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/simpleclient.ts b/src/simpleclient.ts new file mode 100644 index 0000000..aa7a08a --- /dev/null +++ b/src/simpleclient.ts @@ -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, + private spotMarketMappings: Map, + 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(); + 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 { + return await this.client.getMarginAccount( + this.connection, + new PublicKey(this.marginAccountPk), + this.dexProgramId, + ); + } + + private async getOpenOrdersAccountForSymbol( + marketSymbol: SpotMarketSymbol, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`); + // } + // } + // } +} diff --git a/src/utils.ts b/src/utils.ts index 236b08b..5261545 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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(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); +} + diff --git a/tests/id.json.bak b/tests/id.json.bak new file mode 100644 index 0000000..5269468 --- /dev/null +++ b/tests/id.json.bak @@ -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] \ No newline at end of file diff --git a/tests/simpleclient.test.ts b/tests/simpleclient.test.ts new file mode 100644 index 0000000..633bbb9 --- /dev/null +++ b/tests/simpleclient.test.ts @@ -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 { + 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); + }, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 50af598..669fa01 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..7b73ab8 --- /dev/null +++ b/tslint.json @@ -0,0 +1,9 @@ +{ + "defaultSeverity": "error", + "extends": [ + "tslint:recommended" + ], + "jsRules": {}, + "rules": {}, + "rulesDirectory": [] +}