Browse Source

Merge pull request #178 from dandevaud/mr/MR-Upstream-changes-2025-05-21

Mr/mr upstream changes 2025 05 21
pull/5027/head
dandevaud 1 month ago
committed by GitHub
parent
commit
f83a34e8b6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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. 249
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  22. 253
      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. 23
      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
### 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
- Improved the error message of the currency code validation
- 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 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`)
- Fixed the missing localization for "someone" on the public page
## 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 {
AccountBalancesResponse,
Accounts
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
@ -57,17 +57,17 @@ export class AccountController {
@HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
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: this.request.user.id
}
},
{ Order: true }
{ activities: true }
);
if (!account || account?.Order.length > 0) {
if (!account || account?.activities.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -87,10 +87,10 @@ export class AccountController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> {
): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
@ -110,7 +110,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =

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

@ -40,12 +40,12 @@ export class AccountService {
return account;
}
public async accountWithOrders(
public async accountWithActivities(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
activities?: Order[];
}
> {
return this.prismaService.account.findUnique({
@ -63,8 +63,8 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
activities?: Order[];
balances?: AccountBalance[];
Order?: Order[];
Platform?: Platform;
})[]
> {
@ -141,7 +141,7 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
include: { activities: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
});
@ -149,15 +149,15 @@ export class AccountService {
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
for (const { isDraft } of account.activities) {
if (!isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order;
delete result.activities;
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 { ApiService } from '@ghostfolio/api/services/api/api.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import {
@ -16,7 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
@ -50,8 +48,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
@ -60,7 +56,6 @@ export class AdminController {
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -68,7 +63,7 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> {
return this.adminService.get();
return this.adminService.get({ user: this.request.user });
}
@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)
@Post('market-data/:dataSource/:symbol/test')
@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)
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

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

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 userService: UserService
) {
super(
{ 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
);
}
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
}
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
public async validate(apiKey: string) {
const user = await this.validateApiKey(apiKey);
done(null, user);
} catch (error) {
done(error, null);
}
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({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
return user;
}
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 { UserModule } from '@ghostfolio/api/app/user/user.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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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],
imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import {
GetAssetProfileParams,
GetDividendsParams,
@ -327,10 +328,15 @@ export class GhostfolioService {
}
private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false,
name: 'Ghostfolio Premium',
url: 'https://ghostfol.io'
name: 'Ghostfolio Premium'
};
}

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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -25,6 +26,7 @@ import { PublicController } from './public.controller';
controllers: [PublicController],
imports: [
AccessModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
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 { 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 { 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 { RequestWithUser } from '@ghostfolio/common/types';
@ -11,6 +13,7 @@ import {
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
@ -29,13 +32,14 @@ import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@Post()
@HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
return this.watchlistService.createWatchlistItem({
@ -53,13 +57,13 @@ export class WatchlistController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const watchlistItem = await this.watchlistService
.getWatchlistItems(this.request.user.id)
.then((items) => {
return items.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
});
const watchlistItems = await this.watchlistService.getWatchlistItems(
this.request.user.id
);
const watchlistItem = watchlistItems.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
if (!watchlistItem) {
throw new HttpException(
@ -79,7 +83,18 @@ export class WatchlistController {
@HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(): Promise<AssetProfileIdentifier[]> {
return this.watchlistService.getWatchlistItems(this.request.user.id);
public async getWatchlistItems(
@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 { 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 { 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';
@ -10,7 +16,13 @@ import { WatchlistService } from './watchlist.service';
@Module({
controllers: [WatchlistController],
imports: [
BenchmarkModule,
DataGatheringModule,
DataProviderModule,
ImpersonationModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
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 { 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 { DataSource } from '@prisma/client';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client';
@Injectable()
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({
dataSource,
@ -24,11 +36,26 @@ export class WatchlistService {
});
if (!symbolProfile) {
throw new NotFoundException(
`Asset profile not found for ${symbol} (${dataSource})`
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ 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({
data: {
watchlist: {
@ -64,7 +91,7 @@ export class WatchlistService {
public async getWatchlistItems(
userId: string
): Promise<AssetProfileIdentifier[]> {
): Promise<WatchlistResponse['watchlist']> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
@ -74,6 +101,50 @@ export class WatchlistService {
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
}: AssetProfileIdentifier): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding(dataSource, undefined, symbol);
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
@ -68,7 +68,7 @@ export class ImportService {
})
]);
const accounts = orders
const accounts = activities
.filter(({ Account }) => {
return !!Account;
})
@ -88,7 +88,7 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
@ -118,6 +118,7 @@ export class ImportService {
createdAt: undefined,
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: assetProfile,
@ -126,7 +127,8 @@ export class ImportService {
unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined,
userId: Account?.userId
userId: Account?.userId,
valueInBaseCurrency: value
};
})
);
@ -167,9 +169,9 @@ export class ImportService {
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
const accountWithSameId = existingAccounts.find((existingAccount) => {
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 (!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;
error?: ActivityError;
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean;
value: number;
valueInBaseCurrency: number;
}
export interface ActivityError {

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

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

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

@ -124,7 +124,7 @@ export class OrderService {
userId: string;
}
): Promise<Order> {
let Account: Prisma.AccountCreateNestedOneWithoutOrderInput;
let Account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) {
Account = {
@ -581,31 +581,46 @@ export class OrderService {
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 {
...order,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value,
feeInAssetProfileCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
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
)
valueInBaseCurrency,
SymbolProfile: assetProfile
};
})
);

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

@ -54,7 +54,7 @@ export class PlatformService {
await this.prismaService.platform.findMany({
include: {
_count: {
select: { Account: true }
select: { accounts: true }
}
}
});
@ -64,7 +64,7 @@ export class PlatformService {
id,
name,
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(),
currency: undefined,
fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined,
id: undefined,
isDraft: false,
symbolProfileId: undefined,
unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined,
updatedAt: new Date(),
userId: 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;
for (const {
fee,
date,
fee,
quantity,
SymbolProfile,
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 }
]);
});
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
});
});
});

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

@ -0,0 +1,249 @@
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,
null
);
});
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,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 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
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
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,
timeWeightedPerformanceInPercentage: -0.13969735500006986,
timeWeightedPerformanceInPercentageWithCurrencyEffect:
-0.13969735500006986,
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 }
]);
});
});
});

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

@ -0,0 +1,253 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
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;
let orderService: OrderService;
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);
orderService = new OrderService(null, null, null, null, null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
orderService
);
});
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,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 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
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
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,
timeWeightedPerformanceInPercentage: -0.13969735500006986,
timeWeightedPerformanceInPercentageWithCurrencyEffect:
-0.13969735500006986,
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
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', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;

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

@ -148,21 +148,25 @@ describe('PortfolioCalculator', () => {
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({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netPerformance: 24,
netPerformanceInPercentage: 0.158311345646438,
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438,
netPerformanceWithCurrencyEffect: 24,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
netWorth: 151.6,
netWorth: 175.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
@ -176,8 +180,9 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformanceInPercentage: 0.13100263852242744,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
timeWeightedPerformanceInPercentage: -0.02357630979498861,
timeWeightedPerformanceInPercentageWithCurrencyEffect:
-0.02357630979498861,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,

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(
order.unitPriceInBaseCurrency
marketPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
@ -560,10 +567,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
marketPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
@ -703,17 +710,23 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).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] =
totalInvestmentDays > 0
totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
: totalInvestment.gt(0)
? totalInvestment
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
: totalInvestmentWithCurrencyEffect.gt(0)
? totalInvestmentWithCurrencyEffect
: new Big(0);
}
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 };
} else if (isSameDay(parseDate('2018-01-01'), date)) {
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 };

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

@ -23,6 +23,7 @@ import {
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
@ -59,7 +60,6 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -386,6 +386,32 @@ export class PortfolioController {
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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@ -611,6 +637,9 @@ export class PortfolioController {
return performanceInformation;
}
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -620,8 +649,8 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const holding = await this.portfolioService.getPosition(
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding(
dataSource,
impersonationId,
symbol
@ -662,7 +691,7 @@ export class PortfolioController {
}
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@ -671,7 +700,42 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): 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,
impersonationId,
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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -33,6 +34,7 @@ import { RulesService } from './rules.service';
imports: [
AccessModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
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 { 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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -36,12 +37,13 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
AccountsResponse,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
InvestmentItem,
PortfolioDetails,
PortfolioHoldingResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPosition,
@ -88,7 +90,6 @@ import { isEmpty, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
@ -101,6 +102,7 @@ export class PortfolioService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -141,7 +143,7 @@ export class PortfolioService {
}
if (filterByDataSource && filterBySymbol) {
where.Order = {
where.activities = {
some: {
SymbolProfile: {
AND: [
@ -156,7 +158,7 @@ export class PortfolioService {
const [accounts, details] = await Promise.all([
this.accountService.accounts({
where,
include: { Order: true, Platform: true },
include: { activities: true, Platform: true },
orderBy: { name: 'asc' }
}),
this.getDetails({
@ -172,8 +174,8 @@ export class PortfolioService {
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
for (const { isDraft } of account.activities) {
if (!isDraft) {
transactionCount += 1;
}
}
@ -197,7 +199,7 @@ export class PortfolioService {
)
};
delete result.Order;
delete result.activities;
return result;
});
@ -212,7 +214,7 @@ export class PortfolioService {
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<Accounts> {
}): Promise<AccountsResponse> {
const accounts = await this.getAccounts({
filters,
userId,
@ -644,11 +646,11 @@ export class PortfolioService {
}
@LogPerformance
public async getPosition(
public async getHolding(
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioHoldingDetail> {
): Promise<PortfolioHoldingResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
@ -661,6 +663,7 @@ export class PortfolioService {
if (activities.length === 0) {
return {
activities: [],
averagePrice: undefined,
dataProviderInfo: undefined,
stakeRewards: undefined,
@ -676,13 +679,13 @@ export class PortfolioService {
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
marketPriceMax: undefined,
marketPriceMin: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
orders: [],
performances: undefined,
quantity: undefined,
SymbolProfile: undefined,
tags: [],
@ -728,7 +731,7 @@ export class PortfolioService {
transactionCount
} = position;
const activitiesOfPosition = activities.filter(({ SymbolProfile }) => {
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
@ -772,8 +775,18 @@ export class PortfolioService {
);
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice);
let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice);
let marketPriceMax = Math.max(
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]) {
let j = -1;
@ -811,27 +824,40 @@ export class PortfolioService {
quantity: currentQuantity
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
if (marketPrice > marketPriceMax) {
marketPriceMax = marketPrice;
marketPriceMaxDate = parseISO(date);
}
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
}
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: activitiesOfPosition[0].unitPrice,
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
date: firstBuyDate,
marketPrice: activitiesOfPosition[0].unitPrice,
quantity: activitiesOfPosition[0].quantity
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfHolding[0].quantity
});
}
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
marketPriceMax,
marketPrice
);
return {
firstBuyDate,
marketPrice,
maxPrice,
minPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
tags,
transactionCount,
activities: activitiesOfHolding,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
stakeRewards: stakeRewards.toNumber(),
@ -861,7 +887,12 @@ export class PortfolioService {
]?.toNumber(),
netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
orders: activitiesOfPosition,
performances: {
allTimeHigh: {
performancePercent,
date: marketPriceMaxDate
}
},
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(),
@ -900,8 +931,9 @@ export class PortfolioService {
}
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
let marketPriceMax = marketPrice;
let marketPriceMaxDate = new Date();
let marketPriceMin = marketPrice;
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
@ -911,15 +943,28 @@ export class PortfolioService {
value: marketPrice
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
if (marketPrice > marketPriceMax) {
marketPriceMax = marketPrice;
marketPriceMaxDate = parseISO(date);
}
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
}
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
marketPriceMax,
marketPrice
);
return {
marketPrice,
maxPrice,
minPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
activities: [],
averagePrice: 0,
dataProviderInfo: undefined,
stakeRewards: 0,
@ -938,7 +983,12 @@ export class PortfolioService {
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
orders: [],
performances: {
allTimeHigh: {
performancePercent,
date: marketPriceMaxDate
}
},
quantity: 0,
tags: [],
transactionCount: undefined,
@ -948,7 +998,7 @@ export class PortfolioService {
}
@LogPerformance
public async getPositions({
public async getHoldings({
dateRange = 'max',
filters,
impersonationId
@ -1214,7 +1264,7 @@ export class PortfolioService {
const rules: PortfolioReportResponse['rules'] = {
accountClusterRisk:
summary.ordersCount > 0
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
@ -1230,7 +1280,7 @@ export class PortfolioService {
)
: undefined,
assetClassClusterRisk:
summary.ordersCount > 0
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new AssetClassClusterRiskEquity(
@ -1246,7 +1296,7 @@ export class PortfolioService {
)
: undefined,
currencyClusterRisk:
summary.ordersCount > 0
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
@ -1262,7 +1312,7 @@ export class PortfolioService {
)
: undefined,
economicMarketClusterRisk:
summary.ordersCount > 0
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
@ -1303,7 +1353,7 @@ export class PortfolioService {
userSettings
),
regionalMarketClusterRisk:
summary.ordersCount > 0
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new RegionalMarketClusterRiskAsiaPacific(
@ -1625,6 +1675,9 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect,
totalBuy,
totalSell,
activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -1652,9 +1705,6 @@ export class PortfolioService {
interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(),
ordersCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
totalInvestment: totalInvestment.toNumber(),
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { createKeyv } from '@keyv/redis';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { redisStore } from 'cache-manager-redis-yet';
import type { RedisClientOptions } from 'redis';
import { RedisCacheService } from './redis-cache.service';
@Module({
exports: [RedisCacheService],
imports: [
CacheModule.registerAsync<RedisClientOptions>({
CacheModule.registerAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => {
@ -20,10 +19,13 @@ import { RedisCacheService } from './redis-cache.service';
);
return {
store: redisStore,
ttl: configurationService.get('CACHE_TTL'),
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
} as RedisClientOptions;
stores: [
createKeyv(
`redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
)
],
ttl: configurationService.get('CACHE_TTL')
};
}
}),
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 { 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 { Milliseconds } from 'cache-manager';
import { RedisCache } from 'cache-manager-redis-yet';
import { createHash } from 'crypto';
import ms from 'ms';
@Injectable()
export class RedisCacheService {
public constructor(
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly configurationService: ConfigurationService
) {
const client = cache.store.client;
const client = cache.stores[0];
client.on('error', (error) => {
Logger.error(error, 'RedisCacheService');
@ -27,13 +25,33 @@ export class RedisCacheService {
}
public async getKeys(aPrefix?: string): Promise<string[]> {
let prefix = aPrefix;
if (prefix) {
prefix = `${prefix}*`;
const keys: string[] = [];
const prefix = aPrefix;
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({
@ -62,10 +80,8 @@ export class RedisCacheService {
public async isHealthy() {
try {
const client = this.cache.store.client;
const isHealthy = await Promise.race([
client.ping(),
this.getKeys(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Redis health check timeout')),
@ -93,16 +109,14 @@ export class RedisCacheService {
`${this.getPortfolioSnapshotKey({ userId })}`
);
for (const key of keys) {
await this.remove(key);
}
return this.cache.mdel(keys);
}
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(
key,
value,

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

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

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

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

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

@ -1515,6 +1515,7 @@ describe('redactAttributes', () => {
}
},
summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null,
@ -1538,7 +1539,6 @@ describe('redactAttributes', () => {
interest: null,
items: null,
liabilities: null,
ordersCount: 29,
totalInvestment: null,
totalValueInBaseCurrency: null,
currentNetWorth: null
@ -3018,6 +3018,7 @@ describe('redactAttributes', () => {
}
},
summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null,
@ -3041,7 +3042,6 @@ describe('redactAttributes', () => {
interest: null,
items: null,
liabilities: null,
ordersCount: 29,
totalInvestment: null,
totalValueInBaseCurrency: 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({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
@ -302,16 +314,4 @@ export class BenchmarkService {
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 {
return {
dataSource: DataSource.ALPHA_VANTAGE,
isPremium: false,
name: 'Alpha Vantage',
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 {
return {
dataSource: DataSource.COINGECKO,
isPremium: false,
name: 'CoinGecko',
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';
import { isISIN } from 'class-validator';
import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
import YahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private readonly yahooFinance = new YahooFinance();
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
) {}
@ -99,8 +101,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (response.dataSource === 'YAHOO') {
yahooSymbol = symbol;
} else {
const { quotes } = await yahooFinance.search(response.isin);
yahooSymbol = quotes[0].symbol;
const { quotes } = await this.yahooFinance.search(response.isin);
yahooSymbol = quotes[0].symbol as string;
}
const { countries, sectors, url } =
@ -165,10 +167,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (isISIN(symbol)) {
try {
const { quotes } = await yahooFinance.search(symbol);
const { quotes } = await this.yahooFinance.search(symbol);
if (quotes?.[0]?.symbol) {
symbol = quotes[0].symbol;
symbol = quotes[0].symbol as string;
}
} catch {}
} else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) {
@ -177,7 +179,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
symbol = this.convertToYahooFinanceSymbol(symbol);
}
const assetProfile = await yahooFinance.quoteSummary(symbol, {
const assetProfile = await this.yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings']
});
@ -206,7 +208,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
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 (

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

@ -27,6 +27,7 @@ import {
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { hasRole } from '@ghostfolio/common/permissions';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -170,6 +171,7 @@ export class DataProviderService {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
if (
!hasRole(user, 'ADMIN') &&
isBefore(user.createdAt, new Date('2025-03-23')) &&
this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0
) {
@ -186,7 +188,7 @@ export class DataProviderService {
PROPERTY_API_KEY_GHOSTFOLIO
)) as string;
if (ghostfolioApiKey) {
if (ghostfolioApiKey || hasRole(user, 'ADMIN')) {
dataSources.push('GHOSTFOLIO');
}
@ -693,6 +695,7 @@ export class DataProviderService {
lookupItem.dataProviderInfo.isPremium = false;
}
lookupItem.dataProviderInfo.dataSource = undefined;
lookupItem.dataProviderInfo.name = undefined;
lookupItem.dataProviderInfo.url = undefined;
} 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 {
return {
dataSource: DataSource.EOD_HISTORICAL_DATA,
isPremium: true,
name: 'EOD Historical Data',
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 {
return {
dataSource: DataSource.FINANCIAL_MODELING_PREP,
isPremium: true,
name: 'Financial Modeling Prep',
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 {
return {
dataSource: DataSource.GHOSTFOLIO,
isPremium: true,
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 {
return {
dataSource: DataSource.GOOGLE_SHEETS,
isPremium: false,
name: 'Google Sheets',
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 {
return {
dataSource: DataSource.MANUAL,
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 {
return {
dataSource: DataSource.RAPID_API,
isPremium: false,
name: 'Rapid API',
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 { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart';
import YahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart';
import {
HistoricalDividendsResult,
HistoricalHistoryResult
} from 'yahoo-finance2/dist/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
} from 'yahoo-finance2/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/esm/src/modules/quote';
import { SearchQuoteNonYahoo } from 'yahoo-finance2/script/src/modules/search';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinance = new YahooFinance();
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
@ -51,6 +54,7 @@ export class YahooFinanceService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
dataSource: DataSource.YAHOO,
isPremium: false,
name: 'Yahoo Finance',
url: 'https://finance.yahoo.com'
@ -69,7 +73,7 @@ export class YahooFinanceService implements DataProviderInterface {
try {
const historicalResult = this.convertToDividendResult(
await yahooFinance.chart(
await this.yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
@ -118,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface {
try {
const historicalResult = this.convertToHistoricalResult(
await yahooFinance.chart(
await this.yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
@ -187,7 +191,7 @@ export class YahooFinanceService implements DataProviderInterface {
>[] = [];
try {
quotes = await yahooFinance.quote(yahooFinanceSymbols);
quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) {
Logger.error(error, 'YahooFinanceService');
@ -243,13 +247,15 @@ export class YahooFinanceService implements DataProviderInterface {
quoteTypes.push('INDEX');
}
const searchResult = await yahooFinance.search(query);
const searchResult = await this.yahooFinance.search(query);
const quotes = searchResult.quotes
.filter((quote) => {
// Filter out undefined symbols
return quote.symbol;
})
.filter(
(quote): quote is Exclude<typeof quote, SearchQuoteNonYahoo> => {
// Filter out undefined symbols
return !!quote.symbol;
}
)
.filter(({ quoteType, symbol }) => {
return (
(quoteType === 'CRYPTOCURRENCY' &&
@ -275,7 +281,7 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
});
const marketData = await yahooFinance.quote(
const marketData = await this.yahooFinance.quote(
quotes.map(({ symbol }) => {
return symbol;
})
@ -335,7 +341,7 @@ export class YahooFinanceService implements DataProviderInterface {
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return yahooFinance.quoteSummary(symbol).catch(() => {
return this.yahooFinance.quoteSummary(symbol).catch(() => {
Logger.error(
`Could not get quote summary for ${symbol}`,
'YahooFinanceService'

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

@ -4,26 +4,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import {
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;
}
import { DataSource } from '@prisma/client';
export interface IDataProviderHistoricalResponse {
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({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
id: true,
Order: {
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
dataSource: true,
id: true,
scraperConfiguration: true,
symbol: true
},
@ -508,7 +508,7 @@ export class DataGatheringService {
);
})
.map((symbolProfile) => {
let date = symbolProfile.Order?.[0]?.date ?? startDate;
let date = symbolProfile.activities?.[0]?.date ?? startDate;
if (benchmarkAssetProfileIdMap[symbolProfile.id]) {
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({
include: {
Order: {
activities: {
include: {
User: true
}
@ -39,8 +39,7 @@ export class SymbolProfileService {
},
orderBy: [{ symbol: 'asc' }],
where: {
isActive: true,
Order: withUserSubscription
activities: withUserSubscription
? {
some: {
User: {
@ -54,7 +53,8 @@ export class SymbolProfileService {
subscriptions: { none: { expiresAt: { gt: new Date() } } }
}
}
}
},
isActive: true
}
});
}
@ -67,9 +67,9 @@ export class SymbolProfileService {
.findMany({
include: {
_count: {
select: { Order: true }
select: { activities: true }
},
Order: {
activities: {
orderBy: {
date: 'asc'
},
@ -118,7 +118,7 @@ export class SymbolProfileService {
.findMany({
include: {
_count: {
select: { Order: true }
select: { activities: true }
},
SymbolProfileOverrides: true,
tags: true
@ -196,8 +196,8 @@ export class SymbolProfileService {
private enhanceSymbolProfiles(
symbolProfiles: (SymbolProfile & {
_count: { Order: number };
Order?: {
_count: { activities: number };
activities?: {
date: Date;
}[];
tags?: Tag[];
@ -223,11 +223,11 @@ export class SymbolProfileService {
tags: symbolProfile?.tags
};
item.activitiesCount = symbolProfile._count.Order;
item.activitiesCount = symbolProfile._count.activities;
delete item._count;
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date;
delete item.Order;
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.activities;
if (item.SymbolProfileOverrides) {
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 {
registerDecorator,
@ -28,17 +28,11 @@ export class IsExtendedCurrencyConstraint
return '$property must be a valid ISO4217 currency code';
}
public validate(currency: any) {
// Return true if currency is a standard ISO 4217 code or a derived currency
public validate(currency: string) {
// Return true if currency is a derived currency or a standard ISO 4217 code
return (
this.isUpperCase(currency) &&
(isISO4217CurrencyCode(currency) ||
[
...DERIVED_CURRENCIES.map((derivedCurrency) => {
return derivedCurrency.currency;
}),
'USX'
].includes(currency))
isDerivedCurrency(currency) ||
(this.isUpperCase(currency) && isISO4217CurrencyCode(currency))
);
}

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

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

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

@ -55,7 +55,8 @@
adminMarketDataService.hasPermissionToDeleteAssetProfile({
activitiesCount: element.activitiesCount,
isBenchmark: element.isBenchmark,
symbol: element.symbol
symbol: element.symbol,
watchedByCount: element.watchedByCount
})
) {
<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({
activitiesCount,
isBenchmark,
symbol
}: Pick<AdminMarketDataItem, 'activitiesCount' | 'isBenchmark' | 'symbol'>) {
symbol,
watchedByCount
}: Pick<
AdminMarketDataItem,
'activitiesCount' | 'isBenchmark' | 'symbol' | 'watchedByCount'
>) {
return (
activitiesCount === 0 &&
!isBenchmark &&
!isDerivedCurrency(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>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[data]="sectors"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[data]="countries"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="countries"
/>
</div>
}

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

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

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

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

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

@ -1,3 +1,15 @@
:host {
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 {
DataProviderGhostfolioStatusResponse,
DataProviderInfo,
User
} from '@ghostfolio/common/interfaces';
@ -21,10 +22,12 @@ import {
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTableDataSource } from '@angular/material/table';
import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialog/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -34,9 +37,12 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-
standalone: false
})
export class AdminSettingsComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<DataProviderInfo>();
public defaultDateFormat: string;
public displayedColumns = ['name', 'assetProfileCount', 'status', 'actions'];
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean;
public isLoading = false;
public pricingUrl: string;
private deviceType: string;
@ -80,6 +86,10 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.initialize();
}
public isGhostfolioDataProvider(provider: DataProviderInfo): boolean {
return provider.dataSource === 'GHOSTFOLIO';
}
public onRemoveGhostfolioApiKey() {
this.notificationService.confirm({
confirmFn: () => {
@ -101,9 +111,8 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
autoFocus: false,
data: {
deviceType: this.deviceType,
pricingUrl: this.pricingUrl,
user: this.user
},
pricingUrl: this.pricingUrl
} as GhostfolioPremiumApiDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}
@ -123,24 +132,45 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
}
private initialize() {
this.adminService
.fetchGhostfolioDataProviderStatus()
.pipe(
catchError(() => {
this.isGhostfolioApiKeyValid = false;
this.isLoading = true;
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);
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.isLoading = false;
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 { 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 { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminSettingsComponent } from './admin-settings.component';
@ -17,10 +20,13 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
GfAssetProfileIconComponent,
GfPremiumIndicatorComponent,
MatButtonModule,
MatCardModule,
MatMenuModule,
MatProgressBarModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
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>
<p class="gf-text-wrap-balance mb-1">
The official
<p class="gf-text-wrap-balance">
Early access to the official
<a
class="align-items-center d-inline-flex"
target="_blank"
@ -18,32 +18,27 @@
</a>
data provider <strong>for self-hosters</strong>, offering
<strong>80’000+ tickers</strong> from over <strong>50 exchanges</strong>, is
coming soon!
</p>
<p i18n>
Want to stay updated? Click below to get notified as soon as it’s available.
ready now!
</p>
<div>
<a
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
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) {
<div>
<small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
>
I have an API key
</button>
}
I have an API key
</button>
</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 {
deviceType: 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="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
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>
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-1" mat-header-cell 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">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="userId"
>
<ng-container i18n>User</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="userId">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="userId">
<ng-container i18n>User</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">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="holdings">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="holdingCount"
>
<ng-container i18n>Holdings</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.holdingCount }}
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="holdings">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="holdingCount"
>
<ng-container i18n>Holdings</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.holdingCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th
*matHeaderCellDef
class="px-1 text-center"
i18n
mat-header-cell
></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.activityCount > 0"
(click)="onDeleteTag(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.activityCount > 0"
(click)="onDeleteTag(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>
</div>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

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

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

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

@ -312,7 +312,7 @@
>About Ghostfolio</a
>
<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>
</li>
</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 marketDataItems: MarketData[] = [];
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public marketPriceMax: number;
public marketPriceMin: number;
public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number;
@ -233,8 +233,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
stakeRewards,
investment,
marketPrice,
maxPrice,
minPrice,
marketPriceMax,
marketPriceMin,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
@ -301,8 +301,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.marketPriceMax = marketPriceMax;
this.marketPriceMin = marketPriceMin;
this.netPerformance = netPerformance;
if (

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

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

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

@ -36,6 +36,14 @@
[locale]="user?.settings?.locale || undefined"
[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>

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
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">
{{ summary?.ordersCount }}
<ng-container i18n>{summary?.ordersCount, plural,
{{ summary?.activityCount }}
<ng-container i18n>{summary?.activityCount, plural,
=1 {activity}
other {activities}
}</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 ms from 'ms';
import { interval, Subject } from 'rxjs';
import { take, takeUntil, tap } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
@ -11,20 +20,47 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
templateUrl: 'subscription-interstitial-dialog.html',
standalone: false
})
export class SubscriptionInterstitialDialog {
private readonly VARIANTS_COUNT = 2;
export class SubscriptionInterstitialDialog implements OnInit {
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 variantIndex: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
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() {
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 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
color="primary"
mat-flat-button
@ -80,8 +89,16 @@
</div>
</div>
<div class="flex-column" mat-dialog-actions>
<button class="mb-2 py-4 w-100" i18n mat-button (click)="closeDialog()">
Skip
<button
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>
<a
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
>
}
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="zh"
>Chinese (<ng-container i18n>Community</ng-container
>)</mat-option
>
}
<mat-option value="zh"
>Chinese (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="es"
>Español (<ng-container i18n>Community</ng-container
>)</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
})
export class FaqOverviewPageComponent implements OnDestroy {
public pricingUrl =
`https://ghostfol.io/${document.documentElement.lang}/` +
$localize`:snake-case:pricing`;
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public user: User;

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

@ -59,9 +59,14 @@
world. The
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
fully available as open source software (OSS). Thanks to our generous
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a> users
and <a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we
have the ability to run a free, limited plan for novice
<a class="align-items-center d-inline-flex" [href]="pricingUrl"
>Ghostfolio Premium<gf-premium-indicator
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
>
</mat-card>
@ -82,8 +87,11 @@
</mat-card-header>
<mat-card-content
>By offering
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>, a
subscription plan with a managed hosting service and enhanced
<a class="align-items-center d-inline-flex" [href]="pricingUrl"
>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
users.</mat-card-content
>
@ -105,7 +113,11 @@
</mat-card-header>
<mat-card-content
>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
in the
<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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
@ -7,7 +9,12 @@ import { FaqOverviewPageComponent } from './faq-overview-page.component';
@NgModule({
declarations: [FaqOverviewPageComponent],
imports: [CommonModule, FaqOverviewPageRoutingModule, MatCardModule],
imports: [
CommonModule,
FaqOverviewPageRoutingModule,
GfPremiumIndicatorComponent,
MatCardModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
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
})
export class SaasPageComponent implements OnDestroy {
public pricingUrl =
`https://ghostfol.io/${document.documentElement.lang}/` +
$localize`:snake-case:pricing`;
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
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-header>
<mat-card-content>
You can sign up via the<a [routerLink]="routerLinkRegister"
>Get Started</a
>” button at the top of the page. You have multiple options to join
Ghostfolio: Create an account with a security token or
<i>Google Sign</i>. We will guide you to set up your portfolio.
You can sign up via the
<a [routerLink]="routerLinkRegister">Get Started</a> button at the top
of the page. You have multiple options to join Ghostfolio: Create an
account with a security token or <i>Google Sign</i>. We will guide you
to set up your portfolio.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
@ -38,8 +38,8 @@
>
<mat-card-content
>Yes, it is! Our
<a [routerLink]="routerLinkPricing">pricing page</a> details
everything you get for free.</mat-card-content
<a target="_blank" [href]="pricingUrl">pricing page</a>
details everything you get for free.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
@ -49,12 +49,20 @@
></mat-card-header
>
<mat-card-content
><a [routerLink]="routerLinkPricing">Ghostfolio Premium</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
><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>
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 appearance="outlined" class="mb-3">
@ -65,8 +73,15 @@
>
<mat-card-content
>Yes, you can try
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> by signing
up for Ghostfolio and applying for a trial (see
<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>
by signing up for Ghostfolio and applying for a trial (see
<a [routerLink]="['/account', 'membership']">Membership</a>). It is
easy, free and there is no commitment. You can stop using it at any
time.</mat-card-content
@ -93,9 +108,17 @@
>
</mat-card-header>
<mat-card-content
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does
not include auto-renewal. Upon expiration, you can choose whether to
start a new subscription.</mat-card-content
>No,
<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>
does not include auto-renewal. Upon expiration, you can choose whether
to start a new subscription.</mat-card-content
>
</mat-card>
@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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
@ -7,7 +9,12 @@ import { SaasPageComponent } from './saas-page.component';
@NgModule({
declarations: [SaasPageComponent],
imports: [CommonModule, MatCardModule, SaasPageRoutingModule],
imports: [
CommonModule,
GfPremiumIndicatorComponent,
MatCardModule,
SaasPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
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
})
export class SelfHostingPageComponent implements OnDestroy {
public pricingUrl =
`https://ghostfol.io/${document.documentElement.lang}/` +
$localize`:snake-case:pricing`;
private unsubscribeSubject = new Subject<void>();
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
>
</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-header>
<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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
@ -7,7 +9,12 @@ import { SelfHostingPageComponent } from './self-hosting-page.component';
@NgModule({
declarations: [SelfHostingPageComponent],
imports: [CommonModule, MatCardModule, SelfHostingPageRoutingModule],
imports: [
CommonModule,
GfPremiumIndicatorComponent,
MatCardModule,
SelfHostingPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
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-content>
<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">
Ghostfolio automatically switches to a dark color theme based
on your operating system's preferences.
Identify potential risks in your portfolio with Ghostfolio
X-ray, the static portfolio analysis.
</p>
</div>
</mat-card-content>
@ -188,10 +193,14 @@
<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>
<h4 class="align-items-center d-flex">
<span i18n>Watchlist</span>
@if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" />
}
</h4>
<p class="m-0">
Keep calm and activate Zen Mode if the markets are going
crazy.
Follow assets you are interested in closely on your watchlist.
</p>
</div>
</mat-card-content>
@ -221,15 +230,23 @@
<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">
<span i18n>Static Analysis</span>
@if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" />
}
</h4>
<h4 class="align-items-center d-flex" i18n>Dark Mode</h4>
<p class="m-0">
Identify potential risks in your portfolio with Ghostfolio
X-ray, the static portfolio analysis.
Ghostfolio automatically switches to a dark color theme based
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>
</div>
</mat-card-content>
@ -243,9 +260,8 @@
<p class="m-0">
Use Ghostfolio in multiple languages: English,
<!-- Català, -->
<!-- Chinese, -->
Dutch, French, German, Italian, Polish, Portuguese, Spanish
and Turkish
Chinese, Dutch, French, German, Italian, Polish, Portuguese,
Spanish and Turkish
<!-- and Ukrainian -->
are currently supported.
</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 { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.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 { NgModule } from '@angular/core';
@ -36,6 +37,11 @@ const routes: Routes = [
path: 'market',
component: HomeMarketComponent,
title: $localize`Markets`
},
{
path: 'watchlist',
component: HomeWatchlistComponent,
title: $localize`Watchlist`
}
],
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`,
path: ['/home', 'summary']
},
{
iconName: 'bookmark-outline',
label: $localize`Watchlist`,
path: ['/home', 'watchlist']
},
{
iconName: 'newspaper-outline',
label: $localize`Markets`,
path: ['/home', 'market']
}
];
this.user = state.user;
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 { GfHomeOverviewModule } from '@ghostfolio/client/components/home-overview/home-overview.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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -20,6 +21,7 @@ import { HomePageComponent } from './home-page.component';
GfHomeOverviewModule,
GfHomeSummaryModule,
HomePageRoutingModule,
HomeWatchlistComponent,
MatTabsModule,
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) };
});
public currencies: string[] = [];
public currencyOfAssetProfile: string;
public currentMarketPrice = null;
public defaultDateFormat: string;
public isLoading = false;
@ -63,8 +64,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) {}
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.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo();
@ -214,7 +217,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('type').value
)
) {
this.updateSymbol();
this.updateAssetProfile();
}
this.changeDetectorRef.markForCheck();
@ -242,7 +245,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.get('dataSource')
.removeValidators(Validators.required);
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').updateValueAndValidity();
this.activityForm.get('quantity').setValue(1);
@ -252,11 +255,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('searchSymbol').updateValueAndValidity();
this.activityForm.get('updateAccountBalance').disable();
this.activityForm.get('updateAccountBalance').setValue(false);
} else if (
type === 'FEE' ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
} else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm
.get('accountId')
.removeValidators(Validators.required);
@ -275,12 +274,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity();
if (
(type === 'FEE' && this.activityForm.get('fee').value === 0) ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm.get('fee').reset();
if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('fee').setValue(0);
}
this.activityForm.get('name').setValidators(Validators.required);
@ -288,7 +283,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (type === 'FEE') {
this.activityForm.get('quantity').setValue(0);
} else if (type === 'INTEREST' || type === 'LIABILITY') {
} else if (['INTEREST', 'LIABILITY'].includes(type)) {
this.activityForm.get('quantity').setValue(1);
}
@ -409,7 +404,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.dialogRef.close(activity);
} else {
(activity as UpdateOrderDto).id = this.data.activity.id;
(activity as UpdateOrderDto).id = this.data.activity?.id;
await validateObjectForForm({
classDto: UpdateOrderDto,
@ -434,7 +429,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.unsubscribeSubject.complete();
}
private updateSymbol() {
private updateAssetProfile() {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
@ -462,6 +457,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('dataSource').setValue(dataSource);
}
this.currencyOfAssetProfile = currency;
this.currentMarketPrice = marketPrice;
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>
</mat-form-field>
@if (
currencyOfAssetProfile ===
activityForm.get('currencyOfUnitPrice').value &&
currentMarketPrice &&
(data.activity.type === 'BUY' || data.activity.type === 'SELL') &&
['BUY', 'SELL'].includes(data.activity.type) &&
isToday(activityForm.get('date')?.value)
) {
<button

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

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

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

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

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

@ -2,7 +2,7 @@
<div class="row">
<div class="col">
<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!
</h1>
</div>
@ -72,9 +72,9 @@
<mat-card-content>
<gf-portfolio-proportion-chart
class="mx-auto"
[data]="symbols"
[isInPercent]="true"
[keys]="['symbol']"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
/>
</mat-card-content>
@ -90,10 +90,10 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[data]="positions"
[isInPercent]="true"
[keys]="['currency']"
[maxItems]="10"
[positions]="positions"
/>
</mat-card-content>
</mat-card>
@ -107,10 +107,10 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[data]="sectors"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
/>
</mat-card-content>
</mat-card>
@ -126,9 +126,9 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[data]="continents"
[isInPercent]="true"
[keys]="['name']"
[positions]="continents"
/>
</mat-card-content>
</mat-card>
@ -198,10 +198,10 @@
<div class="row">
<div class="col-lg">
<gf-holdings-table
[data]="holdings"
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
/>
</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 {
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN,
PROPERTY_API_KEY_GHOSTFOLIO
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import {
@ -24,7 +23,6 @@ import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull';
import { switchMap } from 'rxjs';
import { environment } from '../../environments/environment';
import { DataService } from './data.service';
@ -115,19 +113,15 @@ export class AdminService {
});
}
public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe(
switchMap(({ settings }) => {
const headers = new HttpHeaders({
[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`,
{ headers }
);
})
public fetchGhostfolioDataProviderStatus(aApiKey: string) {
const headers = new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Api-Key ${aApiKey}`
});
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${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 { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-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 {
Activities,
Activity
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
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 { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
@ -24,7 +24,7 @@ import {
Access,
AccessTokenResponse,
AccountBalancesResponse,
Accounts,
AccountsResponse,
AiPromptResponse,
ApiKeyResponse,
AssetProfileIdentifier,
@ -39,12 +39,14 @@ import {
OAuthResponse,
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioReportResponse,
PublicPortfolioResponse,
User
User,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import type {
@ -189,7 +191,7 @@ export class DataService {
public fetchAccounts({ filters }: { filters?: Filter[] } = {}) {
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({
@ -325,6 +327,10 @@ export class DataService {
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() {
return this.http.get<Access[]>('/api/v1/access');
}
@ -400,13 +406,13 @@ export class DataService {
symbol: string;
}) {
return this.http
.get<PortfolioHoldingDetail>(
`/api/v1/portfolio/position/${dataSource}/${symbol}`
.get<PortfolioHoldingResponse>(
`/api/v1/portfolio/holding/${dataSource}/${symbol}`
)
.pipe(
map((data) => {
if (data.orders) {
for (const order of data.orders) {
if (data.activities) {
for (const order of data.activities) {
order.createdAt = parseISO(order.createdAt 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');
}
public fetchWatchlist() {
return this.http.get<WatchlistResponse>('/api/v1/watchlist');
}
public generateAccessToken(aUserId: string) {
return this.http.post<AccessTokenResponse>(
`/api/v1/user/${aUserId}/access-token`,
@ -759,6 +769,10 @@ export class DataService {
return this.http.post<UserItem>('/api/v1/user', {});
}
public postWatchlistItem(watchlistItem: CreateWatchlistItemDto) {
return this.http.post('/api/v1/watchlist', watchlistItem);
}
public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount);
}
@ -773,7 +787,7 @@ export class DataService {
tags
}: { tags: Tag[] } & AssetProfileIdentifier) {
return this.http.put<void>(
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
`/api/v1/portfolio/holding/${dataSource}/${symbol}/tags`,
{ tags }
);
}

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

Loading…
Cancel
Save