Skip to content

Commit

Permalink
Add filters and tests to investor transaction report
Browse files Browse the repository at this point in the history
  • Loading branch information
sophialittlejohn committed Dec 3, 2024
1 parent 4e1645d commit 54c796e
Show file tree
Hide file tree
Showing 5 changed files with 229 additions and 35 deletions.
133 changes: 131 additions & 2 deletions src/Reports/Processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
78 changes: 59 additions & 19 deletions src/Reports/Processor.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +10,7 @@ import {
ReportFilter,
InvestorTransactionsData,
InvestorTransactionsReport,
InvestorTransactionsReportFilter,
} from './types.js'

export class Processor {
Expand Down Expand Up @@ -141,34 +142,60 @@ export class Processor {
return this.applyGrouping<ProfitAndLossReport>(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',
Expand All @@ -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
}

/**
Expand All @@ -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') {
Expand All @@ -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) {
Expand All @@ -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
})
Expand Down
18 changes: 13 additions & 5 deletions src/Reports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
InvestorTransactionsReport,
ProfitAndLossReport,
ReportFilter,
Report,
DataReport,
DataReportFilter,
} from './types.js'
import { Query } from '../types/query.js'
import {
Expand All @@ -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,
Expand All @@ -49,11 +50,18 @@ export class Reports extends Entity {
return this._generateReport<ProfitAndLossReport>('profitAndLoss', filter)
}

investorTransactions(filter?: ReportFilter) {
investorTransactions(filter?: DataReportFilter) {
return this._generateReport<InvestorTransactionsReport>('investorTransactions', filter)
}

_generateReport<T>(type: ReportType, filter?: ReportFilter): Query<T[]> {
/**
* Reports are split into two types:
* - A `Report` is a standard report: balanceSheet, cashflow, profitAndLoss
* - A `DataReport` is a custom report: investorTransactions
*/
_generateReport<T>(type: Report, filter?: ReportFilter): Query<T[]>
_generateReport<T>(type: DataReport, filter?: DataReportFilter): Query<T[]>
_generateReport<T>(type: string, filter?: Record<string, any>) {
return this._query(
[type, filter?.from, filter?.to, filter?.groupBy],
() => {
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion src/Reports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,7 +131,7 @@ export type InvestorTransactionsReport = {
epoch: string
transactionType: SubqueryInvestorTransactionType
currencyAmount: Currency
trancheTokenName: string
trancheTokenId: string
trancheTokenAmount: Currency
price: Price
transactionHash: string
Expand Down
Loading

0 comments on commit 54c796e

Please sign in to comment.