diff --git a/.github/DOCS.md b/.github/DOCS.md index 89c6319..eb1848a 100644 --- a/.github/DOCS.md +++ b/.github/DOCS.md @@ -15,7 +15,6 @@ This workflow is responsible for versioning and preparing releases based on pull - **Alpha Versioning**: When a PR includes the `alpha` label, versions are created with an `-alpha.N` suffix (e.g., `1.2.3-alpha.1`, `1.2.3-alpha.2`). This continues until the alpha label is removed, at which point the version returns to standard semantic versioning. - ### 2. `build-test-report.yml` This workflow handles building, testing, and reporting. @@ -56,4 +55,3 @@ This workflow handles the publishing of releases to NPM. 2. **Draft Release Creation**: The `create-release.yml` workflow creates a draft release if `package.json` contains a new version on the `main` branch. 3. **Publishing**: When a release is published on GitHub (moved from draft to released), the `publish-release.yml` workflow is triggered. It builds the project and publishes it to NPM, ensuring the package is available for public use. - diff --git a/src/IndexerQueries/assetSnapshots.ts b/src/IndexerQueries/assetSnapshots.ts new file mode 100644 index 0000000..d4e095a --- /dev/null +++ b/src/IndexerQueries/assetSnapshots.ts @@ -0,0 +1,161 @@ +import { Currency, Rate } from '../utils/BigInt.js' + +export type AssetSnapshotFilter = Partial> + +export type AssetSnapshot = { + actualMaturityDate: string | undefined + actualOriginationDate: number | undefined + advanceRate: Rate | undefined + assetId: string + collateralValue: Currency | undefined + currentPrice: Currency | undefined + discountRate: Rate | undefined + faceValue: Currency | undefined + lossGivenDefault: Rate | undefined + name: string + outstandingDebt: Currency | undefined + outstandingInterest: Currency | undefined + outstandingPrincipal: Currency | undefined + outstandingQuantity: Currency | undefined + presentValue: Currency | undefined + probabilityOfDefault: Rate | undefined + status: string + sumRealizedProfitFifo: Currency | undefined + timestamp: string + totalRepaidInterest: Currency | undefined + totalRepaidPrincipal: Currency | undefined + totalRepaidUnscheduled: Currency | undefined + unrealizedProfitAtMarketPrice: Currency | undefined + valuationMethod: string | undefined +} + +export type SubqueryAssetSnapshots = { + assetSnapshots: { + nodes: { + asset: { + pool: { + currency: { + decimals: number + } + } + actualOriginationDate: number + advanceRate: string | undefined + collateralValue: string | undefined + discountRate: string | undefined + id: string + lossGivenDefault: string | undefined + actualMaturityDate: string | undefined + name: string + probabilityOfDefault: string | undefined + status: string + sumRealizedProfitFifo: string | undefined + unrealizedProfitAtMarketPrice: string | undefined + valuationMethod: string + notional: string | undefined + } + timestamp: string + assetId: string + presentValue: string | undefined + currentPrice: string | undefined + outstandingPrincipal: string | undefined + outstandingInterest: string | undefined + outstandingDebt: string | undefined + outstandingQuantity: string | undefined + totalRepaidPrincipal: string | undefined + totalRepaidInterest: string | undefined + totalRepaidUnscheduled: string | undefined + }[] + } +} + +export const assetSnapshotsPostProcess = (data: SubqueryAssetSnapshots): AssetSnapshot[] => { + return data!.assetSnapshots.nodes.map((tx) => { + const currencyDecimals = tx.asset.pool.currency.decimals + return { + ...tx, + timestamp: tx.timestamp, + assetId: tx.assetId, + actualMaturityDate: tx.asset.actualMaturityDate || undefined, + actualOriginationDate: tx.asset.actualOriginationDate || undefined, + advanceRate: new Rate(tx.asset.advanceRate || '0'), + collateralValue: tx.asset?.collateralValue + ? new Currency(tx.asset?.collateralValue, currencyDecimals) + : undefined, + currentPrice: tx.currentPrice ? new Currency(tx.currentPrice, currencyDecimals).mul(10n ** 18n) : undefined, + discountRate: tx.asset.discountRate ? new Rate(tx.asset.discountRate) : undefined, + faceValue: + tx.asset.notional && tx.outstandingQuantity + ? new Currency(tx.asset.notional, currencyDecimals).mul(BigInt(tx.outstandingQuantity)) + : undefined, + lossGivenDefault: tx.asset.lossGivenDefault ? new Rate(tx.asset.lossGivenDefault) : undefined, + name: tx.asset.name, + outstandingDebt: tx.outstandingDebt ? new Currency(tx.outstandingDebt, currencyDecimals) : undefined, + outstandingInterest: tx.outstandingInterest ? new Currency(tx.outstandingInterest, currencyDecimals) : undefined, + outstandingPrincipal: tx.outstandingPrincipal + ? new Currency(tx.outstandingPrincipal, currencyDecimals) + : undefined, + outstandingQuantity: tx.outstandingQuantity ? new Currency(tx.outstandingQuantity, 18) : undefined, + presentValue: tx.presentValue ? new Currency(tx.presentValue, currencyDecimals) : undefined, + probabilityOfDefault: tx.asset.probabilityOfDefault ? new Rate(tx.asset.probabilityOfDefault) : undefined, + status: tx.asset.status, + sumRealizedProfitFifo: tx.asset.sumRealizedProfitFifo + ? new Currency(tx.asset.sumRealizedProfitFifo, currencyDecimals) + : undefined, + totalRepaidInterest: tx.totalRepaidInterest ? new Currency(tx.totalRepaidInterest, currencyDecimals) : undefined, + totalRepaidPrincipal: tx.totalRepaidPrincipal + ? new Currency(tx.totalRepaidPrincipal, currencyDecimals) + : undefined, + totalRepaidUnscheduled: tx.totalRepaidUnscheduled + ? new Currency(tx.totalRepaidUnscheduled, currencyDecimals) + : undefined, + unrealizedProfitAtMarketPrice: tx.asset.unrealizedProfitAtMarketPrice + ? new Currency(tx.asset.unrealizedProfitAtMarketPrice, currencyDecimals) + : undefined, + valuationMethod: tx.asset.valuationMethod, + } + }) satisfies AssetSnapshot[] +} + +export const assetSnapshotsQuery = ` +query($filter: AssetSnapshotFilter) { + assetSnapshots( + first: 1000, + orderBy: TIMESTAMP_ASC, + filter: $filter + ) { + nodes { + assetId + timestamp + totalRepaidUnscheduled + outstandingInterest + totalRepaidInterest + currentPrice + outstandingPrincipal + totalRepaidPrincipal + outstandingQuantity + presentValue + outstandingDebt + asset { + pool { + currency { + decimals + } + } + actualMaturityDate + actualOriginationDate + advanceRate + collateralValue + discountRate + lossGivenDefault + name + notional + probabilityOfDefault + status + sumRealizedProfitFifo + unrealizedProfitAtMarketPrice + valuationMethod + } + } + } + } +` diff --git a/src/IndexerQueries/assetTransactions.ts b/src/IndexerQueries/assetTransactions.ts new file mode 100644 index 0000000..3ed2ad2 --- /dev/null +++ b/src/IndexerQueries/assetTransactions.ts @@ -0,0 +1,174 @@ +import { Currency, Price } from '../utils/BigInt.js' + +export type AssetTransactionFilter = Partial< + Record +> + +export type AssetTransaction = { + id: string + timestamp: Date + poolId: string + accountId: string + epochId: string + type: AssetTransactionType + amount: Currency + settlementPrice: Price | null + quantity: string | null + principalAmount: Currency | undefined + interestAmount: Currency | undefined + hash: string + realizedProfitFifo: Currency | undefined + unrealizedProfitAtMarketPrice: Currency | undefined + asset: { + id: string + metadata: string + type: AssetType + currentPrice: string | null + } + fromAsset?: { + id: string + metadata: string + type: AssetType + } + toAsset?: { + id: string + metadata: string + type: AssetType + } +} + +export enum AssetType { + OnchainCash = 'OnchainCash', + OffchainCash = 'OffchainCash', + Other = 'Other', +} + +export type AssetTransactionType = + | 'CREATED' + | 'PRICED' + | 'BORROWED' + | 'REPAID' + | 'CLOSED' + | 'CASH_TRANSFER' + | 'DEPOSIT_FROM_INVESTMENTS' + | 'WITHDRAWAL_FOR_REDEMPTIONS' + | 'WITHDRAWAL_FOR_FEES' + | 'INCREASE_DEBT' + | 'DECREASE_DEBT' + +type SubqueryAssetTransactions = { + assetTransactions: { + nodes: { + __typename?: 'AssetTransaction' + id: string + timestamp: string + poolId: string + accountId: string + hash: string + epochId: string + type: AssetTransactionType + amount: string | undefined + principalAmount: string | undefined + interestAmount: string | undefined + settlementPrice: string | null + quantity: string | null + realizedProfitFifo: string | undefined + pool: { + currency: { + decimals: number + } + } + asset: { + id: string + metadata: string + name: string + type: AssetType + sumRealizedProfitFifo: string + unrealizedProfitAtMarketPrice: string + currentPrice: string + } + fromAsset?: { + id: string + metadata: string + name: string + type: AssetType + } + toAsset?: { + id: string + metadata: string + name: string + type: AssetType + } + }[] + } +} + +export const assetTransactionsPostProcess = (data: SubqueryAssetTransactions): AssetTransaction[] => { + return ( + data.assetTransactions.nodes.map((tx) => { + const decimals = tx.pool.currency.decimals + return { + ...tx, + settlementPrice: tx.settlementPrice ? new Price(tx.settlementPrice) : null, + amount: new Currency(tx?.amount ?? 0n, decimals), + principalAmount: tx.principalAmount ? new Currency(tx.principalAmount, decimals) : undefined, + interestAmount: tx.interestAmount ? new Currency(tx.interestAmount, decimals) : undefined, + realizedProfitFifo: tx.realizedProfitFifo ? new Currency(tx.realizedProfitFifo, decimals) : undefined, + sumRealizedProfitFifo: tx.asset.sumRealizedProfitFifo + ? new Currency(tx.asset.sumRealizedProfitFifo, decimals) + : undefined, + unrealizedProfitAtMarketPrice: tx.asset.unrealizedProfitAtMarketPrice + ? new Currency(tx.asset.unrealizedProfitAtMarketPrice, decimals) + : undefined, + timestamp: new Date(`${tx.timestamp}+00:00`), + } + }) || ([] satisfies AssetTransaction[]) + ) +} + +export const assetTransactionsQuery = ` +query($filter: AssetTransactionFilter) { + assetTransactions( + orderBy: TIMESTAMP_ASC, + filter: $filter + ) { + nodes { + principalAmount + interestAmount + epochId + type + timestamp + amount + settlementPrice + quantity + hash + realizedProfitFifo + pool { + currency { + decimals + } + } + asset { + id + metadata + name + type + sumRealizedProfitFifo + unrealizedProfitAtMarketPrice + } + fromAsset { + id + metadata + name + type + } + toAsset { + id + metadata + name + type + } + } + } +} +` diff --git a/src/IndexerQueries/index.ts b/src/IndexerQueries/index.ts new file mode 100644 index 0000000..644fe70 --- /dev/null +++ b/src/IndexerQueries/index.ts @@ -0,0 +1,70 @@ +import { Entity } from '../Entity.js' +import { Centrifuge } from '../Centrifuge.js' +import { + poolFeeTransactionPostProcess, + poolFeeTransactionQuery, + PoolFeeTransactionFilter, +} from './poolFeeTransactions.js' +import { poolSnapshotsPostProcess, poolSnapshotsQuery, PoolSnapshotFilter } from './poolSnapshots.js' +import { poolFeeSnapshotsPostProcess, PoolFeeSnapshotFilter, poolFeeSnapshotQuery } from './poolFeeSnapshots.js' +import { Pool } from '../Pool.js' +import { trancheSnapshotsPostProcess, trancheSnapshotsQuery, TrancheSnapshotFilter } from './trancheSnapshots.js' +import { assetTransactionsPostProcess, assetTransactionsQuery, AssetTransactionFilter } from './assetTransactions.js' +import { + investorTransactionsPostProcess, + investorTransactionsQuery, + InvestorTransactionFilter, +} from './investorTransactions.js' +import { AssetSnapshotFilter, assetSnapshotsPostProcess, assetSnapshotsQuery } from './assetSnapshots.js' +import { + trancheCurrencyBalancePostProcessor, + trancheCurrencyBalanceQuery, + TrancheCurrencyBalanceFilter, + TrancheBalanceFilter, + CurrencyBalanceFilter, +} from './trancheCurrencyBalance.js' + +export class IndexerQueries extends Entity { + constructor( + centrifuge: Centrifuge, + public pool: Pool + ) { + super(centrifuge, ['indexerQueries ', pool.id]) + } + + poolFeeSnapshotsQuery(filter?: PoolFeeSnapshotFilter) { + return this._root._queryIndexer(poolFeeSnapshotQuery, { filter }, poolFeeSnapshotsPostProcess) + } + + poolSnapshotsQuery(filter?: PoolSnapshotFilter) { + return this._root._queryIndexer(poolSnapshotsQuery, { filter }, poolSnapshotsPostProcess) + } + + trancheSnapshotsQuery(filter?: TrancheSnapshotFilter) { + return this._root._queryIndexer(trancheSnapshotsQuery, { filter }, trancheSnapshotsPostProcess) + } + + investorTransactionsQuery(filter?: InvestorTransactionFilter) { + return this._root._queryIndexer(investorTransactionsQuery, { filter }, investorTransactionsPostProcess) + } + + assetTransactionsQuery(filter?: AssetTransactionFilter) { + return this._root._queryIndexer(assetTransactionsQuery, { filter }, assetTransactionsPostProcess) + } + + poolFeeTransactionsQuery(filter?: PoolFeeTransactionFilter) { + return this._root._queryIndexer(poolFeeTransactionQuery, { filter }, poolFeeTransactionPostProcess) + } + + assetSnapshotsQuery(filter?: AssetSnapshotFilter) { + return this._root._queryIndexer(assetSnapshotsQuery, { filter }, assetSnapshotsPostProcess) + } + + trancheCurrencyBalanceQuery(filterTranches?: TrancheBalanceFilter, filterCurrencies?: CurrencyBalanceFilter) { + return this._root._queryIndexer( + trancheCurrencyBalanceQuery, + { filterTranches, filterCurrencies }, + trancheCurrencyBalancePostProcessor + ) + } +} diff --git a/src/IndexerQueries/investorTransactions.ts b/src/IndexerQueries/investorTransactions.ts new file mode 100644 index 0000000..905c791 --- /dev/null +++ b/src/IndexerQueries/investorTransactions.ts @@ -0,0 +1,119 @@ +import { Currency, Price } from '../utils/BigInt.js' + +export type InvestorTransactionFilter = Partial< + Record +> + +export type InvestorTransaction = { + id: string + poolId: string + timestamp: Date + accountId: string + trancheId: string + epochNumber: number + type: SubqueryInvestorTransactionType + currencyAmount: Currency + tokenAmount: Currency + tokenPrice: Price + transactionFee: Currency + chainId: number | 'centrifuge' + evmAddress?: string + hash: string +} + +export type SubqueryInvestorTransactionType = + | 'INVEST_ORDER_UPDATE' + | 'REDEEM_ORDER_UPDATE' + | 'INVEST_ORDER_CANCEL' + | 'REDEEM_ORDER_CANCEL' + | 'INVEST_EXECUTION' + | 'REDEEM_EXECUTION' + | 'TRANSFER_IN' + | 'TRANSFER_OUT' + | 'INVEST_COLLECT' + | 'REDEEM_COLLECT' + | 'INVEST_LP_COLLECT' + | 'REDEEM_LP_COLLECT' + +type SubqueryInvestorTransactions = { + investorTransactions: { + nodes: { + id: string + poolId: string + timestamp: string + accountId: string + account: { + chainId: string + evmAddress?: string + } + trancheId: string // poolId-trancheId + pool: { + currency: { + decimals: number + } + } + epochNumber: number + type: SubqueryInvestorTransactionType + hash: string + currencyAmount?: string | null + tokenAmount?: string | null + tokenPrice?: string | null + transactionFee?: string | null + }[] + } +} + +export const investorTransactionsPostProcess = (data: SubqueryInvestorTransactions): InvestorTransaction[] => { + return data.investorTransactions.nodes.map((tx) => { + const currencyDecimals = tx.pool.currency.decimals + return { + id: tx.id, + poolId: tx.poolId, + timestamp: new Date(tx.timestamp), + accountId: tx.accountId, + chainId: tx.account.chainId === '0' ? 'centrifuge' : Number(tx.account.chainId), + evmAddress: tx.account.evmAddress, + trancheId: tx.trancheId.split('-')[1] ?? '', + epochNumber: tx.epochNumber, + type: tx.type as SubqueryInvestorTransactionType, + currencyAmount: new Currency(tx?.currencyAmount || 0n, currencyDecimals), + tokenAmount: new Currency(tx?.tokenAmount || 0n, currencyDecimals), + tokenPrice: new Price(tx?.tokenPrice ?? 0n), + transactionFee: new Currency(tx?.transactionFee ?? 0n, currencyDecimals), + hash: tx.hash, + } satisfies InvestorTransaction + }) +} + +export const investorTransactionsQuery = ` +query($filter: InvestorTransactionFilter) { + investorTransactions( + orderBy: TIMESTAMP_ASC, + filter: $filter + ) { + nodes { + id + timestamp + accountId + account { + chainId + evmAddress + } + pool { + currency { + decimals + } + } + hash + poolId + trancheId + epochNumber + type + tokenAmount + currencyAmount + tokenPrice + transactionFee + } + } +} +` diff --git a/src/queries/poolFeeSnapshots.ts b/src/IndexerQueries/poolFeeSnapshots.ts similarity index 100% rename from src/queries/poolFeeSnapshots.ts rename to src/IndexerQueries/poolFeeSnapshots.ts diff --git a/src/IndexerQueries/poolFeeTransactions.ts b/src/IndexerQueries/poolFeeTransactions.ts new file mode 100644 index 0000000..17872ab --- /dev/null +++ b/src/IndexerQueries/poolFeeTransactions.ts @@ -0,0 +1,81 @@ +import { Currency } from '../utils/BigInt.js' + +export type PoolFeeTransactionFilter = Partial< + Record +> + +export type PoolFeeTransaction = { + feeId: string + type: SubqueryPoolFeeTransactionType + timestamp: string + blockNumber: string + epochNumber: number + amount: Currency +} + +export type SubqueryPoolFeeTransactionType = + | 'PROPOSED' + | 'ADDED' + | 'REMOVED' + | 'CHARGED' + | 'UNCHARGED' + | 'ACCRUED' + | 'PAID' + +export type SubqueryPoolFeeTransaction = { + poolFeeTransactions: { + nodes: { + id: string + type: SubqueryPoolFeeTransactionType + timestamp: string + blockNumber: string + epochNumber: number + amount: string + poolFee: { + feeId: string + pool: { + currency: { + decimals: number + } + } + } + }[] + } +} + +export function poolFeeTransactionPostProcess(data: SubqueryPoolFeeTransaction): PoolFeeTransaction[] { + return data.poolFeeTransactions.nodes.map((tx) => ({ + feeId: tx.id, + type: tx.type as PoolFeeTransaction['type'], + timestamp: tx.timestamp, + blockNumber: tx.blockNumber, + epochNumber: tx.epochNumber, + amount: new Currency(tx.amount, tx.poolFee.pool.currency.decimals), + })) +} + +export const poolFeeTransactionQuery = ` +query($filter: PoolFeeTransactionFilter) { + poolFeeTransactions( + orderBy: TIMESTAMP_ASC, + filter: $filter + ) { + nodes { + id + type + timestamp + blockNumber + epochNumber + amount + poolFee { + feeId + pool { + currency { + decimals + } + } + } + } + } +} +` diff --git a/src/queries/poolSnapshots.ts b/src/IndexerQueries/poolSnapshots.ts similarity index 100% rename from src/queries/poolSnapshots.ts rename to src/IndexerQueries/poolSnapshots.ts diff --git a/src/IndexerQueries/trancheCurrencyBalance.ts b/src/IndexerQueries/trancheCurrencyBalance.ts new file mode 100644 index 0000000..c2e3362 --- /dev/null +++ b/src/IndexerQueries/trancheCurrencyBalance.ts @@ -0,0 +1,177 @@ +import { Currency } from '../utils/BigInt.js' + +export type TrancheCurrencyBalanceFilter = + | Partial> + | Partial> + +export type TrancheBalanceFilter = Partial> +export type CurrencyBalanceFilter = Partial> + +export function trancheCurrencyBalancePostProcessor(data: SubqueryTrancheBalances & SubqueryCurrencyBalances) { + const currencyBalancesByAccountId: Record = {} + data!.currencyBalances.nodes.forEach((balance) => { + const trancheId = balance.currency.trancheId?.split('-')[1] ?? '' + currencyBalancesByAccountId[`${balance.accountId}-${trancheId}`] = balance + }) + + return data!.trancheBalances.nodes.map((balance) => { + const currencyDecimals = balance.pool.currency.decimals + return { + accountId: balance.accountId, + chainId: balance.account?.chainId !== '0' ? Number(balance.account?.chainId) : 'centrifuge', + trancheId: balance.trancheId.split('-')[1] ?? '', + evmAddress: balance.account?.evmAddress, + balance: new Currency( + currencyBalancesByAccountId[`${balance.accountId}-${balance.trancheId.split('-')[1]}`]?.amount ?? 0, + currencyDecimals + ), + pendingInvestCurrency: new Currency(balance.pendingInvestCurrency, currencyDecimals), + claimableTrancheTokens: new Currency(balance.claimableTrancheTokens, currencyDecimals), + sumClaimedTrancheTokens: new Currency(balance.sumClaimedTrancheTokens, currencyDecimals), + pendingRedeemTrancheTokens: new Currency(balance.pendingRedeemTrancheTokens, currencyDecimals), + claimableCurrency: new Currency(balance.claimableCurrency, currencyDecimals), + sumClaimedCurrency: new Currency(balance.sumClaimedCurrency, currencyDecimals), + } satisfies TrancheCurrencyBalance + }) +} + +export type TrancheCurrencyBalance = { + accountId: string + chainId: number | 'centrifuge' + trancheId: string + evmAddress?: string + balance: Currency + pendingInvestCurrency: Currency + claimableTrancheTokens: Currency + sumClaimedTrancheTokens: Currency + pendingRedeemTrancheTokens: Currency + claimableCurrency: Currency + sumClaimedCurrency: Currency +} + +export type SubqueryTrancheBalances = { + trancheBalances: { + nodes: { + __typename?: 'TrancheBalances' + id: string + timestamp: string + accountId: string + account: { + chainId: string + evmAddress?: string + } + pool: { + currency: { + decimals: number + } + } + poolId: string + trancheId: string + pendingInvestCurrency: string + claimableTrancheTokens: string + sumClaimedTrancheTokens: string + pendingRedeemTrancheTokens: string + claimableCurrency: string + sumClaimedCurrency: string + }[] + } +} + +export type SubqueryCurrencyBalances = { + currencyBalances: { + nodes: { + __typename?: 'CurrencyBalances' + id: string + accountId: string + currency: { + trancheId: string | null + } + account: { + chainId: string + evmAddress?: string + } + amount: string + }[] + } +} + +export const trancheBalancesQuery = ` +query($filter: TrancheBalanceFilter) { + trancheBalances(filter: $filter) { + nodes { + accountId + trancheId + account { + chainId + evmAddress + } + pool { + currency { + decimals + } + } + pendingInvestCurrency + claimableTrancheTokens + sumClaimedTrancheTokens + pendingRedeemTrancheTokens + claimableCurrency + sumClaimedCurrency + } + } +}` + +export const currencyBalancesQuery = ` +query($filter: CurrencyBalanceFilter) { + currencyBalances(filter: $filter) { + nodes { + accountId + account { + chainId + evmAddress + } + currency { + trancheId + } + amount + } + } +}` + +export const trancheCurrencyBalanceQuery = ` +query($filterTranches: TrancheBalanceFilter, $filterCurrencies: CurrencyBalanceFilter) { + trancheBalances(filter: $filterTranches) { + nodes { + accountId + trancheId + account { + chainId + evmAddress + } + pool { + currency { + decimals + } + } + pendingInvestCurrency + claimableTrancheTokens + sumClaimedTrancheTokens + pendingRedeemTrancheTokens + claimableCurrency + sumClaimedCurrency + } + } + currencyBalances(filter: $filterCurrencies) { + nodes { + accountId + account { + chainId + evmAddress + } + currency { + trancheId + } + amount + } + } +} +` diff --git a/src/queries/trancheSnapshots.ts b/src/IndexerQueries/trancheSnapshots.ts similarity index 97% rename from src/queries/trancheSnapshots.ts rename to src/IndexerQueries/trancheSnapshots.ts index 7fd85d2..a59a979 100644 --- a/src/queries/trancheSnapshots.ts +++ b/src/IndexerQueries/trancheSnapshots.ts @@ -76,7 +76,7 @@ export type SubqueryTrancheSnapshot = { export type TrancheSnapshot = { id: string - price: Price | null + price: Price timestamp: string trancheId: string poolId: string @@ -126,7 +126,7 @@ export function trancheSnapshotsPostProcess(data: SubqueryTrancheSnapshot): { [d symbol: poolCurrency.symbol, }, }, - price: tranche.tokenPrice ? new Price(tranche.tokenPrice) : null, + price: new Price(tranche.tokenPrice ?? 0), tokenSupply: new Token(tranche.tokenSupply, poolCurrency.decimals), fulfilledInvestOrders: new Currency(tranche.sumFulfilledInvestOrdersByPeriod, poolCurrency.decimals), fulfilledRedeemOrders: new Currency(tranche.sumFulfilledRedeemOrdersByPeriod, poolCurrency.decimals), diff --git a/src/Reports/Processor.test.ts b/src/Reports/Processor.test.ts index ebc78ff..0bb9428 100644 --- a/src/Reports/Processor.test.ts +++ b/src/Reports/Processor.test.ts @@ -1,13 +1,24 @@ import { expect } from 'chai' import { processor } from './Processor.js' import { mockPoolSnapshots } from '../tests/mocks/mockPoolSnapshots.js' +import { mockFeeTransactions } from '../tests/mocks/mockPoolFeeTransactions.js' import { mockTrancheSnapshots } from '../tests/mocks/mockTrancheSnapshots.js' import { mockPoolFeeSnapshots } from '../tests/mocks/mockPoolFeeSnapshot.js' import { mockPoolMetadata } from '../tests/mocks/mockPoolMetadata.js' -import { PoolSnapshot } from '../queries/poolSnapshots.js' -import { Currency } from '../utils/BigInt.js' -import { PoolFeeSnapshot, PoolFeeSnapshotsByDate } from '../queries/poolFeeSnapshots.js' -import { ProfitAndLossReportPrivateCredit, ProfitAndLossReportPublicCredit } from './types.js' +import { mockInvestorTransactions } from '../tests/mocks/mockInvestorTransactions.js' +import { mockAssetTransactions } from '../tests/mocks/mockAssetTransactions.js' +import { mockAssetSnapshots } from '../tests/mocks/mockAssetSnapshots.js' +import { mockInvestorCurrencyBalances } from '../tests/mocks/mockInvestorCurrencyBalance.js' +import { PoolSnapshot } from '../IndexerQueries/poolSnapshots.js' +import { Currency, Price, Token } from '../utils/BigInt.js' +import { PoolFeeSnapshot, PoolFeeSnapshotsByDate } from '../IndexerQueries/poolFeeSnapshots.js' +import { + AssetTransactionReportFilter, + ProfitAndLossReportPrivateCredit, + ProfitAndLossReportPublicCredit, +} from '../types/reports.js' +import { InvestorTransaction } from '../IndexerQueries/investorTransactions.js' +import { AssetSnapshot } from '../IndexerQueries/assetSnapshots.js' describe('Processor', () => { describe('balanceSheet processor', () => { @@ -226,6 +237,7 @@ describe('Processor', () => { expect(result?.[0]).to.have.property('realizedPL') }) }) + describe('profit and loss processor', () => { const mockPLPoolSnapshots: PoolSnapshot[] = [ { @@ -277,7 +289,18 @@ describe('Processor', () => { ) }) - it('should process private credit pool correctly', () => { + it('should handle undefined metadata', () => { + const result = processor.profitAndLoss({ + poolSnapshots: mockPLPoolSnapshots, + poolFeeSnapshots: mockPLFeeSnapshots, + metadata: undefined, + }) + expect(result).to.have.lengthOf(2) + const firstDay = result[0] + expect(firstDay?.subtype).to.equal('privateCredit') // should default to privateCredit + }) + + it('should process private credit pool data correctly', () => { const result = processor.profitAndLoss({ poolSnapshots: mockPLPoolSnapshots, poolFeeSnapshots: mockPLFeeSnapshots, @@ -347,6 +370,340 @@ describe('Processor', () => { expect(result[0]?.fees?.[0]?.timestamp.slice(0, 10)).to.equal('2024-01-01') }) }) + + describe('investor transactions processor', () => { + it('should return empty array when no transactions found', () => { + expect(processor.investorTransactions({ investorTransactions: [] })).to.deep.equal([]) + }) + + it('should process investor transactions correctly without filters', () => { + const result = processor.investorTransactions({ + investorTransactions: mockInvestorTransactions, + }) + + expect(result).to.have.lengthOf(2) + const firstTx = result[0] + + expect(firstTx?.timestamp.slice(0, 10)).to.equal('2024-01-01') + expect(firstTx?.chainId).to.equal(1) + expect(firstTx?.account).to.equal('0x123a') + expect(firstTx?.epoch).to.equal('1') + expect(firstTx?.transactionType).to.equal('INVEST_ORDER_UPDATE') + expect(firstTx?.currencyAmount.toFloat()).to.equal(1.0) + expect(firstTx?.trancheTokenAmount.toFloat()).to.equal(0.9) + expect(firstTx?.price.toString()).to.equal('1100000000000000000') + expect(firstTx?.transactionHash).to.equal('0xabc') + }) + + it('should filter by tokenId', () => { + const mockInvestorTransactionsWithJunior = [ + ...mockInvestorTransactions, + { + ...mockInvestorTransactions[0], + trancheId: 'junior', + } as InvestorTransaction, + ] + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactionsWithJunior, + }, + { tokenId: 'senior' } + ) + expect(result).to.have.lengthOf(2) + expect(result[0]?.trancheTokenId).to.equal('senior') + }) + it('should filter by address', () => { + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactions, + }, + { address: '0x123a' } + ) + expect(result).to.have.lengthOf(1) + expect(result[0]?.account).to.equal('0x123a') + }) + it('should filter by network', () => { + const mockInvestorTransactionsWithNetwork = [ + ...mockInvestorTransactions, + { + ...mockInvestorTransactions[0], + chainId: 2, + } as InvestorTransaction, + ] + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactionsWithNetwork, + }, + { network: 2 } + ) + expect(result).to.have.lengthOf(1) + expect(result[0]?.chainId).to.equal(2) + }) + it('should filter by all networks', () => { + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactions, + }, + { network: 'all' } + ) + expect(result).to.have.lengthOf(2) + }) + it('should filter by centrifuge network', () => { + const mockInvestorTransactionsWithCentrifuge = [ + ...mockInvestorTransactions, + { + ...mockInvestorTransactions[0], + chainId: 'centrifuge', + } as InvestorTransaction, + ] + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactionsWithCentrifuge, + }, + { network: 'centrifuge' } + ) + expect(result).to.have.lengthOf(1) + expect(result[0]?.chainId).to.equal('centrifuge') + }) + it('should filter by transaction type', () => { + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactions, + }, + { transactionType: 'orders' } + ) + expect(result).to.have.lengthOf(1) + expect(result[0]?.transactionType).to.equal('INVEST_ORDER_UPDATE') + }) + it('should filter by network and transaction type', () => { + const mockInvestorTransactionsWithNetworkAndOrders = [ + ...mockInvestorTransactions, + { + ...mockInvestorTransactions[0], + chainId: 2, + } as InvestorTransaction, + ] + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactionsWithNetworkAndOrders, + }, + { network: 1, transactionType: 'orders' } + ) + expect(result).to.have.lengthOf(1) + expect(result[0]?.chainId).to.equal(1) + expect(result[0]?.transactionType).to.equal('INVEST_ORDER_UPDATE') + }) + it('should return an empty array when no filters match', () => { + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactions, + }, + { network: 2, transactionType: 'executions' } + ) + expect(result).to.deep.equal([]) + }) + }) + + describe('asset transactions processor', () => { + it('should return empty array when no transactions found', () => { + expect(processor.assetTransactions({ assetTransactions: [] })).to.deep.equal([]) + }) + it('should process asset transactions correctly without filters', () => { + const result = processor.assetTransactions({ + assetTransactions: mockAssetTransactions, + }) + expect(result).to.have.lengthOf(3) + }) + it('should filter by assetId', () => { + const result = processor.assetTransactions( + { + assetTransactions: mockAssetTransactions, + }, + { assetId: '1' } + ) + expect(result).to.have.lengthOf(2) + }) + it('should filter by transaction type', () => { + const types: { type: AssetTransactionReportFilter['transactionType']; expected: number }[] = [ + { type: 'created', expected: 0 }, + { type: 'financed', expected: 1 }, + { type: 'repaid', expected: 1 }, + { type: 'priced', expected: 0 }, + { type: 'closed', expected: 0 }, + { type: 'cashTransfer', expected: 1 }, + ] + for (const { type, expected } of types) { + const result = processor.assetTransactions( + { + assetTransactions: mockAssetTransactions, + }, + { transactionType: type } + ) + expect(result).to.have.lengthOf(expected) + } + }) + }) + + describe('fee transactions processor', () => { + it('should return empty array when no transactions found', () => { + expect(processor.feeTransactions({ poolFeeTransactions: [] })).to.deep.equal([]) + }) + it('should process fee transactions correctly', () => { + const result = processor.feeTransactions({ poolFeeTransactions: mockFeeTransactions }) + expect(result).to.have.lengthOf(2) + }) + it('should filter by transaction type all', () => { + const result = processor.feeTransactions({ poolFeeTransactions: mockFeeTransactions }, { transactionType: 'all' }) + expect(result).to.have.lengthOf(2) + }) + it('should filter by transaction type accrued', () => { + const result = processor.feeTransactions( + { poolFeeTransactions: mockFeeTransactions }, + { transactionType: 'accrued' } + ) + expect(result).to.have.lengthOf(1) + }) + it('should filter by transaction type paid', () => { + const result = processor.feeTransactions( + { poolFeeTransactions: mockFeeTransactions }, + { transactionType: 'paid' } + ) + expect(result).to.have.lengthOf(1) + }) + it('should return an empty array when no filters match', () => { + const result = processor.feeTransactions( + { poolFeeTransactions: mockFeeTransactions }, + { transactionType: 'directChargeMade' } + ) + expect(result).to.deep.equal([]) + }) + }) + + describe('token price processor', () => { + it('should return empty array when no snapshots found', () => { + expect(processor.tokenPrice({ trancheSnapshots: {} })).to.deep.equal([]) + }) + it('should process token price correctly', () => { + const result = processor.tokenPrice({ trancheSnapshots: mockTrancheSnapshots }) + expect(result).to.have.lengthOf(2) + expect(result[0]?.tranches[0]?.price?.toDecimal().toString()).to.equal('1') + expect(result[0]?.tranches[0]?.id).to.equal('senior') + }) + it('should group by month', () => { + const result = processor.tokenPrice({ trancheSnapshots: mockTrancheSnapshots }, { groupBy: 'month' }) + expect(result).to.have.lengthOf(1) + }) + }) + + describe('asset list processor', () => { + it('should return empty array when no snapshots found', () => { + expect(processor.assetList({ assetSnapshots: [], metadata: undefined })).to.deep.equal([]) + }) + it('should process asset list correctly', () => { + const result = processor.assetList({ assetSnapshots: mockAssetSnapshots, metadata: mockPoolMetadata }) + expect(result).to.have.lengthOf(2) + }) + it('should filter by status ongoing', () => { + const result = processor.assetList( + { assetSnapshots: mockAssetSnapshots, metadata: mockPoolMetadata }, + { status: 'ongoing' } + ) + expect(result).to.have.lengthOf(2) + }) + it('should filter by status repaid', () => { + const result = processor.assetList( + { assetSnapshots: mockAssetSnapshots, metadata: mockPoolMetadata }, + { status: 'repaid' } + ) + expect(result).to.have.lengthOf(0) + }) + it('should filter by status overdue', () => { + const mockAssetSnapshotsOverdue = [ + ...mockAssetSnapshots, + { ...mockAssetSnapshots[0], actualMaturityDate: '2023-01-01' } as AssetSnapshot, + ] + const result = processor.assetList( + { assetSnapshots: mockAssetSnapshotsOverdue, metadata: mockPoolMetadata }, + { status: 'overdue' } + ) + expect(result).to.have.lengthOf(1) + }) + it('should return the correct data for private credit pools', () => { + const result = processor.assetList( + { assetSnapshots: mockAssetSnapshots, metadata: mockPoolMetadata }, + { status: 'ongoing' } + ) + expect(result).to.have.lengthOf(2) + expect(result?.[0]).to.have.property('outstandingPrincipal') + expect(result?.[0]).to.have.property('outstandingInterest') + expect(result?.[0]).to.have.property('repaidPrincipal') + expect(result?.[0]).to.have.property('repaidInterest') + expect(result?.[0]).to.have.property('repaidUnscheduled') + expect(result?.[0]).to.have.property('originationDate') + expect(result?.[0]).to.have.property('maturityDate') + expect(result?.[0]).to.have.property('valuationMethod') + expect(result?.[0]).to.have.property('advanceRate') + expect(result?.[0]).to.have.property('collateralValue') + expect(result?.[0]).to.have.property('probabilityOfDefault') + expect(result?.[0]).to.have.property('lossGivenDefault') + expect(result?.[0]).to.have.property('discountRate') + }) + it('should return the correct data for public credit pools', () => { + const result = processor.assetList( + { + assetSnapshots: mockAssetSnapshots, + metadata: { + ...mockPoolMetadata, + pool: { ...mockPoolMetadata.pool, asset: { ...mockPoolMetadata.pool.asset, class: 'Public credit' } }, + }, + }, + { status: 'ongoing' } + ) + expect(result).to.have.lengthOf(2) + expect(result?.[0]).to.have.property('faceValue') + expect(result?.[0]).to.have.property('outstandingQuantity') + expect(result?.[0]).to.have.property('currentPrice') + expect(result?.[0]).to.have.property('unrealizedProfit') + expect(result?.[0]).to.have.property('realizedProfit') + }) + }) + + describe('investor list processor', () => { + it('should return empty array when no balances found', () => { + expect(processor.investorList({ trancheCurrencyBalance: [] })).to.deep.equal([]) + }) + it('should filter by network', () => { + const result = processor.investorList({ trancheCurrencyBalance: mockInvestorCurrencyBalances }, { network: 1 }) + expect(result).to.have.lengthOf(1) + }) + it('should filter by centrifuge network', () => { + const result = processor.investorList( + { trancheCurrencyBalance: mockInvestorCurrencyBalances }, + { network: 'centrifuge' } + ) + expect(result).to.have.lengthOf(1) + }) + it('should filter by address', () => { + const result = processor.investorList( + { trancheCurrencyBalance: mockInvestorCurrencyBalances }, + { address: '0x123' } + ) + expect(result).to.have.lengthOf(1) + }) + it('should filter by trancheId', () => { + const result = processor.investorList( + { trancheCurrencyBalance: mockInvestorCurrencyBalances }, + { trancheId: 'tranche-1' } + ) + expect(result).to.have.lengthOf(2) + const result2 = processor.investorList( + { trancheCurrencyBalance: mockInvestorCurrencyBalances }, + { trancheId: 'tranche-2' } + ) + expect(result2).to.have.lengthOf(0) + }) + }) + describe('applyGrouping', () => { const applyGrouping = processor['applyGrouping'] const mockData = [ @@ -387,12 +744,29 @@ describe('Processor', () => { ] expect(grouped).to.deep.equal(expected) }) - it('should aggregate values when strategy is sum and grouping is month', () => { - const extendedMockData = [...mockData, { a: Currency.fromFloat(30, 6), timestamp: '2024-02-01' }] + it('should aggregate values when strategy is sum and grouping is month (Token)', () => { + const extendedMockData = [ + { a: Token.fromFloat(10, 6), timestamp: '2024-01-01' }, + { a: Token.fromFloat(20, 6), timestamp: '2024-01-02' }, + { a: Token.fromFloat(30, 6), timestamp: '2024-02-01' }, + ] const grouped = applyGrouping(extendedMockData, 'month', 'sum') const expected = [ - { a: Currency.fromFloat(30, 6), timestamp: '2024-01-01' }, - { a: Currency.fromFloat(30, 6), timestamp: '2024-02-01' }, + { a: Token.fromFloat(30, 6), timestamp: '2024-01-02' }, + { a: Token.fromFloat(30, 6), timestamp: '2024-02-01' }, + ] + expect(grouped).to.deep.equal(expected) + }) + it('should aggregate values when strategy is sum and grouping is month (Price)', () => { + const extendedMockData = [ + { a: Price.fromFloat(10), timestamp: '2024-01-01' }, + { a: Price.fromFloat(20), timestamp: '2024-01-02' }, + { a: Price.fromFloat(30), timestamp: '2024-02-01' }, + ] + const grouped = applyGrouping(extendedMockData, 'month', 'sum') + const expected = [ + { a: Price.fromFloat(30), timestamp: '2024-01-02' }, + { a: Price.fromFloat(30), timestamp: '2024-02-01' }, ] expect(grouped).to.deep.equal(expected) }) diff --git a/src/Reports/Processor.ts b/src/Reports/Processor.ts index 5de11e8..85984e7 100644 --- a/src/Reports/Processor.ts +++ b/src/Reports/Processor.ts @@ -1,4 +1,6 @@ -import { Currency } from '../utils/BigInt.js' +import { AssetTransaction } from '../IndexerQueries/assetTransactions.js' +import { InvestorTransaction } from '../IndexerQueries/investorTransactions.js' +import { Currency, Price, Rate, Token } from '../utils/BigInt.js' import { groupByPeriod } from '../utils/date.js' import { BalanceSheetData, @@ -8,7 +10,28 @@ import { ProfitAndLossReport, ProfitAndLossData, ReportFilter, -} from './types.js' + InvestorTransactionsData, + InvestorTransactionsReport, + InvestorTransactionsReportFilter, + AssetTransactionReport, + AssetTransactionsData, + AssetTransactionReportFilter, + FeeTransactionsData, + FeeTransactionReportFilter, + FeeTransactionReport, + TokenPriceReport, + TokenPriceReportFilter, + TokenPriceData, + AssetListReport, + AssetListReportFilter, + AssetListData, + AssetListReportPublicCredit, + AssetListReportPrivateCredit, + InvestorListData, + InvestorListReportFilter, + InvestorListReport, +} from '../types/reports.js' +import { PoolFeeTransaction } from '../IndexerQueries/poolFeeTransactions.js' export class Processor { /** @@ -17,7 +40,8 @@ export class Processor { * @param filter Optional filtering and grouping options * @returns Processed balance sheet report at the end of each period */ - balanceSheet(data: BalanceSheetData, filter?: ReportFilter): BalanceSheetReport[] { + balanceSheet(data: BalanceSheetData, filter?: Omit): BalanceSheetReport[] { + if (!data.poolSnapshots?.length) return [] const items: BalanceSheetReport[] = data?.poolSnapshots?.map((snapshot) => { const tranches = data.trancheSnapshots[this.getDateKey(snapshot.timestamp)] ?? [] if (tranches.length === 0) throw new Error('No tranches found for snapshot') @@ -52,7 +76,8 @@ export class Processor { * @param filter Optional filtering and grouping options * @returns Processed cashflow report at the end of each period */ - cashflow(data: CashflowData, filter?: ReportFilter): CashflowReport[] { + cashflow(data: CashflowData, filter?: Omit): CashflowReport[] { + if (!data.poolSnapshots?.length) return [] const subtype = data.metadata?.pool.asset.class === 'Public credit' ? 'publicCredit' : 'privateCredit' const items: CashflowReport[] = data.poolSnapshots.map((day) => { const poolFees = @@ -99,7 +124,8 @@ export class Processor { * @param filter Optional filtering and grouping options * @returns Processed profit and loss report at the end of each period */ - profitAndLoss(data: ProfitAndLossData, filter?: ReportFilter): ProfitAndLossReport[] { + profitAndLoss(data: ProfitAndLossData, filter?: Omit): ProfitAndLossReport[] { + if (!data.poolSnapshots?.length) return [] const items: ProfitAndLossReport[] = data.poolSnapshots.map((day) => { const subtype = data.metadata?.pool.asset.class === 'Public credit' ? 'publicCredit' : 'privateCredit' const profitAndLossFromAsset = @@ -139,6 +165,252 @@ export class Processor { return this.applyGrouping(items, filter?.groupBy, 'sum') } + investorTransactions( + data: InvestorTransactionsData, + filter?: Omit + ): InvestorTransactionsReport[] { + if (!data.investorTransactions?.length) return [] + + const validTypes: Set = new Set([ + 'INVEST_ORDER_UPDATE', + 'REDEEM_ORDER_UPDATE', + 'INVEST_ORDER_CANCEL', + 'REDEEM_ORDER_CANCEL', + 'INVEST_EXECUTION', + 'REDEEM_EXECUTION', + 'INVEST_COLLECT', + 'REDEEM_COLLECT', + 'INVEST_LP_COLLECT', + 'REDEEM_LP_COLLECT', + 'TRANSFER_IN', + 'TRANSFER_OUT', + ]) + + const filterAddress = filter?.address?.toLowerCase() + const filterNetwork = filter?.network === 'all' ? null : filter?.network + + return data.investorTransactions.reduce((acc, day) => { + const typeMatches = + (filter?.transactionType === 'orders' && day.type.includes('ORDER')) || + (filter?.transactionType === 'executions' && day.type.includes('EXECUTION')) || + (filter?.transactionType === 'transfers' && (day.type.includes('COLLECT') || day.type.includes('TRANSFER'))) || + ((!filter?.transactionType || filter?.transactionType === 'all') && validTypes.has(day.type)) + + const filterMatches = + (!filterNetwork || filterNetwork === day.chainId) && + (!filter?.tokenId || filter.tokenId === day.trancheId) && + (!filterAddress || + day.accountId.toLowerCase() === filterAddress || + day.evmAddress?.toLowerCase() === filterAddress) + + if (typeMatches && filterMatches) { + acc.push({ + type: 'investorTransactions', + timestamp: day.timestamp.toISOString(), + chainId: day.chainId, + account: day.evmAddress ?? day.accountId, + epoch: day.epochNumber?.toString() ?? '', + transactionType: day.type, + currencyAmount: day.currencyAmount, + trancheTokenAmount: day.tokenAmount, + trancheTokenId: day.trancheId, + price: day.tokenPrice ?? '', + transactionHash: day.hash, + }) + } + + return acc + }, []) + } + + assetTransactions( + data: AssetTransactionsData, + filter?: Omit + ): AssetTransactionReport[] { + if (!data.assetTransactions?.length) return [] + const typeMap: Record< + NonNullable>, + AssetTransaction['type'] + > = { + created: 'CREATED', + financed: 'BORROWED', + repaid: 'REPAID', + priced: 'PRICED', + closed: 'CLOSED', + cashTransfer: 'CASH_TRANSFER', + } as const + + return data.assetTransactions.reduce((acc, tx) => { + const typeMatches = + !filter?.transactionType || filter.transactionType === 'all' || tx.type === typeMap[filter.transactionType] + + const assetMatches = !filter?.assetId || filter.assetId === tx.asset.id.split('-')[1] + + if (typeMatches && assetMatches) { + acc.push({ + type: 'assetTransactions', + timestamp: tx.timestamp.toISOString(), + assetId: tx.asset.id, + epoch: tx.epochId, + transactionType: tx.type, + amount: tx.amount, + transactionHash: tx.hash, + }) + } + + return acc + }, []) + } + + feeTransactions( + data: FeeTransactionsData, + filter?: Omit + ): FeeTransactionReport[] { + if (!data.poolFeeTransactions?.length) return [] + const feeTransactionTypes: { + [key in PoolFeeTransaction['type']]: string + } = { + PROPOSED: 'proposed', + ADDED: 'added', + REMOVED: 'removed', + CHARGED: 'directChargeMade', + UNCHARGED: 'directChargeCancelled', + ACCRUED: 'accrued', + PAID: 'paid', + } + return data.poolFeeTransactions.reduce((acc, tx) => { + if ( + !filter?.transactionType || + filter.transactionType === 'all' || + filter.transactionType === feeTransactionTypes[tx.type] + ) { + acc.push({ + type: 'feeTransactions', + timestamp: tx.timestamp, + feeId: tx.feeId, + amount: tx.amount, + }) + } + return acc + }, []) + } + + tokenPrice(data: TokenPriceData, filter?: Omit): TokenPriceReport[] { + if (Object.values(data.trancheSnapshots).length === 0) return [] + const items = Object.entries(data.trancheSnapshots).map(([timestamp, snapshots]) => ({ + type: 'tokenPrice' as const, + timestamp: timestamp, + tranches: snapshots.map((snapshot) => ({ + timestamp: snapshot.timestamp, + id: snapshot.trancheId, + price: snapshot.price, + supply: snapshot.tokenSupply, + })), + })) + + return this.applyGrouping(items, filter?.groupBy ?? 'day', 'latest') + } + + assetList(data: AssetListData, filter?: Omit): AssetListReport[] { + if (!data.assetSnapshots?.length) return [] + return data.assetSnapshots + .filter((snapshot) => { + if (snapshot.valuationMethod?.toLowerCase() === 'cash') return false + const isMaturityDatePassed = snapshot?.actualMaturityDate + ? new Date() > new Date(snapshot.actualMaturityDate) + : false + const isDebtZero = snapshot?.outstandingDebt?.isZero() + + if (filter?.status === 'ongoing') { + return snapshot.status === 'ACTIVE' && !isMaturityDatePassed && !isDebtZero + } else if (filter?.status === 'repaid') { + return isMaturityDatePassed && isDebtZero + } else if (filter?.status === 'overdue') { + return isMaturityDatePassed && !isDebtZero + } else return true + }) + .sort((a, b) => { + // Sort by actualMaturityDate in descending order + const dateA = new Date(a.actualMaturityDate || 0).getTime() + const dateB = new Date(b.actualMaturityDate || 0).getTime() + return dateB - dateA + }) + .map((snapshot) => { + const subtype = data.metadata?.pool.asset.class === 'Public credit' ? 'publicCredit' : 'privateCredit' + const items = + subtype === 'publicCredit' + ? ({ + subtype, + faceValue: snapshot.faceValue, + outstandingQuantity: snapshot.outstandingQuantity, + currentPrice: snapshot.currentPrice, + maturityDate: snapshot.actualMaturityDate, + unrealizedProfit: snapshot.unrealizedProfitAtMarketPrice, + realizedProfit: snapshot.sumRealizedProfitFifo, + } satisfies AssetListReportPublicCredit) + : ({ + subtype, + outstandingPrincipal: snapshot.outstandingPrincipal, + outstandingInterest: snapshot.outstandingInterest, + repaidPrincipal: snapshot.totalRepaidPrincipal, + repaidInterest: snapshot.totalRepaidInterest, + repaidUnscheduled: snapshot.totalRepaidUnscheduled, + originationDate: snapshot.actualOriginationDate, + maturityDate: snapshot.actualMaturityDate, + valuationMethod: snapshot.valuationMethod, + advanceRate: snapshot.advanceRate, + collateralValue: snapshot.collateralValue, + probabilityOfDefault: snapshot.probabilityOfDefault, + lossGivenDefault: snapshot.lossGivenDefault, + discountRate: snapshot.discountRate, + } satisfies AssetListReportPrivateCredit) + return { + type: 'assetList', + transactionType: snapshot.status, + timestamp: snapshot.timestamp, + assetId: snapshot.assetId, + presentValue: snapshot.presentValue, + ...items, + } + }) + } + + investorList(data: InvestorListData, filter?: Omit): InvestorListReport[] { + if (!data.trancheCurrencyBalance?.length) return [] + + const filterNetwork = filter?.network === 'all' ? null : filter?.network + const filterAddress = filter?.address?.toLowerCase() + + return data.trancheCurrencyBalance + .filter((investor) => { + const networkMatches = !filterNetwork || filterNetwork === investor.chainId + const addressMatches = + !filterAddress || + investor.accountId.toLowerCase() === filterAddress || + investor.evmAddress?.toLowerCase() === filterAddress + const trancheMatches = !filter?.trancheId || filter.trancheId === investor.trancheId + const hasPosition = + !filter?.address && (!investor.balance.isZero() || !investor.claimableTrancheTokens.isZero()) + + return networkMatches && addressMatches && trancheMatches && (hasPosition || filter?.address) + }) + .map((balance) => { + const totalPositions = data.trancheCurrencyBalance.reduce((sum, investor) => { + return sum.add(investor.balance).add(investor.claimableTrancheTokens) + }, new Currency(0)) + return { + type: 'investorList', + chainId: balance.chainId, + accountId: balance.accountId, + evmAddress: balance.evmAddress, + position: balance.balance.add(balance.claimableTrancheTokens), + poolPercentage: new Rate(balance.balance.add(balance.claimableTrancheTokens.div(totalPositions)).toBigInt()), + pendingInvest: balance.pendingInvestCurrency, + pendingRedeem: balance.pendingRedeemTrancheTokens, + } + }) + } + /** * Apply grouping to a report. * @param items Report items @@ -146,13 +418,13 @@ export class Processor { * @param strategy Grouping strategy, sum aggregates data by period, latest returns the latest item in the period * @returns Grouped report * - * Note: if strategy is 'sum', only Currency values that are not nested are aggregated, all + * Note: if strategy is 'sum', only Decimal values that are not nested are aggregated, all * other values are overwritten with the last value in the period */ private applyGrouping< T extends { timestamp: string - [key: string]: Currency | string | { [key: string]: any } | undefined + [key: string]: Currency | Price | Token | string | number | undefined | any[] | { [key: string]: any } }, >(items: T[], groupBy: ReportFilter['groupBy'] = 'day', strategy: 'latest' | 'sum' = 'latest'): T[] { if (strategy === 'latest') { @@ -163,7 +435,7 @@ export class Processor { return groups.map((group) => { const base = { ...group[group.length - 1] } as T - // Aggregate Currency values + // Aggregate Decimal values for (const key in base) { const value = base[key as keyof T] if (value instanceof Currency) { @@ -172,6 +444,18 @@ export class Processor { new Currency(0n, value.decimals) ) as T[keyof T] } + if (value instanceof Token) { + base[key as keyof T] = group.reduce( + (sum, item) => sum.add(item[key as keyof T] as Token), + new Token(0n, value.decimals) + ) as T[keyof T] + } + if (value instanceof Price) { + base[key as keyof T] = group.reduce( + (sum, item) => sum.add(item[key as keyof T] as Price), + new Price(0n) + ) as T[keyof T] + } } return base }) diff --git a/src/Reports/Reports.test.ts b/src/Reports/Reports.test.ts index b3e6e3f..c5fa8d7 100644 --- a/src/Reports/Reports.test.ts +++ b/src/Reports/Reports.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { Centrifuge } from '../Centrifuge.js' import { spy } from 'sinon' import { Reports } from '../Reports/index.js' -import { ReportFilter } from './types.js' +import { ReportFilter } from '../types/reports.js' import { processor } from './Processor.js' describe('Reports', () => { @@ -178,4 +178,235 @@ describe('Reports', () => { expect(report4?.[0]?.timestamp.slice(0, 10)).to.equal('2024-06-30') }) }) + + describe('profit and loss report', () => { + it('should fetch profit and loss report', async () => { + const pool = await centrifuge.pool('1615768079') + const report = await pool.reports.profitAndLoss({ + from: '2024-11-02T22:11:29.776Z', + to: '2024-11-06T22:11:29.776Z', + groupBy: 'day', + }) + expect(report.length).to.equal(4) + }) + }) + + describe('investor transactions report', () => { + it('should fetch investor transactions report', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorTransactions({ + from: '2024-01-16T22:11:29.776Z', + to: '2024-01-19T22:11:29.776Z', + }) + expect(report.length).to.equal(5) + }) + it('should filter by evm address', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorTransactions({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-12-03T22:11:29.776Z', + address: '0x86552B8d4F4a600D92d516eE8eA8B922EFEcB561', + }) + expect(report.length).to.equal(3) + }) + it('should filter by substrate address (subtrate address must be converted to evm style)', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorTransactions({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-12-03T22:11:29.776Z', + address: '0xa23adc45d99e11ba3dbe9c029a4d378565eeb663e393569cee93fd9f89610faf', // 4f1TYnM7qt92veCgRjZy9rgMWXQRS2825NmcBiY9yHFbAXJa + }) + expect(report.length).to.equal(8) + }) + }) + + describe('asset transactions report', () => { + it('should fetch asset transactions report', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetTransactions({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-01-03T22:11:29.776Z', + }) + expect(report.length).to.equal(5) + }) + it('should return empty array when no transactions found', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetTransactions({ + to: '2024-01-01T22:11:29.776Z', + from: '2024-01-01T22:11:29.776Z', + }) + expect(report).to.deep.equal([]) + }) + it('should filter by transaction type', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetTransactions({ + from: '2024-06-30T22:11:29.776Z', + to: '2024-12-04T22:11:29.776Z', + transactionType: 'financed', + }) + expect(report.length).to.equal(13) + }) + it('should filter by asset id', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetTransactions({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-12-04T22:11:29.776Z', + assetId: '14', + }) + expect(report.length).to.equal(2) + }) + }) + + describe('fee transactions report', () => { + it('should fetch fee transactions report', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.feeTransactions({ + from: '2024-12-01T22:11:29.776Z', + to: '2024-12-03T22:11:29.776Z', + }) + expect(report.length).to.equal(4) + }) + it('should filter by transaction type paid', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.feeTransactions({ + from: '2024-07-23T22:11:29.776Z', + to: '2024-07-26T22:11:29.776Z', + transactionType: 'paid', + }) + expect(report.length).to.equal(2) + }) + it('should filter by transaction type directChargeMade', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.feeTransactions({ + from: '2024-03-28T22:11:29.776Z', + to: '2024-12-26T22:11:29.776Z', + transactionType: 'directChargeMade', + }) + expect(report.length).to.equal(0) + }) + }) + + describe('token price report', () => { + it('should fetch token price report', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.tokenPrice({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-01-03T22:11:29.776Z', + }) + expect(report.length).to.equal(2) + }) + it('should group by month', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.tokenPrice({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-02-03T22:11:29.776Z', + groupBy: 'month', + }) + expect(report.length).to.equal(2) + }) + it('should group by quarter', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.tokenPrice({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-12-03T22:11:29.776Z', + groupBy: 'quarter', + }) + expect(report.length).to.equal(4) + }) + it('should group by year', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.tokenPrice({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-12-03T22:11:29.776Z', + groupBy: 'year', + }) + expect(report.length).to.equal(1) + }) + }) + + describe('asset list report', () => { + it('should fetch asset list report', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetList({ + from: '2024-01-01T22:11:29.776Z', + to: '2024-01-03T22:11:29.776Z', + }) + expect(report.length).to.equal(4) + expect(report?.[0]?.subtype).to.equal('privateCredit') + }) + it('should filter by status ongoing', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetList({ + status: 'ongoing', + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-06T23:59:59.999Z', + }) + expect(report.length).to.equal(0) + }) + it('should filter by status overdue', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.assetList({ + status: 'overdue', + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-06T23:59:59.999Z', + }) + expect(report.length).to.equal(6) + }) + }) + + describe('investor list report', () => { + it('should fetch investor list report', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorList() + expect(report.length).to.equal(7) + }) + it('should filter by network', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorList({ network: 1 }) + expect(report.length).to.equal(3) + }) + it('should filter by network centrifuge', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorList({ network: 'centrifuge' }) + expect(report.length).to.equal(1) + }) + it('should filter by network all', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorList({ network: 'all' }) + expect(report.length).to.equal(7) + }) + it('should filter by tranche', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorList({ trancheId: '0x97aa65f23e7be09fcd62d0554d2e9273' }) + expect(report.length).to.equal(7) + }) + it('should filter by address', async () => { + const anemoyPoolId = '4139607887' + const pool = await centrifuge.pool(anemoyPoolId) + const report = await pool.reports.investorList({ address: '0x6f94eb271ceb5a33aeab5bb8b8edea8ecf35ee86' }) + expect(report.length).to.equal(1) + }) + }) }) diff --git a/src/Reports/index.ts b/src/Reports/index.ts index 9754513..bbd8b28 100644 --- a/src/Reports/index.ts +++ b/src/Reports/index.ts @@ -1,32 +1,46 @@ import { Entity } from '../Entity.js' import { Centrifuge } from '../Centrifuge.js' -import { PoolSnapshotFilter, poolSnapshotsPostProcess, poolSnapshotsQuery } from '../queries/poolSnapshots.js' -import { - TrancheSnapshotFilter, - trancheSnapshotsPostProcess, - trancheSnapshotsQuery, -} from '../queries/trancheSnapshots.js' import { combineLatest } from 'rxjs' import { processor } from './Processor.js' import { map } from 'rxjs' -import { BalanceSheetReport, CashflowReport, ProfitAndLossReport, ReportFilter } from './types.js' -import { Query } from '../types/query.js' import { - PoolFeeSnapshotFilter, - poolFeeSnapshotQuery, - poolFeeSnapshotsPostProcess, -} from '../queries/poolFeeSnapshots.js' + BalanceSheetReport, + CashflowReport, + InvestorTransactionsReport, + ProfitAndLossReport, + ReportFilter, + Report, + DataReport, + DataReportFilter, + InvestorTransactionsReportFilter, + AssetTransactionReport, + AssetTransactionReportFilter, + TokenPriceReport, + TokenPriceReportFilter, + FeeTransactionReport, + FeeTransactionReportFilter, + AssetListReportFilter, + AssetListReport, + InvestorListReportFilter, + InvestorListReport, +} from '../types/reports.js' +import { Query } from '../types/query.js' import { Pool } from '../Pool.js' +import { IndexerQueries } from '../IndexerQueries/index.js' -type ReportType = 'balanceSheet' | 'cashflow' | 'profitAndLoss' - +const DEFAULT_FILTER: ReportFilter = { + from: '2024-01-01T00:00:00.000Z', + to: new Date().toISOString(), +} export class Reports extends Entity { + private queries: IndexerQueries constructor( centrifuge: Centrifuge, public pool: Pool ) { super(centrifuge, ['reports', pool.id]) + this.queries = new IndexerQueries(centrifuge, pool) } balanceSheet(filter?: ReportFilter) { @@ -41,54 +55,147 @@ export class Reports extends Entity { return this._generateReport('profitAndLoss', filter) } - _generateReport(type: ReportType, filter?: ReportFilter): Query { + investorTransactions(filter?: InvestorTransactionsReportFilter) { + return this._generateReport('investorTransactions', filter) + } + + assetTransactions(filter?: AssetTransactionReportFilter) { + return this._generateReport('assetTransactions', filter) + } + + tokenPrice(filter?: TokenPriceReportFilter) { + return this._generateReport('tokenPrice', filter) + } + + feeTransactions(filter?: FeeTransactionReportFilter) { + return this._generateReport('feeTransactions', filter) + } + + assetList(filter?: AssetListReportFilter) { + return this._generateReport('assetList', filter) + } + + investorList(filter?: InvestorListReportFilter) { + return this._generateReport('investorList', filter) + } + + /** + * Reports are split into two types: + * - A `Report` is a standard report: balanceSheet, cashflow, profitAndLoss + * - A `DataReport` is a custom report: investorTransactions, assetTransactions, feeTransactions, tokenPrice, assetList, investorList + */ + _generateReport(type: Report, filter?: ReportFilter): Query + _generateReport(type: DataReport, filter?: DataReportFilter): Query + _generateReport(type: string, filter?: Record) { return this._query( - [type, filter?.from, filter?.to, filter?.groupBy], + [ + type, + filter?.from, + filter?.to, + filter?.groupBy, + filter?.address, + filter?.network, + filter?.tokenId, + filter?.transactionType, + filter?.assetId, + filter?.status, + ], () => { + const { from, to, ...restFilter } = filter ?? {} const dateFilter = { timestamp: { - greaterThan: filter?.from, - lessThanOrEqualTo: filter?.to && `${filter.to.split('T')[0]}T23:59:59.999Z`, + greaterThan: from ?? DEFAULT_FILTER.from, + lessThanOrEqualTo: (to && `${to.split('T')[0]}T23:59:59.999Z`) ?? DEFAULT_FILTER.to, }, } const metadata$ = this.pool.metadata() - const poolSnapshots$ = this.poolSnapshots({ + const poolSnapshots$ = this.queries.poolSnapshotsQuery({ ...dateFilter, poolId: { equalTo: this.pool.id }, }) - const trancheSnapshots$ = this.trancheSnapshots({ + const trancheSnapshots$ = this.queries.trancheSnapshotsQuery({ ...dateFilter, tranche: { poolId: { equalTo: this.pool.id } }, }) - const poolFeeSnapshots$ = this.poolFeeSnapshots({ + const poolFeeSnapshots$ = this.queries.poolFeeSnapshotsQuery({ ...dateFilter, poolFeeId: { includes: this.pool.id }, }) + const investorTransactions$ = this.queries.investorTransactionsQuery({ + ...dateFilter, + poolId: { equalTo: this.pool.id }, + }) + const assetTransactions$ = this.queries.assetTransactionsQuery({ + ...dateFilter, + poolId: { equalTo: this.pool.id }, + }) + const poolFeeTransactions$ = this.queries.poolFeeTransactionsQuery({ + ...dateFilter, + poolFee: { poolId: { equalTo: this.pool.id } }, + }) + const assetSnapshots$ = this.queries.assetSnapshotsQuery({ + ...dateFilter, + asset: { poolId: { equalTo: this.pool.id } }, + }) + const trancheCurrencyBalance$ = this.queries.trancheCurrencyBalanceQuery( + { + pool: { id: { equalTo: this.pool.id } }, + }, + { + currency: { pool: { id: { equalTo: this.pool.id } } }, + } + ) switch (type) { case 'balanceSheet': return combineLatest([poolSnapshots$, trancheSnapshots$]).pipe( map( ([poolSnapshots, trancheSnapshots]) => - processor.balanceSheet({ poolSnapshots, trancheSnapshots }, filter) as T[] + processor.balanceSheet({ poolSnapshots, trancheSnapshots }, restFilter) as T[] ) ) case 'cashflow': return combineLatest([poolSnapshots$, poolFeeSnapshots$, metadata$]).pipe( map( ([poolSnapshots, poolFeeSnapshots, metadata]) => - processor.cashflow({ poolSnapshots, poolFeeSnapshots, metadata }, filter) as T[] + processor.cashflow({ poolSnapshots, poolFeeSnapshots, metadata }, restFilter) as T[] ) ) case 'profitAndLoss': return combineLatest([poolSnapshots$, poolFeeSnapshots$, metadata$]).pipe( map( ([poolSnapshots, poolFeeSnapshots, metadata]) => - processor.profitAndLoss({ poolSnapshots, poolFeeSnapshots, metadata }, filter) as T[] + processor.profitAndLoss({ poolSnapshots, poolFeeSnapshots, metadata }, restFilter) as T[] ) ) + case 'investorTransactions': + return combineLatest([investorTransactions$, metadata$]).pipe( + map( + ([investorTransactions]) => processor.investorTransactions({ investorTransactions }, restFilter) as T[] + ) + ) + case 'assetTransactions': + return combineLatest([assetTransactions$, metadata$]).pipe( + map(([assetTransactions]) => processor.assetTransactions({ assetTransactions }, restFilter) as T[]) + ) + case 'feeTransactions': + return combineLatest([poolFeeTransactions$]).pipe( + map(([poolFeeTransactions]) => processor.feeTransactions({ poolFeeTransactions }, restFilter) as T[]) + ) + case 'tokenPrice': + return combineLatest([trancheSnapshots$]).pipe( + map(([trancheSnapshots]) => processor.tokenPrice({ trancheSnapshots }, restFilter) as T[]) + ) + case 'assetList': + return combineLatest([assetSnapshots$, metadata$]).pipe( + map(([assetSnapshots, metadata]) => processor.assetList({ assetSnapshots, metadata }, restFilter) as T[]) + ) + case 'investorList': + return combineLatest([trancheCurrencyBalance$]).pipe( + map(([trancheCurrencyBalance]) => processor.investorList({ trancheCurrencyBalance }, restFilter) as T[]) + ) default: throw new Error(`Unsupported report type: ${type}`) } @@ -98,16 +205,4 @@ export class Reports extends Entity { } ) } - - poolFeeSnapshots(filter?: PoolFeeSnapshotFilter) { - return this._root._queryIndexer(poolFeeSnapshotQuery, { filter }, poolFeeSnapshotsPostProcess) - } - - poolSnapshots(filter?: PoolSnapshotFilter) { - return this._root._queryIndexer(poolSnapshotsQuery, { filter }, poolSnapshotsPostProcess) - } - - trancheSnapshots(filter?: TrancheSnapshotFilter) { - return this._root._queryIndexer(trancheSnapshotsQuery, { filter }, trancheSnapshotsPostProcess) - } } diff --git a/src/Reports/types.ts b/src/Reports/types.ts deleted file mode 100644 index 6a3c2ad..0000000 --- a/src/Reports/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { PoolFeeSnapshotsByDate } from '../queries/poolFeeSnapshots.js' -import { PoolSnapshot } from '../queries/poolSnapshots.js' -import { TrancheSnapshotsByDate } from '../queries/trancheSnapshots.js' -import { PoolMetadata } from '../types/poolMetadata.js' -import { Price } from '../utils/BigInt.js' -import { Currency } from '../utils/BigInt.js' -import { GroupBy } from '../utils/date.js' - -export interface ReportFilter { - from?: string - to?: string - groupBy?: GroupBy -} - -export type BalanceSheetReport = { - type: 'balanceSheet' - timestamp: string - assetValuation: Currency - onchainReserve: Currency - offchainCash: Currency - accruedFees: Currency - netAssetValue: Currency - tranches?: { - name: string - timestamp: string - tokenId: string - tokenSupply: Currency - tokenPrice: Price | null - trancheValue: Currency - }[] - totalCapital?: Currency -} - -export type BalanceSheetData = { - poolSnapshots: PoolSnapshot[] - trancheSnapshots: TrancheSnapshotsByDate -} - -type CashflowReportBase = { - type: 'cashflow' - timestamp: string - principalPayments: Currency - interestPayments: Currency - netCashflowAsset: Currency // sum of cashflow from assetAcquisitions, principalPayments, interestPayments, realizedPL - fees: { name: string; amount: Currency; timestamp: string; feeId: string }[] - netCashflowAfterFees: Currency - investments: Currency - redemptions: Currency - activitiesCashflow: Currency // sum of cashflow from investments and redemptions - totalCashflow: Currency // sum of netCashflowAsset, netCashflowAfterFees and activitiesCashflow - endCashBalance: { balance: Currency } -} - -type CashflowReportPublicCredit = CashflowReportBase & { - subtype: 'publicCredit' - realizedPL?: Currency - assetPurchases?: Currency -} - -type CashflowReportPrivateCredit = CashflowReportBase & { - subtype: 'privateCredit' - assetFinancing?: Currency -} - -export type CashflowReport = CashflowReportPublicCredit | CashflowReportPrivateCredit - -export type CashflowData = { - poolSnapshots: PoolSnapshot[] - poolFeeSnapshots: PoolFeeSnapshotsByDate - metadata: PoolMetadata | undefined -} - -export type ProfitAndLossReportBase = { - type: 'profitAndLoss' - timestamp: string - profitAndLossFromAsset: Currency - interestPayments: Currency - otherPayments: Currency - totalExpenses: Currency - totalProfitAndLoss: Currency - fees: { name: string; amount: Currency; timestamp: string; feeId: string }[] -} - -export type ProfitAndLossReportPublicCredit = ProfitAndLossReportBase & { - subtype: 'publicCredit' - totalIncome: Currency -} - -export type ProfitAndLossReportPrivateCredit = ProfitAndLossReportBase & { - subtype: 'privateCredit' - interestAccrued: Currency - assetWriteOffs: Currency -} - -export type ProfitAndLossReport = ProfitAndLossReportPublicCredit | ProfitAndLossReportPrivateCredit - -export type ProfitAndLossData = { - poolSnapshots: PoolSnapshot[] - poolFeeSnapshots: PoolFeeSnapshotsByDate - metadata: PoolMetadata | undefined -} diff --git a/src/tests/mocks/mockAssetSnapshots.ts b/src/tests/mocks/mockAssetSnapshots.ts new file mode 100644 index 0000000..f09714f --- /dev/null +++ b/src/tests/mocks/mockAssetSnapshots.ts @@ -0,0 +1,57 @@ +import { AssetSnapshot } from '../../IndexerQueries/assetSnapshots.js' +import { Currency, Rate } from '../../utils/BigInt.js' + +export const mockAssetSnapshots: AssetSnapshot[] = [ + { + actualMaturityDate: '2030-01-01', + actualOriginationDate: 1704067200, // 2024-01-01 + advanceRate: new Rate('800000000000000000'), // 0.8 + assetId: 'asset-1', + collateralValue: Currency.fromFloat(125000, 6), // 125k + currentPrice: Currency.fromFloat(1, 6), + discountRate: new Rate('100000000000000000'), // 0.1 + faceValue: Currency.fromFloat(100000, 6), // 100k + lossGivenDefault: new Rate('400000000000000000'), // 0.4 + name: 'Asset 1', + outstandingDebt: Currency.fromFloat(80000, 6), // 80k + outstandingInterest: Currency.fromFloat(2000, 6), // 2k + outstandingPrincipal: Currency.fromFloat(78000, 6), // 78k + outstandingQuantity: Currency.fromFloat(78000, 6), // 78k + presentValue: Currency.fromFloat(77000, 6), // 77k + probabilityOfDefault: new Rate('50000000000000000'), // 0.05 + status: 'ACTIVE', + sumRealizedProfitFifo: Currency.fromFloat(1000, 6), // 1k + timestamp: '2024-01-01T12:00:00Z', + totalRepaidInterest: Currency.fromFloat(500, 6), // 500 + totalRepaidPrincipal: Currency.fromFloat(22000, 6), // 22k + totalRepaidUnscheduled: Currency.fromFloat(0, 6), + unrealizedProfitAtMarketPrice: Currency.fromFloat(-1000, 6), // -1k + valuationMethod: 'DiscountedCashFlow', + }, + { + actualMaturityDate: '2030-01-01', + actualOriginationDate: 1704067200, // 2024-01-01 + advanceRate: new Rate('800000000000000000'), // 0.8 + assetId: 'asset-1', + collateralValue: Currency.fromFloat(125000, 6), + currentPrice: Currency.fromFloat(1.02, 6), // Price increased + discountRate: new Rate('100000000000000000'), + faceValue: Currency.fromFloat(100000, 6), + lossGivenDefault: new Rate('400000000000000000'), + name: 'Asset 1', + outstandingDebt: Currency.fromFloat(75000, 6), // Decreased by 5k + outstandingInterest: Currency.fromFloat(1800, 6), // Decreased + outstandingPrincipal: Currency.fromFloat(73200, 6), // Decreased + outstandingQuantity: Currency.fromFloat(73200, 6), + presentValue: Currency.fromFloat(74000, 6), + probabilityOfDefault: new Rate('50000000000000000'), + status: 'ACTIVE', + sumRealizedProfitFifo: Currency.fromFloat(1200, 6), // Increased + timestamp: '2024-01-02T12:00:00Z', + totalRepaidInterest: Currency.fromFloat(700, 6), // Increased + totalRepaidPrincipal: Currency.fromFloat(26800, 6), // Increased + totalRepaidUnscheduled: Currency.fromFloat(0, 6), + unrealizedProfitAtMarketPrice: Currency.fromFloat(1000, 6), // Changed to profit + valuationMethod: 'DiscountedCashFlow', + }, +] diff --git a/src/tests/mocks/mockAssetTransactions.ts b/src/tests/mocks/mockAssetTransactions.ts new file mode 100644 index 0000000..9d84a9a --- /dev/null +++ b/src/tests/mocks/mockAssetTransactions.ts @@ -0,0 +1,81 @@ +import { AssetTransaction, AssetType, AssetTransactionType } from '../../IndexerQueries/assetTransactions.js' +import { Currency, Price } from '../../utils/BigInt.js' + +export const mockAssetTransactions: AssetTransaction[] = [ + { + id: 'asset-tx-1', + timestamp: new Date('2024-01-01T12:00:00Z'), + poolId: 'pool-1', + accountId: 'account-1', + epochId: 'epoch-1', + type: 'BORROWED' as AssetTransactionType, + amount: Currency.fromFloat(100000, 6), // 100k + settlementPrice: new Price(1000000000000000000n), // 1.0 + quantity: '100000', + principalAmount: Currency.fromFloat(100000, 6), + interestAmount: Currency.fromFloat(0, 6), + hash: '0xabc123', + realizedProfitFifo: Currency.fromFloat(0, 6), + unrealizedProfitAtMarketPrice: Currency.fromFloat(0, 6), + asset: { + id: 'poolId-1', + metadata: 'Asset 1 metadata', + type: AssetType.OnchainCash, + currentPrice: '1000000000000000000', // 1.0 + }, + }, + { + id: 'asset-tx-2', + timestamp: new Date('2024-01-02T12:00:00Z'), + poolId: 'pool-1', + accountId: 'account-1', + epochId: 'epoch-1', + type: 'REPAID' as AssetTransactionType, + amount: Currency.fromFloat(20000, 6), // 20k + settlementPrice: new Price(1000000000000000000n), // 1.0 + quantity: '20000', + principalAmount: Currency.fromFloat(19000, 6), + interestAmount: Currency.fromFloat(1000, 6), + hash: '0xdef456', + realizedProfitFifo: Currency.fromFloat(1000, 6), + unrealizedProfitAtMarketPrice: undefined, + asset: { + id: 'poolId-1', + metadata: 'Asset 1 metadata', + type: AssetType.OnchainCash, + currentPrice: '1000000000000000000', // 1.0 + }, + }, + { + id: 'asset-tx-3', + timestamp: new Date('2024-01-03T12:00:00Z'), + poolId: 'pool-1', + accountId: 'account-1', + epochId: 'epoch-1', + type: 'CASH_TRANSFER' as AssetTransactionType, + amount: Currency.fromFloat(5000, 6), + settlementPrice: null, + quantity: null, + principalAmount: undefined, + interestAmount: undefined, + hash: '0xghi789', + realizedProfitFifo: undefined, + unrealizedProfitAtMarketPrice: undefined, + asset: { + id: 'poolId-2', + metadata: 'Asset 2 metadata', + type: AssetType.OffchainCash, + currentPrice: null, + }, + fromAsset: { + id: 'poolId-1', + metadata: 'Asset 1 metadata', + type: AssetType.OnchainCash, + }, + toAsset: { + id: 'poolId-2', + metadata: 'Asset 2 metadata', + type: AssetType.OffchainCash, + }, + }, +] diff --git a/src/tests/mocks/mockInvestorCurrencyBalance.ts b/src/tests/mocks/mockInvestorCurrencyBalance.ts new file mode 100644 index 0000000..989b7cd --- /dev/null +++ b/src/tests/mocks/mockInvestorCurrencyBalance.ts @@ -0,0 +1,30 @@ +import { Currency } from '../../utils/BigInt.js' +import { TrancheCurrencyBalance } from '../../IndexerQueries/trancheCurrencyBalance.js' + +export const mockInvestorCurrencyBalances: TrancheCurrencyBalance[] = [ + { + accountId: 'account-1', + chainId: 1, + trancheId: 'tranche-1', + evmAddress: '0x123', + balance: Currency.fromFloat(1000, 6), + pendingInvestCurrency: Currency.fromFloat(100, 6), + claimableTrancheTokens: Currency.fromFloat(50, 6), + sumClaimedTrancheTokens: Currency.fromFloat(200, 6), + pendingRedeemTrancheTokens: Currency.fromFloat(75, 6), + claimableCurrency: Currency.fromFloat(25, 6), + sumClaimedCurrency: Currency.fromFloat(300, 6), + }, + { + accountId: 'account-2', + chainId: 'centrifuge', + trancheId: 'tranche-1', + balance: Currency.fromFloat(2000, 6), + pendingInvestCurrency: Currency.fromFloat(200, 6), + claimableTrancheTokens: Currency.fromFloat(100, 6), + sumClaimedTrancheTokens: Currency.fromFloat(400, 6), + pendingRedeemTrancheTokens: Currency.fromFloat(150, 6), + claimableCurrency: Currency.fromFloat(50, 6), + sumClaimedCurrency: Currency.fromFloat(600, 6), + }, +] diff --git a/src/tests/mocks/mockInvestorTransactions.ts b/src/tests/mocks/mockInvestorTransactions.ts new file mode 100644 index 0000000..737451b --- /dev/null +++ b/src/tests/mocks/mockInvestorTransactions.ts @@ -0,0 +1,37 @@ +import { InvestorTransaction } from '../../IndexerQueries/investorTransactions.js' +import { Price } from '../../utils/BigInt.js' + +import { Currency } from '../../utils/BigInt.js' + +export const mockInvestorTransactions: InvestorTransaction[] = [ + { + id: 'tx-1', + poolId: 'pool-1', + timestamp: new Date('2024-01-01T12:00:00Z'), + accountId: 'account-1', + chainId: 1, + evmAddress: '0x123a', + trancheId: 'senior', + epochNumber: 1, + type: 'INVEST_ORDER_UPDATE', + currencyAmount: new Currency(1_000_000n, 6), // 1.0 + tokenAmount: new Currency(900_000n, 6), // 0.9 + tokenPrice: new Price(1_100_000_000_000_000_000n), // 1.1 + hash: '0xabc', + } as InvestorTransaction, + { + id: 'tx-2', + poolId: 'pool-1', + timestamp: new Date('2024-01-01T18:00:00Z'), + accountId: 'account-1', + chainId: 1, + evmAddress: '0x123b', + trancheId: 'senior', + epochNumber: 1, + type: 'INVEST_EXECUTION', + currencyAmount: new Currency(2_000_000n, 6), // 2.0 + tokenAmount: new Currency(1_800_000n, 6), // 1.8 + tokenPrice: new Price(1_100_000_000_000_000_000n), // 1.1 + hash: '0xdef', + } as InvestorTransaction, +] diff --git a/src/tests/mocks/mockPoolFeeSnapshot.ts b/src/tests/mocks/mockPoolFeeSnapshot.ts index 5e43244..3830670 100644 --- a/src/tests/mocks/mockPoolFeeSnapshot.ts +++ b/src/tests/mocks/mockPoolFeeSnapshot.ts @@ -1,5 +1,5 @@ import { Currency } from '../../utils/BigInt.js' -import { PoolFeeSnapshotsByDate } from '../../queries/poolFeeSnapshots.js' +import { PoolFeeSnapshotsByDate } from '../../IndexerQueries/poolFeeSnapshots.js' export const mockPoolFeeSnapshots: PoolFeeSnapshotsByDate = { '2024-01-01': [ diff --git a/src/tests/mocks/mockPoolFeeTransactions.ts b/src/tests/mocks/mockPoolFeeTransactions.ts new file mode 100644 index 0000000..6093177 --- /dev/null +++ b/src/tests/mocks/mockPoolFeeTransactions.ts @@ -0,0 +1,20 @@ +import { Currency } from '../../utils/BigInt.js' +import { PoolFeeTransaction } from '../../IndexerQueries/poolFeeTransactions.js' +export const mockFeeTransactions = [ + { + feeId: 'fee-1', + type: 'ACCRUED' as PoolFeeTransaction['type'], + timestamp: '2024-01-01T00:00:00Z', + blockNumber: '1', + epochNumber: 1, + amount: new Currency(1000000n, 6), + }, + { + feeId: 'fee-2', + type: 'PAID' as PoolFeeTransaction['type'], + timestamp: '2024-01-02T00:00:00Z', + blockNumber: '2', + epochNumber: 2, + amount: new Currency(2000000n, 6), + }, +] diff --git a/src/tests/mocks/mockPoolSnapshots.ts b/src/tests/mocks/mockPoolSnapshots.ts index 792a950..3a66bf9 100644 --- a/src/tests/mocks/mockPoolSnapshots.ts +++ b/src/tests/mocks/mockPoolSnapshots.ts @@ -1,5 +1,5 @@ import { Currency } from '../../utils/BigInt.js' -import { PoolSnapshot } from '../../queries/poolSnapshots.js' +import { PoolSnapshot } from '../../IndexerQueries/poolSnapshots.js' export const mockPoolSnapshots: PoolSnapshot[] = [ { diff --git a/src/tests/mocks/mockTrancheSnapshots.ts b/src/tests/mocks/mockTrancheSnapshots.ts index 8f0ae6a..1447c20 100644 --- a/src/tests/mocks/mockTrancheSnapshots.ts +++ b/src/tests/mocks/mockTrancheSnapshots.ts @@ -1,6 +1,6 @@ import { Price, Currency, Perquintill } from '../../utils/BigInt.js' -import { TrancheSnapshotsByDate } from '../../queries/trancheSnapshots.js' +import { TrancheSnapshotsByDate } from '../../IndexerQueries/trancheSnapshots.js' export const mockTrancheSnapshots: TrancheSnapshotsByDate = { '2024-01-01': [ diff --git a/src/types/reports.ts b/src/types/reports.ts new file mode 100644 index 0000000..9601c21 --- /dev/null +++ b/src/types/reports.ts @@ -0,0 +1,297 @@ +import { AssetSnapshot } from '../IndexerQueries/assetSnapshots.js' +import { AssetTransaction, AssetTransactionType } from '../IndexerQueries/assetTransactions.js' +import { InvestorTransaction, SubqueryInvestorTransactionType } from '../IndexerQueries/investorTransactions.js' +import { PoolFeeSnapshotsByDate } from '../IndexerQueries/poolFeeSnapshots.js' +import { PoolFeeTransaction } from '../IndexerQueries/poolFeeTransactions.js' +import { PoolSnapshot } from '../IndexerQueries/poolSnapshots.js' +import { TrancheCurrencyBalance } from '../IndexerQueries/trancheCurrencyBalance.js' +import { TrancheSnapshotsByDate } from '../IndexerQueries/trancheSnapshots.js' +import { PoolMetadata } from '../types/poolMetadata.js' +import { Price, Rate, Token } from '../utils/BigInt.js' +import { Currency } from '../utils/BigInt.js' +import { GroupBy } from '../utils/date.js' + +export interface ReportFilter { + from?: string + to?: string + groupBy?: GroupBy +} + +export type DataReportFilter = { + to?: string + from?: string +} + +export type Report = 'balanceSheet' | 'cashflow' | 'profitAndLoss' +export type DataReport = + | 'investorTransactions' + | 'assetTransactions' + | 'feeTransactions' + | 'tokenPrice' + | 'assetList' + | 'investorList' + +/** + * Balance sheet type + */ +export type BalanceSheetReport = { + type: 'balanceSheet' + timestamp: string + assetValuation: Currency + onchainReserve: Currency + offchainCash: Currency + accruedFees: Currency + netAssetValue: Currency + tranches?: { + name: string + timestamp: string + tokenId: string + tokenSupply: Currency + tokenPrice: Price | null + trancheValue: Currency + }[] + totalCapital?: Currency +} + +export type BalanceSheetData = { + poolSnapshots: PoolSnapshot[] + trancheSnapshots: TrancheSnapshotsByDate +} + +/** + * Cashflow types + */ +type CashflowReportBase = { + type: 'cashflow' + timestamp: string + principalPayments: Currency + interestPayments: Currency + netCashflowAsset: Currency // sum of cashflow from assetAcquisitions, principalPayments, interestPayments, realizedPL + fees: { name: string; amount: Currency; timestamp: string; feeId: string }[] + netCashflowAfterFees: Currency + investments: Currency + redemptions: Currency + activitiesCashflow: Currency // sum of cashflow from investments and redemptions + totalCashflow: Currency // sum of netCashflowAsset, netCashflowAfterFees and activitiesCashflow + endCashBalance: { balance: Currency } +} + +type CashflowReportPublicCredit = CashflowReportBase & { + subtype: 'publicCredit' + realizedPL?: Currency + assetPurchases?: Currency +} + +type CashflowReportPrivateCredit = CashflowReportBase & { + subtype: 'privateCredit' + assetFinancing?: Currency +} + +export type CashflowReport = CashflowReportPublicCredit | CashflowReportPrivateCredit + +export type CashflowData = { + poolSnapshots: PoolSnapshot[] + poolFeeSnapshots: PoolFeeSnapshotsByDate + metadata: PoolMetadata | undefined +} + +/** + * Profit and loss types + */ +export type ProfitAndLossReportBase = { + type: 'profitAndLoss' + timestamp: string + profitAndLossFromAsset: Currency + interestPayments: Currency + otherPayments: Currency + totalExpenses: Currency + totalProfitAndLoss: Currency + fees: { name: string; amount: Currency; timestamp: string; feeId: string }[] +} + +export type ProfitAndLossReportPublicCredit = ProfitAndLossReportBase & { + subtype: 'publicCredit' + totalIncome: Currency +} + +export type ProfitAndLossReportPrivateCredit = ProfitAndLossReportBase & { + subtype: 'privateCredit' + interestAccrued: Currency + assetWriteOffs: Currency +} + +export type ProfitAndLossReport = ProfitAndLossReportPublicCredit | ProfitAndLossReportPrivateCredit + +export type ProfitAndLossData = { + poolSnapshots: PoolSnapshot[] + poolFeeSnapshots: PoolFeeSnapshotsByDate + metadata: PoolMetadata | undefined +} + +/** + * Investor transactions types + */ +export type InvestorTransactionsData = { + investorTransactions: InvestorTransaction[] +} + +export type InvestorTransactionsReport = { + type: 'investorTransactions' + timestamp: string + chainId: number | 'centrifuge' + account: string + epoch: string + transactionType: SubqueryInvestorTransactionType + currencyAmount: Currency + trancheTokenId: string + trancheTokenAmount: Currency + price: Price + transactionHash: string +} + +export type InvestorTransactionsReportFilter = { + tokenId?: string + transactionType?: 'orders' | 'executions' | 'transfers' | 'all' + network?: number | 'centrifuge' | 'all' + address?: string + to?: string + from?: string +} + +/** + * Asset transactions types + */ +export type AssetTransactionsData = { + assetTransactions: AssetTransaction[] +} + +export type AssetTransactionReport = { + type: 'assetTransactions' + timestamp: string + assetId: string + epoch: string + transactionType: AssetTransactionType + amount: Currency + transactionHash: string +} + +export type AssetTransactionReportFilter = { + from?: string + to?: string + assetId?: string + transactionType?: 'created' | 'financed' | 'repaid' | 'priced' | 'closed' | 'cashTransfer' | 'all' +} + +/** + * Fee transactions types + */ +export type FeeTransactionsData = { + poolFeeTransactions: PoolFeeTransaction[] +} + +export type FeeTransactionReport = { + type: 'feeTransactions' + timestamp: string + feeId: string + amount: Currency +} + +export type FeeTransactionReportFilter = { + from?: string + to?: string + transactionType?: 'directChargeMade' | 'directChargeCanceled' | 'accrued' | 'paid' | 'all' +} + +/** + * Token price types + */ +export type TokenPriceData = { + trancheSnapshots: TrancheSnapshotsByDate +} + +export type TokenPriceReport = { + type: 'tokenPrice' + timestamp: string + tranches: { id: string; price: Price; supply: Token; timestamp: string }[] +} + +export type TokenPriceReportFilter = { + from?: string + to?: string + groupBy?: GroupBy +} + +/** + * Asset list types + */ +export type AssetListData = { + assetSnapshots: AssetSnapshot[] + metadata: PoolMetadata | undefined +} + +export type AssetListReportBase = { + type: 'assetList' + timestamp: string + assetId: string + presentValue: Currency | undefined +} + +export type AssetListReportPublicCredit = { + subtype: 'publicCredit' + faceValue: Currency | undefined + outstandingQuantity: Currency | undefined + currentPrice: Price | undefined + maturityDate: string | undefined + unrealizedProfit: Currency | undefined + realizedProfit: Currency | undefined +} + +export type AssetListReportPrivateCredit = { + subtype: 'privateCredit' + outstandingPrincipal: Currency | undefined + outstandingInterest: Currency | undefined + repaidPrincipal: Currency | undefined + repaidInterest: Currency | undefined + repaidUnscheduled: Currency | undefined + originationDate: number | undefined + maturityDate: string | undefined + valuationMethod: string | undefined + advanceRate: Rate | undefined + collateralValue: Currency | undefined + probabilityOfDefault: Rate | undefined + lossGivenDefault: Rate | undefined + discountRate: Rate | undefined +} + +export type AssetListReport = AssetListReportBase & (AssetListReportPublicCredit | AssetListReportPrivateCredit) + +export type AssetListReportFilter = { + from?: string + to?: string + status?: 'ongoing' | 'repaid' | 'overdue' | 'all' +} + +/** + * Investor list types + */ +export type InvestorListData = { + trancheCurrencyBalance: TrancheCurrencyBalance[] +} +export type InvestorListReport = { + type: 'investorList' + chainId: number | 'centrifuge' | 'all' + accountId: string + evmAddress?: string + position: Currency + poolPercentage: Rate + pendingInvest: Currency + pendingRedeem: Currency +} + +export type InvestorListReportFilter = { + from?: string + to?: string + trancheId?: string + network?: number | 'centrifuge' | 'all' + address?: string +} diff --git a/src/utils/BigInt.ts b/src/utils/BigInt.ts index 4d32b53..58920f0 100644 --- a/src/utils/BigInt.ts +++ b/src/utils/BigInt.ts @@ -24,9 +24,9 @@ export abstract class BigIntWrapper { } export class DecimalWrapper extends BigIntWrapper { - readonly decimals: number + readonly decimals: number = 27 - constructor(value: Numeric | bigint, decimals: number) { + constructor(value: Numeric | bigint, decimals: number = 27) { super(value) this.decimals = decimals } diff --git a/src/utils/date.ts b/src/utils/date.ts index 933c454..141d3cc 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -8,12 +8,13 @@ export function getPeriod(date: Date, groupBy: GroupBy): string | undefined { case 'day': return date.toISOString().slice(0, 10) case 'month': - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const utcDate = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) + return `${utcDate.getUTCFullYear()}-${String(utcDate.getUTCMonth() + 1).padStart(2, '0')}` case 'quarter': - const quarter = Math.floor(date.getMonth() / 3) + 1 - return `${date.getFullYear()}-Q${quarter}` + const quarter = Math.floor(date.getUTCMonth() / 3) + 1 + return `${date.getUTCFullYear()}-Q${quarter}` case 'year': - return date.getFullYear().toString() + return date.getUTCFullYear().toString() default: return undefined }