Browse Source

Merge branch 'main' into feature/move-total-return-from-experimental-to-general-availability

pull/7990/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
fc167fd70f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      CHANGELOG.md
  2. 47
      README.md
  3. 21
      apps/api/src/app/activities/activities.controller.ts
  4. 44
      apps/api/src/app/activities/activities.service.spec.ts
  5. 14
      apps/api/src/app/activities/activities.service.ts
  6. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 69
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  8. 60
      apps/api/src/app/portfolio/current-rate.service.ts
  9. 2
      apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts
  10. 3
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  11. 21
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 25
      apps/api/src/helper/object.helper.spec.ts
  13. 104
      apps/api/src/helper/security-headers.helper.spec.ts
  14. 37
      apps/api/src/helper/security-headers.helper.ts
  15. 4
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  16. 30
      apps/api/src/main.ts
  17. 3
      apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts
  18. 6
      apps/api/src/models/rules/liquidity/buying-power.ts
  19. 5
      apps/api/src/services/configuration/configuration.service.ts
  20. 1
      apps/api/src/services/interfaces/environment.interface.ts
  21. 2
      apps/api/src/services/market-data/market-data.service.ts
  22. 2
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  23. 3
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  24. 4
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  25. 3
      libs/common/src/lib/chart-helper.ts
  26. 7
      libs/common/src/lib/config.ts
  27. 2
      libs/common/src/lib/interfaces/index.ts
  28. 12
      libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts
  29. 11
      libs/ui/src/lib/account-balances/account-balances.component.ts
  30. 6
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  31. 21
      libs/ui/src/lib/line-chart/line-chart.component.ts
  32. 3
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  33. 3
      libs/ui/src/lib/services/data.service.ts
  34. 15
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  35. 7
      libs/ui/src/lib/value/value.component.ts
  36. 1763
      package-lock.json
  37. 26
      package.json
  38. 8
      prisma/migrations/20261005000000_updated_indexes_of_market_data/migration.sql
  39. 3
      prisma/schema.prisma

20
CHANGELOG.md

@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Moved the performance calculation including dividends (total return) from experimental to general availability - Moved the performance calculation including dividends (total return) from experimental to general availability
## 3.80.2 - 2026-10-06
### Added
- Exposed the `ENABLE_FEATURE_SECURITY_HEADERS` environment variable to enable HTTP security headers (experimental)
### Changed
- Improved the portfolio snapshot calculation to get the quotes of active holdings only
- Improved the performance of getting the latest market data by optimizing the indexes of the market data table
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `POST api/v1/activities`
- Upgraded `Nx` from version `23.1.1` to `23.2.1`
### Fixed
- Fixed a layout issue in the top and bottom holdings of the analysis page by truncating long names
- Fixed the missing thousands separator of 4-digit numbers in certain locales
- Fixed an issue where holdings without a market price have been valued at the unit price of a dividend, a fee, an interest or a liability
- Fixed the check for today in the current rate service for instances running in a time zone behind UTC
## 3.79.0 - 2026-10-04 ## 3.79.0 - 2026-10-04
### Added ### Added

47
README.md

@ -87,29 +87,30 @@ Find answers to commonly asked questions about self-hosting Ghostfolio in our [F
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) | | `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `ENABLE_FEATURE_SECURITY_HEADERS` | `boolean` (optional) | `false` | Enables HTTP security headers, e.g. `Content-Security-Policy` and `X-Frame-Options` (experimental). Other origins cannot embed pages (e.g. via `iframe`) or resources of Ghostfolio. `Strict-Transport-Security` is not set, because it requires HTTPS: set it in the reverse proxy |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | | `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy |
#### OpenID Connect OIDC (experimental) #### OpenID Connect OIDC (experimental)

21
apps/api/src/app/activities/activities.controller.ts

@ -9,13 +9,15 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse ActivityResponse,
CreateActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
@ -33,7 +35,7 @@ import {
Query, Query,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -47,7 +49,8 @@ export class ActivitiesController {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService private readonly dataGatheringService: DataGatheringService,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@Delete() @Delete()
@ -209,6 +212,7 @@ export class ActivitiesController {
@RequiresScope(scopes.activityCreate) @RequiresScope(scopes.activityCreate)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async createActivity( public async createActivity(
@Body() data: CreateOrderDto, @Body() data: CreateOrderDto,
@Impersonation() @Impersonation()
@ -217,7 +221,7 @@ export class ActivitiesController {
userId, userId,
userSubscription userSubscription
}: ImpersonationContext }: ImpersonationContext
): Promise<Prisma.OrderGetPayload<{ include: { SymbolProfile: true } }>> { ): Promise<CreateActivityResponse> {
// Evaluate the more restrictive subscription of the authenticated user // Evaluate the more restrictive subscription of the authenticated user
// and the owner of the activity // and the owner of the activity
const subscription = const subscription =
@ -303,7 +307,14 @@ export class ActivitiesController {
}); });
} }
return activity; const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
}
]);
return { ...activity, assetProfile };
} }
@HasPermission(permissions.updateActivity) @HasPermission(permissions.updateActivity)

44
apps/api/src/app/activities/activities.service.spec.ts

@ -3,6 +3,7 @@ import {
activityDummyData, activityDummyData,
assetProfileDummyData assetProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { WHERE_ACTIVITY_NOT_DRAFT } from '@ghostfolio/api/helper/activity.helper';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -14,7 +15,11 @@ import {
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { AssetProfileSplit, DataSource } from '@prisma/client'; import {
AssetProfileSplit,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
@ -381,6 +386,43 @@ describe('ActivitiesService', () => {
return result.activities[0]; return result.activities[0];
} }
}); });
describe('getLatestActivity', () => {
it('filters by type and unit price and excludes draft activities', async () => {
const findFirst = jest.fn().mockResolvedValue(null);
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
{ order: { findFirst } } as unknown as PrismaService,
null,
null
);
await service.getLatestActivity({
dataSource: DataSource.YAHOO,
symbol: 'AAPL',
types: [ActivityType.BUY, ActivityType.SELL]
});
expect(findFirst).toHaveBeenCalledWith({
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
where: {
...WHERE_ACTIVITY_NOT_DRAFT,
SymbolProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
type: { in: [ActivityType.BUY, ActivityType.SELL] },
unitPrice: { gt: 0 }
}
});
});
});
}); });
function createActivity({ function createActivity({

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

@ -581,14 +581,16 @@ export class ActivitiesService {
public async getLatestActivity({ public async getLatestActivity({
dataSource, dataSource,
symbol symbol,
}: AssetProfileIdentifier) { types
}: AssetProfileIdentifier & { types: ActivityType[] }) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
date: 'desc'
},
where: { where: {
SymbolProfile: { dataSource, symbol } ...WHERE_ACTIVITY_NOT_DRAFT,
SymbolProfile: { dataSource, symbol },
type: { in: types },
unitPrice: { gt: 0 }
} }
}); });
} }

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

@ -268,6 +268,7 @@ export abstract class PortfolioCalculator {
}; };
} }
const assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[] = [];
const cashAssetProfileIdentifiers = new Set<string>(); const cashAssetProfileIdentifiers = new Set<string>();
const currencies: { [assetProfileIdentifier: string]: string } = {}; const currencies: { [assetProfileIdentifier: string]: string } = {};
const dataGatheringItems: DataGatheringItem[] = []; const dataGatheringItems: DataGatheringItem[] = [];
@ -279,6 +280,7 @@ export abstract class PortfolioCalculator {
assetSubClass, assetSubClass,
currency, currency,
dataSource, dataSource,
quantity,
symbol symbol
} of holdingBalancesByDate.at(-1).holdings) { } of holdingBalancesByDate.at(-1).holdings) {
// Gather data for all assets except CASH // Gather data for all assets except CASH
@ -287,6 +289,13 @@ export abstract class PortfolioCalculator {
dataSource, dataSource,
symbol symbol
}); });
if (!quantity.eq(0)) {
assetProfileIdentifiersWithQuotes.push({
dataSource,
symbol
});
}
} }
currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency;
@ -305,6 +314,7 @@ export abstract class PortfolioCalculator {
errors: currentRateErrors, errors: currentRateErrors,
values: marketSymbols values: marketSymbols
} = await this.currentRateService.getValues({ } = await this.currentRateService.getValues({
assetProfileIdentifiersWithQuotes,
dataGatheringItems, dataGatheringItems,
dateQuery: { dateQuery: {
gte: this.startDate, gte: this.startDate,

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

@ -5,7 +5,12 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData, Order } from '@prisma/client'; import {
DataSource,
MarketData,
Order,
Type as ActivityType
} from '@prisma/client';
import { addDays, subDays } from 'date-fns'; import { addDays, subDays } from 'date-fns';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
@ -139,6 +144,9 @@ describe('CurrentRateService', () => {
it('getValues', async () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({
assetProfileIdentifiersWithQuotes: [
{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }
],
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
dateQuery: { dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
@ -205,7 +213,8 @@ describe('CurrentRateService', () => {
const { errors, values } = await currentRateService.getValues({ const { errors, values } = await currentRateService.getValues({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery,
assetProfileIdentifiersWithQuotes: dataGatheringItems
}); });
expect(getLatestActivity).not.toHaveBeenCalled(); expect(getLatestActivity).not.toHaveBeenCalled();
@ -220,8 +229,8 @@ describe('CurrentRateService', () => {
]); ]);
}); });
it('should fall back to the unit price of the latest activity without market data', async () => { it('should fall back to the unit price of the latest buy or sell activity without market data', async () => {
jest const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity') .spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order); .mockResolvedValue({ unitPrice: 1000 } as Order);
@ -229,9 +238,15 @@ describe('CurrentRateService', () => {
const { errors, values } = await currentRateService.getValues({ const { errors, values } = await currentRateService.getValues({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery,
assetProfileIdentifiersWithQuotes: dataGatheringItems
}); });
expect(getLatestActivity).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO,
symbol: 'AMZN',
types: [ActivityType.BUY, ActivityType.SELL]
});
expect(errors).toEqual(dataGatheringItems); expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([ expect(values).toEqual([
{ {
@ -242,5 +257,49 @@ describe('CurrentRateService', () => {
} }
]); ]);
}); });
it('should carry the latest market price forward without a quote request', async () => {
const getQuotes = jest.spyOn(dataProviderService, 'getQuotes');
jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(1);
jest.spyOn(marketDataService, 'getRange').mockResolvedValue([
{
createdAt: yesterday,
dataSource: DataSource.YAHOO,
date: yesterday,
id: 'd51d4e0b-9d1f-4d2e-8a0c-9a0f5c6b1d22',
isCarriedForward: false,
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: 'AMZN'
}
]);
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery,
assetProfileIdentifiersWithQuotes: []
});
expect(getQuotes).toHaveBeenCalledWith(
expect.objectContaining({ items: [] })
);
expect(errors).toEqual([]);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: yesterday,
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1841.823902,
symbol: 'AMZN'
}
]);
});
}); });
}); });

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

@ -12,9 +12,11 @@ import {
ResponseError ResponseError
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { utc } from '@date-fns/utc';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { Type as ActivityType } from '@prisma/client';
import { isEmpty, uniqBy } from 'lodash-es'; import { isBefore, isSameDay } from 'date-fns';
import { uniqBy } from 'lodash-es';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
@ -32,28 +34,29 @@ export class CurrentRateService {
@LogPerformance @LogPerformance
public async getValues({ public async getValues({
assetProfileIdentifiersWithQuotes,
dataGatheringItems, dataGatheringItems,
dateQuery, dateQuery,
subscriptionType subscriptionType
}: GetValuesParams): Promise<GetValuesObject> { }: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = []; const dataProviderInfos: DataProviderInfo[] = [];
const today = resetHours(new Date());
const includesToday = const includesToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday({ today, dates: dateQuery.in }));
const quoteErrors: ResponseError['errors'] = []; const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
const values: GetValueObject[] = []; const values: GetValueObject[] = [];
if (includesToday) { if (includesToday) {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
subscriptionType, subscriptionType,
items: dataGatheringItems items: assetProfileIdentifiersWithQuotes
}); });
for (const { dataSource, symbol } of dataGatheringItems) { for (const { dataSource, symbol } of assetProfileIdentifiersWithQuotes) {
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })]; const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (quote?.dataProviderInfo) { if (quote?.dataProviderInfo) {
@ -119,15 +122,32 @@ export class CurrentRateService {
}) })
}; };
if (!isEmpty(quoteErrors)) { if (includesToday) {
for (const { dataSource, symbol } of quoteErrors) { const assetProfileIdentifiersWithoutQuotes = [
...quoteErrors,
...dataGatheringItems.filter(({ dataSource, symbol }) => {
return !assetProfileIdentifiersWithQuotes.some(
(assetProfileIdentifier) => {
return (
assetProfileIdentifier.dataSource === dataSource &&
assetProfileIdentifier.symbol === symbol
);
}
);
})
];
for (const {
dataSource,
symbol
} of assetProfileIdentifiersWithoutQuotes) {
try { try {
// If missing quote, fallback to the latest available historical market price // If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => { let value: GetValueObject = response.values.find((currentValue) => {
return ( return (
currentValue.dataSource === dataSource && currentValue.dataSource === dataSource &&
currentValue.symbol === symbol && currentValue.symbol === symbol &&
isToday(currentValue.date) isSameDay(currentValue.date, today, { in: utc })
); );
}); });
@ -140,11 +160,12 @@ export class CurrentRateService {
let marketPrice = latestMarketData?.marketPrice; let marketPrice = latestMarketData?.marketPrice;
if (!marketPrice) { if (!marketPrice) {
// Fallback to unit price of latest activity // Fallback to unit price of latest buy or sell activity
const latestActivity = const latestActivity =
await this.activitiesService.getLatestActivity({ await this.activitiesService.getLatestActivity({
dataSource, dataSource,
symbol symbol,
types: [ActivityType.BUY, ActivityType.SELL]
}); });
marketPrice = latestActivity?.unitPrice ?? 0; marketPrice = latestActivity?.unitPrice ?? 0;
@ -188,12 +209,15 @@ export class CurrentRateService {
return response; return response;
} }
private containsToday(dates: Date[]): boolean { private containsToday({
for (const date of dates) { dates,
if (isToday(date)) { today
return true; }: {
} dates: Date[];
} today: Date;
return false; }): boolean {
return dates.some((date) => {
return isSameDay(date, today, { in: utc });
});
} }
} }

2
apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts

@ -1,9 +1,11 @@
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DateQuery } from './date-query.interface'; import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
assetProfileIdentifiersWithQuotes: AssetProfileIdentifier[];
dataGatheringItems: DataGatheringItem[]; dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery; dateQuery: DateQuery;
subscriptionType?: SubscriptionType; subscriptionType?: SubscriptionType;

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

@ -996,6 +996,9 @@ describe('PortfolioService', () => {
// The closed holding (MSFT) does not need a market price // The closed holding (MSFT) does not need a market price
expect(getValues).toHaveBeenCalledWith( expect(getValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
assetProfileIdentifiersWithQuotes: [
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
],
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
}) })
); );

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

@ -2543,6 +2543,16 @@ export class PortfolioService {
return !quantity.eq(0); return !quantity.eq(0);
}); });
const assetProfileIdentifiers: AssetProfileIdentifier[] = openHoldings.map(
({
latestActivity: {
assetProfile: { dataSource, symbol }
}
}) => {
return { dataSource, symbol };
}
);
const now = new Date(); const now = new Date();
// Get the market prices of today with the same fallback as the portfolio // Get the market prices of today with the same fallback as the portfolio
@ -2551,15 +2561,8 @@ export class PortfolioService {
openHoldings.length > 0 openHoldings.length > 0
? await this.currentRateService.getValues({ ? await this.currentRateService.getValues({
subscriptionType, subscriptionType,
dataGatheringItems: openHoldings.map( assetProfileIdentifiersWithQuotes: assetProfileIdentifiers,
({ dataGatheringItems: assetProfileIdentifiers,
latestActivity: {
assetProfile: { dataSource, symbol }
}
}) => {
return { dataSource, symbol };
}
),
dateQuery: { dateQuery: {
gte: startOfDay(now), gte: startOfDay(now),
lt: endOfDay(now) lt: endOfDay(now)

25
apps/api/src/helper/object.helper.spec.ts

@ -3121,11 +3121,24 @@ describe('redactAttributes', () => {
}); });
// The endpoint POST api/v1/activities returns a row of the database with the // The endpoint POST api/v1/activities returns a row of the database with the
// relation to the symbol profile // asset profile and the deprecated relation to the symbol profile
it('should redact an activity with the relation to the symbol profile', () => { it('should redact an activity with the asset profile and the relation to the symbol profile', () => {
expect( expect(
redactPaths({ redactPaths({
object: { object: {
assetProfile: {
comment: 'Reviewed by the administrator',
name: 'Apple Inc',
scraperConfiguration: {
headers: { Authorization: 'Bearer TOKEN' },
mode: 'lazy',
selector: '.price',
url: 'https://example.org/get_price'
},
symbol: 'AAPL',
symbolMapping: { YAHOO: 'AAPL' },
watchedByCount: 7
},
comment: 'Bought on a dip', comment: 'Bought on a dip',
currency: 'USD', currency: 'USD',
fee: 19.9, fee: 19.9,
@ -3149,6 +3162,14 @@ describe('redactAttributes', () => {
paths: DEFAULT_REDACTED_PATHS paths: DEFAULT_REDACTED_PATHS
}) })
).toStrictEqual({ ).toStrictEqual({
assetProfile: {
comment: null,
name: 'Apple Inc',
scraperConfiguration: null,
symbol: 'AAPL',
symbolMapping: null,
watchedByCount: null
},
comment: null, comment: null,
currency: 'USD', currency: 'USD',
fee: null, fee: null,

104
apps/api/src/helper/security-headers.helper.spec.ts

@ -0,0 +1,104 @@
import helmet from 'helmet';
import { IncomingMessage, ServerResponse } from 'node:http';
import { getHelmetOptions } from './security-headers.helper';
/**
* Gives the headers which the helmet middleware sets on a response with the
* options of the helper, and the function which it calls as next middleware.
*/
function getHeaders({
isSubscriptionEnabled
}: {
isSubscriptionEnabled: boolean;
}) {
const headers = new Map<string, string>();
const next = jest.fn();
const response = {
removeHeader: (name: string) => {
headers.delete(name.toLowerCase());
},
setHeader: (name: string, value: string) => {
headers.set(name.toLowerCase(), value);
}
};
helmet(getHelmetOptions({ isSubscriptionEnabled }))(
{} as IncomingMessage,
response as unknown as ServerResponse,
next
);
return { headers, next };
}
describe('getHelmetOptions', () => {
describe('without the subscription', () => {
let headers: Map<string, string>;
let next: jest.Mock;
beforeAll(() => {
({ headers, next } = getHeaders({ isSubscriptionEnabled: false }));
});
it('should call the next middleware without an error', () => {
expect(next).toHaveBeenCalledWith();
});
it('should set the Content-Security-Policy header', () => {
expect(headers.get('content-security-policy')).toContain(
"default-src 'self'"
);
});
it('should not upgrade insecure requests', () => {
expect(headers.get('content-security-policy')).not.toContain(
'upgrade-insecure-requests'
);
});
it('should not set the Strict-Transport-Security header', () => {
expect(headers.has('strict-transport-security')).toBe(false);
});
it('should not set the Cross-Origin-Opener-Policy header', () => {
expect(headers.has('cross-origin-opener-policy')).toBe(false);
});
it('should not allow resources of Stripe', () => {
expect(headers.get('content-security-policy')).not.toContain(
'https://js.stripe.com'
);
});
});
describe('with the subscription', () => {
let headers: Map<string, string>;
let next: jest.Mock;
beforeAll(() => {
({ headers, next } = getHeaders({ isSubscriptionEnabled: true }));
});
it('should call the next middleware without an error', () => {
expect(next).toHaveBeenCalledWith();
});
it('should upgrade insecure requests', () => {
expect(headers.get('content-security-policy')).toContain(
'upgrade-insecure-requests'
);
});
it('should set the Strict-Transport-Security header', () => {
expect(headers.has('strict-transport-security')).toBe(true);
});
it('should allow resources of Stripe', () => {
expect(headers.get('content-security-policy')).toContain(
'https://js.stripe.com'
);
});
});
});

37
apps/api/src/helper/security-headers.helper.ts

@ -0,0 +1,37 @@
import { HelmetOptions } from 'helmet';
export function getHelmetOptions({
isSubscriptionEnabled
}: {
isSubscriptionEnabled: boolean;
}): HelmetOptions {
if (isSubscriptionEnabled) {
return {
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
};
}
// The self-hosted setup can run via HTTP, hence the headers which need HTTPS
// are disabled (see https://github.com/ghostfolio/ghostfolio/issues/2102)
return {
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles
upgradeInsecureRequests: null // Disable upgrade-insecure-requests directive (the browser changes each request to HTTPS, which gives a blank page via HTTP)
}
},
crossOriginOpenerPolicy: false, // Disable Cross-Origin-Opener-Policy header (the browser ignores it via HTTP)
strictTransportSecurity: false // Disable Strict-Transport-Security header (the reverse proxy sets it, if HTTPS is required)
};
}

4
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -94,6 +94,10 @@ export class TransformDataSourceInResponseInterceptor<
'items[*].dataSource', 'items[*].dataSource',
'latestActivities[*].assetProfile.dataSource', 'latestActivities[*].assetProfile.dataSource',
'settings["filters.dataSource"]', 'settings["filters.dataSource"]',
/* @deprecated */
'SymbolProfile.dataSource',
'watchlist[*].dataSource' 'watchlist[*].dataSource'
] ]
}); });

30
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { getHelmetOptions } from '@ghostfolio/api/helper/security-headers.helper';
import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware';
import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware'; import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -102,31 +103,30 @@ async function bootstrap() {
app.use(cookieParser()); app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { const configurationService = app.get(ConfigurationService);
const isSubscriptionEnabled =
configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true';
if (
isSubscriptionEnabled ||
configurationService.get('ENABLE_FEATURE_SECURITY_HEADERS')
) {
const helmetMiddleware = helmet(
getHelmetOptions({ isSubscriptionEnabled })
);
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) { if (req.path.startsWith(STORYBOOK_PATH)) {
next(); next();
} else { } else {
helmet({ helmetMiddleware(req, res, next);
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})(req, res, next);
} }
}); });
} }
app.use(languageRedirectMiddleware); app.use(languageRedirectMiddleware);
const configurationService = app.get(ConfigurationService);
const trustProxy = configurationService.get('TRUST_PROXY'); const trustProxy = configurationService.get('TRUST_PROXY');
if (trustProxy) { if (trustProxy) {

3
apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts

@ -53,7 +53,8 @@ export class EmergencyFundCoverage extends Rule<Settings> {
const placeholders = { const placeholders = {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,
emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString(
ruleSettings.locale ruleSettings.locale,
{ useGrouping: true }
) )
}; };

6
apps/api/src/models/rules/liquidity/buying-power.ts

@ -44,7 +44,8 @@ export class BuyingPower extends Rule<Settings> {
placeholders: { placeholders: {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin.toLocaleString( thresholdMin: ruleSettings.thresholdMin.toLocaleString(
ruleSettings.locale ruleSettings.locale,
{ useGrouping: true }
) )
} }
}), }),
@ -59,7 +60,8 @@ export class BuyingPower extends Rule<Settings> {
placeholders: { placeholders: {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin.toLocaleString( thresholdMin: ruleSettings.thresholdMin.toLocaleString(
ruleSettings.locale ruleSettings.locale,
{ useGrouping: true }
) )
} }
}), }),

5
apps/api/src/services/configuration/configuration.service.ts

@ -72,9 +72,14 @@ export class ConfigurationService {
ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
// TODO: Change the default to true, when the server of the Model Context
// Protocol (MCP) was moved from experimental to general availability
ENABLE_FEATURE_MCP: bool({ default: false }), ENABLE_FEATURE_MCP: bool({ default: false }),
ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
// TODO: Change the default to true, when the security headers were
// available for some releases without a report of a defect
ENABLE_FEATURE_SECURITY_HEADERS: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_MCP: boolean; ENABLE_FEATURE_MCP: boolean;
ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_RATE_LIMITING: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SECURITY_HEADERS: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;

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

@ -248,7 +248,7 @@ export class MarketDataService {
state: state as MarketDataState state: state as MarketDataState
}, },
where: { where: {
dataSource_date_symbol: { dataSource_symbol_date: {
dataSource: dataSource as DataSource, dataSource: dataSource as DataSource,
date: date as Date, date: date as Date,
symbol: symbol as string symbol: symbol as string

2
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -348,7 +348,7 @@ export class DataGatheringService {
}, },
update: { marketPrice, isCarriedForward: false }, update: { marketPrice, isCarriedForward: false },
where: { where: {
dataSource_date_symbol: { dataSource_symbol_date: {
dataSource, dataSource,
symbol, symbol,
date: startOfUtcDate date: startOfUtcDate

3
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -170,7 +170,8 @@ export class GfAdminOverviewComponent implements OnInit {
this.activitiesCount / this.userCount this.activitiesCount / this.userCount
).toLocaleString(this.user?.settings?.locale, { ).toLocaleString(this.user?.settings?.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
}); });
return `(${formattedActivitiesCountPerUser} ${$localize`per User`})`; return `(${formattedActivitiesCountPerUser} ${$localize`per User`})`;

4
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -342,7 +342,7 @@
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2"> <div class="flex-grow-1 mr-2 text-truncate">
{{ holding.assetProfile.name }} {{ holding.assetProfile.name }}
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -393,7 +393,7 @@
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2"> <div class="flex-grow-1 mr-2 text-truncate">
{{ holding.assetProfile.name }} {{ holding.assetProfile.name }}
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">

3
libs/common/src/lib/chart-helper.ts

@ -123,7 +123,8 @@ export function getTooltipOptions<T extends ChartType>({
if (currency) { if (currency) {
label += `${yPoint.toLocaleString(locale, { label += `${yPoint.toLocaleString(locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})} ${currency}`; })} ${currency}`;
} else if (unit) { } else if (unit) {
label += `${yPoint.toFixed(2)} ${unit}`; label += `${yPoint.toFixed(2)} ${unit}`;

7
libs/common/src/lib/config.ts

@ -180,9 +180,16 @@ export const DEFAULT_REDACTED_PATHS = [
'settings.emergencyFund', 'settings.emergencyFund',
'settings.projectedTotalAmount', 'settings.projectedTotalAmount',
'settings.savingsRate', 'settings.savingsRate',
/* @deprecated */
'SymbolProfile.comment', 'SymbolProfile.comment',
/* @deprecated */
'SymbolProfile.scraperConfiguration', 'SymbolProfile.scraperConfiguration',
/* @deprecated */
'SymbolProfile.symbolMapping', 'SymbolProfile.symbolMapping',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency', 'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency', 'totalInterestInBaseCurrency',

2
libs/common/src/lib/interfaces/index.ts

@ -53,6 +53,7 @@ import type { AssetProfilesResponse } from './responses/asset-profiles-response.
import type { AssetResponse } from './responses/asset-response.interface'; import type { AssetResponse } from './responses/asset-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { CreateActivityResponse } from './responses/create-activity-response.interface';
import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface'; import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface';
import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface';
import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface'; import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface';
@ -133,6 +134,7 @@ export {
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
Coupon, Coupon,
CreateActivityResponse,
CreateStripeCheckoutSessionResponse, CreateStripeCheckoutSessionResponse,
DataEnhancerHealthResponse, DataEnhancerHealthResponse,
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,

12
libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts

@ -0,0 +1,12 @@
import type { Order, SymbolProfile } from '@ghostfolio/prisma/browser';
import { EnhancedAssetProfile } from '../enhanced-asset-profile.interface';
export interface CreateActivityResponse extends Order {
assetProfile: EnhancedAssetProfile;
/**
* @deprecated Use `assetProfile` instead
*/
SymbolProfile: SymbolProfile;
}

11
libs/ui/src/lib/account-balances/account-balances.component.ts

@ -69,11 +69,18 @@ export class GfAccountBalancesComponent implements OnChanges, OnInit {
public readonly accountBalances = public readonly accountBalances =
input.required<AccountBalancesResponse['balances']>(); input.required<AccountBalancesResponse['balances']>();
public readonly accountCurrency = input.required<string>();
public readonly accountCurrency = input.required<string | null>();
public readonly accountId = input.required<string>(); public readonly accountId = input.required<string>();
public readonly currentBalance = input<number | null>(); public readonly currentBalance = input<number | null>();
public readonly displayedColumns: string[] = ['date', 'value', 'actions']; public readonly displayedColumns: string[] = ['date', 'value', 'actions'];
public readonly locale = input(getLocale());
public readonly locale = input(getLocale(), {
transform: (value?: string) => {
return value ?? getLocale();
}
});
public readonly showActions = input(true); public readonly showActions = input(true);
public readonly sort = viewChild(MatSort); public readonly sort = viewChild(MatSort);

6
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -358,7 +358,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
return `Total: ${new Intl.NumberFormat(this.locale, { return `Total: ${new Intl.NumberFormat(this.locale, {
currency: this.currency ?? DEFAULT_CURRENCY, currency: this.currency ?? DEFAULT_CURRENCY,
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency',
useGrouping: true
}).format(totalAmount)}`; }).format(totalAmount)}`;
}, },
label: (context) => { label: (context) => {
@ -372,7 +373,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
label += new Intl.NumberFormat(this.locale, { label += new Intl.NumberFormat(this.locale, {
currency: this.currency ?? DEFAULT_CURRENCY, currency: this.currency ?? DEFAULT_CURRENCY,
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency',
useGrouping: true
}).format(context.parsed.y); }).format(context.parsed.y);
} }

21
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -6,7 +6,10 @@ import {
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper'; import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces'; import {
LineChartItem,
NullableLineChartItem
} from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { import {
@ -24,6 +27,7 @@ import {
import { import {
type AnimationsSpec, type AnimationsSpec,
Chart, Chart,
type ChartData,
Filler, Filler,
LinearScale, LinearScale,
LineController, LineController,
@ -52,10 +56,10 @@ import {
export class GfLineChartComponent export class GfLineChartComponent
implements AfterViewInit, OnChanges, OnDestroy implements AfterViewInit, OnChanges, OnDestroy
{ {
@Input() benchmarkDataItems: LineChartItem[] = []; @Input() benchmarkDataItems: NullableLineChartItem[] = [];
@Input() benchmarkLabel = ''; @Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme; @Input() colorScheme?: ColorScheme;
@Input() currency: string; @Input() currency?: string;
@Input() historicalDataItems: LineChartItem[]; @Input() historicalDataItems: LineChartItem[];
@Input() isAnimated = false; @Input() isAnimated = false;
@Input() label: string; @Input() label: string;
@ -134,12 +138,12 @@ export class GfLineChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const benchmarkPrices: number[] = []; const benchmarkPrices: (number | null)[] = [];
const labels: string[] = []; const labels: string[] = [];
const marketPrices: number[] = []; const marketPrices: number[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => { this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value); benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null);
labels.push(historicalDataItem.date); labels.push(historicalDataItem.date);
marketPrices.push(historicalDataItem.value); marketPrices.push(historicalDataItem.value);
}); });
@ -164,7 +168,7 @@ export class GfLineChartComponent
gradient.addColorStop(1, getBackgroundColor(this.colorScheme)); gradient.addColorStop(1, getBackgroundColor(this.colorScheme));
} }
const data = { const data: ChartData<'line'> = {
labels, labels,
datasets: [ datasets: [
{ {
@ -205,7 +209,7 @@ export class GfLineChartComponent
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animations: this.isAnimated ? animations : undefined, animations: this.isAnimated ? animations : undefined,
@ -254,7 +258,8 @@ export class GfLineChartComponent
if (typeof tickValue === 'number') { if (typeof tickValue === 'number') {
return tickValue.toLocaleString(this.locale, { return tickValue.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
}); });
} }

3
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -496,7 +496,8 @@ export class GfPortfolioProportionChartComponent
`${name ?? symbol}`, `${name ?? symbol}`,
`${value.toLocaleString(this.locale, { `${value.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)` })} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)`
]; ];
} }

3
libs/ui/src/lib/services/data.service.ts

@ -34,6 +34,7 @@ import {
AssetResponse, AssetResponse,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse, BenchmarkResponse,
CreateActivityResponse,
CreateStripeCheckoutSessionResponse, CreateStripeCheckoutSessionResponse,
DataProviderHealthResponse, DataProviderHealthResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -873,7 +874,7 @@ export class DataService {
} }
public postActivity(aOrder: CreateOrderDto) { public postActivity(aOrder: CreateOrderDto) {
return this.http.post<Order>('/api/v1/activities', aOrder); return this.http.post<CreateActivityResponse>('/api/v1/activities', aOrder);
} }
public postApiKey() { public postApiKey() {

15
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -324,7 +324,8 @@ export class GfTreemapChartComponent
).toLocaleString(this.locale(), { ).toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
signDisplay: 'exceptZero' signDisplay: 'exceptZero',
useGrouping: true
})}%` })}%`
]; ];
}, },
@ -401,7 +402,8 @@ export class GfTreemapChartComponent
raw._data.allocationInPercentage * 100 raw._data.allocationInPercentage * 100
).toLocaleString(this.locale(), { ).toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})}%`; })}%`;
const name = getHoldingName(raw._data.assetProfile); const name = getHoldingName(raw._data.assetProfile);
@ -410,7 +412,8 @@ export class GfTreemapChartComponent
).toLocaleString(this.locale(), { ).toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
signDisplay: 'exceptZero' signDisplay: 'exceptZero',
useGrouping: true
})}%`; })}%`;
if (raw._data.valueInBaseCurrency !== null) { if (raw._data.valueInBaseCurrency !== null) {
@ -420,7 +423,8 @@ export class GfTreemapChartComponent
`${name} (${allocationInPercentage})`, `${name} (${allocationInPercentage})`,
`${value?.toLocaleString(this.locale(), { `${value?.toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})} ${this.baseCurrency()}`, })} ${this.baseCurrency()}`,
'', '',
$localize`Change` + ' (' + $localize`Performance` + ')', $localize`Change` + ' (' + $localize`Performance` + ')',
@ -429,7 +433,8 @@ export class GfTreemapChartComponent
{ {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
signDisplay: 'exceptZero' signDisplay: 'exceptZero',
useGrouping: true
} }
)} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})`
]; ];

7
libs/ui/src/lib/value/value.component.ts

@ -83,7 +83,8 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
return { return {
maximumFractionDigits: digits, maximumFractionDigits: digits,
minimumFractionDigits: digits minimumFractionDigits: digits,
useGrouping: true
}; };
}); });
@ -150,7 +151,9 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
); );
} catch {} } catch {}
} else { } else {
this.formattedValue = this.value?.toLocaleString(this.locale); this.formattedValue = this.value?.toLocaleString(this.locale, {
useGrouping: true
});
} }
if (this.isAbsolute) { if (this.isAbsolute) {

1763
package-lock.json

File diff suppressed because it is too large

26
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.79.0", "version": "3.80.2",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -169,16 +169,17 @@
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0", "@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.2.6", "@nestjs/testing": "11.2.6",
"@nx/angular": "23.1.1", "@nx/angular": "23.2.1",
"@nx/eslint-plugin": "23.1.1", "@nx/eslint": "23.2.1",
"@nx/jest": "23.1.1", "@nx/eslint-plugin": "23.2.1",
"@nx/js": "23.1.1", "@nx/jest": "23.2.1",
"@nx/module-federation": "23.1.1", "@nx/js": "23.2.1",
"@nx/nest": "23.1.1", "@nx/nest": "23.2.1",
"@nx/node": "23.1.1", "@nx/node": "23.2.1",
"@nx/storybook": "23.1.1", "@nx/storybook": "23.2.1",
"@nx/web": "23.1.1", "@nx/web": "23.2.1",
"@nx/workspace": "23.1.1", "@nx/webpack": "23.2.1",
"@nx/workspace": "23.2.1",
"@prisma/config": "7.10.0", "@prisma/config": "7.10.0",
"@schematics/angular": "22.1.4", "@schematics/angular": "22.1.4",
"@storybook/addon-docs": "10.5.7", "@storybook/addon-docs": "10.5.7",
@ -205,7 +206,7 @@
"jest-environment-jsdom": "30.2.0", "jest-environment-jsdom": "30.2.0",
"jest-environment-node": "30.3.0", "jest-environment-node": "30.3.0",
"jest-preset-angular": "17.0.0", "jest-preset-angular": "17.0.0",
"nx": "23.1.1", "nx": "23.2.1",
"prettier": "3.9.9", "prettier": "3.9.9",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.10.0", "prisma": "7.10.0",
@ -219,6 +220,7 @@
"tslib": "2.8.1", "tslib": "2.8.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.67.0", "typescript-eslint": "8.67.0",
"webpack": "5.105.2",
"webpack-cli": "7.1.0" "webpack-cli": "7.1.0"
}, },
"engines": { "engines": {

8
prisma/migrations/20261005000000_updated_indexes_of_market_data/migration.sql

@ -0,0 +1,8 @@
-- CreateIndex
CREATE UNIQUE INDEX "MarketData_dataSource_symbol_date_key" ON "MarketData"("dataSource", "symbol", "date");
-- DropIndex
DROP INDEX "MarketData_dataSource_symbol_idx";
-- DropIndex
DROP INDEX "MarketData_dataSource_date_symbol_key";

3
prisma/schema.prisma

@ -173,9 +173,8 @@ model MarketData {
state MarketDataState @default(CLOSE) state MarketDataState @default(CLOSE)
symbol String symbol String
@@unique([dataSource, date, symbol]) @@unique([dataSource, symbol, date])
@@index([dataSource]) @@index([dataSource])
@@index([dataSource, symbol])
@@index([date]) @@index([date])
@@index([state]) @@index([state])
@@index([symbol]) @@index([symbol])

Loading…
Cancel
Save