Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1,113 changes: 426 additions & 687 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
"hbs": "^4.2.0",
"keyv": "^5.5.3",
"moment": "^2.30.1",
"mysql2": "^3.15.2",
"nestjs-typeorm-paginate": "^4.1.0",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
Expand All @@ -77,10 +76,8 @@
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
Expand Down
79 changes: 79 additions & 0 deletions src/account/controllers/accounts.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AccountsController } from './accounts.controller';
import { StreamableFile } from '@nestjs/common';
import { paginate } from 'nestjs-typeorm-paginate';
import { NotFoundException } from '@nestjs/common';

Expand Down Expand Up @@ -28,6 +29,8 @@ describe('AccountsController', () => {
};
let portfolioService: {
resolveAccountAddress: jest.Mock;
getPortfolioHistory: jest.Mock;
getPnlTimeSeries: jest.Mock;
};
let bclPnlService: {
calculateTradingStats: jest.Mock;
Expand All @@ -48,6 +51,8 @@ describe('AccountsController', () => {
};
portfolioService = {
resolveAccountAddress: jest.fn().mockImplementation((a) => a),
getPortfolioHistory: jest.fn().mockResolvedValue([]),
getPnlTimeSeries: jest.fn().mockResolvedValue([]),
};
bclPnlService = {
calculateTradingStats: jest.fn(),
Expand Down Expand Up @@ -115,4 +120,78 @@ describe('AccountsController', () => {
NotFoundException,
);
});

describe('getPortfolioPnlChart', () => {
it('returns a StreamableFile with SVG content type', async () => {
portfolioService.getPnlTimeSeries.mockResolvedValue([
{ gain: { ae: 1, usd: 2 } },
{ gain: { ae: 3, usd: 6 } },
{ gain: { ae: 2, usd: 4 } },
]);

const result = await controller.getPortfolioPnlChart('ak_test');

expect(result).toBeInstanceOf(StreamableFile);
});

it('calls getPnlTimeSeries with daily interval for a multi-day range', async () => {
await controller.getPortfolioPnlChart(
'ak_test',
'2026-01-01T00:00:00Z',
'2026-01-31T00:00:00Z',
);

const callArgs = portfolioService.getPnlTimeSeries.mock.calls[0][1];
expect(callArgs.interval).toBe(86400);
});

it('calls getPnlTimeSeries with hourly interval for a single-day range', async () => {
await controller.getPortfolioPnlChart(
'ak_test',
'2026-01-01T00:00:00Z',
'2026-01-01T23:59:59Z',
);

const callArgs = portfolioService.getPnlTimeSeries.mock.calls[0][1];
expect(callArgs.interval).toBe(3600);
});

it('passes raw address to getPnlTimeSeries (resolution is handled inside)', async () => {
await controller.getPortfolioPnlChart('myname.chain');

// The controller no longer calls resolveAccountAddress itself —
// getPnlTimeSeries handles it internally.
expect(portfolioService.getPnlTimeSeries).toHaveBeenCalledWith(
'myname.chain',
expect.anything(),
);
});

it('uses usd gain values when convertTo=usd', async () => {
// AE series goes down, USD series goes up — green stroke proves the
// usd field was read rather than ae.
portfolioService.getPnlTimeSeries.mockResolvedValue([
{ gain: { ae: 5, usd: 1 } },
{ gain: { ae: 1, usd: 9 } },
]);

const result = await controller.getPortfolioPnlChart(
'ak_test',
undefined,
undefined,
'usd',
);

const svgBuffer = (result as StreamableFile).getStream().read() as Buffer;
expect(svgBuffer.toString()).toContain('#2EB88A');
});

it('returns empty SVG when no data points available', async () => {
const result = await controller.getPortfolioPnlChart('ak_test');

const svgBuffer = (result as StreamableFile).getStream().read() as Buffer;
expect(svgBuffer.toString()).toContain('<svg');
expect(svgBuffer.toString()).not.toContain('<path');
});
});
});
121 changes: 120 additions & 1 deletion src/account/controllers/accounts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
Controller,
DefaultValuePipe,
Get,
Header,
Logger,
NotFoundException,
Param,
ParseIntPipe,
Query,
StreamableFile,
UseInterceptors,
} from '@nestjs/common';
import {
Expand All @@ -31,6 +33,7 @@ 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';
import { buildSparklineSvg, sparklineStroke } from '@/utils/sparkline.util';

@UseInterceptors(CacheInterceptor)
@Controller('accounts')
Expand Down Expand Up @@ -158,12 +161,20 @@ export class AccountsController {
}

// Portfolio history endpoint - MUST come before :address route to avoid route conflict
@ApiOperation({ operationId: 'getPortfolioHistory' })
@ApiOperation({
operationId: 'getPortfolioHistory',
summary: 'Portfolio value history snapshots',
description:
'Returns portfolio value over time (AE balance, token values, total value). ' +
'Pass include=pnl or include=pnl-range to add aggregate total_pnl data. ' +
'Per-token PnL breakdown (tokens_pnl) is available via the dedicated :address/portfolio/tokens/history endpoint.',
})
@ApiParam({ name: 'address', type: 'string', description: 'Account address' })
@ApiOkResponse({ type: [PortfolioHistorySnapshotDto] })
@CacheTTL(60 * 10) // 10 minutes
@Get(':address/portfolio/history')
async getPortfolioHistory(
//
@Param('address') address: string,
@Query() query: GetPortfolioHistoryQueryDto,
) {
Expand Down Expand Up @@ -194,6 +205,114 @@ export class AccountsController {
});
}

@ApiOperation({
operationId: 'getTokensPnlHistory',
summary: 'Per-token PnL history snapshots',
description:
'Returns portfolio history snapshots that include the per-token PnL breakdown (tokens_pnl). ' +
'PnL data is always included; pass include=pnl-range to use range-based (daily window) PnL instead of cumulative.',
})
@ApiParam({ name: 'address', type: 'string', description: 'Account address' })
@ApiOkResponse({ type: [PortfolioHistorySnapshotDto] })
@CacheTTL(60 * 10)
@Get(':address/portfolio/tokens/history')
async getTokensPnlHistory(
@Param('address') address: string,
@Query() query: GetPortfolioHistoryQueryDto,
) {
const start = query.startDate ? moment(query.startDate) : undefined;
const end = query.endDate ? moment(query.endDate) : undefined;
const includeFields = query.include
? query.include.split(',').map((f) => f.trim())
: [];

const minimumInterval = this.getMinimumInterval(start, end);
const requestedInterval = query.interval || 86400;
const finalInterval = Math.max(requestedInterval, minimumInterval);

const useRangeBasedPnl = includeFields.includes('pnl-range');

return await this.portfolioService.getPortfolioHistory(address, {
startDate: start,
endDate: end,
interval: finalInterval,
convertTo: query.convertTo || 'ae',
includePnl: true,
useRangeBasedPnl,
includeTokensPnl: true,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated interval/date parsing logic across two controller methods

Low Severity

getTokensPnlHistory duplicates the date-parsing, interval-clamping, and include-field splitting logic from getPortfolioHistory. If the interval calculation or date handling changes in one method but not the other, responses from the two endpoints will silently diverge. Extracting the shared query-to-options mapping into a private helper would keep both endpoints consistent.

Additional Locations (1)
Fix in Cursor Fix in Web

}

// Portfolio PnL sparkline — MUST come before :address route to avoid route conflict
@ApiOperation({
operationId: 'getPortfolioPnlChart',
summary:
'SVG sparkline of daily (or hourly for a single-day range) realized PnL',
})
@ApiParam({ name: 'address', type: 'string', description: 'Account address' })
@ApiQuery({ name: 'startDate', type: 'string', required: false })
@ApiQuery({ name: 'endDate', type: 'string', required: false })
@ApiQuery({
name: 'convertTo',
enum: ['ae', 'usd'],
required: false,
example: 'ae',
})
@ApiQuery({ name: 'width', type: 'number', required: false, example: 160 })
@ApiQuery({ name: 'height', type: 'number', required: false, example: 60 })
@ApiQuery({
name: 'background',
type: 'string',
required: false,
example: 'none',
description: 'CSS fill for background rect, e.g. "#1a1a2e" or "none"',
})
@Header('Content-Type', 'image/svg+xml')
@Header('Content-Disposition', 'inline; filename="pnl-chart.svg"')
@CacheTTL(60 * 10)
@Get(':address/portfolio/pnl-chart.svg')
async getPortfolioPnlChart(
@Param('address') address: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('convertTo') convertTo: 'ae' | 'usd' = 'ae',
@Query('width') width = '160',
@Query('height') height = '60',
@Query('background') background = 'none',
): Promise<StreamableFile> {
const start = startDate ? moment(startDate) : moment().subtract(30, 'days');
const end = endDate ? moment(endDate) : moment();

// Use hourly points for a single-day range, daily otherwise
const rangeHours = end.diff(start, 'hours', true);
const interval = rangeHours <= 24 ? 3600 : 86400;

// Use the lightweight PnL-only series instead of the full portfolio history.
// This skips AE-node balance calls, block-height resolution, and cumulative
// PnL queries — only calculateDailyPnlBatch (one SQL query) runs.
// resolveAccountAddress is handled inside getPnlTimeSeries.
const points = await this.portfolioService.getPnlTimeSeries(address, {
startDate: start,
endDate: end,
interval,
});

const values = points.map((p) => p.gain[convertTo]);

const svg = buildSparklineSvg(
values,
Number(width),
Number(height),
sparklineStroke(values),
background,
);

return new StreamableFile(Buffer.from(svg), {
type: 'image/svg+xml',
disposition: 'inline; filename="pnl-chart.svg"',
});
}

// Portfolio stats endpoint - MUST come before :address route to avoid route conflict
@ApiOperation({ operationId: 'getPortfolioStats' })
@ApiParam({ name: 'address', type: 'string', description: 'Account address' })
Expand Down
5 changes: 4 additions & 1 deletion src/account/dto/get-portfolio-history-query.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export class GetPortfolioHistoryQueryDto {
name: 'include',
type: 'string',
required: false,
description: 'Comma-separated list of fields to include (e.g., "pnl")',
description:
'Comma-separated list of fields to include. ' +
'"pnl" adds aggregate total_pnl, "pnl-range" uses daily-window PnL instead of cumulative. ' +
'Per-token breakdown (tokens_pnl) is only available via the /portfolio/tokens/history endpoint.',
example: 'pnl',
})
@IsOptional()
Expand Down
3 changes: 2 additions & 1 deletion src/account/dto/portfolio-history-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export class PortfolioHistorySnapshotDto {

@ApiProperty({
description:
'PNL breakdown per token (included if requested), keyed by token sale_address',
'PNL breakdown per token, keyed by token sale_address. ' +
'Only returned by the dedicated /portfolio/tokens/history endpoint.',
type: Object,
required: false,
})
Expand Down
Loading
Loading