Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reports #20

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/Reports/Processor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { expect } from 'chai'
import { processor } from './Processor.js'
import { mockPoolSnapshots } from '../tests/mocks/mockPoolSnapshots.js'
import { mockTrancheSnapshots } from '../tests/mocks/mockTrancheSnapshots.js'
import { PoolSnapshot } from '../queries/poolSnapshots.js'
import { Currency } from '../utils/BigInt.js'

describe('Processor', () => {
it('should process pool and tranche data correctly', () => {
const result = processor.balanceSheet({
poolSnapshots: mockPoolSnapshots,
trancheSnapshots: mockTrancheSnapshots,
})

expect(result).to.have.lengthOf(2)
const report = result[0]

expect(report?.timestamp).to.equal('2024-01-01T12:00:00Z')
expect(report?.assetValuation.toBigInt()).to.equal(0n)
expect(report?.onchainReserve.toBigInt()).to.equal(0n)
expect(report?.offchainCash.toBigInt()).to.equal(0n)
expect(report?.accruedFees.toBigInt()).to.equal(0n)
expect(report?.netAssetValue.toBigInt()).to.equal(0n)

expect(report?.tranches?.length).to.equal(2)
const seniorTranche = report?.tranches?.find((t) => t.tokenId === 'senior')!
const juniorTranche = report?.tranches?.find((t) => t.tokenId === 'junior')!

expect(seniorTranche?.tokenPrice!.toBigInt()).to.equal(1000000000000000000n)
expect(juniorTranche?.tokenPrice!.toBigInt()).to.equal(1120000000000000000n)
expect(report?.totalCapital?.toBigInt()).to.equal(0n)
})

it('should throw error when no tranches found', () => {
expect(() =>
processor.balanceSheet({
poolSnapshots: mockPoolSnapshots,
trancheSnapshots: [],
})
).to.throw('No tranches found for snapshot')
})
describe('balanceSheet', () => {
it('should group data by day when specified', () => {
const result = processor.balanceSheet(
{
poolSnapshots: mockPoolSnapshots,
trancheSnapshots: mockTrancheSnapshots,
},
{ groupBy: 'day' }
)

expect(result).to.have.lengthOf(2)
expect(result?.[0]?.timestamp.slice(0, 10)).to.equal('2024-01-01')
expect(result?.[1]?.timestamp.slice(0, 10)).to.equal('2024-01-02')
})

it('should group data by month when specified', () => {
const result = processor.balanceSheet(
{
poolSnapshots: mockPoolSnapshots,
trancheSnapshots: mockTrancheSnapshots,
},
{ groupBy: 'month' }
)

expect(result).to.have.lengthOf(1)
expect(result?.[0]?.timestamp.slice(0, 10)).to.equal('2024-01-02')
})
})

describe('cashflow', () => {
// Create specific mocks focusing only on key fields for aggregation testing
const mockCashflowSnapshots: PoolSnapshot[] = [
{
...mockPoolSnapshots[0],
id: 'pool-10',
timestamp: '2024-01-01T12:00:00Z',
sumPrincipalRepaidAmountByPeriod: Currency.fromFloat(1, 6), // 1.0
sumInterestRepaidAmountByPeriod: Currency.fromFloat(0.05, 6), // 0.05
sumBorrowedAmountByPeriod: Currency.fromFloat(2, 6), // 2.0
},
{
...mockPoolSnapshots[0],
id: 'pool-11',
timestamp: '2024-01-01T18:00:00Z', // Same day, different time
sumPrincipalRepaidAmountByPeriod: Currency.fromFloat(0.5, 6), // 0.5
sumInterestRepaidAmountByPeriod: Currency.fromFloat(0.025, 6), // 0.025
sumBorrowedAmountByPeriod: Currency.fromFloat(1, 6), // 1.0
},
{
...mockPoolSnapshots[0],
id: 'pool-12',
timestamp: '2024-01-02T12:00:00Z', // Next day
sumPrincipalRepaidAmountByPeriod: Currency.fromFloat(2, 6), // 2.0
sumInterestRepaidAmountByPeriod: Currency.fromFloat(0.1, 6), // 0.1
sumBorrowedAmountByPeriod: Currency.fromFloat(4, 6), // 4.0
},
] as PoolSnapshot[]

it('should aggregate values correctly when grouping by day', () => {
const result = processor.cashflow({ poolSnapshots: mockCashflowSnapshots }, { groupBy: 'day' })

expect(result).to.have.lengthOf(2)

const jan1 = result[0]
expect(jan1?.timestamp.slice(0, 10)).to.equal('2024-01-01')
expect(jan1?.principalPayments.toFloat()).to.equal(1.5) // 1.0 + 0.5
expect(jan1?.interestPayments.toFloat()).to.equal(0.075) // 0.05 + 0.025
expect(jan1?.assetPurchases.toFloat()).to.equal(3) // 2.0 + 1.0

const jan2 = result[1]
expect(jan2?.timestamp.slice(0, 10)).to.equal('2024-01-02')
expect(jan2?.principalPayments.toFloat()).to.equal(2) // 2.0
expect(jan2?.interestPayments.toFloat()).to.equal(0.1) // 0.1
expect(jan2?.assetPurchases.toFloat()).to.equal(4) // 4.0
})

it('should aggregate values correctly when grouping by month', () => {
const result = processor.cashflow({ poolSnapshots: mockCashflowSnapshots }, { groupBy: 'month' })

expect(result).to.have.lengthOf(1)

const january = result[0]
expect(january?.timestamp.slice(0, 7)).to.equal('2024-01')
expect(january?.principalPayments.toFloat()).to.equal(3.5) // 1.0 + 0.5 + 2.0
expect(january?.interestPayments.toFloat()).to.equal(0.175) // 0.05 + 0.025 + 0.1
expect(january?.assetPurchases.toFloat()).to.equal(7) // 2.0 + 1.0 + 4.0
})
})
})
140 changes: 140 additions & 0 deletions src/Reports/Processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { TrancheSnapshot } from '../queries/trancheSnapshots.js'
import { Currency } from '../utils/BigInt.js'
import { groupByPeriod } from '../utils/date.js'
import { BalanceSheetData, BalanceSheetReport, CashflowData, CashflowReport, ReportFilter } from './types.js'

export class Processor {
/**
* Process raw data into a balance sheet report
* @param data Pool and tranche snapshot data
* @param filter Optional filtering and grouping options
* @returns Processed balance sheet report
*/
balanceSheet(data: BalanceSheetData, filter?: ReportFilter): BalanceSheetReport[] {
const trancheSnapshotsByDate = this.groupTranchesByDate(data.trancheSnapshots)
const items: BalanceSheetReport[] = data?.poolSnapshots?.map((snapshot) => {
const tranches = trancheSnapshotsByDate.get(this.getDateKey(snapshot.timestamp)) ?? []
if (tranches.length === 0) throw new Error('No tranches found for snapshot')
return {
type: 'balanceSheet',
timestamp: snapshot.timestamp,
assetValuation: snapshot.portfolioValuation,
onchainReserve: snapshot.totalReserve,
offchainCash: snapshot.offchainCashValue,
accruedFees: snapshot.sumPoolFeesPendingAmount,
netAssetValue: snapshot.netAssetValue,
tranches: tranches?.map((tranche) => ({
name: tranche.pool.currency.symbol,
timestamp: tranche.timestamp,
tokenId: tranche.trancheId,
tokenSupply: tranche.tokenSupply,
tokenPrice: tranche.price,
trancheValue: tranche.tokenSupply.mul(tranche?.price?.toBigInt() ?? 0n),
})),
totalCapital: tranches.reduce(
(acc, curr) => acc.add(curr.tokenSupply.mul(curr?.price?.toBigInt() ?? 0n).toBigInt()),
new Currency(0, snapshot.poolCurrency.decimals)
),
}
})
return this.applyGrouping<BalanceSheetReport>(items, filter, 'latest')
}

cashflow(data: CashflowData, filter?: ReportFilter): CashflowReport[] {
// TODO: requires pool metadata which requires querying the pool on chain?
// check if metadata is available in the snapshot
const items: CashflowReport[] = data.poolSnapshots.map((day) => {
const principalRepayments = day.sumPrincipalRepaidAmountByPeriod
const interest = day.sumInterestRepaidAmountByPeriod.add(day.sumUnscheduledRepaidAmountByPeriod)
const purchases = day.sumBorrowedAmountByPeriod
const fees = day.sumPoolFeesPaidAmountByPeriod
const netCashflowAsset = principalRepayments.sub(purchases).add(interest)
const investments = day.sumInvestedAmountByPeriod
const redemptions = day.sumRedeemedAmountByPeriod
const activitiesCashflow = investments.sub(redemptions)
const netCashflowAfterFees = netCashflowAsset.sub(fees)
const totalCashflow = netCashflowAfterFees.add(activitiesCashflow)
return {
type: 'cashflow',
timestamp: day.timestamp,
principalPayments: principalRepayments,
realizedPL: day.sumRealizedProfitFifoByPeriod, // show only for pools that are public credit pools
interestPayments: interest,
assetPurchases: purchases,
netCashflowAsset,
fees: [{ name: 'Management Fee', amount: fees }],
netCashflowAfterFees,
investments,
redemptions,
activitiesCashflow,
totalCashflow,
endCashBalance: day.totalReserve.add(day.offchainCashValue),
}
})
return this.applyGrouping<CashflowReport>(items, filter, 'sum')
}

private applyGrouping<T extends CashflowReport | BalanceSheetReport>(
items: T[],
filter?: ReportFilter,
strategy: 'latest' | 'sum' = 'latest'
): T[] {
if (!filter?.groupBy) return items

const grouped = groupByPeriod<T>(items, filter.groupBy)

if (strategy === 'latest') {
return grouped
}

return grouped.map((latest) => {
const result = { ...latest } as T
const itemsInGroup = items.filter(
(item) =>
this.getDateKey(item.timestamp, filter?.groupBy) === this.getDateKey(latest.timestamp, filter?.groupBy)
)

for (const key in latest) {
const value = latest[key as keyof T]
if (value instanceof Currency) {
result[key as keyof T] = itemsInGroup.reduce(
(sum, item) => sum.add(item[key as keyof T] as Currency),
new Currency(0n, value.decimals)
) as T[keyof T]
}
}

return result
})
}

private getDateKey(timestamp: string, groupBy?: ReportFilter['groupBy']): string {
switch (groupBy) {
case 'month':
return timestamp.slice(0, 7) // YYYY-MM
case 'quarter':
const date = new Date(timestamp)
const quarter = Math.floor(date.getMonth() / 3) + 1
return `${date.getFullYear()}-Q${quarter}` // YYYY-Q#
case 'year':
return timestamp.slice(0, 4) // YYYY
default:
return timestamp.slice(0, 10) // YYYY-MM-DD (daily is default)
}
}

private groupTranchesByDate(trancheSnapshots: TrancheSnapshot[]): Map<string, TrancheSnapshot[]> {
const grouped = new Map<string, TrancheSnapshot[]>()
if (!trancheSnapshots) return grouped
trancheSnapshots?.forEach((snapshot) => {
const date = this.getDateKey(snapshot.timestamp)
if (!grouped.has(date)) {
grouped.set(date, [])
}
grouped.get(date)!.push(snapshot)
})
return grouped
}
}

export const processor = new Processor()
69 changes: 57 additions & 12 deletions src/Reports/Reports.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { expect } from 'chai'
import { Centrifuge } from '../Centrifuge.js'
import { spy } from 'sinon'
import { ReportFilter, Reports } from '../Reports/index.js'
import * as balanceSheetProcessor from '../Reports/processors/balanceSheet.js'
import { firstValueFrom } from 'rxjs'
import { Reports } from '../Reports/index.js'
import { ReportFilter } from './types.js'
import { processor } from './Processor.js'

describe('Reports', () => {
let centrifuge: Centrifuge

before(async () => {
before(() => {
centrifuge = new Centrifuge({
environment: 'mainnet',
indexerUrl: 'https://subql.embrio.tech/',
Expand All @@ -19,20 +19,20 @@ describe('Reports', () => {
const ns3PoolId = '1615768079'
const pool = await centrifuge.pool(ns3PoolId)
const balanceSheetReport = await pool.reports.balanceSheet({
from: '2024-11-03T22:11:29.776Z',
from: '2024-11-02T22:11:29.776Z',
to: '2024-11-06T22:11:29.776Z',
groupBy: 'day',
})
expect(balanceSheetReport.length).to.be.eql(3)
expect(balanceSheetReport.length).to.be.eql(4)
expect(balanceSheetReport?.[0]?.tranches?.length ?? 0).to.be.eql(2) // ns3 has 2 tranches
expect(balanceSheetReport?.[0]?.tranches?.[0]?.timestamp.slice(0, 10)).to.be.eql(
balanceSheetReport?.[0]?.date.slice(0, 10)
balanceSheetReport?.[0]?.timestamp.slice(0, 10)
)
})

// TODO: this test is not working as expected
it('should use cached data for repeated queries', async () => {
const processBalanceSheetSpy = spy(balanceSheetProcessor, 'processBalanceSheetData')

const processBalanceSheetSpy = spy(processor, 'balanceSheet')
const ns3PoolId = '1615768079'
const reports = new Reports(centrifuge, ns3PoolId)

Expand All @@ -42,12 +42,57 @@ describe('Reports', () => {
groupBy: 'day',
}

await firstValueFrom(reports.balanceSheet(filter))
await reports.balanceSheet(filter)
expect(processBalanceSheetSpy.callCount).to.equal(1)

// Same query should use cache
await firstValueFrom(reports.balanceSheet(filter))
// TODO: Can't spy on es module
await reports.balanceSheet(filter)
expect(processBalanceSheetSpy.callCount).to.equal(1)

processBalanceSheetSpy.restore()
})

it('should fetch new data for different query', async () => {
const processBalanceSheetSpy = spy(processor, 'balanceSheet')
const ns3PoolId = '1615768079'
const reports = new Reports(centrifuge, ns3PoolId)

const filter: ReportFilter = {
from: '2024-11-03T22:11:29.776Z',
to: '2024-11-06T22:11:29.776Z',
groupBy: 'day',
}

const report = await reports.balanceSheet(filter)
expect(processBalanceSheetSpy.callCount).to.equal(1)
expect(report.length).to.equal(3)

// Different query should fetch new data
const report2 = await reports.balanceSheet({ ...filter, to: '2024-11-10T22:11:29.776Z' })
expect(processBalanceSheetSpy.callCount).to.equal(2)
expect(report2.length).to.equal(7)

const report3 = await reports.balanceSheet({ ...filter, groupBy: 'month' })
expect(processBalanceSheetSpy.callCount).to.equal(3)
expect(report3.length).to.equal(1)

processBalanceSheetSpy.restore()
})

it('should get cashflow report', async () => {
const processCashflowSpy = spy(processor, 'cashflow')
const ns3PoolId = '1615768079'
const pool = await centrifuge.pool(ns3PoolId)
const cashflowReport = await pool.reports.cashflow({
from: '2024-11-02T22:11:29.776Z',
to: '2024-11-06T22:11:29.776Z',
groupBy: 'day',
})
expect(cashflowReport.length).to.be.eql(4)
expect(processCashflowSpy.callCount).to.equal(1)
// expect(cashflowReport?.[0]?.tranches?.length ?? 0).to.be.eql(2) // ns3 has 2 tranches
// expect(cashflowReport?.[0]?.tranches?.[0]?.timestamp.slice(0, 10)).to.be.eql(
// cashflowReport?.[0]?.timestamp.slice(0, 10)
// )
})
})
Loading