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:
parent
6eaf842b9d
commit
3a2dbc889c
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}`);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
28
src/utils.ts
28
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<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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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]
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
|
@ -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"]
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"defaultSeverity": "error",
|
||||
"extends": [
|
||||
"tslint:recommended"
|
||||
],
|
||||
"jsRules": {},
|
||||
"rules": {},
|
||||
"rulesDirectory": []
|
||||
}
|
Loading…
Reference in New Issue