Browse Source

Merge branch 'main' into feature/include-cash-in-portfolio-performance

pull/7148/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
03a669bf82
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 89
      CHANGELOG.md
  2. 2
      README.md
  3. 8
      apps/api/src/app/access/access.controller.ts
  4. 2
      apps/api/src/app/account-balance/account-balance.service.ts
  5. 21
      apps/api/src/app/account/account.service.ts
  6. 22
      apps/api/src/app/activities/activities.service.ts
  7. 113
      apps/api/src/app/admin/admin.service.ts
  8. 26
      apps/api/src/app/app.module.ts
  9. 21
      apps/api/src/app/auth/auth.controller.ts
  10. 4
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  11. 9
      apps/api/src/app/endpoints/public/public.controller.ts
  12. 4
      apps/api/src/app/import/import.controller.ts
  13. 5
      apps/api/src/app/import/import.service.ts
  14. 2
      apps/api/src/app/info/info.module.ts
  15. 28
      apps/api/src/app/info/info.service.ts
  16. 23
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  17. 10
      apps/api/src/app/portfolio/portfolio.controller.ts
  18. 65
      apps/api/src/app/portfolio/portfolio.service.ts
  19. 4
      apps/api/src/app/subscription/subscription.service.ts
  20. 1
      apps/api/src/app/symbol/symbol.controller.ts
  21. 6
      apps/api/src/app/symbol/symbol.service.ts
  22. 7
      apps/api/src/app/user/user.service.ts
  23. 8
      apps/api/src/events/events.module.ts
  24. 55
      apps/api/src/events/portfolio-changed.listener.ts
  25. 4
      apps/api/src/guards/custom-throttler.guard.ts
  26. 8
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  27. 12
      apps/api/src/main.ts
  28. 8
      apps/api/src/middlewares/html-template.middleware.ts
  29. 37
      apps/api/src/middlewares/language-redirect.middleware.ts
  30. 2
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  31. 2
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  32. 2
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  33. 2
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  34. 2
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  35. 2
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  36. 2
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  37. 2
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  38. 2
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  39. 2
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  40. 2
      apps/api/src/models/rules/liquidity/buying-power.ts
  41. 2
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  42. 2
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  43. 2
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  44. 2
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  45. 2
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  46. 16
      apps/api/src/services/api/api.service.ts
  47. 21
      apps/api/src/services/benchmark/benchmark.service.ts
  48. 8
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  49. 10
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  50. 10
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  51. 41
      apps/api/src/services/data-provider/data-provider.service.ts
  52. 29
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  53. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  54. 68
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  55. 6
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  56. 17
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  57. 17
      apps/api/src/services/data-provider/manual/manual.service.ts
  58. 8
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  59. 8
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  60. 2
      apps/api/src/services/market-data/market-data.service.ts
  61. 14
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts
  62. 7
      apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts
  63. 46
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  64. 9
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  65. 2
      apps/api/src/services/tag/tag.service.ts
  66. 3
      apps/client/project.json
  67. 8
      apps/client/src/app/adapter/custom-date-adapter.ts
  68. 7
      apps/client/src/app/app.component.ts
  69. 47
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  70. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  71. 4
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  72. 20
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  73. 36
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  74. 124
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  75. 13
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  76. 27
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  77. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  78. 9
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  79. 56
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  80. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  81. 4
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  82. 47
      apps/client/src/app/components/home-market/home-market.component.ts
  83. 21
      apps/client/src/app/components/home-market/home-market.html
  84. 4
      apps/client/src/app/components/home-market/home-market.scss
  85. 4
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  86. 14
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  87. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  88. 6
      apps/client/src/app/pages/landing/landing-page.html
  89. 141
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  90. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  91. 29
      apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts
  92. 169
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  93. 1
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts
  94. 63
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  95. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  96. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  97. 7
      apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts
  98. 27
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  99. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  100. 15
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

89
CHANGELOG.md

@ -10,6 +10,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Included cash in the performance calculation of the portfolio
- Upgraded `fuse.js` from version `7.3.0` to `7.5.0`
### Fixed
- Resolved an exception in the `POST api/v1/activities` endpoint when creating an activity with the update account balance option but without an account
## 3.33.0 - 2026-07-25
### Added
- Added the stack trace logging for `MaxListenersExceededWarning` occurrences
### Changed
- Moved the support to create custom tags from experimental to general availability
- Recomputed the portfolio snapshot calculation in the background on a portfolio change
- Improved the deduplication of the portfolio snapshot calculation jobs by considering the filters
- Refactored the deprecated animation providers (`provideAnimations()` and `provideNoopAnimations()`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Fixed an issue with the localization in the _FIRE_ page
- Improved the spacing in the testimonial section on the landing page
## 3.32.0 - 2026-07-22
### Changed
- Upgraded `chartjs-chart-treemap` from version `3.1.0` to `4.2.0`
### Fixed
- Skipped opening the holding detail dialog for cash positions on the allocations page, the analysis page and the portfolio holdings page
- Resolved an exception in the `GET api/v1/portfolio/holding/:dataSource/:symbol` endpoint for cash positions
- Improved the error handling in the access endpoints (`POST` and `PUT`) to return `400 Bad Request` when granting access to a non-existent user
## 3.31.0 - 2026-07-20
### Changed
- Removed the deprecated `SymbolProfile` field from the activity interface
- Refactored the language redirect of the root path from the static file serving configuration to a dedicated middleware
- Upgraded `yahoo-finance2` from version `3.15.4` to `4.0.0`
### Fixed
- Fixed the `RangeNotSatisfiableError` for requests with a `Range` header to the root path caused by the empty `index.html` placeholder
- Fixed the unresolved template literal in the page title while the app is loading from the service worker cache
## 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
### Changed
- Migrated the clone, create and edit activity dialogs to dedicated routes
- Improved the language localization in the historical market data table of the admin control panel
- Improved the language localization in the tag management of the admin control panel
### Fixed

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

8
apps/api/src/app/access/access.controller.ts

@ -78,7 +78,7 @@ export class AccessController {
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -87,7 +87,7 @@ export class AccessController {
}
try {
return this.accessService.createAccess({
return await this.accessService.createAccess({
alias: data.alias || undefined,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
@ -134,7 +134,7 @@ export class AccessController {
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -155,7 +155,7 @@ export class AccessController {
}
try {
return this.accessService.updateAccess({
return await this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId

2
apps/api/src/app/account-balance/account-balance.service.ts

@ -178,7 +178,7 @@ export class AccountBalanceService {
accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.account.currency,
balance.account.currency ?? userCurrency,
userCurrency
)
};

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

@ -37,11 +37,26 @@ export class AccountService {
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const [account] = await this.accounts({
where: id_userId
const account = await this.prismaService.account.findUnique({
include: {
balances: {
orderBy: { date: 'desc' },
take: 1
}
},
where: { id_userId }
});
return account;
if (!account) {
return null;
}
const { balances, ...accountData } = account;
return {
...accountData,
balance: balances[0]?.value ?? 0
};
}
public async accountWithActivities(

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

@ -275,7 +275,7 @@ export class ActivitiesService {
include: { SymbolProfile: true }
});
if (updateAccountBalance === true) {
if (accountId && updateAccountBalance === true) {
let amount = new Big(data.unitPrice).mul(data.quantity);
if (['BUY', 'FEE'].includes(data.type)) {
@ -499,23 +499,6 @@ export class ActivitiesService {
id: balanceItem.id,
isDraft: false,
quantity: 1,
SymbolProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
name: account.currency,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
symbolProfileId: account.currency,
type: ActivityType.BUY,
unitPrice: 1,
@ -862,8 +845,7 @@ export class ActivitiesService {
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value,
valueInBaseCurrency,
SymbolProfile: assetProfile
valueInBaseCurrency
};
})
);

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,

26
apps/api/src/app/app.module.ts

@ -14,8 +14,6 @@ import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-g
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES,
THROTTLE_DEFAULT_LIMIT,
THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config';
@ -143,29 +141,7 @@ import { UserModule } from './user/user.module';
'/api/*wildcard',
'/sitemap.xml'
],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: {
setHeaders: (res) => {
if (res.req?.path === '/') {
let languageCode = DEFAULT_LANGUAGE_CODE;
try {
const code = res.req.headers['accept-language']
.split(',')[0]
.split('-')[0];
if (
(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(code)
) {
languageCode = code;
}
} catch {}
res.set('Location', `/${languageCode}`);
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
}
}
}
rootPath: join(__dirname, '..', 'client')
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),

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;
})

9
apps/api/src/app/endpoints/public/public.controller.ts

@ -65,7 +65,7 @@ export class PublicController {
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === SubscriptionType.Premium;
hasDetails = user?.subscription?.type === SubscriptionType.Premium;
}
const { filters } = (access.settings ?? {}) as AccessSettings;
@ -98,7 +98,7 @@ export class PublicController {
sortDirection: 'desc',
take: 10,
types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id,
withExcludedAccountsAndActivities: false
});
@ -129,8 +129,7 @@ export class PublicController {
type,
unitPrice,
value,
valueInBaseCurrency,
SymbolProfile: assetProfile
valueInBaseCurrency
};
}
);
@ -168,7 +167,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
assetProfile.currency,
user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
)
);
})

4
apps/api/src/app/import/import.controller.ts

@ -65,7 +65,7 @@ export class ImportController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium
this.request.user.subscription?.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
@ -109,7 +109,7 @@ export class ImportController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium
this.request.user.subscription?.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}

5
apps/api/src/app/import/import.service.ts

@ -153,7 +153,6 @@ export class ImportService {
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
@ -623,9 +622,7 @@ export class ImportService {
assetProfile,
error,
value,
valueInBaseCurrency,
// @ts-ignore
SymbolProfile: assetProfile
valueInBaseCurrency
});
}

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
};
}

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

@ -50,6 +50,8 @@ import {
format,
isAfter,
isBefore,
isFuture,
isPast,
isWithinInterval,
min,
startOfDay,
@ -133,7 +135,7 @@ export abstract class PortfolioCalculator {
dateOfFirstActivity = date;
}
if (isAfter(date, new Date())) {
if (isFuture(date)) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date());
@ -1078,15 +1080,18 @@ export abstract class PortfolioCalculator {
let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId;
const portfolioSnapshotKey = this.redisCacheService.getPortfolioSnapshotKey(
{
filters: this.filters,
userId: this.userId
}
);
const jobId = portfolioSnapshotKey;
try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters,
userId: this.userId
})
);
const cachedPortfolioSnapshotValue =
await this.redisCacheService.get(portfolioSnapshotKey);
const { expiration, portfolioSnapshot }: PortfolioSnapshotValue =
JSON.parse(cachedPortfolioSnapshotValue);
@ -1096,7 +1101,7 @@ export abstract class PortfolioCalculator {
portfolioSnapshot
);
if (isAfter(new Date(), new Date(expiration))) {
if (isPast(new Date(expiration))) {
isCachedPortfolioSnapshotExpired = true;
}
} catch {}

10
apps/api/src/app/portfolio/portfolio.controller.ts

@ -97,7 +97,7 @@ export class PortfolioController {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails =
this.request.user.subscription.type === SubscriptionType.Premium;
this.request.user.subscription?.type === SubscriptionType.Premium;
}
const filters = this.apiService.buildFiltersFromQueryParams({
@ -383,7 +383,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
dividends = dividends.map((item) => {
return nullifyValuesInObject(item, ['investment']);
@ -511,7 +511,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
@ -623,7 +623,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
@ -651,7 +651,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
for (const category of report.xRay.categories) {
category.rules = null;

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

@ -777,10 +777,24 @@ export class PortfolioService {
return undefined;
}
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
const assetProfile =
symbolProfile ??
({
dataSource,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
currency: symbol,
holdings: [],
name: symbol,
sectors: []
} as EnhancedSymbolProfile);
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
@ -823,9 +837,10 @@ export class PortfolioService {
timeWeightedInvestmentWithCurrencyEffect
} = holding;
const activitiesOfHolding = activities.filter(({ assetProfile }) => {
const activitiesOfHolding = activities.filter((activity) => {
return (
assetProfile.dataSource === dataSource && assetProfile.symbol === symbol
activity.assetProfile.dataSource === dataSource &&
activity.assetProfile.symbol === symbol
);
});
@ -857,19 +872,17 @@ export class PortfolioService {
new Date()
);
const [firstActivity] = activitiesOfHolding;
const referenceUnitPrice =
firstActivity?.unitPriceInAssetProfileCurrency ?? marketPrice;
const historicalDataArray: HistoricalDataItem[] = [];
let marketPriceMax = Math.max(
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
let marketPriceMax = Math.max(referenceUnitPrice, marketPrice);
let marketPriceMaxDate =
marketPrice > activitiesOfHolding[0].unitPriceInAssetProfileCurrency
marketPrice > referenceUnitPrice
? new Date()
: activitiesOfHolding[0].date;
let marketPriceMin = Math.min(
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
: (firstActivity?.date ?? new Date());
let marketPriceMin = Math.min(referenceUnitPrice, marketPrice);
const historicalDataItems =
historicalData[getAssetProfileIdentifier({ dataSource, symbol })];
@ -920,10 +933,10 @@ export class PortfolioService {
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
averagePrice: referenceUnitPrice,
date: dateOfFirstActivity,
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfHolding[0].quantity
marketPrice: referenceUnitPrice,
quantity: firstActivity?.quantity ?? quantity.toNumber()
});
}
@ -941,16 +954,16 @@ export class PortfolioService {
marketPriceMin,
tags,
assetProfile: {
assetClass: SymbolProfile.assetClass,
assetSubClass: SymbolProfile.assetSubClass,
countries: SymbolProfile.countries,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
isin: SymbolProfile.isin,
name: SymbolProfile.name,
sectors: SymbolProfile.sectors,
symbol: SymbolProfile.symbol,
userId: SymbolProfile.userId
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
isin: assetProfile.isin,
name: assetProfile.name,
sectors: assetProfile.sectors,
symbol: assetProfile.symbol,
userId: assetProfile.userId
},
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],

4
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'
}
);
}
@ -149,7 +149,7 @@ export class SubscriptionService {
}
const subscriptionOffer: SubscriptionOffer = JSON.parse(
session.metadata.subscriptionOffer ?? '{}'
session.metadata?.subscriptionOffer ?? '{}'
);
const durationExtension = subscriptionOffer?.durationExtension;

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')) {

8
apps/api/src/events/events.module.ts

@ -1,9 +1,12 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
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 { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { Module } from '@nestjs/common';
@ -13,11 +16,14 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [
ActivitiesModule,
ApiModule,
ConfigurationModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule
PortfolioSnapshotQueueModule,
RedisCacheModule,
UserModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]
})

55
apps/api/src/events/portfolio-changed.listener.ts

@ -1,4 +1,12 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
@ -14,7 +22,12 @@ export class PortfolioChangedListener {
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {}
public constructor(
private readonly apiService: ApiService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService
) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
@ -39,6 +52,44 @@ export class PortfolioChangedListener {
private async processPortfolioChanged({ userId }: { userId: string }) {
this.logger.log(`Portfolio of user '${userId}' has changed`);
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
try {
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
const user = await this.userService.user({ id: userId });
if (!user) {
return;
}
const userSettings = user.settings.settings;
const filters = this.apiService.buildFiltersFromUserSettings({
userSettings
});
// Recompute in the background to avoid a cold start on the next request
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters,
userId,
calculationType: userSettings.performanceCalculationType,
userCurrency: userSettings.baseCurrency
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId: this.redisCacheService.getPortfolioSnapshotKey({
filters,
userId
}),
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
}
});
} catch (error) {
this.logger.error(
`Portfolio snapshot of user '${userId}' could not be recomputed`,
error
);
}
}
}

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) {

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

@ -80,10 +80,6 @@ export class TransformDataSourceInResponseInterceptor<
paths: [
'activities[*].assetProfile.dataSource',
'activities[*].dataSource',
/* @deprecated */
'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource',
'errors[*].dataSource',
@ -92,10 +88,6 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
/* @deprecated */
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
]
});

12
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
BULL_BOARD_ROUTE,
@ -25,6 +26,15 @@ import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
const logger = new Logger('Bootstrap');
const processWarningLogger = new Logger('ProcessWarning');
process.on('warning', ({ name, stack }) => {
if (name === 'MaxListenersExceededWarning') {
// Log the stack trace of MaxListenersExceededWarning occurrences to identify
// the event emitter and the call site which registers the listeners
processWarningLogger.warn(stack);
}
});
async function bootstrap() {
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests
@ -100,6 +110,8 @@ async function bootstrap() {
});
}
app.use(languageRedirectMiddleware);
const configurationService = app.get(ConfigurationService);
const trustProxy = configurationService.get('TRUST_PROXY');

8
apps/api/src/middlewares/html-template.middleware.ts

@ -111,7 +111,13 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
);
try {
map[languageCode] = readFileSync(indexHtmlPath, 'utf8');
// Restore the interpolation token which the template replaces with a
// static fallback title to avoid showing an unresolved template
// literal when served without interpolation (e.g. by the service worker)
map[languageCode] = readFileSync(indexHtmlPath, 'utf8').replace(
/<title>.*?<\/title>/,
'<title>${title}</title>'
);
} catch {
this.logger.warn(
`Skipping language '${languageCode}': ${indexHtmlPath} not found`

37
apps/api/src/middlewares/language-redirect.middleware.ts

@ -0,0 +1,37 @@
import { environment } from '@ghostfolio/api/environments/environment';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { NextFunction, Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
export function languageRedirectMiddleware(
request: Request,
response: Response,
next: NextFunction
) {
if (
!environment.production ||
request.path !== '/' ||
!['GET', 'HEAD'].includes(request.method)
) {
return next();
}
let languageCode = DEFAULT_LANGUAGE_CODE;
try {
const code = request.headers['accept-language'].split(',')[0].split('-')[0];
if ((SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(code)) {
languageCode = code;
}
} catch {}
return response.redirect(
StatusCodes.MOVED_PERMANENTLY,
`/${languageCode}${request.url.slice(1)}`
);
}

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,

16
apps/api/src/services/api/api.service.ts

@ -1,4 +1,4 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { Filter, UserSettings } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -89,4 +89,18 @@ export class ApiService {
return filters;
}
public buildFiltersFromUserSettings({
userSettings
}: {
userSettings: UserSettings;
}): Filter[] {
return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0],
filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'],
filterByTags: userSettings?.['filters.tags']?.[0]
});
}
}

21
apps/api/src/services/benchmark/benchmark.service.ts

@ -18,12 +18,11 @@ import {
BenchmarkProperty,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { addHours, isAfter, subDays } from 'date-fns';
import { addHours, isPast, subDays } from 'date-fns';
import { round, uniqBy } from 'lodash';
import ms from 'ms';
@ -94,7 +93,7 @@ export class BenchmarkService {
this.logger.debug('Fetched benchmarks from cache');
if (isAfter(new Date(), new Date(expiration))) {
if (isPast(new Date(expiration))) {
this.calculateAndCacheBenchmarks({
enableSharing
});
@ -146,7 +145,7 @@ export class BenchmarkService {
public async addBenchmark({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | undefined> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
@ -183,7 +182,7 @@ export class BenchmarkService {
public async deleteBenchmark({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | null> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
@ -240,12 +239,12 @@ export class BenchmarkService {
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const promisesAllTimeHighs: ReturnType<
typeof this.marketDataService.getMax
>[] = [];
const promisesBenchmarkTrends: ReturnType<
typeof this.getBenchmarkTrends
>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {

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
};
}

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

@ -200,13 +200,13 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.currency = assetProfile.price?.currency;
response.dataSource = this.getName();
response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
longName: assetProfile.price?.longName,
quoteType: assetProfile.price?.quoteType,
shortName: assetProfile.price?.shortName,
symbol: assetProfile.price?.symbol
});
response.symbol = this.convertFromYahooFinanceSymbol(
assetProfile.price.symbol

41
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';
@ -98,7 +99,7 @@ export class DataProviderService implements OnModuleInit {
return dataSource;
});
const promises = [];
const promises: Promise<void>[] = [];
for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource
@ -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');
}
@ -251,7 +248,7 @@ export class DataProviderService implements OnModuleInit {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === SubscriptionType.Basic
user.subscription?.type === SubscriptionType.Basic
) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
@ -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,
@ -647,7 +660,7 @@ export class DataProviderService implements OnModuleInit {
} else if (
dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription.type === SubscriptionType.Basic
user?.subscription?.type === SubscriptionType.Basic
) {
// Skip symbols of Premium data providers for users without subscription
return false;
@ -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,
@ -857,7 +876,7 @@ export class DataProviderService implements OnModuleInit {
})
.map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (user.subscription.type === SubscriptionType.Premium) {
if (user.subscription?.type === SubscriptionType.Premium) {
lookupItem.dataProviderInfo.isPremium = false;
}

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
) {

14
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts

@ -1,4 +1,4 @@
import { Job, JobOptions } from 'bull';
import type { Job, JobId, JobOptions } from 'bull';
import { setTimeout } from 'timers/promises';
import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
@ -10,8 +10,8 @@ export const PortfolioSnapshotServiceMock = {
data: PortfolioSnapshotQueueJob;
name: string;
opts?: JobOptions;
}): Promise<Job<any>> {
const mockJob: Partial<Job<any>> = {
}): Promise<Job> {
const mockJob: Partial<Job> = {
finished: async () => {
await setTimeout(100);
@ -21,12 +21,12 @@ export const PortfolioSnapshotServiceMock = {
this.jobsStore.set(opts?.jobId, mockJob);
return Promise.resolve(mockJob as Job<any>);
return Promise.resolve(mockJob as Job);
},
getJob(jobId: string): Promise<Job<any>> {
getJob(jobId: JobId): Promise<Job> {
const job = this.jobsStore.get(jobId);
return Promise.resolve(job as Job<any>);
return Promise.resolve(job as Job);
},
jobsStore: new Map<string, Partial<Job<any>>>()
jobsStore: new Map<JobId, Partial<Job>>()
};

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
});

3
apps/client/project.json

@ -203,9 +203,6 @@
{
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
},
{
"command": "shx cp apps/client/src/assets/index.html dist/apps/client"
},
{
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
},

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) {

7
apps/client/src/app/app.component.ts

@ -40,6 +40,7 @@ import { filter } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogResult } from './components/holding-detail-dialog/interfaces/interfaces';
import { GfAppQueryParams } from './interfaces/interfaces';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { UserService } from './services/user/user.service';
@ -319,7 +320,11 @@ export class GfAppComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.subscribe((result: HoldingDetailDialogResult) => {
if (result?.isNavigating) {
return;
}
void this.router.navigate([], {
queryParams: {
dataSource: null,

47
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -15,7 +15,6 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -41,7 +40,7 @@ import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
@ -53,9 +52,12 @@ import {
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs';
import { filter, forkJoin } from 'rxjs';
import { AccountDetailDialogParams } from './interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -113,11 +115,24 @@ export class GfAccountDetailDialogComponent implements OnInit {
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfAccountDetailDialogComponent>>(MatDialogRef);
inject<
MatDialogRef<GfAccountDetailDialogComponent, AccountDetailDialogResult>
>(MatDialogRef);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
@ -155,17 +170,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() {
this.dialogRef.close();
}
@ -208,17 +212,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
protected showValuesInPercentage() {
return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView

2
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -132,8 +132,6 @@
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)"

4
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -4,3 +4,7 @@ export interface AccountDetailDialogParams {
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean;
}
export interface AccountDetailDialogResult {
isNavigating?: boolean;
}

20
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,
@ -158,6 +156,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
protected placeholder = '';
protected readonly selection = new SelectionModel<AssetProfileItem>(true);
protected totalItems = 0;
protected readonly translate = translate;
protected user: User;
private activeFilters: Filter[] = [];
@ -409,7 +408,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
const dialogRef = this.dialog.open<
GfAssetProfileDialogComponent,
AssetProfileDialogParams
AssetProfileDialogParams,
AssetProfileIdentifier
>(GfAssetProfileDialogComponent, {
autoFocus: false,
data: {
@ -427,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 });
}
);
});
});
}

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

@ -87,13 +87,15 @@
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<td
*matCellDef="let element"
class="line-height-normal px-1"
mat-cell
>
<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>
@ -113,8 +115,17 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetClass }}
<td
*matCellDef="let element"
class="line-height-normal px-1 text-nowrap"
mat-cell
>
<div>{{ translate(element.assetClass) }}</div>
@if (element.assetClass) {
<div>
<small class="text-muted">{{ element.assetClass }}</small>
</div>
}
</td>
</ng-container>
@ -122,8 +133,17 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetSubClass }}
<td
*matCellDef="let element"
class="line-height-normal px-1 text-nowrap"
mat-cell
>
<div>{{ translate(element.assetSubClass) }}</div>
@if (element.assetSubClass) {
<div>
<small class="text-muted">{{ element.assetSubClass }}</small>
</div>
}
</td>
</ng-container>

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">

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

@ -213,9 +213,16 @@ export class GfAdminOverviewComponent implements OnInit {
duration: this.couponDuration
};
const hasCopiedCouponCode = this.clipboard.copy(newCoupon.code);
const coupons = [...this.couponsDataSource.data, newCoupon];
this.saveCoupons({ coupons, codeToCopy: newCoupon.code });
this.saveCoupons({
coupons,
snackBarMessage: hasCopiedCouponCode
? '✅ ' + $localize`${newCoupon.code} has been copied to the clipboard`
: '✅ ' + $localize`Coupon ${newCoupon.code} has been created`
});
}
protected onChangeCouponDuration(aCouponDuration: StringValue) {
@ -374,11 +381,11 @@ export class GfAdminOverviewComponent implements OnInit {
}
private saveCoupons({
codeToCopy,
coupons
coupons,
snackBarMessage
}: {
codeToCopy?: string;
coupons: Coupon[];
snackBarMessage?: string;
}) {
this.dataService
.putAdminSetting(PROPERTY_COUPONS, {
@ -388,14 +395,10 @@ export class GfAdminOverviewComponent implements OnInit {
.subscribe(() => {
this.couponsDataSource.data = coupons;
if (codeToCopy) {
this.clipboard.copy(codeToCopy);
this.snackBar.open(
'✅ ' + $localize`${codeToCopy} has been copied to the clipboard`,
undefined,
{ duration: ms('3 seconds') }
);
if (snackBarMessage) {
this.snackBar.open(snackBarMessage, undefined, {
duration: ms('3 seconds')
});
}
this.changeDetectorRef.markForCheck();

2
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -22,7 +22,7 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
{{ translate(element.name) }}
</td>
</ng-container>

9
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -3,6 +3,7 @@ import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -68,6 +69,7 @@ export class GfAdminTagComponent implements OnInit {
];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[];
protected readonly translate = translate;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
@ -155,7 +157,12 @@ export class GfAdminTagComponent implements OnInit {
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sortingDataAccessor = (tag, path) => {
return path === 'name'
? translate(tag.name).toLocaleLowerCase()
: (getLowercase(tag, path) as number | string);
};
this.dataService.updateInfo();

56
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -63,7 +63,7 @@ import { PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router, RouterModule } from '@angular/router';
import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Account, MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator';
@ -80,9 +80,12 @@ import {
} from 'ionicons/icons';
import { isNumber, round, uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators';
import { filter, switchMap } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
import {
HoldingDetailDialogParams,
HoldingDetailDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -186,9 +189,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected value: number;
protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
protected readonly dialogRef = inject(
MatDialogRef<GfHoldingDetailDialogComponent>
);
protected readonly dialogRef =
inject<
MatDialogRef<GfHoldingDetailDialogComponent, HoldingDetailDialogResult>
>(MatDialogRef);
private tags: Tag[];
@ -200,6 +204,17 @@ export class GfHoldingDetailDialogComponent implements OnInit {
private readonly userService = inject(UserService);
public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
addIcons({
arrowDownCircleOutline,
createOutline,
@ -566,9 +581,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOwnTag =
hasPermission(this.user.permissions, permissions.createOwnTag) &&
(this.user?.settings?.isExperimentalFeatures ?? false);
this.hasPermissionToCreateOwnTag = hasPermission(
this.user?.permissions,
permissions.createOwnTag
);
this.tagsAvailable =
this.user?.tags?.map((tag) => {
@ -589,17 +605,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() {
this.dialogRef.close();
}
@ -661,17 +666,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
private fetchActivities(filters: Filter[] = this.getActivityFilters()) {
this.dataService
.fetchActivities({

2
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -374,8 +374,6 @@
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="activitiesCount"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
/>

4
apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts

@ -15,3 +15,7 @@ export interface HoldingDetailDialogParams {
locale: string;
symbol: string;
}
export interface HoldingDetailDialogResult {
isNavigating?: boolean;
}

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;
}
}

4
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -41,7 +41,7 @@ import {
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import { type AnnotationOptions } from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns';
import { isFuture } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
@ -311,6 +311,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
return undefined;
}
return isAfter(new Date(xValue), new Date()) ? aValue : undefined;
return isFuture(new Date(xValue)) ? aValue : undefined;
}
}

14
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,5 +1,8 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
@ -243,7 +246,8 @@ export class GfAccountsPageComponent implements OnInit {
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
@ -262,7 +266,11 @@ export class GfAccountsPageComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
this.fetchAccounts();
this.router.navigate(['.'], { relativeTo: this.route });

9
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -65,7 +65,7 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected accountForm: FormGroup;
protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined;
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected hasPermissionToCreateOwnTag: boolean;
protected platforms: Platform[] = [];
protected tagsAvailable: Tag[] = [];
@ -82,9 +82,10 @@ export class GfCreateOrUpdateAccountDialogComponent {
const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.permissions,
permissions.createOwnTag
);
this.tagsAvailable = [
...(this.data.user?.tags ?? []),

6
apps/client/src/app/pages/landing/landing-page.html

@ -247,11 +247,11 @@
@if (testimonial.url) {
<a target="_blank" [href]="testimonial.url">{{
testimonial.author
}}</a>
}}</a
>,
} @else {
<span>{{ testimonial.author }}</span>
<span>{{ testimonial.author }},</span>
}
,
{{ testimonial.country }}
</div>
</div>

141
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -2,7 +2,6 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { downloadAsFile } from '@ghostfolio/common/helper';
import {
Activity,
@ -10,6 +9,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -29,17 +29,12 @@ import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces';
import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
import { ActivitiesPageParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -59,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected hasImpersonationId: boolean;
protected hasPermissionToCreateActivity: boolean;
protected hasPermissionToDeleteActivity: boolean;
protected readonly internalRoutes = internalRoutes;
protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected sortColumn = 'date';
@ -77,37 +73,9 @@ export class GfActivitiesPageComponent implements OnInit {
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params: ActivitiesPageParams) => {
if (params.activityId && (params.createDialog || params.editDialog)) {
return this.dataService
.fetchActivity(params.activityId)
.pipe(map((activity) => ({ activity, params })));
}
return of({ params, activity: undefined });
})
)
.subscribe(({ activity, params }) => {
if (params.createDialog) {
this.openCreateActivityDialog(activity);
} else if (params.editDialog) {
if (activity) {
this.openUpdateActivityDialog(activity);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
@ -149,10 +117,6 @@ export class GfActivitiesPageComponent implements OnInit {
});
}
protected onCloneActivity(aActivity: Activity) {
this.openCreateActivityDialog(aActivity);
}
protected onDeleteActivities() {
this.dataService
.deleteActivities({
@ -308,12 +272,6 @@ export class GfActivitiesPageComponent implements OnInit {
this.fetchActivities();
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
}
private fetchActivities() {
// Reset dataSource and totalItems to show loading state
this.dataSource = undefined;
@ -343,48 +301,16 @@ export class GfActivitiesPageComponent implements OnInit {
this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0
) {
this.router.navigate([], { queryParams: { createDialog: true } });
void this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
);
}
this.changeDetectorRef.markForCheck();
});
}
private openUpdateActivityDialog(aActivity: Activity) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity: aActivity,
accounts: this.user?.accounts,
user: this.user
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity: UpdateOrderDto) => {
if (activity) {
this.dataService
.putActivity(activity)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private isCalendarYear(dateRange?: DateRange) {
if (!dateRange) {
return false;
@ -393,59 +319,6 @@ export class GfActivitiesPageComponent implements OnInit {
return /^\d{4}$/.test(dateRange);
}
private openCreateActivityDialog(aActivity?: Activity) {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
accounts: this.user?.accounts,
activity: {
...aActivity,
accountId: aActivity?.accountId,
assetProfile: aActivity?.assetProfile ?? null,
date: new Date(),
id: null,
fee: 0,
type: aActivity?.type ?? 'BUY',
unitPrice: null
},
user: this.user
} satisfies CreateOrUpdateActivityDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((transaction: CreateOrderDto | null) => {
if (transaction) {
this.dataService.postActivity(transaction).subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private updateUser(aUser: User) {
this.user = aUser;

11
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -25,8 +25,6 @@
(activitiesDeleted)="onDeleteActivities()"
(activityClicked)="onClickActivity($event)"
(activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
@ -43,6 +41,13 @@
hasPermissionToCreateActivity &&
!user.settings.isRestrictedView
) {
<gf-fab [queryParams]="{ createDialog: true }" />
<gf-fab
[routerLink]="
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
"
/>
}
</div>
<router-outlet />

29
apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts

@ -4,10 +4,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfActivitiesPageComponent } from './activities-page.component';
import { GfActivityDialogHostComponent } from './activity-dialog-host/activity-dialog-host.component';
const { clone, create, update } =
internalRoutes.portfolio.subRoutes.activities.subRoutes;
export const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'create' },
path: create.path,
title: create.title
},
{
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'clone' },
path: clone.path,
title: clone.title
},
{
component: GfActivityDialogHostComponent,
data: { mode: 'update' },
path: update.path,
title: update.title
}
],
path: ':activityId'
}
],
component: GfActivitiesPageComponent,
path: '',
title: internalRoutes.portfolio.subRoutes.activities.title

169
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts

@ -0,0 +1,169 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces';
import { ActivityDialogMode } from './types/activity-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activity-dialog-host',
template: ''
})
export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>;
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
const mode = this.route.snapshot.data.mode as ActivityDialogMode;
const activityId = this.route.snapshot.paramMap.get('activityId');
const activity$: Observable<Activity | undefined> = activityId
? this.dataService.fetchActivity(activityId)
: of(undefined);
this.userService
.get()
.pipe(
switchMap((user) => {
return activity$.pipe(
map((activity) => {
return { activity, user };
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ activity, user }) => {
if (mode === 'update') {
if (!activity) {
this.navigateBack();
return;
}
this.openDialog({ activity, user, isUpdate: true });
return;
}
if (mode === 'clone' && !activity) {
this.navigateBack();
return;
}
this.openDialog({
user,
activity: {
...activity,
accountId: activity?.accountId,
assetProfile: activity?.assetProfile ?? null,
date: new Date(),
fee: 0,
id: null,
type: activity?.type ?? 'BUY',
unitPrice: null
},
isUpdate: false
});
}
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
}
private navigateBack() {
void this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink
);
}
private openDialog({
activity,
isUpdate,
user
}: {
activity: CreateOrUpdateActivityDialogParams['activity'];
isUpdate: boolean;
user: User;
}) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity,
user,
accounts: user?.accounts
},
height: deviceType === 'mobile' ? '98vh' : '80vh',
width: deviceType === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: CreateOrderDto | UpdateOrderDto | null) => {
if (!result) {
this.navigateBack();
return;
}
const request$: Observable<unknown> = isUpdate
? this.dataService.putActivity(result as UpdateOrderDto)
: this.dataService.postActivity(result as CreateOrderDto);
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
error: () => {
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating back
// destroys this component and the refreshed user is what makes the
// activities page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
}

1
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts

@ -0,0 +1 @@
export type ActivityDialogMode = 'clone' | 'create' | 'update';

63
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -92,7 +92,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
protected currentMarketPrice: number | null = null;
protected defaultDateFormat: string;
protected defaultLookupItems: LookupItem[] = [];
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected hasPermissionToCreateOwnTag: boolean;
protected isLoading = false;
protected readonly isToday = isToday;
protected mode: 'create' | 'update';
@ -120,9 +120,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
public ngOnInit() {
this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.permissions,
permissions.createOwnTag
);
this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create';
@ -265,16 +266,9 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
if (['FEE', 'INTEREST'].includes(type)) {
if (this.activityForm.get('accountId')?.value) {
this.activityForm.get('updateAccountBalance')?.enable();
} else {
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
}
}
}
this.syncUpdateAccountBalanceControl();
});
this.activityForm
@ -298,12 +292,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
});
this.activityForm.get('date')?.valueChanges.subscribe(() => {
if (isToday(this.activityForm.get('date')?.value)) {
this.activityForm.get('updateAccountBalance')?.enable();
} else {
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
}
this.syncUpdateAccountBalanceControl();
this.changeDetectorRef.markForCheck();
});
@ -383,8 +372,6 @@ export class GfCreateOrUpdateActivityDialogComponent {
.get('searchSymbol')
?.removeValidators(Validators.required);
this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
} else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
const currency =
this.data.accounts.find(({ id }) => {
@ -420,16 +407,6 @@ export class GfCreateOrUpdateActivityDialogComponent {
if (type === 'FEE') {
this.activityForm.get('unitPrice')?.setValue(0);
}
if (
['FEE', 'INTEREST'].includes(type) &&
this.activityForm.get('accountId')?.value
) {
this.activityForm.get('updateAccountBalance')?.enable();
} else {
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
}
} else {
this.activityForm
.get('dataSource')
@ -441,9 +418,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
.get('searchSymbol')
?.setValidators(Validators.required);
this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance')?.enable();
}
this.syncUpdateAccountBalanceControl();
this.changeDetectorRef.markForCheck();
});
@ -558,6 +536,27 @@ export class GfCreateOrUpdateActivityDialogComponent {
}
}
private syncUpdateAccountBalanceControl() {
const accountBalanceControl = this.activityForm.get('updateAccountBalance');
const accountId = this.activityForm.get('accountId')?.value;
const dataSource = this.activityForm.get('dataSource')?.value;
const date = this.activityForm.get('date')?.value;
const type = this.activityForm.get('type')?.value;
const isEligible =
!!accountId &&
isToday(date) &&
!['LIABILITY', 'VALUABLE'].includes(type) &&
!(dataSource === 'MANUAL' && type === 'BUY');
if (isEligible) {
accountBalanceControl?.enable();
} else {
accountBalanceControl?.disable();
accountBalanceControl?.setValue(false);
}
}
private updateAssetProfile() {
this.isLoading = true;
this.changeDetectorRef.markForCheck();

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>

7
apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts

@ -1,7 +0,0 @@
import { Params } from '@angular/router';
export interface ActivitiesPageParams extends Params {
activityId?: string;
createDialog?: string;
editDialog?: string;
}

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

@ -1,9 +1,15 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
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 {
canOpenHoldingDetail,
getCountryName
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HoldingWithParents,
@ -113,6 +119,7 @@ export class GfAllocationsPageComponent implements OnInit {
protected symbols: {
[name: string]: {
dataSource?: DataSource;
isClickable?: boolean;
name: string;
symbol: string;
value: number;
@ -492,10 +499,11 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf += this.holdings[symbol].value;
}
this.symbols[prettifySymbol(symbol)] = {
this.symbols[symbol] = {
symbol,
dataSource: position.assetProfile.dataSource,
isClickable: canOpenHoldingDetail(position),
name: position.assetProfile.name ?? '',
symbol: prettifySymbol(symbol),
value:
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
@ -562,11 +570,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
}
@ -604,7 +612,8 @@ export class GfAllocationsPageComponent implements OnInit {
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
@ -623,7 +632,11 @@ export class GfAllocationsPageComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
void this.router.navigate(['.'], { relativeTo: this.route });
});
}

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

@ -6,6 +6,7 @@ import {
DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { canOpenHoldingDetail } from '@ghostfolio/common/helper';
import {
HistoricalDataItem,
InvestmentItem,
@ -365,8 +366,11 @@ export class GfAnalysisPageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
const holdingsSorted = sortBy(
holdings.filter(({ netPerformancePercentWithCurrencyEffect }) => {
return isNumber(netPerformancePercentWithCurrencyEffect);
holdings.filter((holding) => {
return (
canOpenHoldingDetail(holding) &&
isNumber(holding.netPerformancePercentWithCurrencyEffect)
);
}),
'netPerformancePercentWithCurrencyEffect'
).reverse();

15
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -1,6 +1,7 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { formatMonthAndYear } from '@ghostfolio/common/helper';
import {
FireCalculationCompleteEvent,
FireWealth,
@ -77,6 +78,20 @@ export class GfFirePageComponent implements OnInit {
);
private readonly userService = inject(UserService);
protected get retirementDateLabel(): string {
const retirementDate =
this.user?.settings?.retirementDate ?? this.retirementDate;
if (!retirementDate) {
return '';
}
return formatMonthAndYear({
date: new Date(retirementDate),
locale: this.user?.settings?.locale
});
}
public ngOnInit() {
this.isLoading = true;

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save