diff --git a/scripts/db-restore.sh b/scripts/db-restore.sh index 26b07952..75ec5a62 100755 --- a/scripts/db-restore.sh +++ b/scripts/db-restore.sh @@ -26,6 +26,10 @@ docker cp $BACKUP_FILE $CONTAINER_NAME:/tmp/latest.sql.gz echo "๐Ÿ—„๏ธ Importing backup into database '$DB_NAME'..." docker exec -i $CONTAINER_NAME bash -c "gunzip -c /tmp/latest.sql.gz | psql -U $DB_USER -d $DB_NAME" +# === RESET LOCAL PASSWORD (dumps may contain ALTER ROLE that overwrite it) === +echo "๐Ÿ”‘ Resetting local password for '$DB_USER'..." +docker exec -i $CONTAINER_NAME psql -U $DB_USER -d $DB_NAME -c "ALTER USER $DB_USER WITH PASSWORD '$DB_PASSWORD';" + # === CLEANUP === echo "๐Ÿงน Cleaning up..." docker exec -i $CONTAINER_NAME rm /tmp/latest.sql.gz diff --git a/src/account/controllers/accounts.controller.spec.ts b/src/account/controllers/accounts.controller.spec.ts index d84ebe50..e2775a0b 100644 --- a/src/account/controllers/accounts.controller.spec.ts +++ b/src/account/controllers/accounts.controller.spec.ts @@ -26,6 +26,12 @@ describe('AccountsController', () => { let profileReadService: { getProfile: jest.Mock; }; + let portfolioService: { + resolveAccountAddress: jest.Mock; + }; + let bclPnlService: { + calculateTradingStats: jest.Mock; + }; beforeEach(() => { queryBuilder = createQueryBuilder(); @@ -40,10 +46,17 @@ describe('AccountsController', () => { profileReadService = { getProfile: jest.fn(), }; + portfolioService = { + resolveAccountAddress: jest.fn().mockImplementation((a) => a), + }; + bclPnlService = { + calculateTradingStats: jest.fn(), + }; controller = new AccountsController( accountRepository as any, - {} as any, + portfolioService as any, + bclPnlService as any, accountService as any, profileReadService as any, ); diff --git a/src/account/controllers/accounts.controller.ts b/src/account/controllers/accounts.controller.ts index f63020ab..dbee580d 100644 --- a/src/account/controllers/accounts.controller.ts +++ b/src/account/controllers/accounts.controller.ts @@ -23,9 +23,12 @@ import { paginate } from 'nestjs-typeorm-paginate'; import { Brackets, Repository } from 'typeorm'; import { Account } from '../entities/account.entity'; import { PortfolioService } from '../services/portfolio.service'; +import { BclPnlService } from '../services/bcl-pnl.service'; import { AccountService } from '../services/account.service'; import { GetPortfolioHistoryQueryDto } from '../dto/get-portfolio-history-query.dto'; import { PortfolioHistorySnapshotDto } from '../dto/portfolio-history-response.dto'; +import { TradingStatsQueryDto } from '../dto/trading-stats-query.dto'; +import { TradingStatsResponseDto } from '../dto/trading-stats-response.dto'; import { ProfileReadService } from '@/profile/services/profile-read.service'; import { ProfileCache } from '@/profile/entities/profile-cache.entity'; @@ -39,6 +42,7 @@ export class AccountsController { @InjectRepository(Account) private readonly accountRepository: Repository, private readonly portfolioService: PortfolioService, + private readonly bclPnlService: BclPnlService, private readonly accountService: AccountService, private readonly profileReadService: ProfileReadService, ) { @@ -190,6 +194,40 @@ export class AccountsController { }); } + // Portfolio stats endpoint - MUST come before :address route to avoid route conflict + @ApiOperation({ operationId: 'getPortfolioStats' }) + @ApiParam({ name: 'address', type: 'string', description: 'Account address' }) + @ApiOkResponse({ type: TradingStatsResponseDto }) + @CacheTTL(60 * 10) // 10 minutes + @Get(':address/portfolio/stats') + async getPortfolioStats( + @Param('address') address: string, + @Query() query: TradingStatsQueryDto, + ): Promise { + const start = query.startDate + ? moment(query.startDate).toDate() + : moment().subtract(30, 'days').toDate(); + const end = query.endDate ? moment(query.endDate).toDate() : new Date(); + + const resolvedAddress = + await this.portfolioService.resolveAccountAddress(address); + + const stats = await this.bclPnlService.calculateTradingStats( + resolvedAddress, + start, + end, + ); + + return { + top_win: stats.topWin, + unrealized_profit: stats.unrealizedProfit, + win_rate: stats.winRate, + avg_duration_seconds: stats.avgDurationSeconds, + total_trades: stats.totalTrades, + winning_trades: stats.winningTrades, + }; + } + // single account - MUST come after more specific routes @ApiOperation({ operationId: 'getAccount' }) @ApiParam({ name: 'address', type: 'string' }) diff --git a/src/account/dto/trading-stats-query.dto.ts b/src/account/dto/trading-stats-query.dto.ts new file mode 100644 index 00000000..86c4a979 --- /dev/null +++ b/src/account/dto/trading-stats-query.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class TradingStatsQueryDto { + @ApiProperty({ + name: 'startDate', + type: 'string', + required: false, + description: 'Start date (ISO 8601, inclusive). Defaults to 30 days ago.', + example: '2026-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsString() + startDate?: string; + + @ApiProperty({ + name: 'endDate', + type: 'string', + required: false, + description: 'End date (ISO 8601, exclusive). Defaults to now.', + example: '2026-01-31T23:59:59.999Z', + }) + @IsOptional() + @IsString() + endDate?: string; +} diff --git a/src/account/dto/trading-stats-response.dto.ts b/src/account/dto/trading-stats-response.dto.ts new file mode 100644 index 00000000..43d8f524 --- /dev/null +++ b/src/account/dto/trading-stats-response.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PnlAmountDto } from './pnl-response.dto'; + +export class TradingStatsResponseDto { + @ApiProperty({ + description: 'Largest single realized gain from any sell in the date range', + type: () => PnlAmountDto, + }) + top_win: PnlAmountDto; + + @ApiProperty({ + description: + 'Current unrealized profit across all tokens still held (all-time holdings, not date-filtered)', + type: () => PnlAmountDto, + }) + unrealized_profit: PnlAmountDto; + + @ApiProperty({ + description: + 'Percentage of sell transactions in the range that produced a positive gain', + example: 66.7, + }) + win_rate: number; + + @ApiProperty({ + description: + 'Average holding duration in seconds from first buy to sell (across sells in the range)', + example: 259200, + }) + avg_duration_seconds: number; + + @ApiProperty({ + description: 'Total number of sell transactions in the date range', + example: 6, + }) + total_trades: number; + + @ApiProperty({ + description: 'Number of sell transactions that produced a positive gain', + example: 4, + }) + winning_trades: number; +} diff --git a/src/account/services/bcl-pnl.service.spec.ts b/src/account/services/bcl-pnl.service.spec.ts index 487ba59b..61bec771 100644 --- a/src/account/services/bcl-pnl.service.spec.ts +++ b/src/account/services/bcl-pnl.service.spec.ts @@ -1,4 +1,4 @@ -import { BclPnlService } from './bcl-pnl.service'; +import { BclPnlService, DailyPnlWindow } from './bcl-pnl.service'; describe('BclPnlService', () => { const createService = () => { @@ -72,26 +72,33 @@ describe('BclPnlService', () => { expect.any(String), ['ak_test', 100], ); + + // Token one: spent 18 AE total, received 4 AE from sells, current value = 4 * 5 = 20 + // gain = received + currentValue - spent = 4 + 20 - 18 = 6 + // invested = totalAmountSpent = 18 expect(result.pnls.ct_token_one).toEqual({ current_unit_price: { ae: 5, usd: 10 }, - percentage: (8 / 12) * 100, - invested: { ae: 12, usd: 24 }, + percentage: (6 / 18) * 100, + invested: { ae: 18, usd: 36 }, current_value: { ae: 20, usd: 40 }, - gain: { ae: 8, usd: 16 }, + gain: { ae: 6, usd: 12 }, }); + + // Token two: spent 12 AE, no sells, current value = 2 * 8 = 16 + // gain = 0 + 16 - 12 = 4 expect(result.pnls.ct_token_two).toEqual({ current_unit_price: { ae: 8, usd: 16 }, - percentage: (10 / 6) * 100, - invested: { ae: 6, usd: 12 }, + percentage: (4 / 12) * 100, + invested: { ae: 12, usd: 24 }, current_value: { ae: 16, usd: 32 }, - gain: { ae: 10, usd: 20 }, + gain: { ae: 4, usd: 8 }, }); - expect(result.totalCostBasisAe).toBe(18); - expect(result.totalCostBasisUsd).toBe(36); + expect(result.totalCostBasisAe).toBe(30); + expect(result.totalCostBasisUsd).toBe(60); expect(result.totalCurrentValueAe).toBe(36); expect(result.totalCurrentValueUsd).toBe(72); - expect(result.totalGainAe).toBe(18); - expect(result.totalGainUsd).toBe(36); + expect(result.totalGainAe).toBe(10); + expect(result.totalGainUsd).toBe(20); }); it('calculateTokenPnlsBatch runs a single query for all heights and groups results', async () => { @@ -191,8 +198,13 @@ describe('BclPnlService', () => { expect(params).toEqual(['ak_test', [500], 50]); }); - it('preserves range-based pnl result semantics', async () => { + it('preserves range-based pnl result semantics (realized gains only)', async () => { const { service, transactionRepository } = createService(); + + // Scenario: token was bought historically (6 tokens for 24 AE total, avg 4 AE each). + // In the range: 4 more were bought for 20 AE, and 1 was sold for 8 AE. + // Cumulative: 6 tokens for 24 AE total gives avg cost = 4 AE/token. + // Range gain (realized only) = proceeds - (avgCost * volumeSold) = 8 - (4 * 1) = 4 AE transactionRepository.query.mockResolvedValue([ { sale_address: 'ct_range', @@ -205,23 +217,385 @@ describe('BclPnlService', () => { total_volume_sold: '1', current_unit_price_ae: '7', current_unit_price_usd: '14', + cumulative_volume_bought: '6', + cumulative_amount_spent_ae: '24', + cumulative_amount_spent_usd: '48', }, ]); const result = await service.calculateTokenPnls('ak_test', 100, 50); + // avgCostAe = 24/6 = 4; costBasisAe = 4 * 1 sold = 4 + // gainAe = 8 (proceeds) - 4 (cost) = 4 + // current_value always = currentHoldings * unitPrice = 5 * 7 = 35 (for portfolio chart) expect(result.pnls.ct_range).toEqual({ current_unit_price: { ae: 7, usd: 14 }, - percentage: (9 / 20) * 100, - invested: { ae: 20, usd: 40 }, + percentage: (4 / 4) * 100, + invested: { ae: 4, usd: 8 }, current_value: { ae: 35, usd: 70 }, - gain: { ae: 9, usd: 18 }, + gain: { ae: 4, usd: 8 }, }); - expect(result.totalCostBasisAe).toBe(20); - expect(result.totalCostBasisUsd).toBe(40); + expect(result.totalCostBasisAe).toBe(4); + expect(result.totalCostBasisUsd).toBe(8); expect(result.totalCurrentValueAe).toBe(35); expect(result.totalCurrentValueUsd).toBe(70); - expect(result.totalGainAe).toBe(9); - expect(result.totalGainUsd).toBe(18); + expect(result.totalGainAe).toBe(4); + expect(result.totalGainUsd).toBe(8); + }); + + it('includes fully closed positions in cumulative pnl', async () => { + const { service, transactionRepository } = createService(); + + // Token was bought for 10 AE and fully sold for 15 AE โ€” position closed. + // current_holdings = 0, but realized gain should still be reported. + transactionRepository.query.mockResolvedValue([ + { + sale_address: 'ct_closed', + current_holdings: '0', + total_volume_bought: '100', + total_amount_spent_ae: '10', + total_amount_spent_usd: '20', + total_amount_received_ae: '15', + total_amount_received_usd: '30', + total_volume_sold: '100', + current_unit_price_ae: '0.15', + current_unit_price_usd: '0.3', + }, + ]); + + const result = await service.calculateTokenPnls('ak_test', 200); + + // costBasisAe = totalAmountSpentAe = 10 + // currentValueAe = 0 * 0.15 = 0 (position is closed) + // gainAe = 15 (received) + 0 (current) - 10 (spent) = 5 + expect(result.pnls.ct_closed).toEqual({ + current_unit_price: { ae: 0.15, usd: 0.3 }, + percentage: (5 / 10) * 100, + invested: { ae: 10, usd: 20 }, + current_value: { ae: 0, usd: 0 }, + gain: { ae: 5, usd: 10 }, + }); + expect(result.totalCostBasisAe).toBe(10); + expect(result.totalGainAe).toBe(5); + expect(result.totalCurrentValueAe).toBe(0); + }); + + it('includes token sold in range but bought before range with correct realized pnl', async () => { + const { service, transactionRepository } = createService(); + + // Token was bought entirely before the range (cumulative: 10 tokens for 50 AE = 5 AE avg). + // In the range: 0 new buys, but 10 tokens sold for 80 AE. + // Expected realized gain = 80 - (5 * 10) = 30 AE + transactionRepository.query.mockResolvedValue([ + { + sale_address: 'ct_pre_bought', + current_holdings: '0', + total_volume_bought: '0', // no buys in range + total_amount_spent_ae: '0', // no buys in range + total_amount_spent_usd: '0', + total_amount_received_ae: '80', + total_amount_received_usd: '160', + total_volume_sold: '10', + current_unit_price_ae: '8', + current_unit_price_usd: '16', + cumulative_volume_bought: '10', + cumulative_amount_spent_ae: '50', + cumulative_amount_spent_usd: '100', + }, + ]); + + const result = await service.calculateTokenPnls('ak_test', 300, 200); + + // avgCostAe = 50/10 = 5; costBasisAe = 5 * 10 = 50 + // gainAe = 80 (proceeds) - 50 (cost) = 30 + expect(result.pnls.ct_pre_bought).toEqual({ + current_unit_price: { ae: 8, usd: 16 }, + percentage: (30 / 50) * 100, + invested: { ae: 50, usd: 100 }, + current_value: { ae: 0, usd: 0 }, + gain: { ae: 30, usd: 60 }, + }); + expect(result.totalCostBasisAe).toBe(50); + expect(result.totalGainAe).toBe(30); + expect(result.totalCurrentValueAe).toBe(0); + }); + + // โ”€โ”€ calculateDailyPnlBatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('calculateDailyPnlBatch issues one query with two float8 arrays and returns map keyed by snapshotTs', async () => { + const { service, transactionRepository } = createService(); + transactionRepository.query.mockResolvedValue([]); + + const DAY1 = Date.UTC(2026, 0, 1); + const DAY2 = Date.UTC(2026, 0, 2); + const windows: DailyPnlWindow[] = [ + { snapshotTs: DAY1, dayStartTs: DAY1 }, + { snapshotTs: DAY2, dayStartTs: DAY1 }, + ]; + + const result = await service.calculateDailyPnlBatch('ak_test', windows); + + expect(transactionRepository.query).toHaveBeenCalledTimes(1); + const [sql, params] = transactionRepository.query.mock.calls[0]; + + expect(sql).toContain('to_timestamp(unnest($2::float8[]))'); + expect(sql).toContain('to_timestamp(unnest($3::float8[]))'); + expect(sql).toContain('AS snapshot_ts'); + expect(sql).toContain('AS day_start_ts'); + expect(sql).toContain('snapshot_ts_ms'); + expect(sql).toContain('AS MATERIALIZED'); + + // $2 = snapshot epoch seconds, $3 = day-start epoch seconds + expect(params[0]).toBe('ak_test'); + expect(params[1]).toEqual([DAY1 / 1000, DAY2 / 1000]); + expect(params[2]).toEqual([DAY1 / 1000, DAY1 / 1000]); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get(DAY1)).toBeDefined(); + expect(result.get(DAY2)).toBeDefined(); + }); + + it('calculateDailyPnlBatch returns empty map for empty windows array', async () => { + const { service, transactionRepository } = createService(); + + const result = await service.calculateDailyPnlBatch('ak_test', []); + + expect(transactionRepository.query).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it('calculateDailyPnlBatch: sells before dayStartTs are excluded from daily gain', async () => { + const { service, transactionRepository } = createService(); + + const DAY2 = Date.UTC(2026, 0, 2); + const DAY1 = Date.UTC(2026, 0, 1); + + // The DB returns a row for a token where sells in the window = 0 + // (sells from before dayStart were excluded by the SQL WHERE clause). + // Simulate: 10 tokens bought all-time, 5 sold before this day window, + // 0 sold within today's window. + transactionRepository.query.mockResolvedValue([ + { + snapshot_ts_ms: String(DAY2), + sale_address: 'ct_no_daily_sell', + current_holdings: '5', + cumulative_volume_bought: '10', + cumulative_amount_spent_ae: '50', + cumulative_amount_spent_usd: '100', + total_volume_sold: '0', + total_amount_received_ae: '0', + total_amount_received_usd: '0', + current_unit_price_ae: '6', + current_unit_price_usd: '12', + }, + ]); + + const result = await service.calculateDailyPnlBatch('ak_test', [ + { snapshotTs: DAY2, dayStartTs: DAY1 }, + ]); + + const day2 = result.get(DAY2)!; + // No sells today โ†’ gain = 0, costBasis = 0 + expect(day2.pnls['ct_no_daily_sell'].gain.ae).toBe(0); + expect(day2.pnls['ct_no_daily_sell'].invested.ae).toBe(0); + // But current_value is still reported for portfolio display + expect(day2.pnls['ct_no_daily_sell'].current_value.ae).toBe(5 * 6); + }); + + it('calculateDailyPnlBatch: token sold in day window uses all-time avg cost for gain', async () => { + const { service, transactionRepository } = createService(); + + // 10 tokens bought all-time for 50 AE total (avg 5 AE/token). + // Today: 2 tokens sold for 14 AE. + // Expected gain = 14 - (5 * 2) = 4 AE. + const DAY3 = Date.UTC(2026, 0, 3); + const DAY2 = Date.UTC(2026, 0, 2); + + transactionRepository.query.mockResolvedValue([ + { + snapshot_ts_ms: String(DAY3), + sale_address: 'ct_daily_sell', + current_holdings: '8', + cumulative_volume_bought: '10', + cumulative_amount_spent_ae: '50', + cumulative_amount_spent_usd: '100', + total_volume_sold: '2', + total_amount_received_ae: '14', + total_amount_received_usd: '28', + current_unit_price_ae: '7', + current_unit_price_usd: '14', + }, + ]); + + const result = await service.calculateDailyPnlBatch('ak_test', [ + { snapshotTs: DAY3, dayStartTs: DAY2 }, + ]); + + const day3 = result.get(DAY3)!; + // avgCost = 50/10 = 5; costBasis = 5 * 2 = 10; gain = 14 - 10 = 4 + expect(day3.pnls['ct_daily_sell']).toEqual({ + current_unit_price: { ae: 7, usd: 14 }, + percentage: (4 / 10) * 100, + invested: { ae: 10, usd: 20 }, + current_value: { ae: 8 * 7, usd: 8 * 14 }, + gain: { ae: 4, usd: 8 }, + }); + expect(day3.totalGainAe).toBe(4); + expect(day3.totalCostBasisAe).toBe(10); + }); + + // โ”€โ”€ calculateTradingStats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('calculateTradingStats issues one query with address and two date params', async () => { + const { service, transactionRepository } = createService(); + transactionRepository.query.mockResolvedValue([ + { + top_win_ae: '0', + top_win_usd: '0', + winning_sells: '0', + total_sells: '0', + avg_hold_secs: '0', + unrealized_ae: '0', + unrealized_usd: '0', + }, + ]); + + const start = new Date('2026-01-01T00:00:00.000Z'); + const end = new Date('2026-01-31T00:00:00.000Z'); + + await service.calculateTradingStats('ak_test', start, end); + + expect(transactionRepository.query).toHaveBeenCalledTimes(1); + const [sql, params] = transactionRepository.query.mock.calls[0]; + + expect(sql).toContain('address_txs AS MATERIALIZED'); + expect(sql).toContain('token_agg'); + expect(sql).toContain('range_sells'); + expect(sql).toContain('unrealized'); + expect(sql).toContain('CROSS JOIN unrealized'); + // top_win must come from a single trade via top_trade CTE (not independent MAX) + expect(sql).toContain('top_trade'); + expect(sql).toContain('ORDER BY gain_ae DESC'); + expect(sql).not.toContain('MAX(gain_ae)'); + expect(sql).not.toContain('MAX(gain_usd)'); + expect(params[0]).toBe('ak_test'); + expect(params[1]).toBe(start); + expect(params[2]).toBe(end); + }); + + it('calculateTradingStats returns correct stats from mock row', async () => { + const { service, transactionRepository } = createService(); + + // Scenario: + // - 3 sells in range: 2 winning (gains 5 AE / 10 USD, and 3 AE / 12 USD), 1 losing + // - top win should be the 5 AE / 10 USD trade (best by AE), not 3 AE / 12 USD + // - win rate = 2/3 * 100 = 66.67% + // - avg hold = 86400s (1 day) + // - unrealized = 10 AE + transactionRepository.query.mockResolvedValue([ + { + top_win_ae: '5', + top_win_usd: '10', + winning_sells: '2', + total_sells: '3', + avg_hold_secs: '86400', + unrealized_ae: '10', + unrealized_usd: '20', + }, + ]); + + const result = await service.calculateTradingStats( + 'ak_test', + new Date('2026-01-01'), + new Date('2026-01-31'), + ); + + // Both AE and USD values come from the same best-AE trade + expect(result.topWin).toEqual({ ae: 5, usd: 10 }); + expect(result.unrealizedProfit).toEqual({ ae: 10, usd: 20 }); + expect(result.winRate).toBeCloseTo((2 / 3) * 100); + expect(result.avgDurationSeconds).toBe(86400); + expect(result.totalTrades).toBe(3); + expect(result.winningTrades).toBe(2); + }); + + it('calculateTradingStats top_win ae/usd come from the same trade, not independent MAX', async () => { + const { service, transactionRepository } = createService(); + + // Trade A: gain_ae = 5, gain_usd = 8 (best by AE โ€” should be top_win) + // Trade B: gain_ae = 3, gain_usd = 15 (best by USD โ€” must NOT be used for top_win_usd) + // Independent MAX would give { ae: 5, usd: 15 } โ€” wrong. + // Correct result: { ae: 5, usd: 8 } (both from Trade A). + transactionRepository.query.mockResolvedValue([ + { + top_win_ae: '5', + top_win_usd: '8', // paired with the 5 AE trade, not 15 + winning_sells: '2', + total_sells: '2', + avg_hold_secs: '3600', + unrealized_ae: '0', + unrealized_usd: '0', + }, + ]); + + const result = await service.calculateTradingStats( + 'ak_test', + new Date('2026-01-01'), + new Date('2026-01-31'), + ); + + expect(result.topWin).toEqual({ ae: 5, usd: 8 }); + }); + + it('calculateTradingStats returns zero win_rate when no sells in range', async () => { + const { service, transactionRepository } = createService(); + + transactionRepository.query.mockResolvedValue([ + { + top_win_ae: '0', + top_win_usd: '0', + winning_sells: '0', + total_sells: '0', + avg_hold_secs: '0', + unrealized_ae: '25', + unrealized_usd: '50', + }, + ]); + + const result = await service.calculateTradingStats( + 'ak_test', + new Date('2026-01-01'), + new Date('2026-01-31'), + ); + + expect(result.winRate).toBe(0); + expect(result.totalTrades).toBe(0); + expect(result.winningTrades).toBe(0); + expect(result.topWin).toEqual({ ae: 0, usd: 0 }); + // Unrealized profit still computed even with no sells + expect(result.unrealizedProfit).toEqual({ ae: 25, usd: 50 }); + }); + + it('calculateTradingStats returns safe defaults when query returns no rows', async () => { + const { service, transactionRepository } = createService(); + + transactionRepository.query.mockResolvedValue([]); + + const result = await service.calculateTradingStats( + 'ak_test', + new Date('2026-01-01'), + new Date('2026-01-31'), + ); + + expect(result).toEqual({ + topWin: { ae: 0, usd: 0 }, + unrealizedProfit: { ae: 0, usd: 0 }, + winRate: 0, + avgDurationSeconds: 0, + totalTrades: 0, + winningTrades: 0, + }); }); }); diff --git a/src/account/services/bcl-pnl.service.ts b/src/account/services/bcl-pnl.service.ts index ab75344a..daac6617 100644 --- a/src/account/services/bcl-pnl.service.ts +++ b/src/account/services/bcl-pnl.service.ts @@ -3,6 +3,22 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Transaction } from '@/transactions/entities/transaction.entity'; +export interface DailyPnlWindow { + /** Unix epoch milliseconds โ€” upper bound of the day window (exclusive) */ + snapshotTs: number; + /** Unix epoch milliseconds โ€” lower bound of the day window (inclusive) */ + dayStartTs: number; +} + +export interface TradingStatsResult { + topWin: { ae: number; usd: number }; + unrealizedProfit: { ae: number; usd: number }; + winRate: number; + avgDurationSeconds: number; + totalTrades: number; + winningTrades: number; +} + export interface TokenPnlResult { pnls: Record< string, @@ -45,7 +61,10 @@ export class BclPnlService { this.buildTokenPnlsQuery(fromBlockHeight), this.buildQueryParameters(address, blockHeight, fromBlockHeight), ); - return this.mapTokenPnls(tokenPnls, fromBlockHeight); + return this.mapTokenPnls( + tokenPnls, + fromBlockHeight !== undefined && fromBlockHeight !== null, + ); } /** @@ -75,10 +94,271 @@ export class BclPnlService { return this.mapTokenPnlsBatch(rows, uniqueHeights, fromBlockHeight); } + /** + * Calculate per-day realized PnL for each token using timestamp-based windows. + * Each window defines an isolated [dayStartTs, snapshotTs) sell range so the + * result for each day only reflects trades closed on that specific day. + * + * Cumulative buy history (for average cost per token) is always taken from + * all transactions up to snapshotTs, regardless of dayStartTs. + * + * @param address - Account address + * @param windows - Per-snapshot time windows (Unix epoch ms) + * @returns Map from snapshotTs to its TokenPnlResult + */ + async calculateDailyPnlBatch( + address: string, + windows: DailyPnlWindow[], + ): Promise> { + if (windows.length === 0) { + return new Map(); + } + + // Pass epoch seconds (float8) โ€” PostgreSQL to_timestamp() works in seconds + const snapshotEpochs = windows.map((w) => w.snapshotTs / 1000); + const dayStartEpochs = windows.map((w) => w.dayStartTs / 1000); + + const rows = await this.transactionRepository.query( + this.buildDailyPnlQuery(), + [address, snapshotEpochs, dayStartEpochs], + ); + + return this.mapDailyPnlBatch(rows, windows); + } + + /** + * Calculate aggregate trading stats for an address over a date range. + * + * @param address - Account address + * @param startDate - Start of range (inclusive) + * @param endDate - End of range (exclusive) + */ + async calculateTradingStats( + address: string, + startDate: Date, + endDate: Date, + ): Promise { + const rows = await this.transactionRepository.query( + this.buildTradingStatsQuery(), + [address, startDate, endDate], + ); + + return this.mapTradingStats(rows[0]); + } + + private buildTradingStatsQuery(): string { + return ` + WITH + -- All transactions for this address, scanned once + address_txs AS MATERIALIZED ( + SELECT sale_address, created_at, tx_type, volume, amount + FROM transactions + WHERE address = $1 + ), + -- Per-token all-time aggregates needed for avg cost and current holdings + token_agg AS ( + SELECT + sale_address, + COALESCE( + SUM(CASE WHEN tx_type IN ('buy', 'create_community') + THEN CAST(volume AS DECIMAL) ELSE 0 END) - + SUM(CASE WHEN tx_type = 'sell' + THEN CAST(volume AS DECIMAL) ELSE 0 END), + 0 + ) AS current_holdings, + COALESCE(SUM(CASE WHEN tx_type IN ('buy', 'create_community') + THEN CAST(volume AS DECIMAL) ELSE 0 END), 0) AS cum_vol_bought, + COALESCE(SUM(CASE WHEN tx_type IN ('buy', 'create_community') + THEN CAST(NULLIF(amount->>'ae', 'NaN') AS DECIMAL) ELSE 0 END), 0) AS cum_spent_ae, + COALESCE(SUM(CASE WHEN tx_type IN ('buy', 'create_community') + THEN CAST(NULLIF(amount->>'usd', 'NaN') AS DECIMAL) ELSE 0 END), 0) AS cum_spent_usd, + MIN(CASE WHEN tx_type IN ('buy', 'create_community') + THEN created_at ELSE NULL END) AS first_buy_at + FROM address_txs + GROUP BY sale_address + ), + -- Latest market price per token (from any trader, not just this address) + token_price AS ( + SELECT DISTINCT ON (p.sale_address) + p.sale_address, + CAST(NULLIF(p.buy_price->>'ae', 'NaN') AS DECIMAL) AS unit_price_ae, + CAST(NULLIF(p.buy_price->>'usd', 'NaN') AS DECIMAL) AS unit_price_usd + FROM transactions p + WHERE p.sale_address IN ( + SELECT sale_address FROM token_agg WHERE current_holdings > 0 + ) + AND p.buy_price->>'ae' IS NOT NULL + AND p.buy_price->>'ae' NOT IN ('NaN', 'null', '') + ORDER BY p.sale_address, p.created_at DESC + ), + -- Each sell transaction within the requested date range, enriched with + -- avg cost from token_agg so we can compute per-sell realized gain. + range_sells AS ( + SELECT + s.sale_address, + s.created_at AS sell_at, + CAST(NULLIF(s.amount->>'ae', 'NaN') AS DECIMAL) AS proceeds_ae, + CAST(NULLIF(s.amount->>'usd', 'NaN') AS DECIMAL) AS proceeds_usd, + CAST(s.volume AS DECIMAL) AS sell_volume, + CASE WHEN ta.cum_vol_bought > 0 + THEN (ta.cum_spent_ae / ta.cum_vol_bought) * CAST(s.volume AS DECIMAL) + ELSE 0 + END AS cost_ae, + CASE WHEN ta.cum_vol_bought > 0 + THEN (ta.cum_spent_usd / ta.cum_vol_bought) * CAST(s.volume AS DECIMAL) + ELSE 0 + END AS cost_usd, + EXTRACT(EPOCH FROM (s.created_at - ta.first_buy_at)) AS hold_secs + FROM address_txs s + JOIN token_agg ta ON ta.sale_address = s.sale_address + WHERE s.tx_type = 'sell' + AND s.created_at >= $2 + AND s.created_at < $3 + ), + range_sells_with_gain AS ( + SELECT + proceeds_ae - cost_ae AS gain_ae, + proceeds_usd - cost_usd AS gain_usd, + hold_secs + FROM range_sells + ), + -- The single best sell transaction by AE gain (both AE and USD come from the same row). + -- Using a dedicated CTE instead of independent MAX() aggregations avoids mixing + -- AE/USD values from different trades when exchange rates differ. + top_trade AS ( + SELECT gain_ae AS top_win_ae, gain_usd AS top_win_usd + FROM range_sells_with_gain + WHERE gain_ae > 0 + ORDER BY gain_ae DESC + LIMIT 1 + ), + -- Aggregate sell stats over the range + sell_stats AS ( + SELECT + COALESCE((SELECT top_win_ae FROM top_trade), 0) AS top_win_ae, + COALESCE((SELECT top_win_usd FROM top_trade), 0) AS top_win_usd, + COUNT(*) FILTER (WHERE gain_ae > 0) AS winning_sells, + COUNT(*) AS total_sells, + COALESCE(AVG(hold_secs) FILTER (WHERE hold_secs IS NOT NULL AND hold_secs >= 0), 0) AS avg_hold_secs + FROM range_sells_with_gain + ), + -- Unrealized profit: all currently-held tokens regardless of purchase date + unrealized AS ( + SELECT + COALESCE(SUM( + ta.current_holdings * COALESCE(tp.unit_price_ae, 0) - + CASE WHEN ta.cum_vol_bought > 0 + THEN (ta.cum_spent_ae / ta.cum_vol_bought) * ta.current_holdings + ELSE 0 + END + ), 0) AS unrealized_ae, + COALESCE(SUM( + ta.current_holdings * COALESCE(tp.unit_price_usd, 0) - + CASE WHEN ta.cum_vol_bought > 0 + THEN (ta.cum_spent_usd / ta.cum_vol_bought) * ta.current_holdings + ELSE 0 + END + ), 0) AS unrealized_usd + FROM token_agg ta + LEFT JOIN token_price tp ON tp.sale_address = ta.sale_address + WHERE ta.current_holdings > 0 + ) + SELECT + ss.top_win_ae, + ss.top_win_usd, + ss.winning_sells, + ss.total_sells, + ss.avg_hold_secs, + u.unrealized_ae, + u.unrealized_usd + FROM sell_stats ss + CROSS JOIN unrealized u + `; + } + + private mapTradingStats( + row: Record | undefined, + ): TradingStatsResult { + if (!row) { + return { + topWin: { ae: 0, usd: 0 }, + unrealizedProfit: { ae: 0, usd: 0 }, + winRate: 0, + avgDurationSeconds: 0, + totalTrades: 0, + winningTrades: 0, + }; + } + + const totalTrades = Number(row.total_sells || 0); + const winningTrades = Number(row.winning_sells || 0); + const winRate = totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0; + + return { + topWin: { + ae: Number(row.top_win_ae || 0), + usd: Number(row.top_win_usd || 0), + }, + unrealizedProfit: { + ae: Number(row.unrealized_ae || 0), + usd: Number(row.unrealized_usd || 0), + }, + winRate, + avgDurationSeconds: Number(row.avg_hold_secs || 0), + totalTrades, + winningTrades, + }; + } + private buildBatchTokenPnlsQuery(fromBlockHeight?: number): string { const hasRange = fromBlockHeight !== undefined && fromBlockHeight !== null; const rangeCondition = hasRange ? ' AND tx.block_height >= $3' : ''; + // When a range is active, also gather cumulative (all-time) buy totals so we + // can compute the average cost per token across the full history. This is + // needed to price the cost basis of tokens that were sold during the range + // but were originally purchased before it started. + const cumulativeColumns = hasRange + ? `, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') + THEN CAST(tx.volume AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_volume_bought, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') + THEN CAST(NULLIF(tx.amount->>'ae', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_amount_spent_ae, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') + THEN CAST(NULLIF(tx.amount->>'usd', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_amount_spent_usd` + : ''; + + const cumulativeSelectColumns = hasRange + ? ` + agg.cumulative_volume_bought, + agg.cumulative_amount_spent_ae, + agg.cumulative_amount_spent_usd,` + : ''; + return ` WITH heights AS ( SELECT unnest($2::int[]) AS snapshot_height @@ -176,7 +456,7 @@ export class BclPnlService { END ), 0 - ) AS total_volume_sold + ) AS total_volume_sold${cumulativeColumns} FROM heights h JOIN address_txs tx ON tx.block_height < h.snapshot_height GROUP BY h.snapshot_height, tx.sale_address @@ -190,7 +470,7 @@ export class BclPnlService { agg.total_amount_spent_usd, agg.total_amount_received_ae, agg.total_amount_received_usd, - agg.total_volume_sold, + agg.total_volume_sold,${cumulativeSelectColumns} ae_price.current_unit_price_ae, usd_price.current_unit_price_usd FROM aggregated_holdings agg @@ -215,6 +495,8 @@ export class BclPnlService { LIMIT 1 ) usd_price ON true WHERE agg.current_holdings > 0 + OR agg.total_volume_bought > 0 + OR agg.total_volume_sold > 0 `; } @@ -228,11 +510,196 @@ export class BclPnlService { : [address, blockHeights]; } + private buildDailyPnlQuery(): string { + return ` + WITH snapshots AS ( + SELECT + to_timestamp(unnest($2::float8[])) AS snapshot_ts, + to_timestamp(unnest($3::float8[])) AS day_start_ts + ), + -- Scan the address's transactions exactly once. + address_txs AS MATERIALIZED ( + SELECT sale_address, created_at, tx_type, volume, amount + FROM transactions + WHERE address = $1 + ), + aggregated AS ( + SELECT + s.snapshot_ts, + tx.sale_address, + -- All-time holdings up to snapshot (for portfolio value display) + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') AND tx.created_at < s.snapshot_ts + THEN CAST(tx.volume AS DECIMAL) + ELSE 0 + END + ) - + COALESCE( + SUM( + CASE + WHEN tx.tx_type = 'sell' AND tx.created_at < s.snapshot_ts + THEN CAST(tx.volume AS DECIMAL) + ELSE 0 + END + ), + 0 + ), + 0 + ) AS current_holdings, + -- Cumulative buy volume/cost (all-time up to snapshot, for avg cost per token) + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') AND tx.created_at < s.snapshot_ts + THEN CAST(tx.volume AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_volume_bought, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') AND tx.created_at < s.snapshot_ts + THEN CAST(NULLIF(tx.amount->>'ae', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_amount_spent_ae, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') AND tx.created_at < s.snapshot_ts + THEN CAST(NULLIF(tx.amount->>'usd', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_amount_spent_usd, + -- Sells only within [day_start_ts, snapshot_ts) + COALESCE( + SUM( + CASE + WHEN tx.tx_type = 'sell' + AND tx.created_at >= s.day_start_ts + AND tx.created_at < s.snapshot_ts + THEN CAST(tx.volume AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS total_volume_sold, + COALESCE( + SUM( + CASE + WHEN tx.tx_type = 'sell' + AND tx.created_at >= s.day_start_ts + AND tx.created_at < s.snapshot_ts + THEN CAST(NULLIF(tx.amount->>'ae', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS total_amount_received_ae, + COALESCE( + SUM( + CASE + WHEN tx.tx_type = 'sell' + AND tx.created_at >= s.day_start_ts + AND tx.created_at < s.snapshot_ts + THEN CAST(NULLIF(tx.amount->>'usd', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS total_amount_received_usd + FROM snapshots s + JOIN address_txs tx ON tx.created_at < s.snapshot_ts + GROUP BY s.snapshot_ts, tx.sale_address + ) + SELECT + (EXTRACT(EPOCH FROM agg.snapshot_ts) * 1000)::bigint AS snapshot_ts_ms, + agg.sale_address, + agg.current_holdings, + agg.cumulative_volume_bought, + agg.cumulative_amount_spent_ae, + agg.cumulative_amount_spent_usd, + agg.total_volume_sold, + agg.total_amount_received_ae, + agg.total_amount_received_usd, + ae_price.current_unit_price_ae, + usd_price.current_unit_price_usd + FROM aggregated agg + LEFT JOIN LATERAL ( + SELECT CAST(NULLIF(p.buy_price->>'ae', 'NaN') AS DECIMAL) AS current_unit_price_ae + FROM transactions p + WHERE p.sale_address = agg.sale_address + AND p.created_at < agg.snapshot_ts + AND p.buy_price->>'ae' IS NOT NULL + AND p.buy_price->>'ae' NOT IN ('NaN', 'null', '') + ORDER BY p.created_at DESC + LIMIT 1 + ) ae_price ON true + LEFT JOIN LATERAL ( + SELECT CAST(NULLIF(p.buy_price->>'usd', 'NaN') AS DECIMAL) AS current_unit_price_usd + FROM transactions p + WHERE p.sale_address = agg.sale_address + AND p.created_at < agg.snapshot_ts + AND p.buy_price->>'usd' IS NOT NULL + AND p.buy_price->>'usd' NOT IN ('NaN', 'null', '') + ORDER BY p.created_at DESC + LIMIT 1 + ) usd_price ON true + WHERE agg.current_holdings > 0 + OR agg.cumulative_volume_bought > 0 + OR agg.total_volume_sold > 0 + `; + } + + private mapDailyPnlBatch( + rows: Array>, + windows: DailyPnlWindow[], + ): Map { + // Group rows by snapshot_ts_ms + const rowsByTs = new Map>>(); + for (const w of windows) { + rowsByTs.set(w.snapshotTs, []); + } + for (const row of rows) { + // snapshot_ts_ms comes back as a string from pg driver + const ts = Number(row.snapshot_ts_ms); + // Find the matching window key โ€” we need to match by rounding since + // epoch round-trip through float8 and EXTRACT may drift by a few ms + let matchedKey: number | undefined; + for (const w of windows) { + if (Math.abs(ts - w.snapshotTs) <= 1000) { + matchedKey = w.snapshotTs; + break; + } + } + if (matchedKey !== undefined) { + rowsByTs.get(matchedKey)!.push(row); + } + } + + const result = new Map(); + for (const [ts, tsRows] of rowsByTs.entries()) { + result.set(ts, this.mapTokenPnls(tsRows, true)); + } + return result; + } + private mapTokenPnlsBatch( rows: Array>, uniqueHeights: number[], fromBlockHeight?: number, ): Map { + const isRangeBased = + fromBlockHeight !== undefined && fromBlockHeight !== null; + // Group rows by snapshot_height const rowsByHeight = new Map>>(); for (const height of uniqueHeights) { @@ -249,7 +716,7 @@ export class BclPnlService { // Map each height's rows using the existing single-height mapper const result = new Map(); for (const [height, heightRows] of rowsByHeight.entries()) { - result.set(height, this.mapTokenPnls(heightRows, fromBlockHeight)); + result.set(height, this.mapTokenPnls(heightRows, isRangeBased)); } return result; } @@ -258,6 +725,47 @@ export class BclPnlService { const hasRange = fromBlockHeight !== undefined && fromBlockHeight !== null; const rangeCondition = hasRange ? ' AND tx.block_height >= $3' : ''; + const cumulativeColumns = hasRange + ? `, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') + THEN CAST(tx.volume AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_volume_bought, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') + THEN CAST(NULLIF(tx.amount->>'ae', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_amount_spent_ae, + COALESCE( + SUM( + CASE + WHEN tx.tx_type IN ('buy', 'create_community') + THEN CAST(NULLIF(tx.amount->>'usd', 'NaN') AS DECIMAL) + ELSE 0 + END + ), + 0 + ) AS cumulative_amount_spent_usd` + : ''; + + const cumulativeSelectColumns = hasRange + ? ` + agg.cumulative_volume_bought, + agg.cumulative_amount_spent_ae, + agg.cumulative_amount_spent_usd,` + : ''; + return ` WITH aggregated_holdings AS ( SELECT @@ -341,7 +849,7 @@ export class BclPnlService { END ), 0 - ) AS total_volume_sold + ) AS total_volume_sold${cumulativeColumns} FROM transactions tx WHERE tx.address = $1 AND tx.block_height < $2 @@ -355,7 +863,7 @@ export class BclPnlService { agg.total_amount_spent_usd, agg.total_amount_received_ae, agg.total_amount_received_usd, - agg.total_volume_sold, + agg.total_volume_sold,${cumulativeSelectColumns} ae_price.current_unit_price_ae, usd_price.current_unit_price_usd FROM aggregated_holdings agg @@ -380,6 +888,8 @@ export class BclPnlService { LIMIT 1 ) usd_price ON true WHERE agg.current_holdings > 0 + OR agg.total_volume_bought > 0 + OR agg.total_volume_sold > 0 `; } @@ -395,7 +905,7 @@ export class BclPnlService { private mapTokenPnls( tokenPnls: Array>, - fromBlockHeight?: number, + isRangeBased: boolean, ): TokenPnlResult { const result: TokenPnlResult['pnls'] = {}; let totalCostBasisAe = 0; @@ -408,7 +918,6 @@ export class BclPnlService { for (const tokenPnl of tokenPnls) { const saleAddress = tokenPnl.sale_address; const currentHoldings = Number(tokenPnl.current_holdings || 0); - const totalVolumeBought = Number(tokenPnl.total_volume_bought || 0); const totalVolumeSold = Number(tokenPnl.total_volume_sold || 0); const totalAmountSpentAe = Number(tokenPnl.total_amount_spent_ae || 0); const totalAmountSpentUsd = Number(tokenPnl.total_amount_spent_usd || 0); @@ -421,72 +930,71 @@ export class BclPnlService { const currentUnitPriceAe = Number(tokenPnl.current_unit_price_ae || 0); const currentUnitPriceUsd = Number(tokenPnl.current_unit_price_usd || 0); - // Calculate average cost per token (only for tokens bought in range if fromBlockHeight is provided) - const averageCostPerTokenAe = - totalVolumeBought > 0 ? totalAmountSpentAe / totalVolumeBought : 0; - const averageCostPerTokenUsd = - totalVolumeBought > 0 ? totalAmountSpentUsd / totalVolumeBought : 0; - - // Calculate cost basis for range-based PNL - // If fromBlockHeight is provided, cost basis should only include tokens bought in the range - // Cost basis = total amount spent on purchases in range - // If fromBlockHeight is not provided, use all currentHoldings (cumulative PNL) - const costBasisAe = - fromBlockHeight !== undefined && fromBlockHeight !== null - ? totalAmountSpentAe // Only purchases in range - : currentHoldings * averageCostPerTokenAe; // All holdings for cumulative PNL - const costBasisUsd = - fromBlockHeight !== undefined && fromBlockHeight !== null - ? totalAmountSpentUsd // Only purchases in range - : currentHoldings * averageCostPerTokenUsd; // All holdings for cumulative PNL - - totalCostBasisAe += costBasisAe; - totalCostBasisUsd += costBasisUsd; - - // Calculate current value - ALWAYS use cumulative holdings - // Current value represents the actual value of tokens owned at blockHeight, regardless of when they were bought - // The range filter (fromBlockHeight) should only affect cost basis and sale proceeds, not current value - // This ensures accurate portfolio value even when tokens were bought before range and sold within range - const holdingsForCurrentValue = currentHoldings; // Always use cumulative holdings for current value - - const currentValueAe = holdingsForCurrentValue * currentUnitPriceAe; - const currentValueUsd = holdingsForCurrentValue * currentUnitPriceUsd; + // Current value of all tokens still held at this block height. + // Used for portfolio chart and cumulative PnL "current value" field. + const currentValueAe = currentHoldings * currentUnitPriceAe; + const currentValueUsd = currentHoldings * currentUnitPriceUsd; totalCurrentValueAe += currentValueAe; totalCurrentValueUsd += currentValueUsd; - // Calculate gain for range-based PNL - // For range-based PNL: Gain = (sale proceeds + current value of tokens bought in range) - cost basis - // This accounts for both tokens sold and tokens still held from range purchases - // Note: Current value uses all holdings (for accurate portfolio value), but gain calculation - // only attributes value to tokens bought in range to avoid including tokens bought before range + let costBasisAe: number; + let costBasisUsd: number; let gainAe: number; let gainUsd: number; - if (fromBlockHeight !== undefined && fromBlockHeight !== null) { - // Range-based PNL: calculate value of tokens bought in range that are still held - // Remaining tokens from range = max(0, totalVolumeBought - totalVolumeSold) - // But cap at currentHoldings to handle edge cases - const remainingFromRange = Math.max( - 0, - Math.min(currentHoldings, totalVolumeBought - totalVolumeSold), + + if (isRangeBased) { + // --- Range-based (daily) PnL: realized gains only --- + // + // "invested" = cost basis of tokens that were actually sold in this + // period, valued at the all-time average purchase price. + // "gain" = sell proceeds - cost basis of sold tokens. + // + // We deliberately exclude unrealized gains from tokens still open so + // that the calendar only turns green/red when a trade is closed. + const cumulativeVolumeBought = Number( + tokenPnl.cumulative_volume_bought || 0, + ); + const cumulativeAmountSpentAe = Number( + tokenPnl.cumulative_amount_spent_ae || 0, + ); + const cumulativeAmountSpentUsd = Number( + tokenPnl.cumulative_amount_spent_usd || 0, ); - const currentValueFromRangeAe = remainingFromRange * currentUnitPriceAe; - const currentValueFromRangeUsd = - remainingFromRange * currentUnitPriceUsd; - // Gain = (proceeds from sales + current value of tokens bought in range) - cost of purchases - gainAe = totalAmountReceivedAe + currentValueFromRangeAe - costBasisAe; - gainUsd = - totalAmountReceivedUsd + currentValueFromRangeUsd - costBasisUsd; + const cumulativeAvgCostAe = + cumulativeVolumeBought > 0 + ? cumulativeAmountSpentAe / cumulativeVolumeBought + : 0; + const cumulativeAvgCostUsd = + cumulativeVolumeBought > 0 + ? cumulativeAmountSpentUsd / cumulativeVolumeBought + : 0; + + costBasisAe = cumulativeAvgCostAe * totalVolumeSold; + costBasisUsd = cumulativeAvgCostUsd * totalVolumeSold; + + gainAe = totalAmountReceivedAe - costBasisAe; + gainUsd = totalAmountReceivedUsd - costBasisUsd; } else { - // Cumulative PNL: current value - cost basis - gainAe = currentValueAe - costBasisAe; - gainUsd = currentValueUsd - costBasisUsd; + // --- Cumulative PnL --- + // + // "invested" = everything ever spent on this token. + // "current_value"= market value of tokens still held. + // "gain" = proceeds from all sells + current value - total spent. + // This correctly handles partial exits and fully closed positions. + costBasisAe = totalAmountSpentAe; + costBasisUsd = totalAmountSpentUsd; + + gainAe = totalAmountReceivedAe + currentValueAe - totalAmountSpentAe; + gainUsd = + totalAmountReceivedUsd + currentValueUsd - totalAmountSpentUsd; } + totalCostBasisAe += costBasisAe; + totalCostBasisUsd += costBasisUsd; totalGainAe += gainAe; totalGainUsd += gainUsd; - // Calculate PNL percentage const pnlPercentage = costBasisAe > 0 ? (gainAe / costBasisAe) * 100 : 0; result[saleAddress] = { diff --git a/src/account/services/portfolio.service.spec.ts b/src/account/services/portfolio.service.spec.ts index 0014fe27..fdafc1c3 100644 --- a/src/account/services/portfolio.service.spec.ts +++ b/src/account/services/portfolio.service.spec.ts @@ -1,7 +1,7 @@ import moment from 'moment'; import { PortfolioService } from './portfolio.service'; import { batchTimestampToAeHeight } from '@/utils/getBlochHeight'; -import { TokenPnlResult } from './bcl-pnl.service'; +import { DailyPnlWindow, TokenPnlResult } from './bcl-pnl.service'; import { fetchJson } from '@/utils/common'; jest.mock('@/utils/getBlochHeight', () => ({ @@ -40,6 +40,7 @@ describe('PortfolioService', () => { const bclPnlService = { calculateTokenPnls: jest.fn(), calculateTokenPnlsBatch: jest.fn(), + calculateDailyPnlBatch: jest.fn(), }; const service = new PortfolioService( @@ -206,10 +207,14 @@ describe('PortfolioService', () => { expect(calledHeights).toEqual(expect.arrayContaining([0, 300])); }); - it('pre-computes both cumulative and range PNL maps when range PNL is requested', async () => { + it('calls calculateDailyPnlBatch with per-day windows when range PNL is requested', async () => { const { service, aeSdkService, coinGeckoService, bclPnlService } = createService(); + const ts1 = moment.utc('2026-01-01T00:00:00.000Z'); + const ts2 = moment.utc('2026-01-02T00:00:00.000Z'); + const ts3 = moment.utc('2026-01-03T00:00:00.000Z'); + (batchTimestampToAeHeight as jest.Mock).mockImplementation( async (timestamps: number[]) => { const map = new Map(); @@ -233,19 +238,49 @@ describe('PortfolioService', () => { }, ); + bclPnlService.calculateDailyPnlBatch.mockImplementation( + async (_addr: string, windows: DailyPnlWindow[]) => { + const map = new Map(); + windows.forEach((w) => map.set(w.snapshotTs, basePnlResult)); + return map; + }, + ); + const snapshots = await service.getPortfolioHistory('ak_test', { - startDate: moment.utc('2026-01-01T00:00:00.000Z'), - endDate: moment.utc('2026-01-03T00:00:00.000Z'), + startDate: ts1, + endDate: ts3, interval: 86400, includePnl: true, useRangeBasedPnl: true, }); expect(snapshots).toHaveLength(3); - expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledTimes(2); - const calls = bclPnlService.calculateTokenPnlsBatch.mock.calls; - expect(calls[0][2]).toBeUndefined(); - expect(calls[1][2]).toBe(100); + + // Cumulative map still uses calculateTokenPnlsBatch (once, no fromBlockHeight) + expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledTimes(1); + expect( + bclPnlService.calculateTokenPnlsBatch.mock.calls[0][2], + ).toBeUndefined(); + + // Daily PnL now uses calculateDailyPnlBatch + expect(bclPnlService.calculateDailyPnlBatch).toHaveBeenCalledTimes(1); + const [, windows] = bclPnlService.calculateDailyPnlBatch.mock.calls[0] as [ + string, + DailyPnlWindow[], + ]; + expect(windows).toHaveLength(3); + + // First window: zero-width (dayStart === snapshotTs) + expect(windows[0].snapshotTs).toBe(ts1.valueOf()); + expect(windows[0].dayStartTs).toBe(ts1.valueOf()); + + // Second window: [ts1, ts2) + expect(windows[1].snapshotTs).toBe(ts2.valueOf()); + expect(windows[1].dayStartTs).toBe(ts1.valueOf()); + + // Third window: [ts2, ts3) + expect(windows[2].snapshotTs).toBe(ts3.valueOf()); + expect(windows[2].dayStartTs).toBe(ts2.valueOf()); }); it('uses coin_historical_prices DB table when available, skipping CoinGecko', async () => { diff --git a/src/account/services/portfolio.service.ts b/src/account/services/portfolio.service.ts index 87f46577..bb77f47a 100644 --- a/src/account/services/portfolio.service.ts +++ b/src/account/services/portfolio.service.ts @@ -12,7 +12,11 @@ import { CoinHistoricalPriceService } from '@/ae-pricing/services/coin-historica import { ACTIVE_NETWORK, AETERNITY_COIN_ID } from '@/configs'; import { toAe } from '@aeternity/aepp-sdk'; import { batchTimestampToAeHeight } from '@/utils/getBlochHeight'; -import { BclPnlService, TokenPnlResult } from './bcl-pnl.service'; +import { + BclPnlService, + DailyPnlWindow, + TokenPnlResult, +} from './bcl-pnl.service'; import { fetchJson } from '@/utils/common'; export interface PortfolioHistorySnapshot { @@ -236,27 +240,29 @@ export class PortfolioService { ); return lastKnownHeight; }); - // startBlockHeight is the block at the beginning of the requested range. - // timestamps[0] === start (same millisecond value), so blockHeights[0] is - // already the correct answer โ€” no extra DB or API call needed. - const startBlockHeight = - useRangeBasedPnl && includePnl ? blockHeights[0] : undefined; - const uniqueBlockHeights = [...new Set(blockHeights)]; + // Build per-day windows for the daily PnL calendar. + // Each snapshot's window covers [previousTimestamp, currentTimestamp). + // The first snapshot gets a zero-width window (dayStart === snapshotTs) + // so it naturally returns 0 gain since no sells can fall in an empty range. + const dailyWindows: DailyPnlWindow[] = timestamps.map((ts, i) => ({ + snapshotTs: ts.valueOf(), + dayStartTs: i > 0 ? timestamps[i - 1].valueOf() : ts.valueOf(), + })); + // Pre-compute PNL for all unique block heights in a single batch query. // This replaces the previous per-snapshot SQL calls (N queries โ†’ 1 query). - const [pnlMap, rangePnlMap] = await Promise.all([ + const [pnlMap, dailyPnlMap] = await Promise.all([ this.bclPnlService.calculateTokenPnlsBatch( resolvedAddress, uniqueBlockHeights, undefined, ), - includePnl && useRangeBasedPnl && startBlockHeight !== undefined - ? this.bclPnlService.calculateTokenPnlsBatch( + includePnl && useRangeBasedPnl + ? this.bclPnlService.calculateDailyPnlBatch( resolvedAddress, - uniqueBlockHeights, - startBlockHeight, + dailyWindows, ) : Promise.resolve(undefined as Map | undefined), ]); @@ -291,10 +297,11 @@ export class PortfolioService { const tokensPnl = pnlMap.get(blockHeight) ?? emptyPnl; - // For range-based PNL, index 0 reuses the cumulative result (existing semantics) + // Daily PnL is keyed by snapshot timestamp (ms), giving each day its + // own isolated sell window regardless of block height deduplication. const rangeBasedPnl = - useRangeBasedPnl && includePnl && index > 0 - ? (rangePnlMap?.get(blockHeight) ?? undefined) + useRangeBasedPnl && includePnl + ? (dailyPnlMap?.get(timestamp.valueOf()) ?? undefined) : undefined; const aeBalance = await aeBalancePromise; @@ -350,11 +357,9 @@ export class PortfolioService { // Only include range information when using range-based PnL if (useRangeBasedPnl) { - // Determine the range for this PnL calculation - // For range-based PNL with hover support: each snapshot shows PNL from startDate to that timestamp - // First snapshot: cumulative from start (null) to current timestamp - // All other snapshots: from startDate to current timestamp - const rangeFrom = index === 0 ? null : start; + // Each snapshot shows PnL for its own day window: + // from the previous snapshot timestamp (or null for the first) to this snapshot. + const rangeFrom = index === 0 ? null : timestamps[index - 1]; const rangeTo = timestamp; result.total_pnl.range = { from: rangeFrom, @@ -373,7 +378,7 @@ export class PortfolioService { return data; } - private async resolveAccountAddress(address: string): Promise { + async resolveAccountAddress(address: string): Promise { if (!address || address.startsWith('ak_') || !address.includes('.')) { return address; } diff --git a/src/ae-pricing/ae-pricing.service.ts b/src/ae-pricing/ae-pricing.service.ts index d68c5de9..74800847 100644 --- a/src/ae-pricing/ae-pricing.service.ts +++ b/src/ae-pricing/ae-pricing.service.ts @@ -29,9 +29,18 @@ export class AePricingService { }); return this.latestRates; } - this.latestRates = await this.coinPriceRepository.save({ - rates, - }); + try { + this.latestRates = await this.coinPriceRepository.save({ + rates, + }); + } catch (error) { + this.latestRates = await this.coinPriceRepository.findOne({ + where: {}, + order: { + created_at: 'DESC', + }, + }); + } return this.latestRates; }