diff --git a/centrifuge-app/src/pages/Loan/TransactionTable.tsx b/centrifuge-app/src/pages/Loan/TransactionTable.tsx index a602db879..59755c51f 100644 --- a/centrifuge-app/src/pages/Loan/TransactionTable.tsx +++ b/centrifuge-app/src/pages/Loan/TransactionTable.tsx @@ -6,8 +6,8 @@ import Decimal from 'decimal.js-light' import { useMemo } from 'react' import { Column, DataTable } from '../../components/DataTable' import { Dec } from '../../utils/Decimal' -import { formatDate } from '../../utils/date' -import { formatBalance } from '../../utils/formatting' +import { daysBetween, formatDate } from '../../utils/date' +import { formatBalance, formatPercentage } from '../../utils/formatting' type Props = { transactions: AssetTransaction[] @@ -16,6 +16,8 @@ type Props = { loanType: 'external' | 'internal' pricing: PricingInfo poolType: 'publicCredit' | 'privateCredit' | undefined + maturityDate: Date + originationDate: Date | undefined } type Row = { @@ -24,11 +26,21 @@ type Row = { quantity: CurrencyBalance | null transactionDate: string settlePrice: CurrencyBalance | null - faceFlow: Decimal | null + faceValue: Decimal | null position: Decimal + yieldToMaturity: Decimal | null } -export const TransactionTable = ({ transactions, currency, loanType, decimals, pricing, poolType }: Props) => { +export const TransactionTable = ({ + transactions, + currency, + loanType, + decimals, + pricing, + poolType, + maturityDate, + originationDate, +}: Props) => { const assetTransactions = useMemo(() => { const sortedTransactions = transactions.sort((a, b) => { if (a.timestamp > b.timestamp) { @@ -46,46 +58,62 @@ export const TransactionTable = ({ transactions, currency, loanType, decimals, p return 0 }) - return sortedTransactions.map((transaction, index, array) => ({ - type: transaction.type, - amount: transaction.amount, - quantity: transaction.quantity ? new CurrencyBalance(transaction.quantity, 18) : null, - transactionDate: transaction.timestamp, - settlePrice: transaction.settlementPrice - ? new CurrencyBalance(new BN(transaction.settlementPrice), decimals) - : null, - faceFlow: + return sortedTransactions.map((transaction, index, array) => { + const termDays = originationDate + ? daysBetween(originationDate, maturityDate) + : daysBetween(new Date(), maturityDate) + const yearsBetweenDates = termDays / 365 + + const faceValue = transaction.quantity && (pricing as ExternalPricingInfo).notional ? new CurrencyBalance(transaction.quantity, 18) .toDecimal() .mul((pricing as ExternalPricingInfo).notional.toDecimal()) + : null + + return { + type: transaction.type, + amount: transaction.amount, + quantity: transaction.quantity ? new CurrencyBalance(transaction.quantity, 18) : null, + transactionDate: transaction.timestamp, + yieldToMaturity: + transaction.amount && faceValue && transaction.type !== 'REPAID' + ? Dec(2) + .mul(faceValue?.sub(transaction.amount.toDecimal())) + .div(Dec(yearsBetweenDates).mul(faceValue.add(transaction.amount.toDecimal()))) + .mul(100) + : null, + settlePrice: transaction.settlementPrice + ? new CurrencyBalance(new BN(transaction.settlementPrice), decimals) : null, - position: array.slice(0, index + 1).reduce((sum, trx) => { - if (trx.type === 'BORROWED') { - sum = sum.add( - trx.quantity - ? new CurrencyBalance(trx.quantity, 18) - .toDecimal() - .mul((pricing as ExternalPricingInfo).notional.toDecimal()) - : trx.amount - ? trx.amount.toDecimal() - : Dec(0) - ) - } - if (trx.type === 'REPAID') { - sum = sum.sub( - trx.quantity - ? new CurrencyBalance(trx.quantity, 18) - .toDecimal() - .mul((pricing as ExternalPricingInfo).notional.toDecimal()) - : trx.amount - ? trx.amount.toDecimal() - : Dec(0) - ) - } - return sum - }, Dec(0)), - })) + faceValue, + position: array.slice(0, index + 1).reduce((sum, trx) => { + if (trx.type === 'BORROWED') { + sum = sum.add( + trx.quantity + ? new CurrencyBalance(trx.quantity, 18) + .toDecimal() + .mul((pricing as ExternalPricingInfo).notional.toDecimal()) + : trx.amount + ? trx.amount.toDecimal() + : Dec(0) + ) + } + if (trx.type === 'REPAID') { + sum = sum.sub( + trx.quantity + ? new CurrencyBalance(trx.quantity, 18) + .toDecimal() + .mul((pricing as ExternalPricingInfo).notional.toDecimal()) + : trx.amount + ? trx.amount.toDecimal() + : Dec(0) + ) + } + return sum + }, Dec(0)), + } + }) }, [transactions, decimals, pricing]) const getStatusChipType = (type: AssetTransactionType) => { @@ -127,10 +155,10 @@ export const TransactionTable = ({ transactions, currency, loanType, decimals, p ? [ { align: 'left', - header: `Face flow (${currency})`, + header: `Face value (${currency})`, cell: (row: Row) => - row.faceFlow - ? `${row.type === 'REPAID' ? '-' : ''}${formatBalance(row.faceFlow, undefined, 2, 2)}` + row.faceValue + ? `${row.type === 'REPAID' ? '-' : ''}${formatBalance(row.faceValue, undefined, 2, 2)}` : '-', }, { @@ -143,6 +171,16 @@ export const TransactionTable = ({ transactions, currency, loanType, decimals, p header: `Settle price (${currency})`, cell: (row: Row) => (row.settlePrice ? formatBalance(row.settlePrice, undefined, 6, 2) : '-'), }, + ...(loanType === 'external' + ? [ + { + align: 'left', + header: `YTM`, + cell: (row: Row) => + !row.yieldToMaturity || row.yieldToMaturity?.lt(0) ? '-' : formatPercentage(row.yieldToMaturity), + }, + ] + : []), { align: 'left', header: `Net cash flow (${currency})`, @@ -170,6 +208,16 @@ export const TransactionTable = ({ transactions, currency, loanType, decimals, p ? `${row.type === 'REPAID' ? '-' : ''}${formatBalance(row.position, undefined, 2, 2)}` : '-', }, + ...(loanType === 'external' + ? [ + { + align: 'left', + header: `YTM`, + cell: (row: Row) => + !row.yieldToMaturity || row.yieldToMaturity?.lt(0) ? '-' : formatPercentage(row.yieldToMaturity), + }, + ] + : []), ]), ] as Column[] }, []) diff --git a/centrifuge-app/src/pages/Loan/index.tsx b/centrifuge-app/src/pages/Loan/index.tsx index 968401695..43b591800 100644 --- a/centrifuge-app/src/pages/Loan/index.tsx +++ b/centrifuge-app/src/pages/Loan/index.tsx @@ -1,4 +1,11 @@ -import { CurrencyBalance, Loan as LoanType, Pool, PricingInfo, TinlakeLoan } from '@centrifuge/centrifuge-js' +import { + CurrencyBalance, + ExternalPricingInfo, + Loan as LoanType, + Pool, + PricingInfo, + TinlakeLoan, +} from '@centrifuge/centrifuge-js' import { Box, Button, @@ -25,9 +32,10 @@ import { RouterLinkButton } from '../../components/RouterLinkButton' import { Tooltips } from '../../components/Tooltips' import { nftMetadataSchema } from '../../schemas' import { LoanTemplate } from '../../types' +import { Dec } from '../../utils/Decimal' import { copyToClipboard } from '../../utils/copyToClipboard' import { daysBetween, formatDate, isValidDate } from '../../utils/date' -import { formatBalance, truncateText } from '../../utils/formatting' +import { formatBalance, formatPercentage, truncateText } from '../../utils/formatting' import { useLoan, useNftDocumentId } from '../../utils/useLoans' import { useMetadata } from '../../utils/useMetadata' import { useCentNFT } from '../../utils/useNFTs' @@ -139,6 +147,52 @@ function Loan() { return 0 }, [originationDate, loan?.pricing.maturityDate]) + const weightedYTM = React.useMemo(() => { + if ( + loan?.pricing && + 'valuationMethod' in loan.pricing && + loan.pricing.valuationMethod === 'oracle' && + loan.pricing.interestRate.isZero() + ) { + const termDays = originationDate + ? daysBetween(originationDate, loan?.pricing.maturityDate) + : daysBetween(new Date(), loan?.pricing.maturityDate) + const yearsBetweenDates = termDays / 365 + + return borrowerAssetTransactions + ?.filter((tx) => tx.type !== 'REPAID') + .reduce((prev, curr) => { + const faceValue = + curr.quantity && (loan.pricing as ExternalPricingInfo).notional + ? new CurrencyBalance(curr.quantity, 18) + .toDecimal() + .mul((loan.pricing as ExternalPricingInfo).notional.toDecimal()) + : null + + const yieldToMaturity = + curr.amount && faceValue + ? Dec(2) + .mul(faceValue?.sub(curr.amount.toDecimal())) + .div(Dec(yearsBetweenDates).mul(faceValue.add(curr.amount.toDecimal()))) + .mul(100) + : null + return yieldToMaturity?.mul(curr.quantity!).add(prev) || prev + }, Dec(0)) + } + return null + }, [loan, borrowerAssetTransactions]) + + const averageWeightedYTM = React.useMemo(() => { + if (borrowerAssetTransactions?.length && weightedYTM) { + const sum = borrowerAssetTransactions + .filter((tx) => tx.type !== 'REPAID') + .reduce((prev, curr) => { + return curr.quantity ? Dec(curr.quantity).add(prev) : prev + }, Dec(0)) + return sum.isZero() ? Dec(0) : weightedYTM.div(sum) + } + }, [weightedYTM]) + return ( @@ -194,6 +248,12 @@ function Loan() { )}`, }, ], + ...(loan.pricing.maturityDate && + 'valuationMethod' in loan.pricing && + loan.pricing.valuationMethod === 'oracle' && + averageWeightedYTM + ? [{ label: 'Average YTM', value: formatPercentage(averageWeightedYTM) }] + : []), ]} /> @@ -272,6 +332,8 @@ function Loan() { poolType={poolMetadata?.pool?.asset.class as 'publicCredit' | 'privateCredit' | undefined} decimals={pool.currency.decimals} pricing={loan.pricing as PricingInfo} + maturityDate={new Date(loan.pricing.maturityDate)} + originationDate={originationDate ? new Date(originationDate) : undefined} /> ) : null}