Browse Source

Merge pull request #168 from dandevaud/mr/Upstream-2025-04-25

Mr/upstream 2025 04 25
pull/5027/head
dandevaud 2 months ago
committed by GitHub
parent
commit
135a40422c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .husky/pre-commit
  2. 86
      CHANGELOG.md
  3. 16
      apps/api/src/app/admin/admin.controller.ts
  4. 2
      apps/api/src/app/admin/admin.module.ts
  5. 36
      apps/api/src/app/admin/admin.service.ts
  6. 2
      apps/api/src/app/admin/update-asset-profile.dto.ts
  7. 4
      apps/api/src/app/app.module.ts
  8. 5
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  9. 10
      apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts
  10. 85
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  11. 19
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  12. 79
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  13. 3
      apps/api/src/app/export/export.service.ts
  14. 5
      apps/api/src/app/import/import.controller.ts
  15. 82
      apps/api/src/app/import/import.service.ts
  16. 2
      apps/api/src/app/info/info.module.ts
  17. 29
      apps/api/src/app/info/info.service.ts
  18. 2
      apps/api/src/app/order/create-order.dto.ts
  19. 4
      apps/api/src/app/order/interfaces/activities.interface.ts
  20. 21
      apps/api/src/app/order/order.service.ts
  21. 595
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  22. 5
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  23. 3
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  24. 37
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  25. 282
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  26. 21
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  27. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  28. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  29. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  30. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  31. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  32. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts
  33. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  34. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  35. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  36. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  37. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  38. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  39. 29
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  40. 5
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  41. 32
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  42. 69
      apps/api/src/app/portfolio/current-rate.service.ts
  43. 6
      apps/api/src/app/portfolio/portfolio.controller.ts
  44. 70
      apps/api/src/app/portfolio/portfolio.service.ts
  45. 45
      apps/api/src/app/subscription/subscription.service.ts
  46. 23
      apps/api/src/app/user/user.service.ts
  47. 370
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  48. 8
      apps/api/src/services/cron.service.ts
  49. 5
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  50. 7
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  51. 21
      apps/api/src/services/data-provider/data-provider.service.ts
  52. 7
      apps/api/src/services/data-provider/errors/asset-profile-delisted.error.ts
  53. 80
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  54. 7
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  55. 40
      apps/api/src/services/market-data/market-data.service.ts
  56. 90
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  57. 2
      apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts
  58. 7
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  59. 4
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  60. 11
      apps/api/src/validators/is-currency-code.ts
  61. 12
      apps/client/src/app/app.component.ts
  62. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  63. 34
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  64. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  65. 28
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  66. 2
      apps/client/src/app/components/admin-users/admin-users.html
  67. 10
      apps/client/src/app/components/header/header.component.html
  68. 9
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  69. 19
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  70. 15
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  71. 64
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  72. 58
      apps/client/src/app/pages/api/api-page.component.ts
  73. 17
      apps/client/src/app/pages/api/api-page.html
  74. 78
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  75. 74
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  76. 5
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  77. 29
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  78. 10
      apps/client/src/app/pages/pricing/pricing-page.html
  79. 4
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  80. 3
      apps/client/src/app/services/import-activities.service.ts
  81. 360
      apps/client/src/locales/messages.ca.xlf
  82. 358
      apps/client/src/locales/messages.de.xlf
  83. 360
      apps/client/src/locales/messages.es.xlf
  84. 360
      apps/client/src/locales/messages.fr.xlf
  85. 360
      apps/client/src/locales/messages.it.xlf
  86. 360
      apps/client/src/locales/messages.nl.xlf
  87. 360
      apps/client/src/locales/messages.pl.xlf
  88. 360
      apps/client/src/locales/messages.pt.xlf
  89. 360
      apps/client/src/locales/messages.tr.xlf
  90. 360
      apps/client/src/locales/messages.uk.xlf
  91. 354
      apps/client/src/locales/messages.xlf
  92. 360
      apps/client/src/locales/messages.zh.xlf
  93. 4
      libs/common/src/lib/config.ts
  94. 1
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  95. 4
      libs/common/src/lib/interfaces/info-item.interface.ts
  96. 1
      libs/common/src/lib/interfaces/subscription-offer.interface.ts
  97. 2
      libs/common/src/lib/interfaces/user-settings.interface.ts
  98. 4
      libs/common/src/lib/interfaces/user.interface.ts
  99. 16
      libs/common/src/lib/permissions.ts
  100. 38
      libs/common/src/lib/personal-finance-tools.ts

2
.husky/pre-commit

@ -0,0 +1,2 @@
# Check formatting on modified and uncommitted files, stop the commit if issues are found
npm run format:write --uncommitted || exit 1

86
CHANGELOG.md

@ -9,7 +9,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the error message of the currency code validation
- Tightened the currency code validation by requiring uppercase letters
### Fixed
- Improved the file selector of the activities import functionality to accept case-insensitive file extensions (`.CSV` and `.JSON`)
## 2.155.0 - 2025-04-23
### Added
- Added the endpoints (`DELETE`, `GET` and `POST`) for the watchlist
### Changed
- Simplified the data source check in the DTO of the activity creation
- Simplified the data source check in the DTO of the asset profile update
- Renamed `User` to `user` in the `Subscription` database schema
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
### Fixed
- Fixed an issue in the settings dialog to customize the rule thresholds of the _X-ray_ page (experimental)
## 2.154.0 - 2025-04-21
### Added
- Extended the benchmark detail dialog by the current market price
- Added the performance calculation type to the user settings (experimental)
- Added `watchlist` to the `User` database schema as a preparation for watching assets
### Changed
- Made the historical market data editor expandable in the admin control panel
- Renamed `Subscription` to `subscriptions` in the `User` database schema
- Parallelized the requests in the get quotes functionality of the _Financial Modeling Prep_ service
- Migrated the lookup functionality by `isin` of the _Financial Modeling Prep_ service to its stable API version
- Improved the language localization for German (`de`)
### Fixed
- Fixed the word wrap in the menu of the historical market data table in the admin control panel
## 2.153.0 - 2025-04-18
### Changed
- Added support for activities in a custom currency
- Refreshed the cryptocurrencies list
- Upgraded `chart.js` from version `4.4.7` to `4.4.9`
- Upgraded `uuid` from version `11.0.5` to `11.1.0`
### Fixed
- Fixed the functionality to open an asset profile of a custom currency in the admin control panel
- Fixed the asset class parsing in the _Financial Modeling Prep_ service for exchange rates
## 2.152.1 - 2025-04-17
### Changed
- Deactivated asset profiles automatically on delisting in the _Yahoo Finance_ service
- Optimized the query of the data range functionality (`getRange()`) in the market data service
- Moved the subscription offer from the info to the user service
- Upgraded `Nx` from version `20.7.1` to `20.8.0`
- Upgraded `prisma` from version `6.5.0` to `6.6.0`
- Upgraded `storybook` from version `8.4.7` to `8.6.12`
## 2.151.0 - 2025-04-11
### Added
- Added the data gathering status column to the historical market data table of the admin control
### Changed
- Set the maximum number of symbols per request in the _Financial Modeling Prep_ service
- Migrated the get quotes functionality of the _Financial Modeling Prep_ service to its stable API version
- Improved the language localization for Enlish (`en`)
- Upgraded `eslint` dependencies
- Upgraded `Nx` from version `20.6.4` to `20.7.1`
### Fixed
- Fixed the link to the pricing page in the premium indicator component
## 2.150.0 - 2025-04-05

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

@ -9,8 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
@ -92,9 +92,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
@ -136,9 +136,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
@ -159,9 +159,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}

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

@ -1,5 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -32,7 +31,6 @@ import { QueueModule } from './queue/queue.module';
PrismaModule,
PropertyModule,
QueueModule,
SubscriptionModule,
SymbolProfileModule,
SymbolProfileOverwriteModule,
TransformDataSourceInRequestModule

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

@ -1,5 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -62,7 +61,6 @@ export class AdminService {
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -249,6 +247,7 @@ export class AdminService {
currency: true,
dataSource: true,
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
Order: {
@ -310,6 +309,7 @@ export class AdminService {
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
name,
Order,
@ -369,6 +369,7 @@ export class AdminService {
countriesCount,
dataSource,
id,
isActive,
lastMarketPrice,
name,
symbol,
@ -454,7 +455,8 @@ export class AdminService {
currency,
dataSource,
dateOfFirstActivity,
symbol
symbol,
isActive: true
}
};
}
@ -649,7 +651,7 @@ export class AdminService {
Order: {
where: {
User: {
Subscription: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
@ -754,6 +756,7 @@ export class AdminService {
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
tags: []
@ -810,25 +813,32 @@ export class AdminService {
createdAt: true,
id: true,
role: true,
Subscription: true
subscriptions: {
orderBy: {
expiresAt: 'desc'
},
take: 1,
where: {
expiresAt: {
gt: new Date()
}
}
}
}
});
return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, Subscription }) => {
({ _count, Analytics, createdAt, id, role, subscriptions }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics
? Analytics.activityCount / daysSinceRegistration
: undefined;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription({
createdAt,
subscriptions: Subscription
})
const subscription =
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscriptions?.length > 0
? subscriptions[0]
: undefined;
return {

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

@ -38,7 +38,7 @@ export class UpdateAssetProfileDto {
@IsOptional()
currency?: string;
@IsEnum(DataSource, { each: true })
@IsEnum(DataSource)
@IsOptional()
dataSource?: DataSource;

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

@ -38,6 +38,7 @@ import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfol
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -128,7 +129,8 @@ import { UserModule } from './user/user.module';
SymbolModule,
TagsModule,
TwitterBotModule,
UserModule
UserModule,
WatchlistModule
],
providers: [CronService]
})

5
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -24,6 +24,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { isISIN } from 'class-validator';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
@ -301,7 +302,9 @@ export class GhostfolioController {
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
query: isISIN(query.toUpperCase())
? query.toUpperCase()
: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({

10
apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts

@ -0,0 +1,10 @@
import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator';
export class CreateWatchlistItemDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}

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

@ -0,0 +1,85 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@Post()
@HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
return this.watchlistService.createWatchlistItem({
dataSource: data.dataSource,
symbol: data.symbol,
userId: this.request.user.id
});
}
@Delete(':dataSource/:symbol')
@HasPermission(permissions.deleteWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteWatchlistItem(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const watchlistItem = await this.watchlistService
.getWatchlistItems(this.request.user.id)
.then((items) => {
return items.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
});
if (!watchlistItem) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.watchlistService.deleteWatchlistItem({
dataSource,
symbol,
userId: this.request.user.id
});
}
@Get()
@HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(): Promise<AssetProfileIdentifier[]> {
return this.watchlistService.getWatchlistItems(this.request.user.id);
}
}

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

@ -0,0 +1,19 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { WatchlistController } from './watchlist.controller';
import { WatchlistService } from './watchlist.service';
@Module({
controllers: [WatchlistController],
imports: [
PrismaModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [WatchlistService]
})
export class WatchlistModule {}

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

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@Injectable()
export class WatchlistService {
public constructor(private readonly prismaService: PrismaService) {}
public async createWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: { dataSource, symbol }
}
});
if (!symbolProfile) {
throw new NotFoundException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.prismaService.user.update({
data: {
watchlist: {
connect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async deleteWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
await this.prismaService.user.update({
data: {
watchlist: {
disconnect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async getWatchlistItems(
userId: string
): Promise<AssetProfileIdentifier[]> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
select: { dataSource: true, symbol: true }
}
},
where: { id: userId }
});
return user.watchlist ?? [];
}
}

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

@ -120,6 +120,7 @@ export class ExportService {
({
accountId,
comment,
currency,
date,
fee,
id,
@ -137,7 +138,7 @@ export class ExportService {
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)

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

@ -98,12 +98,9 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency
symbol
});
return { activities };

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

@ -10,12 +10,10 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
@ -29,8 +27,8 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { isNumber, uniqBy } from 'lodash';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
@ -40,7 +38,6 @@ export class ImportService {
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
@ -49,9 +46,8 @@ export class ImportService {
public async getDividends({
dataSource,
symbol,
userCurrency
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
symbol
}: AssetProfileIdentifier): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
@ -121,22 +117,16 @@ export class ImportService {
currency: undefined,
createdAt: undefined,
fee: 0,
feeInBaseCurrency: 0,
feeInAssetProfileCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined,
userId: Account?.userId,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
userId: Account?.userId
};
})
);
@ -266,17 +256,17 @@ export class ImportService {
const activities: Activity[] = [];
for (const [index, activity] of activitiesExtendedWithErrors.entries()) {
for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId;
const comment = activity.comment;
const currency = activity.currency;
const date = activity.date;
const error = activity.error;
let fee = activity.fee;
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const type = activity.type;
let unitPrice = activity.unitPrice;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
@ -284,7 +274,6 @@ export class ImportService {
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
@ -320,35 +309,6 @@ export class ImportService {
Account?: { id: string; name: string };
});
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!isNumber(unitPrice)) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) {
order = {
comment,
@ -400,6 +360,7 @@ export class ImportService {
order = await this.orderService.createOrder({
comment,
currency,
date,
fee,
quantity,
@ -439,21 +400,8 @@ export class ImportService {
...order,
error,
value,
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee,
assetProfile.currency,
userCurrency,
date
),
// @ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
SymbolProfile: assetProfile
});
}
@ -520,7 +468,8 @@ export class ImportService {
return (
activity.accountId === accountId &&
activity.comment === comment &&
activity.SymbolProfile.currency === currency &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
@ -538,6 +487,7 @@ export class ImportService {
return {
accountId,
comment,
currency,
date,
error,
fee,
@ -545,7 +495,6 @@ export class ImportService {
type,
unitPrice,
SymbolProfile: {
currency,
dataSource,
symbol,
activitiesCount: undefined,
@ -553,6 +502,7 @@ export class ImportService {
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
currency: undefined,
holdings: undefined,
id: undefined,
isActive: true,

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

@ -1,5 +1,6 @@
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -31,6 +32,7 @@ import { InfoService } from './info.service';
PlatformModule,
PropertyModule,
RedisCacheModule,
SubscriptionModule,
SymbolProfileModule,
TransformDataSourceInResponseModule,
UserModule

29
apps/api/src/app/info/info.service.ts

@ -1,5 +1,6 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -13,7 +14,6 @@ import {
PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import {
@ -21,13 +21,8 @@ import {
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import {
InfoItem,
Statistics,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@ -46,6 +41,7 @@ export class InfoService {
private readonly platformService: PlatformService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
private readonly userService: UserService
) {}
@ -101,7 +97,7 @@ export class InfoService {
isUserSignupEnabled,
platforms,
statistics,
subscriptionOffers
subscriptionOffer
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
@ -110,7 +106,7 @@ export class InfoService {
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.getSubscriptionOffers()
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]);
if (isUserSignupEnabled) {
@ -125,7 +121,7 @@ export class InfoService {
isReadOnlyMode,
platforms,
statistics,
subscriptionOffers,
subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies()
};
@ -299,19 +295,6 @@ export class InfoService {
return statistics;
}
private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOfferKey]: SubscriptionOffer;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
return (
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{}
);
}
private async getUptime(): Promise<number> {
{
try {

2
apps/api/src/app/order/create-order.dto.ts

@ -49,7 +49,7 @@ export class CreateOrderDto {
@IsOptional()
customCurrency?: string;
@IsEnum(DataSource, { each: true })
@IsEnum(DataSource)
@IsOptional()
dataSource?: DataSource;

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

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

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

@ -7,8 +7,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
@ -168,9 +168,9 @@ export class OrderService {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -584,18 +584,25 @@ export class OrderService {
return {
...order,
value,
feeInBaseCurrency:
feeInAssetProfileCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
userCurrency,
order.date
),
SymbolProfile: assetProfile,
unitPriceInAssetProfileCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
order.SymbolProfile.currency,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)

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

@ -1,595 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Inject, Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addDays,
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
subDays
} from 'date-fns';
import { CurrentRateService } from '../../current-rate.service';
import { DateQuery } from '../../interfaces/date-query.interface';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { RoaiPortfolioCalculator } from '../roai/portfolio-calculator';
export class CPRPortfolioCalculator extends RoaiPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
constructor(
{
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
userId,
filters
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
exchangeRateDataService: ExchangeRateDataService;
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
filters: Filter[];
userId: string;
},
@Inject()
private orderService: OrderService
) {
super({
accountBalanceItems,
activities,
configurationService,
currency,
filters,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
userId
});
}
@LogPerformance
public async getPerformanceWithTimeWeightedReturn({
start,
end
}: {
start: Date;
end: Date;
}): Promise<{ chart: HistoricalDataItem[] }> {
const item = await super.getPerformance({
end,
start
});
const itemResult = item.chart;
const dates = itemResult.map((item) => parseDate(item.date));
const timeWeighted = await this.getTimeWeightedChartData({
dates
});
item.chart = itemResult.map((itemInt) => {
const timeWeightedItem = timeWeighted.find(
(timeWeightedItem) => timeWeightedItem.date === itemInt.date
);
if (timeWeightedItem) {
itemInt.timeWeightedPerformance =
timeWeightedItem.netPerformanceInPercentage;
itemInt.timeWeightedPerformanceWithCurrencyEffect =
timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect;
}
return itemInt;
});
return item;
}
@LogPerformance
public async getUnfilteredNetWorth(currency: string): Promise<Big> {
const activities = await this.orderService.getOrders({
userId: this.userId,
userCurrency: currency,
types: ['BUY', 'SELL', 'STAKE'],
withExcludedAccounts: true
});
const orders = this.activitiesToPortfolioOrder(activities.activities);
const start = orders.reduce(
(date, order) =>
parseDate(date.date).getTime() < parseDate(order.date).getTime()
? date
: order,
{ date: orders[0].date }
).date;
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getValues({
dataGatheringItems: this.mapToDataGatheringItems(orders),
dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT);
const exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => {
const symbolCurrency = this.getCurrencyFromActivities(orders, holding);
const exchangeRate =
await this.exchangeRateDataService.toCurrencyAtDate(
1,
symbolCurrency,
this.currency,
end
);
return { symbolCurrency, exchangeRate };
})
);
const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>(
(all, currency): { [currency: string]: number } => {
all[currency.symbolCurrency] ??= currency.exchangeRate;
return all;
},
{}
);
const totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
}
@LogPerformance
protected async getTimeWeightedChartData({
dates
}: {
dates?: Date[];
}): Promise<HistoricalDataItem[]> {
dates = dates.sort((a, b) => a.getTime() - b.getTime());
const start = dates[0];
const end = dates[dates.length - 1];
const marketMapTask = this.computeMarketMap({
gte: start,
lt: addDays(end, 1)
});
const timelineHoldings = await this.getHoldings(
this.activities,
start,
end
);
const data: HistoricalDataItem[] = [];
const startString = format(start, DATE_FORMAT);
data.push({
date: startString,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
this.marketMap = await marketMapTask;
let totalInvestment = Object.keys(timelineHoldings[startString]).reduce(
(sum, holding) => {
return sum.plus(
timelineHoldings[startString][holding].mul(
this.marketMap[startString][holding] ?? new Big(0)
)
);
},
new Big(0)
);
let previousNetPerformanceInPercentage = new Big(0);
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (let i = 1; i < dates.length; i++) {
const date = format(dates[i], DATE_FORMAT);
const previousDate = format(dates[i - 1], DATE_FORMAT);
const holdings = timelineHoldings[previousDate];
let newTotalInvestment = new Big(0);
let netPerformanceInPercentage = new Big(0);
let netPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (const holding of Object.keys(holdings)) {
({
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
} = await this.handleSingleHolding(
previousDate,
holding,
date,
totalInvestment,
timelineHoldings,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
));
}
totalInvestment = newTotalInvestment;
previousNetPerformanceInPercentage = previousNetPerformanceInPercentage
.plus(1)
.mul(netPerformanceInPercentage.plus(1))
.minus(1);
previousNetPerformanceInPercentageWithCurrencyEffect =
previousNetPerformanceInPercentageWithCurrencyEffect
.plus(1)
.mul(netPerformanceInPercentageWithCurrencyEffect.plus(1))
.minus(1);
data.push({
date,
netPerformanceInPercentage: previousNetPerformanceInPercentage
.mul(100)
.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
previousNetPerformanceInPercentageWithCurrencyEffect
.mul(100)
.toNumber()
});
}
return data;
}
@LogPerformance
protected async handleSingleHolding(
previousDate: string,
holding: string,
date: string,
totalInvestment: Big,
timelineHoldings: { [date: string]: { [symbol: string]: Big } },
netPerformanceInPercentage: Big,
netPerformanceInPercentageWithCurrencyEffect: Big,
newTotalInvestment: Big
) {
const previousPrice =
Object.keys(this.marketMap).indexOf(previousDate) > 0
? this.marketMap[previousDate][holding]
: undefined;
const priceDictionary = this.marketMap[date];
let currentPrice =
priceDictionary !== undefined ? priceDictionary[holding] : previousPrice;
currentPrice ??= previousPrice;
const previousHolding = timelineHoldings[previousDate][holding];
const priceInBaseCurrency = currentPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
currentPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(date)
)
)
: new Big(0);
if (previousHolding.eq(0)) {
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
if (previousPrice === undefined || currentPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`,
'PortfolioCalculator'
);
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
const previousPriceInBaseCurrency = previousPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
previousPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(previousDate)
)
)
: new Big(0);
const portfolioWeight = totalInvestment.toNumber()
? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment)
: new Big(0);
netPerformanceInPercentage = netPerformanceInPercentage.plus(
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight)
);
netPerformanceInPercentageWithCurrencyEffect =
netPerformanceInPercentageWithCurrencyEffect.plus(
priceInBaseCurrency
.div(previousPriceInBaseCurrency)
.minus(1)
.mul(portfolioWeight)
);
newTotalInvestment = newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
);
return {
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
};
}
@LogPerformance
protected getCurrency(symbol: string) {
return this.getCurrencyFromActivities(this.activities, symbol);
}
@LogPerformance
protected getCurrencyFromActivities(
activities: PortfolioOrder[],
symbol: string
) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
@LogPerformance
protected async getHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) =>
isAfter(parseDate(h), subDays(end, 1))
) &&
Object.keys(this.holdings).some((h) =>
isBefore(parseDate(h), addDays(start, 1))
)
) {
return this.holdings;
}
this.computeHoldings(activities, start, end);
return this.holdings;
}
@LogPerformance
protected async computeHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
const investmentByDate = this.getInvestmentByDate(activities);
this.calculateHoldings(investmentByDate, start, end);
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
end: Date
) {
const transactionDates = Object.keys(investmentByDate).sort();
const dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
const holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
@LogPerformance
protected calculateInitialHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
currentHoldings: { [date: string]: { [symbol: string]: Big } }
) {
const preRangeTrades = Object.keys(investmentByDate)
.filter((date) => resetHours(new Date(date)) <= start)
.map((date) => investmentByDate[date])
.reduce((a, b) => a.concat(b), [])
.reduce((groupBySymbol, trade) => {
if (!groupBySymbol[trade.SymbolProfile.symbol]) {
groupBySymbol[trade.SymbolProfile.symbol] = [];
}
groupBySymbol[trade.SymbolProfile.symbol].push(trade);
return groupBySymbol;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
const startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
@LogPerformance
protected getInvestmentByDate(activities: PortfolioOrder[]): {
[date: string]: PortfolioOrder[];
} {
return activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
@LogPerformance
protected mapToDataGatheringItems(
orders: PortfolioOrder[]
): IDataGatheringItem[] {
return orders
.map((activity) => {
return {
symbol: activity.SymbolProfile.symbol,
dataSource: activity.SymbolProfile.dataSource
};
})
.filter(
(gathering, i, arr) =>
arr.findIndex((t) => t.symbol === gathering.symbol) === i
);
}
@LogPerformance
protected async computeMarketMap(dateQuery: DateQuery): Promise<{
[date: string]: { [symbol: string]: Big };
}> {
const dataGatheringItems: IDataGatheringItem[] =
this.mapToDataGatheringItems(this.activities);
const { values: marketSymbols } = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
return marketSymbolMap;
}
@LogPerformance
protected activitiesToPortfolioOrder(
activities: Activity[]
): PortfolioOrder[] {
return activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
}

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

@ -4,12 +4,17 @@ import {
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.MWR;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };

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

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

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

@ -6,21 +6,17 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
ROAI = 'ROAI', // Return on Average Investment
TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
}
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
@ -28,7 +24,8 @@ export class PortfolioCalculatorFactory {
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService
private readonly redisCacheService: RedisCacheService,
private readonly orderService: OrderService
) {}
@LogPerformance
@ -59,8 +56,10 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.ROAI:
return new RoaiPortfolioCalculator({
accountBalanceItems,
@ -72,23 +71,27 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.TWR:
return new TwrPortfolioCalculator({
case PerformanceCalculationType.ROI:
return new RoiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
orderService: this.orderService
});
case PerformanceCalculationType.CPR:
return new RoaiPortfolioCalculator({
case PerformanceCalculationType.TWR:
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
@ -98,8 +101,10 @@ export class PortfolioCalculatorFactory {
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
orderService: this.orderService,
filters
});
default:
throw new Error('Invalid calculation type');
}

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

@ -35,11 +35,13 @@ import {
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
addDays,
differenceInDays,
eachDayOfInterval,
endOfDay,
@ -51,6 +53,8 @@ import {
} from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
import { OrderService } from '../../order/order.service';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
@ -63,6 +67,7 @@ export abstract class PortfolioCalculator {
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
protected exchangeRateDataService: ExchangeRateDataService;
protected orderService: OrderService;
private filters: Filter[];
private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService;
@ -72,6 +77,8 @@ export abstract class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
protected userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
public constructor({
accountBalanceItems,
@ -83,7 +90,8 @@ export abstract class PortfolioCalculator {
filters,
portfolioSnapshotService,
redisCacheService,
userId
userId,
orderService
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
@ -95,6 +103,7 @@ export abstract class PortfolioCalculator {
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
userId: string;
orderService: OrderService;
}) {
this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
@ -102,6 +111,7 @@ export abstract class PortfolioCalculator {
this.currentRateService = currentRateService;
this.exchangeRateDataService = exchangeRateDataService;
this.filters = filters;
this.orderService = orderService;
let dateOfFirstActivity = new Date();
@ -113,12 +123,12 @@ export abstract class PortfolioCalculator {
.map(
({
date,
fee,
feeInAssetProfileCurrency,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
unitPriceInAssetProfileCurrency
}) => {
if (isBefore(date, dateOfFirstActivity)) {
dateOfFirstActivity = date;
@ -135,9 +145,9 @@ export abstract class PortfolioCalculator {
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
fee: new Big(feeInAssetProfileCurrency),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
unitPrice: new Big(unitPriceInAssetProfileCurrency)
};
}
)
@ -612,6 +622,78 @@ export abstract class PortfolioCalculator {
};
}
@LogPerformance
public async getUnfilteredNetWorth(currency: string): Promise<Big> {
const activities = await this.orderService.getOrders({
userId: this.userId,
userCurrency: currency,
types: ['BUY', 'SELL', 'STAKE'],
withExcludedAccounts: true
});
const orders = this.activitiesToPortfolioOrder(activities.activities);
const start = orders.reduce(
(date, order) =>
parseDate(date.date).getTime() < parseDate(order.date).getTime()
? date
: order,
{ date: orders[0].date }
).date;
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getValues({
dataGatheringItems: this.mapToDataGatheringItems(orders),
dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT);
const exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => {
const symbolCurrency = this.getCurrencyFromActivities(orders, holding);
const exchangeRate =
await this.exchangeRateDataService.toCurrencyAtDate(
1,
symbolCurrency,
this.currency,
end
);
return { symbolCurrency, exchangeRate };
})
);
const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>(
(all, currency): { [currency: string]: number } => {
all[currency.symbolCurrency] ??= currency.exchangeRate;
return all;
},
{}
);
const totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
}
@LogPerformance
public getDataProviderInfos() {
return this.dataProviderInfos;
@ -802,6 +884,25 @@ export abstract class PortfolioCalculator {
return this.snapshot;
}
@LogPerformance
protected getCurrency(symbol: string) {
return this.getCurrencyFromActivities(this.activities, symbol);
}
@LogPerformance
protected getCurrencyFromActivities(
activities: PortfolioOrder[],
symbol: string
) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
@LogPerformance
protected computeTransactionPoints() {
this.transactionPoints = [];
@ -985,6 +1086,7 @@ export abstract class PortfolioCalculator {
// Compute in the background
this.portfolioSnapshotService.addJobToQueue({
data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
@ -1001,6 +1103,7 @@ export abstract class PortfolioCalculator {
// Wait for computation
await this.portfolioSnapshotService.addJobToQueue({
data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
@ -1023,6 +1126,173 @@ export abstract class PortfolioCalculator {
}
}
@LogPerformance
protected activitiesToPortfolioOrder(
activities: Activity[]
): PortfolioOrder[] {
return activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
@LogPerformance
protected async getHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) =>
isAfter(parseDate(h), subDays(end, 1))
) &&
Object.keys(this.holdings).some((h) =>
isBefore(parseDate(h), addDays(start, 1))
)
) {
return this.holdings;
}
this.computeHoldings(activities, start, end);
return this.holdings;
}
@LogPerformance
protected async computeHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
const investmentByDate = this.getInvestmentByDate(activities);
this.calculateHoldings(investmentByDate, start, end);
}
@LogPerformance
protected calculateInitialHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
currentHoldings: { [date: string]: { [symbol: string]: Big } }
) {
const preRangeTrades = Object.keys(investmentByDate)
.filter((date) => resetHours(new Date(date)) <= start)
.map((date) => investmentByDate[date])
.reduce((a, b) => a.concat(b), [])
.reduce((groupBySymbol, trade) => {
if (!groupBySymbol[trade.SymbolProfile.symbol]) {
groupBySymbol[trade.SymbolProfile.symbol] = [];
}
groupBySymbol[trade.SymbolProfile.symbol].push(trade);
return groupBySymbol;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
const startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
@LogPerformance
protected getInvestmentByDate(activities: PortfolioOrder[]): {
[date: string]: PortfolioOrder[];
} {
return activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
@LogPerformance
protected mapToDataGatheringItems(
orders: PortfolioOrder[]
): IDataGatheringItem[] {
return orders
.map((activity) => {
return {
symbol: activity.SymbolProfile.symbol,
dataSource: activity.SymbolProfile.dataSource
};
})
.filter(
(gathering, i, arr) =>
arr.findIndex((t) => t.symbol === gathering.symbol) === i
);
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
end: Date
) {
const transactionDates = Object.keys(investmentByDate).sort();
const dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
const holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
private getChartDateMap({
endDate,
startDate,
@ -1110,4 +1380,6 @@ export abstract class PortfolioCalculator {
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
}

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -79,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -91,7 +90,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,12 +100,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
feeInAssetProfileCurrency: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -116,12 +115,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -131,7 +130,7 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -79,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -91,7 +90,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,12 +100,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
feeInAssetProfileCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -116,7 +115,7 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -79,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -91,7 +90,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,7 +100,7 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -92,7 +90,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -105,7 +104,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2015-01-01'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -115,12 +114,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD'
},
type: 'BUY',
unitPrice: 320.43
unitPriceInAssetProfileCurrency: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -130,7 +129,7 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD'
},
type: 'SELL',
unitPrice: 14156.4
unitPriceInAssetProfileCurrency: 14156.4
}
];

13
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -79,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -91,7 +90,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-09-01'),
fee: 49,
feeInAssetProfileCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,7 +100,7 @@ describe('PortfolioCalculator', () => {
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
type: 'FEE',
unitPrice: 0
unitPriceInAssetProfileCurrency: 0
}
];

13
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -92,7 +90,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -104,7 +103,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2023-01-03'),
fee: 1,
feeInAssetProfileCurrency: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -114,7 +113,7 @@ describe('PortfolioCalculator', () => {
symbol: 'GOOGL'
},
type: 'BUY',
unitPrice: 89.12
unitPriceInAssetProfileCurrency: 89.12
}
];

13
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -79,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -91,7 +90,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2022-01-01'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,7 +100,7 @@ describe('PortfolioCalculator', () => {
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
type: 'ITEM',
unitPrice: 500000
unitPriceInAssetProfileCurrency: 500000
}
];

17
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -1,13 +1,11 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +15,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -57,6 +56,7 @@ describe('PortfolioCalculator', () => {
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
let orderServiceMock: OrderService;
beforeEach(() => {
configurationService = new ConfigurationService();
@ -74,12 +74,15 @@ describe('PortfolioCalculator', () => {
redisCacheService = new RedisCacheService(null, null);
orderServiceMock = new OrderService(null, null, null, null, null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
orderServiceMock
);
});
@ -91,7 +94,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,7 +104,7 @@ describe('PortfolioCalculator', () => {
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
type: 'LIABILITY',
unitPrice: 3000
unitPriceInAssetProfileCurrency: 3000
}
];

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -92,7 +90,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -104,7 +103,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
feeInAssetProfileCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -114,12 +113,12 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -129,7 +128,7 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
unitPriceInAssetProfileCurrency: 0.62
}
];

9
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -1,8 +1,5 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -12,6 +9,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
@ -74,7 +72,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

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

@ -6,10 +6,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -19,6 +16,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
@ -93,7 +91,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -105,13 +104,15 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({

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

@ -6,10 +6,7 @@ import {
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -19,6 +16,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
@ -93,7 +91,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -105,13 +104,15 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({

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

@ -9,6 +9,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -112,6 +113,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
};
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROAI;
}
protected getSymbolMetrics({
chartDateMap,
dataSource,

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

@ -0,0 +1,29 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

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

@ -4,12 +4,17 @@ import {
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class TwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.TWR;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };

32
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -6,6 +6,7 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
@ -25,33 +26,40 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
},
getRange: ({
assetProfileIdentifiers,
dateRangeEnd,
dateRangeStart
dateQuery
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
dateQuery: DateQuery;
skip?: number;
take?: number;
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
createdAt: dateQuery.gte,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart,
date: dateQuery.gte,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: assetProfileIdentifiers[0].symbol
},
{
createdAt: dateRangeEnd,
createdAt: dateQuery.lt,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd,
date: dateQuery.lt,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: assetProfileIdentifiers[0].symbol
}
]);
},
getRangeCount: ({}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
}) => {
return Promise.resolve<number>(2);
}
};
})
@ -128,9 +136,15 @@ describe('CurrentRateService', () => {
values: [
{
dataSource: 'YAHOO',
date: undefined,
date: new Date('2020-01-01T00:00:00.000Z'),
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
dataSource: 'YAHOO',
date: new Date('2020-01-02T00:00:00.000Z'),
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]
});

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

@ -21,6 +21,8 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
export class CurrentRateService {
private static readonly MARKET_DATA_PAGE_SIZE = 50000;
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
@ -41,30 +43,29 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
const values: GetValueObject[] = [];
if (includesToday) {
promises.push(
this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
const quotesBySymbol = await this.dataProviderService.getQuotes({
items: dataGatheringItems,
user: this.request?.user
});
for (const { dataSource, symbol } of dataGatheringItems) {
if (dataResultProvider?.[symbol]?.dataProviderInfo) {
dataProviderInfos.push(
dataResultProvider[symbol].dataProviderInfo
);
const quote = quotesBySymbol[symbol];
if (quote?.dataProviderInfo) {
dataProviderInfos.push(quote.dataProviderInfo);
}
if (dataResultProvider?.[symbol]?.marketPrice) {
result.push({
if (quote?.marketPrice) {
values.push({
dataSource,
symbol,
date: today,
marketPrice: dataResultProvider?.[symbol]?.marketPrice
marketPrice: quote.marketPrice
});
} else {
quoteErrors.push({
@ -73,10 +74,6 @@ export class CurrentRateService {
});
}
}
return result;
})
);
}
const assetProfileIdentifiers: AssetProfileIdentifier[] =
@ -84,34 +81,42 @@ export class CurrentRateService {
return { dataSource, symbol };
});
promises.push(
this.marketDataService
.getRange({
const marketDataCount = await this.marketDataService.getRangeCount({
assetProfileIdentifiers,
dateQuery
})
.then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => {
return {
});
for (
let i = 0;
i < marketDataCount;
i += CurrentRateService.MARKET_DATA_PAGE_SIZE
) {
// Use page size to limit the number of records fetched at once
const data = await this.marketDataService.getRange({
assetProfileIdentifiers,
dateQuery,
skip: i,
take: CurrentRateService.MARKET_DATA_PAGE_SIZE
});
values.push(
...data.map(({ dataSource, date, marketPrice, symbol }) => ({
dataSource,
date,
marketPrice,
symbol
};
});
})
}))
);
const values = await Promise.all(promises).then((array) => {
return array.flat();
});
}
const response: GetValuesObject = {
dataProviderInfos,
errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
values: uniqBy(values, ({ date, symbol }) => {
return `${date}-${symbol}`;
})
};
if (!isEmpty(quoteErrors)) {

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

@ -506,8 +506,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -522,8 +521,7 @@ export class PortfolioController {
filters,
impersonationId,
withExcludedAccounts,
userId: this.request.user.id,
calculateTimeWeightedPerformance
userId: this.request.user.id
});
if (

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

@ -51,13 +51,14 @@ import {
UserSettings
} from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import type {
import {
AccountWithValue,
DateRange,
GroupBy,
RequestWithUser,
UserWithSettings
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -85,12 +86,8 @@ import {
} from 'date-fns';
import { isEmpty, uniqBy } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from './calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service';
@ -251,10 +248,14 @@ export class PortfolioService {
activities: Activity[];
groupBy?: GroupBy;
}): Promise<InvestmentItem[]> {
let dividends = activities.map(({ date, valueInBaseCurrency }) => {
let dividends = activities.map(({ currency, date, value }) => {
return {
date: format(date, DATE_FORMAT),
investment: valueInBaseCurrency
investment: this.exchangeRateDataService.toCurrency(
value,
currency,
this.getUserCurrency()
)
};
});
@ -280,14 +281,16 @@ export class PortfolioService {
savingsRate: number;
}): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
userId,
userCurrency: this.getUserCurrency()
userCurrency,
userId
});
if (activities.length === 0) {
@ -301,8 +304,8 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
});
const { historicalData } = await portfolioCalculator.getSnapshot();
@ -379,7 +382,7 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.ROAI,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
});
@ -693,7 +696,7 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
calculationType: PerformanceCalculationType.ROAI,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
});
@ -957,12 +960,13 @@ export class PortfolioService {
})?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
userId,
userCurrency: this.getUserCurrency()
userCurrency,
userId
});
if (activities.length === 0) {
@ -976,8 +980,8 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
@ -1097,15 +1101,13 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
userId,
calculateTimeWeightedPerformance = false
userId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean;
}): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
@ -1146,18 +1148,14 @@ export class PortfolioService {
activities,
filters,
userId,
calculationType: PerformanceCalculationType.ROAI,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
});
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const range = { end: endDate, start: startDate };
const { chart } = await (calculateTimeWeightedPerformance
? (
portfolioCalculator as CPRPortfolioCalculator
).getPerformanceWithTimeWeightedReturn(range)
: portfolioCalculator.getPerformance(range));
const { chart } = await portfolioCalculator.getPerformance(range);
const {
netPerformance,
@ -1927,17 +1925,9 @@ export class PortfolioService {
.plus(totalOfExcludedActivities)
.toNumber();
const netWorth =
portfolioCalculator instanceof CPRPortfolioCalculator
? await (portfolioCalculator as CPRPortfolioCalculator)
const netWorth = await portfolioCalculator
.getUnfilteredNetWorth(this.getUserCurrency())
.then((value) => value.toNumber())
: new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
.then((value) => value.toNumber());
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -2057,6 +2047,12 @@ export class PortfolioService {
return impersonationUserId || aUserId;
}
private getUserPerformanceCalculationType(
aUser: UserWithSettings
): PerformanceCalculationType {
return aUser?.Settings?.settings.performanceCalculationType;
}
@LogPerformance
private async getValueOfAccountsAndPlatforms({
activities,

45
apps/api/src/app/subscription/subscription.service.ts

@ -122,7 +122,7 @@ export class SubscriptionService {
data: {
expiresAt,
price,
User: {
user: {
connect: {
id: userId
}
@ -158,38 +158,67 @@ export class SubscriptionService {
}
}
public getSubscription({
public async getSubscription({
createdAt,
subscriptions
}: {
createdAt: UserWithSettings['createdAt'];
subscriptions: Subscription[];
}): UserWithSettings['subscription'] {
}): Promise<UserWithSettings['subscription']> {
if (subscriptions.length > 0) {
const { expiresAt, price } = subscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
let offer: SubscriptionOfferKey = price ? 'renewal' : 'default';
let offerKey: SubscriptionOfferKey = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird-2023';
offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offer = 'renewal-early-bird-2024';
offerKey = 'renewal-early-bird-2024';
}
const offer = await this.getSubscriptionOffer({
key: offerKey
});
return {
expiresAt,
offer,
expiresAt: isBefore(new Date(), expiresAt) ? expiresAt : undefined,
type: isBefore(new Date(), expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
const offer = await this.getSubscriptionOffer({
key: 'default'
});
return {
offer: 'default',
offer,
type: SubscriptionType.Basic
};
}
}
public async getSubscriptionOffer({
key
}: {
key: SubscriptionOfferKey;
}): Promise<SubscriptionOffer> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const offers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{};
return {
...offers[key],
isRenewal: key.startsWith('renewal')
};
}
}

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

@ -41,6 +41,7 @@ import {
permissions
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@ -189,7 +190,7 @@ export class UserService {
provider,
role,
Settings,
Subscription,
subscriptions,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
@ -200,7 +201,7 @@ export class UserService {
},
Analytics: true,
Settings: true,
Subscription: true
subscriptions: true
},
where: userWhereUniqueInput
});
@ -246,6 +247,12 @@ export class UserService {
? 'max'
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for performance calculation type
if (!(user.Settings.settings as UserSettings)?.performanceCalculationType) {
(user.Settings.settings as UserSettings).performanceCalculationType =
PerformanceCalculationType.ROAI;
}
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
@ -339,9 +346,9 @@ export class UserService {
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription({
createdAt: user.createdAt,
subscriptions: Subscription
user.subscription = await this.subscriptionService.getSubscription({
subscriptions,
createdAt: user.createdAt
});
if (user.subscription?.type === 'Basic') {
@ -392,6 +399,12 @@ export class UserService {
currentPermissions,
permissions.deleteOwnUser
);
// Reset offer
user.subscription.offer.coupon = undefined;
user.subscription.offer.couponId = undefined;
user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined;
}
}

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

File diff suppressed because it is too large

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

@ -1,8 +1,8 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
@ -66,9 +66,9 @@ export class CronService {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
}

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

@ -11,6 +11,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
@ -72,7 +73,9 @@ export class AlphaVantageService implements DataProviderInterface {
const historicalData: {
[symbol: string]: IAlphaVantageHistoricalResponse[];
} = await this.alphaVantage.crypto.daily(
symbol.substring(0, symbol.length - 3).toLowerCase(),
symbol
.substring(0, symbol.length - DEFAULT_CURRENCY.length)
.toLowerCase(),
'usd'
);

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

@ -1,4 +1,5 @@
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import {
DEFAULT_CURRENCY,
@ -236,8 +237,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.url = url;
}
} catch (error) {
if (error.message === `Quote not found for symbol: ${aSymbol}`) {
throw new AssetProfileDelistedError(
`No data found, ${aSymbol} (${this.getName()}) may be delisted`
);
} else {
Logger.error(error, 'YahooFinanceService');
}
}
return response;
}

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

@ -19,6 +19,7 @@ import {
DATE_FORMAT,
getCurrencyFromSymbol,
getStartOfUtcDate,
isCurrency,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import {
@ -115,7 +116,13 @@ export class DataProviderService {
}
}
try {
await Promise.all(promises);
} catch (error) {
Logger.error(error, 'DataProviderService');
throw error;
}
return response;
}
@ -464,17 +471,21 @@ export class DataProviderService {
)) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (
const symbols = assetProfileIdentifiers
.filter(({ symbol }) => {
if (isCurrency(getCurrencyFromSymbol(symbol))) {
// Keep non-derived currencies
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
} else if (
dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription.type === 'Basic'
) {
continue;
// Skip symbols of Premium data providers for users without subscription
return false;
}
const symbols = assetProfileIdentifiers
.filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
return true;
})
.map(({ symbol }) => {
return symbol;

7
apps/api/src/services/data-provider/errors/asset-profile-delisted.error.ts

@ -0,0 +1,7 @@
export class AssetProfileDelistedError extends Error {
public constructor(message: string) {
super(message);
this.name = 'AssetProfileDelistedError';
}
}

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

@ -12,8 +12,11 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { REPLACE_NAME_PARTS } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency, parseDate } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
LookupItem,
@ -67,7 +70,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
};
try {
if (this.cryptocurrencyService.isCryptocurrency(symbol)) {
if (
isCurrency(symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length))
) {
response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CASH;
response.currency = symbol.substring(
symbol.length - DEFAULT_CURRENCY.length
);
} else if (this.cryptocurrencyService.isCryptocurrency(symbol)) {
const [quote] = await fetch(
`${this.URL}/quote/${symbol}?apikey=${this.apiKey}`,
{
@ -77,7 +88,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CRYPTOCURRENCY;
response.currency = symbol.substring(symbol.length - 3);
response.currency = symbol.substring(
symbol.length - DEFAULT_CURRENCY.length
);
response.name = quote.name;
} else {
const [assetProfile] = await fetch(
@ -325,6 +338,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 20;
}
public getName(): DataSource {
return DataSource.FINANCIAL_MODELING_PREP;
}
@ -340,18 +357,28 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
try {
const currencyBySymbolMap: {
[symbol: string]: Pick<SymbolProfile, 'currency'>;
} = {};
const quotes = await fetch(
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
`${this.getUrl({ version: 'stable' })}/batch-quote-short?symbols=${symbols.join(',')}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
for (const { price, symbol } of quotes) {
const { currency } = await this.getAssetProfile({ symbol });
await Promise.all(
quotes.map(({ symbol }) => {
return this.getAssetProfile({ symbol }).then(({ currency }) => {
currencyBySymbolMap[symbol] = { currency };
});
})
);
for (const { price, symbol } of quotes) {
response[symbol] = {
currency,
currency: currencyBySymbolMap[symbol]?.currency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price,
@ -378,12 +405,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
const assetProfileBySymbolMap: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
let items: LookupItem[] = [];
try {
if (isISIN(query)) {
if (isISIN(query?.toUpperCase())) {
const result = await fetch(
`${this.getUrl({ version: 4 })}/search/isin?isin=${query}&apikey=${this.apiKey}`,
`${this.getUrl({ version: 'stable' })}/search-isin?isin=${query.toUpperCase()}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
@ -391,15 +421,23 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
).then((res) => res.json());
items = result.map(({ companyName, currency, symbol }) => {
await Promise.all(
result.map(({ symbol }) => {
return this.getAssetProfile({ symbol }).then((assetProfile) => {
assetProfileBySymbolMap[symbol] = assetProfile;
});
})
);
items = result.map(({ assetClass, assetSubClass, name, symbol }) => {
return {
currency,
assetClass,
assetSubClass,
symbol,
assetClass: undefined, // TODO
assetSubClass: undefined, // TODO
currency: assetProfileBySymbolMap[symbol]?.currency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.formatName({ name: companyName })
name: this.formatName({ name })
};
});
} else {
@ -451,8 +489,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return name;
}
private getUrl({ version }: { version: number }) {
return `https://financialmodelingprep.com/api/v${version}`;
private getUrl({ version }: { version: number | 'stable' }) {
const baseUrl = 'https://financialmodelingprep.com';
if (version === 'stable') {
return `${baseUrl}/stable`;
}
return `${baseUrl}/api/v${version}`;
}
private parseAssetClass(profile: any): {
@ -462,6 +506,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
if (profile) {
if (profile.isEtf) {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
@ -472,6 +517,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
}
}
return { assetClass, assetSubClass };
}

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

@ -1,5 +1,6 @@
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
import {
DataProviderInterface,
GetAssetProfileParams,
@ -143,6 +144,11 @@ export class YahooFinanceService implements DataProviderInterface {
return response;
} catch (error) {
if (error.message === 'No data found, symbol may be delisted') {
throw new AssetProfileDelistedError(
`No data found, ${symbol} (${this.getName()}) may be delisted`
);
} else {
throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
from,
@ -151,6 +157,7 @@ export class YahooFinanceService implements DataProviderInterface {
);
}
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;

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

@ -63,12 +63,18 @@ export class MarketDataService {
public async getRange({
assetProfileIdentifiers,
dateQuery
dateQuery,
skip,
take
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery;
skip?: number;
take?: number;
}): Promise<MarketData[]> {
return this.prismaService.marketData.findMany({
skip,
take,
orderBy: [
{
date: 'asc'
@ -78,17 +84,33 @@ export class MarketDataService {
}
],
where: {
dataSource: {
in: assetProfileIdentifiers.map(({ dataSource }) => {
return dataSource;
})
},
date: dateQuery,
symbol: {
in: assetProfileIdentifiers.map(({ symbol }) => {
return symbol;
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
}
});
}
public async getRangeCount({
assetProfileIdentifiers,
dateQuery
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery;
}): Promise<number> {
return this.prismaService.marketData.count({
where: {
date: dateQuery,
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
}
});
}

90
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -1,14 +1,16 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config';
@ -40,7 +42,8 @@ export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService
private readonly marketDataService: MarketDataService,
private readonly symbolProfileService: SymbolProfileService
) {}
@Process({
@ -49,28 +52,49 @@ export class DataGatheringProcessor {
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY.toString(),
10
),
name: GATHER_ASSET_PROFILE_PROCESS
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME
})
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
const { dataSource, symbol } = job.data;
try {
Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
`Asset profile data gathering has been started for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log(
`Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
`Asset profile data gathering has been completed for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
} catch (error) {
if (error instanceof AssetProfileDelistedError) {
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
{
isActive: false
}
);
Logger.log(
`Asset profile data gathering has been discarded for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
return job.discard();
}
Logger.error(
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
throw new Error(error);
throw error;
}
}
@ -83,8 +107,9 @@ export class DataGatheringProcessor {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
try {
let currentDate = parseISO(date as unknown as string);
Logger.log(
@ -149,12 +174,31 @@ export class DataGatheringProcessor {
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
} catch (error) {
if (error instanceof AssetProfileDelistedError) {
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
{
isActive: false
}
);
Logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
return job.discard();
}
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw new Error(error);
throw error;
}
}
@Process({
@ -166,9 +210,8 @@ export class DataGatheringProcessor {
name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherMissingHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
try {
Logger.log(
`Historical market data gathering for missing values has been started for ${symbol} (${dataSource}) at ${format(
date,
@ -237,12 +280,31 @@ export class DataGatheringProcessor {
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
} catch (error) {
if (error instanceof AssetProfileDelistedError) {
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
{
isActive: false
}
);
Logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
return job.discard();
}
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw new Error(error);
throw error;
}
}

2
apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts

@ -1,6 +1,8 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export interface IPortfolioSnapshotQueueJob {
calculationType: PerformanceCalculationType;
filters: Filter[];
userCurrency: string;
userId: string;

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

@ -1,9 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -68,7 +65,7 @@ export class PortfolioSnapshotProcessor {
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.ROAI,
calculationType: job.data.calculationType,
currency: job.data.userCurrency,
filters: job.data.filters,
userId: job.data.userId

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

@ -44,14 +44,14 @@ export class SymbolProfileService {
? {
some: {
User: {
Subscription: { some: { expiresAt: { gt: new Date() } } }
subscriptions: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
subscriptions: { none: { expiresAt: { gt: new Date() } } }
}
}
}

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

@ -25,19 +25,24 @@ export class IsExtendedCurrencyConstraint
implements ValidatorConstraintInterface
{
public defaultMessage() {
return '$value must be a valid ISO4217 currency code';
return '$property must be a valid ISO4217 currency code';
}
public validate(currency: any) {
// Return true if currency is a standard ISO 4217 code or a derived currency
return (
isISO4217CurrencyCode(currency) ||
this.isUpperCase(currency) &&
(isISO4217CurrencyCode(currency) ||
[
...DERIVED_CURRENCIES.map((derivedCurrency) => {
return derivedCurrency.currency;
}),
'USX'
].includes(currency)
].includes(currency))
);
}
private isUpperCase(aString: string) {
return aString === aString?.toUpperCase();
}
}

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

@ -143,8 +143,8 @@ export class AppComponent implements OnDestroy, OnInit {
);
this.hasPromotion =
!!this.info?.subscriptionOffers?.default?.coupon ||
!!this.info?.subscriptionOffers?.default?.durationExtension;
!!this.info?.subscriptionOffer?.coupon ||
!!this.info?.subscriptionOffer?.durationExtension;
this.impersonationStorageService
.onChangeHasImpersonation()
@ -242,12 +242,8 @@ export class AppComponent implements OnDestroy, OnInit {
this.canCreateAccount || !!this.user?.systemMessage;
this.hasPromotion =
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.coupon ||
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.durationExtension;
!!this.user?.subscription?.offer?.coupon ||
!!this.user?.subscription?.offer?.durationExtension;
this.initializeTheme(this.user?.settings.colorScheme);

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

@ -137,6 +137,7 @@ export class AdminMarketDataComponent
);
this.displayedColumns = [
'status',
'select',
'nameWithSymbol',
'dataSource',

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

@ -20,6 +20,34 @@
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="status">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
></th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
@if (!element.isActive) {
<div
class="d-flex justify-content-center"
i18n-title
title="Data Gathering is off"
>
<ion-icon name="ban-outline" />
</div>
}
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="select">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell>
@ -187,7 +215,11 @@
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
<mat-menu
#assetProfilesActionsMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onGather7Days()">
<ng-container i18n
>Gather Recent Historical Market Data</ng-container

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

@ -35,8 +35,8 @@ import {
ValidationErrors,
Validators
} from '@angular/forms';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import {

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

@ -19,17 +19,21 @@
<button
mat-menu-item
type="button"
[disabled]="assetProfileForm.dirty"
[disabled]="
assetProfileForm.dirty || !assetProfileForm.controls.isActive.value
"
(click)="
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
"
>
<ng-container i18n>Gather Historical Market Dataa</ng-container>
<ng-container i18n>Gather Historical Market Data</ng-container>
</button>
<button
mat-menu-item
type="button"
[disabled]="assetProfileForm.dirty"
[disabled]="
assetProfileForm.dirty || !assetProfileForm.controls.isActive.value
"
(click)="
onGatherSymbolMissingOnly({
dataSource: data.dataSource,
@ -86,6 +90,15 @@
[showYAxis]="true"
[symbol]="data.symbol"
/>
<div class="mb-3">
<mat-accordion class="my-3">
<mat-expansion-panel class="shadow-none">
<mat-expansion-panel-header class="p-0 pr-3">
<mat-panel-title class="font-weight-bold" i18n
>Historical Market Data</mat-panel-title
>
</mat-expansion-panel-header>
<gf-historical-market-data-editor
class="mb-3"
[currency]="assetProfile?.currency"
@ -97,6 +110,9 @@
[user]="user"
(marketDataChanged)="onMarketDataChanged($event)"
/>
</mat-expansion-panel>
</mat-accordion>
</div>
<div class="row">
@if (isEditAssetProfileIdentifierMode) {
@ -400,8 +416,10 @@
(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 class="p-0 pr-3">
<mat-panel-title class="font-weight-bold" i18n
>Scraper Configuration</mat-panel-title
>
</mat-expansion-panel-header>
<div formGroupName="scraperConfiguration">
<div class="mt-3">

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

@ -49,7 +49,7 @@
}"
>{{ (element.id | slice: 0 : 5) + '...' }}</span
>
@if (element?.subscription?.type === 'Premium') {
@if (element.subscription?.expiresAt) {
<gf-premium-indicator
class="ml-1"
[enableLink]="false"

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

@ -181,14 +181,10 @@
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex"
><span>
@if (user.subscription.offer === 'default') {
<ng-container i18n>Upgrade Plan</ng-container>
} @else if (
user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird-2023' ||
user.subscription.offer === 'renewal-early-bird-2024'
) {
@if (user.subscription.offer.isRenewal) {
<ng-container i18n>Renew Plan</ng-container>
} @else {
<ng-container i18n>Upgrade Plan</ng-container>
}
</span>
<gf-premium-indicator

9
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,5 +1,6 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -13,7 +14,13 @@ import { MatSliderModule } from '@angular/material/slider';
import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@Component({
imports: [FormsModule, MatButtonModule, MatDialogModule, MatSliderModule],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatSliderModule
],
selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html'

19
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -51,8 +51,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
private stripeService: StripeService,
private userService: UserService
) {
const { baseCurrency, globalPermissions, subscriptionOffers } =
this.dataService.fetchInfo();
const { baseCurrency, globalPermissions } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
@ -81,18 +80,12 @@ export class UserAccountMembershipComponent implements OnDestroy {
permissions.updateUserSettings
);
this.coupon =
subscriptionOffers?.[this.user.subscription.offer]?.coupon;
this.couponId =
subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.coupon = this.user?.subscription?.offer?.coupon;
this.couponId = this.user?.subscription?.offer?.couponId;
this.durationExtension =
subscriptionOffers?.[
this.user.subscription.offer
]?.durationExtension;
this.price =
subscriptionOffers?.[this.user.subscription.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.user?.subscription?.offer?.durationExtension;
this.price = this.user?.subscription?.offer?.price;
this.priceId = this.user?.subscription?.offer?.priceId;
this.changeDetectorRef.markForCheck();
}

15
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -4,10 +4,7 @@
<div class="align-items-center d-flex flex-column">
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[hasPermissionToCreateApiKey]="
hasPermissionToCreateApiKey &&
user?.settings?.isExperimentalFeatures
"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[name]="user?.subscription?.type"
(generateApiKeyClicked)="onGenerateApiKey()"
/>
@ -17,14 +14,10 @@
hasPermissionForSubscription && hasPermissionToUpdateUserSettings
) {
<button color="primary" mat-flat-button (click)="onCheckout()">
@if (user.subscription.offer === 'default') {
<ng-container i18n>Upgrade Plan</ng-container>
} @else if (
user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird-2023' ||
user.subscription.offer === 'renewal-early-bird-2024'
) {
@if (user.subscription.offer.isRenewal) {
<ng-container i18n>Renew Plan</ng-container>
} @else {
<ng-container i18n>Upgrade Plan</ng-container>
}
</button>
@if (price) {

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

@ -2,25 +2,7 @@
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Settings</h1>
<div class="row">
<div class="col">
<div class="align-items-center d-flex py-1">
<div class="pr-1 w-50">
<div i18n>Presenter View</div>
<div class="hint-text text-muted" i18n>
Protection for sensitive information like absolute performances and
quantity values
</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)"
/>
</div>
</div>
<div class="d-flex mt-4 py-1">
<div class="d-flex py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50">
@ -43,6 +25,32 @@
</mat-form-field>
</div>
</div>
@if (user?.settings?.isExperimentalFeatures && !user?.subscription) {
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Performance Calculation</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="performanceCalculationType"
[disabled]="true"
[value]="user.settings.performanceCalculationType"
(selectionChange)="
onChangeUserSetting(
'performanceCalculationType',
$event.value
)
"
>
<mat-option value="ROAI"
>Return on Average Investment (ROAI)</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
}
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Language</div>
@ -172,6 +180,24 @@
</div>
</form>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Presenter View</div>
<div class="hint-text text-muted" i18n>
Protection for sensitive information like absolute performances and
quantity values
</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)"
/>
</div>
</div>
<div class="d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Zen Mode</div>

58
apps/client/src/app/pages/api/api-page.component.ts

@ -27,9 +27,10 @@ import { map, Observable, Subject, takeUntil } from 'rxjs';
export class GfApiPageComponent implements OnInit {
public dividends$: Observable<DividendsResponse['dividends']>;
public historicalData$: Observable<HistoricalResponse['historicalData']>;
public isinLookupItems$: Observable<LookupResponse['items']>;
public lookupItems$: Observable<LookupResponse['items']>;
public quotes$: Observable<QuotesResponse['quotes']>;
public status$: Observable<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>;
private apiKey: string;
private unsubscribeSubject = new Subject<void>();
@ -41,9 +42,10 @@ export class GfApiPageComponent implements OnInit {
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' });
this.lookupItems$ = this.fetchLookupItems({ query: 'apple' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO.US'] });
this.status$ = this.fetchStatus();
this.symbols$ = this.fetchSymbols({ query: 'apple' });
}
public ngOnDestroy() {
@ -93,32 +95,7 @@ export class GfApiPageComponent implements OnInit {
);
}
private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(','));
return this.http
.get<QuotesResponse>('/api/v2/data-providers/ghostfolio/quotes', {
params,
headers: this.getHeaders()
})
.pipe(
map(({ quotes }) => {
return quotes;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchStatus() {
return this.http
.get<DataProviderGhostfolioStatusResponse>(
'/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() }
)
.pipe(takeUntil(this.unsubscribeSubject));
}
private fetchSymbols({
private fetchLookupItems({
includeIndices = false,
query
}: {
@ -144,6 +121,31 @@ export class GfApiPageComponent implements OnInit {
);
}
private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(','));
return this.http
.get<QuotesResponse>('/api/v2/data-providers/ghostfolio/quotes', {
params,
headers: this.getHeaders()
})
.pipe(
map(({ quotes }) => {
return quotes;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchStatus() {
return this.http
.get<DataProviderGhostfolioStatusResponse>(
'/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() }
)
.pipe(takeUntil(this.unsubscribeSubject));
}
private getHeaders() {
return new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',

17
apps/client/src/app/pages/api/api-page.html

@ -3,10 +3,21 @@
<h2 class="text-center">Status</h2>
<div>{{ status$ | async | json }}</div>
</div>
<div class="mb-3">
<div>
<h2 class="text-center">Lookup</h2>
@if (symbols$) {
@let symbols = symbols$ | async;
@if (lookupItems$) {
@let symbols = lookupItems$ | async;
<ul>
@for (item of symbols; track item.symbol) {
<li>{{ item.name }} ({{ item.symbol }})</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Lookup (ISIN)</h2>
@if (isinLookupItems$) {
@let symbols = isinLookupItems$ | async;
<ul>
@for (item of symbols; track item.symbol) {
<li>{{ item.name }} ({{ item.symbol }})</li>

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

@ -15,7 +15,7 @@ import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isAfter, isToday } from 'date-fns';
import { EMPTY, Subject, lastValueFrom } from 'rxjs';
import { EMPTY, Subject } from 'rxjs';
import { catchError, delay, takeUntil } from 'rxjs/operators';
import { DataService } from '../../../../services/data.service';
@ -102,6 +102,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
Validators.required
],
currencyOfUnitPrice: [
this.data.activity?.currency ??
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
@ -111,7 +112,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
],
date: [this.data.activity?.date, Validators.required],
fee: [this.data.activity?.fee, Validators.required],
feeInCustomCurrency: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [
@ -133,10 +133,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
],
type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required],
unitPriceInCustomCurrency: [
this.data.activity?.unitPrice,
Validators.required
],
updateAccountBalance: [false]
});
@ -148,57 +144,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
takeUntil(this.unsubscribeSubject)
)
.subscribe(async () => {
let exchangeRateOfUnitPrice = 1;
this.activityForm.get('feeInCustomCurrency').setErrors(null);
this.activityForm.get('unitPriceInCustomCurrency').setErrors(null);
const currency = this.activityForm.get('currency').value;
const currencyOfUnitPrice = this.activityForm.get(
'currencyOfUnitPrice'
).value;
const date = this.activityForm.get('date').value;
if (
currency &&
currencyOfUnitPrice &&
currency !== currencyOfUnitPrice &&
date
) {
try {
const { marketPrice } = await lastValueFrom(
this.dataService
.fetchExchangeRateForDate({
date,
symbol: `${currencyOfUnitPrice}-${currency}`
})
.pipe(takeUntil(this.unsubscribeSubject))
);
exchangeRateOfUnitPrice = marketPrice;
} catch {
this.activityForm.get('unitPriceInCustomCurrency').setErrors({
invalid: true
});
}
}
const feeInCustomCurrency =
this.activityForm.get('feeInCustomCurrency').value *
exchangeRateOfUnitPrice;
const unitPriceInCustomCurrency =
this.activityForm.get('unitPriceInCustomCurrency').value *
exchangeRateOfUnitPrice;
this.activityForm.get('fee').setValue(feeInCustomCurrency, {
emitEvent: false
});
this.activityForm.get('unitPrice').setValue(unitPriceInCustomCurrency, {
emitEvent: false
});
if (
this.activityForm.get('type').value === 'BUY' ||
this.activityForm.get('type').value === 'FEE' ||
@ -269,10 +214,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('type').value
)
) {
this.activityForm
.get('dataSource')
.setValue(this.activityForm.get('searchSymbol').value.dataSource);
this.updateSymbol();
}
@ -301,7 +242,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.get('dataSource')
.removeValidators(Validators.required);
this.activityForm.get('dataSource').updateValueAndValidity();
this.activityForm.get('feeInCustomCurrency').reset();
this.activityForm.get('fee').reset();
this.activityForm.get('name').setValidators(Validators.required);
this.activityForm.get('name').updateValueAndValidity();
this.activityForm.get('quantity').setValue(1);
@ -335,12 +276,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('dataSource').updateValueAndValidity();
if (
(type === 'FEE' &&
this.activityForm.get('feeInCustomCurrency').value === 0) ||
(type === 'FEE' && this.activityForm.get('fee').value === 0) ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm.get('feeInCustomCurrency').reset();
this.activityForm.get('fee').reset();
}
this.activityForm.get('name').setValidators(Validators.required);
@ -358,7 +298,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('searchSymbol').updateValueAndValidity();
if (type === 'FEE') {
this.activityForm.get('unitPriceInCustomCurrency').setValue(0);
this.activityForm.get('unitPrice').setValue(0);
}
if (
@ -414,7 +354,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public applyCurrentMarketPrice() {
this.activityForm.patchValue({
currencyOfUnitPrice: this.activityForm.get('currency').value,
unitPriceInCustomCurrency: this.currentMarketPrice
unitPrice: this.currentMarketPrice
});
}
@ -500,7 +440,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.dataService
.fetchSymbolItem({
dataSource: this.activityForm.get('dataSource').value,
dataSource: this.activityForm.get('searchSymbol').value.dataSource,
symbol: this.activityForm.get('searchSymbol').value.symbol
})
.pipe(
@ -516,9 +456,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ currency, dataSource, marketPrice }) => {
if (this.mode === 'create') {
this.activityForm.get('currency').setValue(currency);
this.activityForm.get('currencyOfUnitPrice').setValue(currency);
this.activityForm.get('dataSource').setValue(dataSource);
}
this.currentMarketPrice = marketPrice;

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

@ -225,11 +225,7 @@
}
}
</mat-label>
<input
formControlName="unitPriceInCustomCurrency"
matInput
type="number"
/>
<input formControlName="unitPrice" matInput type="number" />
<div
class="ml-2"
matTextSuffix
@ -243,19 +239,6 @@
}
</mat-select>
</div>
@if (
activityForm.get('unitPriceInCustomCurrency').hasError('invalid')
) {
<mat-error
><ng-container i18n
>Oops! Could not get the historical exchange rate
from</ng-container
>
{{
activityForm.get('date')?.value | date: defaultDateFormat
}}</mat-error
>
}
</mat-form-field>
@if (
currentMarketPrice &&
@ -274,36 +257,6 @@
}
</div>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label>
@switch (activityForm.get('type')?.value) {
@case ('DIVIDEND') {
<ng-container i18n>Dividend</ng-container>
}
@case ('FEE') {
<ng-container i18n>Value</ng-container>
}
@case ('INTEREST') {
<ng-container i18n>Value</ng-container>
}
@case ('ITEM') {
<ng-container i18n>Value</ng-container>
}
@case ('LIABILITY') {
<ng-container i18n>Value</ng-container>
}
@default {
<ng-container i18n>Unit Price</ng-container>
}
}
</mat-label>
<input formControlName="unitPrice" matInput type="number" />
<span class="ml-2" matTextSuffix>{{
activityForm.get('currency').value
}}</span>
</mat-form-field>
</div>
<div
class="mb-3"
[ngClass]="{
@ -315,7 +268,7 @@
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="feeInCustomCurrency" matInput type="number" />
<input formControlName="fee" matInput type="number" />
<div
class="ml-2"
matTextSuffix
@ -323,26 +276,6 @@
>
{{ activityForm.get('currencyOfUnitPrice').value }}
</div>
@if (activityForm.get('feeInCustomCurrency').hasError('invalid')) {
<mat-error
><ng-container i18n
>Oops! Could not get the historical exchange rate
from</ng-container
>
{{
activityForm.get('date')?.value | date: defaultDateFormat
}}</mat-error
>
}
</mat-form-field>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" />
<span class="ml-2" matTextSuffix>{{
activityForm.get('currency').value
}}</span>
</mat-form-field>
</div>
<div class="mb-3">
@ -403,7 +336,8 @@
[isCurrency]="true"
[locale]="data.user?.settings?.locale"
[unit]="
activityForm.get('currency')?.value ?? data.user?.settings?.baseCurrency
activityForm.get('currencyOfUnitPrice')?.value ??
data.user?.settings?.baseCurrency
"
[value]="total"
/>

5
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -247,9 +247,10 @@ export class ImportActivitiesDialog implements OnDestroy {
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
const fileExtension = file.name.split('.').pop()?.toLowerCase();
try {
if (file.name.endsWith('.json')) {
if (fileExtension === 'json') {
const content = JSON.parse(fileContent);
this.accounts = content.accounts;
@ -294,7 +295,7 @@ export class ImportActivitiesDialog implements OnDestroy {
}
return;
} else if (file.name.endsWith('.csv')) {
} else if (fileExtension === 'csv') {
const content = fileContent.split('\n').slice(1);
try {

29
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -55,13 +55,13 @@ export class PricingPageComponent implements OnDestroy, OnInit {
) {}
public ngOnInit() {
const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo();
const { baseCurrency, subscriptionOffer } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = subscriptionOffers?.default?.coupon;
this.durationExtension = subscriptionOffers?.default?.durationExtension;
this.label = subscriptionOffers?.default?.label;
this.price = subscriptionOffers?.default?.price;
this.coupon = subscriptionOffer?.coupon;
this.durationExtension = subscriptionOffer?.durationExtension;
this.label = subscriptionOffer?.label;
this.price = subscriptionOffer?.price;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
@ -74,20 +74,13 @@ export class PricingPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.coupon =
subscriptionOffers?.[this.user?.subscription?.offer]?.coupon;
this.couponId =
subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.coupon = this.user?.subscription?.offer?.coupon;
this.couponId = this.user?.subscription?.offer?.couponId;
this.durationExtension =
subscriptionOffers?.[
this.user?.subscription?.offer
]?.durationExtension;
this.label =
subscriptionOffers?.[this.user?.subscription?.offer]?.label;
this.price =
subscriptionOffers?.[this.user?.subscription?.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.user?.subscription?.offer?.durationExtension;
this.label = this.user?.subscription?.offer?.label;
this.price = this.user?.subscription?.offer?.price;
this.priceId = this.user?.subscription?.offer?.priceId;
this.changeDetectorRef.markForCheck();
}

10
apps/client/src/app/pages/pricing/pricing-page.html

@ -306,14 +306,10 @@
mat-flat-button
(click)="onCheckout()"
>
@if (user.subscription.offer === 'default') {
<ng-container i18n>Upgrade Plan</ng-container>
} @else if (
user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird-2023' ||
user.subscription.offer === 'renewal-early-bird-2024'
) {
@if (user.subscription.offer.isRenewal) {
<ng-container i18n>Renew Plan</ng-container>
} @else {
<ng-container i18n>Upgrade Plan</ng-container>
}
</button>
<p class="m-0 text-muted">

4
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -33,9 +33,9 @@ export class GfProductPageComponent implements OnInit {
) {}
public ngOnInit() {
const { subscriptionOffers } = this.dataService.fetchInfo();
const { subscriptionOffer } = this.dataService.fetchInfo();
this.price = subscriptionOffers?.default?.price;
this.price = subscriptionOffer?.price;
this.product1 = {
founded: 2021,

3
apps/client/src/app/services/import-activities.service.ts

@ -126,6 +126,7 @@ export class ImportActivitiesService {
private convertToCreateOrderDto({
accountId,
comment,
currency,
date,
fee,
quantity,
@ -142,7 +143,7 @@ export class ImportActivitiesService {
type,
unitPrice,
updateAccountBalance,
currency: SymbolProfile.currency,
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toString(),
symbol: SymbolProfile.symbol

360
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

358
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

354
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

360
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

4
libs/common/src/lib/config.ts

@ -78,8 +78,8 @@ export const DERIVED_CURRENCIES = [
export const EMERGENCY_FUND_TAG_ID = '4452656d-9fa4-4bd0-ba38-70492e31d180';
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = {
export const GATHER_ASSET_PROFILE_PROCESS_JOB_NAME = 'GATHER_ASSET_PROFILE';
export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = {
attempts: 12,
backoff: {
delay: ms('1 minute'),

1
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -14,6 +14,7 @@ export interface AdminMarketDataItem {
dataSource: DataSource;
date: Date;
id: string;
isActive: boolean;
isBenchmark?: boolean;
isUsedByUsersWithSubscription?: boolean;
lastMarketPrice: number;

4
libs/common/src/lib/interfaces/info-item.interface.ts

@ -1,5 +1,3 @@
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Platform, SymbolProfile } from '@prisma/client';
import { Statistics } from './statistics.interface';
@ -18,5 +16,5 @@ export interface InfoItem {
platforms: Platform[];
statistics: Statistics;
stripePublicKey?: string;
subscriptionOffers: { [offer in SubscriptionOfferKey]: SubscriptionOffer };
subscriptionOffer?: SubscriptionOffer;
}

1
libs/common/src/lib/interfaces/subscription-offer.interface.ts

@ -4,6 +4,7 @@ export interface SubscriptionOffer {
coupon?: number;
couponId?: string;
durationExtension?: StringValue;
isRenewal?: boolean;
label?: string;
price: number;
priceId: string;

2
libs/common/src/lib/interfaces/user-settings.interface.ts

@ -5,6 +5,7 @@ import {
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export interface UserSettings {
annualInterestRate?: number;
@ -22,6 +23,7 @@ export interface UserSettings {
isRestrictedView?: boolean;
language?: string;
locale?: string;
performanceCalculationType?: PerformanceCalculationType;
projectedTotalAmount?: number;
retirementDate?: string;
savingsRate?: number;

4
libs/common/src/lib/interfaces/user.interface.ts

@ -1,8 +1,8 @@
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Access, Account, Tag } from '@prisma/client';
import { SubscriptionOffer } from './subscription-offer.interface';
import { SystemMessage } from './system-message.interface';
import { UserSettings } from './user-settings.interface';
@ -18,7 +18,7 @@ export interface User {
systemMessage?: SystemMessage;
subscription: {
expiresAt?: Date;
offer: SubscriptionOfferKey;
offer: SubscriptionOffer;
type: SubscriptionType;
};
tags: (Tag & { isUsed: boolean })[];

16
libs/common/src/lib/permissions.ts

@ -17,15 +17,17 @@ export const permissions = {
createPlatform: 'createPlatform',
createTag: 'createTag',
createUserAccount: 'createUserAccount',
createWatchlistItem: 'createWatchlistItem',
deleteAccess: 'deleteAccess',
deleteAccount: 'deleteAcccount',
deleteAccountBalance: 'deleteAcccountBalance',
deleteAccount: 'deleteAccount',
deleteAccountBalance: 'deleteAccountBalance',
deleteAuthDevice: 'deleteAuthDevice',
deleteOrder: 'deleteOrder',
deleteOwnUser: 'deleteOwnUser',
deletePlatform: 'deletePlatform',
deleteTag: 'deleteTag',
deleteUser: 'deleteUser',
deleteWatchlistItem: 'deleteWatchlistItem',
enableDataProviderGhostfolio: 'enableDataProviderGhostfolio',
enableFearAndGreedIndex: 'enableFearAndGreedIndex',
enableImport: 'enableImport',
@ -41,6 +43,7 @@ export const permissions = {
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
readPlatforms: 'readPlatforms',
readTags: 'readTags',
readWatchlist: 'readWatchlist',
reportDataGlitch: 'reportDataGlitch',
toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount',
@ -64,7 +67,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccess,
permissions.createAccount,
permissions.createAccountBalance,
permissions.createWatchlistItem,
permissions.deleteAccountBalance,
permissions.deleteWatchlistItem,
permissions.createMarketData,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder,
@ -84,6 +89,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms,
permissions.readTags,
permissions.readWatchlist,
permissions.updateAccount,
permissions.updateAuthDevice,
permissions.updateMarketData,
@ -100,7 +106,8 @@ export function getPermissions(aRole: Role): string[] {
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createUserAccount,
permissions.readAiPrompt
permissions.readAiPrompt,
permissions.readWatchlist
];
case 'USER':
@ -113,14 +120,17 @@ export function getPermissions(aRole: Role): string[] {
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder,
permissions.createOwnTag,
permissions.createWatchlistItem,
permissions.deleteAccess,
permissions.deleteAccount,
permissions.deleteAccountBalance,
permissions.deleteAuthDevice,
permissions.deleteOrder,
permissions.deleteOwnUser,
permissions.deleteWatchlistItem,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.readWatchlist,
permissions.updateAccount,
permissions.updateAuthDevice,
permissions.updateMarketDataOfOwnAssetProfile,

38
libs/common/src/lib/personal-finance-tools.ts

@ -287,6 +287,14 @@ export const personalFinanceTools: Product[] = [
origin: 'British Virgin Islands',
slogan: 'Easy-to-use Portfolio Tracker'
},
{
founded: 2021,
hasSelfHostingAbility: false,
key: 'finvest',
name: 'Finvest',
origin: 'United States',
slogan: 'Grow your wealth in a stress-free way'
},
{
founded: 2023,
hasFreePlan: true,
@ -331,8 +339,10 @@ export const personalFinanceTools: Product[] = [
{
hasFreePlan: true,
hasSelfHostingAbility: false,
isArchived: true,
key: 'gospatz',
name: 'goSPATZ',
note: 'Renamed to Money Peak',
origin: 'Germany',
slogan: 'Volle Kontrolle über deine Investitionen'
},
@ -495,6 +505,15 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$100',
slogan: 'Personal Finance Manager for Mac, Windows, and Linux'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'moneypeak',
name: 'Money Peak',
note: 'Originally named as goSPATZ',
origin: 'Germany',
slogan: 'Dein smarter Finance Assistant'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,
@ -545,6 +564,13 @@ export const personalFinanceTools: Product[] = [
regions: ['Austria', 'Germany', 'Switzerland'],
slogan: 'Dein Vermögen immer im Blick'
},
{
hasSelfHostingAbility: false,
key: 'peek',
name: 'Peek',
origin: 'Singapore',
slogan: 'Feel in control of your money without spreadsheets or shame'
},
{
founded: 2023,
hasFreePlan: true,
@ -780,6 +806,18 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$360',
slogan: 'The Trading Journal to Improve Your Trading Performance'
},
{
founded: 2020,
hasSelfHostingAbility: false,
hasFreePlan: true,
isArchived: true,
key: 'tresor-one',
name: 'Tresor One',
note: 'Renamed to Parqet',
origin: 'Germany',
regions: ['Austria', 'Germany', 'Switzerland'],
slogan: 'Dein Vermögen immer im Blick'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,

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

Loading…
Cancel
Save