diff --git a/src/Reports/Processor.test.ts b/src/Reports/Processor.test.ts index ebc78ff..f4f9022 100644 --- a/src/Reports/Processor.test.ts +++ b/src/Reports/Processor.test.ts @@ -5,9 +5,10 @@ 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 { Currency, Price } from '../utils/BigInt.js' import { PoolFeeSnapshot, PoolFeeSnapshotsByDate } from '../queries/poolFeeSnapshots.js' import { ProfitAndLossReportPrivateCredit, ProfitAndLossReportPublicCredit } from './types.js' +import { InvestorTransaction } from '../queries/investorTransactions.js' describe('Processor', () => { describe('balanceSheet processor', () => { @@ -277,7 +278,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 +359,123 @@ describe('Processor', () => { expect(result[0]?.fees?.[0]?.timestamp.slice(0, 10)).to.equal('2024-01-01') }) }) + describe('investor transactions processor', () => { + 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, + ] + + it('should return empty array when no transactions found', () => { + expect(processor.investorTransactions({ investorTransactions: [], metadata: undefined })).to.deep.equal([]) + }) + + it('should process investor transactions correctly without filters', () => { + const result = processor.investorTransactions({ + investorTransactions: mockInvestorTransactions, + metadata: mockPoolMetadata, + }) + + 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, + metadata: mockPoolMetadata, + }, + { 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, + metadata: mockPoolMetadata, + }, + { 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, + metadata: mockPoolMetadata, + }, + { network: 1 } + ) + expect(result).to.have.lengthOf(2) + expect(result[0]?.chainId).to.equal(1) + }) + it('should filter by transaction type', () => { + const result = processor.investorTransactions( + { + investorTransactions: mockInvestorTransactions, + metadata: mockPoolMetadata, + }, + { transactionType: 'orders' } + ) + expect(result).to.have.lengthOf(1) + expect(result[0]?.transactionType).to.equal('INVEST_ORDER_UPDATE') + }) + }) describe('applyGrouping', () => { const applyGrouping = processor['applyGrouping'] const mockData = [ diff --git a/src/Reports/Processor.ts b/src/Reports/Processor.ts index 72b0ccd..d994385 100644 --- a/src/Reports/Processor.ts +++ b/src/Reports/Processor.ts @@ -1,4 +1,4 @@ -import { Currency } from '../utils/BigInt.js' +import { Currency, Price, Token } from '../utils/BigInt.js' import { groupByPeriod } from '../utils/date.js' import { BalanceSheetData, @@ -10,6 +10,7 @@ import { ReportFilter, InvestorTransactionsData, InvestorTransactionsReport, + InvestorTransactionsReportFilter, } from './types.js' export class Processor { @@ -141,34 +142,60 @@ export class Processor { return this.applyGrouping(items, filter?.groupBy, 'sum') } - investorTransactions(data: InvestorTransactionsData, filter?: ReportFilter): InvestorTransactionsReport[] { - return data.investorTransactions + investorTransactions( + data: InvestorTransactionsData, + filter?: InvestorTransactionsReportFilter + ): InvestorTransactionsReport[] { + const items = data.investorTransactions .filter((day) => { + if (filter?.transactionType === 'all' || !filter?.transactionType) { + return true + } if ( - day.type === 'INVEST_ORDER_UPDATE' || - day.type === 'REDEEM_ORDER_UPDATE' || - day.type === 'INVEST_ORDER_CANCEL' || - day.type === 'REDEEM_ORDER_CANCEL' + filter?.transactionType === 'orders' && + (day.type === 'INVEST_ORDER_UPDATE' || + day.type === 'REDEEM_ORDER_UPDATE' || + day.type === 'INVEST_ORDER_CANCEL' || + day.type === 'REDEEM_ORDER_CANCEL') ) { return true } - if (day.type === 'INVEST_EXECUTION' || day.type === 'REDEEM_EXECUTION') { + if ( + filter?.transactionType === 'executions' && + (day.type === 'INVEST_EXECUTION' || day.type === 'REDEEM_EXECUTION') + ) { return true } if ( - day.type === 'INVEST_COLLECT' || - day.type === 'REDEEM_COLLECT' || - day.type === 'INVEST_LP_COLLECT' || - day.type === 'REDEEM_LP_COLLECT' || - day.type === 'TRANSFER_IN' || - day.type === 'TRANSFER_OUT' + filter?.transactionType === 'transfers' && + (day.type === 'INVEST_COLLECT' || + day.type === 'REDEEM_COLLECT' || + day.type === 'INVEST_LP_COLLECT' || + day.type === 'REDEEM_LP_COLLECT' || + day.type === 'TRANSFER_IN' || + day.type === 'TRANSFER_OUT') ) { return true } return false }) + .filter((day) => { + if (!filter?.network || filter?.network === 'all') return true + return filter?.network === (day.chainId || 'centrifuge') + }) + .filter((day) => { + if (filter?.tokenId) return filter?.tokenId === day.trancheId + return true + }) + .filter((day) => { + if (!filter?.address) return true + return ( + day.accountId.toLowerCase() === filter.address.toLowerCase() || + day.evmAddress?.toLowerCase() === filter.address.toLowerCase() + ) + }) .map((day) => { return { type: 'investorTransactions', @@ -179,11 +206,12 @@ export class Processor { transactionType: day.type, currencyAmount: day.currencyAmount, trancheTokenAmount: day.tokenAmount, - trancheTokenName: '', // TODO: add tranche name + trancheTokenId: day.trancheId, price: day.tokenPrice ?? '', transactionHash: day.hash, - } as InvestorTransactionsReport + } satisfies InvestorTransactionsReport }) + return items } /** @@ -193,13 +221,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 | string | { [key: string]: any } | number | undefined }, >(items: T[], groupBy: ReportFilter['groupBy'] = 'day', strategy: 'latest' | 'sum' = 'latest'): T[] { if (strategy === 'latest') { @@ -210,7 +238,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) { @@ -219,6 +247,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/index.ts b/src/Reports/index.ts index 0b5a03d..b8a0a00 100644 --- a/src/Reports/index.ts +++ b/src/Reports/index.ts @@ -16,6 +16,9 @@ import { InvestorTransactionsReport, ProfitAndLossReport, ReportFilter, + Report, + DataReport, + DataReportFilter, } from './types.js' import { Query } from '../types/query.js' import { @@ -27,8 +30,6 @@ import { Pool } from '../Pool.js' import { investorTransactionsPostProcess } from '../queries/investorTransactions.js' import { InvestorTransactionFilter, investorTransactionsQuery } from '../queries/investorTransactions.js' -type ReportType = 'balanceSheet' | 'cashflow' | 'profitAndLoss' | 'investorTransactions' - export class Reports extends Entity { constructor( centrifuge: Centrifuge, @@ -49,11 +50,18 @@ export class Reports extends Entity { return this._generateReport('profitAndLoss', filter) } - investorTransactions(filter?: ReportFilter) { + investorTransactions(filter?: DataReportFilter) { return this._generateReport('investorTransactions', filter) } - _generateReport(type: ReportType, filter?: ReportFilter): Query { + /** + * Reports are split into two types: + * - A `Report` is a standard report: balanceSheet, cashflow, profitAndLoss + * - A `DataReport` is a custom report: investorTransactions + */ + _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], () => { @@ -109,7 +117,7 @@ export class Reports extends Entity { return combineLatest([investorTransactions$, metadata$]).pipe( map( ([investorTransactions, metadata]) => - processor.investorTransactions({ investorTransactions, metadata }, filter) as T[] + processor.investorTransactions({ investorTransactions, metadata }) as T[] ) ) default: diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 8c20b98..f62f3cc 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -13,6 +13,23 @@ export interface ReportFilter { groupBy?: GroupBy } +export type DataReportFilter = { + to?: string + from?: string +} + +export type InvestorTransactionsReportFilter = { + tokenId?: string + transactionType?: 'orders' | 'executions' | 'transfers' | 'all' + network?: number | 'centrifuge' | 'all' + address?: string + to?: string + from?: string +} + +export type Report = 'balanceSheet' | 'cashflow' | 'profitAndLoss' +export type DataReport = 'investorTransactions' + export type BalanceSheetReport = { type: 'balanceSheet' timestamp: string @@ -114,7 +131,7 @@ export type InvestorTransactionsReport = { epoch: string transactionType: SubqueryInvestorTransactionType currencyAmount: Currency - trancheTokenName: string + trancheTokenId: string trancheTokenAmount: Currency price: Price transactionHash: string diff --git a/src/queries/investorTransactions.ts b/src/queries/investorTransactions.ts index a8aed1c..9c9c2bf 100644 --- a/src/queries/investorTransactions.ts +++ b/src/queries/investorTransactions.ts @@ -12,10 +12,10 @@ export type InvestorTransaction = { trancheId: string epochNumber: number type: SubqueryInvestorTransactionType - currencyAmount?: Currency - tokenAmount?: Currency - tokenPrice?: Price - transactionFee?: Currency + currencyAmount: Currency + tokenAmount: Currency + tokenPrice: Price + transactionFee: Currency chainId: number evmAddress?: string hash: string @@ -76,10 +76,10 @@ export const investorTransactionsPostProcess = (data: SubqueryInvestorTransactio trancheId: tx.trancheId.split('-')[1] ?? '', epochNumber: tx.epochNumber, type: tx.type as SubqueryInvestorTransactionType, - currencyAmount: tx.currencyAmount ? new Currency(tx.currencyAmount, currencyDecimals) : undefined, - tokenAmount: tx.tokenAmount ? new Currency(tx.tokenAmount, currencyDecimals) : undefined, - tokenPrice: tx.tokenPrice ? new Price(tx.tokenPrice) : undefined, - transactionFee: tx.transactionFee ? new Currency(tx.transactionFee, currencyDecimals) : undefined, // native tokenks are always denominated in 18 + 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 })