Browse Source

Merge branch 'main' into task/remove-deprecated-symbol-profile-from-activity-interface

pull/7360/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
8981219953
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 30
      CHANGELOG.md
  2. 2
      README.md
  3. 91
      apps/api/src/app/admin/admin.service.ts
  4. 21
      apps/api/src/app/auth/auth.controller.ts
  5. 4
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  6. 2
      apps/api/src/app/info/info.module.ts
  7. 28
      apps/api/src/app/info/info.service.ts
  8. 2
      apps/api/src/app/subscription/subscription.service.ts
  9. 1
      apps/api/src/app/symbol/symbol.controller.ts
  10. 6
      apps/api/src/app/symbol/symbol.service.ts
  11. 7
      apps/api/src/app/user/user.service.ts
  12. 4
      apps/api/src/guards/custom-throttler.guard.ts
  13. 2
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  14. 2
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  15. 2
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  16. 2
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  17. 2
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  18. 2
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  19. 2
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  20. 2
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  21. 2
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  22. 2
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  23. 2
      apps/api/src/models/rules/liquidity/buying-power.ts
  24. 2
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  25. 2
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  26. 2
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  27. 2
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  28. 2
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  29. 8
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  30. 10
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  31. 33
      apps/api/src/services/data-provider/data-provider.service.ts
  32. 11
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  33. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  34. 68
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  35. 6
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  36. 17
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  37. 13
      apps/api/src/services/data-provider/manual/manual.service.ts
  38. 4
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  39. 8
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  40. 2
      apps/api/src/services/market-data/market-data.service.ts
  41. 7
      apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts
  42. 46
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  43. 9
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  44. 2
      apps/api/src/services/tag/tag.service.ts
  45. 8
      apps/client/src/app/adapter/custom-date-adapter.ts
  46. 11
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  47. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  48. 120
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  49. 13
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  50. 47
      apps/client/src/app/components/home-market/home-market.component.ts
  51. 21
      apps/client/src/app/components/home-market/home-market.html
  52. 4
      apps/client/src/app/components/home-market/home-market.scss
  53. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  54. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  55. 8
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  56. 8
      apps/client/src/app/pages/public/public-page.component.ts
  57. 10
      apps/client/src/locales/messages.ca.xlf
  58. 10
      apps/client/src/locales/messages.de.xlf
  59. 10
      apps/client/src/locales/messages.es.xlf
  60. 10
      apps/client/src/locales/messages.fr.xlf
  61. 10
      apps/client/src/locales/messages.it.xlf
  62. 10
      apps/client/src/locales/messages.ja.xlf
  63. 10
      apps/client/src/locales/messages.ko.xlf
  64. 10
      apps/client/src/locales/messages.nl.xlf
  65. 10
      apps/client/src/locales/messages.pl.xlf
  66. 10
      apps/client/src/locales/messages.pt.xlf
  67. 10
      apps/client/src/locales/messages.tr.xlf
  68. 10
      apps/client/src/locales/messages.uk.xlf
  69. 10
      apps/client/src/locales/messages.xlf
  70. 10
      apps/client/src/locales/messages.zh.xlf
  71. 3
      libs/common/src/lib/config.ts
  72. 2
      libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts
  73. 20
      libs/common/src/lib/helper.spec.ts
  74. 12
      libs/common/src/lib/helper.ts
  75. 2
      libs/common/src/lib/interfaces/info-item.interface.ts
  76. 3
      libs/common/src/lib/pipes/index.ts
  77. 12
      libs/common/src/lib/pipes/symbol.pipe.ts
  78. 4
      libs/ui/src/lib/activities-filter/activities-filter.component.html
  79. 2
      libs/ui/src/lib/activities-filter/activities-filter.component.ts
  80. 4
      libs/ui/src/lib/activities-table/activities-table.component.html
  81. 2
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  82. 2
      libs/ui/src/lib/activities-table/activities-table.component.ts
  83. 3
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  84. 2
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  85. 12
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  86. 2
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  87. 2
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts
  88. 2
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html
  89. 14
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  90. 10
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  91. 12
      libs/ui/src/lib/top-holdings/top-holdings.component.html
  92. 2
      libs/ui/src/lib/top-holdings/top-holdings.component.ts
  93. 9
      libs/ui/src/lib/value/value.component.html
  94. 15
      libs/ui/src/lib/value/value.component.stories.ts
  95. 35
      libs/ui/src/lib/value/value.component.ts
  96. 52
      package-lock.json
  97. 10
      package.json
  98. 2
      tsconfig.base.json

30
CHANGELOG.md

@ -10,7 +10,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Removed the deprecated `SymbolProfile` field from the activity interface - Removed the deprecated `SymbolProfile` field from the activity interface
## 3.30.0 - 2026-07-19
### Added
- Added support for converting an asset profile to the `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Extended the `extractNumberFromString()` function to support negative values
- Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users
- Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`)
- Simplified the `getHistorical()` function response in the data provider interface
- Upgraded `bull-board` from version `8.0.1` to `8.1.2`
## 3.29.0 - 2026-07-18
### Added
- Added support for the _Fear & Greed Index_ (market mood) via the `GHOSTFOLIO` data provider in self-hosted environments
- Added a _Storybook_ story for the copy-to-clipboard functionality in the value component
### Changed
- Improved the copy-to-clipboard functionality in the value component by providing a visual confirmation
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Upgraded `stripe` from version `22.2.3` to `22.3.2`
### Fixed
- Fixed an issue with the delete button in the tags selector component
## 3.28.0 - 2026-07-17 ## 3.28.0 - 2026-07-17

2
README.md

@ -190,8 +190,6 @@ Set the header for each request as follows:
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`) You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Health Check (experimental) ### Health Check (experimental)
#### Request #### Request

91
apps/api/src/app/admin/admin.service.ts

@ -12,6 +12,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -39,6 +40,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@ -240,16 +242,25 @@ export class AdminService {
url url
}: Prisma.SymbolProfileUpdateInput }: Prisma.SymbolProfileUpdateInput
) { ) {
const isConversionToManualDataSource =
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
if (isConversionToManualDataSource && !newSymbol) {
newSymbol = randomUUID();
}
if ( if (
newSymbol &&
newDataSource && newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource) newSymbol &&
(newDataSource !== dataSource || newSymbol !== symbol)
) { ) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ const newAssetProfileIdentifier: AssetProfileIdentifier = {
{ dataSource: newDataSource as DataSource,
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string symbol: newSymbol as string
} };
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]); ]);
if (assetProfile) { if (assetProfile) {
@ -259,45 +270,79 @@ export class AdminService {
); );
} }
try { const operations: Prisma.PrismaPromise<unknown>[] = [
await Promise.all([
this.symbolProfileService.updateAssetProfileIdentifier( this.symbolProfileService.updateAssetProfileIdentifier(
{ {
dataSource, dataSource,
symbol symbol
}, },
{ newAssetProfileIdentifier
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
), ),
this.marketDataService.updateAssetProfileIdentifier( this.marketDataService.updateAssetProfileIdentifier(
{ {
dataSource, dataSource,
symbol symbol
}, },
{ newAssetProfileIdentifier
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
) )
]); ];
const [updatedAssetProfile] = if (isConversionToManualDataSource) {
await this.symbolProfileService.getSymbolProfiles([ const currentAssetProfile =
await this.prismaService.symbolProfile.findUnique({
include: { assetProfileOverrides: true },
where: { dataSource_symbol: { dataSource, symbol } }
});
if (!currentAssetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const currentAssetProfileWithOverrides = applyAssetProfileOverrides(
currentAssetProfile,
currentAssetProfile.assetProfileOverrides
);
operations.push(
// The overrides are applied on every read, so delete them and
// persist the merged values in the asset profile instead
this.symbolProfileService.deleteAssetProfileOverrides(
newAssetProfileIdentifier
),
this.symbolProfileService.updateSymbolProfile(
newAssetProfileIdentifier,
{ {
dataSource: DataSource[newDataSource.toString()], assetClass: currentAssetProfileWithOverrides.assetClass,
symbol: newSymbol as string assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
countries:
currentAssetProfileWithOverrides.countries ?? undefined,
holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
name: currentAssetProfileWithOverrides.name,
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
url: currentAssetProfileWithOverrides.url
}
)
);
} }
]);
return updatedAssetProfile; try {
await this.prismaService.$transaction(operations);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else { } else {
const assetProfileOverrides = { const assetProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,

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

@ -14,7 +14,6 @@ import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param,
Post, Post,
Req, Req,
Res, Res,
@ -36,26 +35,6 @@ export class AuthController {
private readonly webAuthService: WebAuthService private readonly webAuthService: WebAuthService
) {} ) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLoginGet(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken =
await this.authService.validateAnonymousLogin(accessToken);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Post('anonymous') @Post('anonymous')
@UseGuards(CustomThrottlerGuard) @UseGuards(CustomThrottlerGuard)
public async accessTokenLogin( public async accessTokenLogin(

4
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -171,7 +171,7 @@ export class GhostfolioService {
try { try {
const promises: Promise<{ const promises: Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}>[] = []; }>[] = [];
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
@ -185,7 +185,7 @@ export class GhostfolioService {
to to
}) })
.then((historicalData) => { .then((historicalData) => {
result.historicalData = historicalData[symbol]; result.historicalData = historicalData;
return historicalData; return historicalData;
}) })

2
apps/api/src/app/info/info.module.ts

@ -7,6 +7,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -29,6 +30,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
MarketDataModule,
PlatformModule, PlatformModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,

28
apps/api/src/app/info/info.service.ts

@ -1,14 +1,15 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
ghostfolioFearAndGreedIndexSymbolStocks,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS, PROPERTY_DOCKER_HUB_PULLS,
@ -23,6 +24,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { MarketData } from '@prisma/client';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
@ -36,6 +38,7 @@ export class InfoService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -45,6 +48,7 @@ export class InfoService {
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean; let isReadOnlyMode: boolean;
let latestFearAndGreedStocksMarketDataPromise: Promise<MarketData>;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -61,16 +65,12 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
const fearAndGreedIndexDataSource = latestFearAndGreedStocksMarketDataPromise =
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(); this.marketDataService.getLatest({
dataSource:
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
info.fearAndGreedDataSource = encodeDataSource( symbol: ghostfolioFearAndGreedIndexSymbolStocks
fearAndGreedIndexDataSource });
);
} else {
info.fearAndGreedDataSource = fearAndGreedIndexDataSource;
}
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
@ -102,12 +102,14 @@ export class InfoService {
benchmarks, benchmarks,
demoAuthToken, demoAuthToken,
isUserSignupEnabled, isUserSignupEnabled,
latestFearAndGreedStocksMarketData,
statistics, statistics,
subscriptionOffer subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(), this.propertyService.isUserSignupEnabled(),
latestFearAndGreedStocksMarketDataPromise,
this.getStatistics(), this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' }) this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
@ -125,7 +127,9 @@ export class InfoService {
statistics, statistics,
subscriptionOffer, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies(),
fearAndGreedStocksMarketPrice:
latestFearAndGreedStocksMarketData?.marketPrice
}; };
} }

2
apps/api/src/app/subscription/subscription.service.ts

@ -38,7 +38,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2026-05-27.dahlia' apiVersion: '2026-06-24.dahlia'
} }
); );
} }

1
apps/api/src/app/symbol/symbol.controller.ts

@ -65,6 +65,7 @@ export class SymbolController {
* Must be after /lookup * Must be after /lookup
*/ */
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData( public async getSymbolData(

6
apps/api/src/app/symbol/symbol.service.ts

@ -133,6 +133,12 @@ export class SymbolService {
}: { }: {
includeHistoricalData: number; includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> { }): Promise<MarketDataOfMarketsResponse> {
if (await this.dataProviderService.isDataProviderGhostfolioConfigured()) {
return this.dataProviderService.getMarketDataOfMarkets({
includeHistoricalData
});
}
const [ const [
marketDataFearAndGreedIndexCryptocurrencies, marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks marketDataFearAndGreedIndexStocks

7
apps/api/src/app/user/user.service.ts

@ -29,6 +29,7 @@ import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE, DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
@ -546,6 +547,12 @@ export class UserService {
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount); currentPermissions.push(permissions.syncDemoUserAccount);
} }
} else {
if (
await this.propertyService.getByKey<string>(PROPERTY_API_KEY_GHOSTFOLIO)
) {
currentPermissions.push(permissions.readMarketDataOfMarkets);
}
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {

4
apps/api/src/guards/custom-throttler.guard.ts

@ -5,7 +5,9 @@ import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler';
export class CustomThrottlerGuard extends ThrottlerGuard { export class CustomThrottlerGuard extends ThrottlerGuard {
private readonly logger = new Logger(CustomThrottlerGuard.name); private readonly logger = new Logger(CustomThrottlerGuard.name);
public async canActivate(context: ExecutionContext): Promise<boolean> { public override async canActivate(
context: ExecutionContext
): Promise<boolean> {
try { try {
return await super.canActivate(context); return await super.canActivate(context);
} catch (error) { } catch (error) {

2
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -13,7 +13,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']

2
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -11,7 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']

2
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts

@ -11,7 +11,7 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
holdings: PortfolioPosition[] holdings: PortfolioPosition[]

2
apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts

@ -11,7 +11,7 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
holdings: PortfolioPosition[] holdings: PortfolioPosition[]

2
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -11,7 +11,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
languageCode: string languageCode: string

2
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -11,7 +11,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
languageCode: string languageCode: string

2
apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts

@ -8,7 +8,7 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
private developedMarketsValueInBaseCurrency: number; private developedMarketsValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,
developedMarketsValueInBaseCurrency: number, developedMarketsValueInBaseCurrency: number,

2
apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts

@ -8,7 +8,7 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private emergingMarketsValueInBaseCurrency: number; private emergingMarketsValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,
emergingMarketsValueInBaseCurrency: number, emergingMarketsValueInBaseCurrency: number,

2
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -7,7 +7,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
private emergencyFund: number; private emergencyFund: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
emergencyFund: number emergencyFund: number

2
apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts

@ -8,7 +8,7 @@ export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
private totalInvestmentVolumeInBaseCurrency: number; private totalInvestmentVolumeInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
totalInvestmentVolumeInBaseCurrency: number, totalInvestmentVolumeInBaseCurrency: number,

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

@ -7,7 +7,7 @@ export class BuyingPower extends Rule<Settings> {
private buyingPower: number; private buyingPower: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
buyingPower: number, buyingPower: number,
languageCode: string languageCode: string

2
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
private currentValueInBaseCurrency: number; private currentValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

2
apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private emergingMarketsValueInBaseCurrency: number; private emergingMarketsValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

2
apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
private europeValueInBaseCurrency: number; private europeValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

2
apps/api/src/models/rules/regional-market-cluster-risk/japan.ts

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
private japanValueInBaseCurrency: number; private japanValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

2
apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
private northAmericaValueInBaseCurrency: number; private northAmericaValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

8
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -70,7 +70,7 @@ export class AlphaVantageService
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const historicalData: { const historicalData: {
@ -83,11 +83,9 @@ export class AlphaVantageService
); );
const response: { const response: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
response[symbol] = {};
for (const [key, timeSeries] of Object.entries( for (const [key, timeSeries] of Object.entries(
historicalData['Time Series (Digital Currency Daily)'] historicalData['Time Series (Digital Currency Daily)']
).sort()) { ).sort()) {
@ -95,7 +93,7 @@ export class AlphaVantageService
isAfter(from, parse(key, DATE_FORMAT, new Date())) && isAfter(from, parse(key, DATE_FORMAT, new Date())) &&
isBefore(to, parse(key, DATE_FORMAT, new Date())) isBefore(to, parse(key, DATE_FORMAT, new Date()))
) { ) {
response[symbol][key] = { response[key] = {
marketPrice: parseFloat(timeSeries['4a. close (USD)']) marketPrice: parseFloat(timeSeries['4a. close (USD)'])
}; };
} }

10
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -115,7 +115,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
@ -143,13 +143,11 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
} }
const result: { const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = { } = {};
[symbol]: {}
};
for (const [timestamp, marketPrice] of prices) { for (const [timestamp, marketPrice] of prices) {
result[symbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = { result[format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
marketPrice marketPrice
}; };
} }

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

@ -27,7 +27,8 @@ import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderResponse, DataProviderResponse,
LookupItem, LookupItem,
LookupResponse LookupResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
@ -194,11 +195,7 @@ export class DataProviderService implements OnModuleInit {
return DataSource[dataSource]; return DataSource[dataSource];
}); });
const ghostfolioApiKey = await this.propertyService.getByKey<string>( if (await this.isDataProviderGhostfolioConfigured()) {
PROPERTY_API_KEY_GHOSTFOLIO
);
if (ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -511,7 +508,7 @@ export class DataProviderService implements OnModuleInit {
requestTimeout: ms('30 seconds') requestTimeout: ms('30 seconds')
}) })
.then((data) => { .then((data) => {
return { dataSource, symbol, data: data?.[symbol] }; return { data, dataSource, symbol };
}) })
); );
} }
@ -551,6 +548,22 @@ export class DataProviderService implements OnModuleInit {
return result; return result;
} }
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
const dataProvider = this.getDataProvider(DataSource.GHOSTFOLIO);
if (!dataProvider.getMarketDataOfMarkets) {
throw new Error(
`The data provider (${DataSource.GHOSTFOLIO}) does not support the market data of markets`
);
}
return dataProvider.getMarketDataOfMarkets({ includeHistoricalData });
}
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout, requestTimeout,
@ -807,6 +820,12 @@ export class DataProviderService implements OnModuleInit {
return response; return response;
} }
public async isDataProviderGhostfolioConfigured(): Promise<boolean> {
return !!(await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO
));
}
public async search({ public async search({
includeIndices = false, includeIndices = false,
query, query,

11
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -147,7 +147,7 @@ export class EodHistoricalDataService
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
symbol = this.convertToEodSymbol(symbol); symbol = this.convertToEodSymbol(symbol);
@ -166,10 +166,9 @@ export class EodHistoricalDataService
}) })
.then((res) => res.json()); .then((res) => res.json());
return response.reduce( return response.reduce((result, { adjusted_close, date }) => {
(result, { adjusted_close, date }) => {
if (isNumber(adjusted_close)) { if (isNumber(adjusted_close)) {
result[this.convertFromEodSymbol(symbol)][date] = { result[date] = {
marketPrice: adjusted_close marketPrice: adjusted_close
}; };
} else { } else {
@ -179,9 +178,7 @@ export class EodHistoricalDataService
} }
return result; return result;
}, }, {});
{ [this.convertFromEodSymbol(symbol)]: {} }
);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(

10
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -336,14 +336,12 @@ export class FinancialModelingPrepService
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
const MAX_YEARS_PER_REQUEST = 5; const MAX_YEARS_PER_REQUEST = 5;
const result: { const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = { } = {};
[symbol]: {}
};
let currentFrom = from; let currentFrom = from;
@ -378,7 +376,7 @@ export class FinancialModelingPrepService
isAfter(parseDate(date), currentFrom)) && isAfter(parseDate(date), currentFrom)) &&
isBefore(parseDate(date), currentTo) isBefore(parseDate(date), currentTo)
) { ) {
result[symbol][date] = { result[date] = {
marketPrice: close marketPrice: close
}; };
} }

68
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -5,6 +5,7 @@ import {
GetAssetProfileParams, GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetMarketDataOfMarketsParams,
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -23,7 +24,9 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
QuotesResponse MarketDataOfMarketsResponse,
QuotesResponse,
SymbolItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -169,7 +172,7 @@ export class GhostfolioService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
@ -195,9 +198,7 @@ export class GhostfolioService implements DataProviderInterface {
const { historicalData } = (await response.json()) as HistoricalResponse; const { historicalData } = (await response.json()) as HistoricalResponse;
return { return historicalData;
[symbol]: historicalData
};
} catch (error) { } catch (error) {
if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
error.name = 'RequestError'; error.name = 'RequestError';
@ -224,6 +225,63 @@ export class GhostfolioService implements DataProviderInterface {
} }
} }
public async getMarketDataOfMarkets({
includeHistoricalData = 0,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetMarketDataOfMarketsParams): Promise<MarketDataOfMarketsResponse> {
let marketDataOfMarkets: MarketDataOfMarketsResponse = {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {} as SymbolItem,
STOCKS: {} as SymbolItem
}
};
try {
const queryParams = new URLSearchParams({
includeHistoricalData: includeHistoricalData.toString()
});
const response = await this.fetchService.fetch(
`${this.URL}/v1/data-providers/ghostfolio/markets?${queryParams.toString()}`,
{
headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout)
}
);
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
marketDataOfMarkets =
(await response.json()) as MarketDataOfMarketsResponse;
} catch (error) {
let message = error;
if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the market data of markets was aborted because the request to the data provider took more than ${(
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (
[StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
error?.status
)
) {
message =
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
this.logger.error(message);
}
return marketDataOfMarkets;
}
public getMaxNumberOfSymbolsPerRequest() { public getMaxNumberOfSymbolsPerRequest() {
return 20; return 20;
} }

6
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -60,7 +60,7 @@ export class GoogleSheetsService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const sheet = await this.getSheet({ const sheet = await this.getSheet({
@ -85,9 +85,7 @@ export class GoogleSheetsService implements DataProviderInterface {
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close }; historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
}); });
return { return historicalData;
[symbol]: historicalData
};
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(

17
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -2,7 +2,8 @@ import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DataProviderResponse, DataProviderResponse,
LookupResponse LookupResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -34,8 +35,13 @@ export interface DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}>; // TODO: Return only one symbol }>;
getMarketDataOfMarkets?({
includeHistoricalData,
requestTimeout
}: GetMarketDataOfMarketsParams): Promise<MarketDataOfMarketsResponse>;
getMaxNumberOfSymbolsPerRequest?(): number; getMaxNumberOfSymbolsPerRequest?(): number;
@ -72,6 +78,11 @@ export interface GetHistoricalParams {
to: Date; to: Date;
} }
export interface GetMarketDataOfMarketsParams {
includeHistoricalData?: number;
requestTimeout?: number;
}
export interface GetQuotesParams { export interface GetQuotesParams {
requestTimeout?: number; requestTimeout?: number;
symbols: string[]; symbols: string[];

13
apps/api/src/services/data-provider/manual/manual.service.ts

@ -79,7 +79,7 @@ export class ManualService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
@ -90,14 +90,13 @@ export class ManualService implements DataProviderInterface {
if (defaultMarketPrice) { if (defaultMarketPrice) {
const historical: { const historical: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = { } = {};
[symbol]: {}
};
let date = from; let date = from;
while (isBefore(date, to)) { while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = { historical[format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice marketPrice: defaultMarketPrice
}; };
@ -115,11 +114,9 @@ export class ManualService implements DataProviderInterface {
}); });
return { return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value marketPrice: value
} }
}
}; };
} catch (error) { } catch (error) {
throw new Error( throw new Error(

4
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -58,7 +58,7 @@ export class RapidApiService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
@ -66,11 +66,9 @@ export class RapidApiService implements DataProviderInterface {
if (fgi) { if (fgi) {
return { return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value marketPrice: fgi.previousClose.value
} }
}
}; };
} }
} }

8
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -123,7 +123,7 @@ export class YahooFinanceService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
if (isSameDay(from, to)) { if (isSameDay(from, to)) {
to = addDays(to, 1); to = addDays(to, 1);
@ -144,13 +144,11 @@ export class YahooFinanceService implements DataProviderInterface {
); );
const response: { const response: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
response[symbol] = {};
for (const historicalItem of historicalResult) { for (const historicalItem of historicalResult) {
response[symbol][format(historicalItem.date, DATE_FORMAT)] = { response[format(historicalItem.date, DATE_FORMAT)] = {
marketPrice: historicalItem.close marketPrice: historicalItem.close
}; };
} }

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

@ -204,7 +204,7 @@ export class MarketDataService {
); );
} }
public async updateAssetProfileIdentifier( public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier, oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier newAssetProfileIdentifier: AssetProfileIdentifier
) { ) {

7
apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface BetterStackUptimeSlaResponse {
data: {
attributes: {
availability: number;
};
};
}

46
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -25,6 +25,14 @@ import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { BetterStackUptimeSlaResponse } from './interfaces/interfaces';
const GATHER_STATISTICS_CONCURRENCY = parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
);
@Injectable() @Injectable()
@Processor(STATISTICS_GATHERING_QUEUE) @Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor { export class StatisticsGatheringProcessor {
@ -37,11 +45,7 @@ export class StatisticsGatheringProcessor {
) {} ) {}
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME
}) })
public async gatherDockerHubPullsStatistics() { public async gatherDockerHubPullsStatistics() {
@ -58,11 +62,7 @@ export class StatisticsGatheringProcessor {
} }
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME
}) })
public async gatherGitHubContributorsStatistics() { public async gatherGitHubContributorsStatistics() {
@ -83,11 +83,7 @@ export class StatisticsGatheringProcessor {
} }
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME
}) })
public async gatherGitHubStargazersStatistics() { public async gatherGitHubStargazersStatistics() {
@ -106,11 +102,7 @@ export class StatisticsGatheringProcessor {
} }
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
}) })
public async gatherUptimeStatistics() { public async gatherUptimeStatistics() {
@ -140,14 +132,14 @@ export class StatisticsGatheringProcessor {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const { pull_count } = (await this.fetchService const { pull_count } = await this.fetchService
.fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', { .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}) })
.then((res) => res.json())) as { pull_count: number }; .then<{ pull_count: number }>((res) => res.json());
return pull_count; return pull_count;
} catch (error) { } catch (error) {
@ -157,7 +149,7 @@ export class StatisticsGatheringProcessor {
} }
} }
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number | undefined> {
try { try {
const body = await this.fetchService const body = await this.fetchService
.fetch('https://github.com/ghostfolio/ghostfolio', { .fetch('https://github.com/ghostfolio/ghostfolio', {
@ -189,14 +181,14 @@ export class StatisticsGatheringProcessor {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const { stargazers_count } = (await this.fetchService const { stargazers_count } = await this.fetchService
.fetch('https://api.github.com/repos/ghostfolio/ghostfolio', { .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}) })
.then((res) => res.json())) as { stargazers_count: number }; .then<{ stargazers_count: number }>((res) => res.json());
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
@ -213,7 +205,7 @@ export class StatisticsGatheringProcessor {
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to=${format(new Date(), DATE_FORMAT)}`,
{ {
headers: { headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
@ -225,7 +217,7 @@ export class StatisticsGatheringProcessor {
) )
} }
) )
.then((res) => res.json()); .then<BetterStackUptimeSlaResponse>((res) => res.json());
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {

9
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -35,6 +35,15 @@ export class SymbolProfileService {
}); });
} }
public deleteAssetProfileOverrides({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.assetProfileOverrides.deleteMany({
where: { symbolProfile: { dataSource, symbol } }
});
}
public async deleteById(id: string) { public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { id } where: { id }

2
apps/api/src/services/tag/tag.service.ts

@ -20,7 +20,7 @@ export class TagService {
public async getTag( public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> { ): Promise<Tag | null> {
return this.prismaService.tag.findUnique({ return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput where: tagWhereUniqueInput
}); });

8
apps/client/src/app/adapter/custom-date-adapter.ts

@ -6,7 +6,7 @@ import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string, @Inject(MAT_DATE_LOCALE) public override locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
) { ) {
super(matDateLocale); super(matDateLocale);
@ -15,21 +15,21 @@ export class CustomDateAdapter extends NativeDateAdapter {
/** /**
* Formats a date as a string * Formats a date as a string
*/ */
public format(aDate: Date): string { public override format(aDate: Date): string {
return format(aDate, getDateFormatString(this.locale)); return format(aDate, getDateFormatString(this.locale));
} }
/** /**
* Sets the first day of the week to Monday * Sets the first day of the week to Monday
*/ */
public getFirstDayOfWeek(): number { public override getFirstDayOfWeek(): number {
return 1; return 1;
} }
/** /**
* Parses a date from a provided value * Parses a date from a provided value
*/ */
public parse(aValue: string): Date { public override parse(aValue: string): Date {
let date = parse(aValue, getDateFormatString(this.locale), new Date()); let date = parse(aValue, getDateFormatString(this.locale), new Date());
if (getYear(date) < 1900) { if (getYear(date) < 1900) {

11
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -13,7 +13,6 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -83,7 +82,6 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
GfActivitiesFilterComponent, GfActivitiesFilterComponent,
GfFabComponent, GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolPipe,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -410,7 +408,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfAssetProfileDialogComponent, GfAssetProfileDialogComponent,
AssetProfileDialogParams AssetProfileDialogParams,
AssetProfileIdentifier
>(GfAssetProfileDialogComponent, { >(GfAssetProfileDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
@ -428,15 +427,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe((newAssetProfileIdentifier) => {
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) { if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier); this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
} });
);
}); });
} }

4
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -95,9 +95,7 @@
<div class="text-truncate">{{ element.name }}</div> <div class="text-truncate">{{ element.name }}</div>
@if (!isUUID(element.symbol)) { @if (!isUUID(element.symbol)) {
<div> <div>
<small class="text-muted">{{ <small class="text-muted">{{ element.symbol }}</small>
element.symbol | gfSymbol
}}</small>
</div> </div>
} }
</td> </td>

120
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -5,6 +5,7 @@ import {
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
DATE_FORMAT, DATE_FORMAT,
@ -75,6 +76,7 @@ import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataGatheringFrequency, DataGatheringFrequency,
DataSource,
MarketData, MarketData,
Prisma, Prisma,
SymbolProfile SymbolProfile
@ -215,6 +217,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
]; ];
protected readonly DataSource = DataSource;
protected readonly dateRangeOptions = [ protected readonly dateRangeOptions = [
{ {
label: $localize`Current week` + ' (' + $localize`WTD` + ')', label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -277,7 +281,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
@Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>, private dialogRef: MatDialogRef<
GfAssetProfileDialogComponent,
AssetProfileIdentifier
>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
@ -467,6 +474,19 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
protected onConvertToManualDataSource() {
this.patchAssetProfileIdentifier({
getErrorMessage: () => {
return (
'😞 ' +
$localize`An error occurred while converting the data source to ${DataSource.MANUAL}.`
);
},
title: $localize`Do you really want to convert the data source to ${DataSource.MANUAL}?`,
updateAssetProfileDto: { dataSource: DataSource.MANUAL }
});
}
protected onDeleteProfileData({ protected onDeleteProfileData({
dataSource, dataSource,
symbol symbol
@ -666,13 +686,16 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
protected async onSubmitAssetProfileIdentifierForm() { protected async onSubmitAssetProfileIdentifierForm() {
const newAssetProfileIdentifier =
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value;
if (!newAssetProfileIdentifier?.dataSource) {
return;
}
const assetProfileIdentifier: UpdateAssetProfileDto = { const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource: dataSource: newAssetProfileIdentifier.dataSource,
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value symbol: newAssetProfileIdentifier.symbol
?.dataSource ?? undefined,
symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
}; };
try { try {
@ -687,45 +710,18 @@ export class GfAssetProfileDialogComponent implements OnInit {
return; return;
} }
this.adminService this.patchAssetProfileIdentifier({
.patchAssetProfile( getErrorMessage: (error) => {
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
assetProfileIdentifier
)
.pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.CONFLICT) { if (error.status === StatusCodes.CONFLICT) {
this.snackBar.open( // TODO: Ask if the user wants to merge the two asset profiles
$localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`,
undefined,
{
duration: ms('3 seconds')
}
);
} else {
this.snackBar.open(
$localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`,
undefined,
{
duration: ms('3 seconds')
}
);
}
return EMPTY; return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}), }
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
dataSource: assetProfileIdentifier.dataSource,
symbol: assetProfileIdentifier.symbol
};
this.dialogRef.close(newAssetProfileIdentifier); return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`;
},
title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`,
updateAssetProfileDto: assetProfileIdentifier
}); });
} }
@ -824,4 +820,42 @@ export class GfAssetProfileDialogComponent implements OnInit {
return null; return null;
} }
private patchAssetProfileIdentifier({
getErrorMessage,
title,
updateAssetProfileDto
}: {
getErrorMessage: (error: HttpErrorResponse) => string;
title: string;
updateAssetProfileDto: UpdateAssetProfileDto;
}) {
this.notificationService.confirm({
title,
confirmFn: () => {
this.adminService
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
updateAssetProfileDto
)
.pipe(
catchError((error: HttpErrorResponse) => {
this.snackBar.open(getErrorMessage(error), undefined, {
duration: ms('3 seconds')
});
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ dataSource, symbol }) => {
this.dialogRef.close({ dataSource, symbol });
});
},
confirmType: ConfirmationDialogType.Primary
});
}
} }

13
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -162,6 +162,19 @@
<ng-container i18n>Cancel</ng-container> <ng-container i18n>Cancel</ng-container>
</button> </button>
</form> </form>
@if (data.dataSource !== DataSource.MANUAL) {
<p class="my-1 px-1 text-muted" i18n>or</p>
<button
color="accent"
mat-flat-button
type="button"
(click)="onConvertToManualDataSource()"
>
<ng-container i18n
>Convert to {{ DataSource.MANUAL }}</ng-container
>
</button>
}
</div> </div>
} @else { } @else {
<div class="col-6 mb-3"> <div class="col-6 mb-3">

47
apps/client/src/app/components/home-market/home-market.component.ts

@ -1,16 +1,8 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component'; import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { Benchmark, InfoItem, User } from '@ghostfolio/common/interfaces';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -29,11 +21,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [GfBenchmarkComponent, GfFearAndGreedIndexComponent],
GfBenchmarkComponent,
GfFearAndGreedIndexComponent,
GfLineChartComponent
],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-market', selector: 'gf-home-market',
styleUrls: ['./home-market.scss'], styleUrls: ['./home-market.scss'],
@ -41,15 +29,13 @@ import { DeviceDetectorService } from 'ngx-device-detector';
}) })
export class GfHomeMarketComponent implements OnInit { export class GfHomeMarketComponent implements OnInit {
protected readonly benchmarks = signal<Benchmark[]>([]); protected readonly benchmarks = signal<Benchmark[]>([]);
protected readonly deviceType = computed( protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected readonly fearAndGreedIndex = signal<number | undefined>(undefined);
protected readonly fearLabel = $localize`Fear`; protected fearAndGreedIndex: number | undefined;
protected readonly greedLabel = $localize`Greed`;
protected hasPermissionToAccessFearAndGreedIndex: boolean; protected hasPermissionToAccessFearAndGreedIndex: boolean;
protected readonly historicalDataItems = signal<HistoricalDataItem[]>([]);
protected readonly numberOfDays = 365;
protected user: User; protected user: User;
private readonly info: InfoItem; private readonly info: InfoItem;
@ -80,27 +66,8 @@ export class GfHomeMarketComponent implements OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
if ( if (this.hasPermissionToAccessFearAndGreedIndex) {
this.hasPermissionToAccessFearAndGreedIndex && this.fearAndGreedIndex = this.info.fearAndGreedStocksMarketPrice;
this.info.fearAndGreedDataSource
) {
this.dataService
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex.set(marketPrice);
this.historicalDataItems.set([
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
]);
});
} }
this.dataService this.dataService

21
apps/client/src/app/components/home-market/home-market.html

@ -1,28 +1,11 @@
<div class="container"> <div class="container">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
@if (hasPermissionToAccessFearAndGreedIndex) { @if (hasPermissionToAccessFearAndGreedIndex) {
<div class="mb-5 row"> <div class="mb-4 row">
<div class="col-xs-12 col-md-10 offset-md-1"> <div class="col-xs-12 col-md-10 offset-md-1">
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-3"
label="Fear & Greed Index"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="true"
[locale]="user?.settings?.locale || undefined"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
/>
<gf-fear-and-greed-index <gf-fear-and-greed-index
class="d-flex justify-content-center" class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex()" [fearAndGreedIndex]="fearAndGreedIndex"
/> />
</div> </div>
</div> </div>

4
apps/client/src/app/components/home-market/home-market.scss

@ -1,7 +1,3 @@
:host { :host {
display: block; display: block;
gf-line-chart {
aspect-ratio: 16 / 9;
}
} }

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -7,7 +7,6 @@ import {
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
@ -66,7 +65,6 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
GfDialogFooterComponent, GfDialogFooterComponent,
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfFileDropDirective, GfFileDropDirective,
GfSymbolPipe,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -51,7 +51,7 @@
> >
<br /> <br />
<small class="text-muted" <small class="text-muted"
>{{ holding.assetProfile.symbol | gfSymbol }} · >{{ holding.assetProfile.symbol }} ·
{{ holding.assetProfile.currency }}</small {{ holding.assetProfile.currency }}</small
> >
</mat-option> </mat-option>

8
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -6,7 +6,7 @@ import {
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCountryName, prettifySymbol } from '@ghostfolio/common/helper'; import { getCountryName } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HoldingWithParents, HoldingWithParents,
@ -495,10 +495,10 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf += this.holdings[symbol].value; this.totalValueInEtf += this.holdings[symbol].value;
} }
this.symbols[prettifySymbol(symbol)] = { this.symbols[symbol] = {
symbol,
dataSource: position.assetProfile.dataSource, dataSource: position.assetProfile.dataSource,
name: position.assetProfile.name ?? '', name: position.assetProfile.name ?? '',
symbol: prettifySymbol(symbol),
value: value:
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? position.valueInBaseCurrency
@ -565,11 +565,11 @@ export class GfAllocationsPageComponent implements OnInit {
return currentParentHolding && return currentParentHolding &&
isNumber(currentParentHolding.valueInBaseCurrency) isNumber(currentParentHolding.valueInBaseCurrency)
? { ? {
symbol,
allocationInPercentage: allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value, currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '', name: holding.assetProfile.name ?? '',
position: holding, position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency: valueInBaseCurrency:
currentParentHolding.valueInBaseCurrency currentParentHolding.valueInBaseCurrency
} }

8
apps/client/src/app/pages/public/public-page.component.ts

@ -1,5 +1,5 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCountryName, prettifySymbol } from '@ghostfolio/common/helper'; import { getCountryName } from '@ghostfolio/common/helper';
import { import {
InfoItem, InfoItem,
PortfolioPosition, PortfolioPosition,
@ -250,9 +250,9 @@ export class GfPublicPageComponent implements OnInit {
} }
} }
this.symbols[prettifySymbol(symbol)] = { this.symbols[symbol] = {
name: position.assetProfile.name ?? prettifySymbol(symbol), symbol,
symbol: prettifySymbol(symbol), name: position.assetProfile.name ?? symbol,
value: isNumber(position.valueInBaseCurrency) value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? position.valueInBaseCurrency
: (position.valueInPercentage ?? 0) : (position.valueInPercentage ?? 0)

10
apps/client/src/locales/messages.ca.xlf

@ -2545,6 +2545,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -5263,8 +5267,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -7201,7 +7205,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.de.xlf

@ -1432,6 +1432,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -2353,8 +2357,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2446117790692479672" datatype="html"> <trans-unit id="2446117790692479672" datatype="html">
@ -7225,7 +7229,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.es.xlf

@ -1417,6 +1417,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -2338,8 +2342,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2446117790692479672" datatype="html"> <trans-unit id="2446117790692479672" datatype="html">
@ -7202,7 +7206,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.fr.xlf

@ -1800,6 +1800,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -2857,8 +2861,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2446117790692479672" datatype="html"> <trans-unit id="2446117790692479672" datatype="html">
@ -7201,7 +7205,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.it.xlf

@ -1417,6 +1417,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -2338,8 +2342,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2446117790692479672" datatype="html"> <trans-unit id="2446117790692479672" datatype="html">
@ -7202,7 +7206,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.ja.xlf

@ -2342,6 +2342,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -4863,8 +4867,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -7242,7 +7246,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7305671611654052345" datatype="html"> <trans-unit id="7305671611654052345" datatype="html">

10
apps/client/src/locales/messages.ko.xlf

@ -2342,6 +2342,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -4855,8 +4859,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -7242,7 +7246,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7305671611654052345" datatype="html"> <trans-unit id="7305671611654052345" datatype="html">

10
apps/client/src/locales/messages.nl.xlf

@ -1416,6 +1416,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -2337,8 +2341,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2446117790692479672" datatype="html"> <trans-unit id="2446117790692479672" datatype="html">
@ -7201,7 +7205,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.pl.xlf

@ -2309,6 +2309,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -4822,8 +4826,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -7201,7 +7205,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.pt.xlf

@ -1800,6 +1800,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -2785,8 +2789,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2446117790692479672" datatype="html"> <trans-unit id="2446117790692479672" datatype="html">
@ -7201,7 +7205,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.tr.xlf

@ -4246,8 +4246,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -4574,6 +4574,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -7201,7 +7205,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

10
apps/client/src/locales/messages.uk.xlf

@ -2773,6 +2773,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -5600,7 +5604,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">
@ -5679,8 +5683,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5020357869062357338" datatype="html"> <trans-unit id="5020357869062357338" datatype="html">

10
apps/client/src/locales/messages.xlf

@ -2146,6 +2146,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -4429,8 +4433,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -6577,7 +6581,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7305671611654052345" datatype="html"> <trans-unit id="7305671611654052345" datatype="html">

10
apps/client/src/locales/messages.zh.xlf

@ -2318,6 +2318,10 @@
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="4039692315328513907" datatype="html"> <trans-unit id="4039692315328513907" datatype="html">
<source>Grant access</source> <source>Grant access</source>
@ -4839,8 +4843,8 @@
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">18</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1914201149277662818" datatype="html"> <trans-unit id="1914201149277662818" datatype="html">
@ -7202,7 +7206,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6351408992301482473" datatype="html"> <trans-unit id="6351408992301482473" datatype="html">

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

@ -6,9 +6,6 @@ import { ColorScheme, DateRange } from './types';
export const ghostfolioPrefix = 'GF'; export const ghostfolioPrefix = 'GF';
/* @deprecated */
export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`;
export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies =
DataSource.MANUAL; DataSource.MANUAL;
export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`;

2
libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts

@ -9,7 +9,7 @@ export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto {
@IsIn([DataSource.MANUAL], { @IsIn([DataSource.MANUAL], {
message: `dataSource must be '${DataSource.MANUAL}'` message: `dataSource must be '${DataSource.MANUAL}'`
}) })
dataSource: DataSource; override dataSource: DataSource;
@IsArray() @IsArray()
@IsOptional() @IsOptional()

20
libs/common/src/lib/helper.spec.ts

@ -11,6 +11,10 @@ describe('Helper', () => {
expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99);
}); });
it('Get negative decimal number', () => {
expect(extractNumberFromString({ value: '-999.99' })).toEqual(-999.99);
});
it('Get decimal number (with spaces)', () => { it('Get decimal number (with spaces)', () => {
expect(extractNumberFromString({ value: ' 999.99 ' })).toEqual(999.99); expect(extractNumberFromString({ value: ' 999.99 ' })).toEqual(999.99);
}); });
@ -19,6 +23,12 @@ describe('Helper', () => {
expect(extractNumberFromString({ value: '999.99 CHF' })).toEqual(999.99); expect(extractNumberFromString({ value: '999.99 CHF' })).toEqual(999.99);
}); });
it('Get negative decimal number (with currency)', () => {
expect(extractNumberFromString({ value: '-999.99 CHF' })).toEqual(
-999.99
);
});
it('Get decimal number (comma notation)', () => { it('Get decimal number (comma notation)', () => {
expect( expect(
extractNumberFromString({ locale: 'de-DE', value: '999,99' }) extractNumberFromString({ locale: 'de-DE', value: '999,99' })
@ -37,12 +47,22 @@ describe('Helper', () => {
).toEqual(99999.99); ).toEqual(99999.99);
}); });
it('Get negative decimal number with group (comma notation)', () => {
expect(
extractNumberFromString({ locale: 'de-DE', value: '-99.999,99' })
).toEqual(-99999.99);
});
it('Get decimal number (comma notation) for locale where currency is not grouped by default', () => { it('Get decimal number (comma notation) for locale where currency is not grouped by default', () => {
expect( expect(
extractNumberFromString({ locale: 'es-ES', value: '999,99' }) extractNumberFromString({ locale: 'es-ES', value: '999,99' })
).toEqual(999.99); ).toEqual(999.99);
}); });
it('Get decimal number (with hyphenated text)', () => {
expect(extractNumberFromString({ value: 'BRK-B 425.30' })).toEqual(425.3);
});
it('Not a number', () => { it('Not a number', () => {
expect(extractNumberFromString({ value: 'X' })).toEqual(NaN); expect(extractNumberFromString({ value: 'X' })).toEqual(NaN);
}); });

12
libs/common/src/lib/helper.ts

@ -40,7 +40,6 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config'; } from './config';
import { import {
@ -210,12 +209,17 @@ export function extractNumberFromString({
value: string; value: string;
}): number | undefined { }): number | undefined {
try { try {
// Only a leading minus sign indicates a negative value. Detect it before
// stripping so that hyphens within the text cannot flip the sign.
const isNegative = value.trim().startsWith('-');
// Remove non-numeric characters (excluding international formatting characters) // Remove non-numeric characters (excluding international formatting characters)
const numericValue = value.replace(/[^\d.,'’\s]/g, ''); const numericValue = value.replace(/[^\d.,'’\s]/g, '');
const parser = new NumberParser(locale); const parser = new NumberParser(locale);
const parsedValue = parser.parse(numericValue);
return parser.parse(numericValue); return isNegative ? -parsedValue : parsedValue;
} catch { } catch {
return undefined; return undefined;
} }
@ -527,10 +531,6 @@ export function parseSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
}; };
} }
export function prettifySymbol(aSymbol: string): string {
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
}
export function resetHours(aDate: Date) { export function resetHours(aDate: Date) {
const year = getYear(aDate); const year = getYear(aDate);
const month = getMonth(aDate); const month = getMonth(aDate);

2
libs/common/src/lib/interfaces/info-item.interface.ts

@ -9,7 +9,7 @@ export interface InfoItem {
countriesOfSubscribers?: string[]; countriesOfSubscribers?: string[];
currencies: string[]; currencies: string[];
demoAuthToken: string; demoAuthToken: string;
fearAndGreedDataSource?: string; fearAndGreedStocksMarketPrice?: number;
globalPermissions: string[]; globalPermissions: string[];
isDataGatheringEnabled?: string; isDataGatheringEnabled?: string;
isReadOnlyMode?: boolean; isReadOnlyMode?: boolean;

3
libs/common/src/lib/pipes/index.ts

@ -1,3 +0,0 @@
import { GfSymbolPipe } from './symbol.pipe';
export { GfSymbolPipe };

12
libs/common/src/lib/pipes/symbol.pipe.ts

@ -1,12 +0,0 @@
import { prettifySymbol } from '@ghostfolio/common/helper';
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'gfSymbol'
})
export class GfSymbolPipe implements PipeTransform {
public transform(aSymbol: string) {
return prettifySymbol(aSymbol);
}
}

4
libs/ui/src/lib/activities-filter/activities-filter.component.html

@ -8,7 +8,7 @@
[removable]="true" [removable]="true"
(removed)="onRemoveFilter(filter)" (removed)="onRemoveFilter(filter)"
> >
{{ filter.label ?? '' | gfSymbol }} {{ filter.label }}
<button matChipRemove> <button matChipRemove>
<ion-icon name="close-outline" /> <ion-icon name="close-outline" />
</button> </button>
@ -33,7 +33,7 @@
<mat-optgroup [label]="filterGroup.name"> <mat-optgroup [label]="filterGroup.name">
@for (filter of filterGroup.filters; track filter) { @for (filter of filterGroup.filters; track filter) {
<mat-option [value]="filter.id"> <mat-option [value]="filter.id">
{{ filter.label ?? '' | gfSymbol }} {{ filter.label }}
</mat-option> </mat-option>
} }
</mat-optgroup> </mat-optgroup>

2
libs/ui/src/lib/activities-filter/activities-filter.component.ts

@ -1,5 +1,4 @@
import { Filter, FilterGroup } from '@ghostfolio/common/interfaces'; import { Filter, FilterGroup } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -39,7 +38,6 @@ import { translate } from '../i18n';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
GfSymbolPipe,
IonIcon, IonIcon,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,

4
libs/ui/src/lib/activities-table/activities-table.component.html

@ -172,9 +172,7 @@
!isUUID(element.assetProfile?.symbol) !isUUID(element.assetProfile?.symbol)
) { ) {
<div> <div>
<small class="text-muted">{{ <small class="text-muted">{{ element.assetProfile?.symbol }}</small>
element.assetProfile?.symbol | gfSymbol
}}</small>
</div> </div>
} }
</td> </td>

2
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -1,5 +1,4 @@
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -375,7 +374,6 @@ export default {
GfActivityTypeComponent, GfActivityTypeComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfNoTransactionsInfoComponent, GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

2
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -8,7 +8,6 @@ import {
Activity, Activity,
AssetProfileIdentifier AssetProfileIdentifier
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
@ -83,7 +82,6 @@ import { GfValueComponent } from '../value/value.component';
GfActivityTypeComponent, GfActivityTypeComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfNoTransactionsInfoComponent, GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

3
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -1,4 +1,3 @@
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { FocusableOption } from '@angular/cdk/a11y'; import { FocusableOption } from '@angular/cdk/a11y';
@ -24,7 +23,7 @@ import {
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfSymbolPipe, RouterModule], imports: [RouterModule],
selector: 'gf-assistant-list-item', selector: 'gf-assistant-list-item',
styleUrls: ['./assistant-list-item.scss'], styleUrls: ['./assistant-list-item.scss'],
templateUrl: './assistant-list-item.html' templateUrl: './assistant-list-item.html'

2
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html

@ -8,7 +8,7 @@
@if (item && isAsset(item)) { @if (item && isAsset(item)) {
<br /> <br />
<small class="text-muted" <small class="text-muted"
>{{ item?.symbol ?? '' | gfSymbol }} >{{ item?.symbol }}
@if (item.currency) { @if (item.currency) {
· {{ item.currency }} · {{ item.currency }}
} }

12
libs/ui/src/lib/currency-selector/currency-selector.component.ts

@ -75,22 +75,22 @@ export class GfCurrencySelectorComponent
private readonly input = viewChild.required(MatInput); private readonly input = viewChild.required(MatInput);
public constructor( public constructor(
public readonly _elementRef: ElementRef, public override readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor, public override readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef, public readonly changeDetectorRef: ChangeDetectorRef,
private readonly formGroupDirective: FormGroupDirective, private readonly formGroupDirective: FormGroupDirective,
public readonly ngControl: NgControl public override readonly ngControl: NgControl
) { ) {
super(_elementRef, _focusMonitor, ngControl); super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'currency-selector'; this.controlType = 'currency-selector';
} }
public get empty() { public override get empty() {
return this.input().empty; return this.input().empty;
} }
public set value(value: string | null) { public override set value(value: string | null) {
this.control.setValue(value); this.control.setValue(value);
super.value = value; super.value = value;
} }
@ -138,7 +138,7 @@ export class GfCurrencySelectorComponent
}); });
} }
public ngDoCheck() { public override ngDoCheck() {
if (this.ngControl) { if (this.ngControl) {
this.validateRequired(); this.validateRequired();
this.errorState = !!(this.ngControl.invalid && this.ngControl.touched); this.errorState = !!(this.ngControl.invalid && this.ngControl.touched);

2
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html

@ -40,7 +40,7 @@
> >
<br /> <br />
<small class="text-muted" <small class="text-muted"
>{{ holding.assetProfile.symbol | gfSymbol }} · >{{ holding.assetProfile.symbol }} ·
{{ holding.assetProfile.currency }}</small {{ holding.assetProfile.currency }}</small
> >
</div> </div>

2
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

@ -1,6 +1,5 @@
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { AccountWithPlatform } from '@ghostfolio/common/types'; import { AccountWithPlatform } from '@ghostfolio/common/types';
import { import {
@ -37,7 +36,6 @@ import { PortfolioFilterFormValue } from './interfaces';
imports: [ imports: [
FormsModule, FormsModule,
GfEntityLogoComponent, GfEntityLogoComponent,
GfSymbolPipe,
MatFormFieldModule, MatFormFieldModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule ReactiveFormsModule

2
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html

@ -25,7 +25,7 @@
} }
</span> </span>
<small class="text-muted" <small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} >{{ lookupItem.symbol }}
@if (lookupItem.currency) { @if (lookupItem.currency) {
· {{ lookupItem.currency }} · {{ lookupItem.currency }}
} }

14
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -1,5 +1,4 @@
import { LookupItem } from '@ghostfolio/common/interfaces'; import { LookupItem } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { FocusMonitor } from '@angular/cdk/a11y'; import { FocusMonitor } from '@angular/cdk/a11y';
@ -58,7 +57,6 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
imports: [ imports: [
FormsModule, FormsModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolPipe,
MatAutocompleteModule, MatAutocompleteModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@ -95,22 +93,22 @@ export class GfSymbolAutocompleteComponent
private readonly input = viewChild.required(MatInput); private readonly input = viewChild.required(MatInput);
public constructor( public constructor(
public readonly _elementRef: ElementRef, public override readonly _elementRef: ElementRef<HTMLElement>,
public readonly _focusMonitor: FocusMonitor, public override readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef, public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dataService: DataService, public readonly dataService: DataService,
public readonly ngControl: NgControl public override readonly ngControl: NgControl
) { ) {
super(_elementRef, _focusMonitor, ngControl); super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'symbol-autocomplete'; this.controlType = 'symbol-autocomplete';
} }
public get empty() { public override get empty() {
return this.input().empty; return this.input().empty;
} }
public set value(value: LookupItem) { public override set value(value: LookupItem) {
this.control.setValue(value); this.control.setValue(value);
super.value = value; super.value = value;
} }
@ -188,7 +186,7 @@ export class GfSymbolAutocompleteComponent
}); });
} }
public ngDoCheck() { public override ngDoCheck() {
if (this.ngControl) { if (this.ngControl) {
this.validateRequired(); this.validateRequired();
this.errorState = !!(this.ngControl.invalid && this.ngControl.touched); this.errorState = !!(this.ngControl.invalid && this.ngControl.touched);

10
libs/ui/src/lib/tags-selector/tags-selector.component.html

@ -32,13 +32,11 @@
} }
} }
@for (tag of tagsSelected(); track tag.id) { @for (tag of tagsSelected(); track tag.id) {
<mat-chip-row <mat-chip-row [removable]="true" (removed)="onRemoveTag(tag)">
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }} {{ tag.name }}
<ion-icon matChipTrailingIcon name="close-outline" /> <button matChipRemove type="button">
<ion-icon name="close-outline" />
</button>
</mat-chip-row> </mat-chip-row>
} }
<input <input

12
libs/ui/src/lib/top-holdings/top-holdings.component.html

@ -72,15 +72,15 @@
</colgroup> </colgroup>
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<td *matCellDef="let parentHolding" class="px-2" mat-cell> <td
<div *matCellDef="let parentHolding"
class="align-items-center d-flex line-height-1 text-nowrap" class="line-height-normal px-2"
mat-cell
> >
<div>{{ parentHolding?.name }}</div> <div class="text-truncate">{{ parentHolding?.name }}</div>
</div>
<div> <div>
<small class="text-muted">{{ <small class="text-muted">{{
parentHolding?.symbol | gfSymbol parentHolding?.symbol
}}</small> }}</small>
</div> </div>
</td> </td>

2
libs/ui/src/lib/top-holdings/top-holdings.component.ts

@ -3,7 +3,6 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
HoldingWithParents HoldingWithParents
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { import {
animate, animate,
@ -43,7 +42,6 @@ import { GfValueComponent } from '../value/value.component';
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfSymbolPipe,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatPaginatorModule, MatPaginatorModule,

9
libs/ui/src/lib/value/value.component.html

@ -13,13 +13,16 @@
@if (enableCopyToClipboardButton) { @if (enableCopyToClipboardButton) {
<button <button
class="ml-1 no-height no-min-width p-1" class="ml-1 no-height no-min-width p-1"
i18n-title
mat-button mat-button
title="Copy to clipboard"
type="button" type="button"
[title]="isCopied ? copiedTitle : copyToClipboardTitle"
(click)="onCopyValueToClipboard()" (click)="onCopyValueToClipboard()"
(mousedown)="$event.preventDefault()"
> >
<ion-icon class="text-muted" name="copy-outline" /> <ion-icon
class="text-muted"
[name]="isCopied ? 'checkmark-outline' : 'copy-outline'"
/>
</button> </button>
} }
</ng-template> </ng-template>

15
libs/ui/src/lib/value/value.component.stories.ts

@ -1,5 +1,6 @@
import '@angular/localize/init'; import '@angular/localize/init';
import { moduleMetadata } from '@storybook/angular'; import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -9,6 +10,9 @@ export default {
title: 'Value', title: 'Value',
component: GfValueComponent, component: GfValueComponent,
decorators: [ decorators: [
applicationConfig({
providers: [provideNoopAnimations()]
}),
moduleMetadata({ moduleMetadata({
imports: [NgxSkeletonLoaderModule] imports: [NgxSkeletonLoaderModule]
}) })
@ -103,3 +107,12 @@ export const Precision: Story = {
value: 7.2534802394809285309 value: 7.2534802394809285309
} }
}; };
export const WithCopyButton: Story = {
args: {
enableCopyToClipboardButton: true,
locale: 'en-US',
value: 1234.56
},
name: 'With Copy Button'
};

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

@ -13,13 +13,14 @@ import {
input, input,
Input, Input,
OnChanges, OnChanges,
OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { copyOutline } from 'ionicons/icons'; import { checkmarkOutline, copyOutline } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -32,7 +33,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
styleUrls: ['./value.component.scss'], styleUrls: ['./value.component.scss'],
templateUrl: './value.component.html' templateUrl: './value.component.html'
}) })
export class GfValueComponent implements AfterViewInit, OnChanges { export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false; @Input() colorizeSign = false;
@Input() deviceType: string; @Input() deviceType: string;
@Input() enableCopyToClipboardButton = false; @Input() enableCopyToClipboardButton = false;
@ -54,22 +55,29 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
public absoluteValue = 0; public absoluteValue = 0;
public formattedValue = ''; public formattedValue = '';
public hasLabel = false; public hasLabel = false;
public isCopied = false;
public isNumber = false; public isNumber = false;
public isString = false; public isString = false;
public useAbsoluteValue = false; public useAbsoluteValue = false;
public readonly copiedTitle = $localize`The value has been copied to the clipboard`;
public readonly copyToClipboardTitle = $localize`Copy to clipboard`;
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard, private clipboard: Clipboard,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
addIcons({ addIcons({
checkmarkOutline,
copyOutline copyOutline
}); });
} }
public readonly precision = input<number>(); public readonly precision = input<number>();
private copyToClipboardTimeout: ReturnType<typeof setTimeout>;
private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => { private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => {
const digits = this.hasPrecision ? this.precision() : 2; const digits = this.hasPrecision ? this.precision() : 2;
@ -178,6 +186,18 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
public onCopyValueToClipboard() { public onCopyValueToClipboard() {
this.clipboard.copy(String(this.value)); this.clipboard.copy(String(this.value));
this.isCopied = true;
if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout);
}
this.copyToClipboardTimeout = setTimeout(() => {
this.isCopied = false;
this.changeDetectorRef.markForCheck();
}, ms('3 seconds'));
this.snackBar.open( this.snackBar.open(
'✅ ' + $localize`${this.value} has been copied to the clipboard`, '✅ ' + $localize`${this.value} has been copied to the clipboard`,
undefined, undefined,
@ -187,12 +207,23 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
); );
} }
public ngOnDestroy() {
if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout);
}
}
private initializeVariables() { private initializeVariables() {
this.absoluteValue = 0; this.absoluteValue = 0;
this.formattedValue = ''; this.formattedValue = '';
this.isCopied = false;
this.isNumber = false; this.isNumber = false;
this.isString = false; this.isString = false;
this.locale = this.locale || getLocale(); this.locale = this.locale || getLocale();
this.useAbsoluteValue = false; this.useAbsoluteValue = false;
if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout);
}
} }
} }

52
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.28.0", "version": "3.30.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.28.0", "version": "3.30.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "8.0.1", "@bull-board/api": "8.1.2",
"@bull-board/express": "8.0.1", "@bull-board/express": "8.1.2",
"@bull-board/nestjs": "8.0.1", "@bull-board/nestjs": "8.1.2",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
@ -92,7 +92,7 @@
"passport-openidconnect": "0.1.2", "passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "22.2.3", "stripe": "22.3.2",
"svgmap": "2.21.0", "svgmap": "2.21.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
@ -3548,25 +3548,25 @@
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@bull-board/api": { "node_modules/@bull-board/api": {
"version": "8.0.1", "version": "8.1.2",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.1.2.tgz",
"integrity": "sha512-7FELJHRQPtjH9+r/DUArr4pDVU8r1yeDS9azQUzFtIbsUT5xGjyg5R1RB0I/RfwFfK5bqho/4cpzxJ/UnQjSKA==", "integrity": "sha512-6NGYCIRhHJmmoCwAFA1z4lPpR/D/BdOjyERd2MMeG4Samu05FSjzKKdN55S3ga30EcXeENfYzmNn6IDmVz5OTg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "8.0.1" "@bull-board/ui": "8.1.2"
} }
}, },
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "8.0.1", "version": "8.1.2",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-8.1.2.tgz",
"integrity": "sha512-VOEhLNlaaVk3mBBoREXO/Dopzr9rKK5TpOGyaCbcnpzLKlrlwhj6BAeWWreB7/3Wu+pNnbKmrAhK8OOnYdzxLg==", "integrity": "sha512-IldpQLXlezJRzk1BVNJjH+Oi3NBRcBopq9bI2ndLs0R+ySK1ux+TtGDUW+eX2fl0wEHwSpuUgPBAJID695zohA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "8.0.1", "@bull-board/api": "8.1.2",
"@bull-board/ui": "8.0.1", "@bull-board/ui": "8.1.2",
"ejs": "^6.0.1", "ejs": "^6.0.1",
"express": "^5.2.1" "express": "^5.2.1"
} }
@ -3584,12 +3584,12 @@
} }
}, },
"node_modules/@bull-board/nestjs": { "node_modules/@bull-board/nestjs": {
"version": "8.0.1", "version": "8.1.2",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-8.1.2.tgz",
"integrity": "sha512-yBB+S7ibdrcO6y01VQTzd51Qxx19bPnte5TSdA59BWSeH6oVagB+EkCqHXTbfdGrvLrwZEF3tnSCE6339nB7MQ==", "integrity": "sha512-7sgESSitxULFSxuWIYx6zUyCgQIVFtw+s5ezGFvdh3/s2dwP2NoOQ76xJn+16cXh0EAFM+rN1f05BAtc0aLlUQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@bull-board/api": "^8.0.1", "@bull-board/api": "^8.1.2",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
@ -3598,12 +3598,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "8.0.1", "version": "8.1.2",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.1.2.tgz",
"integrity": "sha512-gKEGSD8dlUoWFGJJ4I4Q5bDtfB7yJwGfpgA2DVn1G1TzlUSN5n4fzzcGpLf5fS+QhR7tB/niydUiFRQ2zrjVrQ==", "integrity": "sha512-gC5u9XUSiRile4/VC+WHXtaPvfZV4Mrrz8MSvPe32Hjl2MXStfSuEOb+9Tw/WMpNSZ442tGpFZLz0RDh1Yo3jg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "8.0.1" "@bull-board/api": "8.1.2"
} }
}, },
"node_modules/@cacheable/utils": { "node_modules/@cacheable/utils": {
@ -32428,9 +32428,9 @@
} }
}, },
"node_modules/stripe": { "node_modules/stripe": {
"version": "22.2.3", "version": "22.3.2",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.3.tgz", "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz",
"integrity": "sha512-9lrggvLtjO4Ef5WXH4t50ckHJcJgTD72xFB+KJgZCSszAH9zMV1BqU6PwmWbRLdcdX7LK6PbErBapiGK7pIb+g==", "integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"

10
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.28.0", "version": "3.30.0",
"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",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "8.0.1", "@bull-board/api": "8.1.2",
"@bull-board/express": "8.0.1", "@bull-board/express": "8.1.2",
"@bull-board/nestjs": "8.0.1", "@bull-board/nestjs": "8.1.2",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
@ -136,7 +136,7 @@
"passport-openidconnect": "0.1.2", "passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "22.2.3", "stripe": "22.3.2",
"svgmap": "2.21.0", "svgmap": "2.21.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",

2
tsconfig.base.json

@ -30,7 +30,7 @@
"noImplicitReturns": false, "noImplicitReturns": false,
"noImplicitAny": false, "noImplicitAny": false,
"noImplicitThis": true, "noImplicitThis": true,
"noImplicitOverride": false, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,

Loading…
Cancel
Save