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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.149.0 - 2025-03-30
### Changed ### Changed
@ -96,7 +96,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 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 ## 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 Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git ## Git
### Rebase ### Rebase

4
README.md

@ -7,7 +7,7 @@
**Open Source Wealth Management Software** **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) | [**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: 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) [![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 - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ 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 - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions

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

@ -83,7 +83,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -110,7 +110,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMissing(): Promise<void> { public async gatherMissing(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
const promises = assetProfileIdentifiers.map(({ dataSource, symbol }) => { const promises = assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return this.dataGatheringService.gatherSymbolMissingOnly({ return this.dataGatheringService.gatherSymbolMissingOnly({
@ -127,7 +127,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -366,21 +366,24 @@ export class AdminController {
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData( public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto, @Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData(
...assetProfileData, { dataSource, symbol },
dataSource, {
symbol, ...assetProfile,
tags: { tags: {
connect: assetProfileData.tags?.map(({ id }) => { connect: assetProfile.tags?.map(({ id }) => {
return { id }; return { id };
}), }),
disconnect: assetProfileData.tagsDisconnected?.map(({ id }) => ({ id })) disconnect: assetProfile.tagsDisconnected?.map(({ id }) => ({
id
}))
}
} }
}); );
} }
@HasPermission(permissions.accessAdminControl) @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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -33,7 +32,12 @@ import {
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import {
BadRequestException,
HttpException,
Injectable,
Logger
} from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -44,6 +48,7 @@ import {
DataSource DataSource
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
@ -132,31 +137,6 @@ export class AdminService {
} }
public async get(): Promise<AdminData> { 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([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(), this.prismaService.order.count(),
@ -164,7 +144,6 @@ export class AdminService {
]); ]);
return { return {
exchangeRates,
settings, settings,
transactionCount, transactionCount,
userCount, userCount,
@ -495,63 +474,126 @@ export class AdminService {
return { count, users }; return { count, users };
} }
public async patchAssetProfileData({ public async patchAssetProfileData(
assetClass, { dataSource, symbol }: AssetProfileIdentifier,
assetSubClass, {
comment, assetClass,
countries, assetSubClass,
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 = {
comment, comment,
countries, countries,
currency, currency,
dataSource, dataSource: newDataSource,
holdings, holdings,
name,
tags,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol: newSymbol,
symbolMapping, symbolMapping,
tags, url
...(dataSource === 'MANUAL' }: Prisma.SymbolProfileUpdateInput
? { assetClass, assetSubClass, name, url } ) {
: { if (
SymbolProfileOverrides: { newSymbol &&
upsert: { newDataSource &&
create: symbolProfileOverrides, (newSymbol !== symbol || newDataSource !== dataSource)
update: symbolProfileOverrides ) {
} 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, 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) { 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 { 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 { import {
IsArray, IsArray,
IsEnum, IsEnum,
@ -19,8 +25,8 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsString()
@IsOptional() @IsOptional()
@IsString()
comment?: string; comment?: string;
@IsArray() @IsArray()
@ -31,8 +37,12 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
currency?: string; currency?: string;
@IsString() @IsEnum(DataSource, { each: true })
@IsOptional()
dataSource?: DataSource;
@IsOptional() @IsOptional()
@IsString()
name?: string; name?: string;
@IsArray() @IsArray()
@ -51,6 +61,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
sectors?: Prisma.InputJsonArray; sectors?: Prisma.InputJsonArray;
@IsOptional()
@IsString()
symbol?: string;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
symbolMapping?: { 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 { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.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 { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
@ -61,6 +62,7 @@ import { UserModule } from './user/user.module';
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,
AssetsModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarksModule, BenchmarksModule,

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

@ -20,10 +20,10 @@ export class AuthService {
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken({
accessToken, password: accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT') salt: this.configurationService.get('ACCESS_TOKEN_SALT')
); });
const [user] = await this.userService.users({ const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken } 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE DEFAULT_LANGUAGE_CODE
@ -8,7 +9,14 @@ import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; 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 { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -18,6 +26,7 @@ import { AiService } from './ai.service';
export class AiController { export class AiController {
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -25,9 +34,23 @@ export class AiController {
@HasPermission(permissions.readAiPrompt) @HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt( 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> { ): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({
filters,
mode, mode,
impersonationId: undefined, impersonationId: undefined,
languageCode: 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 { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -25,6 +26,7 @@ import { AiService } from './ai.service';
@Module({ @Module({
controllers: [AiController], controllers: [AiController],
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, 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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types'; import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -8,12 +9,14 @@ export class AiService {
public constructor(private readonly portfolioService: PortfolioService) {} public constructor(private readonly portfolioService: PortfolioService) {}
public async getPrompt({ public async getPrompt({
filters,
impersonationId, impersonationId,
languageCode, languageCode,
mode, mode,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[];
impersonationId: string; impersonationId: string;
languageCode: string; languageCode: string;
mode: AiPromptMode; mode: AiPromptMode;
@ -21,6 +24,7 @@ export class AiService {
userId: string; userId: string;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId, impersonationId,
userId 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) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[], @Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,

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

@ -28,6 +28,22 @@ export class ExportService {
}): Promise<Export> { }): Promise<Export> {
const platformsMap: { [platformId: string]: Platform } = {}; 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 = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
include: { include: {
@ -39,57 +55,55 @@ export class ExportService {
}, },
where: { userId } where: { userId }
}) })
).map( )
({ .filter(({ id }) => {
balance, return activities.length > 0
balances, ? activities.some(({ accountId }) => {
comment, return accountId === id;
currency, })
id, : true;
isExcluded, })
name, .map(
Platform: platform, ({
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance, balance,
balances: balances.map(({ date, value }) => { balances,
return { date: date.toISOString(), value };
}),
comment, comment,
currency, currency,
id, id,
isExcluded, isExcluded,
name, name,
Platform: platform,
platformId platformId
}; }) => {
} if (platformId) {
); platformsMap[platformId] = platform;
}
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
if (activityIds) { return {
activities = activities.filter((activity) => { balance,
return activityIds.includes(activity.id); balances: balances.map(({ date, value }) => {
}); return { date: date.toISOString(), value };
} }),
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
const tags = (await this.tagService.getTagsForUser(userId)) const tags = (await this.tagService.getTagsForUser(userId))
.filter(({ isUsed }) => { .filter(
return isUsed; ({ id, isUsed }) =>
}) isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
)
.map(({ id, name }) => { .map(({ id, name }) => {
return { return {
id, 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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
export class ImportDataDto { export class ImportDataDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@Type(() => CreateAccountDto) @Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
accounts: CreateAccountDto[]; accounts: CreateAccountWithBalancesDto[];
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)

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

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

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

@ -60,43 +60,46 @@ export class OrderService {
} }
]); ]);
const symbolProfile: EnhancedSymbolProfile = promis[0]; const symbolProfile: EnhancedSymbolProfile = promis[0];
const result = await this.symbolProfileService.updateSymbolProfile({ const result = await this.symbolProfileService.updateSymbolProfile(
assetClass: symbolProfile.assetClass, { dataSource, symbol },
assetSubClass: symbolProfile.assetSubClass, {
countries: symbolProfile.countries.reduce( assetClass: symbolProfile.assetClass,
(all, v) => [...all, { code: v.code, weight: v.weight }], assetSubClass: symbolProfile.assetSubClass,
[] countries: symbolProfile.countries.reduce(
), (all, v) => [...all, { code: v.code, weight: v.weight }],
currency: symbolProfile.currency, []
dataSource, ),
holdings: symbolProfile.holdings.reduce( currency: symbolProfile.currency,
(all, v) => [ dataSource,
...all, holdings: symbolProfile.holdings.reduce(
{ name: v.name, weight: v.allocationInPercentage } (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 }], name: symbolProfile.name,
[] sectors: symbolProfile.sectors.reduce(
), (all, v) => [...all, { name: v.name, weight: v.weight }],
symbol, []
tags: { ),
connectOrCreate: tags.map(({ id, name }) => { symbol,
return { tags: {
create: { connectOrCreate: tags.map(({ id, name }) => {
id, return {
name create: {
}, id,
where: { name
id },
} where: {
}; id
}) }
}, };
url: symbolProfile.url })
}); },
url: symbolProfile.url
}
);
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), 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 { CurrentRateService } from '../../current-rate.service';
import { DateQuery } from '../../interfaces/date-query.interface'; import { DateQuery } from '../../interfaces/date-query.interface';
import { PortfolioOrder } from '../../interfaces/portfolio-order.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 holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {}; 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'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot { protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.'); 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 { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType { export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return MWR = 'MWR', // Money-Weighted Rate of Return
ROAI = 'ROAI', // Return on Average Investment
TWR = 'TWR', // Time-Weighted Rate of Return TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return CPR = 'CPR' // Constant Portfolio Rate of Return
} }
@ -27,8 +28,7 @@ export class PortfolioCalculatorFactory {
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioSnapshotService: PortfolioSnapshotService, private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService
private readonly orderService: OrderService
) {} ) {}
@LogPerformance @LogPerformance
@ -49,7 +49,20 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator { }): PortfolioCalculator {
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: 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, accountBalanceItems,
activities, activities,
currency, currency,
@ -62,37 +75,31 @@ export class PortfolioCalculatorFactory {
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
case PerformanceCalculationType.TWR: case PerformanceCalculationType.TWR:
return new CPRPortfolioCalculator( return new TwrPortfolioCalculator({
{ accountBalanceItems,
accountBalanceItems, activities,
activities, currency,
currency, currentRateService: this.currentRateService,
currentRateService: this.currentRateService, userId,
userId, configurationService: this.configurationService,
configurationService: this.configurationService, exchangeRateDataService: this.exchangeRateDataService,
exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService,
portfolioSnapshotService: this.portfolioSnapshotService, redisCacheService: this.redisCacheService,
redisCacheService: this.redisCacheService, filters
filters });
},
this.orderService
);
case PerformanceCalculationType.CPR: case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator( return new RoaiPortfolioCalculator({
{ accountBalanceItems,
accountBalanceItems, activities,
activities, currency,
currency, currentRateService: this.currentRateService,
currentRateService: this.currentRateService, userId,
userId, configurationService: this.configurationService,
configurationService: this.configurationService, exchangeRateDataService: this.exchangeRateDataService,
exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService,
portfolioSnapshotService: this.portfolioSnapshotService, redisCacheService: this.redisCacheService,
redisCacheService: this.redisCacheService, filters
filters });
},
this.orderService
);
default: default:
throw new Error('Invalid calculation type'); throw new Error('Invalid calculation type');
} }

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

@ -49,7 +49,7 @@ import {
min, min,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, sortBy, sum, uniq, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
@ -199,10 +199,7 @@ export abstract class PortfolioCalculator {
for (const { currency, dataSource, symbol } of transactionPoints[ for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1 firstIndex - 1
].items) { ].items) {
dataGatheringItems.push({ dataGatheringItems.push({ dataSource, symbol });
dataSource,
symbol
});
currencies[symbol] = currency; currencies[symbol] = currency;
} }
@ -219,7 +216,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: endOfDay(this.endDate),
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency
@ -231,17 +228,12 @@ export abstract class PortfolioCalculator {
values: marketSymbols values: marketSymbols
} = await this.currentRateService.getValues({ } = await this.currentRateService.getValues({
dataGatheringItems, dataGatheringItems,
dateQuery: { dateQuery: { gte: this.startDate, lt: this.endDate }
gte: this.startDate,
lt: this.endDate
}
}); });
this.dataProviderInfos = dataProviderInfos; this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: { const marketSymbolMap: { [date: string]: { [symbol: string]: Big } } = {};
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) { for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT); const date = format(marketSymbol.date, DATE_FORMAT);
@ -1111,9 +1103,7 @@ export abstract class PortfolioCalculator {
chartDateMap: { [date: string]: boolean }; chartDateMap: { [date: string]: boolean };
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
marketSymbolMap: { marketSymbolMap: { [date: string]: { [symbol: string]: Big } };
[date: string]: { [symbol: string]: Big };
};
start: Date; start: Date;
} & AssetProfileIdentifier): SymbolMetrics; } & 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -138,7 +137,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -177,9 +176,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478') max: new Big('-0.0552834149755073478')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') },
max: new Big('-15.8')
},
marketPrice: 148.9, marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -123,7 +122,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -164,9 +163,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478') max: new Big('-0.0552834149755073478')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') },
max: new Big('-15.8')
},
marketPrice: 148.9, marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -137,7 +136,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -121,7 +120,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -162,9 +161,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628') max: new Big('0.24112962014285697628')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') },
max: new Big('19.851974')
},
marketPrice: 116.45, marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483, marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'), quantity: new Big('1'),
@ -200,30 +197,12 @@ describe('PortfolioCalculator', () => {
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 }, { date: '2023-01-01', investment: 82.329056 },
{ { date: '2023-02-01', investment: 0 },
date: '2023-02-01', { date: '2023-03-01', investment: 0 },
investment: 0 { date: '2023-04-01', investment: 0 },
}, { date: '2023-05-01', investment: 0 },
{ { date: '2023-06-01', investment: 0 },
date: '2023-03-01', { date: '2023-07-01', investment: 0 }
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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -108,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -136,7 +135,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -155,25 +154,25 @@ describe('PortfolioCalculator', () => {
dividendInBaseCurrency: new Big('0.62'), dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'), fee: new Big('19'),
firstBuyDate: '2021-09-16', firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.87'), grossPerformance: new Big('33.25'),
grossPerformancePercentage: new Big('0.11343693482483756447'), grossPerformancePercentage: new Big('0.11136043941322258691'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11343693482483756447' '0.11136043941322258691'
), ),
grossPerformanceWithCurrencyEffect: new Big('33.87'), grossPerformanceWithCurrencyEffect: new Big('33.25'),
investment: new Big('298.58'), investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'), investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83, marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83, marketPriceInBaseCurrency: 331.83,
netPerformance: new Big('14.87'), netPerformance: new Big('14.25'),
netPerformancePercentage: new Big('0.04980239801728180052'), netPerformancePercentage: new Big('0.04772590260566682296'),
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04980239801728180052') max: new Big('0.04772590260566682296')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'), '1d': new Big('-5.39'),
'5y': new Big('14.87'), '5y': new Big('14.25'),
max: new Big('14.87'), max: new Big('14.25'),
wtd: new Big('-5.39') wtd: new Big('-5.39')
}, },
quantity: new Big('1'), 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -85,7 +84,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [], activities: [],
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -117,7 +116,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -158,9 +157,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.12348284960422163588') max: new Big('0.12348284960422163588')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') },
max: new Big('17.68')
},
marketPrice: 87.8, marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'), 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, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService
null
); );
}); });
@ -117,7 +116,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id 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, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, uniq, uniqBy } from 'lodash'; import { isEmpty, uniqBy } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
@ -301,7 +301,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency
}); });
@ -379,7 +379,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency currency: userCurrency
}); });
@ -580,7 +580,7 @@ export class PortfolioService {
const emergencyFundInCash = emergencyFund const emergencyFundInCash = emergencyFund
.minus( .minus(
this.getEmergencyFundPositionsValueInBaseCurrency({ this.getEmergencyFundHoldingsValueInBaseCurrency({
holdings holdings
}) })
) )
@ -619,8 +619,8 @@ export class PortfolioService {
userCurrency, userCurrency,
userId, userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency: emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({ this.getEmergencyFundHoldingsValueInBaseCurrency({
holdings holdings
}) })
}); });
@ -693,7 +693,7 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency currency: userCurrency
}); });
@ -976,7 +976,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency
}); });
@ -1146,7 +1146,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency currency: userCurrency
}); });
@ -1292,7 +1292,11 @@ export class PortfolioService {
[ [
new EmergencyFundSetup( new EmergencyFundSetup(
this.exchangeRateDataService, this.exchangeRateDataService,
userSettings.emergencyFund this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
) )
], ],
userSettings userSettings
@ -1616,7 +1620,7 @@ export class PortfolioService {
} }
@LogPerformance @LogPerformance
private getEmergencyFundPositionsValueInBaseCurrency({ private getEmergencyFundHoldingsValueInBaseCurrency({
holdings holdings
}: { }: {
holdings: PortfolioDetails['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) { for (const { valueInBaseCurrency } of emergencyFundHoldings) {
valueInBaseCurrencyOfEmergencyFundPositions = valueInBaseCurrencyOfEmergencyFundHoldings =
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency); valueInBaseCurrencyOfEmergencyFundHoldings.plus(valueInBaseCurrency);
} }
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber();
} }
private getInitialCashPosition({ private getInitialCashPosition({
@ -1808,7 +1812,7 @@ export class PortfolioService {
@LogPerformance @LogPerformance
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency,
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId, impersonationId,
portfolioCalculator, portfolioCalculator,
@ -1816,7 +1820,7 @@ export class PortfolioService {
userId userId
}: { }: {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number; emergencyFundHoldingsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big; filteredValueInBaseCurrency: Big;
impersonationId: string; impersonationId: string;
portfolioCalculator: PortfolioCalculator; portfolioCalculator: PortfolioCalculator;
@ -1860,12 +1864,10 @@ export class PortfolioService {
const dividendInBaseCurrency = const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency(); await portfolioCalculator.getDividendInBaseCurrency();
const emergencyFund = new Big( const totalEmergencyFund = this.getTotalEmergencyFund({
Math.max( emergencyFundHoldingsValueInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, userSettings: user.Settings?.settings as UserSettings
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 });
)
);
const fees = await portfolioCalculator.getFeesInBaseCurrency(); const fees = await portfolioCalculator.getFeesInBaseCurrency();
@ -1891,8 +1893,8 @@ export class PortfolioService {
}).toNumber(); }).toNumber();
const cash = new Big(balanceInBaseCurrency) const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund) .minus(totalEmergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency) .plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy) const committedFunds = new Big(totalBuy)
@ -1957,7 +1959,6 @@ export class PortfolioService {
annualizedPerformancePercentWithCurrencyEffect, annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
excludedAccountsAndActivities, excludedAccountsAndActivities,
firstOrderDate,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffect,
@ -1968,11 +1969,11 @@ export class PortfolioService {
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency, assets: emergencyFundHoldingsValueInBaseCurrency,
cash: emergencyFund cash: totalEmergencyFund
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(), .toNumber(),
total: emergencyFund.toNumber() total: totalEmergencyFund.toNumber()
}, },
fees: fees.toNumber(), fees: fees.toNumber(),
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
@ -1980,7 +1981,7 @@ export class PortfolioService {
? filteredValueInBaseCurrency.div(netWorth).toNumber() ? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined, : undefined,
fireWealth: new Big(currentValueInBaseCurrency) fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(), .toNumber(),
grossPerformance: new Big(netPerformance).plus(fees).toNumber(), grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformanceWithCurrencyEffect: new Big( 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) { private getUserCurrency(aUser?: UserWithSettings) {
return ( return (
aUser?.Settings?.settings.baseCurrency ?? aUser?.Settings?.settings.baseCurrency ??
@ -2073,14 +2089,16 @@ export class PortfolioService {
where: { id: filters[0].id } where: { id: filters[0].id }
}); });
} else { } else {
const accountIds = uniq( const accountIds = Array.from(
activities new Set(
.filter(({ accountId }) => { activities
return accountId; .filter(({ accountId }) => {
}) return accountId;
.map(({ accountId }) => { })
return accountId; .map(({ accountId }) => {
}) return accountId;
})
)
); );
currentAccounts = await this.accountService.accounts({ 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 { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Response } from 'express'; import { Response } from 'express';
import * as fs from 'fs'; import { readFileSync } from 'fs';
import * as path from 'path'; import { join } from 'path';
@Controller('sitemap.xml') @Controller('sitemap.xml')
export class SitemapController { export class SitemapController {
@ -20,8 +20,8 @@ export class SitemapController {
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
try { try {
this.sitemapXml = fs.readFileSync( this.sitemapXml = readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'), join(__dirname, 'assets', 'sitemap.xml'),
'utf8' 'utf8'
); );
} catch {} } catch {}

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

@ -1,8 +1,13 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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 { 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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -36,6 +41,7 @@ export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
@ -47,10 +53,10 @@ export class UserController {
public async deleteOwnUser( public async deleteOwnUser(
@Body() data: DeleteOwnUserDto @Body() data: DeleteOwnUserDto
): Promise<UserModel> { ): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken({
data.accessToken, password: data.accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT') salt: this.configurationService.get('ACCESS_TOKEN_SALT')
); });
const [user] = await this.userService.users({ const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id } 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() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser( 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); 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); const hash = createHmac('sha512', salt);
hash.update(password); hash.update(password);
return hash.digest('hex'); 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( public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
@ -433,7 +453,7 @@ export class UserService {
data.provider = 'ANONYMOUS'; data.provider = 'ANONYMOUS';
} }
let user = await this.prismaService.user.create({ const user = await this.prismaService.user.create({
data: { data: {
...data, ...data,
Account: { Account: {
@ -464,14 +484,11 @@ export class UserService {
} }
if (data.provider === 'ANONYMOUS') { if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(user.id, getRandomString(10)); const { accessToken, hashedAccessToken } = this.generateAccessToken({
userId: user.id
const hashedAccessToken = this.createAccessToken( });
accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
user = await this.prismaService.user.update({ await this.prismaService.user.update({
data: { accessToken: hashedAccessToken }, data: { accessToken: hashedAccessToken },
where: { id: user.id } 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", "name": "Ghostfolio",
"orientation": "portrait", "orientation": "portrait",
"short_name": "Ghostfolio", "short_name": "Ghostfolio",
"start_url": "/en/", "start_url": "/${languageCode}/",
"theme_color": "#FFFFFF", "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> <loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/development/storybook</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en</loc> <loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <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 = { export const environment = {
production: true, production: true,
rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
version: `${require('../../../../package.json').version}` 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 = { export const environment = {
production: false, production: false,
rootUrl: `https://${DEFAULT_HOST}:4200`,
version: 'dev' version: 'dev'
}; };

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

@ -1519,7 +1519,6 @@ describe('redactAttributes', () => {
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null, cash: null,
excludedAccountsAndActivities: null, excludedAccountsAndActivities: null,
firstOrderDate: '2017-01-02T23:00:00.000Z',
netPerformance: null, netPerformance: null,
netPerformancePercentage: 2.3039314216696174, netPerformancePercentage: 2.3039314216696174,
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606, netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,
@ -3023,7 +3022,6 @@ describe('redactAttributes', () => {
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null, cash: null,
excludedAccountsAndActivities: null, excludedAccountsAndActivities: null,
firstOrderDate: '2017-01-02T23:00:00.000Z',
netPerformance: null, netPerformance: null,
netPerformancePercentage: 2.3039314216696174, netPerformancePercentage: 2.3039314216696174,
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606, 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(); const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.activities) { if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => { request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) { if (DataSource[activity.dataSource]) {
return activity; return activity;

43
apps/api/src/main.ts

@ -1,3 +1,9 @@
import {
DEFAULT_HOST,
DEFAULT_PORT,
STORYBOOK_PATH
} from '@ghostfolio/common/config';
import { import {
Logger, Logger,
LogLevel, LogLevel,
@ -7,6 +13,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
@ -50,26 +57,30 @@ async function bootstrap() {
app.useBodyParser('json', { limit: '10mb' }); app.useBodyParser('json', { limit: '10mb' });
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use( app.use((req: Request, res: Response, next: NextFunction) => {
helmet({ if (req.path.startsWith(STORYBOOK_PATH)) {
contentSecurityPolicy: { next();
directives: { } else {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe helmet({
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe contentSecurityPolicy: {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe directives: {
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles 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
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity) styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}) }
); },
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})(req, res, next);
}
});
} }
app.use(HtmlTemplateMiddleware); app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || DEFAULT_PORT;
await app.listen(PORT, HOST, () => { await app.listen(PORT, HOST, () => {
logLogo(); 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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL, STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
@ -125,11 +125,11 @@ export const HtmlTemplateMiddleware = async (
} }
const currentDate = format(new Date(), DATE_FORMAT); 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 ( if (
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith('/development/storybook') || path.startsWith(STORYBOOK_PATH) ||
isFileRequest(path) || isFileRequest(path) ||
!environment.production !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 { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { import {
CACHE_TTL_NO_CACHE, CACHE_TTL_NO_CACHE,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT
DEFAULT_ROOT_URL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -38,6 +40,9 @@ export class ConfigurationService {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({ DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({
default: [] default: []
}), }),
DATA_SOURCES_LEGACY: json({
default: []
}),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
@ -49,11 +54,11 @@ export class ConfigurationService {
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }), GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }), HOST: host({ default: DEFAULT_HOST }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }), MAX_CHART_ITEMS: num({ default: 365 }),
PORT: port({ default: 3333 }), PORT: port({ default: DEFAULT_PORT }),
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY
}), }),
@ -71,7 +76,9 @@ export class ConfigurationService {
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: ms('3 seconds') }), 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_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),

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

@ -57,7 +57,7 @@ export class CronService {
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { 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; symbol = quotes[0].symbol;
} }
} catch {} } catch {}
} else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) {
throw new Error(`${symbol} is not valid`);
} else { } else {
symbol = this.convertToYahooFinanceSymbol(symbol); 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 { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; 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 { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -155,9 +155,22 @@ export class DataProviderService {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; 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 const dataSources: DataSource[] = this.configurationService
.get('DATA_SOURCES') .get(dataSourcesKey)
.map((dataSource) => { .map((dataSource) => {
return DataSource[dataSource]; return DataSource[dataSource];
}); });
@ -631,7 +644,7 @@ export class DataProviderService {
return { items: lookupItems }; return { items: lookupItems };
} }
const dataSources = await this.getDataSources(); const dataSources = await this.getDataSources({ user });
const dataProviderServices = dataSources.map((dataSource) => { const dataProviderServices = dataSources.map((dataSource) => {
return this.getDataProvider(DataSource[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, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { REPLACE_NAME_PARTS } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
@ -186,7 +187,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
response.isin = assetProfile.isin; response.isin = assetProfile.isin;
} }
response.name = assetProfile.companyName; response.name = this.formatName({ name: assetProfile.companyName });
if (assetProfile.website) { if (assetProfile.website) {
response.url = assetProfile.website; response.url = assetProfile.website;
@ -398,7 +399,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
assetSubClass: undefined, // TODO assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(), dataSource: this.getName(),
name: companyName name: this.formatName({ name: companyName })
}; };
}); });
} else { } else {
@ -414,12 +415,12 @@ export class FinancialModelingPrepService implements DataProviderInterface {
items = result.map(({ currency, name, symbol }) => { items = result.map(({ currency, name, symbol }) => {
return { return {
currency, currency,
name,
symbol, symbol,
assetClass: undefined, // TODO assetClass: undefined, // TODO
assetSubClass: undefined, // TODO assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName() dataSource: this.getName(),
name: this.formatName({ name })
}; };
}); });
} }
@ -438,6 +439,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return { items }; 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 }) { private getUrl({ version }: { version: number }) {
return `https://financialmodelingprep.com/api/v${version}`; 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, isToday,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @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[]) { 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_SOURCE_IMPORT: string;
DATA_SOURCES: string[]; DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
DATA_SOURCES_LEGACY: string[];
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: 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: { public async updateMarketData(params: {
data: { data: {
state: MarketDataState; state: MarketDataState;

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

@ -179,7 +179,8 @@ export class DataGatheringService {
); );
if (!assetProfileIdentifiers) { if (!assetProfileIdentifiers) {
assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers(); assetProfileIdentifiers =
await this.getAllActiveAssetProfileIdentifiers();
} }
if (assetProfileIdentifiers.length <= 0) { if (assetProfileIdentifiers.length <= 0) {
@ -345,11 +346,14 @@ export class DataGatheringService {
); );
} }
public async getAllAssetProfileIdentifiers(): Promise< public async getAllActiveAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[] AssetProfileIdentifier[]
> { > {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }] orderBy: [{ symbol: 'asc' }],
where: {
isActive: true
}
}); });
return symbolProfiles return symbolProfiles
@ -419,9 +423,11 @@ export class DataGatheringService {
withUserSubscription?: boolean; withUserSubscription?: boolean;
}): Promise<IDataGatheringItem[]> { }): Promise<IDataGatheringItem[]> {
const symbolProfiles = const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByUserSubscription({ await this.symbolProfileService.getActiveSymbolProfilesByUserSubscription(
withUserSubscription {
}); withUserSubscription
}
);
const assetProfileIdentifiersWithCompleteMarketData = const assetProfileIdentifiersWithCompleteMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData(); await this.getAssetProfileIdentifiersWithCompleteMarketData();
@ -485,6 +491,9 @@ export class DataGatheringService {
}, },
scraperConfiguration: true, scraperConfiguration: true,
symbol: 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({ const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: job.data.userCurrency, currency: job.data.userCurrency,
filters: job.data.filters, filters: job.data.filters,
userId: job.data.userId 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 { export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async add( @LogPerformance
assetProfile: Prisma.SymbolProfileCreateInput public async getActiveSymbolProfilesByUserSubscription({
): Promise<SymbolProfile | never> { withUserSubscription = false
return this.prismaService.symbolProfile.create({ data: assetProfile }); }: {
} withUserSubscription?: boolean;
}) {
public async delete({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.symbolProfile.findMany({
return this.prismaService.symbolProfile.delete({ include: {
where: { dataSource_symbol: { dataSource, symbol } } Order: {
}); include: {
} User: true
}
public async deleteById(id: string) { }
return this.prismaService.symbolProfile.delete({ },
where: { id } 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( public async getSymbolProfilesByIds(
symbolProfileIds: string[] symbolProfileIds: string[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
@ -100,57 +136,43 @@ export class SymbolProfileService {
}); });
} }
public async getSymbolProfilesByUserSubscription({ public updateAssetProfileIdentifier(
withUserSubscription = false oldAssetProfileIdentifier: AssetProfileIdentifier,
}: { newAssetProfileIdentifier: AssetProfileIdentifier
withUserSubscription?: boolean; ) {
}) { return this.prismaService.symbolProfile.update({
return this.prismaService.symbolProfile.findMany({ data: {
include: { dataSource: newAssetProfileIdentifier.dataSource,
Order: { symbol: newAssetProfileIdentifier.symbol
include: {
User: true
}
}
}, },
orderBy: [{ symbol: 'asc' }],
where: { where: {
Order: withUserSubscription dataSource_symbol: {
? { dataSource: oldAssetProfileIdentifier.dataSource,
some: { symbol: oldAssetProfileIdentifier.symbol
User: { }
Subscription: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
}
}
}
} }
}); });
} }
public updateSymbolProfile({ public updateSymbolProfile(
assetClass, { dataSource, symbol }: AssetProfileIdentifier,
assetSubClass, {
comment, assetClass,
countries, assetSubClass,
currency, comment,
dataSource, countries,
holdings, currency,
name, holdings,
tags, isActive,
scraperConfiguration, name,
sectors, tags,
symbol, scraperConfiguration,
symbolMapping, sectors,
SymbolProfileOverrides, symbolMapping,
url SymbolProfileOverrides,
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) { url
}: Prisma.SymbolProfileUpdateInput
) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { data: {
assetClass, assetClass,
@ -159,6 +181,7 @@ export class SymbolProfileService {
countries, countries,
currency, currency,
holdings, holdings,
isActive,
name, name,
tags, tags,
scraperConfiguration, scraperConfiguration,

8
apps/client/ngsw-config.json

@ -6,13 +6,7 @@
"name": "app", "name": "app",
"installMode": "prefetch", "installMode": "prefetch",
"resources": { "resources": {
"files": [ "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
"/favicon.ico",
"/index.html",
"/assets/site.webmanifest",
"/*.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/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" "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>
} }
<li> @if (!hasPermissionForSubscription) {
<a i18n [routerLink]="routerLinkAboutLicense">License</a> <li>
</li> <a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li>
}
@if (hasPermissionForStatistics) { @if (hasPermissionForStatistics) {
<li> <li>
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="['/open']">Open Startup</a>
@ -104,6 +106,13 @@
> >
</li> </li>
} }
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutTermsOfService"
>Terms of Service</a
>
</li>
}
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<li> <li>
<a <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:about`,
$localize`:snake-case:privacy-policy` $localize`:snake-case:privacy-policy`
]; ];
public routerLinkAboutTermsOfService = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:terms-of-service`
];
public routerLinkFaq = ['/' + $localize`:snake-case:faq`]; public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`]; public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`]; public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];

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

@ -69,7 +69,7 @@
</div> </div>
<mat-tab-group <mat-tab-group
animationDuration="0" animationDuration="0ms"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': isLoadingActivities }" [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 dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(
this.router.navigate(['.'], { relativeTo: this.route }); (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; aspect-ratio: 16/9;
} }
.edit-asset-profile-identifier-container {
bottom: 0;
right: 1rem;
top: 0;
}
.mat-expansion-panel { .mat-expansion-panel {
--mat-expansion-container-background-color: transparent; --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 { translate } from '@ghostfolio/ui/i18n';
import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -27,9 +28,16 @@ import {
ViewChild, ViewChild,
signal signal
} from '@angular/core'; } 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 { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -38,6 +46,8 @@ import {
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import ms from 'ms';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -54,14 +64,26 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
export class AssetProfileDialog implements OnDestroy, OnInit { export class AssetProfileDialog implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>; @ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public separatorKeysCodes: number[] = [ENTER, COMMA]; 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 assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => { public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) }; return { id: assetClass, label: translate(assetClass) };
}); });
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => { public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) }; return { id: assetSubClass, label: translate(assetSubClass) };
}); });
public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined), assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined), assetSubClass: new FormControl<AssetSubClass>(undefined),
@ -86,16 +108,35 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
symbolMapping: '', symbolMapping: '',
url: '' 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 assetProfileSubClass: string;
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public currencies: string[] = []; public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isBenchmark = false; public isBenchmark = false;
public isEditAssetProfileIdentifierMode = false;
public marketDataItems: MarketData[] = []; public marketDataItems: MarketData[] = [];
public modeValues = [ public modeValues = [
{ {
value: 'lazy', value: 'lazy',
@ -106,18 +147,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
viewValue: $localize`Instant` + ' (' + $localize`real-time` + ')' viewValue: $localize`Instant` + ' (' + $localize`real-time` + ')'
} }
]; ];
public scraperConfiguationIsExpanded = signal(false); public scraperConfiguationIsExpanded = signal(false);
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public HoldingTags: { id: string; name: string; userId: string }[]; public HoldingTags: { id: string; name: string; userId: string }[];
public user: User; public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -129,9 +169,22 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService 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() { public ngOnInit() {
const { benchmarks, currencies } = this.dataService.fetchInfo(); 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() { public onClose() {
this.dialogRef.close(); 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 countries = [];
let scraperConfiguration = {}; let scraperConfiguration = {};
let sectors = []; let sectors = [];
@ -352,7 +419,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
); );
} catch {} } catch {}
const assetProfileData: UpdateAssetProfileDto = { const assetProfile: UpdateAssetProfileDto = {
countries, countries,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
@ -371,7 +438,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
await validateObjectForForm({ await validateObjectForForm({
classDto: UpdateAssetProfileDto, classDto: UpdateAssetProfileDto,
form: this.assetProfileForm, form: this.assetProfileForm,
object: assetProfileData object: assetProfile
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -379,16 +446,80 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
this.adminService this.adminService
.patchAssetProfile({ .patchAssetProfile(
...assetProfileData, {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) },
assetProfile
)
.subscribe(() => { .subscribe(() => {
this.initialize(); 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() { public onTestMarketData() {
this.adminService this.adminService
.testMarketData({ .testMarketData({
@ -483,4 +614,24 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); 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 <div class="d-flex flex-column h-100">
class="d-flex flex-column h-100"
[formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<div class="d-flex mb-3"> <div class="d-flex mb-3">
<h1 class="flex-grow-1 m-0" mat-dialog-title> <h1 class="flex-grow-1 m-0" mat-dialog-title>
{{ assetProfile?.name ?? data.symbol }} {{ assetProfile?.name ?? data.symbol }}
@ -104,21 +99,84 @@
/> />
<div class="row"> <div class="row">
<div class="col-6 mb-3"> @if (isEditAssetProfileIdentifierMode) {
<gf-value i18n size="medium" [value]="assetProfile?.symbol" <div class="col-12 mb-4">
>Symbol</gf-value <form
> class="align-items-center d-flex"
</div> [formGroup]="assetProfileIdentifierForm"
<div class="col-6 mb-3"> (keyup.enter)="
<gf-value assetProfileIdentifierForm.valid &&
i18n onSubmitAssetProfileIdentifierForm()
size="medium" "
[value]=" (ngSubmit)="onSubmitAssetProfileIdentifierForm()"
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource >
" <mat-form-field appearance="outline" class="gf-spacer without-hint">
>Data Source</gf-value <mat-label i18n>Name, symbol or ISIN</mat-label>
> <gf-symbol-autocomplete
</div> 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"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency" <gf-value i18n size="medium" [value]="assetProfile?.currency"
>Currency</gf-value >Currency</gf-value
@ -215,261 +273,287 @@
} }
} }
</div> </div>
<div class="mt-3"> <form
<mat-form-field appearance="outline" class="w-100 without-hint"> #assetProfileFormElement
<mat-label i18n>Name</mat-label> [formGroup]="assetProfileForm"
<input formControlName="name" matInput type="text" /> (keyup.enter)="assetProfileForm.valid && onSubmitAssetProfileForm()"
</mat-form-field> (ngSubmit)="onSubmitAssetProfileForm()"
</div> >
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mt-3"> <div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label> <mat-label i18n>Name</mat-label>
<gf-currency-selector <input formControlName="name" matInput type="text" />
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field> </mat-form-field>
</div> </div>
} @if (assetProfile?.dataSource === 'MANUAL') {
<div class="mt-3"> <div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label> <mat-label i18n>Currency</mat-label>
<mat-select formControlName="assetClass"> <gf-currency-selector
<mat-option [value]="null" /> formControlName="currency"
@for (assetClass of assetClasses; track assetClass) { [currencies]="currencies"
<mat-option [value]="assetClass.id">{{ />
assetClass.label </mat-form-field>
}}</mat-option> </div>
} }
</mat-select> <div class="mt-3">
</mat-form-field> <mat-form-field appearance="outline" class="w-100 without-hint">
</div> <mat-label i18n>Asset Class</mat-label>
<div class="mt-3"> <mat-select formControlName="assetClass">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-option [value]="null" />
<mat-label i18n>Asset Sub Class</mat-label> @for (assetClass of assetClasses; track assetClass) {
<mat-select formControlName="assetSubClass"> <mat-option [value]="assetClass.id">{{
<mat-option [value]="null" /> assetClass.label
@for (assetSubClass of assetSubClasses; track assetSubClass) { }}</mat-option>
<mat-option [value]="assetSubClass.id">{{ }
assetSubClass.label </mat-select>
}}</mat-option> </mat-form-field>
} </div>
</mat-select> <div class="mt-3">
</mat-form-field> <mat-form-field appearance="outline" class="w-100 without-hint">
</div> <mat-label i18n>Asset Sub Class</mat-label>
<div class="mt-3"> <mat-select formControlName="assetSubClass">
<mat-form-field appearance="outline" class="w-100"> <mat-option [value]="null" />
<mat-label i18n>Tags</mat-label> @for (assetSubClass of assetSubClasses; track assetSubClass) {
<mat-chip-grid #tagsChipList> <mat-option [value]="assetSubClass.id">{{
<mat-chip-row assetSubClass.label
*ngFor="let tag of assetProfileForm.controls['tags']?.value" }}</mat-option>
matChipRemove }
[removable]="true" </mat-select>
(removed)="onRemoveTag(tag)" </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 }} <mat-option *ngFor="let tag of HoldingTags" [value]="tag.id">
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon> {{ tag.name }}
</mat-chip-row> </mat-option>
<input </mat-autocomplete>
#tagInput </mat-form-field>
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
>
</div> </div>
</div> <div class="d-flex my-3">
<div class="mt-3"> <div class="w-50">
<mat-form-field appearance="outline" class="w-100"> <mat-checkbox
<mat-label i18n>Symbol Mapping</mat-label> color="primary"
<textarea i18n
cdkTextareaAutosize [checked]="isBenchmark"
formControlName="symbolMapping" [disabled]="isEditAssetProfileIdentifierMode"
matInput (change)="
type="text" isBenchmark
></textarea> ? onUnsetBenchmark({
</mat-form-field> dataSource: data.dataSource,
</div> symbol: data.symbol
@if (assetProfile?.dataSource === 'MANUAL') { })
<div class="mb-3"> : onSetBenchmark({
<mat-accordion class="my-3"> dataSource: data.dataSource,
<mat-expansion-panel symbol: data.symbol
class="shadow-none" })
[expanded]="
assetProfileForm.controls.scraperConfiguration.controls.selector
.value !== '' &&
assetProfileForm.controls.scraperConfiguration.controls.url
.value !== ''
" "
(closed)="scraperConfiguationIsExpanded.set(false)" >Benchmark</mat-checkbox
(opened)="scraperConfiguationIsExpanded.set(true)"
> >
<mat-expansion-panel-header class="p-0"> </div>
<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>
} <div class="mt-3">
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label> <mat-label i18n>Symbol Mapping</mat-label>
<textarea <textarea
cdkTextareaAutosize cdkTextareaAutosize
formControlName="sectors" formControlName="symbolMapping"
matInput matInput
type="text" type="text"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </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> <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-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label> <mat-label i18n>Note</mat-label>
<textarea <textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize cdkTextareaAutosize
formControlName="countries" formControlName="comment"
matInput matInput
type="text" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
} </form>
<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>
</div> </div>
<div class="d-flex justify-content-end" mat-dialog-actions> <div class="d-flex justify-content-end" mat-dialog-actions>
@ -477,10 +561,10 @@
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit"
[disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)" [disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)"
(click)="onTriggerSubmitAssetProfileForm()"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>
</div> </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 { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
@ -35,6 +36,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatChipsModule, MatChipsModule,
GfSymbolAutocompleteComponent,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatCheckboxModule, 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'; } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode } from 'class-validator';
import { uniq } from 'lodash';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces'; import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@ -87,7 +86,9 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.createAssetProfileForm.get('addCurrency').value as string this.createAssetProfileForm.get('addCurrency').value as string
).toUpperCase(); ).toUpperCase();
const currencies = uniq([...this.customCurrencies, currency]).sort(); const currencies = Array.from(
new Set([...this.customCurrencies, currency])
).sort();
this.dataService this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, { .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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
PROPERTY_COUPONS, PROPERTY_COUPONS,
PROPERTY_CURRENCIES,
PROPERTY_IS_DATA_GATHERING_ENABLED, PROPERTY_IS_DATA_GATHERING_ENABLED,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED, PROPERTY_IS_USER_SIGNUP_ENABLED,
@ -41,8 +40,6 @@ import { takeUntil } from 'rxjs/operators';
export class AdminOverviewComponent implements OnDestroy, OnInit { export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '14 days'; public couponDuration: StringValue = '14 days';
public coupons: Coupon[]; public coupons: Coupon[];
public customCurrencies: string[];
public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: 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() { public onDeleteSystemMessage() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
@ -231,25 +215,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(({ settings, transactionCount, userCount, version }) => {
({ exchangeRates, settings, transactionCount, userCount, version }) => { this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; this.isDataGatheringEnabled =
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
this.exchangeRates = exchangeRates; this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage;
this.isDataGatheringEnabled = this.transactionCount = transactionCount;
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false this.userCount = userCount;
? false this.version = version;
: 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) { private generateCouponCode(aLength: number) {

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

@ -30,73 +30,6 @@
} }
</div> </div>
</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="d-flex my-3">
<div class="w-50" i18n>User Signup</div> <div class="w-50" i18n>User Signup</div>
<div class="w-50"> <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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
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 { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
@ -26,11 +21,18 @@ import {
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; 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({ @Component({
selector: 'gf-admin-users', selector: 'gf-admin-users',
standalone: false,
styleUrls: ['./admin-users.scss'], styleUrls: ['./admin-users.scss'],
templateUrl: './admin-users.html', templateUrl: './admin-users.html'
standalone: false
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ -55,6 +57,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService, private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo(); 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) { public onImpersonateUser(aId: string) {
if (aId) { if (aId) {
this.impersonationStorageService.setId(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 i18n>Impersonate User</span>
</span> </span>
</button> </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 <button
mat-menu-item mat-menu-item
[disabled]="element.id === user?.id" [disabled]="element.id === user?.id"

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

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

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

@ -312,7 +312,7 @@
</div> </div>
<mat-tab-group <mat-tab-group
animationDuration="0" animationDuration="0ms"
class="mb-5" class="mb-5"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !dataSource?.data.length }" [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> <ng-container i18n>Net Performance</ng-container>
<abbr <abbr
class="initialism ml-2 text-muted" class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return" title="Return on Average Investment"
>(TWR)</abbr >(ROAI)</abbr
> >
</div> </div>
<div class="flex-column flex-wrap justify-content-end"> <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() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if (this.summary.firstOrderDate) { if (this.user.dateOfFirstActivity) {
this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate, { this.timeInMarket = formatDistanceToNow(this.user.dateOfFirstActivity, {
locale: getDateFnsLocale(this.language) locale: getDateFnsLocale(this.language)
}); });
} else { } 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 { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { uniq } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs'; import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; 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.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort()); this.locales = Array.from(new Set(this.locales)).sort();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

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

@ -7,5 +7,6 @@ export const paths = {
pricing: $localize`pricing`, pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`, privacyPolicy: $localize`privacy-policy`,
register: $localize`register`, 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( import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule (m) => m.PrivacyPolicyPageModule
) )
},
{
path: paths.termsOfService,
loadChildren: () =>
import('./terms-of-service/terms-of-service-page.module').then(
(m) => m.TermsOfServicePageModule
)
} }
], ],
component: AboutPageComponent, 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) => { .subscribe((state) => {
this.tabs = [ this.tabs = [
{ {
iconName: 'reader-outline', iconName: 'information-circle-outline',
label: $localize`About`, label: $localize`About`,
path: ['/' + $localize`about`] path: ['/' + $localize`about`]
}, },
@ -53,7 +53,8 @@ export class AboutPageComponent implements OnDestroy, OnInit {
{ {
iconName: 'ribbon-outline', iconName: 'ribbon-outline',
label: $localize`License`, 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`], path: ['/' + $localize`about`, $localize`privacy-policy`],
showCondition: this.hasPermissionForSubscription 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.user = state.user;
this.changeDetectorRef.markForCheck(); 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({ @Component({
selector: 'gf-privacy-policy-page', selector: 'gf-privacy-policy-page',
standalone: false,
styleUrls: ['./privacy-policy-page.scss'], styleUrls: ['./privacy-policy-page.scss'],
templateUrl: './privacy-policy-page.html', templateUrl: './privacy-policy-page.html'
standalone: false
}) })
export class PrivacyPolicyPageComponent implements OnDestroy { export class PrivacyPolicyPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>(); 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); 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> <div class="flex-grow-1" mat-dialog-content>
<mat-stepper <mat-stepper
#stepper #stepper
animationDuration="0" animationDuration="0ms"
[linear]="true" [linear]="true"
[orientation]="stepperOrientation" [orientation]="stepperOrientation"
[selectedIndex]="importStep" [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 { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard'; 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 { MatSnackBar } from '@angular/material/snack-bar';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { isNumber, sortBy } from 'lodash'; import { isNumber, sortBy } from 'lodash';
@ -36,6 +43,8 @@ import { takeUntil } from 'rxjs/operators';
standalone: false standalone: false
}) })
export class AnalysisPageComponent implements OnDestroy, OnInit { export class AnalysisPageComponent implements OnDestroy, OnInit {
@ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger;
public benchmark: Partial<SymbolProfile>; public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = []; public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
@ -57,10 +66,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Investment`; public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[]; public investmentsByGroup: InvestmentItem[];
public isLoadingAnalysisPrompt: boolean;
public isLoadingBenchmarkComparator: boolean; public isLoadingBenchmarkComparator: boolean;
public isLoadingDividendTimelineChart: boolean; public isLoadingDividendTimelineChart: boolean;
public isLoadingInvestmentChart: boolean; public isLoadingInvestmentChart: boolean;
public isLoadingInvestmentTimelineChart: boolean; public isLoadingInvestmentTimelineChart: boolean;
public isLoadingPortfolioPrompt: boolean;
public mode: GroupBy = 'month'; public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [ public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' }, { label: $localize`Monthly`, value: 'month' },
@ -174,8 +185,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
} }
public onCopyPromptToClipboard(mode: AiPromptMode) { public onCopyPromptToClipboard(mode: AiPromptMode) {
if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = true;
} else if (mode === 'portfolio') {
this.isLoadingPortfolioPrompt = true;
}
this.dataService this.dataService
.fetchPrompt(mode) .fetchPrompt({
mode,
filters: this.userService.getFilters()
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ prompt }) => { .subscribe(({ prompt }) => {
this.clipboard.copy(prompt); this.clipboard.copy(prompt);
@ -194,6 +214,14 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.subscribe(() => { .subscribe(() => {
window.open('https://duck.ai', '_blank'); 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="col-lg">
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button <button
#actionsMenuButton
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-stroked-button mat-stroked-button
[matMenuTriggerFor]="actionsMenu" [matMenuTriggerFor]="actionsMenu"
@ -12,39 +13,62 @@
> >
<ion-icon name="ellipsis-vertical" /> <ion-icon name="ellipsis-vertical" />
</button> </button>
<mat-menu #actionsMenu="matMenu" xPosition="before"> <mat-menu
<button #actionsMenu="matMenu"
mat-menu-item class="no-max-width"
[disabled]="!hasPermissionToReadAiPrompt" xPosition="before"
(click)="onCopyPromptToClipboard('portfolio')" >
> <div (click)="$event.stopPropagation()">
<span class="align-items-center d-flex"> <button
@if (user?.subscription?.type === 'Basic') { mat-menu-item
<gf-premium-indicator class="mr-2" /> [disabled]="!hasPermissionToReadAiPrompt"
} @else { (click)="onCopyPromptToClipboard('portfolio')"
<ion-icon class="mr-2" name="copy-outline" /> >
} <span class="align-items-center d-flex">
<ng-container i18n @if (user?.subscription?.type === 'Basic') {
>Copy portfolio data to clipboard for AI prompt</ng-container <gf-premium-indicator class="mr-2" />
> } @else {
</span> @if (isLoadingPortfolioPrompt) {
</button> <mat-spinner
<button class="mr-2"
mat-menu-item color="accent"
[disabled]="!hasPermissionToReadAiPrompt" [diameter]="16"
(click)="onCopyPromptToClipboard('analysis')" />
> } @else {
<span class="align-items-center d-flex"> <ion-icon class="mr-2" name="copy-outline" />
@if (user?.subscription?.type === 'Basic') { }
<gf-premium-indicator class="mr-2" /> }
} @else { <ng-container i18n
<ion-icon class="mr-2" name="copy-outline" /> >Copy portfolio data to clipboard for AI
} prompt</ng-container
<ng-container i18n >
>Copy AI prompt to clipboard for analysis</ng-container </span>
> </button>
</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> </mat-menu>
</div> </div>
</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 { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module'; import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
@ -29,6 +30,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatMenuModule, MatMenuModule,
MatProgressSpinnerModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] 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 { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; 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'; import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({ @Component({
@ -24,6 +25,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public demoAuthToken: string; public demoAuthToken: string;
public deviceType: string; public deviceType: string;
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public info: InfoItem; public info: InfoItem;
@ -52,6 +54,10 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
globalPermissions, globalPermissions,
permissions.enableSocialLogin permissions.enableSocialLogin
); );
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToCreateUser = hasPermission( this.hasPermissionToCreateUser = hasPermission(
globalPermissions, globalPermissions,
permissions.createUserAccount permissions.createUserAccount
@ -61,15 +67,22 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public async onLoginWithInternetIdentity() { public async onLoginWithInternetIdentity() {
try { try {
const { authToken } = await this.internetIdentityService.login(); const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken); this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
await this.router.navigate(['/']);
} catch {} } catch {}
} }
public openShowAccessTokenDialog() { public openShowAccessTokenDialog() {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, { const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
deviceType: this.deviceType,
needsToAcceptTermsOfService: this.hasPermissionForSubscription
} as ShowAccessTokenDialogParams,
disableClose: true, disableClose: true,
width: '30rem' height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '30rem'
}); });
dialogRef 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, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatStepper } from '@angular/material/stepper'; import { MatStepper } from '@angular/material/stepper';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-show-access-token-dialog', selector: 'gf-show-access-token-dialog',
@ -25,11 +29,16 @@ export class ShowAccessTokenDialog {
public isCreateAccountButtonDisabled = true; public isCreateAccountButtonDisabled = true;
public isDisclaimerChecked = false; public isDisclaimerChecked = false;
public role: string; public role: string;
public routerLinkAboutTermsOfService = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:terms-of-service`
];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ShowAccessTokenDialogParams,
private dataService: DataService private dataService: DataService
) {} ) {}

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

Loading…
Cancel
Save