Browse Source

Merge pull request #162 from dandevaud/mr/upstream-changes-2025-04-02

Mr/upstream changes 2025 04 02
pull/5027/head
dandevaud 3 months ago
committed by GitHub
parent
commit
9c50d444a6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 0
      .husky/pre-push
  2. 65
      CHANGELOG.md
  3. 4
      DEVELOPMENT.md
  4. 4
      README.md
  5. 31
      apps/api/src/app/admin/admin.controller.ts
  6. 188
      apps/api/src/app/admin/admin.service.ts
  7. 20
      apps/api/src/app/admin/update-asset-profile.dto.ts
  8. 2
      apps/api/src/app/app.module.ts
  9. 8
      apps/api/src/app/auth/auth.service.ts
  10. 27
      apps/api/src/app/endpoints/ai/ai.controller.ts
  11. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  12. 4
      apps/api/src/app/endpoints/ai/ai.service.ts
  13. 46
      apps/api/src/app/endpoints/assets/assets.controller.ts
  14. 11
      apps/api/src/app/endpoints/assets/assets.module.ts
  15. 3
      apps/api/src/app/export/export.controller.ts
  16. 96
      apps/api/src/app/export/export.service.ts
  17. 10
      apps/api/src/app/import/create-account-with-balances.dto.ts
  18. 7
      apps/api/src/app/import/import-data.dto.ts
  19. 4
      apps/api/src/app/import/import.service.ts
  20. 77
      apps/api/src/app/order/order.service.ts
  21. 4
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  22. 2
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  23. 79
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  24. 22
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  25. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  26. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  27. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  28. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  29. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  30. 39
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  31. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts
  32. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  33. 23
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  34. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  35. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  36. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  37. 0
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  38. 972
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  39. 1038
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  40. 96
      apps/api/src/app/portfolio/portfolio.service.ts
  41. 8
      apps/api/src/app/sitemap/sitemap.controller.ts
  42. 35
      apps/api/src/app/user/user.controller.ts
  43. 35
      apps/api/src/app/user/user.service.ts
  44. 684
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  45. 4
      apps/api/src/assets/site.webmanifest
  46. 4
      apps/api/src/assets/sitemap.xml
  47. 3
      apps/api/src/environments/environment.prod.ts
  48. 3
      apps/api/src/environments/environment.ts
  49. 2
      apps/api/src/helper/object.helper.spec.ts
  50. 2
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  51. 43
      apps/api/src/main.ts
  52. 6
      apps/api/src/middlewares/html-template.middleware.ts
  53. 17
      apps/api/src/services/configuration/configuration.service.ts
  54. 2
      apps/api/src/services/cron.service.ts
  55. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  56. 21
      apps/api/src/services/data-provider/data-provider.service.ts
  57. 21
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  58. 4
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  59. 1
      apps/api/src/services/interfaces/environment.interface.ts
  60. 16
      apps/api/src/services/market-data/market-data.service.ts
  61. 21
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  62. 2
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  63. 143
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  64. 8
      apps/client/ngsw-config.json
  65. 3
      apps/client/project.json
  66. 15
      apps/client/src/app/app.component.html
  67. 4
      apps/client/src/app/app.component.ts
  68. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  69. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  70. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  71. 177
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  72. 590
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  73. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  74. 5
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  75. 44
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  76. 67
      apps/client/src/app/components/admin-overview/admin-overview.html
  77. 45
      apps/client/src/app/components/admin-users/admin-users.component.ts
  78. 11
      apps/client/src/app/components/admin-users/admin-users.html
  79. 2
      apps/client/src/app/components/header/header.component.html
  80. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  81. 4
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  82. 4
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  83. 3
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  84. 3
      apps/client/src/app/core/paths.ts
  85. 7
      apps/client/src/app/pages/about/about-page-routing.module.ts
  86. 13
      apps/client/src/app/pages/about/about-page.component.ts
  87. 4
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts
  88. 8
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.scss
  89. 21
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page-routing.module.ts
  90. 17
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts
  91. 10
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.html
  92. 17
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.module.ts
  93. 29
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.scss
  94. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  95. 32
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  96. 90
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  97. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts
  98. 17
      apps/client/src/app/pages/register/register-page.component.ts
  99. 4
      apps/client/src/app/pages/register/show-access-token-dialog/interfaces/interfaces.ts
  100. 9
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts

0
.husky/pre-commit → .husky/pre-push

65
CHANGELOG.md

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 2.149.0 - 2025-03-30
### Changed
@ -96,7 +96,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for changing the asset profile identifier (`dataSource` and `symbol`) in the asset profile details dialog of the admin control panel (experimental)
- Set up the terms of service for the _Ghostfolio_ SaaS (cloud)
### Changed
- Improved the static portfolio analysis rule: Emergency fund setup by supporting assets
- Restricted the historical market data gathering to active asset profiles
- Improved the language localization for German (`de`)
- Upgraded `Nx` from version `20.5.0` to `20.6.4`
## 2.148.0 - 2025-03-24
### Added
- Added the `isActive` flag to the asset profile model
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ngx-skeleton-loader` from version `9.0.0` to `10.0.0`
## 2.147.0 - 2025-03-22
### Added
- Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
- Added support for generating a new _Security Token_ via the users table of the admin control panel
- Added an endpoint to localize the `site.webmanifest`
- Added the _Storybook_ path to the `sitemap.xml` file
### Changed
- Improved the export functionality by applying filters on accounts and tags
- Improved the symbol validation in the _Yahoo Finance_ service (get asset profiles)
- Eliminated `firstOrderDate` from the summary of the portfolio details endpoint in favor of using `dateOfFirstActivity` from the user endpoint
- Refactored `lodash.uniq` with `Array.from(new Set(...))`
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Turkish (`tr`)
### Fixed
- Fixed an issue in the activities import functionality related to the account balances
- Changed client-side dates to be sent in UTC format to ensure date consistency
- Benchmark endpoint
- Exchange rate endpoint
## 2.146.0 - 2025-03-15
### Changed
- Improved the usability of the user account registration
- Improved the usability of the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
- Formatted the name in the _Financial Modeling Prep_ service
- Removed the exchange rates from the overview of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `19.0.5` to `19.2.1`
- Upgraded `Nx` from version `20.3.2` to `20.5.0`
- Upgraded `prettier` from version `3.5.1` to `3.5.3`
- Upgraded `prisma` from version `6.4.1` to `6.5.0`
### Fixed
- Fixed an issue with serving _Storybook_ related to the `contentSecurityPolicy`
## 2.145.1 - 2025-03-10

4
DEVELOPMENT.md

@ -60,6 +60,10 @@ Remove permission in `UserService` using `without()`
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git
### Rebase

4
README.md

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[**Blog**](https://ghostfol.io/en/blog) | [**LinkedIn**](https://www.linkedin.com/company/ghostfolio) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio)
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions

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

@ -83,7 +83,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -110,7 +110,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMissing(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
const promises = assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return this.dataGatheringService.gatherSymbolMissingOnly({
@ -127,7 +127,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -366,21 +366,24 @@ export class AdminController {
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
}),
disconnect: assetProfileData.tagsDisconnected?.map(({ id }) => ({ id }))
return this.adminService.patchAssetProfileData(
{ dataSource, symbol },
{
...assetProfile,
tags: {
connect: assetProfile.tags?.map(({ id }) => {
return { id };
}),
disconnect: assetProfile.tagsDisconnected?.map(({ id }) => ({
id
}))
}
}
});
);
}
@HasPermission(permissions.accessAdminControl)

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

@ -10,7 +10,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
@ -33,7 +32,12 @@ import {
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
BadRequestException,
HttpException,
Injectable,
Logger
} from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -44,6 +48,7 @@ import {
DataSource
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
@Injectable()
@ -132,31 +137,6 @@ export class AdminService {
}
public async get(): Promise<AdminData> {
const exchangeRates = this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return {
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
});
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
@ -164,7 +144,6 @@ export class AdminService {
]);
return {
exchangeRates,
settings,
transactionCount,
userCount,
@ -495,63 +474,126 @@ export class AdminService {
return { count, users };
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
tags,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
public async patchAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
{
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
dataSource: newDataSource,
holdings,
name,
tags,
scraperConfiguration,
sectors,
symbol,
symbol: newSymbol,
symbolMapping,
tags,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
url
}: Prisma.SymbolProfileUpdateInput
) {
if (
newSymbol &&
newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource)
) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]);
if (assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.CONFLICT),
StatusCodes.CONFLICT
);
}
try {
Promise.all([
await this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
})
};
),
await this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
)
]);
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
])?.[0];
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
} else {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
symbol
}
]);
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
tags,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
updatedSymbolProfile
);
return symbolProfile;
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
])?.[0];
}
}
public async putSetting(key: string, value: string) {

20
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -1,6 +1,12 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
Tag
} from '@prisma/client';
import {
IsArray,
IsEnum,
@ -19,8 +25,8 @@ export class UpdateAssetProfileDto {
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
@IsOptional()
@IsString()
comment?: string;
@IsArray()
@ -31,8 +37,12 @@ export class UpdateAssetProfileDto {
@IsOptional()
currency?: string;
@IsString()
@IsEnum(DataSource, { each: true })
@IsOptional()
dataSource?: DataSource;
@IsOptional()
@IsString()
name?: string;
@IsArray()
@ -51,6 +61,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
sectors?: Prisma.InputJsonArray;
@IsOptional()
@IsString()
symbol?: string;
@IsObject()
@IsOptional()
symbolMapping?: {

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

@ -32,6 +32,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
@ -61,6 +62,7 @@ import { UserModule } from './user/user.module';
AiModule,
ApiKeysModule,
AssetModule,
AssetsModule,
AuthDeviceModule,
AuthModule,
BenchmarksModule,

8
apps/api/src/app/auth/auth.service.ts

@ -20,10 +20,10 @@ export class AuthService {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken }

27
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE
@ -8,7 +9,14 @@ import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Param, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -18,6 +26,7 @@ import { AiService } from './ai.service';
export class AiController {
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -25,9 +34,23 @@ export class AiController {
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(
@Param('mode') mode: AiPromptMode
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const prompt = await this.aiService.getPrompt({
filters,
mode,
impersonationId: undefined,
languageCode:

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

@ -7,6 +7,7 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
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 { 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';
@ -25,6 +26,7 @@ import { AiService } from './ai.service';
@Module({
controllers: [AiController],
imports: [
ApiModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,

4
apps/api/src/app/endpoints/ai/ai.service.ts

@ -1,4 +1,5 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -8,12 +9,14 @@ export class AiService {
public constructor(private readonly portfolioService: PortfolioService) {}
public async getPrompt({
filters,
impersonationId,
languageCode,
mode,
userCurrency,
userId
}: {
filters?: Filter[];
impersonationId: string;
languageCode: string;
mode: AiPromptMode;
@ -21,6 +24,7 @@ export class AiService {
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId
});

46
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -0,0 +1,46 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { interpolate } from '@ghostfolio/common/helper';
import {
Controller,
Get,
Param,
Res,
Version,
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('assets')
export class AssetsController {
private webManifest = '';
public constructor(
public readonly configurationService: ConfigurationService
) {
try {
this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'),
'utf8'
);
} catch {}
}
@Get('/:languageCode/site.webmanifest')
@Version(VERSION_NEUTRAL)
public getWebManifest(
@Param('languageCode') languageCode: string,
@Res() response: Response
): void {
const rootUrl = this.configurationService.get('ROOT_URL');
const webManifest = interpolate(this.webManifest, {
languageCode,
rootUrl
});
response.setHeader('Content-Type', 'application/json');
response.send(webManifest);
}
}

11
apps/api/src/app/endpoints/assets/assets.module.ts

@ -0,0 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
@Module({
controllers: [AssetsController],
providers: [ConfigurationService]
})
export class AssetsModule {}

3
apps/api/src/app/export/export.controller.ts

@ -21,10 +21,11 @@ export class ExportController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[],
@Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,

96
apps/api/src/app/export/export.service.ts

@ -28,6 +28,22 @@ export class ExportService {
}): Promise<Export> {
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
if (activityIds?.length > 0) {
activities = activities.filter(({ id }) => {
return activityIds.includes(id);
});
}
const accounts = (
await this.accountService.accounts({
include: {
@ -39,57 +55,55 @@ export class ExportService {
},
where: { userId }
})
).map(
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
Platform: platform,
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
)
.filter(({ id }) => {
return activities.length > 0
? activities.some(({ accountId }) => {
return accountId === id;
})
: true;
})
.map(
({
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
balances,
comment,
currency,
id,
isExcluded,
name,
Platform: platform,
platformId
};
}
);
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
if (activityIds) {
activities = activities.filter((activity) => {
return activityIds.includes(activity.id);
});
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
const tags = (await this.tagService.getTagsForUser(userId))
.filter(({ isUsed }) => {
return isUsed;
})
.filter(
({ id, isUsed }) =>
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
)
.map(({ id, name }) => {
return {
id,

10
apps/api/src/app/import/create-account-with-balances.dto.ts

@ -0,0 +1,10 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance;
}

7
apps/api/src/app/import/import-data.dto.ts

@ -1,15 +1,16 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
accounts: CreateAccountWithBalancesDto[];
@IsArray()
@Type(() => CreateOrderDto)

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

@ -300,6 +300,7 @@ export class ImportService {
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
@ -375,6 +376,7 @@ export class ImportService {
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
@ -586,7 +588,7 @@ export class ImportService {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources();
const dataSources = await this.dataProviderService.getDataSources({ user });
for (const [
index,

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

@ -60,43 +60,46 @@ export class OrderService {
}
]);
const symbolProfile: EnhancedSymbolProfile = promis[0];
const result = await this.symbolProfileService.updateSymbolProfile({
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries.reduce(
(all, v) => [...all, { code: v.code, weight: v.weight }],
[]
),
currency: symbolProfile.currency,
dataSource,
holdings: symbolProfile.holdings.reduce(
(all, v) => [
...all,
{ name: v.name, weight: v.allocationInPercentage }
],
[]
),
name: symbolProfile.name,
sectors: symbolProfile.sectors.reduce(
(all, v) => [...all, { name: v.name, weight: v.weight }],
[]
),
symbol,
tags: {
connectOrCreate: tags.map(({ id, name }) => {
return {
create: {
id,
name
},
where: {
id
}
};
})
},
url: symbolProfile.url
});
const result = await this.symbolProfileService.updateSymbolProfile(
{ dataSource, symbol },
{
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries.reduce(
(all, v) => [...all, { code: v.code, weight: v.weight }],
[]
),
currency: symbolProfile.currency,
dataSource,
holdings: symbolProfile.holdings.reduce(
(all, v) => [
...all,
{ name: v.name, weight: v.allocationInPercentage }
],
[]
),
name: symbolProfile.name,
sectors: symbolProfile.sectors.reduce(
(all, v) => [...all, { name: v.name, weight: v.weight }],
[]
),
symbol,
tags: {
connectOrCreate: tags.map(({ id, name }) => {
return {
create: {
id,
name
},
where: {
id
}
};
})
},
url: symbolProfile.url
}
);
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),

4
apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts

@ -25,9 +25,9 @@ import {
import { CurrentRateService } from '../../current-rate.service';
import { DateQuery } from '../../interfaces/date-query.interface';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { TWRPortfolioCalculator } from '../twr/portfolio-calculator';
import { RoaiPortfolioCalculator } from '../roai/portfolio-calculator';
export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
export class CPRPortfolioCalculator extends RoaiPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};

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

@ -5,7 +5,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}

79
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -9,13 +9,14 @@ import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service';
import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
ROAI = 'ROAI', // Return on Average Investment
TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
}
@ -27,8 +28,7 @@ export class PortfolioCalculatorFactory {
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly orderService: OrderService
private readonly redisCacheService: RedisCacheService
) {}
@LogPerformance
@ -49,7 +49,20 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator {
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
return new MwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.ROAI:
return new RoaiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
@ -62,37 +75,31 @@ export class PortfolioCalculatorFactory {
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new CPRPortfolioCalculator(
{
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
},
this.orderService
);
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
});
case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator(
{
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
},
this.orderService
);
return new RoaiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
});
default:
throw new Error('Invalid calculation type');
}

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

@ -49,7 +49,7 @@ import {
min,
subDays
} from 'date-fns';
import { isNumber, sortBy, sum, uniq, uniqBy } from 'lodash';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
@ -199,10 +199,7 @@ export abstract class PortfolioCalculator {
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
dataGatheringItems.push({
dataSource,
symbol
});
dataGatheringItems.push({ dataSource, symbol });
currencies[symbol] = currency;
}
@ -219,7 +216,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate),
startDate: this.startDate,
targetCurrency: this.currency
@ -231,17 +228,12 @@ export abstract class PortfolioCalculator {
values: marketSymbols
} = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery: {
gte: this.startDate,
lt: this.endDate
}
dateQuery: { gte: this.startDate, lt: this.endDate }
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
const marketSymbolMap: { [date: string]: { [symbol: string]: Big } } = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
@ -1111,9 +1103,7 @@ export abstract class PortfolioCalculator {
chartDateMap: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
marketSymbolMap: { [date: string]: { [symbol: string]: Big } };
start: Date;
} & AssetProfileIdentifier): SymbolMetrics;

9
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -79,8 +79,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -138,7 +137,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -177,9 +176,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') },
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),

9
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -79,8 +79,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -123,7 +122,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -164,9 +163,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') },
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),

5
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -79,8 +79,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

5
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -92,8 +92,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -137,7 +136,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

5
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -79,8 +79,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

39
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -92,8 +92,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -121,7 +120,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -162,9 +161,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.851974')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') },
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
@ -200,30 +197,12 @@ describe('PortfolioCalculator', () => {
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 },
{
date: '2023-02-01',
investment: 0
},
{
date: '2023-03-01',
investment: 0
},
{
date: '2023-04-01',
investment: 0
},
{
date: '2023-05-01',
investment: 0
},
{
date: '2023-06-01',
investment: 0
},
{
date: '2023-07-01',
investment: 0
}
{ date: '2023-02-01', investment: 0 },
{ date: '2023-03-01', investment: 0 },
{ date: '2023-04-01', investment: 0 },
{ date: '2023-05-01', investment: 0 },
{ date: '2023-06-01', investment: 0 },
{ date: '2023-07-01', investment: 0 }
]);
});
});

5
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts

@ -79,8 +79,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

5
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -79,8 +79,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});

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

@ -92,8 +92,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -136,7 +135,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
@ -155,25 +154,25 @@ describe('PortfolioCalculator', () => {
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformance: new Big('33.25'),
grossPerformancePercentage: new Big('0.11136043941322258691'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11343693482483756447'
'0.11136043941322258691'
),
grossPerformanceWithCurrencyEffect: new Big('33.87'),
grossPerformanceWithCurrencyEffect: new Big('33.25'),
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
netPerformance: new Big('14.87'),
netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformance: new Big('14.25'),
netPerformancePercentage: new Big('0.04772590260566682296'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04980239801728180052')
max: new Big('0.04772590260566682296')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'),
'5y': new Big('14.87'),
max: new Big('14.87'),
'5y': new Big('14.25'),
max: new Big('14.25'),
wtd: new Big('-5.39')
},
quantity: new Big('1'),

5
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -74,8 +74,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -85,7 +84,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

9
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -93,8 +93,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -117,7 +116,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -158,9 +157,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.12348284960422163588')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('17.68')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') },
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),

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

@ -93,8 +93,7 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
redisCacheService
);
});
@ -117,7 +116,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});

0
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts

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

@ -0,0 +1,972 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0),
unitPrices: {}
};
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0),
unitPrices: {}
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect,
unitPrices: marketSymbolMap[endDateString]
};
}
}

1038
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

File diff suppressed because it is too large

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

@ -83,7 +83,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty, uniq, uniqBy } from 'lodash';
import { isEmpty, uniqBy } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
@ -301,7 +301,7 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency
});
@ -379,7 +379,7 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency
});
@ -580,7 +580,7 @@ export class PortfolioService {
const emergencyFundInCash = emergencyFund
.minus(
this.getEmergencyFundPositionsValueInBaseCurrency({
this.getEmergencyFundHoldingsValueInBaseCurrency({
holdings
})
)
@ -619,8 +619,8 @@ export class PortfolioService {
userCurrency,
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({
holdings
})
});
@ -693,7 +693,7 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency
});
@ -976,7 +976,7 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency
});
@ -1146,7 +1146,7 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency
});
@ -1292,7 +1292,11 @@ export class PortfolioService {
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
@ -1616,7 +1620,7 @@ export class PortfolioService {
}
@LogPerformance
private getEmergencyFundPositionsValueInBaseCurrency({
private getEmergencyFundHoldingsValueInBaseCurrency({
holdings
}: {
holdings: PortfolioDetails['holdings'];
@ -1631,14 +1635,14 @@ export class PortfolioService {
);
});
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
let valueInBaseCurrencyOfEmergencyFundHoldings = new Big(0);
for (const { valueInBaseCurrency } of emergencyFundHoldings) {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
valueInBaseCurrencyOfEmergencyFundHoldings =
valueInBaseCurrencyOfEmergencyFundHoldings.plus(valueInBaseCurrency);
}
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber();
}
private getInitialCashPosition({
@ -1808,7 +1812,7 @@ export class PortfolioService {
@LogPerformance
private async getSummary({
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency,
filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator,
@ -1816,7 +1820,7 @@ export class PortfolioService {
userId
}: {
balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number;
emergencyFundHoldingsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big;
impersonationId: string;
portfolioCalculator: PortfolioCalculator;
@ -1860,12 +1864,10 @@ export class PortfolioService {
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const emergencyFund = new Big(
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
)
);
const totalEmergencyFund = this.getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings: user.Settings?.settings as UserSettings
});
const fees = await portfolioCalculator.getFeesInBaseCurrency();
@ -1891,8 +1893,8 @@ export class PortfolioService {
}).toNumber();
const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency)
.minus(totalEmergencyFund)
.plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy)
@ -1957,7 +1959,6 @@ export class PortfolioService {
annualizedPerformancePercentWithCurrencyEffect,
cash,
excludedAccountsAndActivities,
firstOrderDate,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
@ -1968,11 +1969,11 @@ export class PortfolioService {
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
cash: emergencyFund
.minus(emergencyFundPositionsValueInBaseCurrency)
assets: emergencyFundHoldingsValueInBaseCurrency,
cash: totalEmergencyFund
.minus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(),
total: emergencyFund.toNumber()
total: totalEmergencyFund.toNumber()
},
fees: fees.toNumber(),
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
@ -1980,7 +1981,7 @@ export class PortfolioService {
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency)
.minus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(),
grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformanceWithCurrencyEffect: new Big(
@ -2026,6 +2027,21 @@ export class PortfolioService {
);
}
private getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings
}: {
emergencyFundHoldingsValueInBaseCurrency: number;
userSettings: UserSettings;
}) {
return new Big(
Math.max(
emergencyFundHoldingsValueInBaseCurrency,
userSettings?.emergencyFund ?? 0
)
);
}
private getUserCurrency(aUser?: UserWithSettings) {
return (
aUser?.Settings?.settings.baseCurrency ??
@ -2073,14 +2089,16 @@ export class PortfolioService {
where: { id: filters[0].id }
});
} else {
const accountIds = uniq(
activities
.filter(({ accountId }) => {
return accountId;
})
.map(({ accountId }) => {
return accountId;
})
const accountIds = Array.from(
new Set(
activities
.filter(({ accountId }) => {
return accountId;
})
.map(({ accountId }) => {
return accountId;
})
)
);
currentAccounts = await this.accountService.accounts({

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

@ -9,8 +9,8 @@ import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('sitemap.xml')
export class SitemapController {
@ -20,8 +20,8 @@ export class SitemapController {
private readonly configurationService: ConfigurationService
) {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
this.sitemapXml = readFileSync(
join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}

35
apps/api/src/app/user/user.controller.ts

@ -1,8 +1,13 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import {
AccessTokenResponse,
User,
UserSettings
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -36,6 +41,7 @@ export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@ -47,10 +53,10 @@ export class UserController {
public async deleteOwnUser(
@Body() data: DeleteOwnUserDto
): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken(
data.accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const hashedAccessToken = this.userService.createAccessToken({
password: data.accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id }
@ -85,6 +91,25 @@ export class UserController {
});
}
@HasPermission(permissions.accessAdminControl)
@Post(':id/access-token')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateAccessToken(
@Param('id') id: string
): Promise<AccessTokenResponse> {
const { accessToken, hashedAccessToken } =
this.userService.generateAccessToken({
userId: id
});
await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id }
});
return { accessToken };
}
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser(

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

@ -67,13 +67,33 @@ export class UserService {
return this.prismaService.user.count(args);
}
public createAccessToken(password: string, salt: string): string {
public createAccessToken({
password,
salt
}: {
password: string;
salt: string;
}): string {
const hash = createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public generateAccessToken({ userId }: { userId: string }) {
const accessToken = this.createAccessToken({
password: userId,
salt: getRandomString(10)
});
const hashedAccessToken = this.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
return { accessToken, hashedAccessToken };
}
public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
@ -433,7 +453,7 @@ export class UserService {
data.provider = 'ANONYMOUS';
}
let user = await this.prismaService.user.create({
const user = await this.prismaService.user.create({
data: {
...data,
Account: {
@ -464,14 +484,11 @@ export class UserService {
}
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(user.id, getRandomString(10));
const hashedAccessToken = this.createAccessToken(
accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const { accessToken, hashedAccessToken } = this.generateAccessToken({
userId: user.id
});
user = await this.prismaService.user.update({
await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id: user.id }
});

684
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

4
apps/client/src/assets/site.webmanifest → apps/api/src/assets/site.webmanifest

@ -25,7 +25,7 @@
"name": "Ghostfolio",
"orientation": "portrait",
"short_name": "Ghostfolio",
"start_url": "/en/",
"start_url": "/${languageCode}/",
"theme_color": "#FFFFFF",
"url": "https://ghostfol.io"
"url": "${rootUrl}"
}

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

@ -92,6 +92,10 @@
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/development/storybook</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

3
apps/api/src/environments/environment.prod.ts

@ -1,4 +1,7 @@
import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config';
export const environment = {
production: true,
rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
version: `${require('../../../../package.json').version}`
};

3
apps/api/src/environments/environment.ts

@ -1,4 +1,7 @@
import { DEFAULT_HOST } from '@ghostfolio/common/config';
export const environment = {
production: false,
rootUrl: `https://${DEFAULT_HOST}:4200`,
version: 'dev'
};

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

@ -1519,7 +1519,6 @@ describe('redactAttributes', () => {
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null,
excludedAccountsAndActivities: null,
firstOrderDate: '2017-01-02T23:00:00.000Z',
netPerformance: null,
netPerformancePercentage: 2.3039314216696174,
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,
@ -3023,7 +3022,6 @@ describe('redactAttributes', () => {
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null,
excludedAccountsAndActivities: null,
firstOrderDate: '2017-01-02T23:00:00.000Z',
netPerformance: null,
netPerformancePercentage: 2.3039314216696174,
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,

2
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -26,7 +26,7 @@ export class TransformDataSourceInRequestInterceptor<T>
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.activities) {
if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;

43
apps/api/src/main.ts

@ -1,3 +1,9 @@
import {
DEFAULT_HOST,
DEFAULT_PORT,
STORYBOOK_PATH
} from '@ghostfolio/common/config';
import {
Logger,
LogLevel,
@ -7,6 +13,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
@ -50,26 +57,30 @@ async function bootstrap() {
app.useBodyParser('json', { limit: '10mb' });
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})
);
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) {
next();
} else {
helmet({
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})(req, res, next);
}
});
}
app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT;
await app.listen(PORT, HOST, () => {
logLogo();

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

@ -2,7 +2,7 @@ import { environment } from '@ghostfolio/api/environments/environment';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL,
STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
@ -125,11 +125,11 @@ export const HtmlTemplateMiddleware = async (
}
const currentDate = format(new Date(), DATE_FORMAT);
const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL;
const rootUrl = process.env.ROOT_URL || environment.rootUrl;
if (
path.startsWith('/api/') ||
path.startsWith('/development/storybook') ||
path.startsWith(STORYBOOK_PATH) ||
isFileRequest(path) ||
!environment.production
) {

17
apps/api/src/services/configuration/configuration.service.ts

@ -1,11 +1,13 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import {
CACHE_TTL_NO_CACHE,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
DEFAULT_ROOT_URL
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
@ -38,6 +40,9 @@ export class ConfigurationService {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({
default: []
}),
DATA_SOURCES_LEGACY: json({
default: []
}),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
@ -49,11 +54,11 @@ export class ConfigurationService {
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
HOST: host({ default: DEFAULT_HOST }),
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
PORT: port({ default: 3333 }),
PORT: port({ default: DEFAULT_PORT }),
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY
}),
@ -71,7 +76,9 @@ export class ConfigurationService {
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: ms('3 seconds') }),
ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
ROOT_URL: url({
default: environment.rootUrl
}),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),

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

@ -57,7 +57,7 @@ export class CronService {
public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {

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

@ -170,6 +170,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
symbol = quotes[0].symbol;
}
} catch {}
} else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) {
throw new Error(`${symbol} is not valid`);
} else {
symbol = this.convertToYahooFinanceSymbol(symbol);
}

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

@ -31,7 +31,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns';
import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms';
@ -155,9 +155,22 @@ export class DataProviderService {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')];
}
public async getDataSources(): Promise<DataSource[]> {
public async getDataSources({
user
}: {
user: UserWithSettings;
}): Promise<DataSource[]> {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
if (
isBefore(user.createdAt, new Date('2025-03-23')) &&
this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0
) {
dataSourcesKey = 'DATA_SOURCES_LEGACY';
}
const dataSources: DataSource[] = this.configurationService
.get('DATA_SOURCES')
.get(dataSourcesKey)
.map((dataSource) => {
return DataSource[dataSource];
});
@ -631,7 +644,7 @@ export class DataProviderService {
return { items: lookupItems };
}
const dataSources = await this.getDataSources();
const dataSources = await this.getDataSources({ user });
const dataProviderServices = dataSources.map((dataSource) => {
return this.getDataProvider(DataSource[dataSource]);

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

@ -12,6 +12,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { REPLACE_NAME_PARTS } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
@ -186,7 +187,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
response.isin = assetProfile.isin;
}
response.name = assetProfile.companyName;
response.name = this.formatName({ name: assetProfile.companyName });
if (assetProfile.website) {
response.url = assetProfile.website;
@ -398,7 +399,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: companyName
name: this.formatName({ name: companyName })
};
});
} else {
@ -414,12 +415,12 @@ export class FinancialModelingPrepService implements DataProviderInterface {
items = result.map(({ currency, name, symbol }) => {
return {
currency,
name,
symbol,
assetClass: undefined, // TODO
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName()
dataSource: this.getName(),
name: this.formatName({ name })
};
});
}
@ -438,6 +439,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return { items };
}
private formatName({ name }: { name: string }) {
if (name) {
for (const part of REPLACE_NAME_PARTS) {
name = name.replace(part, '');
}
name = name.trim();
}
return name;
}
private getUrl({ version }: { version: number }) {
return `https://financialmodelingprep.com/api/v${version}`;
}

4
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -23,7 +23,7 @@ import {
isToday,
subDays
} from 'date-fns';
import { isNumber, uniq } from 'lodash';
import { isNumber } from 'lodash';
import ms from 'ms';
@Injectable()
@ -515,7 +515,7 @@ export class ExchangeRateDataService {
}
}
return uniq(currencies).filter(Boolean).sort();
return Array.from(new Set(currencies)).filter(Boolean).sort();
}
private prepareCurrencyPairs(aCurrencies: string[]) {

1
apps/api/src/services/interfaces/environment.interface.ts

@ -16,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
DATA_SOURCES_LEGACY: string[];
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

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

@ -113,6 +113,22 @@ export class MarketDataService {
});
}
public async updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier
) {
return this.prismaService.marketData.updateMany({
data: {
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
},
where: {
dataSource: oldAssetProfileIdentifier.dataSource,
symbol: oldAssetProfileIdentifier.symbol
}
});
}
public async updateMarketData(params: {
data: {
state: MarketDataState;

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

@ -179,7 +179,8 @@ export class DataGatheringService {
);
if (!assetProfileIdentifiers) {
assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
assetProfileIdentifiers =
await this.getAllActiveAssetProfileIdentifiers();
}
if (assetProfileIdentifiers.length <= 0) {
@ -345,11 +346,14 @@ export class DataGatheringService {
);
}
public async getAllAssetProfileIdentifiers(): Promise<
public async getAllActiveAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
orderBy: [{ symbol: 'asc' }],
where: {
isActive: true
}
});
return symbolProfiles
@ -419,9 +423,11 @@ export class DataGatheringService {
withUserSubscription?: boolean;
}): Promise<IDataGatheringItem[]> {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByUserSubscription({
withUserSubscription
});
await this.symbolProfileService.getActiveSymbolProfilesByUserSubscription(
{
withUserSubscription
}
);
const assetProfileIdentifiersWithCompleteMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData();
@ -485,6 +491,9 @@ export class DataGatheringService {
},
scraperConfiguration: true,
symbol: true
},
where: {
isActive: true
}
})
)

2
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -68,7 +68,7 @@ export class PortfolioSnapshotProcessor {
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: job.data.userCurrency,
filters: job.data.filters,
userId: job.data.userId

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

@ -23,21 +23,39 @@ import { continents, countries } from 'countries-list';
export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({
where: { id }
@LogPerformance
public async getActiveSymbolProfilesByUserSubscription({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}) {
return this.prismaService.symbolProfile.findMany({
include: {
Order: {
include: {
User: true
}
}
},
orderBy: [{ symbol: 'asc' }],
where: {
isActive: true,
Order: withUserSubscription
? {
some: {
User: {
Subscription: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
}
}
}
}
});
}
@ -75,6 +93,24 @@ export class SymbolProfileService {
});
}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({
where: { id }
});
}
public async getSymbolProfilesByIds(
symbolProfileIds: string[]
): Promise<EnhancedSymbolProfile[]> {
@ -100,57 +136,43 @@ export class SymbolProfileService {
});
}
public async getSymbolProfilesByUserSubscription({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}) {
return this.prismaService.symbolProfile.findMany({
include: {
Order: {
include: {
User: true
}
}
public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier
) {
return this.prismaService.symbolProfile.update({
data: {
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
},
orderBy: [{ symbol: 'asc' }],
where: {
Order: withUserSubscription
? {
some: {
User: {
Subscription: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
}
}
}
dataSource_symbol: {
dataSource: oldAssetProfileIdentifier.dataSource,
symbol: oldAssetProfileIdentifier.symbol
}
}
});
}
public updateSymbolProfile({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
tags,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
SymbolProfileOverrides,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
public updateSymbolProfile(
{ dataSource, symbol }: AssetProfileIdentifier,
{
assetClass,
assetSubClass,
comment,
countries,
currency,
holdings,
isActive,
name,
tags,
scraperConfiguration,
sectors,
symbolMapping,
SymbolProfileOverrides,
url
}: Prisma.SymbolProfileUpdateInput
) {
return this.prismaService.symbolProfile.update({
data: {
assetClass,
@ -159,6 +181,7 @@ export class SymbolProfileService {
countries,
currency,
holdings,
isActive,
name,
tags,
scraperConfiguration,

8
apps/client/ngsw-config.json

@ -6,13 +6,7 @@
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/assets/site.webmanifest",
"/*.css",
"/*.js"
]
"files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
}
},
{

3
apps/client/project.json

@ -192,9 +192,6 @@
{
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
},
{
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
},

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

@ -84,9 +84,11 @@
>
</li>
}
<li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li>
@if (!hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li>
}
@if (hasPermissionForStatistics) {
<li>
<a [routerLink]="['/open']">Open Startup</a>
@ -104,6 +106,13 @@
>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutTermsOfService"
>Terms of Service</a
>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a

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

@ -75,6 +75,10 @@ export class AppComponent implements OnDestroy, OnInit {
'/' + $localize`:snake-case:about`,
$localize`:snake-case:privacy-policy`
];
public routerLinkAboutTermsOfService = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:terms-of-service`
];
public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];

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

@ -69,7 +69,7 @@
</div>
<mat-tab-group
animationDuration="0"
animationDuration="0ms"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': isLoadingActivities }"
>

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

@ -400,9 +400,15 @@ export class AdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
.subscribe(
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
);
});
}

6
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss

@ -8,6 +8,12 @@
aspect-ratio: 16/9;
}
.edit-asset-profile-identifier-container {
bottom: 0;
right: 1rem;
top: 0;
}
.mat-expansion-panel {
--mat-expansion-container-background-color: transparent;

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

@ -16,6 +16,7 @@ import {
import { translate } from '@ghostfolio/ui/i18n';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -27,9 +28,16 @@ import {
ViewChild,
signal
} from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import {
AbstractControl,
FormBuilder,
FormControl,
ValidationErrors,
Validators
} from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
AssetClass,
AssetSubClass,
@ -38,6 +46,8 @@ import {
Tag
} from '@prisma/client';
import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import ms from 'ms';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -54,14 +64,26 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
export class AssetProfileDialog implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public separatorKeysCodes: number[] = [ENTER, COMMA];
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
@ViewChild('assetProfileFormElement')
assetProfileFormElement: ElementRef<HTMLFormElement>;
public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
@ -86,16 +108,35 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
symbolMapping: '',
url: ''
});
public assetProfileIdentifierForm = this.formBuilder.group(
{
assetProfileIdentifier: new FormControl<AssetProfileIdentifier>(
{ symbol: null, dataSource: null },
[Validators.required]
)
},
{
validators: (control) => {
return this.isNewSymbolValid(control);
}
}
);
public assetProfileSubClass: string;
public benchmarks: Partial<SymbolProfile>[];
public countries: {
[code: string]: { name: string; value: number };
};
public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public historicalDataItems: LineChartItem[];
public isBenchmark = false;
public isEditAssetProfileIdentifierMode = false;
public marketDataItems: MarketData[] = [];
public modeValues = [
{
value: 'lazy',
@ -106,18 +147,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
viewValue: $localize`Instant` + ' (' + $localize`real-time` + ')'
}
];
public scraperConfiguationIsExpanded = signal(false);
public sectors: {
[name: string]: { name: string; value: number };
};
public HoldingTags: { id: string; name: string; userId: string }[];
public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -129,9 +169,22 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
) {}
public get canEditAssetProfileIdentifier() {
return (
this.assetProfile?.assetClass &&
!['MANUAL'].includes(this.assetProfile?.dataSource) &&
this.user?.settings?.isExperimentalFeatures
);
}
public get canSaveAssetProfileIdentifier() {
return !this.assetProfileForm.dirty;
}
public ngOnInit() {
const { benchmarks, currencies } = this.dataService.fetchInfo();
@ -248,6 +301,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onCancelEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = false;
this.assetProfileForm.enable();
this.assetProfileIdentifierForm.reset();
}
public onClose() {
this.dialogRef.close();
}
@ -304,7 +365,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public async onSubmit() {
public onSetEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = true;
this.assetProfileForm.disable();
}
public async onSubmitAssetProfileForm() {
let countries = [];
let scraperConfiguration = {};
let sectors = [];
@ -352,7 +419,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
);
} catch {}
const assetProfileData: UpdateAssetProfileDto = {
const assetProfile: UpdateAssetProfileDto = {
countries,
scraperConfiguration,
sectors,
@ -371,7 +438,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
await validateObjectForForm({
classDto: UpdateAssetProfileDto,
form: this.assetProfileForm,
object: assetProfileData
object: assetProfile
});
} catch (error) {
console.error(error);
@ -379,16 +446,80 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
this.adminService
.patchAssetProfile({
...assetProfileData,
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
assetProfile
)
.subscribe(() => {
this.initialize();
});
}
public async onSubmitAssetProfileIdentifierForm() {
const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource: this.assetProfileIdentifierForm.get('assetProfileIdentifier')
.value.dataSource,
symbol: this.assetProfileIdentifierForm.get('assetProfileIdentifier')
.value.symbol
};
try {
await validateObjectForForm({
classDto: UpdateAssetProfileDto,
form: this.assetProfileIdentifierForm,
object: assetProfileIdentifier
});
} catch (error) {
console.error(error);
return;
}
this.adminService
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
assetProfileIdentifier
)
.pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.CONFLICT) {
this.snackBar.open(
$localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`,
undefined,
{
duration: ms('3 seconds')
}
);
} else {
this.snackBar.open(
$localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`,
undefined,
{
duration: ms('3 seconds')
}
);
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
dataSource: assetProfileIdentifier.dataSource,
symbol: assetProfileIdentifier.symbol
};
this.dialogRef.close(newAssetProfileIdentifier);
});
}
public onTestMarketData() {
this.adminService
.testMarketData({
@ -483,4 +614,24 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
public onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm) {
this.assetProfileFormElement.nativeElement.requestSubmit();
}
}
private isNewSymbolValid(control: AbstractControl): ValidationErrors {
const currentAssetProfileIdentifier: AssetProfileIdentifier | undefined =
control.get('assetProfileIdentifier').value;
if (
currentAssetProfileIdentifier?.dataSource === this.data?.dataSource &&
currentAssetProfileIdentifier?.symbol === this.data?.symbol
) {
return {
equalsPreviousProfileIdentifier: true
};
}
}
}

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

@ -1,9 +1,4 @@
<form
class="d-flex flex-column h-100"
[formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<div class="d-flex flex-column h-100">
<div class="d-flex mb-3">
<h1 class="flex-grow-1 m-0" mat-dialog-title>
{{ assetProfile?.name ?? data.symbol }}
@ -104,21 +99,84 @@
/>
<div class="row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
>Symbol</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource
"
>Data Source</gf-value
>
</div>
@if (isEditAssetProfileIdentifierMode) {
<div class="col-12 mb-4">
<form
class="align-items-center d-flex"
[formGroup]="assetProfileIdentifierForm"
(keyup.enter)="
assetProfileIdentifierForm.valid &&
onSubmitAssetProfileIdentifierForm()
"
(ngSubmit)="onSubmitAssetProfileIdentifierForm()"
>
<mat-form-field appearance="outline" class="gf-spacer without-hint">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete
formControlName="assetProfileIdentifier"
[includeIndices]="true"
/>
</mat-form-field>
<button
class="ml-2 no-min-width px-2"
color="primary"
mat-flat-button
type="submit"
[disabled]="
assetProfileIdentifierForm.hasError(
'invalidData',
'assetProfileIdentifier'
) ||
assetProfileIdentifierForm.hasError(
'equalsPreviousProfileIdentifier'
)
"
>
<ng-container i18n>Apply</ng-container>
</button>
<button
class="ml-2 no-min-width px-2"
mat-button
type="button"
(click)="onCancelEditAssetProfileIdentifierMode()"
>
<ng-container i18n>Cancel</ng-container>
</button>
</form>
</div>
} @else {
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
>Symbol</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource
"
>Data Source</gf-value
>
<div
class="edit-asset-profile-identifier-container position-absolute"
>
<button
class="h-100 no-min-width px-2"
mat-button
type="button"
[disabled]="!canSaveAssetProfileIdentifier"
[ngClass]="{
'd-none': !canEditAssetProfileIdentifier
}"
(click)="onSetEditAssetProfileIdentifierMode()"
>
<ion-icon name="create-outline" />
</button>
</div>
</div>
}
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency"
>Currency</gf-value
@ -215,261 +273,287 @@
}
}
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<form
#assetProfileFormElement
[formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmitAssetProfileForm()"
(ngSubmit)="onSubmitAssetProfileForm()"
>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
}
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null" />
@for (assetSubClass of assetSubClasses; track assetSubClass) {
<mat-option [value]="assetSubClass.id">{{
assetSubClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
<mat-chip-row
*ngFor="let tag of assetProfileForm.controls['tags']?.value"
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
}
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null" />
@for (assetSubClass of assetSubClasses; track assetSubClass) {
<mat-option [value]="assetSubClass.id">{{
assetSubClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
<mat-chip-row
*ngFor="let tag of assetProfileForm.controls['tags']?.value"
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip-row>
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip-row>
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
<mat-option *ngFor="let tag of HoldingTags" [value]="tag.id">
{{ tag.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
(change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>Benchmark</mat-checkbox
>
<mat-option *ngFor="let tag of HoldingTags" [value]="tag.id">
{{ tag.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol Mapping</mat-label>
<textarea
cdkTextareaAutosize
formControlName="symbolMapping"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mb-3">
<mat-accordion class="my-3">
<mat-expansion-panel
class="shadow-none"
[expanded]="
assetProfileForm.controls.scraperConfiguration.controls.selector
.value !== '' &&
assetProfileForm.controls.scraperConfiguration.controls.url
.value !== ''
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
[disabled]="isEditAssetProfileIdentifierMode"
(change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
(closed)="scraperConfiguationIsExpanded.set(false)"
(opened)="scraperConfiguationIsExpanded.set(true)"
>Benchmark</mat-checkbox
>
<mat-expansion-panel-header class="p-0">
<mat-panel-title i18n>Scraper Configuration</mat-panel-title>
</mat-expansion-panel-header>
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Locale</mat-label>
<input formControlName="locale" matInput type="text" />
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration.controls.url
.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
</div>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label>
<mat-label i18n>Symbol Mapping</mat-label>
<textarea
cdkTextareaAutosize
formControlName="sectors"
formControlName="symbolMapping"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mb-3">
<mat-accordion class="my-3">
<mat-expansion-panel
class="shadow-none"
[expanded]="
assetProfileForm.controls.scraperConfiguration.controls.selector
.value !== '' &&
assetProfileForm.controls.scraperConfiguration.controls.url
.value !== ''
"
(closed)="scraperConfiguationIsExpanded.set(false)"
(opened)="scraperConfiguationIsExpanded.set(true)"
>
<mat-expansion-panel-header class="p-0">
<mat-panel-title i18n>Scraper Configuration</mat-panel-title>
</mat-expansion-panel-header>
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Locale</mat-label>
<input formControlName="locale" matInput type="text" />
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration.controls
.url.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label>
<textarea
cdkTextareaAutosize
formControlName="sectors"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label>
<textarea
cdkTextareaAutosize
formControlName="countries"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
}
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon
class="mr-3"
matSuffix
[url]="assetProfileForm.get('url').value"
/>
}
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label>
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="countries"
formControlName="comment"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
}
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon
class="mr-3"
matSuffix
[url]="assetProfileForm.get('url').value"
/>
}
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="comment"
matInput
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
</form>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
@ -477,10 +561,10 @@
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)"
(click)="onTriggerSubmitAssetProfileForm()"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>
</div>

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -4,6 +4,7 @@ import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field';
@ -35,6 +36,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfPortfolioProportionChartComponent,
MatAutocompleteModule,
MatChipsModule,
GfSymbolAutocompleteComponent,
GfValueComponent,
MatButtonModule,
MatCheckboxModule,

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

@ -20,7 +20,6 @@ import {
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { isISO4217CurrencyCode } from 'class-validator';
import { uniq } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@ -87,7 +86,9 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.createAssetProfileForm.get('addCurrency').value as string
).toUpperCase();
const currencies = uniq([...this.customCurrencies, currency]).sort();
const currencies = Array.from(
new Set([...this.customCurrencies, currency])
).sort();
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {

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

@ -6,7 +6,6 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PROPERTY_COUPONS,
PROPERTY_CURRENCIES,
PROPERTY_IS_DATA_GATHERING_ENABLED,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED,
@ -41,8 +40,6 @@ import { takeUntil } from 'rxjs/operators';
export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '14 days';
public coupons: Coupon[];
public customCurrencies: string[];
public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: boolean;
@ -138,19 +135,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
});
}
public onDeleteCurrency(aCurrency: string) {
this.notificationService.confirm({
confirmFn: () => {
const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency;
});
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete this currency?`
});
}
public onDeleteSystemMessage() {
this.notificationService.confirm({
confirmFn: () => {
@ -231,25 +215,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({ exchangeRates, settings, transactionCount, userCount, version }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates;
this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false
? false
: true;
this.systemMessage = settings[
PROPERTY_SYSTEM_MESSAGE
] as SystemMessage;
this.transactionCount = transactionCount;
this.userCount = userCount;
this.version = version;
.subscribe(({ settings, transactionCount, userCount, version }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage;
this.transactionCount = transactionCount;
this.userCount = userCount;
this.version = version;
this.changeDetectorRef.markForCheck();
}
);
this.changeDetectorRef.markForCheck();
});
}
private generateCouponCode(aLength: number) {

67
apps/client/src/app/components/admin-overview/admin-overview.html

@ -30,73 +30,6 @@
}
</div>
</div>
<div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div>
<div class="w-50">
<table>
@for (exchangeRate of exchangeRates; track exchangeRate) {
<tr>
<td>
<gf-value [locale]="user?.settings?.locale" [value]="1" />
</td>
<td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td>
<td align="right">
<gf-value
class="d-inline-block"
[locale]="user?.settings?.locale"
[precision]="4"
[value]="exchangeRate.value"
/>
</td>
<td class="pl-1">{{ exchangeRate.label2 }}</td>
<td>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="exchangeRateActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu
#exchangeRateActionsMenu="matMenu"
class="h-100 mx-1 no-min-width px-2"
xPosition="before"
>
<a
mat-menu-item
[queryParams]="{
assetProfileDialog: true,
dataSource: exchangeRate.dataSource,
symbol: exchangeRate.symbol
}"
[routerLink]="['/admin', 'market-data']"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</a>
@if (customCurrencies.includes(exchangeRate.label2)) {
<hr class="m-0" />
<button
mat-menu-item
(click)="onDeleteCurrency(exchangeRate.label2)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
}
</mat-menu>
</td>
</tr>
}
</table>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div>
<div class="w-50">

45
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -1,9 +1,4 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
@ -26,11 +21,18 @@ import {
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ConfirmationDialogType } from '../../core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '../../core/notification/notification.service';
import { AdminService } from '../../services/admin.service';
import { DataService } from '../../services/data.service';
import { ImpersonationStorageService } from '../../services/impersonation-storage.service';
import { UserService } from '../../services/user/user.service';
@Component({
selector: 'gf-admin-users',
standalone: false,
styleUrls: ['./admin-users.scss'],
templateUrl: './admin-users.html',
standalone: false
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ -55,6 +57,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
@ -140,6 +143,32 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
});
}
public onGenerateAccessToken(aUserId: string) {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.generateAccessToken(aUserId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken }) => {
this.notificationService.alert({
discardFn: () => {
if (aUserId === this.user.id) {
this.tokenStorageService.signOut();
this.userService.remove();
document.location.href = `/${document.documentElement.lang}`;
}
},
message: accessToken,
title: $localize`Security token`
});
});
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to generate a new security token for this user?`
});
}
public onImpersonateUser(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);

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

@ -239,8 +239,17 @@
<span i18n>Impersonate User</span>
</span>
</button>
<hr class="m-0" />
}
<button
mat-menu-item
(click)="onGenerateAccessToken(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="key-outline" />
<span i18n>Generate Security Token</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.id === user?.id"

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

@ -134,7 +134,7 @@
</button>
<mat-menu
#assistantMenu="matMenu"
class="assistant"
class="no-max-width"
xPosition="before"
[overlapTrigger]="true"
(closed)="assistantElement?.setIsOpen(false)"

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

@ -312,7 +312,7 @@
</div>
<mat-tab-group
animationDuration="0"
animationDuration="0ms"
class="mb-5"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !dataSource?.data.length }"

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

@ -117,8 +117,8 @@
<ng-container i18n>Net Performance</ng-container>
<abbr
class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return"
>(TWR)</abbr
title="Return on Average Investment"
>(ROAI)</abbr
>
</div>
<div class="flex-column flex-wrap justify-content-end">

4
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -40,8 +40,8 @@ export class PortfolioSummaryComponent implements OnChanges {
public ngOnChanges() {
if (this.summary) {
if (this.summary.firstOrderDate) {
this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate, {
if (this.user.dateOfFirstActivity) {
this.timeInMarket = formatDistanceToNow(this.user.dateOfFirstActivity, {
locale: getDateFnsLocale(this.language)
});
} else {

3
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -24,7 +24,6 @@ import { FormBuilder, Validators } from '@angular/forms';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { format, parseISO } from 'date-fns';
import { uniq } from 'lodash';
import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -108,7 +107,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
);
this.locales.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort());
this.locales = Array.from(new Set(this.locales)).sort();
this.changeDetectorRef.markForCheck();
}

3
apps/client/src/app/core/paths.ts

@ -7,5 +7,6 @@ export const paths = {
pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`,
register: $localize`register`,
resources: $localize`resources`
resources: $localize`resources`,
termsOfService: $localize`terms-of-service`
};

7
apps/client/src/app/pages/about/about-page-routing.module.ts

@ -44,6 +44,13 @@ const routes: Routes = [
import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
},
{
path: paths.termsOfService,
loadChildren: () =>
import('./terms-of-service/terms-of-service-page.module').then(
(m) => m.TermsOfServicePageModule
)
}
],
component: AboutPageComponent,

13
apps/client/src/app/pages/about/about-page.component.ts

@ -41,7 +41,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
.subscribe((state) => {
this.tabs = [
{
iconName: 'reader-outline',
iconName: 'information-circle-outline',
label: $localize`About`,
path: ['/' + $localize`about`]
},
@ -53,7 +53,8 @@ export class AboutPageComponent implements OnDestroy, OnInit {
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/' + $localize`about`, $localize`license`]
path: ['/' + $localize`about`, $localize`license`],
showCondition: !this.hasPermissionForSubscription
}
];
@ -64,6 +65,14 @@ export class AboutPageComponent implements OnDestroy, OnInit {
path: ['/' + $localize`about`, $localize`privacy-policy`],
showCondition: this.hasPermissionForSubscription
});
this.tabs.push({
iconName: 'document-text-outline',
label: $localize`Terms of Service`,
path: ['/' + $localize`about`, $localize`terms-of-service`],
showCondition: this.hasPermissionForSubscription
});
this.user = state.user;
this.changeDetectorRef.markForCheck();

4
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts

@ -3,9 +3,9 @@ import { Subject } from 'rxjs';
@Component({
selector: 'gf-privacy-policy-page',
standalone: false,
styleUrls: ['./privacy-policy-page.scss'],
templateUrl: './privacy-policy-page.html',
standalone: false
templateUrl: './privacy-policy-page.html'
})
export class PrivacyPolicyPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();

8
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.scss

@ -12,6 +12,14 @@
color: rgba(var(--palette-primary-300), 1);
}
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
}
}
}

21
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page-routing.module.ts

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TermsOfServicePageComponent } from './terms-of-service-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: TermsOfServicePageComponent,
path: '',
title: $localize`Terms of Service`
}
];
@NgModule({
exports: [RouterModule],
imports: [RouterModule.forChild(routes)]
})
export class TermsOfServicePageRoutingModule {}

17
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts

@ -0,0 +1,17 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'gf-terms-of-service-page',
standalone: false,
styleUrls: ['./terms-of-service-page.scss'],
templateUrl: './terms-of-service-page.html'
})
export class TermsOfServicePageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

10
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.html

@ -0,0 +1,10 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Terms of Service
</h1>
<markdown [src]="'../assets/terms-of-service.md'"></markdown>
</div>
</div>
</div>

17
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.module.ts

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { TermsOfServicePageRoutingModule } from './terms-of-service-page-routing.module';
import { TermsOfServicePageComponent } from './terms-of-service-page.component';
@NgModule({
declarations: [TermsOfServicePageComponent],
imports: [
CommonModule,
MarkdownModule.forChild(),
TermsOfServicePageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TermsOfServicePageModule {}

29
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.scss

@ -0,0 +1,29 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
::ng-deep {
markdown {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
}
}
}
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

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

@ -8,7 +8,7 @@
<div class="flex-grow-1" mat-dialog-content>
<mat-stepper
#stepper
animationDuration="0"
animationDuration="0ms"
[linear]="true"
[orientation]="stepperOrientation"
[selectedIndex]="importStep"

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

@ -20,7 +20,14 @@ import type {
import { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatMenuTrigger } from '@angular/material/menu';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SymbolProfile } from '@prisma/client';
import { isNumber, sortBy } from 'lodash';
@ -36,6 +43,8 @@ import { takeUntil } from 'rxjs/operators';
standalone: false
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
@ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger;
public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
@ -57,10 +66,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[];
public isLoadingAnalysisPrompt: boolean;
public isLoadingBenchmarkComparator: boolean;
public isLoadingDividendTimelineChart: boolean;
public isLoadingInvestmentChart: boolean;
public isLoadingInvestmentTimelineChart: boolean;
public isLoadingPortfolioPrompt: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' },
@ -174,8 +185,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}
public onCopyPromptToClipboard(mode: AiPromptMode) {
if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = true;
} else if (mode === 'portfolio') {
this.isLoadingPortfolioPrompt = true;
}
this.dataService
.fetchPrompt(mode)
.fetchPrompt({
mode,
filters: this.userService.getFilters()
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ prompt }) => {
this.clipboard.copy(prompt);
@ -194,6 +214,14 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.subscribe(() => {
window.open('https://duck.ai', '_blank');
});
this.actionsMenuButton.closeMenu();
if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = false;
} else if (mode === 'portfolio') {
this.isLoadingPortfolioPrompt = false;
}
});
}

90
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -5,6 +5,7 @@
<div class="col-lg">
<div class="d-flex justify-content-end">
<button
#actionsMenuButton
class="mx-1 no-min-width px-2"
mat-stroked-button
[matMenuTriggerFor]="actionsMenu"
@ -12,39 +13,62 @@
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #actionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard('portfolio')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
<ng-container i18n
>Copy portfolio data to clipboard for AI prompt</ng-container
>
</span>
</button>
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard('analysis')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
<ng-container i18n
>Copy AI prompt to clipboard for analysis</ng-container
>
</span>
</button>
<mat-menu
#actionsMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<div (click)="$event.stopPropagation()">
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard('portfolio')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
@if (isLoadingPortfolioPrompt) {
<mat-spinner
class="mr-2"
color="accent"
[diameter]="16"
/>
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
}
<ng-container i18n
>Copy portfolio data to clipboard for AI
prompt</ng-container
>
</span>
</button>
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard('analysis')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
@if (isLoadingAnalysisPrompt) {
<mat-spinner
class="mr-2"
color="accent"
[diameter]="16"
/>
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
}
<ng-container i18n
>Copy AI prompt to clipboard for analysis</ng-container
>
</span>
</button>
</div>
</mat-menu>
</div>
</div>

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

@ -10,6 +10,7 @@ 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
@ -29,6 +30,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
MatButtonModule,
MatCardModule,
MatMenuModule,
MatProgressSpinnerModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

17
apps/client/src/app/pages/register/register-page.component.ts

@ -11,6 +11,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './show-access-token-dialog/interfaces/interfaces';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({
@ -24,6 +25,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public demoAuthToken: string;
public deviceType: string;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateUser: boolean;
public historicalDataItems: LineChartItem[];
public info: InfoItem;
@ -52,6 +54,10 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
globalPermissions,
permissions.enableSocialLogin
);
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToCreateUser = hasPermission(
globalPermissions,
permissions.createUserAccount
@ -61,15 +67,22 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
await this.router.navigate(['/']);
} catch {}
}
public openShowAccessTokenDialog() {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
deviceType: this.deviceType,
needsToAcceptTermsOfService: this.hasPermissionForSubscription
} as ShowAccessTokenDialogParams,
disableClose: true,
width: '30rem'
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '30rem'
});
dialogRef

4
apps/client/src/app/pages/register/show-access-token-dialog/interfaces/interfaces.ts

@ -0,0 +1,4 @@
export interface ShowAccessTokenDialogParams {
deviceType: string;
needsToAcceptTermsOfService: boolean;
}

9
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts

@ -4,12 +4,16 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
ViewChild
} from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatStepper } from '@angular/material/stepper';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-show-access-token-dialog',
@ -25,11 +29,16 @@ export class ShowAccessTokenDialog {
public isCreateAccountButtonDisabled = true;
public isDisclaimerChecked = false;
public role: string;
public routerLinkAboutTermsOfService = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:terms-of-service`
];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ShowAccessTokenDialogParams,
private dataService: DataService
) {}

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

Loading…
Cancel
Save