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. 113
      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. 29
      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. 17
      apps/api/src/services/data-provider/manual/manual.service.ts
  38. 8
      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. 19
      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. 124
      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. 14
      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
- 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`)
- 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

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>" }`)
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)
#### Request

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

@ -12,6 +12,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol
} from '@ghostfolio/common/helper';
@ -39,6 +40,7 @@ import {
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { randomUUID } from 'node:crypto';
@Injectable()
export class AdminService {
@ -240,16 +242,25 @@ export class AdminService {
url
}: Prisma.SymbolProfileUpdateInput
) {
const isConversionToManualDataSource =
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
if (isConversionToManualDataSource && !newSymbol) {
newSymbol = randomUUID();
}
if (
newSymbol &&
newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource)
newSymbol &&
(newDataSource !== dataSource || newSymbol !== symbol)
) {
const newAssetProfileIdentifier: AssetProfileIdentifier = {
dataSource: newDataSource as DataSource,
symbol: newSymbol as string
};
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
newAssetProfileIdentifier
]);
if (assetProfile) {
@ -259,45 +270,79 @@ export class AdminService {
);
}
try {
await Promise.all([
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
const operations: Prisma.PrismaPromise<unknown>[] = [
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
),
this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
)
];
if (isConversionToManualDataSource) {
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.marketDataService.updateAssetProfileIdentifier(
this.symbolProfileService.updateSymbolProfile(
newAssetProfileIdentifier,
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
assetClass: currentAssetProfileWithOverrides.assetClass,
assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
countries:
currentAssetProfileWithOverrides.countries ?? undefined,
holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
name: currentAssetProfileWithOverrides.name,
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
url: currentAssetProfileWithOverrides.url
}
)
]);
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]);
);
}
return updatedAssetProfile;
try {
await this.prismaService.$transaction(operations);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else {
const assetProfileOverrides = {
assetClass: assetClass as AssetClass,

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

@ -14,7 +14,6 @@ import {
Controller,
Get,
HttpException,
Param,
Post,
Req,
Res,
@ -36,26 +35,6 @@ export class AuthController {
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')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLogin(

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

@ -171,7 +171,7 @@ export class GhostfolioService {
try {
const promises: Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
@ -185,7 +185,7 @@ export class GhostfolioService {
to
})
.then((historicalData) => {
result.historicalData = historicalData[symbol];
result.historicalData = 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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,
signOptions: { expiresIn: '30 days' }
}),
MarketDataModule,
PlatformModule,
PropertyModule,
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 { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
ghostfolioFearAndGreedIndexSymbolStocks,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS,
@ -23,6 +24,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { MarketData } from '@prisma/client';
import { subDays } from 'date-fns';
import { isNil } from 'lodash';
@ -36,6 +38,7 @@ export class InfoService {
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
@ -45,6 +48,7 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
let latestFearAndGreedStocksMarketDataPromise: Promise<MarketData>;
const globalPermissions: string[] = [];
@ -61,16 +65,12 @@ export class InfoService {
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
const fearAndGreedIndexDataSource =
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
fearAndGreedIndexDataSource
);
} else {
info.fearAndGreedDataSource = fearAndGreedIndexDataSource;
}
latestFearAndGreedStocksMarketDataPromise =
this.marketDataService.getLatest({
dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks
});
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
@ -102,12 +102,14 @@ export class InfoService {
benchmarks,
demoAuthToken,
isUserSignupEnabled,
latestFearAndGreedStocksMarketData,
statistics,
subscriptionOffer
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
latestFearAndGreedStocksMarketDataPromise,
this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]);
@ -125,7 +127,9 @@ export class InfoService {
statistics,
subscriptionOffer,
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.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
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(

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

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

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

@ -29,6 +29,7 @@ import {
DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE,
@ -546,6 +547,12 @@ export class UserService {
if (hasRole(user, Role.ADMIN)) {
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')) {

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

@ -5,7 +5,9 @@ import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler';
export class CustomThrottlerGuard extends ThrottlerGuard {
private readonly logger = new Logger(CustomThrottlerGuard.name);
public async canActivate(context: ExecutionContext): Promise<boolean> {
public override async canActivate(
context: ExecutionContext
): Promise<boolean> {
try {
return await super.canActivate(context);
} 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'];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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'];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
holdings: PortfolioPosition[],
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[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
holdings: PortfolioPosition[],
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
currentValueInBaseCurrency: 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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
currentValueInBaseCurrency: 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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
buyingPower: number,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
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;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
currentValueInBaseCurrency: number,

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

@ -70,7 +70,7 @@ export class AlphaVantageService
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
try {
const historicalData: {
@ -83,11 +83,9 @@ export class AlphaVantageService
);
const response: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
} = {};
response[symbol] = {};
for (const [key, timeSeries] of Object.entries(
historicalData['Time Series (Digital Currency Daily)']
).sort()) {
@ -95,7 +93,7 @@ export class AlphaVantageService
isAfter(from, 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)'])
};
}

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

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

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

@ -27,7 +27,8 @@ import {
DataProviderHistoricalResponse,
DataProviderResponse,
LookupItem,
LookupResponse
LookupResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
@ -194,11 +195,7 @@ export class DataProviderService implements OnModuleInit {
return DataSource[dataSource];
});
const ghostfolioApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO
);
if (ghostfolioApiKey) {
if (await this.isDataProviderGhostfolioConfigured()) {
dataSources.push('GHOSTFOLIO');
}
@ -511,7 +508,7 @@ export class DataProviderService implements OnModuleInit {
requestTimeout: ms('30 seconds')
})
.then((data) => {
return { dataSource, symbol, data: data?.[symbol] };
return { data, dataSource, symbol };
})
);
}
@ -551,6 +548,22 @@ export class DataProviderService implements OnModuleInit {
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({
items,
requestTimeout,
@ -807,6 +820,12 @@ export class DataProviderService implements OnModuleInit {
return response;
}
public async isDataProviderGhostfolioConfigured(): Promise<boolean> {
return !!(await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO
));
}
public async search({
includeIndices = false,
query,

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

@ -147,7 +147,7 @@ export class EodHistoricalDataService
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
symbol = this.convertToEodSymbol(symbol);
@ -166,22 +166,19 @@ export class EodHistoricalDataService
})
.then((res) => res.json());
return response.reduce(
(result, { adjusted_close, date }) => {
if (isNumber(adjusted_close)) {
result[this.convertFromEodSymbol(symbol)][date] = {
marketPrice: adjusted_close
};
} else {
this.logger.error(
`Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`
);
}
return response.reduce((result, { adjusted_close, date }) => {
if (isNumber(adjusted_close)) {
result[date] = {
marketPrice: adjusted_close
};
} else {
this.logger.error(
`Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`
);
}
return result;
},
{ [this.convertFromEodSymbol(symbol)]: {} }
);
return result;
}, {});
} catch (error) {
throw new Error(
`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,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
const MAX_YEARS_PER_REQUEST = 5;
const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
} = {
[symbol]: {}
};
[date: string]: DataProviderHistoricalResponse;
} = {};
let currentFrom = from;
@ -378,7 +376,7 @@ export class FinancialModelingPrepService
isAfter(parseDate(date), currentFrom)) &&
isBefore(parseDate(date), currentTo)
) {
result[symbol][date] = {
result[date] = {
marketPrice: close
};
}

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

@ -5,6 +5,7 @@ import {
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetMarketDataOfMarketsParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -23,7 +24,9 @@ import {
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
MarketDataOfMarketsResponse,
QuotesResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
@ -169,7 +172,7 @@ export class GhostfolioService implements DataProviderInterface {
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
try {
const queryParams = new URLSearchParams({
@ -195,9 +198,7 @@ export class GhostfolioService implements DataProviderInterface {
const { historicalData } = (await response.json()) as HistoricalResponse;
return {
[symbol]: historicalData
};
return historicalData;
} catch (error) {
if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
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() {
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,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
try {
const sheet = await this.getSheet({
@ -85,9 +85,7 @@ export class GoogleSheetsService implements DataProviderInterface {
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
return historicalData;
} catch (error) {
throw new Error(
`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,
DataProviderInfo,
DataProviderResponse,
LookupResponse
LookupResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
@ -34,8 +35,13 @@ export interface DataProviderInterface {
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}>; // TODO: Return only one symbol
[date: string]: DataProviderHistoricalResponse;
}>;
getMarketDataOfMarkets?({
includeHistoricalData,
requestTimeout
}: GetMarketDataOfMarketsParams): Promise<MarketDataOfMarketsResponse>;
getMaxNumberOfSymbolsPerRequest?(): number;
@ -72,6 +78,11 @@ export interface GetHistoricalParams {
to: Date;
}
export interface GetMarketDataOfMarketsParams {
includeHistoricalData?: number;
requestTimeout?: number;
}
export interface GetQuotesParams {
requestTimeout?: number;
symbols: string[];

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

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

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

@ -58,7 +58,7 @@ export class RapidApiService implements DataProviderInterface {
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
try {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
@ -66,10 +66,8 @@ export class RapidApiService implements DataProviderInterface {
if (fgi) {
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
}
[format(getYesterday(), DATE_FORMAT)]: {
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,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}> {
if (isSameDay(from, to)) {
to = addDays(to, 1);
@ -144,13 +144,11 @@ export class YahooFinanceService implements DataProviderInterface {
);
const response: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
} = {};
response[symbol] = {};
for (const historicalItem of historicalResult) {
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
response[format(historicalItem.date, DATE_FORMAT)] = {
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,
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 { 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()
@Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor {
@ -37,11 +45,7 @@ export class StatisticsGatheringProcessor {
) {}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
concurrency: GATHER_STATISTICS_CONCURRENCY,
name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME
})
public async gatherDockerHubPullsStatistics() {
@ -58,11 +62,7 @@ export class StatisticsGatheringProcessor {
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
concurrency: GATHER_STATISTICS_CONCURRENCY,
name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME
})
public async gatherGitHubContributorsStatistics() {
@ -83,11 +83,7 @@ export class StatisticsGatheringProcessor {
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
concurrency: GATHER_STATISTICS_CONCURRENCY,
name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME
})
public async gatherGitHubStargazersStatistics() {
@ -106,11 +102,7 @@ export class StatisticsGatheringProcessor {
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
concurrency: GATHER_STATISTICS_CONCURRENCY,
name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
})
public async gatherUptimeStatistics() {
@ -140,14 +132,14 @@ export class StatisticsGatheringProcessor {
private async countDockerHubPulls(): Promise<number> {
try {
const { pull_count } = (await this.fetchService
const { pull_count } = await this.fetchService
.fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', {
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
})
.then((res) => res.json())) as { pull_count: number };
.then<{ pull_count: number }>((res) => res.json());
return pull_count;
} catch (error) {
@ -157,7 +149,7 @@ export class StatisticsGatheringProcessor {
}
}
private async countGitHubContributors(): Promise<number> {
private async countGitHubContributors(): Promise<number | undefined> {
try {
const body = await this.fetchService
.fetch('https://github.com/ghostfolio/ghostfolio', {
@ -189,14 +181,14 @@ export class StatisticsGatheringProcessor {
private async countGitHubStargazers(): Promise<number> {
try {
const { stargazers_count } = (await this.fetchService
const { stargazers_count } = await this.fetchService
.fetch('https://api.github.com/repos/ghostfolio/ghostfolio', {
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
})
.then((res) => res.json())) as { stargazers_count: number };
.then<{ stargazers_count: number }>((res) => res.json());
return stargazers_count;
} catch (error) {
@ -213,7 +205,7 @@ export class StatisticsGatheringProcessor {
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
)}&to=${format(new Date(), DATE_FORMAT)}`,
{
headers: {
[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;
} 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) {
return this.prismaService.symbolProfile.delete({
where: { id }

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

@ -20,7 +20,7 @@ export class TagService {
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
): Promise<Tag | null> {
return this.prismaService.tag.findUnique({
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 {
public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(MAT_DATE_LOCALE) public override locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
) {
super(matDateLocale);
@ -15,21 +15,21 @@ export class CustomDateAdapter extends NativeDateAdapter {
/**
* Formats a date as a string
*/
public format(aDate: Date): string {
public override format(aDate: Date): string {
return format(aDate, getDateFormatString(this.locale));
}
/**
* Sets the first day of the week to Monday
*/
public getFirstDayOfWeek(): number {
public override getFirstDayOfWeek(): number {
return 1;
}
/**
* 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());
if (getYear(date) < 1900) {

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

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

124
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
} from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
canDeleteAssetProfile,
DATE_FORMAT,
@ -75,6 +76,7 @@ import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource,
MarketData,
Prisma,
SymbolProfile
@ -215,6 +217,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
];
protected readonly DataSource = DataSource;
protected readonly dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -277,7 +281,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
@Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private dialogRef: MatDialogRef<
GfAssetProfileDialogComponent,
AssetProfileIdentifier
>,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
@ -467,6 +474,19 @@ export class GfAssetProfileDialogComponent implements OnInit {
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({
dataSource,
symbol
@ -666,13 +686,16 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
protected async onSubmitAssetProfileIdentifierForm() {
const newAssetProfileIdentifier =
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value;
if (!newAssetProfileIdentifier?.dataSource) {
return;
}
const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.dataSource ?? undefined,
symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
};
try {
@ -687,46 +710,19 @@ export class GfAssetProfileDialogComponent implements OnInit {
return;
}
this.adminService
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
assetProfileIdentifier
)
.pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.CONFLICT) {
this.snackBar.open(
$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')
}
);
}
this.patchAssetProfileIdentifier({
getErrorMessage: (error) => {
if (error.status === StatusCodes.CONFLICT) {
// TODO: Ask if the user wants to merge the two asset profiles
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
dataSource: assetProfileIdentifier.dataSource,
symbol: assetProfileIdentifier.symbol
};
return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}
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
});
}
protected onTestMarketData() {
@ -824,4 +820,42 @@ export class GfAssetProfileDialogComponent implements OnInit {
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>
</button>
</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>
} @else {
<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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { Benchmark, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
import {
@ -29,11 +21,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfBenchmarkComponent,
GfFearAndGreedIndexComponent,
GfLineChartComponent
],
imports: [GfBenchmarkComponent, GfFearAndGreedIndexComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-market',
styleUrls: ['./home-market.scss'],
@ -41,15 +29,13 @@ import { DeviceDetectorService } from 'ngx-device-detector';
})
export class GfHomeMarketComponent implements OnInit {
protected readonly benchmarks = signal<Benchmark[]>([]);
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected readonly fearAndGreedIndex = signal<number | undefined>(undefined);
protected readonly fearLabel = $localize`Fear`;
protected readonly greedLabel = $localize`Greed`;
protected fearAndGreedIndex: number | undefined;
protected hasPermissionToAccessFearAndGreedIndex: boolean;
protected readonly historicalDataItems = signal<HistoricalDataItem[]>([]);
protected readonly numberOfDays = 365;
protected user: User;
private readonly info: InfoItem;
@ -80,27 +66,8 @@ export class GfHomeMarketComponent implements OnInit {
permissions.enableFearAndGreedIndex
);
if (
this.hasPermissionToAccessFearAndGreedIndex &&
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
}
]);
});
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.fearAndGreedIndex = this.info.fearAndGreedStocksMarketPrice;
}
this.dataService

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

@ -1,28 +1,11 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
@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="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
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex()"
[fearAndGreedIndex]="fearAndGreedIndex"
/>
</div>
</div>

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

@ -1,7 +1,3 @@
:host {
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
} from '@ghostfolio/common/dtos';
import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
@ -66,7 +65,6 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfFileDropDirective,
GfSymbolPipe,
IonIcon,
MatButtonModule,
MatDialogModule,

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

@ -51,7 +51,7 @@
>
<br />
<small class="text-muted"
>{{ holding.assetProfile.symbol | gfSymbol }} ·
>{{ holding.assetProfile.symbol }} ·
{{ holding.assetProfile.currency }}</small
>
</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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCountryName, prettifySymbol } from '@ghostfolio/common/helper';
import { getCountryName } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HoldingWithParents,
@ -495,10 +495,10 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf += this.holdings[symbol].value;
}
this.symbols[prettifySymbol(symbol)] = {
this.symbols[symbol] = {
symbol,
dataSource: position.assetProfile.dataSource,
name: position.assetProfile.name ?? '',
symbol: prettifySymbol(symbol),
value:
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
@ -565,11 +565,11 @@ export class GfAllocationsPageComponent implements OnInit {
return currentParentHolding &&
isNumber(currentParentHolding.valueInBaseCurrency)
? {
symbol,
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '',
position: holding,
symbol: prettifySymbol(symbol),
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 { getCountryName, prettifySymbol } from '@ghostfolio/common/helper';
import { getCountryName } from '@ghostfolio/common/helper';
import {
InfoItem,
PortfolioPosition,
@ -250,9 +250,9 @@ export class GfPublicPageComponent implements OnInit {
}
}
this.symbols[prettifySymbol(symbol)] = {
name: position.assetProfile.name ?? prettifySymbol(symbol),
symbol: prettifySymbol(symbol),
this.symbols[symbol] = {
symbol,
name: position.assetProfile.name ?? symbol,
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: (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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -5263,8 +5267,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="1914201149277662818" datatype="html">
@ -7201,7 +7205,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -2353,8 +2357,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="2446117790692479672" datatype="html">
@ -7225,7 +7229,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -2338,8 +2342,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="2446117790692479672" datatype="html">
@ -7202,7 +7206,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -2857,8 +2861,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="2446117790692479672" datatype="html">
@ -7201,7 +7205,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -2338,8 +2342,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="2446117790692479672" datatype="html">
@ -7202,7 +7206,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -4863,8 +4867,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="1914201149277662818" datatype="html">
@ -7242,7 +7246,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -4855,8 +4859,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="1914201149277662818" datatype="html">
@ -7242,7 +7246,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -2337,8 +2341,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="2446117790692479672" datatype="html">
@ -7201,7 +7205,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -4822,8 +4826,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="1914201149277662818" datatype="html">
@ -7201,7 +7205,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -2785,8 +2789,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="2446117790692479672" datatype="html">
@ -7201,7 +7205,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -7201,7 +7205,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -5600,7 +5604,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="6351408992301482473" datatype="html">
@ -5679,8 +5683,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -4429,8 +4433,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="1914201149277662818" datatype="html">
@ -6577,7 +6581,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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="linenumber">46</context>
</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 id="4039692315328513907" datatype="html">
<source>Grant access</source>
@ -4839,8 +4843,8 @@
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="sourcefile">libs/ui/src/lib/value/value.component.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="1914201149277662818" datatype="html">
@ -7202,7 +7206,7 @@
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<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';
/* @deprecated */
export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`;
export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies =
DataSource.MANUAL;
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], {
message: `dataSource must be '${DataSource.MANUAL}'`
})
dataSource: DataSource;
override dataSource: DataSource;
@IsArray()
@IsOptional()

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

@ -11,6 +11,10 @@ describe('Helper', () => {
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)', () => {
expect(extractNumberFromString({ value: ' 999.99 ' })).toEqual(999.99);
});
@ -19,6 +23,12 @@ describe('Helper', () => {
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)', () => {
expect(
extractNumberFromString({ locale: 'de-DE', value: '999,99' })
@ -37,12 +47,22 @@ describe('Helper', () => {
).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', () => {
expect(
extractNumberFromString({ locale: 'es-ES', value: '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', () => {
expect(extractNumberFromString({ value: 'X' })).toEqual(NaN);
});

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

@ -40,7 +40,6 @@ import {
DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config';
import {
@ -210,12 +209,17 @@ export function extractNumberFromString({
value: string;
}): number | undefined {
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)
const numericValue = value.replace(/[^\d.,'’\s]/g, '');
const parser = new NumberParser(locale);
const parsedValue = parser.parse(numericValue);
return parser.parse(numericValue);
return isNegative ? -parsedValue : parsedValue;
} catch {
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) {
const year = getYear(aDate);
const month = getMonth(aDate);

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

@ -9,7 +9,7 @@ export interface InfoItem {
countriesOfSubscribers?: string[];
currencies: string[];
demoAuthToken: string;
fearAndGreedDataSource?: string;
fearAndGreedStocksMarketPrice?: number;
globalPermissions: string[];
isDataGatheringEnabled?: string;
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"
(removed)="onRemoveFilter(filter)"
>
{{ filter.label ?? '' | gfSymbol }}
{{ filter.label }}
<button matChipRemove>
<ion-icon name="close-outline" />
</button>
@ -33,7 +33,7 @@
<mat-optgroup [label]="filterGroup.name">
@for (filter of filterGroup.filters; track filter) {
<mat-option [value]="filter.id">
{{ filter.label ?? '' | gfSymbol }}
{{ filter.label }}
</mat-option>
}
</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 { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common';
@ -39,7 +38,6 @@ import { translate } from '../i18n';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfSymbolPipe,
IonIcon,
MatAutocompleteModule,
MatButtonModule,

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

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

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

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

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

@ -8,7 +8,6 @@ import {
Activity,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications';
@ -83,7 +82,6 @@ import { GfValueComponent } from '../value/value.component';
GfActivityTypeComponent,
GfEntityLogoComponent,
GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent,
IonIcon,
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 { FocusableOption } from '@angular/cdk/a11y';
@ -24,7 +23,7 @@ import {
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfSymbolPipe, RouterModule],
imports: [RouterModule],
selector: 'gf-assistant-list-item',
styleUrls: ['./assistant-list-item.scss'],
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)) {
<br />
<small class="text-muted"
>{{ item?.symbol ?? '' | gfSymbol }}
>{{ item?.symbol }}
@if (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);
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public override readonly _elementRef: ElementRef,
public override readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
private readonly formGroupDirective: FormGroupDirective,
public readonly ngControl: NgControl
public override readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'currency-selector';
}
public get empty() {
public override get empty() {
return this.input().empty;
}
public set value(value: string | null) {
public override set value(value: string | null) {
this.control.setValue(value);
super.value = value;
}
@ -138,7 +138,7 @@ export class GfCurrencySelectorComponent
});
}
public ngDoCheck() {
public override ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
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 />
<small class="text-muted"
>{{ holding.assetProfile.symbol | gfSymbol }} ·
>{{ holding.assetProfile.symbol }} ·
{{ holding.assetProfile.currency }}</small
>
</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 { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import {
@ -37,7 +36,6 @@ import { PortfolioFilterFormValue } from './interfaces';
imports: [
FormsModule,
GfEntityLogoComponent,
GfSymbolPipe,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule

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

@ -25,7 +25,7 @@
}
</span>
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }}
>{{ lookupItem.symbol }}
@if (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 { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { DataService } from '@ghostfolio/ui/services';
import { FocusMonitor } from '@angular/cdk/a11y';
@ -58,7 +57,6 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
imports: [
FormsModule,
GfPremiumIndicatorComponent,
GfSymbolPipe,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
@ -95,22 +93,22 @@ export class GfSymbolAutocompleteComponent
private readonly input = viewChild.required(MatInput);
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public override readonly _elementRef: ElementRef<HTMLElement>,
public override readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dataService: DataService,
public readonly ngControl: NgControl
public override readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'symbol-autocomplete';
}
public get empty() {
public override get empty() {
return this.input().empty;
}
public set value(value: LookupItem) {
public override set value(value: LookupItem) {
this.control.setValue(value);
super.value = value;
}
@ -188,7 +186,7 @@ export class GfSymbolAutocompleteComponent
});
}
public ngDoCheck() {
public override ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
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) {
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
<mat-chip-row [removable]="true" (removed)="onRemoveTag(tag)">
{{ tag.name }}
<ion-icon matChipTrailingIcon name="close-outline" />
<button matChipRemove type="button">
<ion-icon name="close-outline" />
</button>
</mat-chip-row>
}
<input

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

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

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

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

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

@ -13,13 +13,16 @@
@if (enableCopyToClipboardButton) {
<button
class="ml-1 no-height no-min-width p-1"
i18n-title
mat-button
title="Copy to clipboard"
type="button"
[title]="isCopied ? copiedTitle : copyToClipboardTitle"
(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>
}
</ng-template>

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

@ -1,5 +1,6 @@
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -9,6 +10,9 @@ export default {
title: 'Value',
component: GfValueComponent,
decorators: [
applicationConfig({
providers: [provideNoopAnimations()]
}),
moduleMetadata({
imports: [NgxSkeletonLoaderModule]
})
@ -103,3 +107,12 @@ export const Precision: Story = {
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,
OnChanges,
OnDestroy,
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { copyOutline } from 'ionicons/icons';
import { checkmarkOutline, copyOutline } from 'ionicons/icons';
import { isNumber } from 'lodash';
import ms from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -32,7 +33,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
styleUrls: ['./value.component.scss'],
templateUrl: './value.component.html'
})
export class GfValueComponent implements AfterViewInit, OnChanges {
export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false;
@Input() deviceType: string;
@Input() enableCopyToClipboardButton = false;
@ -54,22 +55,29 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
public absoluteValue = 0;
public formattedValue = '';
public hasLabel = false;
public isCopied = false;
public isNumber = false;
public isString = 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(
private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard,
private snackBar: MatSnackBar
) {
addIcons({
checkmarkOutline,
copyOutline
});
}
public readonly precision = input<number>();
private copyToClipboardTimeout: ReturnType<typeof setTimeout>;
private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => {
const digits = this.hasPrecision ? this.precision() : 2;
@ -178,6 +186,18 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
public onCopyValueToClipboard() {
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(
'✅ ' + $localize`${this.value} has been copied to the clipboard`,
undefined,
@ -187,12 +207,23 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
);
}
public ngOnDestroy() {
if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout);
}
}
private initializeVariables() {
this.absoluteValue = 0;
this.formattedValue = '';
this.isCopied = false;
this.isNumber = false;
this.isString = false;
this.locale = this.locale || getLocale();
this.useAbsoluteValue = false;
if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout);
}
}
}

52
package-lock.json

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

10
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.28.0",
"version": "3.30.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7",
"@bull-board/api": "8.0.1",
"@bull-board/express": "8.0.1",
"@bull-board/nestjs": "8.0.1",
"@bull-board/api": "8.1.2",
"@bull-board/express": "8.1.2",
"@bull-board/nestjs": "8.1.2",
"@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.7",
@ -136,7 +136,7 @@
"passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "22.2.3",
"stripe": "22.3.2",
"svgmap": "2.21.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.0",

2
tsconfig.base.json

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

Loading…
Cancel
Save