Browse Source

Merge 710e8e63e9 into 5689326b12

pull/6912/merge
Andrea Bugeja 22 hours ago
committed by GitHub
parent
commit
2951589220
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 463
      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. 98
      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. 41
      apps/api/src/app/portfolio/current-rate.service.ts
  10. 88
      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

463
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) { if (startDate) {
where.AND.push({ date: { lte: endDate } }); where.date = { gte: resetHours(startDate) };
} }
if (startDate) { if (endDate) {
where.AND.push({ date: { gt: startDate } }); where.date = {
} ...(where.date as Prisma.DateTimeFilter),
lte: resetHours(endDate)
};
} }
const { if (withExcludedAccountsAndActivities === false) {
ACCOUNT: filtersByAccount, where.account = { isExcluded: false };
ASSET_CLASS: filtersByAssetClass, }
TAG: filtersByTag
} = groupBy(filters, ({ type }) => {
return type;
});
const filterByDataSource = filters?.find(({ type }) => { if (filters?.length > 0) {
return type === 'DATA_SOURCE'; where.OR = [];
})?.id;
const filterBySymbol = filters?.find(({ type }) => { const accountIds = filters
return type === 'SYMBOL'; .filter(({ type }) => type === 'ACCOUNT')
})?.id; .map(({ id }) => id);
if (accountIds.length > 0) {
where.OR.push({ accountId: { in: accountIds } });
}
const searchQuery = filters?.find(({ type }) => { const assetClasses = filters
return type === 'SEARCH_QUERY'; .filter(({ type }) => type === 'ASSET_CLASS')
})?.id; .map(({ id }) => id) as AssetClass[];
if (assetClasses.length > 0) {
where.OR.push({ SymbolProfile: { assetClass: { in: assetClasses } } });
}
if (filtersByAccount?.length > 0) { const tags = filters
where.accountId = { .filter(({ type }) => type === 'TAG')
in: filtersByAccount.map(({ id }) => { .map(({ id }) => id);
return id; if (tags.length > 0) {
}) where.OR.push({ tags: { some: { id: { in: tags } } } });
}; }
} }
if (includeDrafts === false) { let orderBy: Prisma.OrderOrderByWithRelationInput[] = [
where.isDraft = false; { date: sortDirection }
} ];
if (filtersByAssetClass?.length > 0) { if (sortColumn) {
where.SymbolProfile = { orderBy = [];
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
OR: [
{ SymbolProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } }
]
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filterByDataSource && filterBySymbol) { if (
if (where.SymbolProfile) { ['currency', 'fee', 'quantity', 'type', 'unitPrice'].includes(
where.SymbolProfile = { sortColumn
AND: [ )
where.SymbolProfile, ) {
{ orderBy.push({ [sortColumn]: sortDirection });
AND: [
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
]
}
]
};
} else { } else {
where.SymbolProfile = { if (sortColumn === 'SymbolProfile.name') {
AND: [ orderBy.push({ SymbolProfile: { name: sortDirection } });
{ dataSource: filterByDataSource as DataSource }, } else if (sortColumn === 'account.name') {
{ symbol: filterBySymbol } orderBy.push({ account: { name: sortDirection } });
] }
};
} }
} }
if (searchQuery) { const count = await this.prismaService.order.count({ where });
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag?.length > 0) { let orders: OrderWithAccount[] = [];
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
if (sortColumn) { // If take is undefined and count is extremely large, batch fetch to prevent Prisma P2029 limits
orderBy = [{ [sortColumn]: sortDirection }]; const BATCH_SIZE = 5000;
}
if (types?.length > 0) { if (take === undefined && count > BATCH_SIZE) {
where.type = { in: types }; let currentSkip = skip || 0;
} const totalToFetch = count - currentSkip;
if (withExcludedAccountsAndActivities === false) { while (orders.length < totalToFetch) {
where.OR = [ const batch = await this.orders({
{ account: null }, skip: currentSkip,
{ account: { NOT: { isExcluded: true } } } take: BATCH_SIZE,
]; where,
include: {
where.tags = { account: {
...where.tags, include: {
none: { platform: true
id: TAG_ID_EXCLUDE_FROM_ANALYSIS }
},
// 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,60 +634,178 @@ export class ActivitiesService {
assetProfileIdentifiers assetProfileIdentifiers
); );
const activities = await Promise.all( let exchangeRatesToUser: any = {};
orders.map(async (order) => { let exchangeRatesToDefault: any = {};
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return ( if (orders.length > 0) {
dataSource === order.SymbolProfile.dataSource && let minDate = orders[0].date;
symbol === order.SymbolProfile.symbol 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;
}
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); if (to === userCurrency) {
return exchangeRatesToUser[`${from}${userCurrency}`]?.[dateStr];
const [ }
feeInAssetProfileCurrency,
feeInBaseCurrency, if (to === DEFAULT_CURRENCY) {
unitPriceInAssetProfileCurrency, return exchangeRatesToDefault[`${from}${DEFAULT_CURRENCY}`]?.[dateStr];
valueInBaseCurrency }
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate( if (from === DEFAULT_CURRENCY) {
order.fee, const rateToDefault =
order.currency ?? order.SymbolProfile.currency, exchangeRatesToDefault[`${to}${DEFAULT_CURRENCY}`]?.[dateStr];
order.SymbolProfile.currency, return rateToDefault ? 1 / rateToDefault : undefined;
order.date }
),
this.exchangeRateDataService.toCurrencyAtDate( const rateFromToDefault =
order.fee, exchangeRatesToDefault[`${from}${DEFAULT_CURRENCY}`]?.[dateStr];
order.currency ?? order.SymbolProfile.currency, const rateToToDefault =
userCurrency, exchangeRatesToDefault[`${to}${DEFAULT_CURRENCY}`]?.[dateStr];
order.date if (rateFromToDefault !== undefined && rateToToDefault) {
), return rateFromToDefault / rateToToDefault;
this.exchangeRateDataService.toCurrencyAtDate( }
order.unitPrice,
order.currency ?? order.SymbolProfile.currency, return undefined;
order.SymbolProfile.currency, };
order.date
), const convertValue = async (
this.exchangeRateDataService.toCurrencyAtDate( 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 }) => {
return (
dataSource === order.SymbolProfile.dataSource &&
symbol === order.SymbolProfile.symbol
);
});
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
valueInBaseCurrency
] = await Promise.all([
convertValue(
order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
convertValue(
order.fee,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
),
convertValue(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
convertValue(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)
]);
return {
...order,
feeInAssetProfileCurrency: feeInAssetProfileCurrency ?? order.fee,
feeInBaseCurrency: feeInBaseCurrency ?? order.fee,
unitPriceInAssetProfileCurrency:
unitPriceInAssetProfileCurrency ?? order.unitPrice,
value, value,
order.currency ?? order.SymbolProfile.currency, valueInBaseCurrency: valueInBaseCurrency ?? value,
userCurrency, SymbolProfile: assetProfile
order.date };
) })
]); );
return { activities.push(...processedChunk);
...order, }
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value,
valueInBaseCurrency,
SymbolProfile: assetProfile
};
})
);
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:

98
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,9 +175,9 @@ 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(
@ -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,23 +971,38 @@ 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++) {
date, if (i % 500 === 0) {
fee, console.log(
feeInBaseCurrency, '[Trace] computeTransactionPoints progress: ' +
quantity, i +
SymbolProfile, '/' +
tags, this.activities.length
type, );
unitPrice await yieldToEventLoop();
} of this.activities) { }
const {
date,
fee,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags,
type,
unitPrice
} = 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.');
} }
} }

41
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; const data = await this.marketDataService.getRange({
i < marketDataCount; assetProfileIdentifiers: [assetProfile],
i += CurrentRateService.MARKET_DATA_PAGE_SIZE dateQuery
) { });
// Use page size to limit the number of records fetched at once return data.map(({ dataSource, date, marketPrice, symbol }) => ({
const data = await this.marketDataService.getRange({
assetProfileIdentifiers,
dateQuery,
skip: i,
take: CurrentRateService.MARKET_DATA_PAGE_SIZE
});
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 = {

88
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];
} catch {
let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( if (priceFrom && priceTo) {
date, const factor = (1 / priceFrom) * priceTo;
DATE_FORMAT if (!isNaN(factor) && isFinite(factor)) {
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`; factors[dateString] = factor;
continue;
if (DEFAULT_CURRENCY !== currencyTo) {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
} }
}
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${dateString}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
if (DEFAULT_CURRENCY !== currencyTo) {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
} }
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