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
81 changes: 60 additions & 21 deletions src/account/services/portfolio.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import moment from 'moment';
import { PortfolioService } from './portfolio.service';
import { batchTimestampToAeHeight } from '@/utils/getBlochHeight';
import { TokenPnlResult } from './bcl-pnl.service';
import { fetchJson } from '@/utils/common';

jest.mock('@/utils/getBlochHeight', () => ({
batchTimestampToAeHeight: jest.fn(),
}));

jest.mock('@/utils/common', () => ({
fetchJson: jest.fn(),
}));

describe('PortfolioService', () => {
const basePnlResult: TokenPnlResult = {
pnls: {},
Expand All @@ -29,7 +34,7 @@ describe('PortfolioService', () => {
getPriceData: jest.fn(),
};
const coinHistoricalPriceService = {
// Default: no DB data falls back to coinGeckoService.fetchHistoricalPrice
// Default: no DB data -> falls back to coinGeckoService.fetchHistoricalPrice
getHistoricalPriceData: jest.fn().mockResolvedValue([]),
};
const bclPnlService = {
Expand Down Expand Up @@ -59,6 +64,7 @@ describe('PortfolioService', () => {

beforeEach(() => {
jest.clearAllMocks();
(fetchJson as jest.Mock).mockResolvedValue(null);
});

it('limits concurrent balance fetches to snapshotConcurrency', async () => {
Expand Down Expand Up @@ -110,19 +116,15 @@ describe('PortfolioService', () => {
});

expect(snapshots).toHaveLength(10);
// PNL is pre-computed in a single batch call
expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledTimes(1);
// All 10 heights land in different 300-block buckets, so 10 balance calls
expect(aeSdkService.sdk.getBalance).toHaveBeenCalledTimes(10);
// Balance concurrency cap is 15
expect(maxInFlight).toBeLessThanOrEqual(15);
});

it('deduplicates block heights so balance and PNL are only fetched once per unique height', async () => {
const { service, aeSdkService, coinGeckoService, bclPnlService } =
createService();

// All timestamps map to the same block height
(batchTimestampToAeHeight as jest.Mock).mockImplementation(
async (timestamps: number[]) => {
const map = new Map<number, number>();
Expand Down Expand Up @@ -150,25 +152,20 @@ describe('PortfolioService', () => {
});

expect(snapshots).toHaveLength(3);
// All 3 timestamps share height 123, so balance is fetched once
expect(aeSdkService.sdk.getBalance).toHaveBeenCalledTimes(1);
// Batch PNL is called once with deduplicated heights
expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledTimes(1);
expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledWith(
'ak_test',
[123],
undefined,
);
// Per-snapshot price is still resolved per timestamp, not per block height
expect(snapshots.map((snapshot) => snapshot.ae_price)).toEqual([1, 2, 3]);
});

it('buckets block heights to multiples of 300 to share getBalance calls', async () => {
const { service, aeSdkService, coinGeckoService, bclPnlService } =
createService();

// 4 timestamps that map to 3 distinct exact heights, but only 2 distinct
// 300-block buckets: 100 → bucket 0, 250 → bucket 0, 350 → bucket 300, 599 → bucket 300
(batchTimestampToAeHeight as jest.Mock).mockImplementation(
async (timestamps: number[]) => {
const heights = [100, 250, 350, 599];
Expand Down Expand Up @@ -202,10 +199,7 @@ describe('PortfolioService', () => {
});

expect(snapshots).toHaveLength(4);
// Heights 100 and 250 → bucket 0; heights 350 and 599 → bucket 300.
// Only 2 unique buckets → exactly 2 getBalance calls.
expect(aeSdkService.sdk.getBalance).toHaveBeenCalledTimes(2);
// getBalance is called with the bucket boundaries, not the raw heights
const calledHeights = aeSdkService.sdk.getBalance.mock.calls.map(
([, opts]: [any, any]) => opts.height,
);
Expand Down Expand Up @@ -248,12 +242,10 @@ describe('PortfolioService', () => {
});

expect(snapshots).toHaveLength(3);
// Called twice: once for cumulative, once for range-based
expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledTimes(2);
// startBlockHeight = blockHeights[0] = 100 (timestamps[0] maps to 100+0)
const calls = bclPnlService.calculateTokenPnlsBatch.mock.calls;
expect(calls[0][2]).toBeUndefined(); // cumulative: no fromBlockHeight
expect(calls[1][2]).toBe(100); // range: fromBlockHeight = blockHeights[0]
expect(calls[0][2]).toBeUndefined();
expect(calls[1][2]).toBe(100);
});

it('uses coin_historical_prices DB table when available, skipping CoinGecko', async () => {
Expand All @@ -273,7 +265,6 @@ describe('PortfolioService', () => {
},
);

// DB returns prices in ascending order (as the repository does)
coinHistoricalPriceService.getHistoricalPriceData.mockResolvedValue([
[Date.UTC(2026, 0, 1), 1],
[Date.UTC(2026, 0, 2), 2],
Expand All @@ -297,13 +288,61 @@ describe('PortfolioService', () => {
});

expect(snapshots).toHaveLength(3);
// Prices come from DB — CoinGecko historical endpoint is never called
expect(coinGeckoService.fetchHistoricalPrice).not.toHaveBeenCalled();
// DB service was queried for the needed range
expect(
coinHistoricalPriceService.getHistoricalPriceData,
).toHaveBeenCalledTimes(1);
// Correct prices are assigned to each snapshot
expect(snapshots.map((s) => s.ae_price)).toEqual([1, 2, 3]);
});

it('resolves chain names to account pubkeys before balance and pnl lookups', async () => {
const {
service,
aeSdkService,
coinGeckoService,
coinHistoricalPriceService,
bclPnlService,
} = createService();

(batchTimestampToAeHeight as jest.Mock).mockImplementation(
async (timestamps: number[]) => {
const map = new Map<number, number>();
timestamps.forEach((ts) => map.set(ts, 300));
return map;
},
);
(fetchJson as jest.Mock).mockResolvedValue({
owner: 'ak_owner',
pointers: [{ key: 'account_pubkey', id: 'ak_resolved' }],
});
coinGeckoService.getPriceData.mockResolvedValue({ usd: 99 });
coinHistoricalPriceService.getHistoricalPriceData.mockResolvedValue([
[Date.UTC(2026, 0, 1), 1],
]);
aeSdkService.sdk.getBalance.mockResolvedValue('1000000000000000000');
bclPnlService.calculateTokenPnlsBatch.mockResolvedValue(
new Map([[300, basePnlResult]]),
);

await service.getPortfolioHistory('mybtc.chain', {
startDate: moment.utc('2026-01-01T00:00:00.000Z'),
endDate: moment.utc('2026-01-01T00:00:00.000Z'),
interval: 86400,
});

expect(fetchJson).toHaveBeenCalledWith(
expect.stringContaining(
'https://mainnet.aeternity.io/v3/names/mybtc.chain',
),
);
expect(aeSdkService.sdk.getBalance).toHaveBeenCalledWith(
'ak_resolved',
expect.objectContaining({ height: 300 }),
);
expect(bclPnlService.calculateTokenPnlsBatch).toHaveBeenCalledWith(
'ak_resolved',
[300],
undefined,
);
});
});
60 changes: 53 additions & 7 deletions src/account/services/portfolio.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { Transaction } from '@/transactions/entities/transaction.entity';
import { AeSdkService } from '@/ae/ae-sdk.service';
import { CoinGeckoService } from '@/ae/coin-gecko.service';
import { CoinHistoricalPriceService } from '@/ae-pricing/services/coin-historical-price.service';
import { AETERNITY_COIN_ID } from '@/configs';
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 { fetchJson } from '@/utils/common';

export interface PortfolioHistorySnapshot {
timestamp: Moment | Date;
Expand Down Expand Up @@ -98,6 +99,7 @@ export class PortfolioService {
* separate slow historical-state request.
*/
private readonly BALANCE_BUCKET_SIZE = 300;
private readonly accountPubkeyPointerKey = 'account_pubkey';

constructor(
@InjectRepository(TokenHolder)
Expand Down Expand Up @@ -175,6 +177,8 @@ export class PortfolioService {
}
}

const resolvedAddress = await this.resolveAccountAddress(address);

// Fetch price history and current price in parallel.
// For historical prices, query the local coin_historical_prices table first
// (populated by the background CoinGecko sync). Only fall back to the live
Expand Down Expand Up @@ -244,13 +248,13 @@ export class PortfolioService {
// This replaces the previous per-snapshot SQL calls (N queries → 1 query).
const [pnlMap, rangePnlMap] = await Promise.all([
this.bclPnlService.calculateTokenPnlsBatch(
address,
resolvedAddress,
uniqueBlockHeights,
undefined,
),
includePnl && useRangeBasedPnl && startBlockHeight !== undefined
? this.bclPnlService.calculateTokenPnlsBatch(
address,
resolvedAddress,
uniqueBlockHeights,
startBlockHeight,
)
Expand Down Expand Up @@ -281,7 +285,7 @@ export class PortfolioService {
// Balance still requires an external AE node call per unique block height
const aeBalancePromise = this.getCachedBalance(
balanceCache,
address,
resolvedAddress,
blockHeight,
);

Expand Down Expand Up @@ -369,6 +373,45 @@ export class PortfolioService {
return data;
}

private async resolveAccountAddress(address: string): Promise<string> {
if (!address || address.startsWith('ak_') || !address.includes('.')) {
return address;
}

try {
const response = await fetchJson<{
owner?: string;
pointers?: Array<{
key?: string;
encoded_key?: string;
id?: string;
}>;
}>(`${ACTIVE_NETWORK.url}/v3/names/${encodeURIComponent(address)}`);

const accountPointer = response?.pointers?.find(
(pointer) =>
pointer?.key === this.accountPubkeyPointerKey &&
typeof pointer.id === 'string' &&
pointer.id.startsWith('ak_'),
)?.id;

if (accountPointer) {
return accountPointer;
}

if (response?.owner?.startsWith('ak_')) {
return response.owner;
}
} catch (error) {
this.logger.warn(
`Failed to resolve account reference ${address}, falling back to raw value`,
error instanceof Error ? error.stack : String(error),
);
}

return address;
}

private getCachedBalance(
cache: Map<number, Promise<string>>,
address: string,
Expand All @@ -387,9 +430,12 @@ export class PortfolioService {
return cached;
}

const promise = this.aeSdkService.sdk.getBalance(address as any, {
height: bucketHeight,
} as any);
const promise = this.aeSdkService.sdk.getBalance(
address as any,
{
height: bucketHeight,
} as any,
);
cache.set(bucketHeight, promise);
return promise;
}
Expand Down
44 changes: 34 additions & 10 deletions src/ae-pricing/services/coin-historical-price.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { runWithDatabaseIssueLogging } from '@/utils/database-issue-logging';
import { CoinHistoricalPrice } from '../entities/coin-historical-price.entity';

@Injectable()
Expand Down Expand Up @@ -130,16 +131,28 @@ export class CoinHistoricalPriceService {
try {
// Check for existing records to avoid duplicates
const timestamps = priceData.map(([timestamp]) => timestamp);
const existing = await this.repository.find({
where: {
coin_id: coinId,
currency: currency,
timestamp_ms: Between(
Math.min(...timestamps),
Math.max(...timestamps),
),
const existing = await runWithDatabaseIssueLogging({
logger: this.logger,
stage: 'historical price duplicate lookup',
context: {
coinId,
currency,
requestedPoints: priceData.length,
minTimestamp: Math.min(...timestamps),
maxTimestamp: Math.max(...timestamps),
},
select: ['timestamp_ms'],
operation: () =>
this.repository.find({
where: {
coin_id: coinId,
currency: currency,
timestamp_ms: Between(
Math.min(...timestamps),
Math.max(...timestamps),
),
},
select: ['timestamp_ms'],
}),
});

// TypeORM returns bigint as string, so convert to string for Set comparison
Expand Down Expand Up @@ -173,7 +186,18 @@ export class CoinHistoricalPriceService {
const chunkSize = 1000;
for (let i = 0; i < entities.length; i += chunkSize) {
const chunk = entities.slice(i, i + chunkSize);
await this.repository.save(chunk);
await runWithDatabaseIssueLogging({
logger: this.logger,
stage: 'historical price chunk save',
context: {
coinId,
currency,
chunkStart: i,
chunkSize: chunk.length,
totalNewPoints: entities.length,
},
operation: () => this.repository.save(chunk),
});
}

this.logger.log(
Expand Down
16 changes: 16 additions & 0 deletions src/ae/coin-gecko.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,20 @@ describe('CoinGeckoService', () => {
);
expect(result).toEqual({ data: 'mockData' });
});

it('should reuse cached market data for three minutes by default', async () => {
const cachedMarket = {
data: {
id: AETERNITY_COIN_ID,
currentPrice: 0.1,
},
fetchedAt: Date.now() - 2 * 60 * 1000,
};
(cacheManager.get as jest.Mock).mockResolvedValue(cachedMarket);

const result = await service.getCoinMarketData(AETERNITY_COIN_ID, 'usd');

expect(result).toEqual(cachedMarket.data);
expect(fetchJson).not.toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion src/ae/coin-gecko.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CurrencyRates } from '@/utils/types';
import { CoinHistoricalPriceService } from '@/ae-pricing/services/coin-historical-price.service';

const COIN_GECKO_API_URL = 'https://api.coingecko.com/api/v3';
const DEFAULT_MARKET_DATA_MAX_AGE_MS = 3 * 60 * 1000;

export interface CoinGeckoMarketResponse {
ath: number;
Expand Down Expand Up @@ -400,7 +401,7 @@ export class CoinGeckoService {
async getCoinMarketData(
coinId: string,
currencyCode: string,
maxAgeMs: number = 60_000,
maxAgeMs: number = DEFAULT_MARKET_DATA_MAX_AGE_MS,
): Promise<CoinGeckoMarketResponse> {
const cacheKey = `${this.marketCacheKeyPrefix}:${coinId}:${currencyCode}`;

Expand Down
Loading
Loading