Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
efdf5e3
feat(db-restore): reset local password after database restore
ifaouibadi Mar 30, 2026
044c7d1
Merge pull request #97 from superhero-com/main
CedrikNikita Mar 31, 2026
cf855f1
feat(accounts): add portfolio stats endpoint and trading stats DTOs
ifaouibadi Mar 31, 2026
ffc0cbc
refactor(bcl-pnl): update trading stats calculation to ensure top win…
ifaouibadi Mar 31, 2026
6f0d78e
Merge pull request #98 from superhero-com/features/account-pnls-calendar
ifaouibadi Mar 31, 2026
2f57ce6
refactor(accounts): update address resolution in trading stats calcul…
ifaouibadi Mar 31, 2026
74293f5
refactor(bcl-pnl): improve code readability by formatting and restruc…
ifaouibadi Mar 31, 2026
11e9cc5
test(accounts): add mocks for portfolio and BCL PnL services in accou…
ifaouibadi Mar 31, 2026
5b48626
build: remove unused dependencies
CedrikNikita Apr 1, 2026
96dd5f3
build: run npm update
CedrikNikita Apr 1, 2026
bbba02a
Merge pull request #102 from superhero-com/feature/update-dependencies
CedrikNikita Apr 1, 2026
27cd848
feat(accounts): implement getPortfolioPnlChart endpoint for SVG spark…
ifaouibadi Mar 31, 2026
9ba867a
feat(sparkline): add CSS color sanitization for SVG attributes to pre…
ifaouibadi Apr 1, 2026
d515f66
feat(coin-gecko): refactor CoinGeckoService to improve data fetching …
ifaouibadi Apr 1, 2026
08f50ac
refactor(coin-gecko): improve code readability by formatting function…
ifaouibadi Apr 1, 2026
bd024b7
refactor(coin-gecko): enhance getPriceData method to clarify rate war…
ifaouibadi Apr 1, 2026
13cd4ee
feat(coin-gecko): add hourly historical price synchronization and imp…
ifaouibadi Apr 2, 2026
f2fecd1
feat(accounts): add getTokensPnlHistory endpoint to retrieve token Pn…
ifaouibadi Apr 2, 2026
38588cb
feat(accounts): enhance portfolio history endpoints with detailed sum…
ifaouibadi Apr 2, 2026
033201e
Merge branch 'main' of github.com:superhero-com/superhero-api into de…
ifaouibadi Apr 2, 2026
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,
});
}

// 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