Browse Source

Merge branch 'main' into bugfix/benchmark-dates-in-time-zones-other-than-utc

pull/8117/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
67ef597e0b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 22
      apps/api/src/app/activities/activities.service.ts
  3. 19
      apps/api/src/app/endpoints/ai/ai.controller.ts
  4. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  5. 2
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  6. 9
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  8. 91
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  9. 43
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 42
      apps/api/src/services/data-provider/data-provider.service.ts
  11. 10
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  12. 24
      libs/common/src/lib/calculation-helper.spec.ts
  13. 20
      libs/common/src/lib/calculation-helper.ts

4
CHANGELOG.md

@ -15,7 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental)
- Fixed the start date of the date ranges for instances in time zones with a negative UTC offset
- Fixed the dates of the benchmark in the benchmark comparator for instances in time zones with a negative UTC offset
- Fixed the value of the accounts by excluding the cash balance when filtering by an asset class other than liquidity on the accounts page and the allocations page
- Fixed the value of the accounts by excluding the cash balance when filtering by an asset class other than liquidity in the server of the Model Context Protocol (MCP) (experimental)
## 3.83.0 - 2026-10-10

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

@ -83,7 +83,6 @@ export class ActivitiesService {
public areCashActivitiesExcludedByFilters(filters: Filter[] = []) {
const {
ASSET_CLASS: filtersByAssetClass = [],
DATA_SOURCE: [filterByDataSource] = [],
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
@ -92,10 +91,7 @@ export class ActivitiesService {
});
const isFilteredByAssetClassOtherThanLiquidity =
filtersByAssetClass.length > 0 &&
!filtersByAssetClass.some(({ id }) => {
return id === AssetClass.LIQUIDITY;
});
this.isFilteredByAssetClassOtherThanLiquidity(filters);
const isFilteredByAssetProfile = !!(filterByDataSource || filterBySymbol);
const isFilteredByTag = filtersByTag.length > 0;
@ -875,6 +871,22 @@ export class ActivitiesService {
});
}
public isFilteredByAssetClassOtherThanLiquidity(filters: Filter[] = []) {
const { ASSET_CLASS: filtersByAssetClass = [] } = groupBy(
filters,
({ type }) => {
return type;
}
);
return (
filtersByAssetClass.length > 0 &&
!filtersByAssetClass.some(({ id }) => {
return id === AssetClass.LIQUIDITY;
})
);
}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {

19
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,11 +1,17 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import { scopes } from '@ghostfolio/common/scopes';
import type {
AiPromptMode,
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Controller,
@ -13,11 +19,9 @@ import {
Inject,
Param,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AiService } from './ai.service';
@ -31,9 +35,10 @@ export class AiController {
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getPrompt(
@Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('mode') mode: AiPromptMode,
@Query()
{ accounts, assetClasses, dataSource, symbol, tags }: FilterDto
@ -49,9 +54,9 @@ export class AiController {
const prompt = await this.aiService.getPrompt({
filters,
mode,
userId,
languageCode: this.request.user.settings.settings.language,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
userCurrency: userSettings.baseCurrency
});
return { prompt };

2
apps/api/src/app/endpoints/ai/ai.module.ts

@ -1,6 +1,7 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -15,6 +16,7 @@ import { AiService } from './ai.service';
imports: [
ApiModule,
ConfigurationModule,
ImpersonationModule,
PortfolioTableModule,
PropertyModule,
TransformDataSourceInRequestModule

2
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -38,7 +38,7 @@ export class GhostfolioMcpController {
', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getAccountsTableValueColumnNames().join(
', '
)}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter, it is the value of the holding in the account without the cash balance. Without the holding parameter, it includes the full cash balance of the account, also with the assetClasses parameter. The balance is always the full cash balance of the account in the currency of the account.`,
)}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter or with asset classes which do not include LIQUIDITY, it is the value of the matching holdings in the account without the cash balance. Otherwise, it includes the full cash balance of the account. The balance is always the full cash balance of the account in the currency of the account.`,
name: 'get-accounts',
parameters: GET_ACCOUNTS_PARAMETERS
})

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

@ -64,7 +64,6 @@ import {
format,
isAfter,
isBefore,
isEqual,
isFuture,
isPast,
isWithinInterval,
@ -1418,8 +1417,8 @@ export abstract class PortfolioCalculator {
let netPerformanceAtStartDate: number;
let netPerformanceWithCurrencyEffectAtStartDate: number;
const endDate = this.toChartDate(end);
const startDate = this.toChartDate(start);
const endDate = resetHours(end);
const startDate = resetHours(start);
for (const historicalDataItem of historicalData) {
const date = resetHours(parseDate(historicalDataItem.date));
@ -1914,8 +1913,4 @@ export abstract class PortfolioCalculator {
await this.initialize(attempt + 1);
}
}
private toChartDate(aDate: Date) {
return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate;
}
}

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts

@ -190,6 +190,10 @@ describe('PortfolioCalculator', () => {
});
describe('get performance', () => {
afterEach(() => {
jest.useRealTimers();
});
it('with a calendar year date range', async () => {
const snapshot = await portfolioCalculator.getSnapshot();
@ -226,5 +230,45 @@ describe('PortfolioCalculator', () => {
})
).toEqual(['2016-12-31', '2017-12-31']);
});
it('with a year to date date range', async () => {
const snapshot = await portfolioCalculator.getSnapshot();
snapshot.historicalData = [
getHistoricalDataItem({
date: '2016-12-30',
investment: 100,
netPerformance: 0
}),
getHistoricalDataItem({
date: '2016-12-31',
investment: 100,
netPerformance: 10
}),
getHistoricalDataItem({
date: '2017-01-01',
investment: 100,
netPerformance: 20
})
];
// 1 January 2017, 10:00 in New York
jest.useFakeTimers().setSystemTime(new Date('2017-01-01T15:00:00.000Z'));
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: 'ytd'
});
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
expect(
chart.map(({ date }) => {
return date;
})
).toEqual(['2016-12-31', '2017-01-01']);
});
});
});

91
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -16,7 +16,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { parseDate } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier,
@ -1045,7 +1045,7 @@ describe('PortfolioService', () => {
expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2024-12-31')),
start: resetHours(parseDate('2024-06-15'))
start: parseDate('2024-06-15')
});
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, {
end: endOfDay(parseDate('2025-06-15')),
@ -1099,11 +1099,11 @@ describe('PortfolioService', () => {
expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, {
end: endOfDay(parseDate('2026-01-01')),
start: resetHours(parseDate('2025-01-01'))
start: parseDate('2025-01-01')
});
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2025-12-31')),
start: resetHours(parseDate('2025-01-01'))
start: parseDate('2025-01-01')
});
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, {
end: endOfDay(parseDate('2026-01-01')),
@ -1454,7 +1454,11 @@ describe('PortfolioService', () => {
getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{
accounts: Record<
string,
{ quantity?: number; valueInBaseCurrency: number }
{
balance: number;
quantity?: number;
valueInBaseCurrency: number;
}
>;
platforms: Record<string, { valueInBaseCurrency: number }>;
}>;
@ -1675,6 +1679,83 @@ describe('PortfolioService', () => {
expect(platforms[account.platformId].valueInBaseCurrency).toBe(10);
});
it('should exclude the cash balance if the activities are filtered by an asset class other than liquidity', async () => {
const { accounts, platforms } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }],
portfolioItemsNow: {
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
expect(accounts[account.id].balance).toBe(100);
// 1 * 10 (activity), without the balance of 100
expect(accounts[account.id].valueInBaseCurrency).toBe(10);
expect(platforms[account.platformId].valueInBaseCurrency).toBe(10);
});
it('should include the cash balance if the activities are filtered by asset classes including liquidity', async () => {
const { accounts, platforms } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [
{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' },
{ id: AssetClass.LIQUIDITY, type: 'ASSET_CLASS' }
],
portfolioItemsNow: {
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
// 100 (balance) + 1 * 10 (activity)
expect(accounts[account.id].valueInBaseCurrency).toBe(110);
expect(platforms[account.platformId].valueInBaseCurrency).toBe(110);
});
it('should include the cash balance if the activities are filtered by a tag', async () => {
const { accounts, platforms } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [{ id: randomUUID(), type: 'TAG' }],
portfolioItemsNow: {
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
// 100 (balance) + 1 * 10 (activity)
expect(accounts[account.id].valueInBaseCurrency).toBe(110);
expect(platforms[account.platformId].valueInBaseCurrency).toBe(110);
});
it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => {
const platformId = randomUUID();

43
apps/api/src/app/portfolio/portfolio.service.ts

@ -47,6 +47,7 @@ import {
isAccountExcluded,
isDraftActivity,
parseDate,
resetHours,
resolveUserSettings
} from '@ghostfolio/common/helper';
import {
@ -559,8 +560,13 @@ export class PortfolioService {
const { historicalData } = await portfolioCalculator.getSnapshot();
const items = historicalData.filter(({ date }) => {
return !isBefore(date, startDate) && !isAfter(date, endDate);
const items = historicalData.filter((historicalDataItem) => {
const date = resetHours(parseDate(historicalDataItem.date));
return (
!isBefore(date, resetHours(startDate)) &&
!isAfter(date, resetHours(endDate))
);
});
let investments: InvestmentItem[];
@ -1050,11 +1056,11 @@ export class PortfolioService {
);
});
const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol }],
parseISO(dateOfFirstActivity, { in: utc }),
new Date()
);
const historicalData = await this.dataProviderService.getHistorical({
assetProfileIdentifiers: [{ dataSource, symbol }],
from: parseISO(dateOfFirstActivity, { in: utc }),
to: new Date()
});
const [firstActivity] = activitiesOfHolding;
const referenceUnitPrice =
@ -2432,6 +2438,9 @@ export class PortfolioService {
return type;
});
const isFilteredByAssetClassOtherThanLiquidity =
this.activitiesService.isFilteredByAssetClassOtherThanLiquidity(filters);
let currentAccounts: (AccountWithBalance & {
Order?: Order[];
platform?: Platform;
@ -2479,15 +2488,17 @@ export class PortfolioService {
});
if (account) {
// The cash balance is not part of a holding and would distort the value
// and thus the allocation per account and platform
const balanceInBaseCurrency = filterBySymbol
? 0
: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
// The cash balance is not part of a holding or of an asset class other
// than liquidity and would distort the value and thus the allocation
// per account and platform
const balanceInBaseCurrency =
filterBySymbol || isFilteredByAssetClassOtherThanLiquidity
? 0
: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[currentAccountId] = {
balance: account.balance,

42
apps/api/src/services/data-provider/data-provider.service.ts

@ -369,11 +369,15 @@ export class DataProviderService implements OnModuleInit {
});
}
public async getHistorical(
aItems: AssetProfileIdentifier[],
from: Date,
to: Date
): Promise<{
public async getHistorical({
assetProfileIdentifiers,
from,
to
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
from: Date;
to: Date;
}): Promise<{
[assetProfileIdentifier: string]: {
[date: string]: DataProviderHistoricalResponse;
};
@ -384,35 +388,29 @@ export class DataProviderService implements OnModuleInit {
};
} = {};
if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
if (isEmpty(assetProfileIdentifiers) || !isValid(from) || !isValid(to)) {
return response;
}
const rangeQuery =
from && to
? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, {
in: utc
})}::timestamp AND date <= ${format(to, DATE_FORMAT, {
in: utc
})}::timestamp`
: Prisma.empty;
const assetProfileIdentifiers = aItems.map(({ dataSource, symbol }) => {
return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`;
});
const assetProfileIdentifierTuples = assetProfileIdentifiers.map(
({ dataSource, symbol }) => {
return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`;
}
);
try {
const marketDataByGranularity: Pick<
const marketDataItems: Pick<
MarketData,
'dataSource' | 'date' | 'marketPrice' | 'symbol'
>[] = await this.prismaService.$queryRaw`
SELECT "dataSource", "date", "marketPrice", "symbol"
FROM "MarketData"
WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifiers)})
${rangeQuery}
WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifierTuples)})
AND date >= ${format(from, DATE_FORMAT, { in: utc })}::timestamp
AND date <= ${format(to, DATE_FORMAT, { in: utc })}::timestamp
ORDER BY date;`;
response = marketDataByGranularity.reduce((r, marketData) => {
response = marketDataItems.reduce((r, marketData) => {
const { dataSource, date, marketPrice, symbol } = marketData;
const assetProfileIdentifier = getAssetProfileIdentifier({

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

@ -173,11 +173,11 @@ export class ExchangeRateDataService {
{ in: utc }
);
const historicalData = await this.dataProviderService.getHistorical(
this.currencyPairs,
startOfUtcDateOfYesterday,
startOfUtcDateOfYesterday
);
const historicalData = await this.dataProviderService.getHistorical({
assetProfileIdentifiers: this.currencyPairs,
from: startOfUtcDateOfYesterday,
to: startOfUtcDateOfYesterday
});
const quotes = await this.dataProviderService.getQuotes({
items: this.currencyPairs.map(({ dataSource, symbol }) => {

24
libs/common/src/lib/calculation-helper.spec.ts

@ -6,9 +6,14 @@ import {
getIntervalFromDateRange
} from './calculation-helper';
import { DATE_FORMAT } from './helper';
import { DateRange } from './types';
describe('CalculationHelper', () => {
describe('interval from date range', () => {
afterEach(() => {
jest.useRealTimers();
});
it('Get interval of a calendar year', async () => {
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2024'
@ -23,6 +28,25 @@ describe('CalculationHelper', () => {
// part of the interval
expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1);
});
it('Get interval of a date range relative to today', () => {
jest.useFakeTimers().setSystemTime(new Date(2024, 9, 10, 22, 0));
expect(
(['1d', 'mtd', 'wtd', 'ytd', '1y', '5y'] as DateRange[]).map(
(dateRange) => {
return getIntervalFromDateRange({ dateRange }).startDate;
}
)
).toEqual([
new Date(2024, 9, 9),
new Date(2024, 8, 30),
new Date(2024, 9, 6),
new Date(2023, 11, 31),
new Date(2023, 9, 10),
new Date(2019, 9, 10)
]);
});
});
describe('annualized performance percentage', () => {

20
libs/common/src/lib/calculation-helper.ts

@ -3,6 +3,7 @@ import {
endOfDay,
endOfYear,
max,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
@ -12,7 +13,6 @@ import {
} from 'date-fns';
import { isFinite, isNumber } from 'lodash-es';
import { resetHours } from './helper';
import { DateRange } from './types';
export function getAnnualizedPerformancePercent({
@ -48,31 +48,25 @@ export function getIntervalFromDateRange(params: {
switch (dateRange) {
case '1d':
startDate = max([startDate, subDays(resetHours(new Date()), 1)]);
startDate = max([startDate, subDays(startOfDay(new Date()), 1)]);
break;
case 'mtd':
startDate = max([
startDate,
subDays(startOfMonth(resetHours(new Date())), 1)
]);
startDate = max([startDate, subDays(startOfMonth(new Date()), 1)]);
break;
case 'wtd':
startDate = max([
startDate,
subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1)
subDays(startOfWeek(new Date(), { weekStartsOn: 1 }), 1)
]);
break;
case 'ytd':
startDate = max([
startDate,
subDays(startOfYear(resetHours(new Date())), 1)
]);
startDate = max([startDate, subDays(startOfYear(new Date()), 1)]);
break;
case '1y':
startDate = max([startDate, subYears(resetHours(new Date()), 1)]);
startDate = max([startDate, subYears(startOfDay(new Date()), 1)]);
break;
case '5y':
startDate = max([startDate, subYears(resetHours(new Date()), 5)]);
startDate = max([startDate, subYears(startOfDay(new Date()), 5)]);
break;
case 'max':
break;

Loading…
Cancel
Save