From 3c1a9187f604757f41496fdd5c5177535c40d65d Mon Sep 17 00:00:00 2001 From: Onno Visser Date: Fri, 20 Oct 2023 18:00:38 +0200 Subject: [PATCH] Centrifuge App: Portfolio asset allocation (#1641) --- .../components/Portfolio/AssetAllocation.tsx | 97 ++++++++++++++++--- .../components/Portfolio/AssetClassChart.tsx | 52 ++++++++++ .../src/components/Report/PoolBalance.tsx | 10 +- centrifuge-app/src/config.ts | 27 +++--- .../src/pages/IssuerCreatePool/index.tsx | 43 ++++++-- .../src/pages/IssuerCreatePool/validate.ts | 1 + .../IssuerPool/Configuration/Details.tsx | 54 +++++++++-- .../src/pages/Pool/Overview/index.tsx | 2 +- centrifuge-app/src/pages/Portfolio/index.tsx | 2 +- .../src/utils/tinlake/useTinlakePools.ts | 3 +- centrifuge-app/src/utils/usePools.ts | 32 +++++- centrifuge-js/src/modules/pools.ts | 8 +- 12 files changed, 278 insertions(+), 53 deletions(-) create mode 100644 centrifuge-app/src/components/Portfolio/AssetClassChart.tsx diff --git a/centrifuge-app/src/components/Portfolio/AssetAllocation.tsx b/centrifuge-app/src/components/Portfolio/AssetAllocation.tsx index 53428fd1d0..69ec228512 100644 --- a/centrifuge-app/src/components/Portfolio/AssetAllocation.tsx +++ b/centrifuge-app/src/components/Portfolio/AssetAllocation.tsx @@ -1,26 +1,93 @@ import { useBalances } from '@centrifuge/centrifuge-react' -import { Box, Text } from '@centrifuge/fabric' +import { Box, Shelf, Stack, Text } from '@centrifuge/fabric' +import Decimal from 'decimal.js-light' import * as React from 'react' -import { useAddress } from '../../utils/useAddress' +import { useTheme } from 'styled-components' +import { Dec } from '../../utils/Decimal' +import { formatBalanceAbbreviated } from '../../utils/formatting' +import { usePoolMetadataMulti, usePools } from '../../utils/usePools' +import { LabelValueStack } from '../LabelValueStack' +import { AssetClassChart } from './AssetClassChart' -export function AssetAllocation() { - const address = useAddress() +const assetClassLabels = { + privateCredit: 'Private Credit', + publicCredit: 'Public Credit', +} +type AssetClass = 'publicCredit' | 'privateCredit' + +export function AssetAllocation({ address }: { address: string }) { const balances = useBalances(address) + const pools = usePools() + const theme = useTheme() + const poolIds = new Set(balances?.tranches.map((t) => t.poolId)) + const filteredPools = pools?.filter((p) => poolIds.has(p.id)) ?? [] + const metas = usePoolMetadataMulti(filteredPools) + const assetClasses = [...new Set(metas.map((m) => m.data?.pool?.asset?.class as string).filter(Boolean))] + const valueByClass: Record = Object.fromEntries(assetClasses.map((item) => [item, Dec(0)])) + let total = Dec(0) + balances?.tranches.forEach((balance) => { + const poolIndex = filteredPools.findIndex((p) => p.id === balance.poolId) + const price = + filteredPools[poolIndex]?.tranches.find((t) => t.id === balance.trancheId)?.tokenPrice?.toDecimal() ?? Dec(0) + const asset = metas[poolIndex].data?.pool?.asset?.class + const value = balance.balance.toDecimal().mul(price) + total = total.add(value) + valueByClass[asset!] = valueByClass[asset!]?.add(value) + }) + + const shades = [600, 800, 200, 400] + const shares = assetClasses + .map((item, index) => { + const nextShade = shades[index % shades.length] + return { + name: assetClassLabels[item as AssetClass] ?? item, + value: valueByClass[item].toNumber(), + color: theme.colors.accentScale[nextShade], + labelColor: nextShade >= 500 ? 'white' : 'black', + } + }) + .sort((a, b) => (b.value > a.value ? 1 : a === b ? 0 : -1)) return !!balances?.tranches && !!balances?.tranches.length ? ( - + Allocation - - {balances?.tranches.map((tranche, index) => ( - - - Asset Class{' '} - - - ))} - - + + + + {shares.map((cell, i) => ( + <> + {i > 0 && } + + {cell.name} +
+ + } + value={ + + {formatBalanceAbbreviated(cell.value, 'USD')} + + } + key={i} + /> + + ))} + + + ) : null } diff --git a/centrifuge-app/src/components/Portfolio/AssetClassChart.tsx b/centrifuge-app/src/components/Portfolio/AssetClassChart.tsx new file mode 100644 index 0000000000..b8188ffc78 --- /dev/null +++ b/centrifuge-app/src/components/Portfolio/AssetClassChart.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { Cell, Pie, PieChart as RechartsPieChart, Tooltip, TooltipProps } from 'recharts' +import { formatBalanceAbbreviated, formatPercentage } from '../../utils/formatting' +import { TooltipContainer, TooltipEntry, TooltipTitle } from '../Charts/CustomChartElements' + +type PieChartProps = { + data: { name: string; value: number; color?: string }[] + currency: string + total: number +} + +export function AssetClassChart({ data, currency, total }: PieChartProps) { + return ( + + + {data.map((item, index) => ( + + ))} + + } /> + + ) +} + +function TooltipContent({ payload, currency, total }: TooltipProps & { currency: string; total: number }) { + if (payload && payload.length > 0) { + return ( + + {payload[0].name} + {payload.map(({ payload, name, value }) => ( + + {formatPercentage((value / total) * 100)} + + ))} + + ) + } + return null +} diff --git a/centrifuge-app/src/components/Report/PoolBalance.tsx b/centrifuge-app/src/components/Report/PoolBalance.tsx index 96cd88e84c..7ae7daf2e7 100644 --- a/centrifuge-app/src/components/Report/PoolBalance.tsx +++ b/centrifuge-app/src/components/Report/PoolBalance.tsx @@ -1,6 +1,7 @@ import { Pool } from '@centrifuge/centrifuge-js/dist/modules/pools' import { Text } from '@centrifuge/fabric' import * as React from 'react' +import { Dec } from '../../utils/Decimal' import { formatBalanceAbbreviated } from '../../utils/formatting' import { getCSVDownloadUrl } from '../../utils/getCSVDownloadUrl' import { useDailyPoolStates, useMonthlyPoolStates } from '../../utils/usePools' @@ -86,7 +87,7 @@ export function PoolBalance({ pool }: { pool: Pool }) { name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, value: poolStates?.map((state) => - state.tranches[token.id].price + state.tranches[token.id]?.price ? formatBalanceAbbreviated(state.tranches[token.id].price?.toFloat()!, pool.currency.symbol) : '1.000' ) || [], @@ -110,7 +111,10 @@ export function PoolBalance({ pool }: { pool: Pool }) { name: `\u00A0 \u00A0 ${token.currency.name.split(' ').at(-1)} tranche`, value: poolStates?.map((state) => - formatBalanceAbbreviated(state.tranches[token.id].fulfilledInvestOrders.toDecimal(), pool.currency.symbol) + formatBalanceAbbreviated( + state.tranches[token.id]?.fulfilledInvestOrders.toDecimal() ?? Dec(0), + pool.currency.symbol + ) ) || [], heading: false, })) || [], @@ -129,7 +133,7 @@ export function PoolBalance({ pool }: { pool: Pool }) { value: poolStates?.map((state) => formatBalanceAbbreviated( - state.tranches[token.id].fulfilledRedeemOrders.toDecimal(), + state.tranches[token.id]?.fulfilledRedeemOrders.toDecimal() ?? Dec(0), pool.currency.symbol ) ) || [], diff --git a/centrifuge-app/src/config.ts b/centrifuge-app/src/config.ts index d5b9f2d954..c7d9bf31a5 100644 --- a/centrifuge-app/src/config.ts +++ b/centrifuge-app/src/config.ts @@ -66,8 +66,7 @@ type EnvironmentConfig = { } defaultTheme: 'light' | 'dark' baseCurrency: 'USD' - assetClasses: string[] - defaultAssetClass: string + assetClasses: Record<'publicCredit' | 'privateCredit', string[]> poolCreationType: TransactionOptions['createType'] useDocumentNfts: boolean defaultPodUrl: string @@ -86,8 +85,7 @@ const ALTAIR: EnvironmentConfig = { }, defaultTheme: 'dark', baseCurrency: 'USD', - assetClasses: ['Art NFTs'], - defaultAssetClass: 'Art NFTs', + assetClasses: { privateCredit: ['Art NFTs'], publicCredit: [] }, poolCreationType, useDocumentNfts: true, defaultPodUrl, @@ -103,16 +101,17 @@ const CENTRIFUGE: EnvironmentConfig = { }, defaultTheme: 'light', baseCurrency: 'USD', - assetClasses: [ - 'Consumer Credit', - 'Corporate Credit', - 'Commercial Real Estate', - 'Residential Real Estate', - 'Project Finance', - 'Trade Finance', - 'US Treasuries', - ], - defaultAssetClass: 'Consumer Credit', + assetClasses: { + privateCredit: [ + 'Consumer Credit', + 'Corporate Credit', + 'Commercial Real Estate', + 'Residential Real Estate', + 'Project Finance', + 'Trade Finance', + ], + publicCredit: ['Corporate Bonds', 'US Treasuries'], + }, poolCreationType, useDocumentNfts: true, defaultPodUrl, diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index 56f5f6791c..46a3a3ea8a 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -49,11 +49,16 @@ import { TrancheSection } from './TrancheInput' import { useStoredIssuer } from './useStoredIssuer' import { validate } from './validate' -const ASSET_CLASSES = config.assetClasses.map((label) => ({ - label, - value: label, +const assetClassLabels = { + privateCredit: 'Private Credit', + publicCredit: 'Public Credit', +} +type AssetClass = 'publicCredit' | 'privateCredit' + +const ASSET_CLASSES = Object.keys(config.assetClasses).map((key) => ({ + label: assetClassLabels[key as AssetClass], + value: key, })) -const DEFAULT_ASSET_CLASS = config.defaultAssetClass export const IssuerCreatePoolPage: React.FC = () => { return ( @@ -98,7 +103,8 @@ export type CreatePoolValues = Omit< const initialValues: CreatePoolValues = { poolIcon: null, poolName: '', - assetClass: DEFAULT_ASSET_CLASS, + assetClass: 'privateCredit', + subAssetClass: '', currency: '', maxReserve: '', epochHours: 23, // in hours @@ -453,6 +459,12 @@ function CreatePoolForm() { .add(collectionDeposit.toDecimal()) const deposit = createDeposit.add(proxyDeposit.toDecimal()) + const subAssetClasses = + config.assetClasses[form.values.assetClass]?.map((label) => ({ + label, + value: label, + })) ?? [] + return ( <> } - onChange={(event) => form.setFieldValue('assetClass', event.target.value)} + onChange={(event) => { + form.setFieldValue('assetClass', event.target.value) + form.setFieldValue('subAssetClass', '', false) + }} onBlur={field.onBlur} errorMessage={meta.touched && meta.error ? meta.error : undefined} value={field.value} @@ -544,6 +559,22 @@ function CreatePoolForm() { )} + + + {({ field, meta, form }: FieldProps) => ( + } - onChange={(event) => form.setFieldValue('assetClass', event.target.value)} + onChange={(event) => { + form.setFieldValue('assetClass', event.target.value) + form.setFieldValue('subAssetClass', '', false) + }} onBlur={field.onBlur} errorMessage={meta.touched && meta.error ? meta.error : undefined} value={field.value} @@ -181,6 +203,20 @@ export function Details() { /> )} + + {({ field, meta, form }: FieldProps) => ( + - + + diff --git a/centrifuge-app/src/pages/Pool/Overview/index.tsx b/centrifuge-app/src/pages/Pool/Overview/index.tsx index 0f957e8df1..6ff5f01bf8 100644 --- a/centrifuge-app/src/pages/Pool/Overview/index.tsx +++ b/centrifuge-app/src/pages/Pool/Overview/index.tsx @@ -100,7 +100,7 @@ export function PoolDetailOverview({ const pageSummaryData = [ { label: , - value: {metadata?.pool?.asset.class}, + value: {metadata?.pool?.asset.subClass}, }, { label: , value: formatBalance(getPoolValueLocked(pool), pool.currency.symbol) }, ] diff --git a/centrifuge-app/src/pages/Portfolio/index.tsx b/centrifuge-app/src/pages/Portfolio/index.tsx index 48c7ae4eb6..897e4c4546 100644 --- a/centrifuge-app/src/pages/Portfolio/index.tsx +++ b/centrifuge-app/src/pages/Portfolio/index.tsx @@ -53,7 +53,7 @@ function Portfolio() { - + diff --git a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts index cc1a5d6478..02288d5b49 100644 --- a/centrifuge-app/src/utils/tinlake/useTinlakePools.ts +++ b/centrifuge-app/src/utils/tinlake/useTinlakePools.ts @@ -611,7 +611,8 @@ async function getPools(pools: IpfsPools): Promise<{ pools: TinlakePool[] }> { name: p.metadata.name, icon: p.metadata.media?.icon ? { uri: p.metadata.media.icon, mime: 'image/svg' } : null, asset: { - class: p.metadata.asset, + class: 'privateCredit', + subClass: p.metadata.asset, }, newInvestmentsStatus: p.metadata.newInvestmentsStatus, issuer: { diff --git a/centrifuge-app/src/utils/usePools.ts b/centrifuge-app/src/utils/usePools.ts index cfa343ab62..8ad9e5ae41 100644 --- a/centrifuge-app/src/utils/usePools.ts +++ b/centrifuge-app/src/utils/usePools.ts @@ -2,12 +2,12 @@ import Centrifuge, { BorrowerTransaction, Loan, Pool, PoolMetadata } from '@cent import { useCentrifugeConsts, useCentrifugeQuery, useWallet } from '@centrifuge/centrifuge-react' import BN from 'bn.js' import { useEffect, useMemo } from 'react' -import { useQuery } from 'react-query' +import { useQueries, useQuery } from 'react-query' import { combineLatest, map, Observable } from 'rxjs' import { Dec } from './Decimal' import { TinlakePool, useTinlakePools } from './tinlake/useTinlakePools' import { useLoan, useLoans } from './useLoans' -import { useMetadata } from './useMetadata' +import { useMetadata, useMetadataMulti } from './useMetadata' export function usePools(suspense = true) { const [result] = useCentrifugeQuery(['pools'], (cent) => cent.pools.getPools(), { @@ -238,6 +238,34 @@ export function usePoolMetadata( }, [data.data]) return typeof pool?.metadata === 'string' ? data : tinlakeData } +export function usePoolMetadataMulti(pools?: (Pool | TinlakePool)[]) { + const poolsIndexed = pools?.map((p, i) => [i, p, 'isTinlakePool' in p] as const) ?? [] + const indices: Record = {} + const centPools = poolsIndexed?.filter(([, , isTinlake]) => !isTinlake) + const tinlakePools = poolsIndexed?.filter(([, , isTinlake]) => isTinlake) + centPools.forEach(([pi], qi) => { + indices[pi] = qi + }) + tinlakePools.forEach(([pi], qi) => { + indices[pi] = qi + }) + + const centData = useMetadataMulti(centPools?.map(([, p]) => p.metadata as string) ?? []) + const tinlakeData = useQueries( + tinlakePools?.map(([, p]) => { + return { + queryKey: ['tinlakeMetadata', p.id], + queryFn: () => p.metadata as PoolMetadata, + enabled: !!p.metadata, + staleTime: Infinity, + } + }) + ) + return poolsIndexed.map(([poolIndex, , isTinlake]) => { + const queryIndex = indices[poolIndex] + return isTinlake ? tinlakeData[queryIndex] : centData[queryIndex] + }) +} export function useWriteOffGroups(poolId: string) { const [result] = useCentrifugeQuery(['writeOffGroups', poolId], (cent) => cent.pools.getWriteOffPolicy([poolId])) diff --git a/centrifuge-js/src/modules/pools.ts b/centrifuge-js/src/modules/pools.ts index 1a705cd6a1..3b539108b2 100644 --- a/centrifuge-js/src/modules/pools.ts +++ b/centrifuge-js/src/modules/pools.ts @@ -550,7 +550,8 @@ export interface PoolMetadataInput { // details poolIcon: FileType | null poolName: string - assetClass: string + assetClass: 'publicCredit' | 'privateCredit' + subAssetClass: string currency: string maxReserve: number | '' epochHours: number | '' @@ -588,7 +589,8 @@ export type PoolMetadata = { name: string icon: FileType | null asset: { - class: string + class: 'publicCredit' | 'privateCredit' + subClass: string } newInvestmentsStatus?: Record issuer: { @@ -793,7 +795,7 @@ export function getPoolsModule(inst: Centrifuge) { pool: { name: metadata.poolName, icon: metadata.poolIcon, - asset: { class: metadata.assetClass }, + asset: { class: metadata.assetClass, subClass: metadata.subAssetClass }, issuer: { name: metadata.issuerName, repName: metadata.issuerRepName,