ts: rework getMaxWithdrawWithBorrowForToken (#900)

* ts: rework getMaxWithdrawWithBorrowForToken

* binary seach for maxWithdraw

* tests

* warnings

* revert later, change for debugging

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* fix looking deeper than 0.5 tokens

* no borrows on no-borrow-tokens

---------

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>
Co-authored-by: microwavedcola1 <microwavedcola@gmail.com>
This commit is contained in:
Christian Kamm 2024-03-10 14:11:30 +01:00 committed by GitHub
parent ef5da37fba
commit 077199ed39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 568 additions and 269 deletions

View File

@ -1,16 +1,9 @@
import { AnchorProvider, Wallet } from '@coral-xyz/anchor';
import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js';
import copy from 'fast-copy';
import { cpuUsage } from 'process';
import { Group } from '../../src/accounts/group';
import { HealthCache } from '../../src/accounts/healthCache';
import { HealthType, MangoAccount } from '../../src/accounts/mangoAccount';
import { PerpMarket } from '../../src/accounts/perp';
import { Serum3Market } from '../../src/accounts/serum3';
import { MangoAccount } from '../../src/accounts/mangoAccount';
import { MangoClient } from '../../src/client';
import { MANGO_V4_ID } from '../../src/constants';
import { ZERO_I80F48 } from '../../src/numbers/I80F48';
import { toUiDecimalsForQuote } from '../../src/utils';
const CLUSTER_URL =
process.env.CLUSTER_URL_OVERRIDE || process.env.MB_CLUSTER_URL;
@ -27,56 +20,56 @@ async function debugUser(
group: Group,
mangoAccount: MangoAccount,
): Promise<void> {
console.log(mangoAccount.toString(group));
// console.log(mangoAccount.toString(group));
await mangoAccount.reload(client);
console.log(
'mangoAccount.getEquity() ' +
toUiDecimalsForQuote(mangoAccount.getEquity(group)!.toNumber()),
);
console.log(
'mangoAccount.getHealth(HealthType.init) ' +
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(group, HealthType.init)!.toNumber(),
);
console.log(
'mangoAccount.getHealthRatioUi(HealthType.init) ' +
mangoAccount.getHealthRatioUi(group, HealthType.init),
);
console.log(
'mangoAccount.getHealthRatio(HealthType.maint) ' +
mangoAccount.getHealthRatio(group, HealthType.maint)!.toNumber(),
);
console.log(
'mangoAccount.getHealthRatioUi(HealthType.maint) ' +
mangoAccount.getHealthRatioUi(group, HealthType.maint),
);
console.log(
'mangoAccount.getCollateralValue() ' +
toUiDecimalsForQuote(mangoAccount.getCollateralValue(group)!.toNumber()),
);
console.log(
'mangoAccount.getAssetsValue() ' +
toUiDecimalsForQuote(mangoAccount.getAssetsValue(group)!.toNumber()),
);
console.log(
'mangoAccount.getLiabsValue() ' +
toUiDecimalsForQuote(mangoAccount.getLiabsValue(group)!.toNumber()),
);
// console.log(
// 'mangoAccount.getEquity() ' +
// toUiDecimalsForQuote(mangoAccount.getEquity(group)!.toNumber()),
// );
// console.log(
// 'mangoAccount.getHealth(HealthType.init) ' +
// 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(group, HealthType.init)!.toNumber(),
// );
// console.log(
// 'mangoAccount.getHealthRatioUi(HealthType.init) ' +
// mangoAccount.getHealthRatioUi(group, HealthType.init),
// );
// console.log(
// 'mangoAccount.getHealthRatio(HealthType.maint) ' +
// mangoAccount.getHealthRatio(group, HealthType.maint)!.toNumber(),
// );
// console.log(
// 'mangoAccount.getHealthRatioUi(HealthType.maint) ' +
// mangoAccount.getHealthRatioUi(group, HealthType.maint),
// );
// console.log(
// 'mangoAccount.getCollateralValue() ' +
// toUiDecimalsForQuote(mangoAccount.getCollateralValue(group)!.toNumber()),
// );
// console.log(
// 'mangoAccount.getAssetsValue() ' +
// toUiDecimalsForQuote(mangoAccount.getAssetsValue(group)!.toNumber()),
// );
// console.log(
// 'mangoAccount.getLiabsValue() ' +
// toUiDecimalsForQuote(mangoAccount.getLiabsValue(group)!.toNumber()),
// );
async function getMaxWithdrawWithBorrowForTokenUiWrapper(
token,
@ -93,178 +86,178 @@ async function debugUser(
await getMaxWithdrawWithBorrowForTokenUiWrapper(srcToken);
}
function getMaxSourceForTokenSwapWrapper(src, tgt): void {
// Turn on for debugging specific pairs
// if (src != 'USDC' || tgt != 'MNGO') return;
// function getMaxSourceForTokenSwapWrapper(src, tgt): void {
// // Turn on for debugging specific pairs
// // if (src != 'USDC' || tgt != 'MNGO') return;
let maxSourceUi;
try {
maxSourceUi = mangoAccount.getMaxSourceUiForTokenSwap(
group,
group.banksMapByName.get(src)![0].mint,
group.banksMapByName.get(tgt)![0].mint,
);
} catch (error) {
console.log(`Error for ${src}->${tgt}, ` + error.toString());
}
// let maxSourceUi;
// try {
// maxSourceUi = mangoAccount.getMaxSourceUiForTokenSwap(
// group,
// group.banksMapByName.get(src)![0].mint,
// group.banksMapByName.get(tgt)![0].mint,
// );
// } catch (error) {
// console.log(`Error for ${src}->${tgt}, ` + error.toString());
// }
const maxTargetUi =
maxSourceUi *
(group.banksMapByName.get(src)![0].uiPrice /
group.banksMapByName.get(tgt)![0].uiPrice);
// const maxTargetUi =
// maxSourceUi *
// (group.banksMapByName.get(src)![0].uiPrice /
// group.banksMapByName.get(tgt)![0].uiPrice);
const sim = mangoAccount.simHealthRatioWithTokenPositionUiChanges(group, [
{
mintPk: group.banksMapByName.get(src)![0].mint,
uiTokenAmount: -maxSourceUi,
},
{
mintPk: group.banksMapByName.get(tgt)![0].mint,
uiTokenAmount: maxTargetUi,
},
]);
console.log(
`getMaxSourceForTokenSwap ${src.padEnd(4)} ${tgt.padEnd(4)} ` +
maxSourceUi.toFixed(3).padStart(10) +
`, health ratio after (${sim.toFixed(3).padStart(10)})`,
);
}
for (const srcToken of Array.from(group.banksMapByName.keys()).sort()) {
for (const tgtToken of Array.from(group.banksMapByName.keys()).sort()) {
getMaxSourceForTokenSwapWrapper(srcToken, tgtToken);
}
}
// const sim = mangoAccount.simHealthRatioWithTokenPositionUiChanges(group, [
// {
// mintPk: group.banksMapByName.get(src)![0].mint,
// uiTokenAmount: -maxSourceUi,
// },
// {
// mintPk: group.banksMapByName.get(tgt)![0].mint,
// uiTokenAmount: maxTargetUi,
// },
// ]);
// console.log(
// `getMaxSourceForTokenSwap ${src.padEnd(4)} ${tgt.padEnd(4)} ` +
// maxSourceUi.toFixed(3).padStart(10) +
// `, health ratio after (${sim.toFixed(3).padStart(10)})`,
// );
// }
// for (const srcToken of Array.from(group.banksMapByName.keys()).sort()) {
// for (const tgtToken of Array.from(group.banksMapByName.keys()).sort()) {
// getMaxSourceForTokenSwapWrapper(srcToken, tgtToken);
// }
// }
function getMaxForPerpWrapper(perpMarket: PerpMarket): void {
const maxQuoteUi = mangoAccount.getMaxQuoteForPerpBidUi(
group,
perpMarket.perpMarketIndex,
);
const simMaxQuote = mangoAccount.simHealthRatioWithPerpBidUiChanges(
group,
perpMarket.perpMarketIndex,
maxQuoteUi / perpMarket.uiPrice,
);
const maxBaseUi = mangoAccount.getMaxBaseForPerpAskUi(
group,
perpMarket.perpMarketIndex,
);
const simMaxBase = mangoAccount.simHealthRatioWithPerpAskUiChanges(
group,
perpMarket.perpMarketIndex,
maxBaseUi,
);
console.log(
`getMaxPerp ${perpMarket.name.padStart(
10,
)} getMaxQuoteForPerpBidUi ${maxQuoteUi
.toFixed(3)
.padStart(10)} health ratio after (${simMaxQuote
.toFixed(3)
.padStart(10)}), getMaxBaseForPerpAskUi ${maxBaseUi
.toFixed(3)
.padStart(10)} health ratio after (${simMaxBase
.toFixed(3)
.padStart(10)})`,
);
}
for (const perpMarket of Array.from(
group.perpMarketsMapByMarketIndex.values(),
)) {
getMaxForPerpWrapper(perpMarket);
}
// function getMaxForPerpWrapper(perpMarket: PerpMarket): void {
// const maxQuoteUi = mangoAccount.getMaxQuoteForPerpBidUi(
// group,
// perpMarket.perpMarketIndex,
// );
// const simMaxQuote = mangoAccount.simHealthRatioWithPerpBidUiChanges(
// group,
// perpMarket.perpMarketIndex,
// maxQuoteUi / perpMarket.uiPrice,
// );
// const maxBaseUi = mangoAccount.getMaxBaseForPerpAskUi(
// group,
// perpMarket.perpMarketIndex,
// );
// const simMaxBase = mangoAccount.simHealthRatioWithPerpAskUiChanges(
// group,
// perpMarket.perpMarketIndex,
// maxBaseUi,
// );
// console.log(
// `getMaxPerp ${perpMarket.name.padStart(
// 10,
// )} getMaxQuoteForPerpBidUi ${maxQuoteUi
// .toFixed(3)
// .padStart(10)} health ratio after (${simMaxQuote
// .toFixed(3)
// .padStart(10)}), getMaxBaseForPerpAskUi ${maxBaseUi
// .toFixed(3)
// .padStart(10)} health ratio after (${simMaxBase
// .toFixed(3)
// .padStart(10)})`,
// );
// }
// for (const perpMarket of Array.from(
// group.perpMarketsMapByMarketIndex.values(),
// )) {
// getMaxForPerpWrapper(perpMarket);
// }
function getMaxForSerum3Wrapper(serum3Market: Serum3Market): void {
console.log(
`getMaxQuoteForSerum3BidUi ${serum3Market.name} ` +
mangoAccount.getMaxQuoteForSerum3BidUi(
group,
serum3Market.serumMarketExternal,
),
);
console.log(
`- simHealthRatioWithSerum3BidUiChanges ${serum3Market.name} ` +
mangoAccount.simHealthRatioWithSerum3BidUiChanges(
group,
mangoAccount.getMaxQuoteForSerum3BidUi(
group,
serum3Market.serumMarketExternal,
),
serum3Market.serumMarketExternal,
HealthType.init,
),
);
console.log(
`getMaxBaseForSerum3AskUi ${serum3Market.name} ` +
mangoAccount.getMaxBaseForSerum3AskUi(
group,
serum3Market.serumMarketExternal,
),
);
console.log(
`- simHealthRatioWithSerum3BidUiChanges ${serum3Market.name} ` +
mangoAccount.simHealthRatioWithSerum3AskUiChanges(
group,
mangoAccount.getMaxBaseForSerum3AskUi(
group,
serum3Market.serumMarketExternal,
),
serum3Market.serumMarketExternal,
HealthType.init,
),
);
}
for (const serum3Market of Array.from(
group.serum3MarketsMapByExternal.values(),
)) {
getMaxForSerum3Wrapper(serum3Market);
}
// function getMaxForSerum3Wrapper(serum3Market: Serum3Market): void {
// console.log(
// `getMaxQuoteForSerum3BidUi ${serum3Market.name} ` +
// mangoAccount.getMaxQuoteForSerum3BidUi(
// group,
// serum3Market.serumMarketExternal,
// ),
// );
// console.log(
// `- simHealthRatioWithSerum3BidUiChanges ${serum3Market.name} ` +
// mangoAccount.simHealthRatioWithSerum3BidUiChanges(
// group,
// mangoAccount.getMaxQuoteForSerum3BidUi(
// group,
// serum3Market.serumMarketExternal,
// ),
// serum3Market.serumMarketExternal,
// HealthType.init,
// ),
// );
// console.log(
// `getMaxBaseForSerum3AskUi ${serum3Market.name} ` +
// mangoAccount.getMaxBaseForSerum3AskUi(
// group,
// serum3Market.serumMarketExternal,
// ),
// );
// console.log(
// `- simHealthRatioWithSerum3BidUiChanges ${serum3Market.name} ` +
// mangoAccount.simHealthRatioWithSerum3AskUiChanges(
// group,
// mangoAccount.getMaxBaseForSerum3AskUi(
// group,
// serum3Market.serumMarketExternal,
// ),
// serum3Market.serumMarketExternal,
// HealthType.init,
// ),
// );
// }
// for (const serum3Market of Array.from(
// group.serum3MarketsMapByExternal.values(),
// )) {
// getMaxForSerum3Wrapper(serum3Market);
// }
// Liquidation price for perp positions
for (const pp of mangoAccount.perpActive()) {
const pm = group.getPerpMarketByMarketIndex(pp.marketIndex);
const health = toUiDecimalsForQuote(
mangoAccount.getHealth(group, HealthType.maint),
);
// // Liquidation price for perp positions
// for (const pp of mangoAccount.perpActive()) {
// const pm = group.getPerpMarketByMarketIndex(pp.marketIndex);
// const health = toUiDecimalsForQuote(
// mangoAccount.getHealth(group, HealthType.maint),
// );
if (
// pp.getNotionalValueUi(pm) > 1000 &&
// !(pp.getNotionalValueUi(pm) < health && pp.getBasePosition(pm).isPos())
// eslint-disable-next-line no-constant-condition
true
) {
const then = Date.now();
const startUsage = cpuUsage();
// if (
// // pp.getNotionalValueUi(pm) > 1000 &&
// // !(pp.getNotionalValueUi(pm) < health && pp.getBasePosition(pm).isPos())
// // eslint-disable-next-line no-constant-condition
// true
// ) {
// const then = Date.now();
// const startUsage = cpuUsage();
const lp = await pp.getLiquidationPrice(group, mangoAccount);
if (lp == null || lp.lt(ZERO_I80F48())) {
continue;
}
const lpUi = group
.getPerpMarketByMarketIndex(pp.marketIndex)
.priceNativeToUi(lp.toNumber());
// const lp = await pp.getLiquidationPrice(group, mangoAccount);
// if (lp == null || lp.lt(ZERO_I80F48())) {
// continue;
// }
// const lpUi = group
// .getPerpMarketByMarketIndex(pp.marketIndex)
// .priceNativeToUi(lp.toNumber());
const gClone: Group = copy(group);
gClone.getPerpMarketByMarketIndex(pm.perpMarketIndex)._price = lp;
// const gClone: Group = copy(group);
// gClone.getPerpMarketByMarketIndex(pm.perpMarketIndex)._price = lp;
const simHealth = toUiDecimalsForQuote(
mangoAccount.getHealth(gClone, HealthType.maint),
);
// const simHealth = toUiDecimalsForQuote(
// mangoAccount.getHealth(gClone, HealthType.maint),
// );
const now = Date.now();
const endUsage = cpuUsage(startUsage);
// const now = Date.now();
// const endUsage = cpuUsage(startUsage);
console.log(
` - ${pm.name}, health: ${health.toLocaleString()}, side: ${
pp.getBasePosition(pm).isPos() ? 'LONG' : 'SHORT'
}, notional: ${pp
.getNotionalValueUi(pm)
.toLocaleString()}, liq price: ${lpUi.toLocaleString()}, sim health: ${simHealth.toLocaleString()}, time ${
now - then
}ms, cpu usage ${(endUsage['user'] / 1000).toLocaleString()}ms`,
);
}
}
// console.log(
// ` - ${pm.name}, health: ${health.toLocaleString()}, side: ${
// pp.getBasePosition(pm).isPos() ? 'LONG' : 'SHORT'
// }, notional: ${pp
// .getNotionalValueUi(pm)
// .toLocaleString()}, liq price: ${lpUi.toLocaleString()}, sim health: ${simHealth.toLocaleString()}, time ${
// now - then
// }ms, cpu usage ${(endUsage['user'] / 1000).toLocaleString()}ms`,
// );
// }
// }
}
async function main(): Promise<void> {

View File

@ -811,7 +811,7 @@ export class HealthCache {
}
}
private static binaryApproximationSearch(
static binaryApproximationSearch(
left: I80F48,
leftValue: I80F48,
right: I80F48,
@ -830,10 +830,8 @@ export class HealthCache {
// );
if (
(leftValue.sub(targetValue).isPos() &&
rightValue.sub(targetValue).isPos()) ||
(leftValue.sub(targetValue).isNeg() &&
rightValue.sub(targetValue).isNeg())
(leftValue.gt(targetValue) && rightValue.gt(targetValue)) ||
(leftValue.lt(targetValue) && rightValue.lt(targetValue))
) {
throw new Error(
`Internal error: left ${leftValue.toNumber()} and right ${rightValue.toNumber()} don't contain the target value ${targetValue.toNumber()}!`,

View File

@ -1,10 +1,18 @@
import { PublicKey } from '@solana/web3.js';
import { MangoAccount } from './mangoAccount';
import {
HealthType,
MangoAccount,
TokenPosition,
TokenPositionDto,
} from './mangoAccount';
import BN from 'bn.js';
import { Bank } from './bank';
import { toNative, toUiDecimals } from '../utils';
import { Bank, TokenIndex } from './bank';
import { deepClone, toNative, toUiDecimals } from '../utils';
import { expect } from 'chai';
import { I80F48 } from '../numbers/I80F48';
import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48';
import { Group } from './group';
import { HealthCache } from './healthCache';
import { assert } from 'console';
describe('Mango Account', () => {
const mangoAccount = new MangoAccount(
@ -78,3 +86,237 @@ describe('Mango Account', () => {
done();
});
});
describe('maxWithdraw', () => {
const protoAccount = new MangoAccount(
PublicKey.default,
PublicKey.default,
PublicKey.default,
[],
PublicKey.default,
0,
false,
false,
new BN(0),
new BN(0),
new BN(0),
new BN(0),
new BN(0),
new BN(0),
new BN(0),
0,
[],
[],
[],
[],
[],
new Map(),
);
protoAccount.tokens.push(
new TokenPosition(ZERO_I80F48(), 0 as TokenIndex, 0, ZERO_I80F48(), 0, 0),
);
protoAccount.tokens.push(
new TokenPosition(ZERO_I80F48(), 1 as TokenIndex, 0, ZERO_I80F48(), 0, 0),
);
const protoBank = {
vault: PublicKey.default,
mint: PublicKey.default,
tokenIndex: 0,
price: ONE_I80F48(),
getAssetPrice() {
return this.price;
},
getLiabPrice() {
return this.price;
},
stablePriceModel: { stablePrice: ONE_I80F48() },
initAssetWeight: I80F48.fromNumber(0.8),
initLiabWeight: I80F48.fromNumber(1.2),
maintWeights() {
return [I80F48.fromNumber(0.9), I80F48.fromNumber(1.1)];
},
scaledInitAssetWeight(price) {
return this.initAssetWeight;
},
scaledInitLiabWeight(price) {
return this.initLiabWeight;
},
loanOriginationFeeRate: I80F48.fromNumber(0.001),
minVaultToDepositsRatio: I80F48.fromNumber(0.1),
depositIndex: I80F48.fromNumber(1000000),
borrowIndex: I80F48.fromNumber(1000000),
indexedDeposits: I80F48.fromNumber(0),
indexedBorrows: I80F48.fromNumber(0),
nativeDeposits() {
return this.depositIndex.mul(this.indexedDeposits);
},
nativeBorrows() {
return this.borrowIndex.mul(this.indexedBorrows);
},
areBorrowsReduceOnly() {
return false;
},
} as any as Bank;
function makeGroup(bank0, bank1, vaultAmount) {
return {
getFirstBankByMint(mint) {
if (mint.equals(bank0.mint)) {
return bank0;
} else if (mint.equals(bank1.mint)) {
return bank1;
}
},
getFirstBankByTokenIndex(tokenIndex) {
return [bank0, bank1][tokenIndex];
},
getFirstBankForPerpSettlement() {
return bank0;
},
vaultAmountsMap: new Map<string, BN>([
[bank0.vault.toBase58(), new BN(vaultAmount)],
]),
} as any as Group;
}
function setup(vaultAmount): [Group, Bank, Bank, MangoAccount] {
const account = deepClone<MangoAccount>(protoAccount);
const bank0 = deepClone(protoBank);
const bank1 = deepClone(protoBank);
bank1.tokenIndex = 1 as TokenIndex;
bank1.mint = PublicKey.unique();
bank1.initAssetWeight = ONE_I80F48();
bank1.initLiabWeight = ONE_I80F48();
const group = makeGroup(bank0, bank1, vaultAmount);
return [group, bank0, bank1, account];
}
function deposit(bank, account, amount) {
const amountV = I80F48.fromNumber(amount);
const indexedAmount = amountV.div(bank.depositIndex);
if (indexedAmount.mul(bank.depositIndex).lt(amountV)) {
const delta = new I80F48(new BN(1));
indexedAmount.iadd(delta);
}
bank.indexedDeposits.iadd(indexedAmount);
const tp = account.tokens[bank.tokenIndex];
assert(!tp.indexedPosition.isNeg());
tp.indexedPosition.iadd(indexedAmount);
}
function borrow(bank, account, amount) {
const indexedAmount = I80F48.fromNumber(amount).div(bank.borrowIndex);
bank.indexedBorrows.iadd(indexedAmount);
const tp = account.tokens[bank.tokenIndex];
assert(!tp.indexedPosition.isPos());
tp.indexedPosition.isub(indexedAmount);
}
function maxWithdraw(group, account) {
return account
.getMaxWithdrawWithBorrowForToken(group, PublicKey.default)
.toNumber();
}
it('full withdraw', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
deposit(bank0, account, 100);
expect(maxWithdraw(group, account)).equal(100);
done();
});
it('full withdraw limited vault', (done) => {
const [group, bank0, bank1, account] = setup(90);
deposit(bank0, account, 100);
expect(maxWithdraw(group, account)).equal(90);
done();
});
it('full withdraw limited utilization', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
const other = deepClone(account);
deposit(bank0, account, 100);
borrow(bank0, other, 50);
expect(maxWithdraw(group, account)).equal(50);
done();
});
it('withdraw limited health', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
deposit(bank0, account, 100);
borrow(bank1, account, 50);
expect(maxWithdraw(group, account)).equal(Math.floor(100 - 50 / 0.8));
done();
});
it('pure borrow', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
const other = deepClone(account);
deposit(bank0, other, 1000); // so there's something to borrow
deposit(bank1, account, 100);
expect(maxWithdraw(group, account)).equal(Math.floor(100 / 1.2));
done();
});
it('pure borrow limited utilization', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
const other = deepClone(account);
deposit(bank0, other, 50);
deposit(bank1, account, 100);
expect(maxWithdraw(group, account)).equal(44); // due to origination fees!
bank0.loanOriginationFeeRate = ZERO_I80F48();
expect(maxWithdraw(group, account)).equal(45);
done();
});
it('withdraw and borrow', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
const other = deepClone(account);
deposit(bank0, account, 100);
deposit(bank1, account, 100);
deposit(bank0, other, 10000);
expect(maxWithdraw(group, account)).equal(100 + Math.floor(100 / 1.2));
done();
});
it('withdraw limited health and scaling', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
bank0.scaledInitAssetWeight = function (price) {
const startScale = I80F48.fromNumber(50);
if (this.nativeDeposits().gt(startScale)) {
return this.initAssetWeight.div(this.nativeDeposits().div(startScale));
}
return this.initAssetWeight;
};
const other = deepClone(account);
deposit(bank0, other, 100);
deposit(bank0, account, 200);
borrow(bank1, account, 20);
// initial account health = 200 * 0.8 * 50 / 300 - 20 = 6.66
// zero account health = 100 * 0.8 * 50 / 200 - 20 = 0
// so can withdraw 100
expect(maxWithdraw(group, account)).equal(100);
done();
});
it('borrow limited health and scaling', (done) => {
const [group, bank0, bank1, account] = setup(1000000);
bank0.scaledInitLiabWeight = function (price) {
const startScale = I80F48.fromNumber(50);
if (this.nativeBorrows().gt(startScale)) {
return this.initLiabWeight.mul(this.nativeBorrows().div(startScale));
}
return this.initLiabWeight;
};
const other = deepClone(account);
deposit(bank0, other, 100);
deposit(bank1, account, 100);
// -64*1.2*64/50+100 = 1.69
// -65*1.2*65/50+100 = -1.4
expect(maxWithdraw(group, account)).equal(64);
done();
});
});

View File

@ -4,7 +4,13 @@ import { OpenOrders, Order, Orderbook } from '@project-serum/serum/lib/market';
import { AccountInfo, PublicKey } from '@solana/web3.js';
import { MangoClient } from '../client';
import { OPENBOOK_PROGRAM_ID, RUST_I64_MAX, RUST_I64_MIN } from '../constants';
import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48';
import {
I80F48,
I80F48Dto,
MAX_I80F48,
ONE_I80F48,
ZERO_I80F48,
} from '../numbers/I80F48';
import {
U64_MAX_BN,
roundTo5,
@ -12,6 +18,7 @@ import {
toUiDecimals,
toUiDecimalsForQuote,
toUiSellPerBuyTokenPrice,
deepClone,
} from '../utils';
import { MangoSignatureStatus } from '../utils/rpc';
import { Bank, TokenIndex } from './bank';
@ -533,67 +540,126 @@ export class MangoAccount {
mintPk: PublicKey,
): I80F48 {
const tokenBank: Bank = group.getFirstBankByMint(mintPk);
const initHealth = this.getHealth(group, HealthType.init);
const loanOriginationFactor = ONE_I80F48().add(
tokenBank.loanOriginationFeeRate,
);
const maxBorrowUtilization = I80F48.fromNumber(
1 - tokenBank.minVaultToDepositsRatio,
);
const tp = this.getToken(tokenBank.tokenIndex);
let healthCache = HealthCache.fromMangoAccount(group, this);
const tokenInfoIndex = healthCache.getOrCreateTokenInfoIndex(tokenBank);
const initHealth = healthCache.health(HealthType.init);
// Case 1:
// Cannot withdraw if init health is below 0
if (initHealth.lte(ZERO_I80F48())) {
return ZERO_I80F48();
}
// Deposits need special treatment since they would neither count towards liabilities
// nor would be charged loanOriginationFeeRate when withdrawn
// Step 1: Since withdraws can change the asset weight scaling and borrows will
// change the liab weight scaling, we use a binary search to find something
// close to the true maximum value.
// To do that, we first get an upper bound that the search can start with.
const tp = this.getToken(tokenBank.tokenIndex);
const existingTokenDeposits = tp ? tp.deposits(tokenBank) : ZERO_I80F48();
let existingPositionHealthContrib = ZERO_I80F48();
if (existingTokenDeposits.gt(ZERO_I80F48())) {
existingPositionHealthContrib = existingTokenDeposits
.mul(tokenBank.getAssetPrice())
.imul(tokenBank.scaledInitAssetWeight(tokenBank.getAssetPrice()));
}
// Case 2: token deposits have higher contribution than initHealth,
// can withdraw without borrowing until initHealth reaches 0
if (existingPositionHealthContrib.gt(initHealth)) {
const withdrawAbleExistingPositionHealthContrib = initHealth;
return withdrawAbleExistingPositionHealthContrib
.div(tokenBank.scaledInitAssetWeight(tokenBank.getAssetPrice()))
.div(tokenBank.getAssetPrice());
}
// Case 3: withdraw = withdraw existing deposits + borrows until initHealth reaches 0
const initHealthWithoutExistingPosition = initHealth.sub(
existingPositionHealthContrib,
const lowerBoundBorrowHealthFactor = tokenBank
.getLiabPrice()
.mul(tokenBank.scaledInitLiabWeight(tokenBank.getLiabPrice()));
const upperBound = existingTokenDeposits.add(
initHealth.div(lowerBoundBorrowHealthFactor),
);
let maxBorrowNative = initHealthWithoutExistingPosition
.div(tokenBank.scaledInitLiabWeight(tokenBank.price))
.div(tokenBank.price);
// Cap maxBorrow to maintain minVaultToDepositsRatio on the bank
// Step 2: Find the maximum withdraw amount
let mutTokenBank = deepClone<Bank>(tokenBank);
let mutHealthCache = deepClone<HealthCache>(healthCache);
const invalidHealthValue = MAX_I80F48().div(I80F48.fromNumber(2)).neg();
function healthAfterWithdraw(amount: I80F48): I80F48 {
const withdrawOfDepositsAmount = amount.min(existingTokenDeposits);
const borrowAmount = amount.sub(withdrawOfDepositsAmount);
// Take care of loan origination fee
const borrowCost = borrowAmount.mul(loanOriginationFactor);
// Update the account's token position
let mutTi = mutHealthCache.tokenInfos[tokenInfoIndex];
const startTi = healthCache.tokenInfos[tokenInfoIndex];
mutTi.balanceSpot = startTi.balanceSpot
.sub(withdrawOfDepositsAmount)
.sub(borrowCost);
// Update the bank and the scaled weights
mutTokenBank.indexedDeposits = tokenBank.indexedDeposits.sub(
withdrawOfDepositsAmount.div(tokenBank.depositIndex),
);
mutTokenBank.indexedBorrows = tokenBank.indexedBorrows.add(
borrowCost.div(tokenBank.borrowIndex),
);
if (mutTokenBank.nativeBorrows().gt(mutTokenBank.nativeDeposits())) {
return invalidHealthValue;
}
if (borrowAmount.isPos()) {
if (
mutTokenBank
.nativeBorrows()
.gt(mutTokenBank.nativeDeposits().mul(maxBorrowUtilization))
) {
return invalidHealthValue;
}
}
mutTi.initScaledAssetWeight = mutTokenBank.scaledInitAssetWeight(
tokenBank.getAssetPrice(),
);
mutTi.initScaledLiabWeight = mutTokenBank.scaledInitLiabWeight(
tokenBank.getLiabPrice(),
);
return mutHealthCache.health(HealthType.init);
}
// Withdrawing one token will change health by at least this much.
// We use this to define a good stopping criterion for the search.
const minHealthChangePerNative = tokenBank
.getAssetPrice()
.mul(tokenBank.scaledInitAssetWeight(tokenBank.getAssetPrice()));
let amount = HealthCache.binaryApproximationSearch(
ZERO_I80F48(),
initHealth,
upperBound,
I80F48.fromNumber(0.5).mul(minHealthChangePerNative).min(initHealth),
I80F48.fromNumber(0.5),
healthAfterWithdraw,
{
maxIterations: 100,
targetError: I80F48.fromNumber(0.2)
.mul(minHealthChangePerNative)
.toNumber(),
},
);
// Step 3: Only full tokens can be withdrawn, do the rounding and
// check if withdrawing one-native more would also be fine
amount = amount.floor();
const amountPlusOne = amount.add(ONE_I80F48());
if (!healthAfterWithdraw(amountPlusOne).isNeg()) {
amount = amountPlusOne;
}
// Step 4: No borrows on no-borrow tokens
if (tokenBank.areBorrowsReduceOnly()) {
amount = amount.min(existingTokenDeposits);
}
// Step 5: also limit by vault funds
const vaultAmount = group.vaultAmountsMap.get(tokenBank.vault.toBase58());
if (!vaultAmount) {
throw new Error(
`No vault amount found for ${tokenBank.name} vault ${tokenBank.vault}!`,
);
}
const vaultAmountAfterWithdrawingDeposits = I80F48.fromU64(vaultAmount).sub(
existingTokenDeposits,
);
const expectedVaultMinAmount = tokenBank
.nativeDeposits()
.mul(I80F48.fromNumber(tokenBank.minVaultToDepositsRatio));
if (vaultAmountAfterWithdrawingDeposits.gt(expectedVaultMinAmount)) {
maxBorrowNative = maxBorrowNative.min(
vaultAmountAfterWithdrawingDeposits.sub(expectedVaultMinAmount),
);
}
const vaultLimit = I80F48.fromU64(vaultAmount);
const maxBorrowNativeWithoutFees = maxBorrowNative.div(
ONE_I80F48().add(tokenBank.loanOriginationFeeRate),
);
return maxBorrowNativeWithoutFees.add(existingTokenDeposits);
return amount.min(vaultLimit).max(ZERO_I80F48());
}
public getMaxWithdrawWithBorrowForTokenUi(