Browse Source

Merge 710e8e63e9 into 5689326b12

pull/6912/merge
Andrea Bugeja 1 day ago
committed by GitHub
parent
commit
2951589220
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 369
      apps/api/src/app/activities/activities.service.ts
  2. 2
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  3. 14
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  4. 80
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  5. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  6. 25
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  7. 2
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  8. 2
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  9. 37
      apps/api/src/app/portfolio/current-rate.service.ts
  10. 82
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  11. 20
      apps/api/src/services/market-data/market-data.service.ts
  12. 1
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

369
apps/api/src/app/activities/activities.service.ts

@ -11,12 +11,16 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DEFAULT_CURRENCY,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix, ghostfolioPrefix
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
Activity, Activity,
@ -39,8 +43,8 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, format, isAfter, subDays } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
@ -469,7 +473,7 @@ export class ActivitiesService {
sortColumn, sortColumn,
sortDirection = 'asc', sortDirection = 'asc',
startDate, startDate,
take = Number.MAX_SAFE_INTEGER, take,
types, types,
userCurrency, userCurrency,
userId, userId,
@ -488,166 +492,112 @@ export class ActivitiesService {
userId: string; userId: string;
withExcludedAccountsAndActivities?: boolean; withExcludedAccountsAndActivities?: boolean;
}): Promise<ActivitiesResponse> { }): Promise<ActivitiesResponse> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ const where: Prisma.OrderWhereInput = {
{ date: 'asc' } ...(includeDrafts === false ? { isDraft: false } : {}),
]; ...(types?.length > 0 ? { type: { in: types } } : {}),
...(userId ? { userId } : {})
const where: Prisma.OrderWhereInput = { userId }; };
if (endDate || startDate) {
where.AND = [];
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (startDate) { if (startDate) {
where.AND.push({ date: { gt: startDate } }); where.date = { gte: resetHours(startDate) };
}
} }
const { if (endDate) {
ACCOUNT: filtersByAccount, where.date = {
ASSET_CLASS: filtersByAssetClass, ...(where.date as Prisma.DateTimeFilter),
TAG: filtersByTag lte: resetHours(endDate)
} = groupBy(filters, ({ type }) => {
return type;
});
const filterByDataSource = filters?.find(({ type }) => {
return type === 'DATA_SOURCE';
})?.id;
const filterBySymbol = filters?.find(({ type }) => {
return type === 'SYMBOL';
})?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
}; };
} }
if (includeDrafts === false) { if (withExcludedAccountsAndActivities === false) {
where.isDraft = false; where.account = { isExcluded: false };
} }
if (filtersByAssetClass?.length > 0) { if (filters?.length > 0) {
where.SymbolProfile = { where.OR = [];
OR: [
{ const accountIds = filters
AND: [ .filter(({ type }) => type === 'ACCOUNT')
{ .map(({ id }) => id);
OR: filtersByAssetClass.map(({ id }) => { if (accountIds.length > 0) {
return { assetClass: AssetClass[id] }; where.OR.push({ accountId: { in: accountIds } });
})
},
{
OR: [
{ SymbolProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } }
]
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
} }
if (filterByDataSource && filterBySymbol) { const assetClasses = filters
if (where.SymbolProfile) { .filter(({ type }) => type === 'ASSET_CLASS')
where.SymbolProfile = { .map(({ id }) => id) as AssetClass[];
AND: [ if (assetClasses.length > 0) {
where.SymbolProfile, where.OR.push({ SymbolProfile: { assetClass: { in: assetClasses } } });
{
AND: [
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
]
} }
]
}; const tags = filters
} else { .filter(({ type }) => type === 'TAG')
where.SymbolProfile = { .map(({ id }) => id);
AND: [ if (tags.length > 0) {
{ dataSource: filterByDataSource as DataSource }, where.OR.push({ tags: { some: { id: { in: tags } } } });
{ symbol: filterBySymbol }
]
};
} }
} }
if (searchQuery) { let orderBy: Prisma.OrderOrderByWithRelationInput[] = [
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ { date: sortDirection }
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
]; ];
if (where.SymbolProfile) { if (sortColumn) {
where.SymbolProfile = { orderBy = [];
AND: [
where.SymbolProfile, if (
{ ['currency', 'fee', 'quantity', 'type', 'unitPrice'].includes(
OR: searchQueryWhereInput sortColumn
} )
] ) {
}; orderBy.push({ [sortColumn]: sortDirection });
} else { } else {
where.SymbolProfile = { if (sortColumn === 'SymbolProfile.name') {
OR: searchQueryWhereInput orderBy.push({ SymbolProfile: { name: sortDirection } });
}; } else if (sortColumn === 'account.name') {
orderBy.push({ account: { name: sortDirection } });
} }
} }
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
} }
if (sortColumn) { const count = await this.prismaService.order.count({ where });
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types?.length > 0) { let orders: OrderWithAccount[] = [];
where.type = { in: types };
}
if (withExcludedAccountsAndActivities === false) { // If take is undefined and count is extremely large, batch fetch to prevent Prisma P2029 limits
where.OR = [ const BATCH_SIZE = 5000;
{ account: null },
{ account: { NOT: { isExcluded: true } } } if (take === undefined && count > BATCH_SIZE) {
]; let currentSkip = skip || 0;
const totalToFetch = count - currentSkip;
where.tags = { while (orders.length < totalToFetch) {
...where.tags, const batch = await this.orders({
none: { skip: currentSkip,
id: TAG_ID_EXCLUDE_FROM_ANALYSIS take: BATCH_SIZE,
where,
include: {
account: {
include: {
platform: true
} }
}; },
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
tags: true
},
orderBy: [...orderBy, { id: sortDirection }]
});
if (batch.length === 0) {
break;
} }
const [orders, count] = await Promise.all([ orders = orders.concat(batch);
this.orders({ currentSkip += batch.length;
}
} else {
orders = await this.orders({
skip, skip,
take, take,
where, where,
@ -662,9 +612,8 @@ export class ActivitiesService {
tags: true tags: true
}, },
orderBy: [...orderBy, { id: sortDirection }] orderBy: [...orderBy, { id: sortDirection }]
}), });
this.prismaService.order.count({ where }) }
]);
const assetProfileIdentifiers = uniqBy( const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => { orders.map(({ SymbolProfile }) => {
@ -685,8 +634,122 @@ export class ActivitiesService {
assetProfileIdentifiers assetProfileIdentifiers
); );
const activities = await Promise.all( let exchangeRatesToUser: any = {};
orders.map(async (order) => { let exchangeRatesToDefault: any = {};
if (orders.length > 0) {
let minDate = orders[0].date;
let maxDate = orders[0].date;
const uniqueCurrencies = new Set<string>();
uniqueCurrencies.add(userCurrency);
uniqueCurrencies.add(DEFAULT_CURRENCY);
const uniqueDatesSet = new Set<number>();
for (const order of orders) {
if (order.date < minDate) {
minDate = order.date;
}
if (order.date > maxDate) {
maxDate = order.date;
}
uniqueDatesSet.add(resetHours(order.date).getTime());
if (order.currency) {
uniqueCurrencies.add(order.currency);
}
if (order.SymbolProfile?.currency) {
uniqueCurrencies.add(order.SymbolProfile.currency);
}
}
const currenciesList = Array.from(uniqueCurrencies).filter(Boolean);
const uniqueDates = Array.from(uniqueDatesSet).map(
(time) => new Date(time)
);
const startDatePreload = subDays(resetHours(minDate), 1);
const endDatePreload = resetHours(maxDate);
const [ratesUser, ratesDefault] = await Promise.all([
this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: currenciesList,
dates: uniqueDates,
endDate: endDatePreload,
startDate: startDatePreload,
targetCurrency: userCurrency
}),
this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: currenciesList,
dates: uniqueDates,
endDate: endDatePreload,
startDate: startDatePreload,
targetCurrency: DEFAULT_CURRENCY
})
]);
exchangeRatesToUser = ratesUser;
exchangeRatesToDefault = ratesDefault;
}
const getPreloadedRate = (
from: string,
to: string,
dateStr: string
): number | undefined => {
if (from === to) {
return 1;
}
if (to === userCurrency) {
return exchangeRatesToUser[`${from}${userCurrency}`]?.[dateStr];
}
if (to === DEFAULT_CURRENCY) {
return exchangeRatesToDefault[`${from}${DEFAULT_CURRENCY}`]?.[dateStr];
}
if (from === DEFAULT_CURRENCY) {
const rateToDefault =
exchangeRatesToDefault[`${to}${DEFAULT_CURRENCY}`]?.[dateStr];
return rateToDefault ? 1 / rateToDefault : undefined;
}
const rateFromToDefault =
exchangeRatesToDefault[`${from}${DEFAULT_CURRENCY}`]?.[dateStr];
const rateToToDefault =
exchangeRatesToDefault[`${to}${DEFAULT_CURRENCY}`]?.[dateStr];
if (rateFromToDefault !== undefined && rateToToDefault) {
return rateFromToDefault / rateToToDefault;
}
return undefined;
};
const convertValue = async (
val: number,
from: string,
to: string,
date: Date
): Promise<number> => {
if (val === 0) {
return 0;
}
const dateStr = format(date, DATE_FORMAT);
const rate = getPreloadedRate(from, to, dateStr);
if (rate !== undefined && !isNaN(rate)) {
return rate * val;
}
return this.exchangeRateDataService.toCurrencyAtDate(val, from, to, date);
};
const activities = [];
const chunkSize = 500;
for (let i = 0; i < orders.length; i += chunkSize) {
const chunk = orders.slice(i, i + chunkSize);
const processedChunk = await Promise.all(
chunk.map(async (order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return ( return (
dataSource === order.SymbolProfile.dataSource && dataSource === order.SymbolProfile.dataSource &&
@ -702,25 +765,25 @@ export class ActivitiesService {
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency,
valueInBaseCurrency valueInBaseCurrency
] = await Promise.all([ ] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate( convertValue(
order.fee, order.fee,
order.currency ?? order.SymbolProfile.currency, order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency, order.SymbolProfile.currency,
order.date order.date
), ),
this.exchangeRateDataService.toCurrencyAtDate( convertValue(
order.fee, order.fee,
order.currency ?? order.SymbolProfile.currency, order.currency ?? order.SymbolProfile.currency,
userCurrency, userCurrency,
order.date order.date
), ),
this.exchangeRateDataService.toCurrencyAtDate( convertValue(
order.unitPrice, order.unitPrice,
order.currency ?? order.SymbolProfile.currency, order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency, order.SymbolProfile.currency,
order.date order.date
), ),
this.exchangeRateDataService.toCurrencyAtDate( convertValue(
value, value,
order.currency ?? order.SymbolProfile.currency, order.currency ?? order.SymbolProfile.currency,
userCurrency, userCurrency,
@ -730,16 +793,20 @@ export class ActivitiesService {
return { return {
...order, ...order,
feeInAssetProfileCurrency, feeInAssetProfileCurrency: feeInAssetProfileCurrency ?? order.fee,
feeInBaseCurrency, feeInBaseCurrency: feeInBaseCurrency ?? order.fee,
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency:
unitPriceInAssetProfileCurrency ?? order.unitPrice,
value, value,
valueInBaseCurrency, valueInBaseCurrency: valueInBaseCurrency ?? value,
SymbolProfile: assetProfile SymbolProfile: assetProfile
}; };
}) })
); );
activities.push(...processedChunk);
}
return { activities, count }; return { activities, count };
} }

2
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -23,7 +23,7 @@ export class MwrPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & AssetProfileIdentifier): SymbolMetrics { } & AssetProfileIdentifier): Promise<SymbolMetrics> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

14
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -34,6 +34,7 @@ export class PortfolioCalculatorFactory {
calculationType, calculationType,
currency, currency,
filters = [], filters = [],
skipInitialize = false,
userId userId
}: { }: {
accountBalanceItems?: HistoricalDataItem[]; accountBalanceItems?: HistoricalDataItem[];
@ -41,6 +42,7 @@ export class PortfolioCalculatorFactory {
calculationType: PerformanceCalculationType; calculationType: PerformanceCalculationType;
currency: string; currency: string;
filters?: Filter[]; filters?: Filter[];
skipInitialize?: boolean;
userId: string; userId: string;
}): PortfolioCalculator { }): PortfolioCalculator {
switch (calculationType) { switch (calculationType) {
@ -55,7 +57,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService,
skipInitialize
}); });
case PerformanceCalculationType.ROAI: case PerformanceCalculationType.ROAI:
@ -69,7 +72,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService,
skipInitialize
}); });
case PerformanceCalculationType.ROI: case PerformanceCalculationType.ROI:
@ -83,7 +87,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService,
skipInitialize
}); });
case PerformanceCalculationType.TWR: case PerformanceCalculationType.TWR:
@ -97,7 +102,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService,
skipInitialize
}); });
default: default:

80
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -59,6 +59,13 @@ import {
} from 'date-fns'; } from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
const yieldToEventLoop = async () => {
if (process.env.NODE_ENV === 'test') {
return;
}
await new Promise((resolve) => setImmediate(resolve));
};
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
@ -90,6 +97,7 @@ export abstract class PortfolioCalculator {
filters, filters,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService,
skipInitialize = false,
userId userId
}: { }: {
accountBalanceItems: HistoricalDataItem[]; accountBalanceItems: HistoricalDataItem[];
@ -101,6 +109,7 @@ export abstract class PortfolioCalculator {
filters: Filter[]; filters: Filter[];
portfolioSnapshotService: PortfolioSnapshotService; portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService; redisCacheService: RedisCacheService;
skipInitialize?: boolean;
userId: string; userId: string;
}) { }) {
this.accountBalanceItems = accountBalanceItems; this.accountBalanceItems = accountBalanceItems;
@ -166,10 +175,10 @@ export abstract class PortfolioCalculator {
this.endDate = endOfDay(endDate); this.endDate = endOfDay(endDate);
this.startDate = startOfDay(startDate); this.startDate = startOfDay(startDate);
this.computeTransactionPoints(); if (!skipInitialize) {
this.snapshotPromise = this.initialize(); this.snapshotPromise = this.initialize();
} }
}
protected abstract calculateOverallPerformance( protected abstract calculateOverallPerformance(
positions: TimelinePosition[] positions: TimelinePosition[]
@ -177,6 +186,11 @@ export abstract class PortfolioCalculator {
@LogPerformance @LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> { public async computeSnapshot(): Promise<PortfolioSnapshot> {
console.log('[Trace] computeSnapshot started');
if (!this.transactionPoints) {
await this.computeTransactionPoints();
}
const lastTransactionPoint = this.transactionPoints.at(-1); const lastTransactionPoint = this.transactionPoints.at(-1);
const transactionPoints = this.transactionPoints?.filter(({ date }) => { const transactionPoints = this.transactionPoints?.filter(({ date }) => {
@ -234,6 +248,8 @@ export abstract class PortfolioCalculator {
} }
} }
Logger.log('Fetching exchange rates...', 'Trace');
const t1 = Date.now();
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))), currencies: Array.from(new Set(Object.values(currencies))),
@ -242,6 +258,13 @@ export abstract class PortfolioCalculator {
targetCurrency: this.currency targetCurrency: this.currency
}); });
Logger.log(
'Exchange rates fetched in ' +
(Date.now() - t1) +
'ms. Fetching market data...',
'Trace'
);
const t2 = Date.now();
const { const {
dataProviderInfos, dataProviderInfos,
errors: currentRateErrors, errors: currentRateErrors,
@ -256,6 +279,13 @@ export abstract class PortfolioCalculator {
this.dataProviderInfos = dataProviderInfos; this.dataProviderInfos = dataProviderInfos;
Logger.log(
'Market data fetched in ' +
(Date.now() - t2) +
'ms. Processing symbols...',
'Trace'
);
const t3 = Date.now();
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
} = {}; } = {};
@ -294,6 +324,13 @@ export abstract class PortfolioCalculator {
chartDateMap[accountBalanceItem.date] = true; chartDateMap[accountBalanceItem.date] = true;
} }
Logger.log(
'Symbols processed in ' +
(Date.now() - t3) +
'ms. Processing positions...',
'Trace'
);
console.log('t4', Date.now());
const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => {
return chartDate; return chartDate;
}); });
@ -338,7 +375,13 @@ export abstract class PortfolioCalculator {
}; };
} = {}; } = {};
for (const item of lastTransactionPoint.items) { Logger.log('Starting symbol metrics loop...', 'Trace');
console.log('t5', Date.now());
for (let i = 0; i < lastTransactionPoint.items.length; i++) {
if (i % 5 === 0) {
await yieldToEventLoop();
}
const item = lastTransactionPoint.items[i];
const marketPriceInBaseCurrency = ( const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul( ).mul(
@ -374,7 +417,7 @@ export abstract class PortfolioCalculator {
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency totalLiabilitiesInBaseCurrency
} = this.getSymbolMetrics({ } = await this.getSymbolMetrics({
chartDateMap, chartDateMap,
marketSymbolMap, marketSymbolMap,
dataSource: item.dataSource, dataSource: item.dataSource,
@ -483,7 +526,11 @@ export abstract class PortfolioCalculator {
let lastKnownBalance = new Big(0); let lastKnownBalance = new Big(0);
for (const dateString of chartDates) { for (let c = 0; c < chartDates.length; c++) {
if (c % 100 === 0) {
await yieldToEventLoop();
}
const dateString = chartDates[c];
if (accountBalanceItemsMap[dateString] !== undefined) { if (accountBalanceItemsMap[dateString] !== undefined) {
// If there's an exact balance for this date, update lastKnownBalance // If there's an exact balance for this date, update lastKnownBalance
lastKnownBalance = accountBalanceItemsMap[dateString]; lastKnownBalance = accountBalanceItemsMap[dateString];
@ -831,7 +878,7 @@ export abstract class PortfolioCalculator {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
}; };
start: Date; start: Date;
} & AssetProfileIdentifier): SymbolMetrics; } & AssetProfileIdentifier): Promise<SymbolMetrics>;
public getTransactionPoints() { public getTransactionPoints() {
return this.transactionPoints; return this.transactionPoints;
@ -924,14 +971,29 @@ export abstract class PortfolioCalculator {
} }
@LogPerformance @LogPerformance
private computeTransactionPoints() { protected async computeTransactionPoints() {
console.log(
'[Trace] computeTransactionPoints started, activities count: ' +
this.activities.length
);
this.transactionPoints = []; this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {}; const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null; let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const { for (let i = 0; i < this.activities.length; i++) {
if (i % 500 === 0) {
console.log(
'[Trace] computeTransactionPoints progress: ' +
i +
'/' +
this.activities.length
);
await yieldToEventLoop();
}
const {
date, date,
fee, fee,
feeInBaseCurrency, feeInBaseCurrency,
@ -940,7 +1002,7 @@ export abstract class PortfolioCalculator {
tags, tags,
type, type,
unitPrice unitPrice
} of this.activities) { } = this.activities[i];
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass; const assetSubClass = SymbolProfile.assetSubClass;

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -72,7 +72,7 @@ describe('PortfolioCalculator', () => {
}); });
describe('get transaction point', () => { describe('get transaction point', () => {
it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', () => { it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
@ -133,6 +133,7 @@ describe('PortfolioCalculator', () => {
userId: userDummyData.id userId: userDummyData.id
}); });
await portfolioCalculator.computeSnapshot();
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const lastTransactionPoint = const lastTransactionPoint =
transactionPoints[transactionPoints.length - 1]; transactionPoints[transactionPoints.length - 1];

25
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -23,6 +23,13 @@ import {
} from 'date-fns'; } from 'date-fns';
import { cloneDeep, sortBy } from 'lodash'; import { cloneDeep, sortBy } from 'lodash';
const yieldToEventLoop = async () => {
if (process.env.NODE_ENV === 'test') {
return;
}
await new Promise((resolve) => setImmediate(resolve));
};
export class RoaiPortfolioCalculator extends PortfolioCalculator { export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[]; private chartDates: string[];
@ -127,7 +134,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
return PerformanceCalculationType.ROAI; return PerformanceCalculationType.ROAI;
} }
protected getSymbolMetrics({ protected async getSymbolMetrics({
chartDateMap, chartDateMap,
dataSource, dataSource,
end, end,
@ -143,7 +150,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
}; };
start: Date; start: Date;
} & AssetProfileIdentifier): SymbolMetrics { } & AssetProfileIdentifier): Promise<SymbolMetrics> {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {}; const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
@ -345,7 +352,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
this.chartDates = Object.keys(chartDateMap).sort(); this.chartDates = Object.keys(chartDateMap).sort();
} }
for (const dateString of this.chartDates) { for (let d = 0; d < this.chartDates.length; d++) {
if (d % 500 === 0) {
await yieldToEventLoop();
}
const dateString = this.chartDates[d];
if (dateString < startDateString) { if (dateString < startDateString) {
continue; continue;
} else if (dateString > endDateString) { } else if (dateString > endDateString) {
@ -408,6 +419,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) { for (let i = 0; i < orders.length; i += 1) {
if (i % 500 === 0) {
await yieldToEventLoop();
}
const order = orders[i]; const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
@ -887,6 +902,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let dayCount = 0; let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
if (i % 500 === 0) {
await yieldToEventLoop();
}
const date = this.chartDates[i]; const date = this.chartDates[i];
if (date > rangeEndDateString) { if (date > rangeEndDateString) {

2
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -23,7 +23,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & AssetProfileIdentifier): SymbolMetrics { } & AssetProfileIdentifier): Promise<SymbolMetrics> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -23,7 +23,7 @@ export class TwrPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & AssetProfileIdentifier): SymbolMetrics { } & AssetProfileIdentifier): Promise<SymbolMetrics> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

37
apps/api/src/app/portfolio/current-rate.service.ts

@ -24,8 +24,6 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
export class CurrentRateService { export class CurrentRateService {
private static readonly MARKET_DATA_PAGE_SIZE = 50000;
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -84,32 +82,27 @@ export class CurrentRateService {
return { dataSource, symbol }; return { dataSource, symbol };
}); });
const marketDataCount = await this.marketDataService.getRangeCount({ // Fetch each asset profile individually to use the composite index efficiently
assetProfileIdentifiers, // Process in batches of 10 to avoid overwhelming the database
dateQuery const batchSize = 10;
}); for (let i = 0; i < assetProfileIdentifiers.length; i += batchSize) {
const batch = assetProfileIdentifiers.slice(i, i + batchSize);
for ( const promises = batch.map(async (assetProfile) => {
let i = 0;
i < marketDataCount;
i += CurrentRateService.MARKET_DATA_PAGE_SIZE
) {
// Use page size to limit the number of records fetched at once
const data = await this.marketDataService.getRange({ const data = await this.marketDataService.getRange({
assetProfileIdentifiers, assetProfileIdentifiers: [assetProfile],
dateQuery, dateQuery
skip: i,
take: CurrentRateService.MARKET_DATA_PAGE_SIZE
}); });
return data.map(({ dataSource, date, marketPrice, symbol }) => ({
values.push(
...data.map(({ dataSource, date, marketPrice, symbol }) => ({
dataSource, dataSource,
date, date,
marketPrice, marketPrice,
symbol symbol
})) }));
); });
const results = await Promise.all(promises);
for (const result of results) {
values.push(...result);
}
} }
const response: GetValuesObject = { const response: GetValuesObject = {

82
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -53,16 +53,18 @@ export class ExchangeRateDataService {
@LogPerformance @LogPerformance
public async getExchangeRatesByCurrency({ public async getExchangeRatesByCurrency({
currencies, currencies,
dates,
endDate = new Date(), endDate = new Date(),
startDate, startDate,
targetCurrency targetCurrency
}: { }: {
currencies: string[]; currencies: string[];
dates?: Date[];
endDate?: Date; endDate?: Date;
startDate: Date; startDate?: Date;
targetCurrency: string; targetCurrency: string;
}): Promise<ExchangeRatesByCurrency> { }): Promise<ExchangeRatesByCurrency> {
if (!startDate) { if (!startDate && !dates?.length) {
return {}; return {};
} }
@ -73,6 +75,8 @@ export class ExchangeRateDataService {
for (const currency of currencies) { for (const currency of currencies) {
exchangeRatesByCurrency[`${currency}${targetCurrency}`] = exchangeRatesByCurrency[`${currency}${targetCurrency}`] =
await this.getExchangeRates({ await this.getExchangeRates({
dates,
endDate,
startDate, startDate,
currencyFrom: currency, currencyFrom: currency,
currencyTo: targetCurrency currencyTo: targetCurrency
@ -90,11 +94,14 @@ export class ExchangeRateDataService {
lastDateString lastDateString
] ?? 1; ] ?? 1;
const loopStartDate =
startDate || dates?.reduce((min, d) => (d < min ? d : min), dates[0]);
// Start from the most recent date and fill in missing exchange rates // Start from the most recent date and fill in missing exchange rates
// using the latest available rate // using the latest available rate
for ( for (
let date = endDate; let date = endDate;
!isBefore(date, startDate); loopStartDate && !isBefore(date, loopStartDate);
date = subDays(resetHours(date), 1) date = subDays(resetHours(date), 1)
) { ) {
const dateString = format(date, DATE_FORMAT); const dateString = format(date, DATE_FORMAT);
@ -110,7 +117,7 @@ export class ExchangeRateDataService {
previousExchangeRate; previousExchangeRate;
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
Logger.error( Logger.debug(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`,
'ExchangeRateDataService' 'ExchangeRateDataService'
); );
@ -355,19 +362,22 @@ export class ExchangeRateDataService {
private async getExchangeRates({ private async getExchangeRates({
currencyFrom, currencyFrom,
currencyTo, currencyTo,
dates,
endDate = new Date(), endDate = new Date(),
startDate startDate
}: { }: {
currencyFrom: string; currencyFrom: string;
currencyTo: string; currencyTo: string;
dates?: Date[];
endDate?: Date; endDate?: Date;
startDate: Date; startDate?: Date;
}) { }) {
const dates = eachDayOfInterval({ end: endDate, start: startDate }); const datesToProcess =
dates ?? eachDayOfInterval({ end: endDate, start: startDate });
const factors: { [dateString: string]: number } = {}; const factors: { [dateString: string]: number } = {};
if (currencyFrom === currencyTo) { if (currencyFrom === currencyTo) {
for (const date of dates) { for (const date of datesToProcess) {
factors[format(date, DATE_FORMAT)] = 1; factors[format(date, DATE_FORMAT)] = 1;
} }
@ -378,7 +388,7 @@ export class ExchangeRateDataService {
this.derivedCurrencyFactors[`${currencyFrom}${currencyTo}`]; this.derivedCurrencyFactors[`${currencyFrom}${currencyTo}`];
if (derivedCurrencyFactor) { if (derivedCurrencyFactor) {
for (const date of dates) { for (const date of datesToProcess) {
factors[format(date, DATE_FORMAT)] = derivedCurrencyFactor; factors[format(date, DATE_FORMAT)] = derivedCurrencyFactor;
} }
@ -395,7 +405,8 @@ export class ExchangeRateDataService {
symbol symbol
} }
], ],
dateQuery: { gte: startDate, lt: endDate } dateQuery:
dates?.length > 0 ? { in: dates } : { gte: startDate, lt: endDate }
}); });
if (marketData?.length > 0) { if (marketData?.length > 0) {
@ -414,7 +425,7 @@ export class ExchangeRateDataService {
try { try {
if (currencyFrom === DEFAULT_CURRENCY) { if (currencyFrom === DEFAULT_CURRENCY) {
for (const date of dates) { for (const date of datesToProcess) {
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] = 1; marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] = 1;
} }
} else { } else {
@ -425,7 +436,10 @@ export class ExchangeRateDataService {
symbol: `${DEFAULT_CURRENCY}${currencyFrom}` symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
} }
], ],
dateQuery: { gte: startDate, lt: endDate } dateQuery:
dates?.length > 0
? { in: dates }
: { gte: startDate, lt: endDate }
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {
@ -437,7 +451,7 @@ export class ExchangeRateDataService {
try { try {
if (currencyTo === DEFAULT_CURRENCY) { if (currencyTo === DEFAULT_CURRENCY) {
for (const date of dates) { for (const date of datesToProcess) {
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1; marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1;
} }
} else { } else {
@ -448,10 +462,10 @@ export class ExchangeRateDataService {
symbol: `${DEFAULT_CURRENCY}${currencyTo}` symbol: `${DEFAULT_CURRENCY}${currencyTo}`
} }
], ],
dateQuery: { dateQuery:
gte: startDate, dates?.length > 0
lt: endDate ? { in: dates }
} : { gte: startDate, lt: endDate }
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {
@ -461,30 +475,32 @@ export class ExchangeRateDataService {
} }
} catch {} } catch {}
for (const date of dates) { for (let i = 0; i < datesToProcess.length; i++) {
try { if (i % 500 === 0 && process.env.NODE_ENV !== 'test') {
const factor = await new Promise((resolve) => setImmediate(resolve));
(1 / }
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)]) *
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
if (isNaN(factor)) { const date = datesToProcess[i];
throw new Error('Exchange rate is not a number'); const dateString = format(date, DATE_FORMAT);
} else {
factors[format(date, DATE_FORMAT)] = factor; const priceFrom = marketPriceBaseCurrencyFromCurrency[dateString];
const priceTo = marketPriceBaseCurrencyToCurrency[dateString];
if (priceFrom && priceTo) {
const factor = (1 / priceFrom) * priceTo;
if (!isNaN(factor) && isFinite(factor)) {
factors[dateString] = factor;
continue;
} }
} catch { }
let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
date, let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${dateString}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
DATE_FORMAT
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
if (DEFAULT_CURRENCY !== currencyTo) { if (DEFAULT_CURRENCY !== currencyTo) {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
} }
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); Logger.debug(`${errorMessage}.`, 'ExchangeRateDataService');
}
} }
} }

20
apps/api/src/services/market-data/market-data.service.ts

@ -92,26 +92,6 @@ export class MarketDataService {
}); });
} }
public async getRangeCount({
assetProfileIdentifiers,
dateQuery
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery;
}): Promise<number> {
return this.prismaService.marketData.count({
where: {
date: dateQuery,
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
}
});
}
public async marketDataItems(params: { public async marketDataItems(params: {
select?: Prisma.MarketDataSelectScalar; select?: Prisma.MarketDataSelectScalar;
skip?: number; skip?: number;

1
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -67,6 +67,7 @@ export class PortfolioSnapshotProcessor {
calculationType: job.data.calculationType, calculationType: job.data.calculationType,
currency: job.data.userCurrency, currency: job.data.userCurrency,
filters: job.data.filters, filters: job.data.filters,
skipInitialize: true,
userId: job.data.userId userId: job.data.userId
}); });

Loading…
Cancel
Save