Browse Source

Merge pull request #137 from dandevaud/mr/Upstream-2024-11-13

Mr/upstream 2024 11 13
pull/5027/head
dandevaud 8 months ago
committed by GitHub
parent
commit
6944d926fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 123
      CHANGELOG.md
  2. 4
      apps/api/src/app/admin/admin.service.ts
  3. 4
      apps/api/src/app/auth/jwt.strategy.ts
  4. 2
      apps/api/src/app/benchmark/benchmark.service.ts
  5. 16
      apps/api/src/app/info/info.service.ts
  6. 250
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 21
      apps/api/src/app/portfolio/current-rate.service.ts
  8. 25
      apps/api/src/app/portfolio/portfolio.controller.ts
  9. 16
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 71
      apps/api/src/app/subscription/subscription.service.ts
  11. 4
      apps/api/src/app/symbol/symbol.controller.ts
  12. 12
      apps/api/src/app/symbol/symbol.service.ts
  13. 8
      apps/api/src/app/user/update-user-setting.dto.ts
  14. 53
      apps/api/src/app/user/user.service.ts
  15. 13
      apps/api/src/services/cron.service.ts
  16. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  17. 11
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  18. 62
      apps/api/src/services/data-provider/data-provider.service.ts
  19. 11
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  20. 11
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  21. 10
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  22. 11
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  23. 6
      apps/api/src/services/data-provider/manual/manual.service.ts
  24. 8
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  25. 9
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  26. 5
      apps/api/src/services/prisma/prisma.module.ts
  27. 24
      apps/api/src/services/prisma/prisma.service.ts
  28. 4
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  29. 2
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  30. 1
      apps/client/src/app/app.component.html
  31. 13
      apps/client/src/app/app.component.ts
  32. 27
      apps/client/src/app/components/header/header.component.html
  33. 38
      apps/client/src/app/components/header/header.component.ts
  34. 19
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  35. 10
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  36. 2
      apps/client/src/app/pages/portfolio/fire/fire-page-routing.module.ts
  37. 93
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  38. 130
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  39. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.module.ts
  40. 5
      apps/client/src/app/pages/portfolio/portfolio-page-routing.module.ts
  41. 7
      apps/client/src/app/pages/portfolio/portfolio-page.component.ts
  42. 21
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page-routing.module.ts
  43. 123
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  44. 3
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss
  45. 150
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  46. 22
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.module.ts
  47. 24
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  48. 18
      apps/client/src/app/pages/pricing/pricing-page.html
  49. 4
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  50. 10
      apps/client/src/app/services/data.service.ts
  51. 14
      apps/client/src/app/services/user/user.service.ts
  52. 110
      apps/client/src/locales/messages.it.xlf
  53. 4
      docker/docker-compose.build.yml
  54. 5
      docker/docker-compose.dev.yml
  55. 4
      docker/docker-compose.yml
  56. 8
      libs/common/src/lib/interfaces/index.ts
  57. 6
      libs/common/src/lib/interfaces/info-item.interface.ts
  58. 4
      libs/common/src/lib/interfaces/lookup-item.interface.ts
  59. 5
      libs/common/src/lib/interfaces/responses/lookup-response.interface.ts
  60. 9
      libs/common/src/lib/interfaces/subscription-offer.interface.ts
  61. 6
      libs/common/src/lib/interfaces/subscription.interface.ts
  62. 2
      libs/common/src/lib/interfaces/user-settings.interface.ts
  63. 4
      libs/common/src/lib/interfaces/user.interface.ts
  64. 4
      libs/common/src/lib/types/index.ts
  65. 2
      libs/common/src/lib/types/subscription-offer-key.type.ts
  66. 4
      libs/common/src/lib/types/user-with-settings.type.ts
  67. 136
      libs/ui/src/lib/assistant/assistant.component.ts
  68. 28
      libs/ui/src/lib/assistant/assistant.html
  69. 2
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  70. 15
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  71. 461
      package-lock.json
  72. 12
      package.json
  73. 2
      prisma/migrations/20241029190323_added_data_provider_ghostfolio_daily_requests_to_analytics/migration.sql
  74. 5
      prisma/migrations/20241102121004_added_last_request_at_to_analytics/migration.sql
  75. 15
      prisma/schema.prisma
  76. 2
      test/import/ok-derived-currency.json
  77. 6
      test/import/ok-vti-buy-long-history.json
  78. 8
      test/import/ok-without-accounts.json
  79. 10
      test/import/ok.json
  80. 2
      test/import/unavailable-exchange-rate.json

123
CHANGELOG.md

@ -94,6 +94,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 2.115.0 - 2024-10-14
### Changed
- Extended the assistant by a holding selector
- Separated the _FIRE_ / _X-ray_ page
- Improved the language localization for Italian (`it`)
- Upgraded `ngx-skeleton-loader` from version `7.0.0` to `9.0.0`
## 2.122.0 - 2024-11-07
### Changed
- Upgraded `countries-list` from version `3.1.0` to `3.1.1`
### Fixed
- Fixed an issue with the algebraic sign in the chart of the holdings tab on the home page (experimental)
- Improved the exception handling in the user authorization service
- Disabled the caching of the benchmarks in the markets overview if sharing the _Fear & Greed Index_ (market mood) is enabled
## 2.121.1 - 2024-11-02
### Added
- Set the stack and container names in the `docker-compose` files (`docker-compose.yml`, `docker-compose.build.yml` and `docker-compose.dev.yml`)
### Changed
- Reverted the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
- Upgraded the _Stripe_ dependencies
## 2.120.0 - 2024-10-30
### Added
- Added support for log levels (`LOG_LEVELS`) to conditionally log `prisma` query events (`debug` or `verbose`)
### Changed
- Restructured the resources page
- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets)
- Improved the language localization for German (`de`)
- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration
- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration
- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `Nx` from version `20.0.3` to `20.0.6`
## 2.119.0 - 2024-10-26
### Changed
- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `prisma` from version `5.20.0` to `5.21.1`
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
## 2.118.0 - 2024-10-23
### Added
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
- Added support for mutual funds in the _EOD Historical Data_ service
### Changed
- Improved the font colors of the chart of the holdings tab on the home page (experimental)
- Optimized the dialog sizes for mobile (full screen)
- Optimized the git-hook via `husky` to lint only affected projects before a commit
- Upgraded `angular` from version `18.1.1` to `18.2.8`
- Upgraded `Nx` from version `19.5.6` to `20.0.3`
### Fixed
- Fixed the warning `export was not found` in connection with `GetValuesParams`
- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
## 2.117.0 - 2024-10-19
### Added
- Added the logotype to the footer
- Added the data providers management to the admin control panel
### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the carousel component for the testimonial section on the landing page
## 2.116.0 - 2024-10-17
### Added
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page
- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
### Changed
- Improved the empty state in the benchmarks of the markets overview
- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental)
- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.115.0 - 2024-10-14
### Added
- Added the name to the tooltip of the chart of the holdings tab on the home page (experimental)

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

@ -648,7 +648,7 @@ export class AdminService {
}
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
let orderBy: any = {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc'
};
let where: Prisma.UserWhereInput;
@ -656,7 +656,7 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
Analytics: {
updatedAt: 'desc'
lastRequestAt: 'desc'
}
};
where = {

4
apps/api/src/app/auth/jwt.strategy.ts

@ -46,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
update: {
country,
activityCount: { increment: 1 },
updatedAt: new Date()
lastRequestAt: new Date()
},
where: { userId: user.id }
});
@ -60,7 +60,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(

2
apps/api/src/app/benchmark/benchmark.service.ts

@ -437,7 +437,7 @@ export class BenchmarkService {
};
});
if (storeInCache) {
if (!enableSharing && storeInCache) {
const expiration = addHours(new Date(), 2);
await this.redisCacheService.set(

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

@ -23,10 +23,10 @@ import {
import {
InfoItem,
Statistics,
Subscription
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@ -101,7 +101,7 @@ export class InfoService {
isUserSignupEnabled,
platforms,
statistics,
subscriptions
subscriptionOffers
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
@ -110,7 +110,7 @@ export class InfoService {
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.getSubscriptions()
this.getSubscriptionOffers()
]);
if (isUserSignupEnabled) {
@ -125,7 +125,7 @@ export class InfoService {
isReadOnlyMode,
platforms,
statistics,
subscriptions,
subscriptionOffers,
baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies()
};
@ -142,7 +142,7 @@ export class InfoService {
},
{
Analytics: {
updatedAt: {
lastRequestAt: {
gt: subDays(new Date(), aDays)
}
}
@ -314,8 +314,8 @@ export class InfoService {
return statistics;
}
private async getSubscriptions(): Promise<{
[offer in SubscriptionOffer]: Subscription;
private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOfferKey]: SubscriptionOffer;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;

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

@ -162,10 +162,6 @@ export abstract class PortfolioCalculator {
this.snapshotPromise = this.initialize();
}
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
@LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints);
@ -769,62 +765,6 @@ export abstract class PortfolioCalculator {
return { chart };
}
@LogPerformance
public async getSnapshot() {
await this.snapshotPromise;
return this.snapshot;
}
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
protected abstract getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() {
return this.transactionPoints;
}
@LogPerformance
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
@ -832,72 +772,11 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
const chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (const date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of last 90 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
// Reduce the step size of last 30 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
@LogPerformance
public async getSnapshot() {
await this.snapshotPromise;
return chartDateMap;
return this.snapshot;
}
@LogPerformance
@ -1120,4 +999,125 @@ export abstract class PortfolioCalculator {
await this.initialize();
}
}
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
public getTransactionPoints() {
return this.transactionPoints;
}
private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
const chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (const date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of last 90 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
// Reduce the step size of last 30 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap;
}
protected abstract getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics;
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
}

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

@ -52,27 +52,24 @@ export class CurrentRateService {
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
for (const { dataSource, symbol } of dataGatheringItems) {
if (dataResultProvider?.[symbol]?.dataProviderInfo) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
dataResultProvider[symbol].dataProviderInfo
);
}
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
if (dataResultProvider?.[symbol]?.marketPrice) {
result.push({
dataSource: dataGatheringItem.dataSource,
dataSource,
symbol,
date: today,
marketPrice:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
marketPrice: dataResultProvider?.[symbol]?.marketPrice
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
dataSource,
symbol
});
}
}

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

@ -77,12 +77,15 @@ export class PortfolioController {
@Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> {
@ -98,6 +101,8 @@ export class PortfolioController {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
@ -310,17 +315,22 @@ export class PortfolioController {
@Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
@ -377,21 +387,26 @@ export class PortfolioController {
@Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterByHoldingType,
filterBySearchQuery,
filterBySymbol,
filterByTags
});
@ -407,17 +422,22 @@ export class PortfolioController {
@Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
@ -472,6 +492,7 @@ export class PortfolioController {
@Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
@LogPerformance
@ -479,7 +500,9 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false
@ -487,6 +510,8 @@ export class PortfolioController {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});

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

@ -420,15 +420,16 @@ export class PortfolioService {
);
}
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(dataGatheringItems);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
@ -868,7 +869,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) {
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [
assetProfileIdentifiers: [
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
],
from: portfolioStart,
@ -975,7 +976,7 @@ export class PortfolioService {
return !quantity.eq(0);
});
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
@ -983,7 +984,10 @@ export class PortfolioService {
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.dataProviderService.getQuotes({
user,
items: assetProfileIdentifiers
}),
this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol };

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

@ -1,8 +1,16 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer, UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionOffer } from '@ghostfolio/common/interfaces';
import {
SubscriptionOfferKey,
UserWithSettings
} from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';
@ -17,14 +25,17 @@ export class SubscriptionService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2024-04-10'
}
);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2024-09-30.acacia'
}
);
}
}
public async createCheckoutSession({
@ -36,6 +47,18 @@ export class SubscriptionService {
priceId: string;
user: UserWithSettings;
}) {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{};
const subscriptionOffer = Object.values(subscriptionOffers).find(
(subscriptionOffer) => {
return subscriptionOffer.priceId === priceId;
}
);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
@ -47,6 +70,13 @@ export class SubscriptionService {
quantity: 1
}
],
locale:
(user.Settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
@ -73,17 +103,25 @@ export class SubscriptionService {
public async createSubscription({
duration = '1 year',
durationExtension,
price,
userId
}: {
duration?: StringValue;
durationExtension?: StringValue;
price: number;
userId: string;
}) {
let expiresAt = addMilliseconds(new Date(), ms(duration));
if (durationExtension) {
expiresAt = addMilliseconds(expiresAt, ms(durationExtension));
}
await this.prismaService.subscription.create({
data: {
expiresAt,
price,
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: {
connect: {
id: userId
@ -95,10 +133,21 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try {
let durationExtension: StringValue;
const session =
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
const subscriptionOffer: SubscriptionOffer = JSON.parse(
session.metadata.subscriptionOffer ?? '{}'
);
if (subscriptionOffer) {
durationExtension = subscriptionOffer.durationExtension;
}
await this.createSubscription({
durationExtension,
price: session.amount_total / 100,
userId: session.client_reference_id
});
@ -121,7 +170,7 @@ export class SubscriptionService {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
let offer: SubscriptionOffer = price ? 'renewal' : 'default';
let offer: SubscriptionOfferKey = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird-2023';

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

@ -2,6 +2,7 @@ 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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { LookupResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -21,7 +22,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service';
@ -41,7 +41,7 @@ export class SymbolController {
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<{ items: LookupItem[] }> {
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
try {

12
apps/api/src/app/symbol/symbol.service.ts

@ -5,13 +5,15 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import {
HistoricalDataItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable()
@ -84,7 +86,7 @@ export class SymbolService {
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
assetProfileIdentifiers: [{ dataSource, symbol }],
from: date,
to: date
});
@ -104,8 +106,8 @@ export class SymbolService {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
}): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
return results;

8
apps/api/src/app/user/update-user-setting.dto.ts

@ -67,6 +67,14 @@ export class UpdateUserSettingDto {
@IsOptional()
'filters.assetClasses'?: string[];
@IsString()
@IsOptional()
'filters.dataSource'?: string;
@IsString()
@IsOptional()
'filters.symbol'?: string;
@IsArray()
@IsOptional()
'filters.tags'?: string[];

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

@ -37,7 +37,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash';
const crypto = require('crypto');
@ -60,6 +60,13 @@ export class UserService {
return this.prismaService.user.count(args);
}
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
@ -358,13 +365,6 @@ export class UserService {
});
}
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public async createUser({
data
}: {
@ -426,17 +426,6 @@ export class UserService {
return user;
}
public async updateUser(params: {
where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput;
}): Promise<User> {
const { where, data } = params;
return this.prismaService.user.update({
data,
where
});
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
try {
await this.prismaService.access.deleteMany({
@ -473,6 +462,32 @@ export class UserService {
});
}
public async resetAnalytics() {
return this.prismaService.analytics.updateMany({
data: {
dataProviderGhostfolioDailyRequests: 0
},
where: {
updatedAt: {
gte: subDays(new Date(), 1)
}
}
});
}
public async updateUser({
data,
where
}: {
data: Prisma.UserUpdateInput;
where: Prisma.UserWhereUniqueInput;
}): Promise<User> {
return this.prismaService.user.update({
data,
where
});
}
public async updateUserSetting({
emitPortfolioChangedEvent,
userId,

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

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS,
@ -9,6 +10,7 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigurationService } from './configuration/configuration.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
@ -19,10 +21,12 @@ export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService,
private readonly twitterBotService: TwitterBotService
private readonly twitterBotService: TwitterBotService,
private readonly userService: UserService
) {}
@Cron(CronExpression.EVERY_HOUR)
@ -42,6 +46,13 @@ export class CronService {
this.twitterBotService.tweetFearAndGreedIndex();
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
public async runEveryDayAtMidnight() {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.userService.resetAnalytics();
}
}
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -12,7 +11,10 @@ import {
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -119,9 +121,7 @@ export class AlphaVantageService implements DataProviderInterface {
return undefined;
}
public async search({
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
const result = await this.alphaVantage.data.search(query);
return {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -13,7 +12,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -221,9 +224,7 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin';
}
public async search({
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
let items: LookupItem[] = [];
try {

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

@ -1,5 +1,4 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -21,7 +20,11 @@ import {
getStartOfUtcDate,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -89,11 +92,11 @@ export class DataProviderService {
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
const symbols = assetProfileIdentifiers.map(({ symbol }) => {
return symbol;
});
for (const symbol of symbols) {
@ -240,11 +243,11 @@ export class DataProviderService {
}
public async getHistoricalRaw({
dataGatheringItems,
assetProfileIdentifiers,
from,
to
}: {
dataGatheringItems: AssetProfileIdentifier[];
assetProfileIdentifiers: AssetProfileIdentifier[];
from: Date;
to: Date;
}): Promise<{
@ -253,25 +256,32 @@ export class DataProviderService {
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if (
this.hasCurrency({
dataGatheringItems,
assetProfileIdentifiers,
currency: `${DEFAULT_CURRENCY}${currency}`
})
) {
// Skip derived currency
dataGatheringItems = dataGatheringItems.filter(({ symbol }) => {
return symbol !== `${DEFAULT_CURRENCY}${currency}`;
});
assetProfileIdentifiers = assetProfileIdentifiers.filter(
({ symbol }) => {
return symbol !== `${DEFAULT_CURRENCY}${currency}`;
}
);
// Add root currency
dataGatheringItems.push({
assetProfileIdentifiers.push({
dataSource: this.getDataSourceForExchangeRates(),
symbol: `${DEFAULT_CURRENCY}${rootCurrency}`
});
}
}
dataGatheringItems = uniqWith(dataGatheringItems, (obj1, obj2) => {
return obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol;
});
assetProfileIdentifiers = uniqWith(
assetProfileIdentifiers,
(obj1, obj2) => {
return (
obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol
);
}
);
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -281,7 +291,7 @@ export class DataProviderService {
data: { [date: string]: IDataProviderHistoricalResponse };
symbol: string;
}>[] = [];
for (const { dataSource, symbol } of dataGatheringItems) {
for (const { dataSource, symbol } of assetProfileIdentifiers) {
const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) {
if (symbol === `${DEFAULT_CURRENCY}USX`) {
@ -417,7 +427,7 @@ export class DataProviderService {
const promises: Promise<any>[] = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource
)) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
@ -430,7 +440,7 @@ export class DataProviderService {
continue;
}
const symbols = dataGatheringItems
const symbols = assetProfileIdentifiers
.filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
})
@ -594,9 +604,9 @@ export class DataProviderService {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
}): Promise<LookupResponse> {
let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
@ -626,9 +636,9 @@ export class DataProviderService {
});
const filteredItems = lookupItems
.filter((lookupItem) => {
.filter(({ currency }) => {
// Only allow symbols with supported currency
return lookupItem.currency ? true : false;
return currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
@ -654,13 +664,13 @@ export class DataProviderService {
}
private hasCurrency({
currency,
dataGatheringItems
assetProfileIdentifiers,
currency
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
currency: string;
dataGatheringItems: AssetProfileIdentifier[];
}) {
return dataGatheringItems.some(({ dataSource, symbol }) => {
return assetProfileIdentifiers.some(({ dataSource, symbol }) => {
return (
dataSource === this.getDataSourceForExchangeRates() &&
symbol === currency

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -17,7 +16,11 @@ import {
REPLACE_NAME_PARTS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -317,9 +320,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US';
}
public async search({
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
const searchResult = await this.getSearchResult(query);
return {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -13,7 +12,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -169,9 +172,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL';
}
public async search({
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
let items: LookupItem[] = [];
try {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -14,7 +13,10 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -157,9 +159,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return 'INDEXSP:.INX';
}
public async search({
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,

11
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -44,10 +46,7 @@ export interface DataProviderInterface {
getTestSymbol(): string;
search({
includeIndices,
query
}: GetSearchParams): Promise<{ items: LookupItem[] }>;
search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
}
export interface GetDividendsParams {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -21,6 +20,7 @@ import {
} from '@ghostfolio/common/helper';
import {
DataProviderInfo,
LookupResponse,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
@ -227,9 +227,7 @@ export class ManualService implements DataProviderInterface {
return undefined;
}
public async search({
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
let items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -13,7 +12,10 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -121,7 +123,7 @@ export class RapidApiService implements DataProviderInterface {
return undefined;
}
public async search({}: GetSearchParams): Promise<{ items: LookupItem[] }> {
public async search({}: GetSearchParams): Promise<LookupResponse> {
return { items: [] };
}

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
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 {
@ -14,7 +13,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -224,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async search({
includeIndices = false,
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
}: GetSearchParams): Promise<LookupResponse> {
const items: LookupItem[] = [];
try {

5
apps/api/src/services/prisma/prisma.module.ts

@ -1,9 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Module({
providers: [PrismaService],
exports: [PrismaService]
exports: [PrismaService],
providers: [ConfigService, PrismaService]
})
export class PrismaModule {}

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

@ -1,16 +1,38 @@
import {
Injectable,
Logger,
LogLevel,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';
import { Prisma, PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
public constructor(configService: ConfigService) {
let customLogLevels: LogLevel[];
try {
customLogLevels = JSON.parse(
configService.get<string>('LOG_LEVELS')
) as LogLevel[];
} catch {}
const log: Prisma.LogDefinition[] =
customLogLevels?.includes('debug') || customLogLevels?.includes('verbose')
? [{ emit: 'stdout', level: 'query' }]
: [];
super({
log,
errorFormat: 'colorless'
});
}
public async onModuleInit() {
try {
await this.$connect();

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

@ -96,7 +96,7 @@ export class DataGatheringProcessor {
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
assetProfileIdentifiers: [{ dataSource, symbol }],
from: currentDate,
to: new Date()
});
@ -214,7 +214,7 @@ export class DataGatheringProcessor {
dates = dates.filter((d) => !marketData.some((md) => isEqual(md, d)));
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
assetProfileIdentifiers: [{ dataSource, symbol }],
from: firstEntry.date,
to: new Date()
});

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

@ -142,7 +142,7 @@ export class DataGatheringService {
}) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
assetProfileIdentifiers: [{ dataSource, symbol }],
from: date,
to: date
});

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

@ -33,6 +33,7 @@
[deviceType]="deviceType"
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
[hasPromotion]="hasPromotion"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"

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

@ -57,6 +57,7 @@ export class AppComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false;
public info: InfoItem;
public pageTitle: string;
@ -136,6 +137,10 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex
);
this.hasPromotion =
!!this.info?.subscriptionOffers?.default?.coupon ||
!!this.info?.subscriptionOffers?.default?.durationExtension;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
@ -231,6 +236,14 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasInfoMessage =
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.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck();

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

@ -88,15 +88,20 @@
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
}"
[routerLink]="routerLinkPricing"
>Pricing</a
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li>
}
<li class="list-inline-item">
@ -290,12 +295,17 @@
) {
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[routerLink]="routerLinkPricing"
>Pricing</a
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
}
<a
class="d-flex d-sm-none"
@ -358,15 +368,20 @@
<li class="list-inline-item">
<a
class="d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
}"
[routerLink]="routerLinkPricing"
>Pricing</a
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li>
}
@if (hasPermissionToAccessFearAndGreedIndex) {

38
apps/client/src/app/components/header/header.component.ts

@ -58,6 +58,7 @@ export class HeaderComponent implements OnChanges {
@Input() deviceType: string;
@Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean;
@Input() hasPromotion: boolean;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@ -174,24 +175,18 @@ export class HeaderComponent implements OnChanges {
const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) {
const filtersType = this.getFilterType(filter.type);
const userFilters = filters
.filter((f) => f.type === filter.type && filter.id)
.map((f) => f.id);
userSetting[`filters.${filtersType}`] = userFilters.length
? userFilters
: null;
if (filter.type === 'ACCOUNT') {
userSetting['filters.accounts'] = filter.id ? [filter.id] : null;
} else if (filter.type === 'ASSET_CLASS') {
userSetting['filters.assetClasses'] = filter.id ? [filter.id] : null;
} else if (filter.type === 'DATA_SOURCE') {
userSetting['filters.dataSource'] = filter.id ? filter.id : null;
} else if (filter.type === 'SYMBOL') {
userSetting['filters.symbol'] = filter.id ? filter.id : null;
} else if (filter.type === 'TAG') {
userSetting['filters.tags'] = filter.id ? [filter.id] : null;
}
}
['ACCOUNT', 'ASSET_CLASS', 'TAG']
.filter(
(fitlerType) =>
!filters.some((f: Filter) => f.type.toString() === fitlerType)
)
.forEach((filterType) => {
userSetting[`filters.${this.getFilterType(filterType)}`] = null;
});
this.dataService
.putUserSetting(userSetting)
@ -285,13 +280,4 @@ export class HeaderComponent implements OnChanges {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private getFilterType(filterType: string) {
if (filterType === 'ACCOUNT') {
return 'accounts';
} else if (filterType === 'ASSET_CLASS') {
return 'assetClasses';
} else if (filterType === 'TAG') {
return 'tags';
}
}
}

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

@ -16,6 +16,7 @@ import {
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -31,6 +32,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public coupon: number;
public couponId: string;
public defaultDateFormat: string;
public durationExtension: StringValue;
public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
@ -51,7 +53,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
private stripeService: StripeService,
private userService: UserService
) {
const { baseCurrency, globalPermissions, subscriptions } =
const { baseCurrency, globalPermissions, subscriptionOffers } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
@ -76,11 +78,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
permissions.updateUserSettings
);
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
this.coupon =
subscriptionOffers?.[this.user.subscription.offer]?.coupon;
this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user.subscription.offer]?.price;
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
subscriptionOffers?.[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.changeDetectorRef.markForCheck();
}

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

@ -34,6 +34,16 @@
&nbsp;<span i18n>per year</span>
</div>
}
@if (durationExtension) {
<div class="mt-1 text-center">
<div
class="badge badge-pill badge-warning font-weight-normal px-2 py-1"
>
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
</div>
</div>
}
}
<div class="align-items-center d-flex justify-content-center mt-4">
@if (!user?.subscription?.expiresAt) {

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

@ -10,7 +10,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
component: FirePageComponent,
path: '',
title: $localize`FIRE`
title: 'FIRE'
}
];

93
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -1,12 +1,7 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioReport,
PortfolioReportRule,
User
} from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -21,18 +16,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './fire-page.html'
})
export class FirePageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public deviceType: string;
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public fireWealth: Big;
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoading = false;
public isLoadingPortfolioReport = false;
public user: User;
public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: Big;
@ -95,8 +83,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.initializePortfolioReport();
}
public onAnnualInterestRateChange(annualInterestRate: number) {
@ -133,21 +119,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
});
});
}
public onRulesUpdated(event: UpdateUserSettingDto) {
this.dataService
.putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport();
});
}
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
@ -187,66 +158,4 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !isActive;
})
);
}
return inactiveRules;
}
}

130
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -101,133 +101,3 @@
}
</div>
</div>
<div class="container mt-5">
<div class="row">
<div class="col">
<h2 class="h3 mb-3 text-center">X-ray</h2>
<p class="mb-4">
<span i18n
>Ghostfolio X-ray uses static analysis to identify potential issues
and risks in your portfolio.</span
>
<span class="d-none"
>It will be highly configurable in the future: activate / deactivate
rules and customize the thresholds to match your personal investment
style.</span
>
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

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

@ -1,4 +1,3 @@
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -17,7 +16,6 @@ import { FirePageComponent } from './fire-page.component';
FirePageRoutingModule,
GfFireCalculatorComponent,
GfPremiumIndicatorComponent,
GfRulesModule,
GfValueComponent,
NgxSkeletonLoaderModule
],

5
apps/client/src/app/pages/portfolio/portfolio-page-routing.module.ts

@ -34,6 +34,11 @@ const routes: Routes = [
path: 'fire',
loadChildren: () =>
import('./fire/fire-page.module').then((m) => m.FirePageModule)
},
{
path: 'x-ray',
loadChildren: () =>
import('./x-ray/x-ray-page.module').then((m) => m.XRayPageModule)
}
],
component: PortfolioPageComponent,

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

@ -46,8 +46,13 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
},
{
iconName: 'calculator-outline',
label: 'FIRE / X-ray',
label: 'FIRE ',
path: ['/portfolio', 'fire']
},
{
iconName: 'scan-outline',
label: 'X-ray',
path: ['/portfolio', 'x-ray']
}
];
this.user = state.user;

21
apps/client/src/app/pages/portfolio/x-ray/x-ray-page-routing.module.ts

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { XRayPageComponent } from './x-ray-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: XRayPageComponent,
path: '',
title: 'X-ray'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class XRayPageRoutingModule {}

123
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -0,0 +1,123 @@
<div class="container">
<div class="row">
<div class="col">
<h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2>
<p class="mb-4" i18n>
Ghostfolio X-ray uses static analysis to uncover potential issues and
risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy.
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

3
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss

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

150
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -0,0 +1,150 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioReportRule,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces/user.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'gf-x-ray-page',
styleUrl: './x-ray-page.component.scss',
templateUrl: './x-ray-page.component.html'
})
export class XRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoadingPortfolioReport = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === 'Basic'
? false
: hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}
});
this.initializePortfolioReport();
}
public onRulesUpdated(event: UpdateUserSettingDto) {
this.dataService
.putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !isActive;
})
);
}
return inactiveRules;
}
}

22
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.module.ts

@ -0,0 +1,22 @@
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { XRayPageRoutingModule } from './x-ray-page-routing.module';
import { XRayPageComponent } from './x-ray-page.component';
@NgModule({
declarations: [XRayPageComponent],
imports: [
CommonModule,
GfPremiumIndicatorComponent,
GfRulesModule,
NgxSkeletonLoaderModule,
XRayPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class XRayPageModule {}

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

@ -6,6 +6,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe';
import { Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -20,6 +21,7 @@ export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string;
public coupon: number;
public couponId: string;
public durationExtension: StringValue;
public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
@ -51,11 +53,12 @@ export class PricingPageComponent implements OnDestroy, OnInit {
) {}
public ngOnInit() {
const { baseCurrency, subscriptions } = this.dataService.fetchInfo();
const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.default?.coupon;
this.price = subscriptions?.default?.price;
this.coupon = subscriptionOffers?.default?.coupon;
this.durationExtension = subscriptionOffers?.default?.durationExtension;
this.price = subscriptionOffers?.default?.price;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
@ -68,11 +71,18 @@ export class PricingPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon;
this.coupon =
subscriptionOffers?.[this.user?.subscription?.offer]?.coupon;
this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user?.subscription?.offer]?.price;
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
subscriptionOffers?.[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.changeDetectorRef.markForCheck();
}

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

@ -101,6 +101,11 @@
</p>
</div>
}
@if (durationExtension) {
<div class="d-none d-lg-block hidden mt-3">
<div class="badge p-3 w-100">&nbsp;</div>
</div>
}
</mat-card-content>
</mat-card>
</div>
@ -159,6 +164,11 @@
</p>
</div>
}
@if (durationExtension) {
<div class="d-none d-lg-block hidden mt-3">
<div class="badge p-3 w-100">&nbsp;</div>
</div>
}
</mat-card-content>
</mat-card>
</div>
@ -289,6 +299,14 @@
</p>
</div>
}
@if (durationExtension) {
<div class="mt-3">
<div class="badge badge-warning font-weight-normal p-3 w-100">
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
</div>
</div>
}
</mat-card-content>
</mat-card>
</div>

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

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

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

@ -10,7 +10,6 @@ import {
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
@ -30,6 +29,7 @@ import {
Filter,
ImportResponse,
InfoItem,
LookupResponse,
OAuthResponse,
PortfolioDetails,
PortfolioDividends,
@ -464,10 +464,10 @@ export class DataService {
}
return this.http
.get<{ items: LookupItem[] }>('/api/v1/symbol/lookup', { params })
.get<LookupResponse>('/api/v1/symbol/lookup', { params })
.pipe(
map((respose) => {
return respose.items;
map(({ items }) => {
return items;
})
);
}
@ -535,7 +535,7 @@ export class DataService {
}: {
filters?: Filter[];
range?: DateRange;
}) {
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (range) {

14
apps/client/src/app/services/user/user.service.ts

@ -65,6 +65,20 @@ export class UserService extends ObservableStore<UserStoreState> {
});
}
if (user?.settings['filters.dataSource']) {
filters.push({
id: user.settings['filters.dataSource'],
type: 'DATA_SOURCE'
});
}
if (user?.settings['filters.symbol']) {
filters.push({
id: user.settings['filters.symbol'],
type: 'SYMBOL'
});
}
if (user?.settings['filters.tags']) {
filters.push({
id: user.settings['filters.tags'].join(','),

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

@ -2872,7 +2872,7 @@
</trans-unit>
<trans-unit id="82fe55446d3fad9db11eb79caaedf325587b9c0a" datatype="html">
<source> Hello, <x id="INTERPOLATION" equiv-text="{{ publicPortfolioDetails?.alias ?? &apos;someone&apos; }}"/> has shared a <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> with you! </source>
<target state="new">Salve, <x id="INTERPOLATION" equiv-text="{{ portfolioPublicDetails?.alias ?? &apos;someone&apos; }}"/> ha condiviso un <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portafoglio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> con te! </target>
<target state="translated">Salve, <x id="INTERPOLATION" equiv-text="{{ portfolioPublicDetails?.alias ?? &apos;someone&apos; }}"/> ha condiviso un <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portafoglio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> con te! </target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">4</context>
@ -5881,7 +5881,7 @@
</trans-unit>
<trans-unit id="50760c2140335b2324deaee120f40d9aa64b3238" datatype="html">
<source>Currency Cluster Risks</source>
<target state="new">Currency Cluster Risks</target>
<target state="translated">Rischio di Concentrazione Valutario</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">141</context>
@ -5889,7 +5889,7 @@
</trans-unit>
<trans-unit id="c7b797e5df289241021db16010efb6ac3c6cfb86" datatype="html">
<source>Account Cluster Risks</source>
<target state="new">Account Cluster Risks</target>
<target state="translated">Rischi di Concentrazione dei Conti</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">160</context>
@ -6237,7 +6237,7 @@
</trans-unit>
<trans-unit id="97bad3b5e318e5c7c755cd99062f2973efcf17e5" datatype="html">
<source>Restricted view</source>
<target state="new">Restricted view</target>
<target state="translated">Vista limitata</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context>
<context context-type="linenumber">26</context>
@ -6357,7 +6357,7 @@
</trans-unit>
<trans-unit id="7451343426685730864" datatype="html">
<source>WTD</source>
<target state="new">WTD</target>
<target state="translated">Settimana corrente</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context>
<context context-type="linenumber">212</context>
@ -6373,7 +6373,7 @@
</trans-unit>
<trans-unit id="399380803601269035" datatype="html">
<source>MTD</source>
<target state="new">MTD</target>
<target state="translated">Mese corrente</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context>
<context context-type="linenumber">216</context>
@ -6597,7 +6597,7 @@
</trans-unit>
<trans-unit id="ebf471e68247ca2110cdc5c98538e2e2bbf6e56e" datatype="html">
<source>{VAR_PLURAL, plural, =1 {activity} other {activities}}</source>
<target state="new">{VAR_PLURAL, plural, =1 {activity} other {activities}}</target>
<target state="translated">{VAR_PLURAL, plural, =1 {attivit&agrave;} other {attivit&agrave;}}</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html</context>
<context context-type="linenumber">14</context>
@ -6781,7 +6781,7 @@
</trans-unit>
<trans-unit id="4622218074144052433" datatype="html">
<source>Family Office</source>
<target state="new">Family Office</target>
<target state="translated">Ufficio familiare</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts</context>
<context context-type="linenumber">87</context>
@ -6837,7 +6837,7 @@
</trans-unit>
<trans-unit id="2657610384052021428" datatype="html">
<source>User Experience</source>
<target state="new">User Experience</target>
<target state="translated">Esperienza Utente</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts</context>
<context context-type="linenumber">98</context>
@ -7061,7 +7061,7 @@
</trans-unit>
<trans-unit id="665692df9ab12bc228c1276f7d04e97902ff9afc" datatype="html">
<source>Copy link to clipboard</source>
<target state="new">Copy link to clipboard</target>
<target state="translated">Copia link negli appunti</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context>
<context context-type="linenumber">70</context>
@ -7069,7 +7069,7 @@
</trans-unit>
<trans-unit id="306e3758e5303c780f0984c003e6283d49796f79" datatype="html">
<source>Portfolio Snapshot</source>
<target state="new">Portfolio Snapshot</target>
<target state="translated">Stato del Portfolio</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-jobs/admin-jobs.html</context>
<context context-type="linenumber">39</context>
@ -7077,7 +7077,7 @@
</trans-unit>
<trans-unit id="76897e07c5670ce3b7710cc10c5e1c08b5f6a83a" datatype="html">
<source><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Change with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Change <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source>
<target state="new"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Change with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Change <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Cambio con effetto valuta <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Cambia <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context>
<context context-type="linenumber">50</context>
@ -7085,7 +7085,7 @@
</trans-unit>
<trans-unit id="65ff514a2e167229e1a34b3712f2cf2908576d0f" datatype="html">
<source><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Performance with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Performance <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source>
<target state="new"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Performance with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Performance <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Prestazioni con effetto valuta <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Prestazioni <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context>
<context context-type="linenumber">69</context>
@ -7093,7 +7093,7 @@
</trans-unit>
<trans-unit id="5502bf2eace842803c7b3f5ce5f600e102d3424a" datatype="html">
<source>Threshold Min</source>
<target state="new">Threshold Min</target>
<target state="translated">Soglia Minima</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context>
<context context-type="linenumber">9</context>
@ -7101,7 +7101,7 @@
</trans-unit>
<trans-unit id="012b48ee5281a77c66760b2007c3ccd7e34aa340" datatype="html">
<source>Threshold Max</source>
<target state="new">Threshold Max</target>
<target state="translated">Soglia Massima</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context>
<context context-type="linenumber">44</context>
@ -7109,7 +7109,7 @@
</trans-unit>
<trans-unit id="f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8" datatype="html">
<source>Close</source>
<target state="new">Close</target>
<target state="translated">Chiudi</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context>
<context context-type="linenumber">77</context>
@ -7117,7 +7117,7 @@
</trans-unit>
<trans-unit id="072d4d4ec83a5a97345a1c13b90c213b47326d09" datatype="html">
<source>Customize</source>
<target state="new">Customize</target>
<target state="translated">Personalizza</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule.component.html</context>
<context context-type="linenumber">67</context>
@ -7125,7 +7125,7 @@
</trans-unit>
<trans-unit id="06296af0cdaf7bed02043379359ed1975fc22077" datatype="html">
<source>No auto-renewal.</source>
<target state="new">No auto-renewal.</target>
<target state="translated">No rinnovo automatico.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-membership/user-account-membership.html</context>
<context context-type="linenumber">62</context>
@ -7133,7 +7133,7 @@
</trans-unit>
<trans-unit id="7fb1099e29660162f9154d5b2feee7743a423df6" datatype="html">
<source>Today</source>
<target state="new">Today</target>
<target state="translated">Oggi</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">24</context>
@ -7141,7 +7141,7 @@
</trans-unit>
<trans-unit id="65cefcc53d1f6445df7568e8a40c49165f1090ee" datatype="html">
<source>This year</source>
<target state="new">This year</target>
<target state="translated">Anno corrente</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">42</context>
@ -7149,7 +7149,7 @@
</trans-unit>
<trans-unit id="5beadaafe995fa04343008b0ab57e579c9fc81b9" datatype="html">
<source>From the beginning</source>
<target state="new">From the beginning</target>
<target state="translated">Dall'inizio</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">60</context>
@ -7157,7 +7157,7 @@
</trans-unit>
<trans-unit id="f7ed8f2e1ac78c5a63741ae684779bcf7a99ab16" datatype="html">
<source>Oops! Invalid currency.</source>
<target state="new">Oops! Invalid currency.</target>
<target state="translated">Oops! Valuta sbagliata.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html</context>
<context context-type="linenumber">49</context>
@ -7165,7 +7165,7 @@
</trans-unit>
<trans-unit id="f1cc4a59110dd72517210565f76118df74336fec" datatype="html">
<source>This page has been archived.</source>
<target state="new">This page has been archived.</target>
<target state="translated">Questa pagina è stata archiviata.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">14</context>
@ -7173,7 +7173,7 @@
</trans-unit>
<trans-unit id="ea66ce0bb1cb1dcee2bcb86a8876c5f2c2fa65f6" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is Open Source Software</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> è un programma Open Source</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">139</context>
@ -7181,7 +7181,7 @@
</trans-unit>
<trans-unit id="aa945d3772281b66851cc8b86d6de9726e65db70" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is not Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is not Open Source Software</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non è un programma Open Source</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">146</context>
@ -7189,7 +7189,7 @@
</trans-unit>
<trans-unit id="942e46b94b9d9662c3c3b7b5e7f9005c7b9feab7" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is Open Source Software</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> è un programma Open Source</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">156</context>
@ -7197,7 +7197,7 @@
</trans-unit>
<trans-unit id="08beed5100360c242c0aec92e8706d92f0cb70f3" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is not Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is not Open Source Software</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non è un programma Open Source</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">163</context>
@ -7205,7 +7205,7 @@
</trans-unit>
<trans-unit id="1302d86668c92816f6e69a61fee77d573ace918b" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be self-hosted</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> pu&ograve; essere ospitato in proprio </target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">178</context>
@ -7213,7 +7213,7 @@
</trans-unit>
<trans-unit id="2371f735292afd7ed2b6c90640068d33667933e7" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be self-hosted</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non pu&ograve; essere ospitato in proprio</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">185</context>
@ -7221,7 +7221,7 @@
</trans-unit>
<trans-unit id="4803807735afa465179312a0094a432c7d8e1a55" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be self-hosted</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> pu&ograve; essere ospitato in proprio</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">195</context>
@ -7229,7 +7229,7 @@
</trans-unit>
<trans-unit id="e44d1606f6b1c8b549b5bbc22c8f2d53b6626404" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be self-hosted</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non pu&ograve; essere ospitato in proprio</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">202</context>
@ -7237,7 +7237,7 @@
</trans-unit>
<trans-unit id="575dc02bb1fbd579cb7ecca00198bbf2893d6115" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be used anonymously</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> pu&ograve; essere usato anonimamente</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">217</context>
@ -7245,7 +7245,7 @@
</trans-unit>
<trans-unit id="60bcadcc133112179baf414d6bca496616026ddf" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be used anonymously</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non pu&ograve; essere usato anonimamente</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">224</context>
@ -7253,7 +7253,7 @@
</trans-unit>
<trans-unit id="ea9dead7214d6e5e68bedcfc41ed92e6f331b476" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be used anonymously</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> pu&ograve; essere usato anonimamente</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">234</context>
@ -7261,7 +7261,7 @@
</trans-unit>
<trans-unit id="d69adb6f3253a16368a171d0a5d998e9a6b95717" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be used anonymously</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non pu&ograve; essere usato anonimamente</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">241</context>
@ -7269,7 +7269,7 @@
</trans-unit>
<trans-unit id="0499b37a6ed7d654307685844b35684df638a95f" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> offers a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> offers a free plan</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> ha un piano gratuito</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">256</context>
@ -7277,7 +7277,7 @@
</trans-unit>
<trans-unit id="0a3bfda56ea7cd7ae419cb5091e3a68ade032c30" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> does not offer a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> does not offer a free plan</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non ha un piano gratuito</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">263</context>
@ -7285,7 +7285,7 @@
</trans-unit>
<trans-unit id="04eede9cafd81f04b01b6c7937e047824f78b05d" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> offers a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> offers a free plan</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> ha un piano gratuito</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">273</context>
@ -7293,7 +7293,7 @@
</trans-unit>
<trans-unit id="2452ec985c64ade4904d93697e68d3846beb2bf4" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> does not offer a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> does not offer a free plan</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non ha un piano gratuito</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">280</context>
@ -7301,7 +7301,7 @@
</trans-unit>
<trans-unit id="b225488f8b209e9704760dc9f5d99845a5d07bf6" datatype="html">
<source>Oops! Could not find any assets.</source>
<target state="new">Oops! Could not find any assets.</target>
<target state="translated">Oops! Non ho trovato alcun asset.</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html</context>
<context context-type="linenumber">37</context>
@ -7309,7 +7309,7 @@
</trans-unit>
<trans-unit id="be839b9dc1563aec0f80f5b55c8bde1a1dd10ca1" datatype="html">
<source>Data Providers</source>
<target state="new">Data Providers</target>
<target state="translated">Fornitori di dati</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">4</context>
@ -7317,7 +7317,7 @@
</trans-unit>
<trans-unit id="b082171edef6d502186c4577e201b2ef7935e955" datatype="html">
<source>NEW</source>
<target state="new">NEW</target>
<target state="translated">NUOVO</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">14</context>
@ -7325,7 +7325,7 @@
</trans-unit>
<trans-unit id="66cbb0c1695dc25d22f824b62f0ffb9435ec8c4b" datatype="html">
<source>Set API Key</source>
<target state="new">Set API Key</target>
<target state="translated">Imposta API Key</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">29</context>
@ -7333,7 +7333,7 @@
</trans-unit>
<trans-unit id="2fcf96765ae87821e12fe4f6900ba1a218742cfc" datatype="html">
<source> Want to stay updated? Click below to get notified as soon as it’s available. </source>
<target state="new"> Want to stay updated? Click below to get notified as soon as it’s available. </target>
<target state="translated"> Vuoi seguire le novità? Clicca sotto per essere notificato appena è disponibile. </target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context>
<context context-type="linenumber">23</context>
@ -7341,7 +7341,7 @@
</trans-unit>
<trans-unit id="3a0843b9fa08ab1d2294e2b5bd60042052ab8d10" datatype="html">
<source> Notify me </source>
<target state="new"> Notify me </target>
<target state="translated"> Notificami </target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context>
<context context-type="linenumber">32</context>
@ -7349,7 +7349,7 @@
</trans-unit>
<trans-unit id="8908015589937012101" datatype="html">
<source>Get access to 100’000+ tickers from over 50 exchanges</source>
<target state="new">Get access to 100’000+ tickers from over 50 exchanges</target>
<target state="translated">Ottieni accesso a oltre 100’000+ titoli da oltre 50 borse</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">24</context>
@ -7357,7 +7357,7 @@
</trans-unit>
<trans-unit id="4346283537747431562" datatype="html">
<source>Ukraine</source>
<target state="new">Ukraine</target>
<target state="translated">Ucraina</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">92</context>
@ -7365,7 +7365,7 @@
</trans-unit>
<trans-unit id="f977904fb6da18ed1fcc2b56caa2315e991fd2ac" datatype="html">
<source> Skip </source>
<target state="new"> Skip </target>
<target state="translated"> Salta </target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">83</context>
@ -7373,7 +7373,7 @@
</trans-unit>
<trans-unit id="3e0b7db80b1d6c100266b97b9bb3f9ddd7652844" datatype="html">
<source>Join now</source>
<target state="new">Join now</target>
<target state="translated">Iscriviti adesso</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">93</context>
@ -7381,7 +7381,7 @@
</trans-unit>
<trans-unit id="e8a4d8ee23e2c3f89acf7214598bcc3182c79268" datatype="html">
<source>Allocation Cluster Risks</source>
<target state="new">Allocation Cluster Risks</target>
<target state="translated">Rischi di allocazione dei Conti</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">179</context>
@ -7389,7 +7389,7 @@
</trans-unit>
<trans-unit id="5020357869062357338" datatype="html">
<source>Glossary</source>
<target state="new">Glossary</target>
<target state="translated">Glossario</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/glossary/resources-glossary-routing.module.ts</context>
<context context-type="linenumber">10</context>
@ -7401,7 +7401,7 @@
</trans-unit>
<trans-unit id="7423212324650924366" datatype="html">
<source>Guides</source>
<target state="new">Guides</target>
<target state="translated">Guide</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/guides/resources-guides-routing.module.ts</context>
<context context-type="linenumber">10</context>
@ -7413,7 +7413,7 @@
</trans-unit>
<trans-unit id="7491998780064454778" datatype="html">
<source>guides</source>
<target state="new">guides</target>
<target state="translated">guide</target>
<note priority="1" from="description">snake-case</note>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context>
@ -7426,7 +7426,7 @@
</trans-unit>
<trans-unit id="6255655462254999912" datatype="html">
<source>glossary</source>
<target state="new">glossary</target>
<target state="translated">glossario</target>
<note priority="1" from="description">snake-case</note>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context>
@ -7439,4 +7439,4 @@
</trans-unit>
</body>
</file>
</xliff>
</xliff>

4
docker/docker-compose.build.yml

@ -1,6 +1,8 @@
name: ghostfolio_build
services:
ghostfolio:
build: ../
container_name: ghostfolio-build
init: true
env_file:
- ../.env
@ -23,6 +25,7 @@ services:
postgres:
image: docker.io/library/postgres:15
container_name: gf-postgres-build
env_file:
- ../.env
healthcheck:
@ -35,6 +38,7 @@ services:
redis:
image: docker.io/library/redis:alpine
container_name: gf-redis-build
env_file:
- ../.env
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]

5
docker/docker-compose.dev.yml

@ -1,7 +1,8 @@
name: ghostfolio_dev
services:
postgres:
image: docker.io/library/postgres:15
container_name: postgres
container_name: gf-postgres-dev
restart: unless-stopped
env_file:
- ../.env
@ -12,7 +13,7 @@ services:
redis:
image: docker.io/library/redis:alpine
container_name: redis
container_name: gf-redis-dev
restart: unless-stopped
env_file:
- ../.env

4
docker/docker-compose.yml

@ -1,6 +1,8 @@
name: ghostfolio
services:
ghostfolio:
image: docker.io/ghostfolio/ghostfolio:latest
container_name: ghostfolio
init: true
cap_drop:
- ALL
@ -27,6 +29,7 @@ services:
postgres:
image: docker.io/library/postgres:15
container_name: gf-postgres
cap_drop:
- ALL
cap_add:
@ -49,6 +52,7 @@ services:
redis:
image: docker.io/library/redis:alpine
container_name: gf-redis
user: '999:1000'
cap_drop:
- ALL

8
libs/common/src/lib/interfaces/index.ts

@ -23,6 +23,7 @@ import type { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface';
import type { LineChartItem } from './line-chart-item.interface';
import type { LookupItem } from './lookup-item.interface';
import type { PortfolioChart } from './portfolio-chart.interface';
import type { PortfolioDetails } from './portfolio-details.interface';
import type { PortfolioDividends } from './portfolio-dividends.interface';
@ -40,13 +41,14 @@ import type { AccountBalancesResponse } from './responses/account-balances-respo
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { ResponseError } from './responses/errors.interface';
import type { ImportResponse } from './responses/import-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface';
import type { Subscription } from './subscription.interface';
import type { SubscriptionOffer } from './subscription-offer.interface';
import type { SymbolMetrics } from './symbol-metrics.interface';
import type { SystemMessage } from './system-message.interface';
import type { TabConfiguration } from './tab-configuration.interface';
@ -82,6 +84,8 @@ export {
InfoItem,
InvestmentItem,
LineChartItem,
LookupItem,
LookupResponse,
OAuthResponse,
PortfolioChart,
PortfolioDetails,
@ -102,8 +106,8 @@ export {
ResponseError,
ScraperConfiguration,
Statistics,
SubscriptionOffer,
SystemMessage,
Subscription,
SymbolMetrics,
TabConfiguration,
ToggleOption,

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

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

4
apps/api/src/app/symbol/interfaces/lookup-item.interface.ts → libs/common/src/lib/interfaces/lookup-item.interface.ts

@ -1,7 +1,7 @@
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { DataProviderInfo } from './data-provider-info.interface';
export interface LookupItem {
assetClass: AssetClass;
assetSubClass: AssetSubClass;

5
libs/common/src/lib/interfaces/responses/lookup-response.interface.ts

@ -0,0 +1,5 @@
import { LookupItem } from '../lookup-item.interface';
export interface LookupResponse {
items: LookupItem[];
}

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

@ -0,0 +1,9 @@
import { StringValue } from 'ms';
export interface SubscriptionOffer {
coupon?: number;
couponId?: string;
durationExtension?: StringValue;
price: number;
priceId: string;
}

6
libs/common/src/lib/interfaces/subscription.interface.ts

@ -1,6 +0,0 @@
export interface Subscription {
coupon?: number;
couponId?: string;
price: number;
priceId: string;
}

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

@ -14,6 +14,8 @@ export interface UserSettings {
dateRange?: DateRange;
emergencyFund?: number;
'filters.accounts'?: string[];
'filters.dataSource'?: string;
'filters.symbol'?: string;
'filters.tags'?: string[];
holdingsViewMode?: HoldingsViewMode;
isExperimentalFeatures?: boolean;

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

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

4
libs/common/src/lib/types/index.ts

@ -15,7 +15,7 @@ import type { MarketState } from './market-state.type';
import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type';
import type { SubscriptionOffer } from './subscription-offer.type';
import type { SubscriptionOfferKey } from './subscription-offer-key.type';
import type { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type';
@ -37,7 +37,7 @@ export type {
MarketState,
OrderWithAccount,
RequestWithUser,
SubscriptionOffer,
SubscriptionOfferKey,
UserWithSettings,
ViewMode
};

2
libs/common/src/lib/types/subscription-offer.type.ts → libs/common/src/lib/types/subscription-offer-key.type.ts

@ -1,4 +1,4 @@
export type SubscriptionOffer =
export type SubscriptionOfferKey =
| 'default'
| 'renewal'
| 'renewal-early-bird-2023'

4
libs/common/src/lib/types/user-with-settings.type.ts

@ -1,5 +1,5 @@
import { UserSettings } from '@ghostfolio/common/interfaces';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Access, Account, Settings, User } from '@prisma/client';
@ -13,7 +13,7 @@ export type UserWithSettings = User & {
Settings: Settings & { settings: UserSettings };
subscription?: {
expiresAt?: Date;
offer: SubscriptionOffer;
offer: SubscriptionOfferKey;
type: SubscriptionType;
};
};

136
libs/ui/src/lib/assistant/assistant.component.ts

@ -1,7 +1,9 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Filter, User } from '@ghostfolio/common/interfaces';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
@ -35,7 +37,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatMenuTrigger } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { Account, AssetClass } from '@prisma/client';
import { Account, AssetClass, DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import {
@ -61,6 +63,7 @@ import {
FormsModule,
GfAssetProfileIconComponent,
GfAssistantListItemComponent,
GfSymbolModule,
MatButtonModule,
MatFormFieldModule,
MatSelectModule,
@ -130,10 +133,12 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
public dateRangeFormControl = new FormControl<string>(undefined);
public dateRangeOptions: IDateRangeOption[] = [];
public filterForm = this.formBuilder.group({
account: new FormControl<string[]>(undefined),
assetClass: new FormControl<string[]>(undefined),
tag: new FormControl<string[]>(undefined)
account: new FormControl<string>(undefined),
assetClass: new FormControl<string>(undefined),
holding: new FormControl<PortfolioPosition>(undefined),
tag: new FormControl<string>(undefined)
});
public holdings: PortfolioPosition[] = [];
public isLoading = false;
public isOpen = false;
public placeholder = $localize`Find holding...`;
@ -144,7 +149,13 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
};
public tags: Filter[] = [];
private filterTypes: Filter['type'][] = ['ACCOUNT', 'ASSET_CLASS', 'TAG'];
private filterTypes: Filter['type'][] = [
'ACCOUNT',
'ASSET_CLASS',
'DATA_SOURCE',
'SYMBOL',
'TAG'
];
private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
private unsubscribeSubject = new Subject<void>();
@ -156,6 +167,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
) {}
public ngOnInit() {
this.initializeFilterForm();
this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
return {
id: assetClass,
@ -263,16 +276,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filterForm.enable({ emitEvent: false });
}
this.filterForm.setValue(
{
account: this.user?.settings?.['filters.accounts'] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses'] ?? null,
tag: this.user?.settings?.['filters.tags'] ?? null
},
{
emitEvent: false
}
);
this.initializeFilterForm();
this.tags =
this.user?.tags
@ -298,6 +302,19 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
});
}
public holdingComparisonFunction(
option: PortfolioPosition,
value: PortfolioPosition
): boolean {
if (value === null) {
return false;
}
return (
getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value)
);
}
public async initialize() {
this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
@ -322,28 +339,28 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
public onApplyFilters() {
const accountFilters =
this.filterForm
.get('account')
.value?.reduce(
(arr, val) => [...arr, { id: val, type: 'ACCOUNT' }],
[]
) ?? [];
const assetClassFilters =
this.filterForm
.get('assetClass')
.value?.reduce(
(arr, val) => [...arr, { id: val, type: 'ASSET_CLASS' }],
[]
) ?? [];
const tagFilters =
this.filterForm
.get('tag')
.value?.reduce((arr, val) => [...arr, { id: val, type: 'TAG' }], []) ??
[];
let filters = [...accountFilters, ...assetClassFilters];
filters = [...filters, ...tagFilters];
this.filtersChanged.emit(filters);
this.filtersChanged.emit([
{
id: this.filterForm.get('account').value,
type: 'ACCOUNT'
},
{
id: this.filterForm.get('assetClass').value,
type: 'ASSET_CLASS'
},
{
id: this.filterForm.get('holding').value?.dataSource,
type: 'DATA_SOURCE'
},
{
id: this.filterForm.get('holding').value?.symbol,
type: 'SYMBOL'
},
{
id: this.filterForm.get('tag').value,
type: 'TAG'
}
]);
this.onCloseAssistant();
}
@ -481,4 +498,47 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
takeUntil(this.unsubscribeSubject)
);
}
private initializeFilterForm() {
this.dataService
.fetchPortfolioHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings
.filter(({ assetSubClass }) => {
return !['CASH'].includes(assetSubClass);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
});
this.setFilterFormValues();
});
}
private setFilterFormValues() {
const dataSource = this.user?.settings?.[
'filters.dataSource'
] as DataSource;
const symbol = this.user?.settings?.['filters.symbol'];
const selectedHolding = this.holdings.find((holding) => {
return (
getAssetProfileIdentifier({
dataSource: holding.dataSource,
symbol: holding.symbol
}) === getAssetProfileIdentifier({ dataSource, symbol })
);
});
this.filterForm.setValue(
{
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
holding: selectedHolding ?? null,
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
},
{
emitEvent: false
}
);
}
}

28
libs/ui/src/lib/assistant/assistant.html

@ -122,6 +122,34 @@
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Holding</mat-label>
<mat-select
formControlName="holding"
[compareWith]="holdingComparisonFunction"
>
<mat-select-trigger>{{
filterForm.get('holding')?.value?.name
}}</mat-select-trigger>
<mat-option [value]="null" />
@for (holding of holdings; track holding.name) {
<mat-option [value]="holding">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} ·
{{ holding.currency }}</small
>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>

2
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -1,6 +1,6 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DataService } from '@ghostfolio/client/services/data.service';
import { LookupItem } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';

15
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -261,12 +261,21 @@ export class GfTreemapChartComponent
display: true,
font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter: (ctx) => {
const netPerformancePercentWithCurrencyEffect =
ctx.raw._data.netPerformancePercentWithCurrencyEffect;
// Round to 4 decimal places
let netPerformancePercentWithCurrencyEffect =
Math.round(
ctx.raw._data.netPerformancePercentWithCurrencyEffect * 10000
) / 10000;
if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) {
netPerformancePercentWithCurrencyEffect = Math.abs(
netPerformancePercentWithCurrencyEffect
);
}
return [
ctx.raw._data.symbol,
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
];
},
hoverColor: undefined,

461
package-lock.json

File diff suppressed because it is too large

12
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.119.0",
"version": "2.122.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -90,7 +90,7 @@
"@prisma/client": "5.21.1",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0",
"@stripe/stripe-js": "4.9.0",
"alphavantage": "2.2.0",
"await-lock": "^2.2.2",
"big.js": "6.2.1",
@ -109,7 +109,7 @@
"class-validator": "0.14.1",
"color": "4.2.3",
"countries-and-timezones": "3.4.1",
"countries-list": "3.1.0",
"countries-list": "3.1.1",
"countup.js": "2.8.0",
"date-fns": "3.6.0",
"envalid": "7.3.1",
@ -125,8 +125,8 @@
"ng-extract-i18n-merge": "2.12.0",
"ngx-device-detector": "8.0.0",
"ngx-markdown": "18.0.0",
"ngx-skeleton-loader": "7.0.0",
"ngx-stripe": "18.0.0",
"ngx-skeleton-loader": "9.0.0",
"ngx-stripe": "18.1.0",
"open-color": "1.9.1",
"papaparse": "5.3.1",
"passport": "0.7.0",
@ -134,7 +134,7 @@
"passport-jwt": "4.0.1",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "15.11.0",
"stripe": "17.3.0",
"svgmap": "2.6.0",
"twitter-api-v2": "1.14.2",
"uuid": "9.0.1",

2
prisma/migrations/20241029190323_added_data_provider_ghostfolio_daily_requests_to_analytics/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Analytics" ADD COLUMN "dataProviderGhostfolioDailyRequests" INTEGER NOT NULL DEFAULT 0;

5
prisma/migrations/20241102121004_added_last_request_at_to_analytics/migration.sql

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Analytics" ADD COLUMN "lastRequestAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE INDEX "Analytics_lastRequestAt_idx" ON "Analytics"("lastRequestAt");

15
prisma/schema.prisma

@ -65,12 +65,15 @@ model AccountBalance {
}
model Analytics {
activityCount Int @default(0)
country String?
updatedAt DateTime @updatedAt
userId String @id
User User @relation(fields: [userId], onDelete: Cascade, references: [id])
activityCount Int @default(0)
country String?
dataProviderGhostfolioDailyRequests Int @default(0)
lastRequestAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String @id
User User @relation(fields: [userId], onDelete: Cascade, references: [id])
@@index([lastRequestAt])
@@index([updatedAt])
}

2
test/import/ok-derived-currency.json

@ -23,7 +23,7 @@
"unitPrice": 10875.00,
"currency": "ZAc",
"dataSource": "YAHOO",
"date": "2024-06-27T22:00:00.000Z",
"date": "2024-06-28T00:00:00.000Z",
"symbol": "JSE.JO"
}
]

6
test/import/ok-vti-buy-long-history.json

@ -11,7 +11,7 @@
"unitPrice": 65.31,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2012-01-02T22:00:00.000Z",
"date": "2012-01-03T00:00:00.000Z",
"symbol": "VTI"
},
{
@ -21,7 +21,7 @@
"unitPrice": 65.40,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2011-01-02T22:00:00.000Z",
"date": "2011-01-03T00:00:00.000Z",
"symbol": "VTI"
},
{
@ -31,7 +31,7 @@
"unitPrice": 57.05,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2010-01-03T22:00:00.000Z",
"date": "2010-01-04T00:00:00.000Z",
"symbol": "VTI"
}
]

8
test/import/ok-without-accounts.json

@ -11,7 +11,7 @@
"unitPrice": 0,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z",
"date": "2050-06-06T00:00:00.000Z",
"symbol": "MSFT"
},
{
@ -21,7 +21,7 @@
"unitPrice": 500000,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z",
"date": "2022-01-01T00:00:00.000Z",
"symbol": "Penthouse Apartment"
},
{
@ -31,7 +31,7 @@
"unitPrice": 0.62,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z",
"date": "2021-11-17T00:00:00.000Z",
"symbol": "MSFT"
},
{
@ -41,7 +41,7 @@
"unitPrice": 298.58,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z",
"date": "2021-09-16T00:00:00.000Z",
"symbol": "MSFT"
}
]

10
test/import/ok.json

@ -23,7 +23,7 @@
"unitPrice": 0,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z",
"date": "2050-06-06T00:00:00.000Z",
"symbol": "US5949181045"
},
{
@ -35,7 +35,7 @@
"unitPrice": 500000,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z",
"date": "2022-01-01T00:00:00.000Z",
"symbol": "Penthouse Apartment"
},
{
@ -47,7 +47,7 @@
"unitPrice": 0.62,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z",
"date": "2021-11-17T00:00:00.000Z",
"symbol": "MSFT"
},
{
@ -59,7 +59,7 @@
"unitPrice": 298.58,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z",
"date": "2021-09-16T00:00:00.000Z",
"symbol": "MSFT"
},
{
@ -71,7 +71,7 @@
"unitPrice": 0,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2021-08-31T22:00:00.000Z",
"date": "2021-09-01T00:00:00.000Z",
"symbol": "Account Opening Fee"
}
]

2
test/import/unavailable-exchange-rate.json

@ -12,7 +12,7 @@
"unitPrice": 0,
"currency": "EUR",
"dataSource": "YAHOO",
"date": "1990-01-01T22:00:00.000Z",
"date": "1990-01-01T00:00:00.000Z",
"symbol": "MSFT"
}
]

Loading…
Cancel
Save