Skip to content

Commit

Permalink
Fixes to nToken redeem and borrow requirement (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeffywu authored Oct 15, 2021
1 parent f4632d6 commit f4eff94
Show file tree
Hide file tree
Showing 12 changed files with 216 additions and 165 deletions.
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ module.exports = {
},
],
coverageReporters: ["text", "html"],
collectCoverage: true
collectCoverage: true,
collectCoverageFrom: [
"src/**/*.ts",
"!src/typechain/**"
]
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@notional-finance/sdk-v2",
"version": "0.0.4",
"version": "0.0.5",
"description": "Notional Finance SDK V2",
"author": "Jeff Wu <jeff@notional.finance>",
"homepage": "https://notional.finance",
Expand Down
6 changes: 6 additions & 0 deletions src/account/AccountData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export default class AccountData {
);
}

public static emptyAccountData() {
return AccountData.copyAccountData();
}

/**
* Copies an account data object for simulation
* @param account if undefined, will return an empty account data object
Expand Down Expand Up @@ -192,6 +196,8 @@ export default class AccountData {
*/
public updateAsset(asset: Asset) {
if (!this.isCopy) throw Error('Cannot update assets on non copy');
if (asset.hasMatured) throw Error('Cannot add matured asset to account copy');

// eslint-disable-next-line no-underscore-dangle
this.portfolio = AccountData._updateAsset(this.portfolio, asset, this.bitmapCurrencyId);
const {symbol} = System.getSystem().getCurrencyById(asset.currencyId);
Expand Down
8 changes: 5 additions & 3 deletions src/account/AssetSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ export default class AssetSummary {
return this.currency.symbol;
}

public rateOfReturnString(locale = 'en-US', precision = 3) {
// TODO: override this if there is no actual IRR
// TODO: change this to look at the last rate lent or borrowed
public internalRateOfReturnString(locale = 'en-US', precision = 3) {
return `${this.irr.toLocaleString(locale, {
maximumFractionDigits: precision,
minimumFractionDigits: precision,
})}%`;
}

public mostRecentTradedRate() {
return this.history[this.history.length - 1].tradedInterestRate;
}

constructor(
public assetKey: string,
public underlyingInternalPV: TypedBigNumber,
Expand Down
18 changes: 11 additions & 7 deletions src/account/BalanceSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,13 @@ export default class BalanceSummary {

if (withdrawAmountInternalAsset.gt(this.maxWithdrawValueAssetCash)) {
throw new Error('Cannot withdraw, over maximum value');
} else if (!canWithdrawNToken) {
} else if (!canWithdrawNToken || !this.nTokenBalance || this.nTokenBalance.isZero()) {
// If the nToken cannot be withdrawn, only return cash amounts
const cashWithdraw = withdrawAmountInternalAsset.lte(this.assetCashBalance)
? withdrawAmountInternalAsset
: this.assetCashBalance;
const cashWithdraw = TypedBigNumber.min(withdrawAmountInternalAsset, this.assetCashBalance);
const nTokenRedeem = this.nTokenBalance?.copy(0);
return {cashWithdraw, nTokenRedeem};
} else if (preferCash || !this.nTokenBalance || this.nTokenBalance.isZero()) {
// Cash preference supersedes nToken (or nToken balance does not exist)
} else if (preferCash) {
// Cash preference supersedes nToken
if (withdrawAmountInternalAsset.lte(this.assetCashBalance)) {
// Cash is sufficient to cover the withdraw
return {
Expand All @@ -143,7 +141,13 @@ export default class BalanceSummary {
// Withdraw all cash and some part of the nToken balance
const requiredNTokenRedeem = withdrawAmountInternalAsset.sub(this.assetCashBalance);
const nTokenRedeem = NTokenValue.getNTokenRedeemFromAsset(this.currencyId, requiredNTokenRedeem);
return {cashWithdraw: this.assetCashBalance, nTokenRedeem};

return {
cashWithdraw: this.assetCashBalance,
// Cap the nTokenRedeem at the balance, it may go slightly over due to the reverse approximation inside
// getNTokenRedeemFromAsset
nTokenRedeem: TypedBigNumber.min(nTokenRedeem, this.nTokenBalance),
};
} else {
// nToken supersedes cash and we know there is some nToken balance
const nTokenRedeem = NTokenValue.getNTokenRedeemFromAsset(this.currencyId, withdrawAmountInternalAsset);
Expand Down
10 changes: 10 additions & 0 deletions src/libs/TypedBigNumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ class TypedBigNumber {
return new TypedBigNumber(BigNumber.from(value), type, symbol);
}

static max(a: TypedBigNumber, b: TypedBigNumber): TypedBigNumber {
a.checkMatch(b);
return a.gte(b) ? a : b;
}

static min(a: TypedBigNumber, b: TypedBigNumber): TypedBigNumber {
a.checkMatch(b);
return a.lte(b) ? a : b;
}

checkType(type: BigNumberType) {
if (this.type !== type) throw TypeError(`Invalid TypedBigNumber type ${this.type} != ${type}`);
}
Expand Down
4 changes: 1 addition & 3 deletions src/libs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,15 @@ export interface TradeHistory {
transactionHash: string;
blockTime: Date;
currencyId: number;

tradeType: TradeType;
settlementDate: BigNumber | null;
maturityLength: number | null;
maturity: BigNumber;

netAssetCash: TypedBigNumber;
netUnderlyingCash: TypedBigNumber;
netfCash: TypedBigNumber;

netLiquidityTokens: TypedBigNumber | null;
tradedInterestRate: number;
}

export interface BalanceHistory {
Expand Down
102 changes: 14 additions & 88 deletions src/system/FreeCollateral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,9 @@ export default class FreeCollateral {
/**
* Calculates borrow requirements for a given amount of fCash and a target collateral ratio
*
* @param borrowfCashAmount fcash amount to borrow (must be negative)
* @param maturity maturity of the fcash to borrow
* @param borrowCurrencyId currency id of the fcash asset
* @param collateralCurrencyId currency to collateralize this asset by
* @param bufferedRatio the target post haircut / buffer collateral ratio
* @param accountData account data object, if it exists
* @param accountData account data object with borrow amounts applied
* @param blockTime
* @returns
* - minCollateral: minimum amount of collateral required for the borrow
Expand All @@ -204,64 +201,34 @@ export default class FreeCollateral {
* - targetCollateralRatio: target buffered/haircut collateral ratio
*/
public static calculateBorrowRequirement(
borrowfCashAmount: TypedBigNumber,
maturity: number,
borrowCurrencyId: number,
collateralCurrencyId: number,
_bufferedRatio: number,
accountData?: AccountData,
accountData: AccountData,
blockTime = getNowSeconds(),
): {
minCollateral: TypedBigNumber;
targetCollateral: TypedBigNumber;
minCollateralRatio: number | null;
targetCollateralRatio: number | null;
} {
const system = System.getSystem();
if (!borrowfCashAmount.isNegative()) throw new Error('Borrow fCash amount must be negative');
const cashGroup = system.getCashGroup(borrowCurrencyId);
const bufferedRatio = Math.trunc(_bufferedRatio);
if (!cashGroup) throw new Error(`Cash group for ${borrowCurrencyId} not found`);
if (bufferedRatio < 100) throw new RangeError('Buffered ratio must be more than 100');

let netETHDebt = FreeCollateral.getZeroUnderlying(ETH);
let netETHDebtWithBuffer = FreeCollateral.getZeroUnderlying(ETH);
let netETHCollateralWithHaircut = FreeCollateral.getZeroUnderlying(ETH);
let borrowNetAvailable = FreeCollateral.getZeroUnderlying(borrowCurrencyId);
let collateralNetAvailable = FreeCollateral.getZeroUnderlying(collateralCurrencyId);

if (accountData) {
let netUnderlyingAvailable: Map<number, TypedBigNumber>;
// prettier-ignore
({
netETHCollateralWithHaircut,
netETHDebt,
netETHDebtWithBuffer,
netUnderlyingAvailable,
} = FreeCollateral.getFreeCollateral(
accountData,
blockTime,
));
borrowNetAvailable = netUnderlyingAvailable.get(borrowCurrencyId) || borrowNetAvailable;
collateralNetAvailable = netUnderlyingAvailable.get(collateralCurrencyId) || collateralNetAvailable;
}

const borrowAmountHaircutPV = cashGroup.getfCashPresentValueUnderlyingInternal(
maturity,
borrowfCashAmount,
true,
blockTime,
);

// Updates the net ETH amounts to take into account the new debt, netting for
// local currency purposes only
({netETHCollateralWithHaircut, netETHDebt, netETHDebtWithBuffer} = FreeCollateral.updateNetETHAmounts(
borrowAmountHaircutPV,
// prettier-ignore
const {
netETHCollateralWithHaircut,
netETHDebt,
netETHDebtWithBuffer,
borrowNetAvailable,
));
netUnderlyingAvailable,
} = FreeCollateral.getFreeCollateral(
accountData,
blockTime,
);

const collateralNetAvailable = (
netUnderlyingAvailable.get(collateralCurrencyId)
|| FreeCollateral.getZeroUnderlying(collateralCurrencyId)
);

return FreeCollateral.calculateTargetCollateral(
netETHCollateralWithHaircut,
Expand All @@ -273,47 +240,6 @@ export default class FreeCollateral {
);
}

/* eslint-disable no-param-reassign */
private static updateNetETHAmounts(
borrowAmountHaircutPV: TypedBigNumber,
netETHCollateralWithHaircut: TypedBigNumber,
netETHDebt: TypedBigNumber,
netETHDebtWithBuffer: TypedBigNumber,
borrowNetAvailable: TypedBigNumber,
) {
if (borrowNetAvailable.isNegative() || borrowNetAvailable.isZero()) {
// If local net is already in debt then borrowAmount is added to netETHDebt
netETHDebtWithBuffer = netETHDebtWithBuffer.add(borrowAmountHaircutPV.toETH(useHaircut).abs());
netETHDebt = netETHDebt.add(borrowAmountHaircutPV.toETH(noHaircut).abs());
} else if (borrowNetAvailable.gte(borrowAmountHaircutPV.abs())) {
// If there's enough local to net off then this is the change to ETH collateral
// Formula here is:
// netETHBorrowBefore = convertToETH(borrowNetAvailable) * haircut
// netETHBorrowAfter = convertToETH(borrowNetAvailable - borrowAmountHaircutPV) * haircut
// netETHCollateralWithHaircutFinal = netETHCollateralWithHaircut - (netETHBorrowBefore - netETHBorrowAfter)
const netCollateralDifferenceWithHaircut = borrowNetAvailable
.toETH(useHaircut)
.sub(borrowNetAvailable.add(borrowAmountHaircutPV).toETH(useHaircut));
netETHCollateralWithHaircut = netETHCollateralWithHaircut.sub(netCollateralDifferenceWithHaircut);
} else {
// In this case it's a partial thing so we add/subtract to both.
// First, we reduce the collateral by the borrowNetAvailable
netETHCollateralWithHaircut = netETHCollateralWithHaircut.sub(borrowNetAvailable.toETH(useHaircut));

// Second we add whatever remaining debt there is after accounting for the borrowNetAvailable
const netBorrowCurrencyDebt = borrowAmountHaircutPV.add(borrowNetAvailable);
netETHDebtWithBuffer = netETHDebtWithBuffer.add(netBorrowCurrencyDebt.toETH(useHaircut).abs());
netETHDebt = netETHDebt.add(netBorrowCurrencyDebt.toETH(noHaircut).abs());
}

return {
netETHCollateralWithHaircut,
netETHDebtWithBuffer,
netETHDebt,
};
}
/* eslint-enable no-param-reassign */

/**
* Returns the amount of target collateral required to achieve the given buffered ratio
*
Expand Down
16 changes: 11 additions & 5 deletions src/system/NTokenValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export default class NTokenValue {
public static getNTokenRedeemFromAsset(
currencyId: number,
assetCashAmountInternal: TypedBigNumber,
precision = BigNumber.from(1e4),
precision = BigNumber.from(1e2),
) {
const {totalSupply, nTokenPV} = NTokenValue.getNTokenFactors(currencyId);

Expand All @@ -121,18 +121,24 @@ export default class NTokenValue {
let redeemValue = NTokenValue.getAssetFromRedeemNToken(currencyId, nTokenRedeem);
// We always want to redeem value slightly less than the specified amount, if we were to
// redeem slightly more then it could result in a free collateral failure. We continue to
// loop while assetCash - redeemValue < 0 or assetCash - redeemValue > precision
// loop while assetCash - redeemValue < 0 or assetCash - redeemValue > precision. Note that
// we allow negative one as a diff due to rounding issues
let diff = assetCashAmountInternal.sub(redeemValue);
let totalLoops = 0;
while (diff.isNegative() || diff.n.gt(precision)) {
while (diff.n.lt(-1) || diff.n.gt(precision)) {
// If the nToken redeem value is too high (diff < 0), we reduce the nTokenRedeem amount by
// the proportion of the total supply. If the nToken redeem value is too low (diff > 0), increase
// the nTokenRedeem amount by the proportion of the total supply
nTokenRedeem = nTokenRedeem.add(totalSupply.scale(diff.n, nTokenPV.n));
const updateAmount = totalSupply.scale(diff.n, nTokenPV.n);
// If the diff is so small it rounds down to zero when we convert to an nToken balance then
// we can break at this point, the calculation will not converge after this
if (updateAmount.isZero()) break;

nTokenRedeem = nTokenRedeem.add(updateAmount);
redeemValue = NTokenValue.getAssetFromRedeemNToken(currencyId, nTokenRedeem);
diff = assetCashAmountInternal.sub(redeemValue);
totalLoops += 1;
if (totalLoops > 250) throw Error('Unable to converge on nTokenRedeem');
if (totalLoops > 50) throw Error('Unable to converge on nTokenRedeem');
}

return nTokenRedeem;
Expand Down
1 change: 1 addition & 0 deletions tests/unit/AssetSummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('Asset Summary', () => {
netUnderlyingCash: TypedBigNumber.from(-95e8, BigNumberType.InternalUnderlying, 'DAI'),
netfCash: TypedBigNumber.from(100e8, BigNumberType.InternalUnderlying, 'DAI'),
netLiquidityTokens: null,
tradedInterestRate: 0,
};

it('produces irr for a lend fcash asset', () => {
Expand Down
Loading

0 comments on commit f4eff94

Please sign in to comment.