diff --git a/.eslintrc.json b/.eslintrc.json index c525d1fa1..4ad317502 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,12 +14,21 @@ "ecmaVersion": 12, "sourceType": "module" }, - "plugins": ["@typescript-eslint"], + "plugins": [ + "@typescript-eslint" + ], "rules": { - "linebreak-style": ["error", "unix"], - "semi": ["error", "always"], + "linebreak-style": [ + "error", + "unix" + ], + "semi": [ + "error", + "always" + ], "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/ban-ts-comment": 0, - "@typescript-eslint/no-explicit-any": 0 + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/explicit-function-return-type": "warn" } -} +} \ No newline at end of file diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index e5a8d8668..0bd410447 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -1,17 +1,19 @@ import { BN } from '@project-serum/anchor'; import { utf8 } from '@project-serum/anchor/dist/cjs/utils/bytes'; import { PublicKey } from '@solana/web3.js'; -import { nativeI80F48ToUi } from '../utils'; +import { As, nativeI80F48ToUi } from '../utils'; import { I80F48, I80F48Dto, ZERO_I80F48 } from './I80F48'; export const QUOTE_DECIMALS = 6; +export type TokenIndex = number & As<'token-index'>; + export type OracleConfig = { confFilter: I80F48Dto; }; export interface BankForHealth { - tokenIndex: number; + tokenIndex: TokenIndex; maintAssetWeight: I80F48; initAssetWeight: I80F48; maintLiabWeight: I80F48; @@ -85,7 +87,7 @@ export class Bank implements BankForHealth { mintDecimals: number; bankNum: number; }, - ) { + ): Bank { return new Bank( publicKey, obj.name, @@ -120,7 +122,7 @@ export class Bank implements BankForHealth { obj.dust, obj.flashLoanTokenAccountInitial, obj.flashLoanApprovedAmount, - obj.tokenIndex, + obj.tokenIndex as TokenIndex, obj.mintDecimals, obj.bankNum, ); @@ -160,7 +162,7 @@ export class Bank implements BankForHealth { dust: I80F48Dto, flashLoanTokenAccountInitial: BN, flashLoanApprovedAmount: BN, - public tokenIndex: number, + public tokenIndex: TokenIndex, public mintDecimals: number, public bankNum: number, ) { @@ -207,9 +209,9 @@ export class Bank implements BankForHealth { '\n oracle - ' + this.oracle.toBase58() + '\n price - ' + - this.price?.toNumber() + + this._price?.toNumber() + '\n uiPrice - ' + - this.uiPrice + + this._uiPrice + '\n deposit index - ' + this.depositIndex.toNumber() + '\n borrow index - ' + @@ -268,7 +270,7 @@ export class Bank implements BankForHealth { get price(): I80F48 { if (!this._price) { throw new Error( - `Undefined price for bank ${this.publicKey}, tokenIndex ${this.tokenIndex}`, + `Undefined price for bank ${this.publicKey} with tokenIndex ${this.tokenIndex}!`, ); } return this._price; @@ -277,7 +279,7 @@ export class Bank implements BankForHealth { get uiPrice(): number { if (!this._uiPrice) { throw new Error( - `Undefined uiPrice for bank ${this.publicKey}, tokenIndex ${this.tokenIndex}`, + `Undefined uiPrice for bank ${this.publicKey} with tokenIndex ${this.tokenIndex}!`, ); } return this._uiPrice; @@ -388,11 +390,11 @@ export class MintInfo { registrationTime: BN; groupInsuranceFund: number; }, - ) { + ): MintInfo { return new MintInfo( publicKey, obj.group, - obj.tokenIndex, + obj.tokenIndex as TokenIndex, obj.mint, obj.banks, obj.vaults, @@ -405,7 +407,7 @@ export class MintInfo { constructor( public publicKey: PublicKey, public group: PublicKey, - public tokenIndex: number, + public tokenIndex: TokenIndex, public mint: PublicKey, public banks: PublicKey[], public vaults: PublicKey[], diff --git a/ts/client/src/accounts/group.ts b/ts/client/src/accounts/group.ts index 2973a5ed4..9f746a461 100644 --- a/ts/client/src/accounts/group.ts +++ b/ts/client/src/accounts/group.ts @@ -6,7 +6,7 @@ import { Market, Orderbook, } from '@project-serum/serum'; -import { parsePriceData, PriceData } from '@pythnetwork/client'; +import { parsePriceData } from '@pythnetwork/client'; import { AccountInfo, AddressLookupTableAccount, @@ -17,15 +17,15 @@ import { MangoClient } from '../client'; import { SERUM3_PROGRAM_ID } from '../constants'; import { Id } from '../ids'; import { toNativeDecimals, toUiDecimals } from '../utils'; -import { Bank, MintInfo } from './bank'; +import { Bank, MintInfo, TokenIndex } from './bank'; import { I80F48, ONE_I80F48 } from './I80F48'; import { isPythOracle, isSwitchboardOracle, parseSwitchboardOracle, } from './oracle'; -import { BookSide, PerpMarket } from './perp'; -import { Serum3Market } from './serum3'; +import { BookSide, PerpMarket, PerpMarketIndex } from './perp'; +import { MarketIndex, Serum3Market } from './serum3'; export class Group { static from( @@ -57,12 +57,14 @@ export class Group { new Map(), // banksMapByName new Map(), // banksMapByMint new Map(), // banksMapByTokenIndex - new Map(), // serum3MarketsMap + new Map(), // serum3MarketsMapByExternal + new Map(), // serum3MarketsMapByMarketIndex new Map(), // serum3MarketExternalsMap - new Map(), // perpMarketsMap + new Map(), // perpMarketsMapByOracle + new Map(), // perpMarketsMapByMarketIndex + new Map(), // perpMarketsMapByName new Map(), // mintInfosMapByTokenIndex new Map(), // mintInfosMapByMint - new Map(), // oraclesMap new Map(), // vaultAmountsMap ); } @@ -81,18 +83,19 @@ export class Group { public addressLookupTablesList: AddressLookupTableAccount[], public banksMapByName: Map, public banksMapByMint: Map, - public banksMapByTokenIndex: Map, + public banksMapByTokenIndex: Map, public serum3MarketsMapByExternal: Map, - public serum3MarketExternalsMap: Map, - // TODO rethink key - public perpMarketsMap: Map, - public mintInfosMapByTokenIndex: Map, + public serum3MarketsMapByMarketIndex: Map, + public serum3ExternalMarketsMap: Map, + public perpMarketsMapByOracle: Map, + public perpMarketsMapByMarketIndex: Map, + public perpMarketsMapByName: Map, + public mintInfosMapByTokenIndex: Map, public mintInfosMapByMint: Map, - private oraclesMap: Map, // UNUSED public vaultAmountsMap: Map, ) {} - public async reloadAll(client: MangoClient) { + public async reloadAll(client: MangoClient): Promise { let ids: Id | undefined = undefined; if (client.idsSource === 'api') { @@ -109,12 +112,12 @@ export class Group { this.reloadBanks(client, ids).then(() => Promise.all([ this.reloadBankOraclePrices(client), - this.reloadVaults(client, ids), + this.reloadVaults(client), ]), ), this.reloadMintInfos(client, ids), this.reloadSerum3Markets(client, ids).then(() => - this.reloadSerum3ExternalMarkets(client, ids), + this.reloadSerum3ExternalMarkets(client), ), this.reloadPerpMarkets(client, ids).then(() => this.reloadPerpMarketOraclePrices(client), @@ -123,7 +126,7 @@ export class Group { // console.timeEnd('group.reload'); } - public async reloadAlts(client: MangoClient) { + public async reloadAlts(client: MangoClient): Promise { const alts = await Promise.all( this.addressLookupTables .filter((alt) => !alt.equals(PublicKey.default)) @@ -133,13 +136,13 @@ export class Group { ); this.addressLookupTablesList = alts.map((res, i) => { if (!res || !res.value) { - throw new Error(`Error in getting ALT ${this.addressLookupTables[i]}`); + throw new Error(`Undefined ALT ${this.addressLookupTables[i]}!`); } return res.value; }); } - public async reloadBanks(client: MangoClient, ids?: Id) { + public async reloadBanks(client: MangoClient, ids?: Id): Promise { let banks: Bank[]; if (ids && ids.getBanks().length) { @@ -169,7 +172,7 @@ export class Group { } } - public async reloadMintInfos(client: MangoClient, ids?: Id) { + public async reloadMintInfos(client: MangoClient, ids?: Id): Promise { let mintInfos: MintInfo[]; if (ids && ids.getMintInfos().length) { mintInfos = ( @@ -194,7 +197,10 @@ export class Group { ); } - public async reloadSerum3Markets(client: MangoClient, ids?: Id) { + public async reloadSerum3Markets( + client: MangoClient, + ids?: Id, + ): Promise { let serum3Markets: Serum3Market[]; if (ids && ids.getSerum3Markets().length) { serum3Markets = ( @@ -214,9 +220,15 @@ export class Group { serum3Market, ]), ); + this.serum3MarketsMapByMarketIndex = new Map( + serum3Markets.map((serum3Market) => [ + serum3Market.marketIndex, + serum3Market, + ]), + ); } - public async reloadSerum3ExternalMarkets(client: MangoClient, ids?: Id) { + public async reloadSerum3ExternalMarkets(client: MangoClient): Promise { const externalMarkets = await Promise.all( Array.from(this.serum3MarketsMapByExternal.values()).map((serum3Market) => Market.load( @@ -228,7 +240,7 @@ export class Group { ), ); - this.serum3MarketExternalsMap = new Map( + this.serum3ExternalMarketsMap = new Map( Array.from(this.serum3MarketsMapByExternal.values()).map( (serum3Market, index) => [ serum3Market.serumMarketExternal.toBase58(), @@ -238,7 +250,7 @@ export class Group { ); } - public async reloadPerpMarkets(client: MangoClient, ids?: Id) { + public async reloadPerpMarkets(client: MangoClient, ids?: Id): Promise { let perpMarkets: PerpMarket[]; if (ids && ids.getPerpMarkets().length) { perpMarkets = ( @@ -252,9 +264,18 @@ export class Group { perpMarkets = await client.perpGetMarkets(this); } - this.perpMarketsMap = new Map( + this.perpMarketsMapByName = new Map( perpMarkets.map((perpMarket) => [perpMarket.name, perpMarket]), ); + this.perpMarketsMapByOracle = new Map( + perpMarkets.map((perpMarket) => [ + perpMarket.oracle.toBase58(), + perpMarket, + ]), + ); + this.perpMarketsMapByMarketIndex = new Map( + perpMarkets.map((perpMarket) => [perpMarket.perpMarketIndex, perpMarket]), + ); } public async reloadBankOraclePrices(client: MangoClient): Promise { @@ -293,7 +314,9 @@ export class Group { public async reloadPerpMarketOraclePrices( client: MangoClient, ): Promise { - const perpMarkets: PerpMarket[] = Array.from(this.perpMarketsMap.values()); + const perpMarkets: PerpMarket[] = Array.from( + this.perpMarketsMapByName.values(), + ); const oracles = perpMarkets.map((b) => b.oracle); const ais = await client.program.provider.connection.getMultipleAccountsInfo(oracles); @@ -302,15 +325,17 @@ export class Group { ais.forEach(async (ai, i) => { const perpMarket = perpMarkets[i]; if (!ai) - throw new Error('Undefined ai object in reloadPerpMarketOraclePrices!'); + throw new Error( + `Undefined ai object in reloadPerpMarketOraclePrices for ${perpMarket.oracle}!`, + ); const { price, uiPrice } = await this.decodePriceFromOracleAi( coder, perpMarket.oracle, ai, perpMarket.baseDecimals, ); - perpMarket.price = price; - perpMarket.uiPrice = uiPrice; + perpMarket._price = price; + perpMarket._uiPrice = uiPrice; }); } @@ -319,7 +344,7 @@ export class Group { oracle: PublicKey, ai: AccountInfo, baseDecimals: number, - ) { + ): Promise<{ price: I80F48; uiPrice: number }> { let price, uiPrice; if ( !BorshAccountsCoder.accountDiscriminator('stubOracle').compare( @@ -337,13 +362,13 @@ export class Group { price = this?.toNativePrice(uiPrice, baseDecimals); } else { throw new Error( - `Unknown oracle provider for oracle ${oracle}, with owner ${ai.owner}`, + `Unknown oracle provider (parsing not implemented) for oracle ${oracle}, with owner ${ai.owner}!`, ); } return { price, uiPrice }; } - public async reloadVaults(client: MangoClient, ids?: Id): Promise { + public async reloadVaults(client: MangoClient): Promise { const vaultPks = Array.from(this.banksMapByMint.values()) .flat() .map((bank) => bank.vault); @@ -354,7 +379,9 @@ export class Group { this.vaultAmountsMap = new Map( vaultAccounts.map((vaultAi, i) => { - if (!vaultAi) throw new Error('Missing vault account info'); + if (!vaultAi) { + throw new Error(`Undefined vaultAi for ${vaultPks[i]}`!); + } const vaultAmount = coder() .accounts.decode('token', vaultAi.data) .amount.toNumber(); @@ -365,8 +392,7 @@ export class Group { public getMintDecimals(mintPk: PublicKey): number { const banks = this.banksMapByMint.get(mintPk.toString()); - if (!banks) - throw new Error(`Unable to find mint decimals for ${mintPk.toString()}`); + if (!banks) throw new Error(`No bank found for mint ${mintPk}!`); return banks[0].mintDecimals; } @@ -376,14 +402,13 @@ export class Group { public getFirstBankByMint(mintPk: PublicKey): Bank { const banks = this.banksMapByMint.get(mintPk.toString()); - if (!banks) throw new Error(`Unable to find bank for ${mintPk.toString()}`); + if (!banks) throw new Error(`No bank found for mint ${mintPk}!`); return banks[0]; } - public getFirstBankByTokenIndex(tokenIndex: number): Bank { + public getFirstBankByTokenIndex(tokenIndex: TokenIndex): Bank { const banks = this.banksMapByTokenIndex.get(tokenIndex); - if (!banks) - throw new Error(`Unable to find banks for tokenIndex ${tokenIndex}`); + if (!banks) throw new Error(`No bank found for tokenIndex ${tokenIndex}!`); return banks[0]; } @@ -394,10 +419,7 @@ export class Group { */ public getTokenVaultBalanceByMint(mintPk: PublicKey): I80F48 { const banks = this.banksMapByMint.get(mintPk.toBase58()); - if (!banks) - throw new Error( - `Mint does not exist in getTokenVaultBalanceByMint ${mintPk.toString()}`, - ); + if (!banks) throw new Error(`No bank found for mint ${mintPk}!`); let totalAmount = 0; for (const bank of banks) { const amount = this.vaultAmountsMap.get(bank.vault.toBase58()); @@ -408,83 +430,6 @@ export class Group { return I80F48.fromNumber(totalAmount); } - public findPerpMarket(marketIndex: number): PerpMarket | undefined { - return Array.from(this.perpMarketsMap.values()).find( - (perpMarket) => perpMarket.perpMarketIndex === marketIndex, - ); - } - - public findSerum3Market(marketIndex: number): Serum3Market | undefined { - return Array.from(this.serum3MarketsMapByExternal.values()).find( - (serum3Market) => serum3Market.marketIndex === marketIndex, - ); - } - - public findSerum3MarketByName(name: string): Serum3Market | undefined { - return Array.from(this.serum3MarketsMapByExternal.values()).find( - (serum3Market) => serum3Market.name === name, - ); - } - - public async loadSerum3BidsForMarket( - client: MangoClient, - externalMarketPk: PublicKey, - ): Promise { - const serum3Market = this.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), - ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } - return await serum3Market.loadBids(client, this); - } - - public async loadSerum3AsksForMarket( - client: MangoClient, - externalMarketPk: PublicKey, - ): Promise { - const serum3Market = this.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), - ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } - return await serum3Market.loadAsks(client, this); - } - - public getFeeRate(maker = true) { - // TODO: fetch msrm/srm vault balance - const feeTier = getFeeTier(0, 0); - const rates = getFeeRates(feeTier); - return maker ? rates.maker : rates.taker; - } - - public async loadPerpBidsForMarket( - client: MangoClient, - marketName: string, - ): Promise { - const perpMarket = this.perpMarketsMap.get(marketName); - if (!perpMarket) { - throw new Error(`Perp Market ${marketName} not found!`); - } - return await perpMarket.loadBids(client); - } - - public async loadPerpAsksForMarket( - client: MangoClient, - marketName: string, - ): Promise { - const perpMarket = this.perpMarketsMap.get(marketName); - if (!perpMarket) { - throw new Error(`Perp Market ${marketName} not found!`); - } - return await perpMarket.loadAsks(client); - } - /** * * @param mintPk @@ -497,7 +442,131 @@ export class Group { return toUiDecimals(vaultBalance, mintDecimals); } - public consoleLogBanks() { + public getSerum3MarketByMarketIndex(marketIndex: MarketIndex): Serum3Market { + const serum3Market = this.serum3MarketsMapByMarketIndex.get(marketIndex); + if (!serum3Market) { + throw new Error(`No serum3Market found for marketIndex ${marketIndex}!`); + } + return serum3Market; + } + + public getSerum3MarketByName(name: string): Serum3Market { + const serum3Market = Array.from( + this.serum3MarketsMapByExternal.values(), + ).find((serum3Market) => serum3Market.name === name); + if (!serum3Market) { + throw new Error(`No serum3Market found by name ${name}!`); + } + return serum3Market; + } + + public getSerum3MarketByExternalMarket( + externalMarketPk: PublicKey, + ): Serum3Market { + const serum3Market = Array.from( + this.serum3MarketsMapByExternal.values(), + ).find((serum3Market) => + serum3Market.serumMarketExternal.equals(externalMarketPk), + ); + if (!serum3Market) { + throw new Error( + `No serum3Market found for external serum3 market ${externalMarketPk.toString()}!`, + ); + } + return serum3Market; + } + + public getSerum3ExternalMarket(externalMarketPk: PublicKey): Market { + const market = this.serum3ExternalMarketsMap.get( + externalMarketPk.toBase58(), + ); + if (!market) { + throw new Error( + `No external market found for pk ${externalMarketPk.toString()}!`, + ); + } + return market; + } + + public async loadSerum3BidsForMarket( + client: MangoClient, + externalMarketPk: PublicKey, + ): Promise { + const serum3Market = this.getSerum3MarketByExternalMarket(externalMarketPk); + return await serum3Market.loadBids(client, this); + } + + public async loadSerum3AsksForMarket( + client: MangoClient, + externalMarketPk: PublicKey, + ): Promise { + const serum3Market = this.getSerum3MarketByExternalMarket(externalMarketPk); + return await serum3Market.loadAsks(client, this); + } + + public getSerum3FeeRates(maker = true): number { + // TODO: fetch msrm/srm vault balance + const feeTier = getFeeTier(0, 0); + const rates = getFeeRates(feeTier); + return maker ? rates.maker : rates.taker; + } + + public findPerpMarket(marketIndex: PerpMarketIndex): PerpMarket { + const perpMarket = Array.from(this.perpMarketsMapByName.values()).find( + (perpMarket) => perpMarket.perpMarketIndex === marketIndex, + ); + if (!perpMarket) { + throw new Error( + `No perpMarket found for perpMarketIndex ${marketIndex}!`, + ); + } + return perpMarket; + } + + public getPerpMarketByOracle(oracle: PublicKey): PerpMarket { + const perpMarket = this.perpMarketsMapByOracle.get(oracle.toBase58()); + if (!perpMarket) { + throw new Error(`No PerpMarket found for oracle ${oracle}!`); + } + return perpMarket; + } + + public getPerpMarketByMarketIndex(marketIndex: PerpMarketIndex): PerpMarket { + const perpMarket = this.perpMarketsMapByMarketIndex.get(marketIndex); + if (!perpMarket) { + throw new Error(`No PerpMarket found with marketIndex ${marketIndex}!`); + } + return perpMarket; + } + + public getPerpMarketByName(perpMarketName: string): PerpMarket { + const perpMarket = Array.from( + this.perpMarketsMapByMarketIndex.values(), + ).find((perpMarket) => perpMarket.name === perpMarketName); + if (!perpMarket) { + throw new Error(`No PerpMarket found by name ${perpMarketName}!`); + } + return perpMarket; + } + + public async loadPerpBidsForMarket( + client: MangoClient, + perpMarketIndex: PerpMarketIndex, + ): Promise { + const perpMarket = this.getPerpMarketByMarketIndex(perpMarketIndex); + return await perpMarket.loadBids(client); + } + + public async loadPerpAsksForMarket( + client: MangoClient, + group: Group, + perpMarketIndex: PerpMarketIndex, + ): Promise { + const perpMarket = this.getPerpMarketByMarketIndex(perpMarketIndex); + return await perpMarket.loadAsks(client); + } + + public consoleLogBanks(): void { for (const mintBanks of this.banksMapByMint.values()) { for (const bank of mintBanks) { console.log(bank.toString()); diff --git a/ts/client/src/accounts/healthCache.spec.ts b/ts/client/src/accounts/healthCache.spec.ts index 5d1697083..e0beb5252 100644 --- a/ts/client/src/accounts/healthCache.spec.ts +++ b/ts/client/src/accounts/healthCache.spec.ts @@ -1,14 +1,371 @@ +import { BN } from '@project-serum/anchor'; +import { OpenOrders } from '@project-serum/serum'; import { expect } from 'chai'; import { toUiDecimalsForQuote } from '../utils'; -import { BankForHealth } from './bank'; -import { HealthCache, TokenInfo } from './healthCache'; +import { BankForHealth, TokenIndex } from './bank'; +import { HealthCache, PerpInfo, Serum3Info, TokenInfo } from './healthCache'; import { I80F48, ZERO_I80F48 } from './I80F48'; +import { HealthType, PerpPosition } from './mangoAccount'; +import { PerpMarket } from './perp'; +import { MarketIndex } from './serum3'; + +function mockBankAndOracle( + tokenIndex: TokenIndex, + maintWeight: number, + initWeight: number, + price: number, +): BankForHealth { + return { + tokenIndex, + maintAssetWeight: I80F48.fromNumber(1 - maintWeight), + initAssetWeight: I80F48.fromNumber(1 - initWeight), + maintLiabWeight: I80F48.fromNumber(1 + maintWeight), + initLiabWeight: I80F48.fromNumber(1 + initWeight), + price: I80F48.fromNumber(price), + }; +} + +function mockPerpMarket( + perpMarketIndex: number, + maintWeight: number, + initWeight: number, + price: I80F48, +): PerpMarket { + return { + perpMarketIndex, + maintAssetWeight: I80F48.fromNumber(1 - maintWeight), + initAssetWeight: I80F48.fromNumber(1 - initWeight), + maintLiabWeight: I80F48.fromNumber(1 + maintWeight), + initLiabWeight: I80F48.fromNumber(1 + initWeight), + price, + quoteLotSize: new BN(100), + baseLotSize: new BN(10), + longFunding: ZERO_I80F48(), + shortFunding: ZERO_I80F48(), + } as unknown as PerpMarket; +} describe('Health Cache', () => { + it('test_health0', () => { + const sourceBank: BankForHealth = mockBankAndOracle( + 1 as TokenIndex, + 0.1, + 0.2, + 1, + ); + const targetBank: BankForHealth = mockBankAndOracle( + 4 as TokenIndex, + 0.3, + 0.5, + 5, + ); + + const ti1 = TokenInfo.fromBank(sourceBank, I80F48.fromNumber(100)); + const ti2 = TokenInfo.fromBank(targetBank, I80F48.fromNumber(-10)); + + const si1 = Serum3Info.fromOoModifyingTokenInfos( + 1, + ti2, + 0, + ti1, + 2 as MarketIndex, + { + quoteTokenTotal: new BN(21), + baseTokenTotal: new BN(18), + quoteTokenFree: new BN(1), + baseTokenFree: new BN(3), + referrerRebatesAccrued: new BN(2), + } as any as OpenOrders, + ); + + const pM = mockPerpMarket(9, 0.1, 0.2, targetBank.price); + const pp = new PerpPosition( + pM.perpMarketIndex, + 3, + I80F48.fromNumber(-310), + 7, + 11, + 1, + 2, + I80F48.fromNumber(0), + I80F48.fromNumber(0), + ); + const pi1 = PerpInfo.fromPerpPosition(pM, pp); + + const hc = new HealthCache([ti1, ti2], [si1], [pi1]); + + // for bank1/oracle1, including open orders (scenario: bids execute) + const health1 = (100.0 + 1.0 + 2.0 + (20.0 + 15.0 * 5.0)) * 0.8; + // for bank2/oracle2 + const health2 = (-10.0 + 3.0) * 5.0 * 1.5; + // for perp (scenario: bids execute) + const health3 = + (3.0 + 7.0 + 1.0) * 10.0 * 5.0 * 0.8 + + (-310.0 + 2.0 * 100.0 - 7.0 * 10.0 * 5.0); + + const health = hc.health(HealthType.init).toNumber(); + console.log( + `health ${health + .toFixed(3) + .padStart( + 10, + )}, case "test that includes all the side values (like referrer_rebates_accrued)"`, + ); + + expect(health - (health1 + health2 + health3)).lessThan(0.0000001); + }); + + it('test_health1', () => { + function testFixture(fixture: { + name: string; + token1: number; + token2: number; + token3: number; + oo12: [number, number]; + oo13: [number, number]; + perp1: [number, number, number, number]; + expectedHealth: number; + }): void { + const bank1: BankForHealth = mockBankAndOracle( + 1 as TokenIndex, + 0.1, + 0.2, + 1, + ); + const bank2: BankForHealth = mockBankAndOracle( + 4 as TokenIndex, + 0.3, + 0.5, + 5, + ); + const bank3: BankForHealth = mockBankAndOracle( + 5 as TokenIndex, + 0.3, + 0.5, + 10, + ); + + const ti1 = TokenInfo.fromBank(bank1, I80F48.fromNumber(fixture.token1)); + const ti2 = TokenInfo.fromBank(bank2, I80F48.fromNumber(fixture.token2)); + const ti3 = TokenInfo.fromBank(bank3, I80F48.fromNumber(fixture.token3)); + + const si1 = Serum3Info.fromOoModifyingTokenInfos( + 1, + ti2, + 0, + ti1, + 2 as MarketIndex, + { + quoteTokenTotal: new BN(fixture.oo12[0]), + baseTokenTotal: new BN(fixture.oo12[1]), + quoteTokenFree: new BN(0), + baseTokenFree: new BN(0), + referrerRebatesAccrued: new BN(0), + } as any as OpenOrders, + ); + + const si2 = Serum3Info.fromOoModifyingTokenInfos( + 2, + ti3, + 0, + ti1, + 2 as MarketIndex, + { + quoteTokenTotal: new BN(fixture.oo13[0]), + baseTokenTotal: new BN(fixture.oo13[1]), + quoteTokenFree: new BN(0), + baseTokenFree: new BN(0), + referrerRebatesAccrued: new BN(0), + } as any as OpenOrders, + ); + + const pM = mockPerpMarket(9, 0.1, 0.2, bank2.price); + const pp = new PerpPosition( + pM.perpMarketIndex, + fixture.perp1[0], + I80F48.fromNumber(fixture.perp1[1]), + fixture.perp1[2], + fixture.perp1[3], + 0, + 0, + I80F48.fromNumber(0), + I80F48.fromNumber(0), + ); + const pi1 = PerpInfo.fromPerpPosition(pM, pp); + + const hc = new HealthCache([ti1, ti2, ti3], [si1, si2], [pi1]); + const health = hc.health(HealthType.init).toNumber(); + console.log( + `health ${health.toFixed(3).padStart(10)}, case "${fixture.name}"`, + ); + expect(health - fixture.expectedHealth).lessThan(0.0000001); + } + + const basePrice = 5; + const baseLotsToQuote = 10.0 * basePrice; + + testFixture({ + name: '0', + token1: 100, + token2: -10, + token3: 0, + oo12: [20, 15], + oo13: [0, 0], + perp1: [3, -131, 7, 11], + expectedHealth: + // for token1, including open orders (scenario: bids execute) + (100.0 + (20.0 + 15.0 * basePrice)) * 0.8 - + // for token2 + 10.0 * basePrice * 1.5 + + // for perp (scenario: bids execute) + (3.0 + 7.0) * baseLotsToQuote * 0.8 + + (-131.0 - 7.0 * baseLotsToQuote), + }); + + testFixture({ + name: '1', + token1: -100, + token2: 10, + token3: 0, + oo12: [20, 15], + oo13: [0, 0], + perp1: [-10, -131, 7, 11], + expectedHealth: + // for token1 + -100.0 * 1.2 + + // for token2, including open orders (scenario: asks execute) + (10.0 * basePrice + (20.0 + 15.0 * basePrice)) * 0.5 + + // for perp (scenario: asks execute) + (-10.0 - 11.0) * baseLotsToQuote * 1.2 + + (-131.0 + 11.0 * baseLotsToQuote), + }); + + testFixture({ + name: '2', + token1: 0, + token2: 0, + token3: 0, + oo12: [0, 0], + oo13: [0, 0], + perp1: [-10, 100, 0, 0], + expectedHealth: 0, + }); + + testFixture({ + name: '3', + token1: 0, + token2: 0, + token3: 0, + oo12: [0, 0], + oo13: [0, 0], + perp1: [1, -100, 0, 0], + expectedHealth: -100.0 + 0.8 * 1.0 * baseLotsToQuote, + }); + + testFixture({ + name: '4', + token1: 0, + token2: 0, + token3: 0, + oo12: [0, 0], + oo13: [0, 0], + perp1: [10, 100, 0, 0], + expectedHealth: 0, + }); + + testFixture({ + name: '5', + token1: 0, + token2: 0, + token3: 0, + oo12: [0, 0], + oo13: [0, 0], + perp1: [30, -100, 0, 0], + expectedHealth: 0, + }); + + testFixture({ + name: '6, reserved oo funds', + token1: -100, + token2: -10, + token3: -10, + oo12: [1, 1], + oo13: [1, 1], + perp1: [30, -100, 0, 0], + expectedHealth: + // tokens + -100.0 * 1.2 - + 10.0 * 5.0 * 1.5 - + 10.0 * 10.0 * 1.5 + + // oo_1_2 (-> token1) + (1.0 + 5.0) * 1.2 + + // oo_1_3 (-> token1) + (1.0 + 10.0) * 1.2, + }); + + testFixture({ + name: '7, reserved oo funds cross the zero balance level', + token1: -14, + token2: -10, + token3: -10, + oo12: [1, 1], + oo13: [1, 1], + perp1: [0, 0, 0, 0], + expectedHealth: + -14.0 * 1.2 - + 10.0 * 5.0 * 1.5 - + 10.0 * 10.0 * 1.5 + + // oo_1_2 (-> token1) + 3.0 * 1.2 + + 3.0 * 0.8 + + // oo_1_3 (-> token1) + 8.0 * 1.2 + + 3.0 * 0.8, + }); + + testFixture({ + name: '8, reserved oo funds in a non-quote currency', + token1: -100, + token2: -100, + token3: -1, + oo12: [0, 0], + oo13: [10, 1], + perp1: [0, 0, 0, 0], + expectedHealth: + // tokens + -100.0 * 1.2 - + 100.0 * 5.0 * 1.5 - + 10.0 * 1.5 + + // oo_1_3 (-> token3) + 10.0 * 1.5 + + 10.0 * 0.5, + }); + + testFixture({ + name: '9, like 8 but oo_1_2 flips the oo_1_3 target', + token1: -100, + token2: -100, + token3: -1, + oo12: [100, 0], + oo13: [10, 1], + perp1: [0, 0, 0, 0], + expectedHealth: + // tokens + -100.0 * 1.2 - + 100.0 * 5.0 * 1.5 - + 10.0 * 1.5 + + // oo_1_2 (-> token1) + 80.0 * 1.2 + + 20.0 * 0.8 + + // oo_1_3 (-> token1) + 20.0 * 0.8, + }); + }); + it('max swap tokens for min ratio', () => { // USDC like const sourceBank: BankForHealth = { - tokenIndex: 0, + tokenIndex: 0 as TokenIndex, maintAssetWeight: I80F48.fromNumber(1), initAssetWeight: I80F48.fromNumber(1), maintLiabWeight: I80F48.fromNumber(1), @@ -17,7 +374,7 @@ describe('Health Cache', () => { }; // BTC like const targetBank: BankForHealth = { - tokenIndex: 1, + tokenIndex: 1 as TokenIndex, maintAssetWeight: I80F48.fromNumber(0.9), initAssetWeight: I80F48.fromNumber(0.8), maintLiabWeight: I80F48.fromNumber(1.1), @@ -28,7 +385,7 @@ describe('Health Cache', () => { const hc = new HealthCache( [ new TokenInfo( - 0, + 0 as TokenIndex, sourceBank.maintAssetWeight, sourceBank.initAssetWeight, sourceBank.maintLiabWeight, @@ -39,7 +396,7 @@ describe('Health Cache', () => { ), new TokenInfo( - 1, + 1 as TokenIndex, targetBank.maintAssetWeight, targetBank.initAssetWeight, targetBank.maintLiabWeight, diff --git a/ts/client/src/accounts/healthCache.ts b/ts/client/src/accounts/healthCache.ts index c7444cfed..b06477ab7 100644 --- a/ts/client/src/accounts/healthCache.ts +++ b/ts/client/src/accounts/healthCache.ts @@ -1,18 +1,20 @@ +import { BN } from '@project-serum/anchor'; +import { OpenOrders } from '@project-serum/serum'; import { PublicKey } from '@solana/web3.js'; import _ from 'lodash'; -import { Bank, BankForHealth } from './bank'; +import { Bank, BankForHealth, TokenIndex } from './bank'; import { Group } from './group'; import { HUNDRED_I80F48, I80F48, I80F48Dto, MAX_I80F48, - ONE_I80F48, ZERO_I80F48, } from './I80F48'; -import { HealthType } from './mangoAccount'; + +import { HealthType, MangoAccount, PerpPosition } from './mangoAccount'; import { PerpMarket, PerpOrderSide } from './perp'; -import { Serum3Market, Serum3Side } from './serum3'; +import { MarketIndex, Serum3Market, Serum3Side } from './serum3'; // ░░░░ // @@ -45,7 +47,63 @@ export class HealthCache { public perpInfos: PerpInfo[], ) {} - static fromDto(dto) { + static fromMangoAccount( + group: Group, + mangoAccount: MangoAccount, + ): HealthCache { + // token contribution from token accounts + const tokenInfos = mangoAccount.tokensActive().map((tokenPosition) => { + const bank = group.getFirstBankByTokenIndex(tokenPosition.tokenIndex); + return TokenInfo.fromBank(bank, tokenPosition.balance(bank)); + }); + + // Fill the TokenInfo balance with free funds in serum3 oo accounts, and fill + // the serum3MaxReserved with their reserved funds. Also build Serum3Infos. + const serum3Infos = mangoAccount.serum3Active().map((serum3) => { + const oo = mangoAccount.getSerum3OoAccount(serum3.marketIndex); + + // find the TokenInfos for the market's base and quote tokens + const baseIndex = tokenInfos.findIndex( + (tokenInfo) => tokenInfo.tokenIndex === serum3.baseTokenIndex, + ); + const baseInfo = tokenInfos[baseIndex]; + if (!baseInfo) { + throw new Error( + `BaseInfo not found for market with marketIndex ${serum3.marketIndex}!`, + ); + } + const quoteIndex = tokenInfos.findIndex( + (tokenInfo) => tokenInfo.tokenIndex === serum3.quoteTokenIndex, + ); + const quoteInfo = tokenInfos[quoteIndex]; + if (!quoteInfo) { + throw new Error( + `QuoteInfo not found for market with marketIndex ${serum3.marketIndex}!`, + ); + } + + return Serum3Info.fromOoModifyingTokenInfos( + baseIndex, + baseInfo, + quoteIndex, + quoteInfo, + serum3.marketIndex, + oo, + ); + }); + + // health contribution from perp accounts + const perpInfos = mangoAccount.perpActive().map((perpPosition) => { + const perpMarket = group.getPerpMarketByMarketIndex( + perpPosition.marketIndex, + ); + return PerpInfo.fromPerpPosition(perpMarket, perpPosition); + }); + + return new HealthCache(tokenInfos, serum3Infos, perpInfos); + } + + static fromDto(dto): HealthCache { return new HealthCache( dto.tokenInfos.map((dto) => TokenInfo.fromDto(dto)), dto.serum3Infos.map((dto) => Serum3Info.fromDto(dto)), @@ -57,6 +115,7 @@ export class HealthCache { const health = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { const contrib = tokenInfo.healthContribution(healthType); + // console.log(` - ti ${contrib}`); health.iadd(contrib); } for (const serum3Info of this.serum3Infos) { @@ -64,10 +123,12 @@ export class HealthCache { healthType, this.tokenInfos, ); + // console.log(` - si ${contrib}`); health.iadd(contrib); } for (const perpInfo of this.perpInfos) { const contrib = perpInfo.healthContribution(healthType); + // console.log(` - pi ${contrib}`); health.iadd(contrib); } return health; @@ -164,34 +225,32 @@ export class HealthCache { } } - findTokenInfoIndex(tokenIndex: number): number { + findTokenInfoIndex(tokenIndex: TokenIndex): number { return this.tokenInfos.findIndex( - (tokenInfo) => tokenInfo.tokenIndex == tokenIndex, + (tokenInfo) => tokenInfo.tokenIndex === tokenIndex, ); } getOrCreateTokenInfoIndex(bank: BankForHealth): number { const index = this.findTokenInfoIndex(bank.tokenIndex); if (index == -1) { - this.tokenInfos.push(TokenInfo.emptyFromBank(bank)); + this.tokenInfos.push(TokenInfo.fromBank(bank)); } return this.findTokenInfoIndex(bank.tokenIndex); } - findSerum3InfoIndex(marketIndex: number): number { + findSerum3InfoIndex(marketIndex: MarketIndex): number { return this.serum3Infos.findIndex( (serum3Info) => serum3Info.marketIndex === marketIndex, ); } - getOrCreateSerum3InfoIndex(group: Group, serum3Market: Serum3Market): number { + getOrCreateSerum3InfoIndex( + baseBank: BankForHealth, + quoteBank: BankForHealth, + serum3Market: Serum3Market, + ): number { const index = this.findSerum3InfoIndex(serum3Market.marketIndex); - const baseBank = group.getFirstBankByTokenIndex( - serum3Market.baseTokenIndex, - ); - const quoteBank = group.getFirstBankByTokenIndex( - serum3Market.quoteTokenIndex, - ); const baseEntryIndex = this.getOrCreateTokenInfoIndex(baseBank); const quoteEntryIndex = this.getOrCreateTokenInfoIndex(quoteBank); if (index == -1) { @@ -208,20 +267,14 @@ export class HealthCache { adjustSerum3Reserved( // todo change indices to types from numbers - group: Group, + baseBank: BankForHealth, + quoteBank: BankForHealth, serum3Market: Serum3Market, reservedBaseChange: I80F48, freeBaseChange: I80F48, reservedQuoteChange: I80F48, freeQuoteChange: I80F48, - ) { - const baseBank = group.getFirstBankByTokenIndex( - serum3Market.baseTokenIndex, - ); - const quoteBank = group.getFirstBankByTokenIndex( - serum3Market.quoteTokenIndex, - ); - + ): void { const baseEntryIndex = this.getOrCreateTokenInfoIndex(baseBank); const quoteEntryIndex = this.getOrCreateTokenInfoIndex(quoteBank); @@ -238,7 +291,11 @@ export class HealthCache { quoteEntry.balance.iadd(freeQuoteChange.mul(quoteEntry.oraclePrice)); // Apply it to the serum3 info - const index = this.getOrCreateSerum3InfoIndex(group, serum3Market); + const index = this.getOrCreateSerum3InfoIndex( + baseBank, + quoteBank, + serum3Market, + ); const serum3Info = this.serum3Infos[index]; serum3Info.reserved = serum3Info.reserved.add(reservedAmount); } @@ -257,7 +314,7 @@ export class HealthCache { return this.findPerpInfoIndex(perpMarket.perpMarketIndex); } - public static logHealthCache(debug: string, healthCache: HealthCache) { + public static logHealthCache(debug: string, healthCache: HealthCache): void { if (debug) console.log(debug); for (const token of healthCache.tokenInfos) { console.log(` ${token.toString()}`); @@ -293,10 +350,6 @@ export class HealthCache { for (const change of nativeTokenChanges) { const bank: Bank = group.getFirstBankByMint(change.mintPk); const changeIndex = adjustedCache.getOrCreateTokenInfoIndex(bank); - if (!bank.price) - throw new Error( - `Oracle price not loaded for ${change.mintPk.toString()}`, - ); adjustedCache.tokenInfos[changeIndex].balance.iadd( change.nativeTokenAmount.mul(bank.price), ); @@ -306,18 +359,13 @@ export class HealthCache { } simHealthRatioWithSerum3BidChanges( - group: Group, + baseBank: BankForHealth, + quoteBank: BankForHealth, bidNativeQuoteAmount: I80F48, serum3Market: Serum3Market, healthType: HealthType = HealthType.init, ): I80F48 { const adjustedCache: HealthCache = _.cloneDeep(this); - const quoteBank = group.getFirstBankByTokenIndex( - serum3Market.quoteTokenIndex, - ); - if (!quoteBank) { - throw new Error(`No bank for index ${serum3Market.quoteTokenIndex}`); - } const quoteIndex = adjustedCache.getOrCreateTokenInfoIndex(quoteBank); const quote = adjustedCache.tokenInfos[quoteIndex]; @@ -331,7 +379,8 @@ export class HealthCache { // Increase reserved in Serum3Info for quote adjustedCache.adjustSerum3Reserved( - group, + baseBank, + quoteBank, serum3Market, ZERO_I80F48(), ZERO_I80F48(), @@ -342,18 +391,13 @@ export class HealthCache { } simHealthRatioWithSerum3AskChanges( - group: Group, + baseBank: BankForHealth, + quoteBank: BankForHealth, askNativeBaseAmount: I80F48, serum3Market: Serum3Market, healthType: HealthType = HealthType.init, ): I80F48 { const adjustedCache: HealthCache = _.cloneDeep(this); - const baseBank = group.getFirstBankByTokenIndex( - serum3Market.baseTokenIndex, - ); - if (!baseBank) { - throw new Error(`No bank for index ${serum3Market.quoteTokenIndex}`); - } const baseIndex = adjustedCache.getOrCreateTokenInfoIndex(baseBank); const base = adjustedCache.tokenInfos[baseIndex]; @@ -367,7 +411,8 @@ export class HealthCache { // Increase reserved in Serum3Info for base adjustedCache.adjustSerum3Reserved( - group, + baseBank, + quoteBank, serum3Market, askNativeBaseAmount, ZERO_I80F48(), @@ -384,7 +429,7 @@ export class HealthCache { rightRatio: I80F48, targetRatio: I80F48, healthRatioAfterActionFn: (I80F48) => I80F48, - ) { + ): I80F48 { const maxIterations = 40; // TODO: make relative to health ratio decimals? Might be over engineering const targetError = I80F48.fromNumber(0.001); @@ -396,11 +441,12 @@ export class HealthCache { rightRatio.sub(targetRatio).isNeg()) ) { throw new Error( - `internal error: left ${leftRatio.toNumber()} and right ${rightRatio.toNumber()} don't contain the target value ${targetRatio.toNumber()}`, + `Internal error: left ${leftRatio.toNumber()} and right ${rightRatio.toNumber()} don't contain the target value ${targetRatio.toNumber()}, likely reason is the zeroAmount not been tight enough!`, ); } let newAmount; + // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const key of Array(maxIterations).fill(0).keys()) { newAmount = left.add(right).mul(I80F48.fromNumber(0.5)); const newAmountRatio = healthRatioAfterActionFn(newAmount); @@ -427,15 +473,6 @@ export class HealthCache { minRatio: I80F48, priceFactor: I80F48, ): I80F48 { - if ( - !sourceBank.price || - sourceBank.price.lte(ZERO_I80F48()) || - !targetBank.price || - targetBank.price.lte(ZERO_I80F48()) - ) { - return ZERO_I80F48(); - } - if ( sourceBank.initLiabWeight .sub(targetBank.initAssetWeight) @@ -454,6 +491,7 @@ export class HealthCache { // - be careful about finding the minRatio point: the function isn't convex const initialRatio = this.healthRatio(HealthType.init); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const initialHealth = this.health(HealthType.init); if (initialRatio.lte(ZERO_I80F48())) { return ZERO_I80F48(); @@ -481,7 +519,7 @@ export class HealthCache { // negative. // The maximum will be at one of these points (ignoring serum3 effects). - function cacheAfterSwap(amount: I80F48) { + function cacheAfterSwap(amount: I80F48): HealthCache { const adjustedCache: HealthCache = _.cloneDeep(healthCacheClone); // HealthCache.logHealthCache('beforeSwap', adjustedCache); adjustedCache.tokenInfos[sourceIndex].balance.isub(amount); @@ -578,24 +616,12 @@ export class HealthCache { } getMaxSerum3OrderForHealthRatio( - group: Group, + baseBank: BankForHealth, + quoteBank: BankForHealth, serum3Market: Serum3Market, side: Serum3Side, minRatio: I80F48, - ) { - const baseBank = group.getFirstBankByTokenIndex( - serum3Market.baseTokenIndex, - ); - if (!baseBank) { - throw new Error(`No bank for index ${serum3Market.baseTokenIndex}`); - } - const quoteBank = group.getFirstBankByTokenIndex( - serum3Market.quoteTokenIndex, - ); - if (!quoteBank) { - throw new Error(`No bank for index ${serum3Market.quoteTokenIndex}`); - } - + ): I80F48 { const healthCacheClone: HealthCache = _.cloneDeep(this); const baseIndex = healthCacheClone.getOrCreateTokenInfoIndex(baseBank); @@ -652,10 +678,11 @@ export class HealthCache { } const cache = cacheAfterPlacingOrder(zeroAmount); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const zeroAmountHealth = cache.health(HealthType.init); const zeroAmountRatio = cache.healthRatio(HealthType.init); - function cacheAfterPlacingOrder(amount: I80F48) { + function cacheAfterPlacingOrder(amount: I80F48): HealthCache { const adjustedCache: HealthCache = _.cloneDeep(healthCacheClone); side === Serum3Side.ask @@ -663,7 +690,8 @@ export class HealthCache { : adjustedCache.tokenInfos[quoteIndex].balance.isub(amount); adjustedCache.adjustSerum3Reserved( - group, + baseBank, + quoteBank, serum3Market, side === Serum3Side.ask ? amount.div(base.oraclePrice) : ZERO_I80F48(), ZERO_I80F48(), @@ -687,18 +715,7 @@ export class HealthCache { healthRatioAfterPlacingOrder, ); - // If its a bid then the reserved fund and potential loan is in quote, - // If its a ask then the reserved fund and potential loan is in base, - // also keep some buffer for fees, use taker fees for worst case simulation. - return side === Serum3Side.bid - ? amount - .div(quote.oraclePrice) - .div(ONE_I80F48().add(baseBank.loanOriginationFeeRate)) - .div(ONE_I80F48().add(I80F48.fromNumber(group.getFeeRate(false)))) - : amount - .div(base.oraclePrice) - .div(ONE_I80F48().add(quoteBank.loanOriginationFeeRate)) - .div(ONE_I80F48().add(I80F48.fromNumber(group.getFeeRate(false)))); + return amount; } getMaxPerpForHealthRatio( @@ -813,7 +830,7 @@ export class HealthCache { export class TokenInfo { constructor( - public tokenIndex: number, + public tokenIndex: TokenIndex, public maintAssetWeight: I80F48, public initAssetWeight: I80F48, public maintLiabWeight: I80F48, @@ -828,7 +845,7 @@ export class TokenInfo { static fromDto(dto: TokenInfoDto): TokenInfo { return new TokenInfo( - dto.tokenIndex, + dto.tokenIndex as TokenIndex, I80F48.from(dto.maintAssetWeight), I80F48.from(dto.initAssetWeight), I80F48.from(dto.maintLiabWeight), @@ -839,11 +856,11 @@ export class TokenInfo { ); } - static emptyFromBank(bank: BankForHealth): TokenInfo { - if (!bank.price) - throw new Error( - `Failed to create TokenInfo. Bank price unavailable for bank with tokenIndex ${bank.tokenIndex}`, - ); + static fromBank( + bank: BankForHealth, + nativeBalance?: I80F48, + serum3MaxReserved?: I80F48, + ): TokenInfo { return new TokenInfo( bank.tokenIndex, bank.maintAssetWeight, @@ -851,8 +868,8 @@ export class TokenInfo { bank.maintLiabWeight, bank.initLiabWeight, bank.price, - ZERO_I80F48(), - ZERO_I80F48(), + nativeBalance ? nativeBalance.mul(bank.price) : ZERO_I80F48(), + serum3MaxReserved ? serum3MaxReserved : ZERO_I80F48(), ); } @@ -876,7 +893,7 @@ export class TokenInfo { ).mul(this.balance); } - toString() { + toString(): string { return ` tokenIndex: ${this.tokenIndex}, balance: ${ this.balance }, serum3MaxReserved: ${ @@ -890,15 +907,15 @@ export class Serum3Info { public reserved: I80F48, public baseIndex: number, public quoteIndex: number, - public marketIndex: number, + public marketIndex: MarketIndex, ) {} - static fromDto(dto: Serum3InfoDto) { + static fromDto(dto: Serum3InfoDto): Serum3Info { return new Serum3Info( I80F48.from(dto.reserved), dto.baseIndex, dto.quoteIndex, - dto.marketIndex, + dto.marketIndex as MarketIndex, ); } @@ -906,7 +923,7 @@ export class Serum3Info { serum3Market: Serum3Market, baseEntryIndex: number, quoteEntryIndex: number, - ) { + ): Serum3Info { return new Serum3Info( ZERO_I80F48(), baseEntryIndex, @@ -915,10 +932,47 @@ export class Serum3Info { ); } + static fromOoModifyingTokenInfos( + baseIndex: number, + baseInfo: TokenInfo, + quoteIndex: number, + quoteInfo: TokenInfo, + marketIndex: MarketIndex, + oo: OpenOrders, + ): Serum3Info { + // add the amounts that are freely settleable + const baseFree = I80F48.fromString(oo.baseTokenFree.toString()); + // NOTE: referrerRebatesAccrued is not declared on oo class, but the layout + // is aware of it + const quoteFree = I80F48.fromString( + oo.quoteTokenFree.add((oo as any).referrerRebatesAccrued).toString(), + ); + baseInfo.balance.iadd(baseFree.mul(baseInfo.oraclePrice)); + quoteInfo.balance.iadd(quoteFree.mul(quoteInfo.oraclePrice)); + + // add the reserved amount to both sides, to have the worst-case covered + const reservedBase = I80F48.fromString( + oo.baseTokenTotal.sub(oo.baseTokenFree).toString(), + ); + const reservedQuote = I80F48.fromString( + oo.quoteTokenTotal.sub(oo.quoteTokenFree).toString(), + ); + const reservedBalance = reservedBase + .mul(baseInfo.oraclePrice) + .add(reservedQuote.mul(quoteInfo.oraclePrice)); + baseInfo.serum3MaxReserved.iadd(reservedBalance); + quoteInfo.serum3MaxReserved.iadd(reservedBalance); + + return new Serum3Info(reservedBalance, baseIndex, quoteIndex, marketIndex); + } + healthContribution(healthType: HealthType, tokenInfos: TokenInfo[]): I80F48 { const baseInfo = tokenInfos[this.baseIndex]; const quoteInfo = tokenInfos[this.quoteIndex]; const reserved = this.reserved; + // console.log(` - reserved ${reserved}`); + // console.log(` - this.baseIndex ${this.baseIndex}`); + // console.log(` - this.quoteIndex ${this.quoteIndex}`); if (reserved.isZero()) { return ZERO_I80F48(); @@ -926,7 +980,7 @@ export class Serum3Info { // How much the health would increase if the reserved balance were applied to the passed // token info? - const computeHealthEffect = function (tokenInfo: TokenInfo) { + const computeHealthEffect = function (tokenInfo: TokenInfo): I80F48 { // This balance includes all possible reserved funds from markets that relate to the // token, including this market itself: `reserved` is already included in `max_balance`. const maxBalance = tokenInfo.balance.add(tokenInfo.serum3MaxReserved); @@ -946,15 +1000,25 @@ export class Serum3Info { } const assetWeight = tokenInfo.assetWeight(healthType); const liabWeight = tokenInfo.liabWeight(healthType); + + // console.log(` - tokenInfo.index ${tokenInfo.tokenIndex}`); + // console.log(` - tokenInfo.balance ${tokenInfo.balance}`); + // console.log( + // ` - tokenInfo.serum3MaxReserved ${tokenInfo.serum3MaxReserved}`, + // ); + // console.log(` - assetPart ${assetPart}`); + // console.log(` - liabPart ${liabPart}`); return assetWeight.mul(assetPart).add(liabWeight.mul(liabPart)); }; const reservedAsBase = computeHealthEffect(baseInfo); const reservedAsQuote = computeHealthEffect(quoteInfo); + // console.log(` - reservedAsBase ${reservedAsBase}`); + // console.log(` - reservedAsQuote ${reservedAsQuote}`); return reservedAsBase.min(reservedAsQuote); } - toString(tokenInfos: TokenInfo[]) { + toString(tokenInfos: TokenInfo[]): string { return ` marketIndex: ${this.marketIndex}, baseIndex: ${ this.baseIndex }, quoteIndex: ${this.quoteIndex}, reserved: ${ @@ -970,15 +1034,14 @@ export class PerpInfo { public initAssetWeight: I80F48, public maintLiabWeight: I80F48, public initLiabWeight: I80F48, - // in health-reference-token native units, needs scaling by asset/liab public base: I80F48, - // in health-reference-token native units, no asset/liab factor needed public quote: I80F48, public oraclePrice: I80F48, public hasOpenOrders: boolean, + public trustedMarket: boolean, ) {} - static fromDto(dto: PerpInfoDto) { + static fromDto(dto: PerpInfoDto): PerpInfo { return new PerpInfo( dto.perpMarketIndex, I80F48.from(dto.maintAssetWeight), @@ -989,6 +1052,114 @@ export class PerpInfo { I80F48.from(dto.quote), I80F48.from(dto.oraclePrice), dto.hasOpenOrders, + dto.trustedMarket, + ); + } + + static fromPerpPosition( + perpMarket: PerpMarket, + perpPosition: PerpPosition, + ): PerpInfo { + const baseLotSize = I80F48.fromString(perpMarket.baseLotSize.toString()); + const baseLots = I80F48.fromNumber( + perpPosition.basePositionLots + perpPosition.takerBaseLots, + ); + + const unsettledFunding = perpPosition.unsettledFunding(perpMarket); + + const takerQuote = I80F48.fromString( + new BN(perpPosition.takerQuoteLots) + .mul(perpMarket.quoteLotSize) + .toString(), + ); + const quoteCurrent = I80F48.fromString( + perpPosition.quotePositionNative.toString(), + ) + .sub(unsettledFunding) + .add(takerQuote); + + // Two scenarios: + // 1. The price goes low and all bids execute, converting to base. + // That means the perp position is increased by `bids` and the quote position + // is decreased by `bids * baseLotSize * price`. + // The health for this case is: + // (weighted(baseLots + bids) - bids) * baseLotSize * price + quote + // 2. The price goes high and all asks execute, converting to quote. + // The health for this case is: + // (weighted(baseLots - asks) + asks) * baseLotSize * price + quote + // + // Comparing these makes it clear we need to pick the worse subfactor + // weighted(baseLots + bids) - bids =: scenario1 + // or + // weighted(baseLots - asks) + asks =: scenario2 + // + // Additionally, we want this scenario choice to be the same no matter whether we're + // computing init or maint health. This can be guaranteed by requiring the weights + // to satisfy the property (P): + // + // (1 - initAssetWeight) / (initLiabWeight - 1) + // == (1 - maintAssetWeight) / (maintLiabWeight - 1) + // + // Derivation: + // Set asksNetLots := baseLots - asks, bidsNetLots := baseLots + bids. + // Now + // scenario1 = weighted(bidsNetLots) - bidsNetLots + baseLots and + // scenario2 = weighted(asksNetLots) - asksNetLots + baseLots + // So with expanding weigthed(a) = weightFactorForA * a, the question + // scenario1 < scenario2 + // becomes: + // (weightFactorForBidsNetLots - 1) * bidsNetLots + // < (weightFactorForAsksNetLots - 1) * asksNetLots + // Since asksNetLots < 0 and bidsNetLots > 0 is the only interesting case, (P) follows. + // + // We satisfy (P) by requiring + // assetWeight = 1 - x and liabWeight = 1 + x + // + // And with that assumption the scenario choice condition further simplifies to: + // scenario1 < scenario2 + // iff abs(bidsNetLots) > abs(asksNetLots) + + const bidsNetLots = baseLots.add( + I80F48.fromNumber(perpPosition.bidsBaseLots), + ); + const asksNetLots = baseLots.sub( + I80F48.fromNumber(perpPosition.asksBaseLots), + ); + + const lotsToQuote = baseLotSize.mul(perpMarket.price); + + let base, quote; + if (bidsNetLots.abs().gt(asksNetLots.abs())) { + const bidsBaseLots = I80F48.fromString( + perpPosition.bidsBaseLots.toString(), + ); + base = bidsNetLots.mul(lotsToQuote); + quote = quoteCurrent.sub(bidsBaseLots.mul(lotsToQuote)); + } else { + const asksBaseLots = I80F48.fromString( + perpPosition.asksBaseLots.toString(), + ); + base = asksNetLots.mul(lotsToQuote); + quote = quoteCurrent.add(asksBaseLots.mul(lotsToQuote)); + } + + // console.log(`bidsNetLots ${bidsNetLots}`); + // console.log(`asksNetLots ${asksNetLots}`); + // console.log(`quoteCurrent ${quoteCurrent}`); + // console.log(`base ${base}`); + // console.log(`quote ${quote}`); + + return new PerpInfo( + perpMarket.perpMarketIndex, + perpMarket.maintAssetWeight, + perpMarket.initAssetWeight, + perpMarket.maintLiabWeight, + perpMarket.initLiabWeight, + base, + quote, + perpMarket.price, + perpPosition.hasOpenOrders(), + perpMarket.trustedMarket, ); } @@ -1006,16 +1177,21 @@ export class PerpInfo { weight = this.maintAssetWeight; } - // FUTURE: Allow v3-style "reliable" markets where we can return - // `self.quote + weight * self.base` here - return this.quote.add(weight.mul(this.base)).min(ZERO_I80F48()); + // console.log(`initLiabWeight ${this.initLiabWeight}`); + // console.log(`initAssetWeight ${this.initAssetWeight}`); + // console.log(`weight ${weight}`); + // console.log(`this.quote ${this.quote}`); + // console.log(`this.base ${this.base}`); + + const uncappedHealthContribution = this.quote.add(weight.mul(this.base)); + if (this.trustedMarket) { + return uncappedHealthContribution; + } else { + return uncappedHealthContribution.min(ZERO_I80F48()); + } } static emptyFromPerpMarket(perpMarket: PerpMarket): PerpInfo { - if (!perpMarket.price) - throw new Error( - `Failed to create PerpInfo. Oracle price unavailable. ${perpMarket.oracle.toString()}`, - ); return new PerpInfo( perpMarket.perpMarketIndex, perpMarket.maintAssetWeight, @@ -1024,10 +1200,19 @@ export class PerpInfo { perpMarket.initLiabWeight, ZERO_I80F48(), ZERO_I80F48(), - I80F48.fromNumber(perpMarket.price), + perpMarket.price, false, + perpMarket.trustedMarket, ); } + + toString(): string { + return ` perpMarketIndex: ${this.perpMarketIndex}, base: ${ + this.base + }, quote: ${this.quote}, oraclePrice: ${ + this.oraclePrice + }, initHealth ${this.healthContribution(HealthType.init)}`; + } } export class HealthCacheDto { @@ -1087,10 +1272,9 @@ export class PerpInfoDto { initAssetWeight: I80F48Dto; maintLiabWeight: I80F48Dto; initLiabWeight: I80F48Dto; - // in health-reference-token native units, needs scaling by asset/liab base: I80F48Dto; - // in health-reference-token native units, no asset/liab factor needed quote: I80F48Dto; oraclePrice: I80F48Dto; hasOpenOrders: boolean; + trustedMarket: boolean; } diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index d97166bb5..1bc777101 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -1,20 +1,21 @@ import { BN } from '@project-serum/anchor'; import { utf8 } from '@project-serum/anchor/dist/cjs/utils/bytes'; -import { Order, Orderbook } from '@project-serum/serum/lib/market'; +import { OpenOrders, Order, Orderbook } from '@project-serum/serum/lib/market'; import { PublicKey } from '@solana/web3.js'; import { MangoClient } from '../client'; +import { SERUM3_PROGRAM_ID } from '../constants'; import { nativeI80F48ToUi, toNative, toUiDecimals, toUiDecimalsForQuote, } from '../utils'; -import { Bank } from './bank'; +import { Bank, TokenIndex } from './bank'; import { Group } from './group'; -import { HealthCache, HealthCacheDto } from './healthCache'; +import { HealthCache } from './healthCache'; import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from './I80F48'; -import { PerpOrder, PerpOrderSide } from './perp'; -import { Serum3Side } from './serum3'; +import { PerpMarket, PerpMarketIndex, PerpOrder, PerpOrderSide } from './perp'; +import { MarketIndex, Serum3Side } from './serum3'; export class MangoAccount { public tokens: TokenPosition[]; public serum3: Serum3Orders[]; @@ -58,7 +59,7 @@ export class MangoAccount { obj.serum3 as Serum3PositionDto[], obj.perps as PerpPositionDto[], obj.perpOpenOrders as PerpOoDto[], - {} as any, + new Map(), // serum3OosMapByMarketIndex ); } @@ -78,39 +79,56 @@ export class MangoAccount { serum3: Serum3PositionDto[], perps: PerpPositionDto[], perpOpenOrders: PerpOoDto[], - public accountData: undefined | MangoAccountData, + public serum3OosMapByMarketIndex: Map, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.tokens = tokens.map((dto) => TokenPosition.from(dto)); this.serum3 = serum3.map((dto) => Serum3Orders.from(dto)); this.perps = perps.map((dto) => PerpPosition.from(dto)); this.perpOpenOrders = perpOpenOrders.map((dto) => PerpOo.from(dto)); - this.accountData = undefined; this.netDeposits = netDeposits; } - async reload(client: MangoClient, group: Group): Promise { + async reload(client: MangoClient): Promise { const mangoAccount = await client.getMangoAccount(this); - await mangoAccount.reloadAccountData(client, group); + await mangoAccount.reloadAccountData(client); Object.assign(this, mangoAccount); return mangoAccount; } async reloadWithSlot( client: MangoClient, - group: Group, ): Promise<{ value: MangoAccount; slot: number }> { const resp = await client.getMangoAccountWithSlot(this.publicKey); - await resp?.value.reloadAccountData(client, group); + await resp?.value.reloadAccountData(client); Object.assign(this, resp?.value); return { value: resp!.value, slot: resp!.slot }; } - async reloadAccountData( - client: MangoClient, - group: Group, - ): Promise { - this.accountData = await client.computeAccountData(group, this); + async reloadAccountData(client: MangoClient): Promise { + const serum3Active = this.serum3Active(); + const ais = + await client.program.provider.connection.getMultipleAccountsInfo( + serum3Active.map((serum3) => serum3.openOrders), + ); + this.serum3OosMapByMarketIndex = new Map( + Array.from( + ais.map((ai, i) => { + if (!ai) { + throw new Error( + `Undefined AI for open orders ${serum3Active[i].openOrders} and market ${serum3Active[i].marketIndex}!`, + ); + } + const oo = OpenOrders.fromAccountInfo( + serum3Active[i].openOrders, + ai, + SERUM3_PROGRAM_ID[client.cluster], + ); + return [serum3Active[i].marketIndex, oo]; + }), + ), + ); + return this; } @@ -132,14 +150,26 @@ export class MangoAccount { ); } - findToken(tokenIndex: number): TokenPosition | undefined { + getToken(tokenIndex: TokenIndex): TokenPosition | undefined { return this.tokens.find((ta) => ta.tokenIndex == tokenIndex); } - findSerum3Account(marketIndex: number): Serum3Orders | undefined { + getSerum3Account(marketIndex: MarketIndex): Serum3Orders | undefined { return this.serum3.find((sa) => sa.marketIndex == marketIndex); } + getSerum3OoAccount(marketIndex: MarketIndex): OpenOrders { + const oo: OpenOrders | undefined = + this.serum3OosMapByMarketIndex.get(marketIndex); + + if (!oo) { + throw new Error( + `Open orders account not loaded for market with marketIndex ${marketIndex}!`, + ); + } + return oo; + } + // How to navigate // * if a function is returning a I80F48, then usually the return value is in native quote or native token, unless specified // * if a function is returning a number, then usually the return value is in ui tokens, unless specified @@ -152,7 +182,7 @@ export class MangoAccount { * @returns native balance for a token, is signed */ getTokenBalance(bank: Bank): I80F48 { - const tp = this.findToken(bank.tokenIndex); + const tp = this.getToken(bank.tokenIndex); return tp ? tp.balance(bank) : ZERO_I80F48(); } @@ -162,7 +192,7 @@ export class MangoAccount { * @returns native deposits for a token, 0 if position has borrows */ getTokenDeposits(bank: Bank): I80F48 { - const tp = this.findToken(bank.tokenIndex); + const tp = this.getToken(bank.tokenIndex); return tp ? tp.deposits(bank) : ZERO_I80F48(); } @@ -172,7 +202,7 @@ export class MangoAccount { * @returns native borrows for a token, 0 if position has deposits */ getTokenBorrows(bank: Bank): I80F48 { - const tp = this.findToken(bank.tokenIndex); + const tp = this.getToken(bank.tokenIndex); return tp ? tp.borrows(bank) : ZERO_I80F48(); } @@ -182,7 +212,7 @@ export class MangoAccount { * @returns UI balance for a token, is signed */ getTokenBalanceUi(bank: Bank): number { - const tp = this.findToken(bank.tokenIndex); + const tp = this.getToken(bank.tokenIndex); return tp ? tp.balanceUi(bank) : 0; } @@ -192,7 +222,7 @@ export class MangoAccount { * @returns UI deposits for a token, 0 or more */ getTokenDepositsUi(bank: Bank): number { - const ta = this.findToken(bank.tokenIndex); + const ta = this.getToken(bank.tokenIndex); return ta ? ta.depositsUi(bank) : 0; } @@ -202,7 +232,7 @@ export class MangoAccount { * @returns UI borrows for a token, 0 or less */ getTokenBorrowsUi(bank: Bank): number { - const ta = this.findToken(bank.tokenIndex); + const ta = this.getToken(bank.tokenIndex); return ta ? ta.borrowsUi(bank) : 0; } @@ -211,10 +241,9 @@ export class MangoAccount { * @param healthType * @returns raw health number, in native quote */ - getHealth(healthType: HealthType): I80F48 | undefined { - return healthType == HealthType.init - ? this.accountData?.initHealth - : this.accountData?.maintHealth; + getHealth(group: Group, healthType: HealthType): I80F48 { + const hc = HealthCache.fromMangoAccount(group, this); + return hc.health(healthType); } /** @@ -223,8 +252,9 @@ export class MangoAccount { * @param healthType * @returns health ratio, in percentage form */ - getHealthRatio(healthType: HealthType): I80F48 | undefined { - return this.accountData?.healthCache.healthRatio(healthType); + getHealthRatio(group: Group, healthType: HealthType): I80F48 { + const hc = HealthCache.fromMangoAccount(group, this); + return hc.healthRatio(healthType); } /** @@ -232,8 +262,8 @@ export class MangoAccount { * @param healthType * @returns health ratio, in percentage form, capped to 100 */ - getHealthRatioUi(healthType: HealthType): number | undefined { - const ratio = this.getHealthRatio(healthType)?.toNumber(); + getHealthRatioUi(group: Group, healthType: HealthType): number | undefined { + const ratio = this.getHealthRatio(group, healthType).toNumber(); if (ratio) { return ratio > 100 ? 100 : Math.trunc(ratio); } else { @@ -245,40 +275,73 @@ export class MangoAccount { * Sum of all the assets i.e. token deposits, borrows, total assets in spot open orders, (perps positions is todo) in terms of quote value. * @returns equity, in native quote */ - getEquity(): I80F48 | undefined { - if (this.accountData) { - const equity = this.accountData.equity; - const total_equity = equity.tokens.reduce( - (a, b) => a.add(b.value), - ZERO_I80F48(), - ); - return total_equity; + getEquity(group: Group): I80F48 { + const tokensMap = new Map(); + for (const tp of this.tokensActive()) { + const bank = group.getFirstBankByTokenIndex(tp.tokenIndex); + tokensMap.set(tp.tokenIndex, tp.balance(bank).mul(bank.price)); } - return undefined; + + for (const sp of this.serum3Active()) { + const oo = this.getSerum3OoAccount(sp.marketIndex); + const baseBank = group.getFirstBankByTokenIndex(sp.baseTokenIndex); + tokensMap + .get(baseBank.tokenIndex)! + .iadd( + I80F48.fromString(oo.baseTokenTotal.toString()).mul(baseBank.price), + ); + const quoteBank = group.getFirstBankByTokenIndex(sp.quoteTokenIndex); + // NOTE: referrerRebatesAccrued is not declared on oo class, but the layout + // is aware of it + tokensMap + .get(baseBank.tokenIndex)! + .iadd( + I80F48.fromString( + oo.quoteTokenTotal + .add((oo as any).referrerRebatesAccrued) + .toString(), + ).mul(quoteBank.price), + ); + } + + const tokenEquity = Array.from(tokensMap.values()).reduce( + (a, b) => a.add(b), + ZERO_I80F48(), + ); + + const perpEquity = this.perpActive().reduce( + (a, b) => + a.add(b.getEquity(group.getPerpMarketByMarketIndex(b.marketIndex))), + ZERO_I80F48(), + ); + + return tokenEquity.add(perpEquity); } /** * The amount of native quote you could withdraw against your existing assets. * @returns collateral value, in native quote */ - getCollateralValue(): I80F48 | undefined { - return this.getHealth(HealthType.init); + getCollateralValue(group: Group): I80F48 { + return this.getHealth(group, HealthType.init); } /** * Sum of all positive assets. * @returns assets, in native quote */ - getAssetsValue(healthType: HealthType): I80F48 | undefined { - return this.accountData?.healthCache.assets(healthType); + getAssetsValue(group: Group, healthType: HealthType): I80F48 { + const hc = HealthCache.fromMangoAccount(group, this); + return hc.assets(healthType); } /** * Sum of all negative assets. * @returns liabs, in native quote */ - getLiabsValue(healthType: HealthType): I80F48 | undefined { - return this.accountData?.healthCache.liabs(healthType); + getLiabsValue(group: Group, healthType: HealthType): I80F48 { + const hc = HealthCache.fromMangoAccount(group, this); + return hc.liabs(healthType); } /** @@ -286,8 +349,8 @@ export class MangoAccount { * PNL is defined here as spot value + serum3 open orders value + perp value - net deposits value (evaluated at native quote price at the time of the deposit/withdraw) * spot value + serum3 open orders value + perp value is returned by getEquity (open orders values are added to spot token values implicitly) */ - getPnl(): I80F48 | undefined { - return this.getEquity()?.add( + getPnl(group: Group): I80F48 { + return this.getEquity(group)?.add( I80F48.fromI64(this.netDeposits).mul(I80F48.fromNumber(-1)), ); } @@ -301,7 +364,7 @@ export class MangoAccount { mintPk: PublicKey, ): I80F48 | undefined { const tokenBank: Bank = group.getFirstBankByMint(mintPk); - const initHealth = this.accountData?.initHealth; + const initHealth = this.getHealth(group, HealthType.init); if (!initHealth) return undefined; @@ -314,8 +377,7 @@ export class MangoAccount { // Deposits need special treatment since they would neither count towards liabilities // nor would be charged loanOriginationFeeRate when withdrawn - const tp = this.findToken(tokenBank.tokenIndex); - if (!tokenBank.price) return undefined; + const tp = this.getToken(tokenBank.tokenIndex); const existingTokenDeposits = tp ? tp.deposits(tokenBank) : ZERO_I80F48(); let existingPositionHealthContrib = ZERO_I80F48(); if (existingTokenDeposits.gt(ZERO_I80F48())) { @@ -377,18 +439,14 @@ export class MangoAccount { targetMintPk: PublicKey, priceFactor: number, ): number | undefined { - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } if (sourceMintPk.equals(targetMintPk)) { return 0; } - const maxSource = this.accountData.healthCache.getMaxSourceForTokenSwap( + const hc = HealthCache.fromMangoAccount(group, this); + const maxSource = hc.getMaxSourceForTokenSwap( group.getFirstBankByMint(sourceMintPk), group.getFirstBankByMint(targetMintPk), - ONE_I80F48(), // target 1% health + I80F48.fromNumber(2), // target 2% health I80F48.fromNumber(priceFactor), ); maxSource.idiv( @@ -426,7 +484,8 @@ export class MangoAccount { mintPk: tokenChange.mintPk, }; }); - return this.accountData?.healthCache + const hc = HealthCache.fromMangoAccount(group, this); + return hc .simHealthRatioWithTokenPositionChanges( group, nativeTokenChanges, @@ -440,14 +499,8 @@ export class MangoAccount { group: Group, externalMarketPk: PublicKey, ): Promise { - const serum3Market = group.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), - ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } + const serum3Market = + group.getSerum3MarketByExternalMarket(externalMarketPk); const serum3OO = this.serum3Active().find( (s) => s.marketIndex === serum3Market.marketIndex, ); @@ -455,7 +508,7 @@ export class MangoAccount { throw new Error(`No open orders account found for ${externalMarketPk}`); } - const serum3MarketExternal = group.serum3MarketExternalsMap.get( + const serum3MarketExternal = group.serum3ExternalMarketsMap.get( externalMarketPk.toBase58(), )!; const [bidsInfo, asksInfo] = @@ -463,9 +516,14 @@ export class MangoAccount { serum3MarketExternal.bidsAddress, serum3MarketExternal.asksAddress, ]); - if (!bidsInfo || !asksInfo) { + if (!bidsInfo) { throw new Error( - `bids and asks ai were not fetched for ${externalMarketPk.toString()}`, + `Undefined bidsInfo for serum3Market with externalMarket ${externalMarketPk.toString()!}`, + ); + } + if (!asksInfo) { + throw new Error( + `Undefined asksInfo for serum3Market with externalMarket ${externalMarketPk.toString()!}`, ); } const bids = Orderbook.decode(serum3MarketExternal, bidsInfo.data); @@ -476,7 +534,6 @@ export class MangoAccount { } /** - * TODO priceFactor * @param group * @param externalMarketPk * @returns maximum ui quote which can be traded for base token given current health @@ -485,26 +542,28 @@ export class MangoAccount { group: Group, externalMarketPk: PublicKey, ): number { - const serum3Market = group.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), + const serum3Market = + group.getSerum3MarketByExternalMarket(externalMarketPk); + const baseBank = group.getFirstBankByTokenIndex( + serum3Market.baseTokenIndex, ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } - const nativeAmount = - this.accountData.healthCache.getMaxSerum3OrderForHealthRatio( - group, - serum3Market, - Serum3Side.bid, - I80F48.fromNumber(1), - ); + const quoteBank = group.getFirstBankByTokenIndex( + serum3Market.quoteTokenIndex, + ); + const hc = HealthCache.fromMangoAccount(group, this); + let nativeAmount = hc.getMaxSerum3OrderForHealthRatio( + baseBank, + quoteBank, + serum3Market, + Serum3Side.bid, + I80F48.fromNumber(2), + ); + // If its a bid then the reserved fund and potential loan is in base + // also keep some buffer for fees, use taker fees for worst case simulation. + nativeAmount = nativeAmount + .div(quoteBank.price) + .div(ONE_I80F48().add(baseBank.loanOriginationFeeRate)) + .div(ONE_I80F48().add(I80F48.fromNumber(group.getSerum3FeeRates(false)))); return toUiDecimals( nativeAmount, group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex).mintDecimals, @@ -512,7 +571,6 @@ export class MangoAccount { } /** - * TODO priceFactor * @param group * @param externalMarketPk * @returns maximum ui base which can be traded for quote token given current health @@ -521,26 +579,28 @@ export class MangoAccount { group: Group, externalMarketPk: PublicKey, ): number { - const serum3Market = group.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), + const serum3Market = + group.getSerum3MarketByExternalMarket(externalMarketPk); + const baseBank = group.getFirstBankByTokenIndex( + serum3Market.baseTokenIndex, ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } - const nativeAmount = - this.accountData.healthCache.getMaxSerum3OrderForHealthRatio( - group, - serum3Market, - Serum3Side.ask, - I80F48.fromNumber(1), - ); + const quoteBank = group.getFirstBankByTokenIndex( + serum3Market.quoteTokenIndex, + ); + const hc = HealthCache.fromMangoAccount(group, this); + let nativeAmount = hc.getMaxSerum3OrderForHealthRatio( + baseBank, + quoteBank, + serum3Market, + Serum3Side.ask, + I80F48.fromNumber(2), + ); + // If its a ask then the reserved fund and potential loan is in base + // also keep some buffer for fees, use taker fees for worst case simulation. + nativeAmount = nativeAmount + .div(baseBank.price) + .div(ONE_I80F48().add(baseBank.loanOriginationFeeRate)) + .div(ONE_I80F48().add(I80F48.fromNumber(group.getSerum3FeeRates(false)))); return toUiDecimals( nativeAmount, group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex).mintDecimals, @@ -561,22 +621,19 @@ export class MangoAccount { externalMarketPk: PublicKey, healthType: HealthType = HealthType.init, ): number { - const serum3Market = group.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), + const serum3Market = + group.getSerum3MarketByExternalMarket(externalMarketPk); + const baseBank = group.getFirstBankByTokenIndex( + serum3Market.baseTokenIndex, ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } - return this.accountData.healthCache + const quoteBank = group.getFirstBankByTokenIndex( + serum3Market.quoteTokenIndex, + ); + const hc = HealthCache.fromMangoAccount(group, this); + return hc .simHealthRatioWithSerum3BidChanges( - group, + baseBank, + quoteBank, toNative( uiQuoteAmount, group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex) @@ -602,22 +659,19 @@ export class MangoAccount { externalMarketPk: PublicKey, healthType: HealthType = HealthType.init, ): number { - const serum3Market = group.serum3MarketsMapByExternal.get( - externalMarketPk.toBase58(), + const serum3Market = + group.getSerum3MarketByExternalMarket(externalMarketPk); + const baseBank = group.getFirstBankByTokenIndex( + serum3Market.baseTokenIndex, ); - if (!serum3Market) { - throw new Error( - `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, - ); - } - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } - return this.accountData.healthCache + const quoteBank = group.getFirstBankByTokenIndex( + serum3Market.quoteTokenIndex, + ); + const hc = HealthCache.fromMangoAccount(group, this); + return hc .simHealthRatioWithSerum3AskChanges( - group, + baseBank, + quoteBank, toNative( uiBaseAmount, group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex) @@ -638,28 +692,21 @@ export class MangoAccount { */ public getMaxQuoteForPerpBidUi( group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, uiPrice: number, ): number { - const perpMarket = group.perpMarketsMap.get(perpMarketName); - if (!perpMarket) { - throw new Error(`PerpMarket for ${perpMarketName} not found!`); - } - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } - const baseLots = this.accountData.healthCache.getMaxPerpForHealthRatio( + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); + const hc = HealthCache.fromMangoAccount(group, this); + const baseLots = hc.getMaxPerpForHealthRatio( perpMarket, PerpOrderSide.bid, - I80F48.fromNumber(1), + I80F48.fromNumber(2), group.toNativePrice(uiPrice, perpMarket.baseDecimals), ); const nativeBase = baseLots.mul( I80F48.fromString(perpMarket.baseLotSize.toString()), ); - const nativeQuote = nativeBase.mul(I80F48.fromNumber(perpMarket.price)); + const nativeQuote = nativeBase.mul(perpMarket.price); return toUiDecimalsForQuote(nativeQuote.toNumber()); } @@ -672,22 +719,15 @@ export class MangoAccount { */ public getMaxBaseForPerpAskUi( group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, uiPrice: number, ): number { - const perpMarket = group.perpMarketsMap.get(perpMarketName); - if (!perpMarket) { - throw new Error(`PerpMarket for ${perpMarketName} not found!`); - } - if (!this.accountData) { - throw new Error( - `accountData not loaded on MangoAccount, try reloading MangoAccount`, - ); - } - const baseLots = this.accountData.healthCache.getMaxPerpForHealthRatio( + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); + const hc = HealthCache.fromMangoAccount(group, this); + const baseLots = hc.getMaxPerpForHealthRatio( perpMarket, PerpOrderSide.ask, - I80F48.fromNumber(1), + I80F48.fromNumber(2), group.toNativePrice(uiPrice, perpMarket.baseDecimals), ); return perpMarket.baseLotsToUi(new BN(baseLots.toString())); @@ -696,12 +736,9 @@ export class MangoAccount { public async loadPerpOpenOrdersForMarket( client: MangoClient, group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName); - if (!perpMarket) { - throw new Error(`Perp Market ${perpMarketName} not found!`); - } + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const [bids, asks] = await Promise.all([ perpMarket.loadBids(client), perpMarket.loadAsks(client), @@ -759,17 +796,17 @@ export class MangoAccount { export class TokenPosition { static TokenIndexUnset = 65535; - static from(dto: TokenPositionDto) { + static from(dto: TokenPositionDto): TokenPosition { return new TokenPosition( I80F48.from(dto.indexedPosition), - dto.tokenIndex, + dto.tokenIndex as TokenIndex, dto.inUseCount, ); } constructor( public indexedPosition: I80F48, - public tokenIndex: number, + public tokenIndex: TokenIndex, public inUseCount: number, ) {} @@ -877,17 +914,17 @@ export class Serum3Orders { static from(dto: Serum3PositionDto): Serum3Orders { return new Serum3Orders( dto.openOrders, - dto.marketIndex, - dto.baseTokenIndex, - dto.quoteTokenIndex, + dto.marketIndex as MarketIndex, + dto.baseTokenIndex as TokenIndex, + dto.quoteTokenIndex as TokenIndex, ); } constructor( public openOrders: PublicKey, - public marketIndex: number, - public baseTokenIndex: number, - public quoteTokenIndex: number, + public marketIndex: MarketIndex, + public baseTokenIndex: TokenIndex, + public quoteTokenIndex: TokenIndex, ) {} public isActive(): boolean { @@ -907,31 +944,77 @@ export class Serum3PositionDto { export class PerpPosition { static PerpMarketIndexUnset = 65535; - static from(dto: PerpPositionDto) { + static from(dto: PerpPositionDto): PerpPosition { return new PerpPosition( - dto.marketIndex, + dto.marketIndex as PerpMarketIndex, dto.basePositionLots.toNumber(), - dto.quotePositionNative.val, + I80F48.from(dto.quotePositionNative), dto.bidsBaseLots.toNumber(), dto.asksBaseLots.toNumber(), dto.takerBaseLots.toNumber(), dto.takerQuoteLots.toNumber(), + I80F48.from(dto.longSettledFunding), + I80F48.from(dto.shortSettledFunding), ); } constructor( - public marketIndex: number, + public marketIndex: PerpMarketIndex, public basePositionLots: number, - public quotePositionNative: BN, + public quotePositionNative: I80F48, public bidsBaseLots: number, public asksBaseLots: number, public takerBaseLots: number, public takerQuoteLots: number, + public longSettledFunding: I80F48, + public shortSettledFunding: I80F48, ) {} isActive(): boolean { return this.marketIndex != PerpPosition.PerpMarketIndexUnset; } + + public unsettledFunding(perpMarket: PerpMarket): I80F48 { + if (this.basePositionLots > 0) { + return perpMarket.longFunding + .sub(this.longSettledFunding) + .mul(I80F48.fromString(this.basePositionLots.toString())); + } else if (this.basePositionLots < 0) { + return perpMarket.shortFunding + .sub(this.shortSettledFunding) + .mul(I80F48.fromString(this.basePositionLots.toString())); + } + return ZERO_I80F48(); + } + + public getEquity(perpMarket: PerpMarket): I80F48 { + const lotsToQuote = I80F48.fromString( + perpMarket.baseLotSize.toString(), + ).mul(perpMarket.price); + + const baseLots = I80F48.fromNumber( + this.basePositionLots + this.takerBaseLots, + ); + + const unsettledFunding = this.unsettledFunding(perpMarket); + const takerQuote = I80F48.fromString( + new BN(this.takerQuoteLots).mul(perpMarket.quoteLotSize).toString(), + ); + const quoteCurrent = I80F48.fromString(this.quotePositionNative.toString()) + .sub(unsettledFunding) + .add(takerQuote); + + return baseLots.mul(lotsToQuote).add(quoteCurrent); + } + + public hasOpenOrders(): boolean { + return ( + this.asksBaseLots != 0 || + this.bidsBaseLots != 0 || + this.takerBaseLots != 0 || + this.takerQuoteLots != 0 + ); + } } export class PerpPositionDto { @@ -944,12 +1027,14 @@ export class PerpPositionDto { public asksBaseLots: BN, public takerBaseLots: BN, public takerQuoteLots: BN, + public longSettledFunding: I80F48Dto, + public shortSettledFunding: I80F48Dto, ) {} } export class PerpOo { static OrderMarketUnset = 65535; - static from(dto: PerpOoDto) { + static from(dto: PerpOoDto): PerpOo { return new PerpOo( dto.orderSide, dto.orderMarket, @@ -978,66 +1063,3 @@ export class HealthType { static maint = { maint: {} }; static init = { init: {} }; } - -export class MangoAccountData { - constructor( - public healthCache: HealthCache, - public initHealth: I80F48, - public maintHealth: I80F48, - public equity: Equity, - ) {} - - static from(event: { - healthCache: HealthCacheDto; - initHealth: I80F48Dto; - maintHealth: I80F48Dto; - equity: { - tokens: [{ tokenIndex: number; value: I80F48Dto }]; - perps: [{ perpMarketIndex: number; value: I80F48Dto }]; - }; - initHealthLiabs: I80F48Dto; - tokenAssets: any; - }) { - return new MangoAccountData( - HealthCache.fromDto(event.healthCache), - I80F48.from(event.initHealth), - I80F48.from(event.maintHealth), - Equity.from(event.equity), - ); - } -} - -export class Equity { - public constructor( - public tokens: TokenEquity[], - public perps: PerpEquity[], - ) {} - - static from(dto: EquityDto): Equity { - return new Equity( - dto.tokens.map( - (token) => new TokenEquity(token.tokenIndex, I80F48.from(token.value)), - ), - dto.perps.map( - (perpAccount) => - new PerpEquity( - perpAccount.perpMarketIndex, - I80F48.from(perpAccount.value), - ), - ), - ); - } -} - -export class TokenEquity { - public constructor(public tokenIndex: number, public value: I80F48) {} -} - -export class PerpEquity { - public constructor(public perpMarketIndex: number, public value: I80F48) {} -} - -export class EquityDto { - tokens: { tokenIndex: number; value: I80F48Dto }[]; - perps: { perpMarketIndex: number; value: I80F48Dto }[]; -} diff --git a/ts/client/src/accounts/oracle.ts b/ts/client/src/accounts/oracle.ts index b84642358..10420caea 100644 --- a/ts/client/src/accounts/oracle.ts +++ b/ts/client/src/accounts/oracle.ts @@ -102,7 +102,7 @@ export async function parseSwitchboardOracle( return parseSwitcboardOracleV1(accountInfo); } - throw new Error(`Unable to parse switchboard oracle ${accountInfo.owner}`); + throw new Error(`Should not be reached!`); } export function isSwitchboardOracle(accountInfo: AccountInfo): boolean { diff --git a/ts/client/src/accounts/perp.ts b/ts/client/src/accounts/perp.ts index ce00a0cfc..5bf8fa2d8 100644 --- a/ts/client/src/accounts/perp.ts +++ b/ts/client/src/accounts/perp.ts @@ -3,10 +3,12 @@ import { utf8 } from '@project-serum/anchor/dist/cjs/utils/bytes'; import { PublicKey } from '@solana/web3.js'; import Big from 'big.js'; import { MangoClient } from '../client'; -import { U64_MAX_BN } from '../utils'; +import { As, U64_MAX_BN } from '../utils'; import { OracleConfig, QUOTE_DECIMALS } from './bank'; import { I80F48, I80F48Dto } from './I80F48'; +export type PerpMarketIndex = number & As<'perp-market-index'>; + export class PerpMarket { public name: string; public maintAssetWeight: I80F48; @@ -18,21 +20,23 @@ export class PerpMarket { public takerFee: I80F48; public minFunding: I80F48; public maxFunding: I80F48; + public longFunding: I80F48; + public shortFunding: I80F48; public openInterest: number; public seqNum: number; public feesAccrued: I80F48; priceLotsToUiConverter: number; baseLotsToUiConverter: number; quoteLotsToUiConverter: number; - public price: number; - public uiPrice: number; + public _price: I80F48; + public _uiPrice: number; static from( publicKey: PublicKey, obj: { group: PublicKey; - quoteTokenIndex: number; perpMarketIndex: number; + trustedMarket: number; name: number[]; oracle: PublicKey; oracleConfig: OracleConfig; @@ -55,7 +59,7 @@ export class PerpMarket { shortFunding: I80F48Dto; fundingLastUpdated: BN; openInterest: BN; - seqNum: any; // TODO: ts complains that this is unknown for whatever reason + seqNum: BN; feesAccrued: I80F48Dto; bump: number; baseDecimals: number; @@ -65,8 +69,8 @@ export class PerpMarket { return new PerpMarket( publicKey, obj.group, - obj.quoteTokenIndex, - obj.perpMarketIndex, + obj.perpMarketIndex as PerpMarketIndex, + obj.trustedMarket == 1, obj.name, obj.oracle, obj.oracleConfig, @@ -100,8 +104,8 @@ export class PerpMarket { constructor( public publicKey: PublicKey, public group: PublicKey, - public quoteTokenIndex: number, - public perpMarketIndex: number, + public perpMarketIndex: PerpMarketIndex, // TODO rename to marketIndex? + public trustedMarket: boolean, name: number[], public oracle: PublicKey, oracleConfig: OracleConfig, @@ -140,6 +144,8 @@ export class PerpMarket { this.takerFee = I80F48.from(takerFee); this.minFunding = I80F48.from(minFunding); this.maxFunding = I80F48.from(maxFunding); + this.longFunding = I80F48.from(longFunding); + this.shortFunding = I80F48.from(shortFunding); this.openInterest = openInterest.toNumber(); this.seqNum = seqNum.toNumber(); this.feesAccrued = I80F48.from(feesAccrued); @@ -159,6 +165,23 @@ export class PerpMarket { .toNumber(); } + get price(): I80F48 { + if (!this._price) { + throw new Error( + `Undefined price for perpMarket ${this.publicKey} with marketIndex ${this.perpMarketIndex}!`, + ); + } + return this._price; + } + + get uiPrice(): number { + if (!this._uiPrice) { + throw new Error( + `Undefined price for perpMarket ${this.publicKey} with marketIndex ${this.perpMarketIndex}!`, + ); + } + return this._uiPrice; + } public async loadAsks(client: MangoClient): Promise { const asks = await client.program.account.bookSide.fetch(this.asks); return BookSide.from(client, this, BookSideType.asks, asks); @@ -176,7 +199,10 @@ export class PerpMarket { return new PerpEventQueue(client, eventQueue.header, eventQueue.buf); } - public async loadFills(client: MangoClient, lastSeqNum: BN) { + public async loadFills( + client: MangoClient, + lastSeqNum: BN, + ): Promise<(OutEvent | FillEvent | LiquidateEvent)[]> { const eventQueue = await this.loadEventQueue(client); return eventQueue .eventsSince(lastSeqNum) @@ -189,13 +215,13 @@ export class PerpMarket { * @param asks * @returns returns funding rate per hour */ - public getCurrentFundingRate(bids: BookSide, asks: BookSide) { + public getCurrentFundingRate(bids: BookSide, asks: BookSide): number { const MIN_FUNDING = this.minFunding.toNumber(); const MAX_FUNDING = this.maxFunding.toNumber(); const bid = bids.getImpactPriceUi(new BN(this.impactQuantity)); const ask = asks.getImpactPriceUi(new BN(this.impactQuantity)); - const indexPrice = this.uiPrice; + const indexPrice = this._uiPrice; let funding; if (bid !== undefined && ask !== undefined) { @@ -284,7 +310,7 @@ export class BookSide { leafCount: number; nodes: unknown; }, - ) { + ): BookSide { return new BookSide( client, perpMarket, @@ -311,7 +337,6 @@ export class BookSide { public includeExpired = false, maxBookDelay?: number, ) { - // TODO why? Ask Daffy // Determine the maxTimestamp found on the book to use for tif // If maxBookDelay is not provided, use 3600 as a very large number maxBookDelay = maxBookDelay === undefined ? 3600 : maxBookDelay; @@ -329,7 +354,7 @@ export class BookSide { this.now = maxTimestamp; } - static getPriceFromKey(key: BN) { + static getPriceFromKey(key: BN): BN { return key.ushrn(64); } @@ -456,7 +481,7 @@ export class LeafNode { ) {} } export class InnerNode { - static from(obj: { children: [number] }) { + static from(obj: { children: [number] }): InnerNode { return new InnerNode(obj.children); } @@ -477,7 +502,11 @@ export class PerpOrderType { } export class PerpOrder { - static from(perpMarket: PerpMarket, leafNode: LeafNode, type: BookSideType) { + static from( + perpMarket: PerpMarket, + leafNode: LeafNode, + type: BookSideType, + ): PerpOrder { const side = type == BookSideType.bids ? PerpOrderSide.bid : PerpOrderSide.ask; const price = BookSide.getPriceFromKey(leafNode.key); @@ -555,7 +584,7 @@ export class PerpEventQueue { ), ); } - throw new Error(`Unknown event with eventType ${event.eventType}`); + throw new Error(`Unknown event with eventType ${event.eventType}!`); }); } diff --git a/ts/client/src/accounts/serum3.ts b/ts/client/src/accounts/serum3.ts index bb07fd09d..56324e064 100644 --- a/ts/client/src/accounts/serum3.ts +++ b/ts/client/src/accounts/serum3.ts @@ -4,9 +4,13 @@ import { Cluster, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { MangoClient } from '../client'; import { SERUM3_PROGRAM_ID } from '../constants'; +import { As } from '../utils'; +import { TokenIndex } from './bank'; import { Group } from './group'; import { MAX_I80F48, ONE_I80F48, ZERO_I80F48 } from './I80F48'; +export type MarketIndex = number & As<'market-index'>; + export class Serum3Market { public name: string; static from( @@ -26,12 +30,12 @@ export class Serum3Market { return new Serum3Market( publicKey, obj.group, - obj.baseTokenIndex, - obj.quoteTokenIndex, + obj.baseTokenIndex as TokenIndex, + obj.quoteTokenIndex as TokenIndex, obj.name, obj.serumProgram, obj.serumMarketExternal, - obj.marketIndex, + obj.marketIndex as MarketIndex, obj.registrationTime, ); } @@ -39,12 +43,12 @@ export class Serum3Market { constructor( public publicKey: PublicKey, public group: PublicKey, - public baseTokenIndex: number, - public quoteTokenIndex: number, + public baseTokenIndex: TokenIndex, + public quoteTokenIndex: TokenIndex, name: number[], public serumProgram: PublicKey, public serumMarketExternal: PublicKey, - public marketIndex: number, + public marketIndex: MarketIndex, public registrationTime: BN, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; @@ -58,19 +62,7 @@ export class Serum3Market { */ maxBidLeverage(group: Group): number { const baseBank = group.getFirstBankByTokenIndex(this.baseTokenIndex); - if (!baseBank) { - throw new Error( - `bank for base token with index ${this.baseTokenIndex} not found`, - ); - } - const quoteBank = group.getFirstBankByTokenIndex(this.quoteTokenIndex); - if (!quoteBank) { - throw new Error( - `bank for quote token with index ${this.quoteTokenIndex} not found`, - ); - } - if ( quoteBank.initLiabWeight.sub(baseBank.initAssetWeight).lte(ZERO_I80F48()) ) { @@ -90,18 +82,7 @@ export class Serum3Market { */ maxAskLeverage(group: Group): number { const baseBank = group.getFirstBankByTokenIndex(this.baseTokenIndex); - if (!baseBank) { - throw new Error( - `bank for base token with index ${this.baseTokenIndex} not found`, - ); - } - const quoteBank = group.getFirstBankByTokenIndex(this.quoteTokenIndex); - if (!quoteBank) { - throw new Error( - `bank for quote token with index ${this.quoteTokenIndex} not found`, - ); - } if ( baseBank.initLiabWeight.sub(quoteBank.initAssetWeight).lte(ZERO_I80F48()) @@ -115,28 +96,18 @@ export class Serum3Market { } public async loadBids(client: MangoClient, group: Group): Promise { - const serum3MarketExternal = group.serum3MarketExternalsMap.get( - this.serumMarketExternal.toBase58(), + const serum3MarketExternal = group.getSerum3ExternalMarket( + this.serumMarketExternal, ); - if (!serum3MarketExternal) { - throw new Error( - `Unable to find serum3MarketExternal for ${this.serumMarketExternal.toBase58()}`, - ); - } return await serum3MarketExternal.loadBids( client.program.provider.connection, ); } public async loadAsks(client: MangoClient, group: Group): Promise { - const serum3MarketExternal = group.serum3MarketExternalsMap.get( - this.serumMarketExternal.toBase58(), + const serum3MarketExternal = group.getSerum3ExternalMarket( + this.serumMarketExternal, ); - if (!serum3MarketExternal) { - throw new Error( - `Unable to find serum3MarketExternal for ${this.serumMarketExternal.toBase58()}`, - ); - } return await serum3MarketExternal.loadAsks( client.program.provider.connection, ); diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 0b4fb1fc2..d177e9b19 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -24,14 +24,13 @@ import { TransactionSignature, } from '@solana/web3.js'; import bs58 from 'bs58'; -import { Bank, MintInfo } from './accounts/bank'; +import { Bank, MintInfo, TokenIndex } from './accounts/bank'; import { Group } from './accounts/group'; import { I80F48 } from './accounts/I80F48'; import { MangoAccount, - MangoAccountData, - TokenPosition, PerpPosition, + TokenPosition, } from './accounts/mangoAccount'; import { StubOracle } from './accounts/oracle'; import { @@ -39,6 +38,7 @@ import { OutEvent, PerpEventQueue, PerpMarket, + PerpMarketIndex, PerpOrderSide, PerpOrderType, } from './accounts/perp'; @@ -59,7 +59,6 @@ import { I64_MAX_BN, toNativeDecimals, } from './utils'; -import { simulate } from './utils/anchor'; import { sendTransaction } from './utils/rpc'; enum AccountRetriever { @@ -70,7 +69,6 @@ enum AccountRetriever { export type IdsSource = 'api' | 'static' | 'get-program-accounts'; // TODO: replace ui values with native as input wherever possible -// TODO: replace token/market names with token or market indices export class MangoClient { private postSendTxCallback?: ({ txid }) => void; private prioritizationFee: number; @@ -405,7 +403,7 @@ export class MangoClient { public async getMintInfoForTokenIndex( group: Group, - tokenIndex: number, + tokenIndex: TokenIndex, ): Promise { const tokenIndexBuf = Buffer.alloc(2); tokenIndexBuf.writeUInt16LE(tokenIndex); @@ -649,21 +647,27 @@ export class MangoClient { ); } - public async getMangoAccount(mangoAccount: MangoAccount) { + public async getMangoAccount( + mangoAccount: MangoAccount, + ): Promise { return MangoAccount.from( mangoAccount.publicKey, await this.program.account.mangoAccount.fetch(mangoAccount.publicKey), ); } - public async getMangoAccountForPublicKey(mangoAccountPk: PublicKey) { + public async getMangoAccountForPublicKey( + mangoAccountPk: PublicKey, + ): Promise { return MangoAccount.from( mangoAccountPk, await this.program.account.mangoAccount.fetch(mangoAccountPk), ); } - public async getMangoAccountWithSlot(mangoAccountPk: PublicKey) { + public async getMangoAccountWithSlot( + mangoAccountPk: PublicKey, + ): Promise<{ slot: number; value: MangoAccount } | undefined> { const resp = await this.program.provider.connection.getAccountInfoAndContext( mangoAccountPk, @@ -753,52 +757,6 @@ export class MangoClient { ); } - public async computeAccountData( - group: Group, - mangoAccount: MangoAccount, - ): Promise { - const healthRemainingAccounts: PublicKey[] = - this.buildHealthRemainingAccounts( - AccountRetriever.Fixed, - group, - [mangoAccount], - [], - [], - ); - - // Use our custom simulate fn in utils/anchor.ts so signing the tx is not required - this.program.provider.simulate = simulate; - - const res = await this.program.methods - .computeAccountData() - .accounts({ - group: group.publicKey, - account: mangoAccount.publicKey, - }) - .remainingAccounts( - healthRemainingAccounts.map( - (pk) => - ({ - pubkey: pk, - isWritable: false, - isSigner: false, - } as AccountMeta), - ), - ) - .simulate(); - - if (res.events) { - const accountDataEvent = res?.events.find( - (event) => (event.name = 'MangoAccountData'), - ); - return accountDataEvent - ? MangoAccountData.from(accountDataEvent.data as any) - : undefined; - } else { - return undefined; - } - } - public async tokenDeposit( group: Group, mangoAccount: MangoAccount, @@ -820,7 +778,7 @@ export class MangoClient { mangoAccount: MangoAccount, mintPk: PublicKey, nativeAmount: number, - ) { + ): Promise { const bank = group.getFirstBankByMint(mintPk); const tokenAccountPk = await getAssociatedTokenAddress( @@ -1148,19 +1106,19 @@ export class MangoClient { orderType: Serum3OrderType, clientOrderId: number, limit: number, - ) { + ): Promise { const serum3Market = group.serum3MarketsMapByExternal.get( externalMarketPk.toBase58(), )!; - if (!mangoAccount.findSerum3Account(serum3Market.marketIndex)) { + if (!mangoAccount.getSerum3Account(serum3Market.marketIndex)) { await this.serum3CreateOpenOrders( group, mangoAccount, serum3Market.serumMarketExternal, ); - await mangoAccount.reload(this, group); + await mangoAccount.reload(this); } - const serum3MarketExternal = group.serum3MarketExternalsMap.get( + const serum3MarketExternal = group.serum3ExternalMarketsMap.get( externalMarketPk.toBase58(), )!; const serum3MarketExternalVaultSigner = @@ -1182,13 +1140,17 @@ export class MangoClient { const limitPrice = serum3MarketExternal.priceNumberToLots(price); const maxBaseQuantity = serum3MarketExternal.baseSizeNumberToLots(size); const maxQuoteQuantity = serum3MarketExternal.decoded.quoteLotSize - .mul(new BN(1 + group.getFeeRate(orderType === Serum3OrderType.postOnly))) + .mul( + new BN( + 1 + group.getSerum3FeeRates(orderType === Serum3OrderType.postOnly), + ), + ) .mul( serum3MarketExternal .baseSizeNumberToLots(size) .mul(serum3MarketExternal.priceNumberToLots(price)), ); - const payerTokenIndex = (() => { + const payerTokenIndex = ((): TokenIndex => { if (side == Serum3Side.bid) { return serum3Market.quoteTokenIndex; } else { @@ -1211,7 +1173,7 @@ export class MangoClient { group: group.publicKey, account: mangoAccount.publicKey, owner: (this.program.provider as AnchorProvider).wallet.publicKey, - openOrders: mangoAccount.findSerum3Account(serum3Market.marketIndex) + openOrders: mangoAccount.getSerum3Account(serum3Market.marketIndex) ?.openOrders, serumMarket: serum3Market.publicKey, serumProgram: SERUM3_PROGRAM_ID[this.cluster], @@ -1249,12 +1211,12 @@ export class MangoClient { mangoAccount: MangoAccount, externalMarketPk: PublicKey, limit: number, - ) { + ): Promise { const serum3Market = group.serum3MarketsMapByExternal.get( externalMarketPk.toBase58(), )!; - const serum3MarketExternal = group.serum3MarketExternalsMap.get( + const serum3MarketExternal = group.serum3ExternalMarketsMap.get( externalMarketPk.toBase58(), )!; @@ -1264,7 +1226,7 @@ export class MangoClient { group: group.publicKey, account: mangoAccount.publicKey, owner: (this.program.provider as AnchorProvider).wallet.publicKey, - openOrders: mangoAccount.findSerum3Account(serum3Market.marketIndex) + openOrders: mangoAccount.getSerum3Account(serum3Market.marketIndex) ?.openOrders, serumMarket: serum3Market.publicKey, serumProgram: SERUM3_PROGRAM_ID[this.cluster], @@ -1293,7 +1255,7 @@ export class MangoClient { const serum3Market = group.serum3MarketsMapByExternal.get( externalMarketPk.toBase58(), )!; - const serum3MarketExternal = group.serum3MarketExternalsMap.get( + const serum3MarketExternal = group.serum3ExternalMarketsMap.get( externalMarketPk.toBase58(), )!; const serum3MarketExternalVaultSigner = @@ -1309,7 +1271,7 @@ export class MangoClient { group: group.publicKey, account: mangoAccount.publicKey, owner: (this.program.provider as AnchorProvider).wallet.publicKey, - openOrders: mangoAccount.findSerum3Account(serum3Market.marketIndex) + openOrders: mangoAccount.getSerum3Account(serum3Market.marketIndex) ?.openOrders, serumMarket: serum3Market.publicKey, serumProgram: SERUM3_PROGRAM_ID[this.cluster], @@ -1349,7 +1311,7 @@ export class MangoClient { externalMarketPk.toBase58(), )!; - const serum3MarketExternal = group.serum3MarketExternalsMap.get( + const serum3MarketExternal = group.serum3ExternalMarketsMap.get( externalMarketPk.toBase58(), )!; @@ -1358,7 +1320,7 @@ export class MangoClient { .accounts({ group: group.publicKey, account: mangoAccount.publicKey, - openOrders: mangoAccount.findSerum3Account(serum3Market.marketIndex) + openOrders: mangoAccount.getSerum3Account(serum3Market.marketIndex) ?.openOrders, serumMarket: serum3Market.publicKey, serumProgram: SERUM3_PROGRAM_ID[this.cluster], @@ -1495,7 +1457,7 @@ export class MangoClient { async perpEditMarket( group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, oracle: PublicKey, oracleConfFilter: number, baseDecimals: number, @@ -1516,7 +1478,7 @@ export class MangoClient { settleFeeAmountThreshold: number, settleFeeFractionLowHealth: number, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); return await this.program.methods .perpEditMarket( @@ -1554,9 +1516,9 @@ export class MangoClient { async perpCloseMarket( group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); return await this.program.methods .perpCloseMarket() @@ -1594,9 +1556,9 @@ export class MangoClient { async perpDeactivatePosition( group: Group, mangoAccount: MangoAccount, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const healthRemainingAccounts: PublicKey[] = this.buildHealthRemainingAccounts( AccountRetriever.Fixed, @@ -1625,7 +1587,7 @@ export class MangoClient { async perpPlaceOrder( group: Group, mangoAccount: MangoAccount, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, side: PerpOrderSide, price: number, quantity: number, @@ -1635,14 +1597,14 @@ export class MangoClient { expiryTimestamp: number, limit: number, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const healthRemainingAccounts: PublicKey[] = this.buildHealthRemainingAccounts( AccountRetriever.Fixed, group, [mangoAccount], // Settlement token bank, because a position for it may be created - [group.getFirstBankByTokenIndex(0)], + [group.getFirstBankByTokenIndex(0 as TokenIndex)], [perpMarket], ); const ix = await this.program.methods @@ -1689,10 +1651,10 @@ export class MangoClient { async perpCancelAllOrders( group: Group, mangoAccount: MangoAccount, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, limit: number, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const ix = await this.program.methods .perpCancelAllOrders(limit) .accounts({ @@ -1717,11 +1679,11 @@ export class MangoClient { async perpConsumeEvents( group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, accounts: PublicKey[], limit: number, ): Promise { - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); return await this.program.methods .perpConsumeEvents(new BN(limit)) .accounts({ @@ -1740,10 +1702,10 @@ export class MangoClient { async perpConsumeAllEvents( group: Group, - perpMarketName: string, + perpMarketIndex: PerpMarketIndex, ): Promise { const limit = 8; - const perpMarket = group.perpMarketsMap.get(perpMarketName)!; + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const eventQueue = await perpMarket.loadEventQueue(this); const unconsumedEvents = eventQueue.getUnconsumedEvents(); while (unconsumedEvents.length > 0) { @@ -1762,12 +1724,12 @@ export class MangoClient { case PerpEventQueue.LIQUIDATE_EVENT_TYPE: return []; default: - throw new Error(`Unknown event with eventType ${ev.eventType}`); + throw new Error(`Unknown event with eventType ${ev.eventType}!`); } }) .flat(); - await this.perpConsumeEvents(group, perpMarketName, accounts, limit); + await this.perpConsumeEvents(group, perpMarketIndex, accounts, limit); } } @@ -1793,8 +1755,6 @@ export class MangoClient { const inputBank: Bank = group.getFirstBankByMint(inputMintPk); const outputBank: Bank = group.getFirstBankByMint(outputMintPk); - if (!inputBank || !outputBank) throw new Error('Invalid token'); - const healthRemainingAccounts: PublicKey[] = this.buildHealthRemainingAccounts( AccountRetriever.Fixed, @@ -1944,12 +1904,15 @@ export class MangoClient { ); } - async updateIndexAndRate(group: Group, mintPk: PublicKey) { + async updateIndexAndRate( + group: Group, + mintPk: PublicKey, + ): Promise { // TODO: handle updating multiple banks const bank = group.getFirstBankByMint(mintPk); const mintInfo = group.mintInfosMapByMint.get(mintPk.toString())!; - await this.program.methods + return await this.program.methods .tokenUpdateIndexAndRate() .accounts({ group: group.publicKey, @@ -1976,7 +1939,7 @@ export class MangoClient { assetMintPk: PublicKey, liabMintPk: PublicKey, maxLiabTransfer: number, - ) { + ): Promise { const assetBank: Bank = group.getFirstBankByMint(assetMintPk); const liabBank: Bank = group.getFirstBankByMint(liabMintPk); @@ -2024,7 +1987,11 @@ export class MangoClient { ); } - async altSet(group: Group, addressLookupTable: PublicKey, index: number) { + async altSet( + group: Group, + addressLookupTable: PublicKey, + index: number, + ): Promise { const ix = await this.program.methods .altSet(index) .accounts({ @@ -2049,7 +2016,7 @@ export class MangoClient { addressLookupTable: PublicKey, index: number, pks: PublicKey[], - ) { + ): Promise { return await this.program.methods .altExtend(index, pks) .accounts({ @@ -2070,9 +2037,6 @@ export class MangoClient { opts: any = {}, getIdsFromApi: IdsSource = 'api', ): MangoClient { - // TODO: use IDL on chain or in repository? decide... - // Alternatively we could fetch IDL from chain. - // const idl = await Program.fetchIdl(MANGO_V4_ID, provider); const idl = IDL; return new MangoClient( @@ -2108,8 +2072,7 @@ export class MangoClient { /// private - // todo make private - public buildHealthRemainingAccounts( + private buildHealthRemainingAccounts( retriever: AccountRetriever, group: Group, mangoAccounts: MangoAccount[], @@ -2133,8 +2096,7 @@ export class MangoClient { } } - // todo make private - public buildFixedAccountRetrieverHealthAccounts( + private buildFixedAccountRetrieverHealthAccounts( group: Group, mangoAccount: MangoAccount, // Banks and perpMarkets for whom positions don't exist on mango account, @@ -2207,8 +2169,7 @@ export class MangoClient { return healthRemainingAccounts; } - // todo make private - public buildScanningAccountRetrieverHealthAccounts( + private buildScanningAccountRetrieverHealthAccounts( group: Group, mangoAccounts: MangoAccount[], banks: Bank[], @@ -2216,7 +2177,7 @@ export class MangoClient { ): PublicKey[] { const healthRemainingAccounts: PublicKey[] = []; - let tokenIndices: number[] = []; + let tokenIndices: TokenIndex[] = []; for (const mangoAccount of mangoAccounts) { tokenIndices.push( ...mangoAccount.tokens @@ -2241,7 +2202,7 @@ export class MangoClient { ...mintInfos.map((mintInfo) => mintInfo.oracle), ); - const perpIndices: number[] = []; + const perpIndices: PerpMarketIndex[] = []; for (const mangoAccount of mangoAccounts) { perpIndices.push( ...mangoAccount.perps diff --git a/ts/client/src/debug-scripts/mb-debug-user.ts b/ts/client/src/debug-scripts/mb-debug-user.ts index 36d5395e4..7f315ef66 100644 --- a/ts/client/src/debug-scripts/mb-debug-user.ts +++ b/ts/client/src/debug-scripts/mb-debug-user.ts @@ -2,6 +2,7 @@ import { AnchorProvider, Wallet } from '@project-serum/anchor'; import { Cluster, Connection, Keypair } from '@solana/web3.js'; import fs from 'fs'; import { Group } from '../accounts/group'; +import { HealthCache } from '../accounts/healthCache'; import { HealthType, MangoAccount } from '../accounts/mangoAccount'; import { PerpMarket } from '../accounts/perp'; import { Serum3Market } from '../accounts/serum3'; @@ -26,7 +27,7 @@ async function debugUser( ) { console.log(mangoAccount.toString(group)); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log( 'buildFixedAccountRetrieverHealthAccounts ' + @@ -45,42 +46,52 @@ async function debugUser( ); console.log( 'mangoAccount.getEquity() ' + - toUiDecimalsForQuote(mangoAccount.getEquity()!.toNumber()), + toUiDecimalsForQuote(mangoAccount.getEquity(group)!.toNumber()), ); console.log( 'mangoAccount.getHealth(HealthType.init) ' + - toUiDecimalsForQuote(mangoAccount.getHealth(HealthType.init)!.toNumber()), + toUiDecimalsForQuote( + mangoAccount.getHealth(group, HealthType.init)!.toNumber(), + ), + ); + console.log( + 'HealthCache.fromMangoAccount(group,mangoAccount).health(HealthType.init) ' + + toUiDecimalsForQuote( + HealthCache.fromMangoAccount(group, mangoAccount) + .health(HealthType.init) + .toNumber(), + ), ); console.log( 'mangoAccount.getHealthRatio(HealthType.init) ' + - mangoAccount.getHealthRatio(HealthType.init)!.toNumber(), + mangoAccount.getHealthRatio(group, HealthType.init)!.toNumber(), ); console.log( 'mangoAccount.getHealthRatioUi(HealthType.init) ' + - mangoAccount.getHealthRatioUi(HealthType.init), + mangoAccount.getHealthRatioUi(group, HealthType.init), ); console.log( 'mangoAccount.getHealthRatio(HealthType.maint) ' + - mangoAccount.getHealthRatio(HealthType.maint)!.toNumber(), + mangoAccount.getHealthRatio(group, HealthType.maint)!.toNumber(), ); console.log( 'mangoAccount.getHealthRatioUi(HealthType.maint) ' + - mangoAccount.getHealthRatioUi(HealthType.maint), + mangoAccount.getHealthRatioUi(group, HealthType.maint), ); console.log( 'mangoAccount.getCollateralValue() ' + - toUiDecimalsForQuote(mangoAccount.getCollateralValue()!.toNumber()), + toUiDecimalsForQuote(mangoAccount.getCollateralValue(group)!.toNumber()), ); console.log( 'mangoAccount.getAssetsValue() ' + toUiDecimalsForQuote( - mangoAccount.getAssetsValue(HealthType.init)!.toNumber(), + mangoAccount.getAssetsValue(group, HealthType.init)!.toNumber(), ), ); console.log( 'mangoAccount.getLiabsValue() ' + toUiDecimalsForQuote( - mangoAccount.getLiabsValue(HealthType.init)!.toNumber(), + mangoAccount.getLiabsValue(group, HealthType.init)!.toNumber(), ), ); @@ -223,9 +234,9 @@ async function main() { for (const mangoAccount of mangoAccounts) { console.log(`MangoAccount ${mangoAccount.publicKey}`); - // if (mangoAccount.name === 'PnL Test') { - await debugUser(client, group, mangoAccount); - // } + if (mangoAccount.name === 'PnL Test') { + await debugUser(client, group, mangoAccount); + } } } diff --git a/ts/client/src/ids.ts b/ts/client/src/ids.ts index fec0dd482..004ad2f7a 100644 --- a/ts/client/src/ids.ts +++ b/ts/client/src/ids.ts @@ -51,7 +51,7 @@ export class Id { static fromIdsByName(name: string): Id { const groupConfig = ids.groups.find((id) => id['name'] === name); - if (!groupConfig) throw new Error(`Unable to find group config ${name}`); + if (!groupConfig) throw new Error(`No group config ${name} found in Ids!`); return new Id( groupConfig.cluster as Cluster, groupConfig.name, @@ -71,7 +71,7 @@ export class Id { (id) => id['publicKey'] === groupPk.toString(), ); if (!groupConfig) - throw new Error(`Unable to find group config ${groupPk.toString()}`); + throw new Error(`No group config ${groupPk.toString()} found in Ids!`); return new Id( groupConfig.cluster as Cluster, groupConfig.name, diff --git a/ts/client/src/scripts/devnet-admin.ts b/ts/client/src/scripts/devnet-admin.ts index 5bf5dc558..d706407f9 100644 --- a/ts/client/src/scripts/devnet-admin.ts +++ b/ts/client/src/scripts/devnet-admin.ts @@ -567,7 +567,6 @@ async function main() { // ); // console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); - // TODO decide on what keys should go in console.log(`ALT: extending manually with bank publick keys and oracles`); const extendIx = AddressLookupTableProgram.extendLookupTable({ lookupTable: createIx[1], diff --git a/ts/client/src/scripts/devnet-user.ts b/ts/client/src/scripts/devnet-user.ts index 4c4e5b25e..fa58ee709 100644 --- a/ts/client/src/scripts/devnet-user.ts +++ b/ts/client/src/scripts/devnet-user.ts @@ -78,10 +78,10 @@ async function main() { console.log(`...created/found mangoAccount ${mangoAccount.publicKey}`); console.log(mangoAccount.toString(group)); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); // set delegate, and change name - if (false) { + if (true) { console.log(`...changing mango account name, and setting a delegate`); const randomKey = new PublicKey( '4ZkS7ZZkxfsC3GtvvsHP3DFcUeByU9zzZELS4r8HCELo', @@ -93,7 +93,7 @@ async function main() { 'my_changed_name', randomKey, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log(mangoAccount.toString()); console.log(`...resetting mango account name, and re-setting a delegate`); @@ -103,7 +103,7 @@ async function main() { 'my_mango_account', PublicKey.default, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log(mangoAccount.toString()); } @@ -113,7 +113,7 @@ async function main() { `...expanding mango account to have serum3 and perp position slots`, ); await client.expandMangoAccount(group, mangoAccount, 8, 8, 8, 8); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); } // deposit and withdraw @@ -126,7 +126,7 @@ async function main() { new PublicKey(DEVNET_MINTS.get('USDC')!), 50, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); await client.tokenDeposit( group, @@ -134,7 +134,7 @@ async function main() { new PublicKey(DEVNET_MINTS.get('SOL')!), 1, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); await client.tokenDeposit( group, @@ -142,7 +142,7 @@ async function main() { new PublicKey(DEVNET_MINTS.get('MNGO')!), 1, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log(`...withdrawing 1 USDC`); await client.tokenWithdraw( @@ -152,7 +152,7 @@ async function main() { 1, true, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log(`...depositing 0.0005 BTC`); await client.tokenDeposit( @@ -161,7 +161,7 @@ async function main() { new PublicKey(DEVNET_MINTS.get('BTC')!), 0.0005, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log(mangoAccount.toString(group)); } catch (error) { @@ -171,12 +171,6 @@ async function main() { if (true) { // serum3 - const serum3Market = group.serum3MarketsMapByExternal.get( - DEVNET_SERUM3_MARKETS.get('BTC/USDC')?.toBase58()!, - ); - const serum3MarketExternal = group.serum3MarketExternalsMap.get( - DEVNET_SERUM3_MARKETS.get('BTC/USDC')?.toBase58()!, - ); const asks = await group.loadSerum3AsksForMarket( client, DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, @@ -205,7 +199,7 @@ async function main() { Date.now(), 10, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); price = lowestAsk.price + lowestAsk.price / 2; qty = 0.0001; @@ -224,7 +218,7 @@ async function main() { Date.now(), 10, ); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); price = highestBid.price - highestBid.price / 2; qty = 0.0001; @@ -291,33 +285,27 @@ async function main() { } if (true) { - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log( '...mangoAccount.getEquity() ' + - toUiDecimalsForQuote(mangoAccount.getEquity()!.toNumber()), + toUiDecimalsForQuote(mangoAccount.getEquity(group)!.toNumber()), ); console.log( '...mangoAccount.getCollateralValue() ' + - toUiDecimalsForQuote(mangoAccount.getCollateralValue()!.toNumber()), - ); - console.log( - '...mangoAccount.accountData["healthCache"].health(HealthType.init) ' + toUiDecimalsForQuote( - mangoAccount - .accountData!['healthCache'].health(HealthType.init) - .toNumber(), + mangoAccount.getCollateralValue(group)!.toNumber(), ), ); console.log( '...mangoAccount.getAssetsVal() ' + toUiDecimalsForQuote( - mangoAccount.getAssetsValue(HealthType.init)!.toNumber(), + mangoAccount.getAssetsValue(group, HealthType.init)!.toNumber(), ), ); console.log( '...mangoAccount.getLiabsVal() ' + toUiDecimalsForQuote( - mangoAccount.getLiabsValue(HealthType.init)!.toNumber(), + mangoAccount.getLiabsValue(group, HealthType.init)!.toNumber(), ), ); console.log( @@ -400,10 +388,11 @@ async function main() { // perps if (true) { let sig; + const perpMarket = group.getPerpMarketByName('BTC-PERP'); const orders = await mangoAccount.loadPerpOpenOrdersForMarket( client, group, - 'BTC-PERP', + perpMarket.perpMarketIndex, ); for (const order of orders) { console.log( @@ -411,7 +400,12 @@ async function main() { ); } console.log(`...cancelling all perp orders`); - sig = await client.perpCancelAllOrders(group, mangoAccount, 'BTC-PERP', 10); + sig = await client.perpCancelAllOrders( + group, + mangoAccount, + perpMarket.perpMarketIndex, + 10, + ); console.log(`sig https://explorer.solana.com/tx/${sig}?cluster=devnet`); // scenario 1 @@ -423,7 +417,7 @@ async function main() { Math.floor(Math.random() * 100); const quoteQty = mangoAccount.getMaxQuoteForPerpBidUi( group, - 'BTC-PERP', + perpMarket.perpMarketIndex, 1, ); const baseQty = quoteQty / price; @@ -433,7 +427,7 @@ async function main() { const sig = await client.perpPlaceOrder( group, mangoAccount, - 'BTC-PERP', + perpMarket.perpMarketIndex, PerpOrderSide.bid, price, baseQty, @@ -448,7 +442,12 @@ async function main() { console.log(error); } console.log(`...cancelling all perp orders`); - sig = await client.perpCancelAllOrders(group, mangoAccount, 'BTC-PERP', 10); + sig = await client.perpCancelAllOrders( + group, + mangoAccount, + perpMarket.perpMarketIndex, + 10, + ); console.log(`sig https://explorer.solana.com/tx/${sig}?cluster=devnet`); // bid max perp + some @@ -458,7 +457,11 @@ async function main() { group.banksMapByName.get('BTC')![0].uiPrice! - Math.floor(Math.random() * 100); const quoteQty = - mangoAccount.getMaxQuoteForPerpBidUi(group, 'BTC-PERP', 1) * 1.02; + mangoAccount.getMaxQuoteForPerpBidUi( + group, + perpMarket.perpMarketIndex, + 1, + ) * 1.02; const baseQty = quoteQty / price; console.log( `...placing max qty * 1.02 perp bid clientId ${clientId} at price ${price}, base ${baseQty}, quote ${quoteQty}`, @@ -466,7 +469,7 @@ async function main() { const sig = await client.perpPlaceOrder( group, mangoAccount, - 'BTC-PERP', + perpMarket.perpMarketIndex, PerpOrderSide.bid, price, baseQty, @@ -487,7 +490,11 @@ async function main() { const price = group.banksMapByName.get('BTC')![0].uiPrice! + Math.floor(Math.random() * 100); - const baseQty = mangoAccount.getMaxBaseForPerpAskUi(group, 'BTC-PERP', 1); + const baseQty = mangoAccount.getMaxBaseForPerpAskUi( + group, + perpMarket.perpMarketIndex, + 1, + ); const quoteQty = baseQty * price; console.log( `...placing max qty perp ask clientId ${clientId} at price ${price}, base ${baseQty}, quote ${quoteQty}`, @@ -495,7 +502,7 @@ async function main() { const sig = await client.perpPlaceOrder( group, mangoAccount, - 'BTC-PERP', + perpMarket.perpMarketIndex, PerpOrderSide.ask, price, baseQty, @@ -517,7 +524,11 @@ async function main() { group.banksMapByName.get('BTC')![0].uiPrice! + Math.floor(Math.random() * 100); const baseQty = - mangoAccount.getMaxBaseForPerpAskUi(group, 'BTC-PERP', 1) * 1.02; + mangoAccount.getMaxBaseForPerpAskUi( + group, + perpMarket.perpMarketIndex, + 1, + ) * 1.02; const quoteQty = baseQty * price; console.log( `...placing max qty perp ask * 1.02 clientId ${clientId} at price ${price}, base ${baseQty}, quote ${quoteQty}`, @@ -525,7 +536,7 @@ async function main() { const sig = await client.perpPlaceOrder( group, mangoAccount, - 'BTC-PERP', + perpMarket.perpMarketIndex, PerpOrderSide.ask, price, baseQty, @@ -541,7 +552,12 @@ async function main() { } console.log(`...cancelling all perp orders`); - sig = await client.perpCancelAllOrders(group, mangoAccount, 'BTC-PERP', 10); + sig = await client.perpCancelAllOrders( + group, + mangoAccount, + perpMarket.perpMarketIndex, + 10, + ); console.log(`sig https://explorer.solana.com/tx/${sig}?cluster=devnet`); // // scenario 2 @@ -553,8 +569,8 @@ async function main() { // const sig = await client.perpPlaceOrder( // group, // mangoAccount, - // 'BTC-PERP', - // PerpOrderSide.bid, + // perpMarket.perpMarketIndex, + // PerpOrderSide.bid, // price, // 0.01, // price * 0.01, @@ -574,8 +590,8 @@ async function main() { // const sig = await client.perpPlaceOrder( // group, // mangoAccount, - // 'BTC-PERP', - // PerpOrderSide.ask, + // perpMarket.perpMarketIndex, + // PerpOrderSide.ask, // price, // 0.01, // price * 0.011, @@ -590,11 +606,9 @@ async function main() { // } // // // should be able to cancel them : know bug // // console.log(`...cancelling all perp orders`); - // // sig = await client.perpCancelAllOrders(group, mangoAccount, 'BTC-PERP', 10); + // // sig = await client.perpCancelAllOrders(group, mangoAccount, perpMarket.perpMarketIndex, 10); // // console.log(`sig https://explorer.solana.com/tx/${sig}?cluster=devnet`); - const perpMarket = group.perpMarketsMap.get('BTC-PERP')!; - const bids: BookSide = await perpMarket?.loadBids(client)!; console.log(`bids - ${Array.from(bids.items())}`); const asks: BookSide = await perpMarket?.loadAsks(client)!; @@ -615,7 +629,7 @@ async function main() { // make+take orders should have cancelled each other, and if keeper has already cranked, then should not appear in position await group.reloadAll(client); - await mangoAccount.reload(client, group); + await mangoAccount.reload(client); console.log(`${mangoAccount.toString(group)}`); } diff --git a/ts/client/src/utils.ts b/ts/client/src/utils.ts index d6f616b74..aa7e18cdf 100644 --- a/ts/client/src/utils.ts +++ b/ts/client/src/utils.ts @@ -23,7 +23,13 @@ import { PerpMarket } from './accounts/perp'; export const U64_MAX_BN = new BN('18446744073709551615'); export const I64_MAX_BN = new BN('9223372036854775807').toTwos(64); -export function debugAccountMetas(ams: AccountMeta[]) { +// https://stackoverflow.com/questions/70261755/user-defined-type-guard-function-and-type-narrowing-to-more-specific-type/70262876#70262876 +export declare abstract class As { + private static readonly $as$: unique symbol; + private [As.$as$]: Record; +} + +export function debugAccountMetas(ams: AccountMeta[]): void { for (const am of ams) { console.log( `${am.pubkey.toBase58()}, isSigner: ${am.isSigner @@ -39,7 +45,7 @@ export function debugHealthAccounts( group: Group, mangoAccount: MangoAccount, publicKeys: PublicKey[], -) { +): void { const banks = new Map( Array.from(group.banksMapByName.values()).map((banks: Bank[]) => [ banks[0].publicKey.toBase58(), @@ -66,10 +72,12 @@ export function debugHealthAccounts( }), ); const perps = new Map( - Array.from(group.perpMarketsMap.values()).map((perpMarket: PerpMarket) => [ - perpMarket.publicKey.toBase58(), - `${perpMarket.name} perp market`, - ]), + Array.from(group.perpMarketsMapByName.values()).map( + (perpMarket: PerpMarket) => [ + perpMarket.publicKey.toBase58(), + `${perpMarket.name} perp market`, + ], + ), ); publicKeys.map((pk) => { @@ -126,7 +134,7 @@ export async function getAssociatedTokenAddress( associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID, ): Promise { if (!allowOwnerOffCurve && !PublicKey.isOnCurve(owner.toBuffer())) - throw new Error('TokenOwnerOffCurve'); + throw new Error('TokenOwnerOffCurve!'); const [address] = await PublicKey.findProgramAddress( [owner.toBuffer(), programId.toBuffer(), mint.toBuffer()], diff --git a/ts/client/src/utils/anchor.ts b/ts/client/src/utils/anchor.ts deleted file mode 100644 index 230404299..000000000 --- a/ts/client/src/utils/anchor.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - simulateTransaction, - SuccessfulTxSimulationResponse, -} from '@project-serum/anchor/dist/cjs/utils/rpc'; -import { - Signer, - PublicKey, - Transaction, - Commitment, - SimulatedTransactionResponse, -} from '@solana/web3.js'; - -class SimulateError extends Error { - constructor( - readonly simulationResponse: SimulatedTransactionResponse, - message?: string, - ) { - super(message); - } -} - -export async function simulate( - tx: Transaction, - signers?: Signer[], - commitment?: Commitment, - includeAccounts?: boolean | PublicKey[], -): Promise { - tx.feePayer = this.wallet.publicKey; - tx.recentBlockhash = ( - await this.connection.getLatestBlockhash( - commitment ?? this.connection.commitment, - ) - ).blockhash; - - const result = await simulateTransaction(this.connection, tx); - - if (result.value.err) { - throw new SimulateError(result.value); - } - - return result.value; -} diff --git a/ts/client/src/utils/rpc.ts b/ts/client/src/utils/rpc.ts index e2b2ad074..e8a7e9b3c 100644 --- a/ts/client/src/utils/rpc.ts +++ b/ts/client/src/utils/rpc.ts @@ -10,7 +10,7 @@ export async function sendTransaction( ixs: TransactionInstruction[], alts: AddressLookupTableAccount[], opts: any = {}, -) { +): Promise { const connection = provider.connection; const latestBlockhash = await connection.getLatestBlockhash( opts.preflightCommitment,