Browse Source

Merge remote-tracking branch 'origin/main' into mr/MR-Upstream-changes-2025-05-21

pull/5027/head
Dan 1 month ago
parent
commit
11cc917684
  1. 116
      CHANGELOG.md
  2. 14
      apps/api/src/app/account/account.controller.ts
  3. 14
      apps/api/src/app/account/account.service.ts
  4. 72
      apps/api/src/app/admin/admin.controller.ts
  5. 61
      apps/api/src/app/admin/admin.service.ts
  6. 50
      apps/api/src/app/auth/api-key.strategy.ts
  7. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  8. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  9. 2
      apps/api/src/app/endpoints/public/public.module.ts
  10. 37
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  11. 12
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  12. 87
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  13. 18
      apps/api/src/app/import/import.service.ts
  14. 2
      apps/api/src/app/order/interfaces/activities.interface.ts
  15. 4
      apps/api/src/app/order/order.controller.ts
  16. 61
      apps/api/src/app/order/order.service.ts
  17. 4
      apps/api/src/app/platform/platform.service.ts
  18. 3
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  19. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  20. 78
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  21. 241
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  22. 241
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  23. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  24. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  25. 29
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  26. 4
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  27. 74
      apps/api/src/app/portfolio/portfolio.controller.ts
  28. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  29. 130
      apps/api/src/app/portfolio/portfolio.service.ts
  30. 16
      apps/api/src/app/redis-cache/redis-cache.module.ts
  31. 50
      apps/api/src/app/redis-cache/redis-cache.service.ts
  32. 19
      apps/api/src/app/user/user.service.ts
  33. 2
      apps/api/src/assets/sitemap.xml
  34. 4
      apps/api/src/helper/object.helper.spec.ts
  35. 23
      apps/api/src/models/interfaces/portfolio.interface.ts
  36. 83
      apps/api/src/models/order.ts
  37. 24
      apps/api/src/services/benchmark/benchmark.service.ts
  38. 1
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  39. 1
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  40. 21
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  41. 5
      apps/api/src/services/data-provider/data-provider.service.ts
  42. 1
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  43. 1
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  44. 3
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  45. 1
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  46. 1
      apps/api/src/services/data-provider/manual/manual.service.ts
  47. 1
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  48. 34
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  49. 21
      apps/api/src/services/interfaces/interfaces.ts
  50. 8
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  51. 24
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  52. 16
      apps/api/src/validators/is-currency-code.ts
  53. 8
      apps/client/src/app/app.component.html
  54. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  55. 11
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  56. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  57. 201
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  58. 170
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  59. 12
      apps/client/src/app/components/admin-settings/admin-settings.component.scss
  60. 68
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  61. 10
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  62. 37
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  63. 3
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts
  64. 213
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  65. 2
      apps/client/src/app/components/admin-users/admin-users.html
  66. 2
      apps/client/src/app/components/header/header.component.html
  67. 12
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  68. 16
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  69. 8
      apps/client/src/app/components/home-market/home-market.html
  70. 3
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss
  71. 92
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts
  72. 25
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html
  73. 4
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts
  74. 185
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  75. 35
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  76. 3
      apps/client/src/app/components/home-watchlist/home-watchlist.scss
  77. 6
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  78. 44
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts
  79. 23
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html
  80. 10
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  81. 3
      apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts
  82. 24
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  83. 9
      apps/client/src/app/pages/faq/overview/faq-overview-page.module.ts
  84. 4
      apps/client/src/app/pages/faq/saas/saas-page.component.ts
  85. 59
      apps/client/src/app/pages/faq/saas/saas-page.html
  86. 9
      apps/client/src/app/pages/faq/saas/saas-page.module.ts
  87. 4
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts
  88. 19
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  89. 9
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.module.ts
  90. 50
      apps/client/src/app/pages/features/features-page.html
  91. 6
      apps/client/src/app/pages/home/home-page-routing.module.ts
  92. 6
      apps/client/src/app/pages/home/home-page.component.ts
  93. 2
      apps/client/src/app/pages/home/home-page.module.ts
  94. 30
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  95. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  96. 20
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  97. 1
      apps/client/src/app/pages/public/public-page.component.ts
  98. 12
      apps/client/src/app/pages/public/public-page.html
  99. 26
      apps/client/src/app/services/admin.service.ts
  100. 32
      apps/client/src/app/services/data.service.ts

116
CHANGELOG.md

@ -7,14 +7,130 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a hint about delayed market data to the markets overview
- Added the asset profile count per data provider to the endpoint `GET api/v1/admin`
### Changed
- Harmonized the data providers management style of the admin control panel
- Extended the data providers management of the admin control panel by the asset profile count
- Restricted the permissions of the demo user
- Renamed `Order` to `activities` in the `User` database schema
- Removed the deprecated endpoint `GET api/v1/admin/market-data/:dataSource/:symbol`
- Removed the deprecated endpoint `POST api/v1/admin/market-data/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/admin/market-data/:dataSource/:symbol/:dateString`
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Upgraded `countup.js` from version `2.8.0` to `2.8.2`
- Upgraded `nestjs` from version `10.4.15` to `11.0.12`
- Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0`
- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.2`
### Fixed
- Displayed the button to fetch the current market price only if the activity is not in a custom currency
- Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard`
## 2.161.0 - 2025-05-06
### Added
- Extended the endpoint to get a holding by the date of the last all time high and the current change to the all time high
### Changed
- Renamed `Order` to `activities` in the `SymbolProfile` database schema
- Improved the language localization for Turkish (`tr`)
### Fixed
- Fixed an issue in the performance calculation on the date of an activity when the unit price differs from the market price
- Fixed the horizontal overflow in the table of the benchmark component
## 2.160.0 - 2025-05-04
### Added
- Added the watchlist to the features page
- Extended the content of the Frequently Asked Questions (FAQ) pages
### Changed
- Moved the watchlist from experimental to general availability
- Deprecated the endpoint to get a portfolio position in favor of get a holding
- Deprecated the endpoint to update portfolio position tags in favor of update holding tags
- Renamed `Account` to `accounts` in the `Platform` database schema
- Upgraded `prisma` from version `6.6.0` to `6.7.0`
### Fixed
- Fixed an issue with the fee calculations related to activities in a custom currency
## 2.159.0 - 2025-05-02
### Added
- Extended the watchlist by the date of the last all time high, the current change to the all time high and the current market condition (experimental)
- Added support for the impersonation mode in the watchlist (experimental)
### Changed
- Improved the language localization for Français (`fr`)
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
### Fixed
- Fixed the currency code validation by allowing `GBp`
## 2.158.0 - 2025-04-30
### Added
- Added support to delete an asset from the watchlist (experimental)
### Changed
- Renamed `Order` to `activities` in the `Account` database schema
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue with the saving of activities with type `INTEREST`, `ITEM` and `LIABILITY`
## 2.157.1 - 2025-04-29
### Added
- Introduced a watchlist to follow assets (experimental)
### Changed
- Changed the column label from _Index_ to _Name_ in the benchmark component
- Extended the data providers management of the admin control panel
- Improved the language localization for German (`de`)
## 2.156.0 - 2025-04-27
### Changed ### Changed
- Improved the error message of the currency code validation - Improved the error message of the currency code validation
- Tightened the currency code validation by requiring uppercase letters - Tightened the currency code validation by requiring uppercase letters
- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel
- Improved the language localization for Français (`fr`)
- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0`
- Upgraded `Nx` from version `20.8.0` to `20.8.1`
### Fixed ### Fixed
- Fixed an issue with the investment calculation for activities in a custom currency
- Improved the file selector of the activities import functionality to accept case-insensitive file extensions (`.CSV` and `.JSON`) - Improved the file selector of the activities import functionality to accept case-insensitive file extensions (`.CSV` and `.JSON`)
- Fixed the missing localization for "someone" on the public page
## 2.155.0 - 2025-04-23 ## 2.155.0 - 2025-04-23

14
apps/api/src/app/account/account.controller.ts

@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -57,17 +57,17 @@ export class AccountController {
@HasPermission(permissions.deleteAccount) @HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
const account = await this.accountService.accountWithOrders( const account = await this.accountService.accountWithActivities(
{ {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
}, },
{ Order: true } { activities: true }
); );
if (!account || account?.Order.length > 0) { if (!account || account?.activities.length > 0) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -87,10 +87,10 @@ export class AccountController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<Accounts> { ): Promise<AccountsResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
@ -110,7 +110,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountWithValue> { ): Promise<AccountWithValue> {
const impersonationUserId = const impersonationUserId =

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

@ -40,12 +40,12 @@ export class AccountService {
return account; return account;
} }
public async accountWithOrders( public async accountWithActivities(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput, accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude accountInclude: Prisma.AccountInclude
): Promise< ): Promise<
Account & { Account & {
Order?: Order[]; activities?: Order[];
} }
> { > {
return this.prismaService.account.findUnique({ return this.prismaService.account.findUnique({
@ -63,8 +63,8 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
activities?: Order[];
balances?: AccountBalance[]; balances?: AccountBalance[];
Order?: Order[];
Platform?: Platform; Platform?: Platform;
})[] })[]
> { > {
@ -141,7 +141,7 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: { activities: true, Platform: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
}); });
@ -149,15 +149,15 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let transactionCount = 0;
for (const order of account.Order) { for (const { isDraft } of account.activities) {
if (!order.isDraft) { if (!isDraft) {
transactionCount += 1; transactionCount += 1;
} }
} }
const result = { ...account, transactionCount }; const result = { ...account, transactionCount };
delete result.Order; delete result.activities;
return result; return result;
}); });

72
apps/api/src/app/admin/admin.controller.ts

@ -3,7 +3,6 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { import {
@ -16,7 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails,
AdminUsers, AdminUsers,
EnhancedSymbolProfile EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -50,8 +48,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -60,7 +56,6 @@ export class AdminController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -68,7 +63,7 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
return this.adminService.get(); return this.adminService.get({ user: this.request.user });
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -246,19 +241,6 @@ export class AdminController {
}); });
} }
/**
* @deprecated
*/
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test') @Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -285,58 +267,6 @@ export class AdminController {
} }
} }
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -29,7 +29,7 @@ import {
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
@ -134,14 +134,36 @@ export class AdminService {
} }
} }
public async get(): Promise<AdminData> { public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({ user });
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(), this.prismaService.order.count(),
this.countUsersWithAnalytics() this.countUsersWithAnalytics()
]); ]);
const dataProviders = await Promise.all(
dataSources.map(async (dataSource) => {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
const assetProfileCount = await this.prismaService.symbolProfile.count({
where: {
dataSource
}
});
return {
...dataProviderInfo,
assetProfileCount
};
})
);
return { return {
dataProviders,
settings, settings,
transactionCount, transactionCount,
userCount, userCount,
@ -220,7 +242,7 @@ export class AdminService {
if (sortColumn === 'activitiesCount') { if (sortColumn === 'activitiesCount') {
orderBy = { orderBy = {
Order: { activities: {
_count: sortDirection _count: sortDirection
} }
}; };
@ -238,7 +260,15 @@ export class AdminService {
where, where,
select: { select: {
_count: { _count: {
select: { Order: true } select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
}, },
assetClass: true, assetClass: true,
assetSubClass: true, assetSubClass: true,
@ -250,11 +280,6 @@ export class AdminService {
isActive: true, isActive: true,
isUsedByUsersWithSubscription: true, isUsedByUsersWithSubscription: true,
name: true, name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true, symbol: true,
@ -302,6 +327,7 @@ export class AdminService {
assetProfiles.map( assetProfiles.map(
async ({ async ({
_count, _count,
activities,
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
@ -312,7 +338,6 @@ export class AdminService {
isActive, isActive,
isUsedByUsersWithSubscription, isUsedByUsersWithSubscription,
name, name,
Order,
sectors, sectors,
symbol, symbol,
SymbolProfileOverrides, SymbolProfileOverrides,
@ -375,10 +400,11 @@ export class AdminService {
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.activities,
date: Order?.[0]?.date, date: activities?.[0]?.date,
isUsedByUsersWithSubscription: isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription, await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy,
tags tags
}; };
} }
@ -648,7 +674,7 @@ export class AdminService {
select: { select: {
_count: { _count: {
select: { select: {
Order: { activities: {
where: { where: {
User: { User: {
subscriptions: { subscriptions: {
@ -669,7 +695,7 @@ export class AdminService {
} }
}); });
return _count.Order > 0; return _count.activities > 0;
} }
} }
} }
@ -759,6 +785,7 @@ export class AdminService {
isActive: true, isActive: true,
name: symbol, name: symbol,
sectorsCount: 0, sectorsCount: 0,
watchedByCount: 0,
tags: [] tags: []
}; };
} }
@ -800,7 +827,7 @@ export class AdminService {
where, where,
select: { select: {
_count: { _count: {
select: { Account: true, Order: true } select: { Account: true, activities: true }
}, },
Analytics: { Analytics: {
select: { select: {
@ -848,10 +875,10 @@ export class AdminService {
role, role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
activityCount: _count.activities || 0,
country: Analytics?.country, country: Analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: Analytics?.updatedAt, lastActivity: Analytics?.updatedAt
transactionCount: _count.Order || 0
}; };
} }
); );

50
apps/api/src/app/auth/api-key.strategy.ts

@ -21,37 +21,31 @@ export class ApiKeyStrategy extends PassportStrategy(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super( super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, }
true,
async (apiKey: string, done: (error: any, user?: any) => void) => {
try {
const user = await this.validateApiKey(apiKey);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({ public async validate(apiKey: string) {
create: { User: { connect: { id: user.id } } }, const user = await this.validateApiKey(apiKey);
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
done(null, user); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
} catch (error) { if (hasRole(user, 'INACTIVE')) {
done(error, null); throw new HttpException(
} getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
} }
);
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
return user;
} }
private async validateApiKey(apiKey: string) { private async validateApiKey(apiKey: string) {

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

@ -8,6 +8,7 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -27,6 +28,7 @@ import { AiService } from './ai.service';
controllers: [AiController], controllers: [AiController],
imports: [ imports: [
ApiModule, ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,

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

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { import {
GetAssetProfileParams, GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
@ -327,10 +328,15 @@ export class GhostfolioService {
} }
private getDataProviderInfo(): DataProviderInfo { private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return { return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false, isPremium: false,
name: 'Ghostfolio Premium', name: 'Ghostfolio Premium'
url: 'https://ghostfol.io'
}; };
} }

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

@ -9,6 +9,7 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -25,6 +26,7 @@ import { PublicController } from './public.controller';
controllers: [PublicController], controllers: [PublicController],
imports: [ imports: [
AccessModule, AccessModule,
BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,

37
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -2,7 +2,9 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -11,6 +13,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -29,13 +32,14 @@ import { WatchlistService } from './watchlist.service';
@Controller('watchlist') @Controller('watchlist')
export class WatchlistController { export class WatchlistController {
public constructor( public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService private readonly watchlistService: WatchlistService
) {} ) {}
@Post() @Post()
@HasPermission(permissions.createWatchlistItem) @HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
return this.watchlistService.createWatchlistItem({ return this.watchlistService.createWatchlistItem({
@ -53,13 +57,13 @@ export class WatchlistController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
const watchlistItem = await this.watchlistService const watchlistItems = await this.watchlistService.getWatchlistItems(
.getWatchlistItems(this.request.user.id) this.request.user.id
.then((items) => { );
return items.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol; const watchlistItem = watchlistItems.find((item) => {
}); return item.dataSource === dataSource && item.symbol === symbol;
}); });
if (!watchlistItem) { if (!watchlistItem) {
throw new HttpException( throw new HttpException(
@ -79,7 +83,18 @@ export class WatchlistController {
@HasPermission(permissions.readWatchlist) @HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(): Promise<AssetProfileIdentifier[]> { public async getWatchlistItems(
return this.watchlistService.getWatchlistItems(this.request.user.id); @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<WatchlistResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return {
watchlist
};
} }
} }

12
apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -1,6 +1,12 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -10,7 +16,13 @@ import { WatchlistService } from './watchlist.service';
@Module({ @Module({
controllers: [WatchlistController], controllers: [WatchlistController],
imports: [ imports: [
BenchmarkModule,
DataGatheringModule,
DataProviderModule,
ImpersonationModule,
MarketDataModule,
PrismaModule, PrismaModule,
SymbolProfileModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],

87
apps/api/src/app/endpoints/watchlist/watchlist.service.ts

@ -1,12 +1,24 @@
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class WatchlistService { export class WatchlistService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async createWatchlistItem({ public async createWatchlistItem({
dataSource, dataSource,
@ -24,11 +36,26 @@ export class WatchlistService {
}); });
if (!symbolProfile) { if (!symbolProfile) {
throw new NotFoundException( const assetProfiles = await this.dataProviderService.getAssetProfiles([
`Asset profile not found for ${symbol} (${dataSource})` { dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} }
await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
await this.prismaService.user.update({ await this.prismaService.user.update({
data: { data: {
watchlist: { watchlist: {
@ -64,7 +91,7 @@ export class WatchlistService {
public async getWatchlistItems( public async getWatchlistItems(
userId: string userId: string
): Promise<AssetProfileIdentifier[]> { ): Promise<WatchlistResponse['watchlist']> {
const user = await this.prismaService.user.findUnique({ const user = await this.prismaService.user.findUnique({
select: { select: {
watchlist: { watchlist: {
@ -74,6 +101,50 @@ export class WatchlistService {
where: { id: userId } where: { id: userId }
}); });
return user.watchlist ?? []; const [assetProfiles, quotes] = await Promise.all([
this.symbolProfileService.getSymbolProfiles(user.watchlist),
this.dataProviderService.getQuotes({
items: user.watchlist.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
})
]);
const watchlist = await Promise.all(
user.watchlist.map(async ({ dataSource, symbol }) => {
const assetProfile = assetProfiles.find((profile) => {
return profile.dataSource === dataSource && profile.symbol === symbol;
});
const allTimeHigh = await this.marketDataService.getMax({
dataSource,
symbol
});
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
);
return {
dataSource,
symbol,
marketCondition:
this.benchmarkService.getMarketCondition(performancePercent),
name: assetProfile?.name,
performances: {
allTimeHigh: {
performancePercent,
date: allTimeHigh?.date
}
}
};
})
);
return watchlist.sort((a, b) => {
return a.name.localeCompare(b.name);
});
} }
} }

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

@ -49,8 +49,8 @@ export class ImportService {
symbol symbol
}: AssetProfileIdentifier): Promise<Activity[]> { }: AssetProfileIdentifier): Promise<Activity[]> {
try { try {
const { firstBuyDate, historicalData, orders } = const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getPosition(dataSource, undefined, symbol); await this.portfolioService.getHolding(dataSource, undefined, symbol);
const [[assetProfile], dividends] = await Promise.all([ const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
@ -68,7 +68,7 @@ export class ImportService {
}) })
]); ]);
const accounts = orders const accounts = activities
.filter(({ Account }) => { .filter(({ Account }) => {
return !!Account; return !!Account;
}) })
@ -88,7 +88,7 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber(); const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString); const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === Account?.id && activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
@ -118,6 +118,7 @@ export class ImportService {
createdAt: undefined, createdAt: undefined,
fee: 0, fee: 0,
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
@ -126,7 +127,8 @@ export class ImportService {
unitPrice: marketPrice, unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice, unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined, updatedAt: undefined,
userId: Account?.userId userId: Account?.userId,
valueInBaseCurrency: value
}; };
}) })
); );
@ -167,9 +169,9 @@ export class ImportService {
for (const account of accountsDto) { for (const account of accountsDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find( const accountWithSameId = existingAccounts.find((existingAccount) => {
(existingAccount) => existingAccount.id === account.id return existingAccount.id === account.id;
); });
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {

2
apps/api/src/app/order/interfaces/activities.interface.ts

@ -12,11 +12,13 @@ export interface Activity extends Order {
Account?: AccountWithPlatform; Account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInAssetProfileCurrency: number; feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile; SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[]; tags?: Tag[];
unitPriceInAssetProfileCurrency: number; unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
value: number; value: number;
valueInBaseCurrency: number;
} }
export interface ActivityError { export interface ActivityError {

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

@ -97,7 +97,7 @@ export class OrderController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@ -150,7 +150,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById( public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<Activity> { ): Promise<Activity> {
const impersonationUserId = const impersonationUserId =

61
apps/api/src/app/order/order.service.ts

@ -124,7 +124,7 @@ export class OrderService {
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
let Account: Prisma.AccountCreateNestedOneWithoutOrderInput; let Account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) { if (data.accountId) {
Account = { Account = {
@ -581,31 +581,46 @@ export class OrderService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
valueInBaseCurrency
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)
]);
return { return {
...order, ...order,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value, value,
feeInAssetProfileCurrency: valueInBaseCurrency,
await this.exchangeRateDataService.toCurrencyAtDate( SymbolProfile: assetProfile
order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
SymbolProfile: assetProfile,
unitPriceInAssetProfileCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)
}; };
}) })
); );

4
apps/api/src/app/platform/platform.service.ts

@ -54,7 +54,7 @@ export class PlatformService {
await this.prismaService.platform.findMany({ await this.prismaService.platform.findMany({
include: { include: {
_count: { _count: {
select: { Account: true } select: { accounts: true }
} }
} }
}); });
@ -64,7 +64,7 @@ export class PlatformService {
id, id,
name, name,
url, url,
accountCount: _count.Account accountCount: _count.accounts
}; };
}); });
} }

3
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -7,10 +7,13 @@ export const activityDummyData = {
createdAt: new Date(), createdAt: new Date(),
currency: undefined, currency: undefined,
fee: undefined, fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined,
id: undefined, id: undefined,
isDraft: false, isDraft: false,
symbolProfileId: undefined, symbolProfileId: undefined,
unitPrice: undefined, unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined,
updatedAt: new Date(), updatedAt: new Date(),
userId: undefined, userId: undefined,
value: undefined, value: undefined,

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

@ -955,8 +955,8 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const { for (const {
fee,
date, date,
fee,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags, tags,

78
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -194,5 +194,83 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 0 } { date: '2021-12-01', investment: 0 }
]); ]);
}); });
it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
}); });
}); });

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

@ -0,0 +1,241 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok-btceur.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2021-12-12: 50098.3
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2021-12-12',
investmentValueWithCurrencyEffect: 44558.42,
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42,
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
valueWithCurrencyEffect: 50098.3
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-01-14',
investmentValueWithCurrencyEffect: 0,
netPerformance: -1463.18,
netPerformanceInPercentage: -0.032837340282712,
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7,
valueWithCurrencyEffect: 43099.7
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('43099.7'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.03273724696701543726'
),
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
investment: new Big('44558.42'),
investmentWithCurrencyEffect: new Big('44558.42'),
netPerformance: new Big('-1463.18'),
netPerformancePercentage: new Big('-0.03283734028271199921'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.03283734028271199921')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-1463.18')
},
marketPrice: 43099.7,
marketPriceInBaseCurrency: 43099.7,
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
totalFeesWithCurrencyEffect: new Big('4.46'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

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

@ -0,0 +1,241 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok-btcusd.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2021-12-12: 50098.3
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2021-12-12',
investmentValueWithCurrencyEffect: 44558.42,
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
valueWithCurrencyEffect: 50098.3
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-01-14',
investmentValueWithCurrencyEffect: 0,
netPerformance: -1463.18,
netPerformanceInPercentage: -0.032837340282712,
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7,
valueWithCurrencyEffect: 43099.7
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('43099.7'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.03273724696701543726'
),
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
investment: new Big('44558.42'),
investmentWithCurrencyEffect: new Big('44558.42'),
netPerformance: new Big('-1463.18'),
netPerformancePercentage: new Big('-0.03283734028271199921'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.03283734028271199921')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-1463.18')
},
marketPrice: 43099.7,
marketPriceInBaseCurrency: 43099.7,
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
totalFeesWithCurrencyEffect: new Big('4.46'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

13
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -11,7 +11,6 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -49,18 +48,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}; };
}); });
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;

8
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -148,6 +148,10 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({ expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07', date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6, investmentValueWithCurrencyEffect: 151.6,
@ -161,8 +165,8 @@ describe('PortfolioCalculator', () => {
totalAccountBalance: 0, totalAccountBalance: 0,
totalInvestment: 151.6, totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6, value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 151.6 valueWithCurrencyEffect: 175.6
}); });
expect( expect(

29
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -458,12 +458,19 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
); );
} }
const marketPriceInBaseCurrency =
order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ??
new Big(0);
const marketPriceInBaseCurrencyWithCurrencyEffect =
order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ??
new Big(0);
const valueOfInvestmentBeforeTransaction = totalUnits.mul( const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency marketPriceInBaseCurrency
); );
const valueOfInvestmentBeforeTransactionWithCurrencyEffect = const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) { if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0); investmentAtStartDate = totalInvestment ?? new Big(0);
@ -560,10 +567,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect marketPriceInBaseCurrencyWithCurrencyEffect
); );
const grossPerformanceFromSell = const grossPerformanceFromSell =
@ -703,17 +710,23 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect); ).add(transactionInvestmentWithCurrencyEffect);
// If duration is effectively zero (first day), use the actual investment as the base.
// Otherwise, use the calculated time-weighted average.
timeWeightedInvestmentValues[order.date] = timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0 totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) ? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0); : totalInvestment.gt(0)
? totalInvestment
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0 totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays totalInvestmentDays
) )
: new Big(0); : totalInvestmentWithCurrencyEffect.gt(0)
? totalInvestmentWithCurrencyEffect
: new Big(0);
} }
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {

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

@ -47,6 +47,10 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 14156.4 }; return { marketPrice: 14156.4 };
} else if (isSameDay(parseDate('2018-01-01'), date)) { } else if (isSameDay(parseDate('2018-01-01'), date)) {
return { marketPrice: 13657.2 }; return { marketPrice: 13657.2 };
} else if (isSameDay(parseDate('2021-12-12'), date)) {
return { marketPrice: 50098.3 };
} else if (isSameDay(parseDate('2022-01-14'), date)) {
return { marketPrice: 43099.7 };
} }
return { marketPrice: 0 }; return { marketPrice: 0 };

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

@ -23,6 +23,7 @@ import {
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
@ -59,7 +60,6 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -386,6 +386,32 @@ export class PortfolioController {
return { dividends }; return { dividends };
} }
@Get('holding/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHolding(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@ -611,6 +637,9 @@ export class PortfolioController {
return performanceInformation; return performanceInformation;
} }
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol') @Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -620,8 +649,8 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> { ): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getPosition( const holding = await this.portfolioService.getHolding(
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol
@ -662,7 +691,7 @@ export class PortfolioController {
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags( public async updateHoldingTags(
@ -671,7 +700,42 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
const holding = await this.portfolioService.getPosition( const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
/**
* @deprecated
*/
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePositionTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getHolding(
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol

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

@ -9,6 +9,7 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -33,6 +34,7 @@ import { RulesService } from './rules.service';
imports: [ imports: [
AccessModule, AccessModule,
ApiModule, ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

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

@ -21,6 +21,7 @@ import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan'; import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -36,12 +37,13 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, AccountsResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
PortfolioDetails, PortfolioDetails,
PortfolioHoldingResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
@ -88,7 +90,6 @@ import { isEmpty, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
@ -101,6 +102,7 @@ export class PortfolioService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -141,7 +143,7 @@ export class PortfolioService {
} }
if (filterByDataSource && filterBySymbol) { if (filterByDataSource && filterBySymbol) {
where.Order = { where.activities = {
some: { some: {
SymbolProfile: { SymbolProfile: {
AND: [ AND: [
@ -156,7 +158,7 @@ export class PortfolioService {
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { Order: true, Platform: true }, include: { activities: true, Platform: true },
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
@ -172,8 +174,8 @@ export class PortfolioService {
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let transactionCount = 0;
for (const order of account.Order) { for (const { isDraft } of account.activities) {
if (!order.isDraft) { if (!isDraft) {
transactionCount += 1; transactionCount += 1;
} }
} }
@ -197,7 +199,7 @@ export class PortfolioService {
) )
}; };
delete result.Order; delete result.activities;
return result; return result;
}); });
@ -212,7 +214,7 @@ export class PortfolioService {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<Accounts> { }): Promise<AccountsResponse> {
const accounts = await this.getAccounts({ const accounts = await this.getAccounts({
filters, filters,
userId, userId,
@ -644,11 +646,11 @@ export class PortfolioService {
} }
@LogPerformance @LogPerformance
public async getPosition( public async getHolding(
aDataSource: DataSource, aDataSource: DataSource,
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioHoldingDetail> { ): Promise<PortfolioHoldingResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -661,6 +663,7 @@ export class PortfolioService {
if (activities.length === 0) { if (activities.length === 0) {
return { return {
activities: [],
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined, dataProviderInfo: undefined,
stakeRewards: undefined, stakeRewards: undefined,
@ -676,13 +679,13 @@ export class PortfolioService {
historicalData: [], historicalData: [],
investment: undefined, investment: undefined,
marketPrice: undefined, marketPrice: undefined,
maxPrice: undefined, marketPriceMax: undefined,
minPrice: undefined, marketPriceMin: undefined,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined, netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined,
orders: [], performances: undefined,
quantity: undefined, quantity: undefined,
SymbolProfile: undefined, SymbolProfile: undefined,
tags: [], tags: [],
@ -728,7 +731,7 @@ export class PortfolioService {
transactionCount transactionCount
} = position; } = position;
const activitiesOfPosition = activities.filter(({ SymbolProfile }) => { const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
return ( return (
SymbolProfile.dataSource === dataSource && SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol SymbolProfile.symbol === symbol
@ -772,8 +775,18 @@ export class PortfolioService {
); );
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice); let marketPriceMax = Math.max(
let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice); activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
let marketPriceMaxDate =
marketPrice > activitiesOfHolding[0].unitPriceInAssetProfileCurrency
? new Date()
: activitiesOfHolding[0].date;
let marketPriceMin = Math.min(
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
if (historicalData[aSymbol]) { if (historicalData[aSymbol]) {
let j = -1; let j = -1;
@ -811,27 +824,40 @@ export class PortfolioService {
quantity: currentQuantity quantity: currentQuantity
}); });
maxPrice = Math.max(marketPrice ?? 0, maxPrice); if (marketPrice > marketPriceMax) {
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); marketPriceMax = marketPrice;
marketPriceMaxDate = parseISO(date);
}
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
} }
} else { } else {
// Add historical entry for buy date, if no historical data available // Add historical entry for buy date, if no historical data available
historicalDataArray.push({ historicalDataArray.push({
averagePrice: activitiesOfPosition[0].unitPrice, averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
date: firstBuyDate, date: firstBuyDate,
marketPrice: activitiesOfPosition[0].unitPrice, marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfPosition[0].quantity quantity: activitiesOfHolding[0].quantity
}); });
} }
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
marketPriceMax,
marketPrice
);
return { return {
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
maxPrice, marketPriceMax,
minPrice, marketPriceMin,
SymbolProfile, SymbolProfile,
tags, tags,
transactionCount, transactionCount,
activities: activitiesOfHolding,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
stakeRewards: stakeRewards.toNumber(), stakeRewards: stakeRewards.toNumber(),
@ -861,7 +887,12 @@ export class PortfolioService {
]?.toNumber(), ]?.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
orders: activitiesOfPosition, performances: {
allTimeHigh: {
performancePercent,
date: marketPriceMaxDate
}
},
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(), quantity.mul(marketPrice ?? 0).toNumber(),
@ -900,8 +931,9 @@ export class PortfolioService {
} }
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice; let marketPriceMax = marketPrice;
let minPrice = marketPrice; let marketPriceMaxDate = new Date();
let marketPriceMin = marketPrice;
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol] historicalData[aSymbol]
@ -911,15 +943,28 @@ export class PortfolioService {
value: marketPrice value: marketPrice
}); });
maxPrice = Math.max(marketPrice ?? 0, maxPrice); if (marketPrice > marketPriceMax) {
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); marketPriceMax = marketPrice;
marketPriceMaxDate = parseISO(date);
}
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
} }
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
marketPriceMax,
marketPrice
);
return { return {
marketPrice, marketPrice,
maxPrice, marketPriceMax,
minPrice, marketPriceMin,
SymbolProfile, SymbolProfile,
activities: [],
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined, dataProviderInfo: undefined,
stakeRewards: 0, stakeRewards: 0,
@ -938,7 +983,12 @@ export class PortfolioService {
netPerformancePercent: undefined, netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined, netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined,
orders: [], performances: {
allTimeHigh: {
performancePercent,
date: marketPriceMaxDate
}
},
quantity: 0, quantity: 0,
tags: [], tags: [],
transactionCount: undefined, transactionCount: undefined,
@ -948,7 +998,7 @@ export class PortfolioService {
} }
@LogPerformance @LogPerformance
public async getPositions({ public async getHoldings({
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId impersonationId
@ -1214,7 +1264,7 @@ export class PortfolioService {
const rules: PortfolioReportResponse['rules'] = { const rules: PortfolioReportResponse['rules'] = {
accountClusterRisk: accountClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AccountClusterRiskCurrentInvestment( new AccountClusterRiskCurrentInvestment(
@ -1230,7 +1280,7 @@ export class PortfolioService {
) )
: undefined, : undefined,
assetClassClusterRisk: assetClassClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AssetClassClusterRiskEquity( new AssetClassClusterRiskEquity(
@ -1246,7 +1296,7 @@ export class PortfolioService {
) )
: undefined, : undefined,
currencyClusterRisk: currencyClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
@ -1262,7 +1312,7 @@ export class PortfolioService {
) )
: undefined, : undefined,
economicMarketClusterRisk: economicMarketClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new EconomicMarketClusterRiskDevelopedMarkets( new EconomicMarketClusterRiskDevelopedMarkets(
@ -1303,7 +1353,7 @@ export class PortfolioService {
userSettings userSettings
), ),
regionalMarketClusterRisk: regionalMarketClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new RegionalMarketClusterRiskAsiaPacific( new RegionalMarketClusterRiskAsiaPacific(
@ -1625,6 +1675,9 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
totalBuy, totalBuy,
totalSell, totalSell,
activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -1652,9 +1705,6 @@ export class PortfolioService {
interest: interest.toNumber(), interest: interest.toNumber(),
items: valuables.toNumber(), items: valuables.toNumber(),
liabilities: liabilities.toNumber(), liabilities: liabilities.toNumber(),
ordersCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };

16
apps/api/src/app/redis-cache/redis-cache.module.ts

@ -1,17 +1,16 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { createKeyv } from '@keyv/redis';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { redisStore } from 'cache-manager-redis-yet';
import type { RedisClientOptions } from 'redis';
import { RedisCacheService } from './redis-cache.service'; import { RedisCacheService } from './redis-cache.service';
@Module({ @Module({
exports: [RedisCacheService], exports: [RedisCacheService],
imports: [ imports: [
CacheModule.registerAsync<RedisClientOptions>({ CacheModule.registerAsync({
imports: [ConfigurationModule], imports: [ConfigurationModule],
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
@ -20,10 +19,13 @@ import { RedisCacheService } from './redis-cache.service';
); );
return { return {
store: redisStore, stores: [
ttl: configurationService.get('CACHE_TTL'), createKeyv(
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
} as RedisClientOptions; )
],
ttl: configurationService.get('CACHE_TTL')
};
} }
}), }),
ConfigurationModule ConfigurationModule

50
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -2,20 +2,18 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { Milliseconds } from 'cache-manager';
import { RedisCache } from 'cache-manager-redis-yet';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
public constructor( public constructor(
@Inject(CACHE_MANAGER) private readonly cache: RedisCache, @Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
const client = cache.store.client; const client = cache.stores[0];
client.on('error', (error) => { client.on('error', (error) => {
Logger.error(error, 'RedisCacheService'); Logger.error(error, 'RedisCacheService');
@ -27,13 +25,33 @@ export class RedisCacheService {
} }
public async getKeys(aPrefix?: string): Promise<string[]> { public async getKeys(aPrefix?: string): Promise<string[]> {
let prefix = aPrefix; const keys: string[] = [];
const prefix = aPrefix;
if (prefix) {
prefix = `${prefix}*`; this.cache.stores[0].deserialize = (value) => {
try {
return JSON.parse(value);
} catch (error: any) {
if (error instanceof SyntaxError) {
Logger.debug(
`Failed to parse json, returning the value as String: ${value}`,
'RedisCacheService'
);
return value;
} else {
throw error;
}
}
};
for await (const [key] of this.cache.stores[0].iterator({})) {
if ((prefix && key.startsWith(prefix)) || !prefix) {
keys.push(key);
}
} }
return this.cache.store.keys(prefix); return keys;
} }
public getPortfolioSnapshotKey({ public getPortfolioSnapshotKey({
@ -62,10 +80,8 @@ export class RedisCacheService {
public async isHealthy() { public async isHealthy() {
try { try {
const client = this.cache.store.client;
const isHealthy = await Promise.race([ const isHealthy = await Promise.race([
client.ping(), this.getKeys(),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check timeout')), () => reject(new Error('Redis health check timeout')),
@ -93,16 +109,14 @@ export class RedisCacheService {
`${this.getPortfolioSnapshotKey({ userId })}` `${this.getPortfolioSnapshotKey({ userId })}`
); );
for (const key of keys) { return this.cache.mdel(keys);
await this.remove(key);
}
} }
public async reset() { public async reset() {
return this.cache.reset(); return this.cache.clear();
} }
public async set(key: string, value: string, ttl?: Milliseconds) { public async set(key: string, value: string, ttl?: number) {
return this.cache.set( return this.cache.set(
key, key,
value, value,

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

@ -356,18 +356,20 @@ export class UserService {
new Date(), new Date(),
user.createdAt user.createdAt
); );
let frequency = 10; let frequency = 7;
if (daysSinceRegistration > 365) { if (daysSinceRegistration > 720) {
frequency = 1;
} else if (daysSinceRegistration > 360) {
frequency = 2; frequency = 2;
} else if (daysSinceRegistration > 180) { } else if (daysSinceRegistration > 180) {
frequency = 3; frequency = 3;
} else if (daysSinceRegistration > 60) { } else if (daysSinceRegistration > 60) {
frequency = 4; frequency = 4;
} else if (daysSinceRegistration > 30) { } else if (daysSinceRegistration > 30) {
frequency = 6; frequency = 5;
} else if (daysSinceRegistration > 15) { } else if (daysSinceRegistration > 15) {
frequency = 8; frequency = 6;
} }
if (Analytics?.activityCount % frequency === 1) { if (Analytics?.activityCount % frequency === 1) {
@ -380,6 +382,7 @@ export class UserService {
permissions.createAccess, permissions.createAccess,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag, permissions.createOwnTag,
permissions.createWatchlistItem,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile permissions.updateMarketDataOfOwnAssetProfile
@ -391,9 +394,11 @@ export class UserService {
// Reset holdings view mode // Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined; user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.createApiKey); if (!hasRole(user, Role.DEMO)) {
currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch);
}
currentPermissions = without( currentPermissions = without(
currentPermissions, currentPermissions,

2
apps/api/src/assets/sitemap.xml

@ -590,11 +590,9 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
--> -->
<!--
<url> <url>
<loc>https://ghostfol.io/zh</loc> <loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
-->
${personalFinanceTools} ${personalFinanceTools}
</urlset> </urlset>

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

@ -1515,6 +1515,7 @@ describe('redactAttributes', () => {
} }
}, },
summary: { summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786, annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null, cash: null,
@ -1538,7 +1539,6 @@ describe('redactAttributes', () => {
interest: null, interest: null,
items: null, items: null,
liabilities: null, liabilities: null,
ordersCount: 29,
totalInvestment: null, totalInvestment: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null
@ -3018,6 +3018,7 @@ describe('redactAttributes', () => {
} }
}, },
summary: { summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786, annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null, cash: null,
@ -3041,7 +3042,6 @@ describe('redactAttributes', () => {
interest: null, interest: null,
items: null, items: null,
liabilities: null, liabilities: null,
ordersCount: 29,
totalInvestment: null, totalInvestment: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null

23
apps/api/src/models/interfaces/portfolio.interface.ts

@ -1,23 +0,0 @@
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
import { Order } from '../order';
export interface PortfolioInterface {
get(aDate?: Date): PortfolioItem[];
getFees(): number;
getPositions(aDate: Date): {
[symbol: string]: Position;
};
getSymbols(aDate?: Date): string[];
getTotalBuy(): number;
getTotalSell(): number;
getOrders(): Order[];
getValue(aDate?: Date): number;
}

83
apps/api/src/models/order.ts

@ -1,83 +0,0 @@
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
export class Order {
private account: Account;
private currency: string;
private fee: number;
private date: string;
private id: string;
private isDraft: boolean;
private quantity: number;
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: ActivityType;
private unitPrice: number;
public constructor(data: IOrder) {
this.account = data.account;
this.currency = data.currency;
this.fee = data.fee;
this.date = data.date;
this.id = data.id || uuidv4();
this.isDraft = data.isDraft;
this.quantity = data.quantity;
this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
this.type = data.type;
this.unitPrice = data.unitPrice;
this.total = this.quantity * data.unitPrice;
}
public getAccount() {
return this.account;
}
public getCurrency() {
return this.currency;
}
public getDate() {
return this.date;
}
public getFee() {
return this.fee;
}
public getId() {
return this.id;
}
public getIsDraft() {
return this.isDraft;
}
public getQuantity() {
return this.quantity;
}
public getSymbol() {
return this.symbol;
}
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() {
return this.total;
}
public getType() {
return this.type;
}
public getUnitPrice() {
return this.unitPrice;
}
}

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

@ -212,6 +212,18 @@ export class BenchmarkService {
}; };
} }
public getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
}
private async calculateAndCacheBenchmarks({ private async calculateAndCacheBenchmarks({
enableSharing = false enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> { }): Promise<BenchmarkResponse['benchmarks']> {
@ -302,16 +314,4 @@ export class BenchmarkService {
return benchmarks; return benchmarks;
} }
private getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
}
} }

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

@ -52,6 +52,7 @@ export class AlphaVantageService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.ALPHA_VANTAGE,
isPremium: false, isPremium: false,
name: 'Alpha Vantage', name: 'Alpha Vantage',
url: 'https://www.alphavantage.co' url: 'https://www.alphavantage.co'

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

@ -92,6 +92,7 @@ export class CoinGeckoService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.COINGECKO,
isPremium: false, isPremium: false,
name: 'CoinGecko', name: 'CoinGecko',
url: 'https://coingecko.com' url: 'https://coingecko.com'

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

@ -18,11 +18,13 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2'; import YahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private readonly yahooFinance = new YahooFinance();
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -99,8 +101,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (response.dataSource === 'YAHOO') { if (response.dataSource === 'YAHOO') {
yahooSymbol = symbol; yahooSymbol = symbol;
} else { } else {
const { quotes } = await yahooFinance.search(response.isin); const { quotes } = await this.yahooFinance.search(response.isin);
yahooSymbol = quotes[0].symbol; yahooSymbol = quotes[0].symbol as string;
} }
const { countries, sectors, url } = const { countries, sectors, url } =
@ -165,10 +167,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (isISIN(symbol)) { if (isISIN(symbol)) {
try { try {
const { quotes } = await yahooFinance.search(symbol); const { quotes } = await this.yahooFinance.search(symbol);
if (quotes?.[0]?.symbol) { if (quotes?.[0]?.symbol) {
symbol = quotes[0].symbol; symbol = quotes[0].symbol as string;
} }
} catch {} } catch {}
} else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) { } else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) {
@ -177,7 +179,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
symbol = this.convertToYahooFinanceSymbol(symbol); symbol = this.convertToYahooFinanceSymbol(symbol);
} }
const assetProfile = await yahooFinance.quoteSummary(symbol, { const assetProfile = await this.yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings'] modules: ['price', 'summaryProfile', 'topHoldings']
}); });
@ -206,7 +208,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
for (const sectorWeighting of assetProfile.topHoldings for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) { ?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) { for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({ weight, name: this.parseSector(sector) }); response.sectors.push({
name: this.parseSector(sector),
weight: weight as number
});
} }
} }
} else if ( } else if (

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

@ -27,6 +27,7 @@ import {
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasRole } from '@ghostfolio/common/permissions';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -170,6 +171,7 @@ export class DataProviderService {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES'; let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
if ( if (
!hasRole(user, 'ADMIN') &&
isBefore(user.createdAt, new Date('2025-03-23')) && isBefore(user.createdAt, new Date('2025-03-23')) &&
this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0 this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0
) { ) {
@ -186,7 +188,7 @@ export class DataProviderService {
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
)) as string; )) as string;
if (ghostfolioApiKey) { if (ghostfolioApiKey || hasRole(user, 'ADMIN')) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -693,6 +695,7 @@ export class DataProviderService {
lookupItem.dataProviderInfo.isPremium = false; lookupItem.dataProviderInfo.isPremium = false;
} }
lookupItem.dataProviderInfo.dataSource = undefined;
lookupItem.dataProviderInfo.name = undefined; lookupItem.dataProviderInfo.name = undefined;
lookupItem.dataProviderInfo.url = undefined; lookupItem.dataProviderInfo.url = undefined;
} else { } else {

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

@ -68,6 +68,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.EOD_HISTORICAL_DATA,
isPremium: true, isPremium: true,
name: 'EOD Historical Data', name: 'EOD Historical Data',
url: 'https://eodhd.com' url: 'https://eodhd.com'

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

@ -223,6 +223,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.FINANCIAL_MODELING_PREP,
isPremium: true, isPremium: true,
name: 'Financial Modeling Prep', name: 'Financial Modeling Prep',
url: 'https://financialmodelingprep.com/developer/docs' url: 'https://financialmodelingprep.com/developer/docs'

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

@ -92,9 +92,10 @@ export class GhostfolioService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.GHOSTFOLIO,
isPremium: true, isPremium: true,
name: 'Ghostfolio', name: 'Ghostfolio',
url: 'https://ghostfo.io' url: 'https://ghostfol.io'
}; };
} }

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

@ -47,6 +47,7 @@ export class GoogleSheetsService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.GOOGLE_SHEETS,
isPremium: false, isPremium: false,
name: 'Google Sheets', name: 'Google Sheets',
url: 'https://docs.google.com/spreadsheets' url: 'https://docs.google.com/spreadsheets'

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

@ -65,6 +65,7 @@ export class ManualService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.MANUAL,
isPremium: false isPremium: false
}; };
} }

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

@ -43,6 +43,7 @@ export class RapidApiService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.RAPID_API,
isPremium: false, isPremium: false,
name: 'Rapid API', name: 'Rapid API',
url: 'https://rapidapi.com' url: 'https://rapidapi.com'

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

@ -24,16 +24,19 @@ import {
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2'; import YahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart'; import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart';
import { import {
HistoricalDividendsResult, HistoricalDividendsResult,
HistoricalHistoryResult HistoricalHistoryResult
} from 'yahoo-finance2/dist/esm/src/modules/historical'; } from 'yahoo-finance2/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote'; import { Quote } from 'yahoo-finance2/esm/src/modules/quote';
import { SearchQuoteNonYahoo } from 'yahoo-finance2/script/src/modules/search';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinance = new YahooFinance();
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
@ -51,6 +54,7 @@ export class YahooFinanceService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.YAHOO,
isPremium: false, isPremium: false,
name: 'Yahoo Finance', name: 'Yahoo Finance',
url: 'https://finance.yahoo.com' url: 'https://finance.yahoo.com'
@ -69,7 +73,7 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
const historicalResult = this.convertToDividendResult( const historicalResult = this.convertToDividendResult(
await yahooFinance.chart( await this.yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol symbol
), ),
@ -118,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
const historicalResult = this.convertToHistoricalResult( const historicalResult = this.convertToHistoricalResult(
await yahooFinance.chart( await this.yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol symbol
), ),
@ -187,7 +191,7 @@ export class YahooFinanceService implements DataProviderInterface {
>[] = []; >[] = [];
try { try {
quotes = await yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); Logger.error(error, 'YahooFinanceService');
@ -243,13 +247,15 @@ export class YahooFinanceService implements DataProviderInterface {
quoteTypes.push('INDEX'); quoteTypes.push('INDEX');
} }
const searchResult = await yahooFinance.search(query); const searchResult = await this.yahooFinance.search(query);
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter(
// Filter out undefined symbols (quote): quote is Exclude<typeof quote, SearchQuoteNonYahoo> => {
return quote.symbol; // Filter out undefined symbols
}) return !!quote.symbol;
}
)
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
return ( return (
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
@ -275,7 +281,7 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
}); });
const marketData = await yahooFinance.quote( const marketData = await this.yahooFinance.quote(
quotes.map(({ symbol }) => { quotes.map(({ symbol }) => {
return symbol; return symbol;
}) })
@ -335,7 +341,7 @@ export class YahooFinanceService implements DataProviderInterface {
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return yahooFinance.quoteSummary(symbol).catch(() => { return this.yahooFinance.quoteSummary(symbol).catch(() => {
Logger.error( Logger.error(
`Could not get quote summary for ${symbol}`, `Could not get quote summary for ${symbol}`,
'YahooFinanceService' 'YahooFinanceService'

21
apps/api/src/services/interfaces/interfaces.ts

@ -4,26 +4,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { import { DataSource } from '@prisma/client';
Account,
DataSource,
SymbolProfile,
Type as ActivityType
} from '@prisma/client';
export interface IOrder {
account: Account;
currency: string;
date: string;
fee: number;
id?: string;
isDraft: boolean;
quantity: number;
symbol: string;
symbolProfile: SymbolProfile;
type: ActivityType;
unitPrice: number;
}
export interface IDataProviderHistoricalResponse { export interface IDataProviderHistoricalResponse {
marketPrice: number; marketPrice: number;

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

@ -482,13 +482,13 @@ export class DataGatheringService {
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { select: {
dataSource: true, activities: {
id: true,
Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
take: 1 take: 1
}, },
dataSource: true,
id: true,
scraperConfiguration: true, scraperConfiguration: true,
symbol: true symbol: true
}, },
@ -508,7 +508,7 @@ export class DataGatheringService {
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
let date = symbolProfile.Order?.[0]?.date ?? startDate; let date = symbolProfile.activities?.[0]?.date ?? startDate;
if (benchmarkAssetProfileIdMap[symbolProfile.id]) { if (benchmarkAssetProfileIdMap[symbolProfile.id]) {
date = this.getEarliestDate(startDate); date = this.getEarliestDate(startDate);

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

@ -31,7 +31,7 @@ export class SymbolProfileService {
}) { }) {
return this.prismaService.symbolProfile.findMany({ return this.prismaService.symbolProfile.findMany({
include: { include: {
Order: { activities: {
include: { include: {
User: true User: true
} }
@ -39,8 +39,7 @@ export class SymbolProfileService {
}, },
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
where: { where: {
isActive: true, activities: withUserSubscription
Order: withUserSubscription
? { ? {
some: { some: {
User: { User: {
@ -54,7 +53,8 @@ export class SymbolProfileService {
subscriptions: { none: { expiresAt: { gt: new Date() } } } subscriptions: { none: { expiresAt: { gt: new Date() } } }
} }
} }
} },
isActive: true
} }
}); });
} }
@ -67,9 +67,9 @@ export class SymbolProfileService {
.findMany({ .findMany({
include: { include: {
_count: { _count: {
select: { Order: true } select: { activities: true }
}, },
Order: { activities: {
orderBy: { orderBy: {
date: 'asc' date: 'asc'
}, },
@ -118,7 +118,7 @@ export class SymbolProfileService {
.findMany({ .findMany({
include: { include: {
_count: { _count: {
select: { Order: true } select: { activities: true }
}, },
SymbolProfileOverrides: true, SymbolProfileOverrides: true,
tags: true tags: true
@ -196,8 +196,8 @@ export class SymbolProfileService {
private enhanceSymbolProfiles( private enhanceSymbolProfiles(
symbolProfiles: (SymbolProfile & { symbolProfiles: (SymbolProfile & {
_count: { Order: number }; _count: { activities: number };
Order?: { activities?: {
date: Date; date: Date;
}[]; }[];
tags?: Tag[]; tags?: Tag[];
@ -223,11 +223,11 @@ export class SymbolProfileService {
tags: symbolProfile?.tags tags: symbolProfile?.tags
}; };
item.activitiesCount = symbolProfile._count.Order; item.activitiesCount = symbolProfile._count.activities;
delete item._count; delete item._count;
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date; item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.Order; delete item.activities;
if (item.SymbolProfileOverrides) { if (item.SymbolProfileOverrides) {
item.assetClass = item.assetClass =

16
apps/api/src/validators/is-currency-code.ts

@ -1,4 +1,4 @@
import { DERIVED_CURRENCIES } from '@ghostfolio/common/config'; import { isDerivedCurrency } from '@ghostfolio/common/helper';
import { import {
registerDecorator, registerDecorator,
@ -28,17 +28,11 @@ export class IsExtendedCurrencyConstraint
return '$property must be a valid ISO4217 currency code'; return '$property must be a valid ISO4217 currency code';
} }
public validate(currency: any) { public validate(currency: string) {
// Return true if currency is a standard ISO 4217 code or a derived currency // Return true if currency is a derived currency or a standard ISO 4217 code
return ( return (
this.isUpperCase(currency) && isDerivedCurrency(currency) ||
(isISO4217CurrencyCode(currency) || (this.isUpperCase(currency) && isISO4217CurrencyCode(currency))
[
...DERIVED_CURRENCIES.map((derivedCurrency) => {
return derivedCurrency.currency;
}),
'USX'
].includes(currency))
); );
} }

8
apps/client/src/app/app.component.html

@ -171,6 +171,9 @@
<a href="../ca" title="Ghostfolio en català">Català</a> <a href="../ca" title="Ghostfolio en català">Català</a>
</li> </li>
--> -->
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
</li>
<li> <li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a> <a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li> </li>
@ -203,11 +206,6 @@
<a href="../uk" title="Ghostfolio in Українська">Українська</a> <a href="../uk" title="Ghostfolio in Українська">Українська</a>
</li> </li>
--> -->
<!--
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
</li>
-->
</ul> </ul>
</div> </div>
</div> </div>

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

@ -55,7 +55,8 @@
adminMarketDataService.hasPermissionToDeleteAssetProfile({ adminMarketDataService.hasPermissionToDeleteAssetProfile({
activitiesCount: element.activitiesCount, activitiesCount: element.activitiesCount,
isBenchmark: element.isBenchmark, isBenchmark: element.isBenchmark,
symbol: element.symbol symbol: element.symbol,
watchedByCount: element.watchedByCount
}) })
) { ) {
<mat-checkbox <mat-checkbox

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

@ -72,14 +72,19 @@ export class AdminMarketDataService {
public hasPermissionToDeleteAssetProfile({ public hasPermissionToDeleteAssetProfile({
activitiesCount, activitiesCount,
isBenchmark, isBenchmark,
symbol symbol,
}: Pick<AdminMarketDataItem, 'activitiesCount' | 'isBenchmark' | 'symbol'>) { watchedByCount
}: Pick<
AdminMarketDataItem,
'activitiesCount' | 'isBenchmark' | 'symbol' | 'watchedByCount'
>) {
return ( return (
activitiesCount === 0 && activitiesCount === 0 &&
!isBenchmark && !isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) && !isRootCurrency(getCurrencyFromSymbol(symbol)) &&
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix) !symbol.startsWith(ghostfolioScraperApiSymbolPrefix) &&
watchedByCount === 0
); );
} }
} }

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

@ -270,20 +270,20 @@
<div class="h5" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="sectors"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
[positions]="sectors"
/> />
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div> <div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="countries"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
[positions]="countries"
/> />
</div> </div>
} }

201
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -1,115 +1,94 @@
<div class="container"> <div class="d-flex justify-content-end">
<div class="row"> <a
<div class="col"> color="primary"
<div class="d-flex justify-content-end"> i18n
<a mat-flat-button
color="primary" [queryParams]="{ createPlatformDialog: true }"
i18n [routerLink]="[]"
mat-flat-button >
[queryParams]="{ createPlatformDialog: true }" Add Platform
[routerLink]="[]" </a>
> </div>
Add Platform <table
</a> class="gf-table w-100"
</div> mat-table
<table matSort
class="gf-table w-100" matSortActive="name"
mat-table matSortDirection="asc"
matSort [dataSource]="dataSource"
matSortActive="name" >
matSortDirection="asc" <ng-container matColumnDef="name">
[dataSource]="dataSource" <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
> <ng-container i18n>Name</ng-container>
<ng-container matColumnDef="name"> </th>
<th <td *matCellDef="let element" class="px-1" mat-cell>
*matHeaderCellDef @if (element.url) {
class="px-1" <gf-asset-profile-icon
mat-header-cell class="d-inline mr-1"
mat-sort-header="name" [tooltip]="element.name"
> [url]="element.url"
<ng-container i18n>Name</ng-container> />
</th> }
<td *matCellDef="let element" class="px-1" mat-cell> <span>{{ element.name }}</span>
@if (element.url) { </td></ng-container
<gf-asset-profile-icon >
class="d-inline mr-1"
[tooltip]="element.name"
[url]="element.url"
/>
}
<span>{{ element.name }}</span>
</td></ng-container
>
<ng-container matColumnDef="url"> <ng-container matColumnDef="url">
<th <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="url">
*matHeaderCellDef <ng-container i18n>Url</ng-container>
class="px-1" </th>
mat-header-cell <td *matCellDef="let element" class="px-1" mat-cell>
mat-sort-header="url" {{ element.url }}
> </td>
<ng-container i18n>Url</ng-container> </ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.url }}
</td>
</ng-container>
<ng-container matColumnDef="accounts"> <ng-container matColumnDef="accounts">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1" class="px-1"
mat-header-cell mat-header-cell
mat-sort-header="accountCount" mat-sort-header="accountCount"
> >
<ng-container i18n>Accounts</ng-container> <ng-container i18n>Accounts</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.accountCount }} {{ element.accountCount }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
*matHeaderCellDef <td *matCellDef="let element" class="px-1 text-center" mat-cell>
class="px-1 text-center" <button
i18n class="mx-1 no-min-width px-2"
mat-header-cell mat-button
></th> [matMenuTriggerFor]="platformMenu"
<td *matCellDef="let element" class="px-1 text-center" mat-cell> (click)="$event.stopPropagation()"
<button >
class="mx-1 no-min-width px-2" <ion-icon name="ellipsis-horizontal" />
mat-button </button>
[matMenuTriggerFor]="platformMenu" <mat-menu #platformMenu="matMenu" xPosition="before">
(click)="$event.stopPropagation()" <button mat-menu-item (click)="onUpdatePlatform(element)">
> <span class="align-items-center d-flex">
<ion-icon name="ellipsis-horizontal" /> <ion-icon class="mr-2" name="create-outline" />
</button> <span i18n>Edit</span>
<mat-menu #platformMenu="matMenu" xPosition="before"> </span>
<button mat-menu-item (click)="onUpdatePlatform(element)"> </button>
<span class="align-items-center d-flex"> <hr class="m-0" />
<ion-icon class="mr-2" name="create-outline" /> <button
<span i18n>Edit</span> mat-menu-item
</span> [disabled]="element.accountCount > 0"
</button> (click)="onDeletePlatform(element.id)"
<hr class="m-0" /> >
<button <span class="align-items-center d-flex">
mat-menu-item <ion-icon class="mr-2" name="trash-outline" />
[disabled]="element.accountCount > 0" <span i18n>Delete</span>
(click)="onDeletePlatform(element.id)" </span>
> </button>
<span class="align-items-center d-flex"> </mat-menu>
<ion-icon class="mr-2" name="trash-outline" /> </td>
<span i18n>Delete</span> </ng-container>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
</div>
</div>
</div>

170
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -1,65 +1,108 @@
<div class="container"> <div class="container">
<div class="d-md-block d-none mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Data Providers</h2> <h2 class="text-center" i18n>Data Providers</h2>
<mat-card appearance="outlined"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<mat-card-content> <ng-container matColumnDef="name">
<div class="align-items-center d-flex my-3"> <th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<div class="w-50"> <ng-container i18n>Name</ng-container>
<a </th>
class="align-items-center d-inline-flex" <td *matCellDef="let element" class="px-1 py-2" mat-cell>
target="_blank" <div class="d-flex align-items-center">
[href]="pricingUrl" <gf-asset-profile-icon class="mr-1" [url]="element.url" />
> <div>
@if (isGhostfolioApiKeyValid === false) { @if (isGhostfolioDataProvider(element)) {
<span class="badge badge-warning mr-1" i18n <a
>Early Access</span class="align-items-center d-inline-flex"
target="_blank"
[href]="pricingUrl"
> >
Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/>
@if (isGhostfolioApiKeyValid === false) {
<span class="badge badge-warning ml-2" i18n
>Early Access</span
>
}
</a>
@if (isGhostfolioApiKeyValid === true) {
<div class="line-height-1">
<small class="text-muted">
<ng-container i18n>Valid until</ng-container>
{{
ghostfolioApiStatus?.subscription?.expiresAt
| date: defaultDateFormat
}}
</small>
</div>
}
} @else {
{{ element.name }}
} }
Ghostfolio Premium </div>
<gf-premium-indicator </div>
class="d-inline-block ml-1" </td>
[enableLink]="false" </ng-container>
/>
</a> <ng-container matColumnDef="assetProfileCount">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<ng-container i18n>Asset Profiles</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.assetProfileCount }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
@if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) { @if (isGhostfolioApiKeyValid === true) {
<div class="line-height-1"> <mat-progress-bar
<small class="text-muted"> mode="determinate"
<ng-container i18n>Valid until</ng-container> [value]="
{{ 100 -
ghostfolioApiStatus?.subscription?.expiresAt (ghostfolioApiStatus.dailyRequests /
| date: defaultDateFormat ghostfolioApiStatus.dailyRequestsMax) *
}}</small 100
> "
</div> />
<small class="text-muted">
{{ ghostfolioApiStatus.dailyRequests }}
<ng-container i18n>of</ng-container>
{{ ghostfolioApiStatus.dailyRequestsMax }}
<ng-container i18n>daily requests</ng-container>
</small>
} }
</div> }
<div class="w-50"> </td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
@if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) { @if (isGhostfolioApiKeyValid === true) {
<div class="align-items-center d-flex flex-wrap"> <button
<div class="flex-grow-1 mr-3"> class="mx-1 no-min-width px-2"
{{ ghostfolioApiStatus.dailyRequests }} mat-button
<ng-container i18n>of</ng-container> [matMenuTriggerFor]="ghostfolioApiMenu"
{{ ghostfolioApiStatus.dailyRequestsMax }} (click)="$event.stopPropagation()"
<ng-container i18n>daily requests</ng-container> >
</div> <ion-icon name="ellipsis-horizontal" />
<button </button>
class="mx-1 no-min-width px-2" <mat-menu #ghostfolioApiMenu="matMenu" xPosition="before">
mat-button <button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
[matMenuTriggerFor]="ghostfolioApiMenu" <span class="align-items-center d-flex">
(click)="$event.stopPropagation()" <ion-icon class="mr-2" name="trash-outline" />
> <span i18n>Remove API key</span>
<ion-icon name="ellipsis-horizontal" /> </span>
</button> </button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before"> </mat-menu>
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Remove API key</span>
</span>
</button>
</mat-menu>
</div>
} @else if (isGhostfolioApiKeyValid === false) { } @else if (isGhostfolioApiKeyValid === false) {
<button <button
color="accent" color="accent"
@ -70,10 +113,23 @@
<span i18n>Set API key</span> <span i18n>Set API key</span>
</button> </button>
} }
</div> }
</div> </td>
</mat-card-content> </ng-container>
</mat-card>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div> </div>
</div> </div>
<div class="mb-5 row"> <div class="mb-5 row">

12
apps/client/src/app/components/admin-settings/admin-settings.component.scss

@ -1,3 +1,15 @@
:host { :host {
display: block; display: block;
.mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-height: 0.5rem;
--mdc-linear-progress-track-height: 0.5rem;
border-radius: 0.25rem;
::ng-deep {
.mdc-linear-progress__buffer-bar {
background-color: rgb(var(--palette-background-unselected-chip));
}
}
}
} }

68
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -10,6 +10,7 @@ import {
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DataProviderInfo,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -21,10 +22,12 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatTableDataSource } from '@angular/material/table';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, filter, of, Subject, takeUntil } from 'rxjs'; import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component'; import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -34,9 +37,12 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-
standalone: false standalone: false
}) })
export class AdminSettingsComponent implements OnDestroy, OnInit { export class AdminSettingsComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<DataProviderInfo>();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns = ['name', 'assetProfileCount', 'status', 'actions'];
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean; public isGhostfolioApiKeyValid: boolean;
public isLoading = false;
public pricingUrl: string; public pricingUrl: string;
private deviceType: string; private deviceType: string;
@ -80,6 +86,10 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.initialize(); this.initialize();
} }
public isGhostfolioDataProvider(provider: DataProviderInfo): boolean {
return provider.dataSource === 'GHOSTFOLIO';
}
public onRemoveGhostfolioApiKey() { public onRemoveGhostfolioApiKey() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
@ -101,9 +111,8 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType, deviceType: this.deviceType,
pricingUrl: this.pricingUrl, pricingUrl: this.pricingUrl
user: this.user } as GhostfolioPremiumApiDialogParams,
},
height: this.deviceType === 'mobile' ? '98vh' : undefined, height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
} }
@ -123,24 +132,45 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
} }
private initialize() { private initialize() {
this.adminService this.isLoading = true;
.fetchGhostfolioDataProviderStatus()
.pipe(
catchError(() => {
this.isGhostfolioApiKeyValid = false;
this.changeDetectorRef.markForCheck(); this.dataSource = new MatTableDataSource();
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataProviders, settings }) => {
const filteredProviders = dataProviders.filter(({ dataSource }) => {
return dataSource !== 'MANUAL';
});
this.dataSource = new MatTableDataSource(filteredProviders);
this.adminService
.fetchGhostfolioDataProviderStatus(
settings[PROPERTY_API_KEY_GHOSTFOLIO] as string
)
.pipe(
catchError(() => {
this.isGhostfolioApiKeyValid = false;
this.changeDetectorRef.markForCheck();
return of(null);
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck();
});
return of(null); this.isLoading = false;
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

10
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -1,13 +1,16 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module'; import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminSettingsComponent } from './admin-settings.component'; import { AdminSettingsComponent } from './admin-settings.component';
@ -17,10 +20,13 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule, CommonModule,
GfAdminPlatformModule, GfAdminPlatformModule,
GfAdminTagModule, GfAdminTagModule,
GfAssetProfileIconComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
MatButtonModule, MatButtonModule,
MatCardModule,
MatMenuModule, MatMenuModule,
MatProgressBarModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

37
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html

@ -7,8 +7,8 @@
/> />
<div class="text-center" mat-dialog-content> <div class="text-center" mat-dialog-content>
<p class="gf-text-wrap-balance mb-1"> <p class="gf-text-wrap-balance">
The official Early access to the official
<a <a
class="align-items-center d-inline-flex" class="align-items-center d-inline-flex"
target="_blank" target="_blank"
@ -18,32 +18,27 @@
</a> </a>
data provider <strong>for self-hosters</strong>, offering data provider <strong>for self-hosters</strong>, offering
<strong>80’000+ tickers</strong> from over <strong>50 exchanges</strong>, is <strong>80’000+ tickers</strong> from over <strong>50 exchanges</strong>, is
coming soon! ready now!
</p>
<p i18n>
Want to stay updated? Click below to get notified as soon as it’s available.
</p> </p>
<div> <div>
<a <a
color="primary" color="primary"
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards" href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DI am interested in the Ghostfolio Premium data provider. Could you please give me early access so I can try it for some time?%0D%0DKind regards"
i18n i18n
mat-flat-button mat-flat-button
>Notify me</a >Get Early Access</a
>
<div>
<small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
> >
@if (data.user?.settings?.isExperimentalFeatures) { I have an API key
<div> </button>
<small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
>
I have an API key
</button>
}
</div> </div>
</div> </div>

3
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts

@ -1,7 +1,4 @@
import { User } from '@ghostfolio/common/interfaces';
export interface GhostfolioPremiumApiDialogParams { export interface GhostfolioPremiumApiDialogParams {
deviceType: string; deviceType: string;
pricingUrl: string; pricingUrl: string;
user: User;
} }

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

@ -1,121 +1,100 @@
<div class="container"> <div class="d-flex justify-content-end">
<div class="row"> <a
<div class="col"> color="primary"
<div class="d-flex justify-content-end"> i18n
<a mat-flat-button
color="primary" [queryParams]="{ createTagDialog: true }"
i18n [routerLink]="[]"
mat-flat-button >
[queryParams]="{ createTagDialog: true }" Add Tag
[routerLink]="[]" </a>
> </div>
Add Tag <table
</a> class="gf-table w-100"
</div> mat-table
<table matSort
class="gf-table w-100" matSortActive="name"
mat-table matSortDirection="asc"
matSort [dataSource]="dataSource"
matSortActive="name" >
matSortDirection="asc" <ng-container matColumnDef="name">
[dataSource]="dataSource" <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
> <ng-container i18n>Name</ng-container>
<ng-container matColumnDef="name"> </th>
<th <td *matCellDef="let element" class="px-1" mat-cell>
*matHeaderCellDef {{ element.name }}
class="px-1" </td>
mat-header-cell </ng-container>
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="userId"> <ng-container matColumnDef="userId">
<th <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="userId">
*matHeaderCellDef <ng-container i18n>User</ng-container>
class="px-1" </th>
mat-header-cell <td *matCellDef="let element" class="px-1" mat-cell>
mat-sort-header="userId" <span class="text-monospace">{{ element.userId }}</span>
> </td>
<ng-container i18n>User</ng-container> </ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<span class="text-monospace">{{ element.userId }}</span>
</td>
</ng-container>
<ng-container matColumnDef="activities"> <ng-container matColumnDef="activities">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1" class="px-1"
mat-header-cell mat-header-cell
mat-sort-header="activityCount" mat-sort-header="activityCount"
> >
<ng-container i18n>Activities</ng-container> <ng-container i18n>Activities</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }} {{ element.activityCount }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="holdings"> <ng-container matColumnDef="holdings">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1" class="px-1"
mat-header-cell mat-header-cell
mat-sort-header="holdingCount" mat-sort-header="holdingCount"
> >
<ng-container i18n>Holdings</ng-container> <ng-container i18n>Holdings</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.holdingCount }} {{ element.holdingCount }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
*matHeaderCellDef <td *matCellDef="let element" class="px-1 text-center" mat-cell>
class="px-1 text-center" <button
i18n class="mx-1 no-min-width px-2"
mat-header-cell mat-button
></th> [matMenuTriggerFor]="tagMenu"
<td *matCellDef="let element" class="px-1 text-center" mat-cell> (click)="$event.stopPropagation()"
<button >
class="mx-1 no-min-width px-2" <ion-icon name="ellipsis-horizontal" />
mat-button </button>
[matMenuTriggerFor]="tagMenu" <mat-menu #tagMenu="matMenu" xPosition="before">
(click)="$event.stopPropagation()" <button mat-menu-item (click)="onUpdateTag(element)">
> <span class="align-items-center d-flex">
<ion-icon name="ellipsis-horizontal" /> <ion-icon class="mr-2" name="create-outline" />
</button> <span i18n>Edit</span>
<mat-menu #tagMenu="matMenu" xPosition="before"> </span>
<button mat-menu-item (click)="onUpdateTag(element)"> </button>
<span class="align-items-center d-flex"> <hr class="m-0" />
<ion-icon class="mr-2" name="create-outline" /> <button
<span i18n>Edit</span> mat-menu-item
</span> [disabled]="element.activityCount > 0"
</button> (click)="onDeleteTag(element.id)"
<hr class="m-0" /> >
<button <span class="align-items-center d-flex">
mat-menu-item <ion-icon class="mr-2" name="trash-outline" />
[disabled]="element.activityCount > 0" <span i18n>Delete</span>
(click)="onDeleteTag(element.id)" </span>
> </button>
<span class="align-items-center d-flex"> </mat-menu>
<ion-icon class="mr-2" name="trash-outline" /> </td>
<span i18n>Delete</span> </ng-container>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
</div>
</div>
</div>

2
apps/client/src/app/components/admin-users/admin-users.html

@ -142,7 +142,7 @@
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="element.transactionCount" [value]="element.activityCount"
/> />
</td> </td>
</ng-container> </ng-container>

2
apps/client/src/app/components/header/header.component.html

@ -312,7 +312,7 @@
>About Ghostfolio</a >About Ghostfolio</a
> >
<hr class="d-flex d-sm-none m-0" /> <hr class="d-flex d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button> <button i18n mat-menu-item (click)="onSignOut()">Log out</button>
</mat-menu> </mat-menu>
</li> </li>
</ul> </ul>

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

@ -106,8 +106,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public investmentPrecision = 2; public investmentPrecision = 2;
public marketDataItems: MarketData[] = []; public marketDataItems: MarketData[] = [];
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public marketPriceMax: number;
public minPrice: number; public marketPriceMin: number;
public netPerformance: number; public netPerformance: number;
public netPerformancePrecision = 2; public netPerformancePrecision = 2;
public netPerformancePercent: number; public netPerformancePercent: number;
@ -233,8 +233,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
stakeRewards, stakeRewards,
investment, investment,
marketPrice, marketPrice,
maxPrice, marketPriceMax,
minPrice, marketPriceMin,
netPerformance, netPerformance,
netPerformancePercent, netPerformancePercent,
netPerformancePercentWithCurrencyEffect, netPerformancePercentWithCurrencyEffect,
@ -301,8 +301,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.marketPriceMax = marketPriceMax;
this.minPrice = minPrice; this.marketPriceMin = marketPriceMin;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
if ( if (

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

@ -106,11 +106,11 @@
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ [ngClass]="{
'text-danger': 'text-danger':
minPrice?.toFixed(2) === marketPrice?.toFixed(2) && marketPriceMin?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2) marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2)
}" }"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="minPrice" [value]="marketPriceMin"
>Minimum Price</gf-value >Minimum Price</gf-value
> >
</div> </div>
@ -122,11 +122,11 @@
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ [ngClass]="{
'text-success': 'text-success':
maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && marketPriceMax?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2) marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2)
}" }"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="maxPrice" [value]="marketPriceMax"
>Maximum Price</gf-value >Maximum Price</gf-value
> >
</div> </div>
@ -263,11 +263,11 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="sectors"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
[positions]="sectors"
/> />
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
@ -275,11 +275,11 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[data]="countries"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
[positions]="countries"
/> />
</div> </div>
} }

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

@ -36,6 +36,14 @@
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale || undefined"
[user]="user" [user]="user"
/> />
@if (benchmarks?.length > 0) {
<div class="mt-3 text-center">
<small class="text-muted" i18n>
Calculations are based on delayed market data and may not be
displayed in real-time.</small
>
</div>
}
</div> </div>
</div> </div>
</div> </div>

3
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

92
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts

@ -0,0 +1,92 @@
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { Subject } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
imports: [
CommonModule,
FormsModule,
GfSymbolAutocompleteComponent,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
ReactiveFormsModule
],
selector: 'gf-create-watchlist-item-dialog',
styleUrls: ['./create-watchlist-item-dialog.component.scss'],
templateUrl: 'create-watchlist-item-dialog.html'
})
export class CreateWatchlistItemDialogComponent implements OnInit, OnDestroy {
public createWatchlistItemForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly dialogRef: MatDialogRef<CreateWatchlistItemDialogComponent>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createWatchlistItemForm = this.formBuilder.group(
{
searchSymbol: new FormControl(null, [Validators.required])
},
{
validators: this.validator
}
);
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
this.dialogRef.close({
dataSource:
this.createWatchlistItemForm.get('searchSymbol').value.dataSource,
symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private validator(control: AbstractControl): ValidationErrors {
const searchSymbolControl = control.get('searchSymbol');
if (
searchSymbolControl.valid &&
searchSymbolControl.value.dataSource &&
searchSymbolControl.value.symbol
) {
return { incomplete: false };
}
return { incomplete: true };
}
}

25
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html

@ -0,0 +1,25 @@
<form
class="d-flex flex-column h-100"
[formGroup]="createWatchlistItemForm"
(keyup.enter)="createWatchlistItemForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Add asset to watchlist</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete formControlName="searchSymbol" />
</mat-form-field>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="createWatchlistItemForm.hasError('incomplete')"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

4
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts

@ -0,0 +1,4 @@
export interface CreateWatchlistItemDialogParams {
deviceType: string;
locale: string;
}

185
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -0,0 +1,185 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
AssetProfileIdentifier,
Benchmark,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
OnInit
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component';
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfBenchmarkComponent,
GfPremiumIndicatorComponent,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-watchlist',
styleUrls: ['./home-watchlist.scss'],
templateUrl: './home-watchlist.html'
})
export class HomeWatchlistComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateWatchlistItem: boolean;
public hasPermissionToDeleteWatchlistItem: boolean;
public user: User;
public watchlist: Benchmark[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createWatchlistItemDialog']) {
this.openCreateWatchlistItemDialog();
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateWatchlistItem =
!this.hasImpersonationId &&
hasPermission(
this.user.permissions,
permissions.createWatchlistItem
);
this.hasPermissionToDeleteWatchlistItem =
!this.hasImpersonationId &&
hasPermission(
this.user.permissions,
permissions.deleteWatchlistItem
);
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {
this.loadWatchlistData();
}
public onWatchlistItemDeleted({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.dataService
.deleteWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
return this.loadWatchlistData();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadWatchlistData() {
this.dataService
.fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ watchlist }) => {
this.watchlist = watchlist.map(
({ dataSource, marketCondition, name, performances, symbol }) => ({
dataSource,
marketCondition,
name,
performances,
symbol,
trend50d: 'UNKNOWN' as BenchmarkTrend,
trend200d: 'UNKNOWN' as BenchmarkTrend
})
);
this.changeDetectorRef.markForCheck();
});
}
private openCreateWatchlistItemDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(CreateWatchlistItemDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType,
locale: this.user?.settings?.locale
} as CreateWatchlistItemDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.dataService
.postWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => this.loadWatchlistData()
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

35
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -0,0 +1,35 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-4">
<span class="align-items-center d-flex justify-content-center">
<span i18n>Watchlist</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</span>
</h1>
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
[benchmarks]="watchlist"
[deviceType]="deviceType"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined"
[user]="user"
(itemDeleted)="onWatchlistItemDeleted($event)"
/>
</div>
</div>
</div>
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createWatchlistItemDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
}

3
apps/client/src/app/components/home-watchlist/home-watchlist.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

6
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -7,11 +7,11 @@
</div> </div>
<div <div
class="flex-nowrap px-3 py-1 row" class="flex-nowrap px-3 py-1 row"
[hidden]="summary?.ordersCount === null" [hidden]="summary?.activityCount === null"
> >
<div class="d-flex flex-grow-1 ml-3 text-truncate"> <div class="d-flex flex-grow-1 ml-3 text-truncate">
{{ summary?.ordersCount }} {{ summary?.activityCount }}
<ng-container i18n>{summary?.ordersCount, plural, <ng-container i18n>{summary?.activityCount, plural,
=1 {activity} =1 {activity}
other {activities} other {activities}
}</ng-container> }</ng-container>

44
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts

@ -1,5 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import ms from 'ms';
import { interval, Subject } from 'rxjs';
import { take, takeUntil, tap } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces'; import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
@ -11,20 +20,47 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
templateUrl: 'subscription-interstitial-dialog.html', templateUrl: 'subscription-interstitial-dialog.html',
standalone: false standalone: false
}) })
export class SubscriptionInterstitialDialog { export class SubscriptionInterstitialDialog implements OnInit {
private readonly VARIANTS_COUNT = 2; private static readonly SKIP_BUTTON_DELAY_IN_SECONDS = 5;
private static readonly VARIANTS_COUNT = 2;
public remainingSkipButtonDelay =
SubscriptionInterstitialDialog.SKIP_BUTTON_DELAY_IN_SECONDS;
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public variantIndex: number; public variantIndex: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams, @Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog> public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>
) { ) {
this.variantIndex = Math.floor(Math.random() * this.VARIANTS_COUNT); this.variantIndex = Math.floor(
Math.random() * SubscriptionInterstitialDialog.VARIANTS_COUNT
);
}
public ngOnInit() {
interval(ms('1 second'))
.pipe(
take(SubscriptionInterstitialDialog.SKIP_BUTTON_DELAY_IN_SECONDS),
tap(() => {
this.remainingSkipButtonDelay--;
this.changeDetectorRef.markForCheck();
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe();
} }
public closeDialog() { public closeDialog() {
this.dialogRef.close({}); this.dialogRef.close({});
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

23
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html

@ -51,7 +51,16 @@
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="closeDialog()">Skip</button> <button
mat-button
[disabled]="remainingSkipButtonDelay > 0"
(click)="closeDialog()"
>
<ng-container i18n>Skip</ng-container>
@if (remainingSkipButtonDelay > 0) {
({{ remainingSkipButtonDelay }})
}
</button>
<a <a
color="primary" color="primary"
mat-flat-button mat-flat-button
@ -80,8 +89,16 @@
</div> </div>
</div> </div>
<div class="flex-column" mat-dialog-actions> <div class="flex-column" mat-dialog-actions>
<button class="mb-2 py-4 w-100" i18n mat-button (click)="closeDialog()"> <button
Skip class="mb-2 py-4 w-100"
mat-button
[disabled]="remainingSkipButtonDelay > 0"
(click)="closeDialog()"
>
<ng-container i18n>Skip</ng-container>
@if (remainingSkipButtonDelay > 0) {
({{ remainingSkipButtonDelay }})
}
</button> </button>
<a <a
class="m-0 py-4 w-100" class="m-0 py-4 w-100"

10
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -88,12 +88,10 @@
>)</mat-option >)</mat-option
> >
} }
@if (user?.settings?.isExperimentalFeatures) { <mat-option value="zh"
<mat-option value="zh" >Chinese (<ng-container i18n>Community</ng-container
>Chinese (<ng-container i18n>Community</ng-container >)</mat-option
>)</mat-option >
>
}
<mat-option value="es" <mat-option value="es"
>Español (<ng-container i18n>Community</ng-container >Español (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option

3
apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts

@ -12,6 +12,9 @@ import { Subject, takeUntil } from 'rxjs';
standalone: false standalone: false
}) })
export class FaqOverviewPageComponent implements OnDestroy { export class FaqOverviewPageComponent implements OnDestroy {
public pricingUrl =
`https://ghostfol.io/${document.documentElement.lang}/` +
$localize`:snake-case:pricing`;
public routerLinkFeatures = ['/' + $localize`:snake-case:features`]; public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public user: User; public user: User;

24
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -59,9 +59,14 @@
world. The world. The
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is <a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
fully available as open source software (OSS). Thanks to our generous fully available as open source software (OSS). Thanks to our generous
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a> users <a class="align-items-center d-inline-flex" [href]="pricingUrl"
and <a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we >Ghostfolio Premium<gf-premium-indicator
have the ability to run a free, limited plan for novice class="d-inline-block ml-1"
[enableLink]="false"
/></a>
users and
<a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we have
the ability to run a free, limited plan for novice
investors.</mat-card-content investors.</mat-card-content
> >
</mat-card> </mat-card>
@ -82,8 +87,11 @@
</mat-card-header> </mat-card-header>
<mat-card-content <mat-card-content
>By offering >By offering
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>, a <a class="align-items-center d-inline-flex" [href]="pricingUrl"
subscription plan with a managed hosting service and enhanced >Ghostfolio Premium<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false" /></a
>, a subscription plan with a managed hosting service and enhanced
features, we fund our business while providing added value to our features, we fund our business while providing added value to our
users.</mat-card-content users.</mat-card-content
> >
@ -105,7 +113,11 @@
</mat-card-header> </mat-card-header>
<mat-card-content <mat-card-content
>Any support for Ghostfolio is welcome. Be it with a >Any support for Ghostfolio is welcome. Be it with a
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a> <a class="align-items-center d-inline-flex" [href]="pricingUrl"
>Ghostfolio Premium<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/></a>
subscription to finance the hosting infrastructure, a positive rating subscription to finance the hosting infrastructure, a positive rating
in the in the
<a <a

9
apps/client/src/app/pages/faq/overview/faq-overview-page.module.ts

@ -1,3 +1,5 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -7,7 +9,12 @@ import { FaqOverviewPageComponent } from './faq-overview-page.component';
@NgModule({ @NgModule({
declarations: [FaqOverviewPageComponent], declarations: [FaqOverviewPageComponent],
imports: [CommonModule, FaqOverviewPageRoutingModule, MatCardModule], imports: [
CommonModule,
FaqOverviewPageRoutingModule,
GfPremiumIndicatorComponent,
MatCardModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class FaqOverviewPageModule {} export class FaqOverviewPageModule {}

4
apps/client/src/app/pages/faq/saas/saas-page.component.ts

@ -12,8 +12,10 @@ import { Subject, takeUntil } from 'rxjs';
standalone: false standalone: false
}) })
export class SaasPageComponent implements OnDestroy { export class SaasPageComponent implements OnDestroy {
public pricingUrl =
`https://ghostfol.io/${document.documentElement.lang}/` +
$localize`:snake-case:pricing`;
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`]; public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`]; public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public user: User; public user: User;

59
apps/client/src/app/pages/faq/saas/saas-page.html

@ -14,11 +14,11 @@
<mat-card-title>How do I start?</mat-card-title> <mat-card-title>How do I start?</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
You can sign up via the<a [routerLink]="routerLinkRegister" You can sign up via the
>Get Started</a <a [routerLink]="routerLinkRegister">Get Started</a> button at the top
>” button at the top of the page. You have multiple options to join of the page. You have multiple options to join Ghostfolio: Create an
Ghostfolio: Create an account with a security token or account with a security token or <i>Google Sign</i>. We will guide you
<i>Google Sign</i>. We will guide you to set up your portfolio. to set up your portfolio.
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
@ -38,8 +38,8 @@
> >
<mat-card-content <mat-card-content
>Yes, it is! Our >Yes, it is! Our
<a [routerLink]="routerLinkPricing">pricing page</a> details <a target="_blank" [href]="pricingUrl">pricing page</a>
everything you get for free.</mat-card-content details everything you get for free.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
@ -49,12 +49,20 @@
></mat-card-header ></mat-card-header
> >
<mat-card-content <mat-card-content
><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully ><a
managed Ghostfolio cloud offering for ambitious investors. Revenue is class="align-items-center d-inline-flex"
used to cover the costs of the hosting infrastructure and to fund target="_blank"
ongoing development. It is the Open Source code base with some extras [href]="pricingUrl"
like the <a [routerLink]="routerLinkMarkets">markets overview</a> and >Ghostfolio Premium<gf-premium-indicator
a professional data provider.</mat-card-content class="d-inline-block ml-1"
[enableLink]="false"
/></a>
is a fully managed Ghostfolio cloud offering for ambitious investors.
Revenue is used to cover the costs of the hosting infrastructure and
to fund ongoing development. It is the Open Source code base with some
extras like the
<a [routerLink]="routerLinkMarkets">markets overview</a> and a
professional data provider.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
@ -65,8 +73,15 @@
> >
<mat-card-content <mat-card-content
>Yes, you can try >Yes, you can try
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> by signing <a
up for Ghostfolio and applying for a trial (see class="align-items-center d-inline-flex"
target="_blank"
[href]="pricingUrl"
>Ghostfolio Premium<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/></a>
by signing up for Ghostfolio and applying for a trial (see
<a [routerLink]="['/account', 'membership']">Membership</a>). It is <a [routerLink]="['/account', 'membership']">Membership</a>). It is
easy, free and there is no commitment. You can stop using it at any easy, free and there is no commitment. You can stop using it at any
time.</mat-card-content time.</mat-card-content
@ -93,9 +108,17 @@
> >
</mat-card-header> </mat-card-header>
<mat-card-content <mat-card-content
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does >No,
not include auto-renewal. Upon expiration, you can choose whether to <a
start a new subscription.</mat-card-content class="align-items-center d-inline-flex"
target="_blank"
[href]="pricingUrl"
>Ghostfolio Premium<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/></a>
does not include auto-renewal. Upon expiration, you can choose whether
to start a new subscription.</mat-card-content
> >
</mat-card> </mat-card>
@if (user?.subscription?.type === 'Premium') { @if (user?.subscription?.type === 'Premium') {

9
apps/client/src/app/pages/faq/saas/saas-page.module.ts

@ -1,3 +1,5 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -7,7 +9,12 @@ import { SaasPageComponent } from './saas-page.component';
@NgModule({ @NgModule({
declarations: [SaasPageComponent], declarations: [SaasPageComponent],
imports: [CommonModule, MatCardModule, SaasPageRoutingModule], imports: [
CommonModule,
GfPremiumIndicatorComponent,
MatCardModule,
SaasPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class SaasPageModule {} export class SaasPageModule {}

4
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts

@ -9,6 +9,10 @@ import { Subject } from 'rxjs';
standalone: false standalone: false
}) })
export class SelfHostingPageComponent implements OnDestroy { export class SelfHostingPageComponent implements OnDestroy {
public pricingUrl =
`https://ghostfol.io/${document.documentElement.lang}/` +
$localize`:snake-case:pricing`;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public ngOnDestroy() { public ngOnDestroy() {

19
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -140,6 +140,25 @@
providers are considered experimental.</mat-card-content providers are considered experimental.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Can I get access to a professional data provider?</mat-card-title
>
</mat-card-header>
<mat-card-content
>Yes, access to a professional data provider is included with a
<a
class="align-items-center d-inline-flex"
target="_blank"
[href]="pricingUrl"
>Ghostfolio Premium<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/></a>
subscription via an API key.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title>How do I set up a benchmark?</mat-card-title> <mat-card-title>How do I set up a benchmark?</mat-card-title>

9
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.module.ts

@ -1,3 +1,5 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -7,7 +9,12 @@ import { SelfHostingPageComponent } from './self-hosting-page.component';
@NgModule({ @NgModule({
declarations: [SelfHostingPageComponent], declarations: [SelfHostingPageComponent],
imports: [CommonModule, MatCardModule, SelfHostingPageRoutingModule], imports: [
CommonModule,
GfPremiumIndicatorComponent,
MatCardModule,
SelfHostingPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class SelfHostingPageModule {} export class SelfHostingPageModule {}

50
apps/client/src/app/pages/features/features-page.html

@ -175,10 +175,15 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>Dark Mode</h4> <h4 class="align-items-center d-flex">
<span i18n>Static Analysis</span>
@if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" />
}
</h4>
<p class="m-0"> <p class="m-0">
Ghostfolio automatically switches to a dark color theme based Identify potential risks in your portfolio with Ghostfolio
on your operating system's preferences. X-ray, the static portfolio analysis.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -188,10 +193,14 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>Zen Mode</h4> <h4 class="align-items-center d-flex">
<span i18n>Watchlist</span>
@if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" />
}
</h4>
<p class="m-0"> <p class="m-0">
Keep calm and activate Zen Mode if the markets are going Follow assets you are interested in closely on your watchlist.
crazy.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -221,15 +230,23 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex" i18n>Dark Mode</h4>
<span i18n>Static Analysis</span>
@if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" />
}
</h4>
<p class="m-0"> <p class="m-0">
Identify potential risks in your portfolio with Ghostfolio Ghostfolio automatically switches to a dark color theme based
X-ray, the static portfolio analysis. on your operating system's preferences.
</p>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>Zen Mode</h4>
<p class="m-0">
Keep calm and activate Zen Mode if the markets are going
crazy.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -243,9 +260,8 @@
<p class="m-0"> <p class="m-0">
Use Ghostfolio in multiple languages: English, Use Ghostfolio in multiple languages: English,
<!-- Català, --> <!-- Català, -->
<!-- Chinese, --> Chinese, Dutch, French, German, Italian, Polish, Portuguese,
Dutch, French, German, Italian, Polish, Portuguese, Spanish Spanish and Turkish
and Turkish
<!-- and Ukrainian --> <!-- and Ukrainian -->
are currently supported. are currently supported.
</p> </p>

6
apps/client/src/app/pages/home/home-page-routing.module.ts

@ -2,6 +2,7 @@ import { HomeHoldingsComponent } from '@ghostfolio/client/components/home-holdin
import { HomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; import { HomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component';
import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component'; import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component';
import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component'; import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component';
import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
@ -36,6 +37,11 @@ const routes: Routes = [
path: 'market', path: 'market',
component: HomeMarketComponent, component: HomeMarketComponent,
title: $localize`Markets` title: $localize`Markets`
},
{
path: 'watchlist',
component: HomeWatchlistComponent,
title: $localize`Watchlist`
} }
], ],
component: HomePageComponent, component: HomePageComponent,

6
apps/client/src/app/pages/home/home-page.component.ts

@ -48,12 +48,18 @@ export class HomePageComponent implements OnDestroy, OnInit {
label: $localize`Summary`, label: $localize`Summary`,
path: ['/home', 'summary'] path: ['/home', 'summary']
}, },
{
iconName: 'bookmark-outline',
label: $localize`Watchlist`,
path: ['/home', 'watchlist']
},
{ {
iconName: 'newspaper-outline', iconName: 'newspaper-outline',
label: $localize`Markets`, label: $localize`Markets`,
path: ['/home', 'market'] path: ['/home', 'market']
} }
]; ];
this.user = state.user; this.user = state.user;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

2
apps/client/src/app/pages/home/home-page.module.ts

@ -2,6 +2,7 @@ import { GfHomeHoldingsModule } from '@ghostfolio/client/components/home-holding
import { GfHomeMarketModule } from '@ghostfolio/client/components/home-market/home-market.module'; import { GfHomeMarketModule } from '@ghostfolio/client/components/home-market/home-market.module';
import { GfHomeOverviewModule } from '@ghostfolio/client/components/home-overview/home-overview.module'; import { GfHomeOverviewModule } from '@ghostfolio/client/components/home-overview/home-overview.module';
import { GfHomeSummaryModule } from '@ghostfolio/client/components/home-summary/home-summary.module'; import { GfHomeSummaryModule } from '@ghostfolio/client/components/home-summary/home-summary.module';
import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -20,6 +21,7 @@ import { HomePageComponent } from './home-page.component';
GfHomeOverviewModule, GfHomeOverviewModule,
GfHomeSummaryModule, GfHomeSummaryModule,
HomePageRoutingModule, HomePageRoutingModule,
HomeWatchlistComponent,
MatTabsModule, MatTabsModule,
RouterModule RouterModule
], ],

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

@ -39,6 +39,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
return { id: assetSubClass, label: translate(assetSubClass) }; return { id: assetSubClass, label: translate(assetSubClass) };
}); });
public currencies: string[] = []; public currencies: string[] = [];
public currencyOfAssetProfile: string;
public currentMarketPrice = null; public currentMarketPrice = null;
public defaultDateFormat: string; public defaultDateFormat: string;
public isLoading = false; public isLoading = false;
@ -63,8 +64,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.mode = this.data.activity.id ? 'update' : 'create'; this.currencyOfAssetProfile = this.data.activity?.SymbolProfile?.currency;
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user?.settings?.locale;
this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo(); const { currencies, platforms } = this.dataService.fetchInfo();
@ -214,7 +217,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('type').value this.activityForm.get('type').value
) )
) { ) {
this.updateSymbol(); this.updateAssetProfile();
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -242,7 +245,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.get('dataSource') .get('dataSource')
.removeValidators(Validators.required); .removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource').updateValueAndValidity();
this.activityForm.get('fee').reset(); this.activityForm.get('fee').setValue(0);
this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name').setValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('name').updateValueAndValidity();
this.activityForm.get('quantity').setValue(1); this.activityForm.get('quantity').setValue(1);
@ -252,11 +255,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('searchSymbol').updateValueAndValidity();
this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance').disable();
this.activityForm.get('updateAccountBalance').setValue(false); this.activityForm.get('updateAccountBalance').setValue(false);
} else if ( } else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
type === 'FEE' ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm this.activityForm
.get('accountId') .get('accountId')
.removeValidators(Validators.required); .removeValidators(Validators.required);
@ -275,12 +274,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.removeValidators(Validators.required); .removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity(); this.activityForm.get('dataSource').updateValueAndValidity();
if ( if (['INTEREST', 'LIABILITY'].includes(type)) {
(type === 'FEE' && this.activityForm.get('fee').value === 0) || this.activityForm.get('fee').setValue(0);
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm.get('fee').reset();
} }
this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name').setValidators(Validators.required);
@ -288,7 +283,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.get('quantity').setValue(0); this.activityForm.get('quantity').setValue(0);
} else if (type === 'INTEREST' || type === 'LIABILITY') { } else if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('quantity').setValue(1); this.activityForm.get('quantity').setValue(1);
} }
@ -409,7 +404,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.dialogRef.close(activity); this.dialogRef.close(activity);
} else { } else {
(activity as UpdateOrderDto).id = this.data.activity.id; (activity as UpdateOrderDto).id = this.data.activity?.id;
await validateObjectForForm({ await validateObjectForForm({
classDto: UpdateOrderDto, classDto: UpdateOrderDto,
@ -434,7 +429,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private updateSymbol() { private updateAssetProfile() {
this.isLoading = true; this.isLoading = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -462,6 +457,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('dataSource').setValue(dataSource); this.activityForm.get('dataSource').setValue(dataSource);
} }
this.currencyOfAssetProfile = currency;
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;
this.isLoading = false; this.isLoading = false;

4
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -241,8 +241,10 @@
</div> </div>
</mat-form-field> </mat-form-field>
@if ( @if (
currencyOfAssetProfile ===
activityForm.get('currencyOfUnitPrice').value &&
currentMarketPrice && currentMarketPrice &&
(data.activity.type === 'BUY' || data.activity.type === 'SELL') && ['BUY', 'SELL'].includes(data.activity.type) &&
isToday(activityForm.get('date')?.value) isToday(activityForm.get('date')?.value)
) { ) {
<button <button

20
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -48,10 +48,10 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="platforms"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['id']" [keys]="['id']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="platforms"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -70,10 +70,10 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="positions"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['currency']" [keys]="['currency']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -92,10 +92,10 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="positions"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['assetClassLabel', 'assetSubClassLabel']" [keys]="['assetClassLabel', 'assetSubClassLabel']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -113,10 +113,10 @@
cursor="pointer" cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="symbols"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['symbol']" [keys]="['symbol']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
(proportionChartClicked)="onSymbolChartClicked($event)" (proportionChartClicked)="onSymbolChartClicked($event)"
/> />
@ -137,11 +137,11 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="sectors"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10" [maxItems]="10"
[positions]="sectors"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -160,10 +160,10 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="continents"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="continents"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -182,9 +182,9 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="marketsAdvanced"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="marketsAdvanced"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -271,11 +271,11 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="countries"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10" [maxItems]="10"
[positions]="countries"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -290,10 +290,10 @@
cursor="pointer" cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="accounts"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['id']" [keys]="['id']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="accounts"
(proportionChartClicked)="onAccountChartClicked($event)" (proportionChartClicked)="onAccountChartClicked($event)"
/> />
</mat-card-content> </mat-card-content>
@ -313,10 +313,10 @@
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[data]="positions"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['etfProvider']" [keys]="['etfProvider']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

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

@ -30,6 +30,7 @@ export class PublicPageComponent implements OnInit {
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public defaultAlias = $localize`someone`;
public deviceType: string; public deviceType: string;
public holdings: PublicPortfolioResponse['holdings'][string][]; public holdings: PublicPortfolioResponse['holdings'][string][];
public markets: { public markets: {

12
apps/client/src/app/pages/public/public-page.html

@ -2,7 +2,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h1 class="h4 mb-3 text-center" i18n> <h1 class="h4 mb-3 text-center" i18n>
Hello, {{ publicPortfolioDetails?.alias ?? 'someone' }} has shared a Hello, {{ publicPortfolioDetails?.alias ?? defaultAlias }} has shared a
<strong>Portfolio</strong> with you! <strong>Portfolio</strong> with you!
</h1> </h1>
</div> </div>
@ -72,9 +72,9 @@
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
class="mx-auto" class="mx-auto"
[data]="symbols"
[isInPercent]="true" [isInPercent]="true"
[keys]="['symbol']" [keys]="['symbol']"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
/> />
</mat-card-content> </mat-card-content>
@ -90,10 +90,10 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[data]="positions"
[isInPercent]="true" [isInPercent]="true"
[keys]="['currency']" [keys]="['currency']"
[maxItems]="10" [maxItems]="10"
[positions]="positions"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -107,10 +107,10 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[data]="sectors"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
[positions]="sectors"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -126,9 +126,9 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[data]="continents"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[positions]="continents"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -198,10 +198,10 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<gf-holdings-table <gf-holdings-table
[data]="holdings"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToShowValues]="false" [hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7" [pageSize]="7"
/> />
</div> </div>

26
apps/client/src/app/services/admin.service.ts

@ -4,8 +4,7 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { import {
HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN, HEADER_KEY_TOKEN
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { import {
@ -24,7 +23,6 @@ import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform } from '@prisma/client'; import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { switchMap } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { DataService } from './data.service'; import { DataService } from './data.service';
@ -115,19 +113,15 @@ export class AdminService {
}); });
} }
public fetchGhostfolioDataProviderStatus() { public fetchGhostfolioDataProviderStatus(aApiKey: string) {
return this.fetchAdminData().pipe( const headers = new HttpHeaders({
switchMap(({ settings }) => { [HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
const headers = new HttpHeaders({ [HEADER_KEY_TOKEN]: `Api-Key ${aApiKey}`
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true', });
[HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}); return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
return this.http.get<DataProviderGhostfolioStatusResponse>( { headers }
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
{ headers }
);
})
); );
} }

32
apps/client/src/app/services/data.service.ts

@ -6,13 +6,13 @@ import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto'; import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto'; import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { CreateWatchlistItemDto } from '@ghostfolio/api/app/endpoints/watchlist/create-watchlist-item.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { import {
Activities, Activities,
Activity Activity
} from '@ghostfolio/api/app/order/interfaces/activities.interface'; } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto'; import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
@ -24,7 +24,7 @@ import {
Access, Access,
AccessTokenResponse, AccessTokenResponse,
AccountBalancesResponse, AccountBalancesResponse,
Accounts, AccountsResponse,
AiPromptResponse, AiPromptResponse,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
@ -39,12 +39,14 @@ import {
OAuthResponse, OAuthResponse,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReportResponse, PortfolioReportResponse,
PublicPortfolioResponse, PublicPortfolioResponse,
User User,
WatchlistResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -189,7 +191,7 @@ export class DataService {
public fetchAccounts({ filters }: { filters?: Filter[] } = {}) { public fetchAccounts({ filters }: { filters?: Filter[] } = {}) {
const params = this.buildFiltersAsQueryParams({ filters }); const params = this.buildFiltersAsQueryParams({ filters });
return this.http.get<Accounts>('/api/v1/account', { params }); return this.http.get<AccountsResponse>('/api/v1/account', { params });
} }
public fetchActivities({ public fetchActivities({
@ -325,6 +327,10 @@ export class DataService {
return this.http.delete<any>(`/api/v1/user/${aId}`); return this.http.delete<any>(`/api/v1/user/${aId}`);
} }
public deleteWatchlistItem({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<any>(`/api/v1/watchlist/${dataSource}/${symbol}`);
}
public fetchAccesses() { public fetchAccesses() {
return this.http.get<Access[]>('/api/v1/access'); return this.http.get<Access[]>('/api/v1/access');
} }
@ -400,13 +406,13 @@ export class DataService {
symbol: string; symbol: string;
}) { }) {
return this.http return this.http
.get<PortfolioHoldingDetail>( .get<PortfolioHoldingResponse>(
`/api/v1/portfolio/position/${dataSource}/${symbol}` `/api/v1/portfolio/holding/${dataSource}/${symbol}`
) )
.pipe( .pipe(
map((data) => { map((data) => {
if (data.orders) { if (data.activities) {
for (const order of data.orders) { for (const order of data.activities) {
order.createdAt = parseISO(order.createdAt as unknown as string); order.createdAt = parseISO(order.createdAt as unknown as string);
order.date = parseISO(order.date as unknown as string); order.date = parseISO(order.date as unknown as string);
} }
@ -697,6 +703,10 @@ export class DataService {
return this.http.get<Tag[]>('/api/v1/tags'); return this.http.get<Tag[]>('/api/v1/tags');
} }
public fetchWatchlist() {
return this.http.get<WatchlistResponse>('/api/v1/watchlist');
}
public generateAccessToken(aUserId: string) { public generateAccessToken(aUserId: string) {
return this.http.post<AccessTokenResponse>( return this.http.post<AccessTokenResponse>(
`/api/v1/user/${aUserId}/access-token`, `/api/v1/user/${aUserId}/access-token`,
@ -759,6 +769,10 @@ export class DataService {
return this.http.post<UserItem>('/api/v1/user', {}); return this.http.post<UserItem>('/api/v1/user', {});
} }
public postWatchlistItem(watchlistItem: CreateWatchlistItemDto) {
return this.http.post('/api/v1/watchlist', watchlistItem);
}
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount); return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount);
} }
@ -773,7 +787,7 @@ export class DataService {
tags tags
}: { tags: Tag[] } & AssetProfileIdentifier) { }: { tags: Tag[] } & AssetProfileIdentifier) {
return this.http.put<void>( return this.http.put<void>(
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`, `/api/v1/portfolio/holding/${dataSource}/${symbol}/tags`,
{ tags } { tags }
); );
} }

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

Loading…
Cancel
Save