Browse Source

Merge branch 'main' into native-healthcheck

Signed-off-by: rare-magma <rare-magma@posteo.eu>
pull/3621/head
rare-magma 1 year ago
parent
commit
15eded863d
Failed to extract signature
  1. 27
      CHANGELOG.md
  2. 8
      Dockerfile
  3. 21
      apps/api/src/app/admin/admin.service.ts
  4. 8
      apps/api/src/app/benchmark/benchmark.controller.ts
  5. 15
      apps/api/src/app/benchmark/benchmark.service.ts
  6. 4
      apps/api/src/app/import/import.service.ts
  7. 4
      apps/api/src/app/logo/logo.service.ts
  8. 46
      apps/api/src/app/order/order.service.ts
  9. 7
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  10. 22
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  11. 7
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  12. 16
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  13. 15
      apps/api/src/app/portfolio/current-rate.service.ts
  14. 4
      apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts
  15. 55
      apps/api/src/app/portfolio/portfolio.controller.ts
  16. 78
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  17. 50
      apps/api/src/app/portfolio/portfolio.service.ts
  18. 7
      apps/api/src/app/portfolio/update-holding-tags.dto.ts
  19. 4
      apps/api/src/app/redis-cache/redis-cache.service.ts
  20. 7
      apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
  21. 6
      apps/api/src/app/symbol/symbol.service.ts
  22. 5
      apps/api/src/app/user/update-user-setting.dto.ts
  23. 5
      apps/api/src/app/user/user.service.ts
  24. 4
      apps/api/src/services/data-gathering/data-gathering.processor.ts
  25. 41
      apps/api/src/services/data-gathering/data-gathering.service.ts
  26. 14
      apps/api/src/services/data-provider/data-provider.service.ts
  27. 24
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  28. 7
      apps/api/src/services/interfaces/interfaces.ts
  29. 16
      apps/api/src/services/market-data/market-data.service.ts
  30. 12
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  31. 4
      apps/client/src/app/app.component.ts
  32. 16
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  33. 16
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  34. 15
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  35. 95
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  36. 46
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  37. 1
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  38. 44
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  39. 4
      apps/client/src/app/components/home-overview/home-overview.component.ts
  40. 6
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  41. 9
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  42. 19
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  43. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  44. 19
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  45. 8
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  46. 6
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  47. 19
      apps/client/src/app/services/admin.service.ts
  48. 26
      apps/client/src/app/services/data.service.ts
  49. 862
      apps/client/src/locales/messages.ca.xlf
  50. 486
      apps/client/src/locales/messages.de.xlf
  51. 498
      apps/client/src/locales/messages.es.xlf
  52. 486
      apps/client/src/locales/messages.fr.xlf
  53. 540
      apps/client/src/locales/messages.it.xlf
  54. 500
      apps/client/src/locales/messages.nl.xlf
  55. 550
      apps/client/src/locales/messages.pl.xlf
  56. 486
      apps/client/src/locales/messages.pt.xlf
  57. 524
      apps/client/src/locales/messages.tr.xlf
  58. 454
      apps/client/src/locales/messages.xlf
  59. 486
      apps/client/src/locales/messages.zh.xlf
  60. 20
      apps/client/src/styles/theme.scss
  61. 7
      docker/docker-compose.build.yml
  62. 5
      docker/docker-compose.dev.yml
  63. 29
      docker/docker-compose.yml
  64. 50
      libs/common/src/lib/calculation-helper.spec.ts
  65. 20
      libs/common/src/lib/calculation-helper.ts
  66. 9
      libs/common/src/lib/helper.ts
  67. 6
      libs/common/src/lib/interfaces/admin-data.interface.ts
  68. 2
      libs/common/src/lib/interfaces/asset-profile-identifier.interface.ts
  69. 4
      libs/common/src/lib/interfaces/index.ts
  70. 4
      libs/common/src/lib/interfaces/responses/errors.interface.ts
  71. 8
      libs/common/src/lib/interfaces/user-settings.interface.ts
  72. 4
      libs/common/src/lib/models/portfolio-snapshot.ts
  73. 133
      libs/common/src/lib/personal-finance-tools.ts
  74. 1
      libs/common/src/lib/types/holding-view-mode.type.ts
  75. 1
      libs/common/src/lib/types/holdings-view-mode.type.ts
  76. 4
      libs/common/src/lib/types/index.ts
  77. 6
      libs/ui/src/lib/activities-table/activities-table.component.ts
  78. 4
      libs/ui/src/lib/assistant/interfaces/interfaces.ts
  79. 13
      libs/ui/src/lib/benchmark/benchmark.component.ts
  80. 7
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  81. 7
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  82. 62
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  83. 882
      package-lock.json
  84. 24
      package.json

27
CHANGELOG.md

@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
## 2.101.0 - 2024-08-03
### Changed
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
## 2.100.0 - 2024-08-03
### Added
- Added support to manage tags of holdings in the holding detail dialog
### Changed
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
- Persisted the view mode of the holdings tab on the home page (experimental)
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Spanish (`es`)
## 2.99.0 - 2024-07-29 ## 2.99.0 - 2024-07-29
### Changed ### Changed

8
Dockerfile

@ -11,7 +11,7 @@ COPY ./package.json package.json
COPY ./package-lock.json package-lock.json COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \ RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \ g++ \
git \ git \
make \ make \
@ -50,17 +50,19 @@ RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:20-slim FROM node:20-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production
RUN apt update && apt install -y \ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
COPY ./docker/healthcheck.js /ghostfolio/healthcheck.js COPY ./docker/healthcheck.js /ghostfolio/healthcheck.js
RUN chown -R node:node /ghostfolio
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node
CMD [ "/ghostfolio/entrypoint.sh" ] CMD [ "/ghostfolio/entrypoint.sh" ]
HEALTHCHECK --interval=15s --timeout=5s --start-period=2s --retries=3 CMD [ "node", "/ghostfolio/healthcheck.js" ] HEALTHCHECK --interval=15s --timeout=5s --start-period=2s --retries=3 CMD [ "node", "/ghostfolio/healthcheck.js" ]

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

@ -21,9 +21,9 @@ import {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
@ -59,7 +59,9 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
symbol symbol
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> { }: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({ return this.symbolProfileService.add({
@ -96,7 +98,10 @@ export class AdminService {
} }
} }
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { public async deleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol });
} }
@ -325,7 +330,7 @@ export class AdminService {
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> { }: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-'; let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
@ -386,7 +391,7 @@ export class AdminService {
symbol, symbol,
symbolMapping, symbolMapping,
url url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = { const symbolProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
@ -394,8 +399,8 @@ export class AdminService {
url: url as string url: url as string
}; };
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset = const updatedSymbolProfile: AssetProfileIdentifier &
{ Prisma.SymbolProfileUpdateInput = {
comment, comment,
countries, countries,
currency, currency,

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

@ -4,9 +4,9 @@ import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; 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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -41,7 +41,9 @@ export class BenchmarkController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(
@Body() { dataSource, symbol }: AssetProfileIdentifier
) {
try { try {
const benchmark = await this.benchmarkService.addBenchmark({ const benchmark = await this.benchmarkService.addBenchmark({
dataSource, dataSource,

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

@ -17,11 +17,11 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types'; import { BenchmarkTrend } from '@ghostfolio/common/types';
@ -61,7 +61,10 @@ export class BenchmarkService {
return 0; return 0;
} }
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) { public async getBenchmarkTrends({
dataSource,
symbol
}: AssetProfileIdentifier) {
const historicalData = await this.marketDataService.marketDataItems({ const historicalData = await this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -228,7 +231,7 @@ export class BenchmarkService {
endDate?: Date; endDate?: Date;
startDate: Date; startDate: Date;
userCurrency: string; userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1; const days = differenceInDays(endDate, startDate) + 1;
@ -348,7 +351,7 @@ export class BenchmarkService {
public async addBenchmark({ public async addBenchmark({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -385,7 +388,7 @@ export class BenchmarkService {
public async deleteBenchmark({ public async deleteBenchmark({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,

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

@ -19,7 +19,7 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
OrderWithAccount, OrderWithAccount,
@ -51,7 +51,7 @@ export class ImportService {
dataSource, dataSource,
symbol, symbol,
userCurrency userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> { }: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
try { try {
const { firstBuyDate, historicalData, orders } = const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol); await this.portfolioService.getPosition(dataSource, undefined, symbol);

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

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class LogoService {
public async getLogoByDataSourceAndSymbol({ public async getLogoByDataSourceAndSymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset) { }: AssetProfileIdentifier) {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),

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

@ -11,9 +11,9 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
@ -46,6 +46,39 @@ export class OrderService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async assignTags({
dataSource,
symbol,
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
dataSource,
symbol
}
}
});
return Promise.all(
orders.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
// The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => {
return { id };
})
}
},
where: { id }
})
)
);
}
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
@ -252,7 +285,7 @@ export class OrderService {
return count; return count;
} }
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) { public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -431,7 +464,7 @@ export class OrderService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
const uniqueAssets = uniqBy( const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => { orders.map(({ SymbolProfile }) => {
return { return {
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
@ -446,8 +479,9 @@ export class OrderService {
} }
); );
const assetProfiles = const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
await this.symbolProfileService.getSymbolProfiles(uniqueAssets); assetProfileIdentifiers
);
const activities = orders.map((order) => { const activities = orders.map((order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {

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

@ -1,5 +1,8 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MWRPortfolioCalculator extends PortfolioCalculator {
@ -27,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

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

@ -19,12 +19,12 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
ResponseError, ResponseError,
SymbolMetrics, SymbolMetrics
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types'; import { DateRange, GroupBy } from '@ghostfolio/common/types';
@ -356,15 +356,15 @@ export abstract class PortfolioCalculator {
dataSource: item.dataSource, dataSource: item.dataSource,
fee: item.fee, fee: item.fee,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null ? (grossPerformancePercentage ?? null)
: null, : null,
grossPerformancePercentageWithCurrencyEffect: !hasErrors grossPerformancePercentageWithCurrencyEffect: !hasErrors
? grossPerformancePercentageWithCurrencyEffect ?? null ? (grossPerformancePercentageWithCurrencyEffect ?? null)
: null, : null,
grossPerformanceWithCurrencyEffect: !hasErrors grossPerformanceWithCurrencyEffect: !hasErrors
? grossPerformanceWithCurrencyEffect ?? null ? (grossPerformanceWithCurrencyEffect ?? null)
: null, : null,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
@ -372,15 +372,15 @@ export abstract class PortfolioCalculator {
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null, marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null, netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null ? (netPerformancePercentage ?? null)
: null, : null,
netPerformancePercentageWithCurrencyEffect: !hasErrors netPerformancePercentageWithCurrencyEffect: !hasErrors
? netPerformancePercentageWithCurrencyEffect ?? null ? (netPerformancePercentageWithCurrencyEffect ?? null)
: null, : null,
netPerformanceWithCurrencyEffect: !hasErrors netPerformanceWithCurrencyEffect: !hasErrors
? netPerformanceWithCurrencyEffect ?? null ? (netPerformanceWithCurrencyEffect ?? null)
: null, : null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
@ -905,7 +905,7 @@ export abstract class PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics; } & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() { public getTransactionPoints() {
return this.transactionPoints; return this.transactionPoints;

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

@ -2,7 +2,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -151,7 +154,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {}; const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};

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

@ -1,7 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
}); });
}, },
getRange: ({ getRange: ({
assetProfileIdentifiers,
dateRangeEnd, dateRangeEnd,
dateRangeStart, dateRangeStart
uniqueAssets
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date; dateRangeEnd: Date;
dateRangeStart: Date; dateRangeStart: Date;
uniqueAssets: UniqueAsset[];
}) => { }) => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: uniqueAssets[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE', state: 'CLOSE',
symbol: uniqueAssets[0].symbol symbol: assetProfileIdentifiers[0].symbol
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: uniqueAssets[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE', state: 'CLOSE',
symbol: uniqueAssets[0].symbol symbol: assetProfileIdentifiers[0].symbol
} }
]); ]);
} }

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

@ -3,9 +3,9 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
ResponseError, ResponseError
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -80,17 +80,16 @@ export class CurrentRateService {
); );
} }
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map( const assetProfileIdentifiers: AssetProfileIdentifier[] =
({ dataSource, symbol }) => { dataGatheringItems.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
} });
);
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
dateQuery, assetProfileIdentifiers,
uniqueAssets dateQuery
}) })
.then((data) => { .then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => { return data.map(({ dataSource, date, marketPrice, symbol }) => {

4
apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts

@ -1,6 +1,6 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export interface GetValueObject extends UniqueAsset { export interface GetValueObject extends AssetProfileIdentifier {
date: Date; date: Date;
marketPrice: number; marketPrice: number;
} }

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

@ -1,6 +1,7 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
@ -29,7 +30,8 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
isRestrictedView isRestrictedView,
permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { import type {
DateRange, DateRange,
@ -38,12 +40,14 @@ import type {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
Headers, Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
Put,
Query, Query,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -51,12 +55,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -566,25 +571,25 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition( public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol @Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> { ): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition( const holding = await this.portfolioService.getPosition(
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol
); );
if (position) { if (!holding) {
return position;
}
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
); );
} }
return holding;
}
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@ -605,4 +610,36 @@ export class PortfolioController {
return report; return report;
} }
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
} }

78
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -1,78 +0,0 @@
import { Big } from 'big.js';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let portfolioService: PortfolioService;
beforeAll(async () => {
portfolioService = new PortfolioService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
});
describe('annualized performance percentage', () => {
it('Get annualized performance', async () => {
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercentage: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercentage: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercentage: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

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

@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
@ -58,7 +59,8 @@ import {
DataSource, DataSource,
Order, Order,
Platform, Platform,
Prisma Prisma,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
@ -70,7 +72,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; import { isEmpty, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { import {
@ -206,24 +208,6 @@ export class PortfolioService {
}; };
} }
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage
}: {
daysInMarket: number;
netPerformancePercentage: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public async getDividends({ public async getDividends({
activities, activities,
groupBy groupBy
@ -713,7 +697,7 @@ export class PortfolioService {
return Account; return Account;
}); });
const dividendYieldPercent = this.getAnnualizedPerformancePercent({ const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0) netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0) ? new Big(0)
@ -721,7 +705,7 @@ export class PortfolioService {
}); });
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0 0
@ -1321,6 +1305,24 @@ export class PortfolioService {
}; };
} }
public async updateTags({
dataSource,
impersonationId,
symbol,
tags,
userId
}: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
tags: Tag[];
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
@ -1724,13 +1726,13 @@ export class PortfolioService {
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercentage: new Big(netPerformancePercentage) netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber(); })?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect = const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercentage: new Big( netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect netPerformancePercentageWithCurrencyEffect

7
apps/api/src/app/portfolio/update-holding-tags.dto.ts

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

4
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -28,7 +28,7 @@ export class RedisCacheService {
return `portfolio-snapshot-${userId}`; return `portfolio-snapshot-${userId}`;
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }

7
apps/api/src/app/symbol/interfaces/symbol-item.interface.ts

@ -1,6 +1,9 @@
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
export interface SymbolItem extends UniqueAsset { export interface SymbolItem extends AssetProfileIdentifier {
currency: string; currency: string;
historicalData: HistoricalDataItem[]; historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;

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

@ -40,13 +40,13 @@ export class SymbolService {
const days = includeHistoricalData; const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource: dataGatheringItem.dataSource, dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
} }
] ],
dateQuery: { gte: subDays(new Date(), days) }
}); });
historicalData = marketData.map(({ date, marketPrice: value }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {

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

@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import type { import type {
ColorScheme, ColorScheme,
DateRange, DateRange,
HoldingsViewMode,
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;

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

@ -190,7 +190,7 @@ export class UserService {
(user.Settings.settings as UserSettings).dateRange = (user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN' (user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max' ? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max'; : ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for view mode // Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) { if (!(user.Settings.settings as UserSettings).viewMode) {
@ -243,6 +243,9 @@ export class UserService {
// Reset benchmark // Reset benchmark
user.Settings.settings.benchmark = undefined; user.Settings.settings.benchmark = undefined;
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);

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

@ -7,7 +7,7 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
) {} ) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) { public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try { try {
Logger.log( Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`, `Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,

41
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -20,7 +20,10 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
BenchmarkProperty
} from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -91,7 +94,7 @@ export class DataGatheringService {
}); });
} }
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const dataGatheringItems = (await this.getSymbolsMax()).filter( const dataGatheringItems = (await this.getSymbolsMax()).filter(
@ -146,23 +149,29 @@ export class DataGatheringService {
} }
} }
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) { public async gatherAssetProfiles(
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => { aAssetProfileIdentifiers?: AssetProfileIdentifier[]
) {
let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL'; return dataGatheringItem.dataSource !== 'MANUAL';
}); }
);
if (!uniqueAssets) { if (!assetProfileIdentifiers) {
uniqueAssets = await this.getAllAssetProfileIdentifiers(); assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
} }
if (uniqueAssets.length <= 0) { if (assetProfileIdentifiers.length <= 0) {
return; return;
} }
const assetProfiles = const assetProfiles = await this.dataProviderService.getAssetProfiles(
await this.dataProviderService.getAssetProfiles(uniqueAssets); assetProfileIdentifiers
const symbolProfiles = );
await this.symbolProfileService.getSymbolProfiles(uniqueAssets); const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -248,7 +257,7 @@ export class DataGatheringService {
'DataGatheringService' 'DataGatheringService'
); );
if (uniqueAssets.length === 1) { if (assetProfileIdentifiers.length === 1) {
throw error; throw error;
} }
} }
@ -284,7 +293,9 @@ export class DataGatheringService {
); );
} }
public async getAllAssetProfileIdentifiers(): Promise<UniqueAsset[]> { public async getAllAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }] orderBy: [{ symbol: 'asc' }]
}); });
@ -305,7 +316,7 @@ export class DataGatheringService {
} }
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise< private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
UniqueAsset[] AssetProfileIdentifier[]
> { > {
return ( return (
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({

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

@ -20,7 +20,7 @@ import {
getStartOfUtcDate, getStartOfUtcDate,
isDerivedCurrency isDerivedCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -75,7 +75,7 @@ export class DataProviderService {
return false; return false;
} }
public async getAssetProfiles(items: UniqueAsset[]): Promise<{ public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[symbol: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
}> { }> {
const response: { const response: {
@ -173,7 +173,7 @@ export class DataProviderService {
} }
public async getHistorical( public async getHistorical(
aItems: UniqueAsset[], aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
@ -243,7 +243,7 @@ export class DataProviderService {
from, from,
to to
}: { }: {
dataGatheringItems: UniqueAsset[]; dataGatheringItems: AssetProfileIdentifier[];
from: Date; from: Date;
to: Date; to: Date;
}): Promise<{ }): Promise<{
@ -350,7 +350,7 @@ export class DataProviderService {
useCache = true, useCache = true,
user user
}: { }: {
items: UniqueAsset[]; items: AssetProfileIdentifier[];
requestTimeout?: number; requestTimeout?: number;
useCache?: boolean; useCache?: boolean;
user?: UserWithSettings; user?: UserWithSettings;
@ -376,7 +376,7 @@ export class DataProviderService {
} }
// Get items from cache // Get items from cache
const itemsToFetch: UniqueAsset[] = []; const itemsToFetch: AssetProfileIdentifier[] = [];
for (const { dataSource, symbol } of items) { for (const { dataSource, symbol } of items) {
if (useCache) { if (useCache) {
@ -633,7 +633,7 @@ export class DataProviderService {
dataGatheringItems dataGatheringItems
}: { }: {
currency: string; currency: string;
dataGatheringItems: UniqueAsset[]; dataGatheringItems: AssetProfileIdentifier[];
}) { }) {
return dataGatheringItems.some(({ dataSource, symbol }) => { return dataGatheringItems.some(({ dataSource, symbol }) => {
return ( return (

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

@ -361,13 +361,13 @@ export class ExchangeRateDataService {
const symbol = `${currencyFrom}${currencyTo}`; const symbol = `${currencyFrom}${currencyTo}`;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource, dataSource,
symbol symbol
} }
] ],
dateQuery: { gte: startDate, lt: endDate }
}); });
if (marketData?.length > 0) { if (marketData?.length > 0) {
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
} }
} else { } else {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource, dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}` symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
} }
] ],
dateQuery: { gte: startDate, lt: endDate }
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
} }
} else { } else {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { assetProfileIdentifiers: [
gte: startDate,
lt: endDate
},
uniqueAssets: [
{ {
dataSource, dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}` symbol: `${DEFAULT_CURRENCY}${currencyTo}`
} }
] ],
dateQuery: {
gte: startDate,
lt: endDate
}
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {

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

@ -1,4 +1,7 @@
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
DataProviderInfo
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { import {
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
marketState: MarketState; marketState: MarketState;
} }
export interface IDataGatheringItem extends UniqueAsset { export interface IDataGatheringItem extends AssetProfileIdentifier {
date?: Date; date?: Date;
} }

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

@ -3,7 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
@ -17,7 +17,7 @@ import {
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({ dataSource, symbol }: UniqueAsset) { public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
dataSource, dataSource,
@ -40,7 +40,7 @@ export class MarketDataService {
}); });
} }
public async getMax({ dataSource, symbol }: UniqueAsset) { public async getMax({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.findFirst({ return this.prismaService.marketData.findFirst({
select: { select: {
date: true, date: true,
@ -59,11 +59,11 @@ export class MarketDataService {
} }
public async getRange({ public async getRange({
dateQuery, assetProfileIdentifiers,
uniqueAssets dateQuery
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery; dateQuery: DateQuery;
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
return this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: [ orderBy: [
@ -76,13 +76,13 @@ export class MarketDataService {
], ],
where: { where: {
dataSource: { dataSource: {
in: uniqueAssets.map(({ dataSource }) => { in: assetProfileIdentifiers.map(({ dataSource }) => {
return dataSource; return dataSource;
}) })
}, },
date: dateQuery, date: dateQuery,
symbol: { symbol: {
in: uniqueAssets.map(({ symbol }) => { in: assetProfileIdentifiers.map(({ symbol }) => {
return symbol; return symbol;
}) })
} }

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

@ -1,10 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Holding, Holding,
ScraperConfiguration, ScraperConfiguration
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -23,7 +23,7 @@ export class SymbolProfileService {
return this.prismaService.symbolProfile.create({ data: assetProfile }); return this.prismaService.symbolProfile.create({ data: assetProfile });
} }
public async delete({ dataSource, symbol }: UniqueAsset) { public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
@ -36,7 +36,7 @@ export class SymbolProfileService {
} }
public async getSymbolProfiles( public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
@ -54,7 +54,7 @@ export class SymbolProfileService {
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
OR: aUniqueAssets.map(({ dataSource, symbol }) => { OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol symbol
@ -140,7 +140,7 @@ export class SymbolProfileService {
symbolMapping, symbolMapping,
SymbolProfileOverrides, SymbolProfileOverrides,
url url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { data: {
assetClass, assetClass,

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

@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
this.user?.permissions, this.user?.permissions,
permissions.reportDataGlitch permissions.reportDataGlitch
), ),
hasPermissionToUpdateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) &&
!user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

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

@ -7,9 +7,9 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
Filter, Filter,
InfoItem, InfoItem,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
@ -225,7 +225,7 @@ export class AdminMarketDataComponent
}); });
} }
public onDeleteAssetProfile({ dataSource, symbol }: UniqueAsset) { public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
} }
@ -266,21 +266,27 @@ export class AdminMarketDataComponent
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) { public onOpenAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { queryParams: {
dataSource, dataSource,

16
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -2,8 +2,8 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataItem, AssetProfileIdentifier,
UniqueAsset AdminMarketDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
@ -13,7 +13,7 @@ import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
export class AdminMarketDataService { export class AdminMarketDataService {
public constructor(private adminService: AdminService) {} public constructor(private adminService: AdminService) {}
public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) { public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const confirmation = confirm( const confirmation = confirm(
$localize`Do you really want to delete this asset profile?` $localize`Do you really want to delete this asset profile?`
); );
@ -29,15 +29,19 @@ export class AdminMarketDataService {
} }
} }
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) { public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
) {
const confirmation = confirm( const confirmation = confirm(
$localize`Do you really want to delete these profiles?` $localize`Do you really want to delete these profiles?`
); );
if (confirmation) { if (confirmation) {
const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => { const deleteRequests = aAssetProfileIdentifiers.map(
({ dataSource, symbol }) => {
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
}); }
);
forkJoin(deleteRequests) forkJoin(deleteRequests)
.pipe( .pipe(

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

@ -8,7 +8,7 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
UniqueAsset AssetProfileIdentifier
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -175,20 +175,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close(); this.dialogRef.close();
} }
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -242,7 +245,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
} }
public onSetBenchmark({ dataSource, symbol }: UniqueAsset) { public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.postBenchmark({ dataSource, symbol }) .postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -342,7 +345,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}); });
} }
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) { public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.deleteBenchmark({ dataSource, symbol }) .deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

95
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -19,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit OnInit,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import {
MatAutocompleteModule,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { import {
@ -36,14 +44,15 @@ import {
MatDialogModule, MatDialogModule,
MatDialogRef MatDialogRef
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Observable, of, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { map, startWith, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfValueComponent, GfValueComponent,
MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule, MatChipsModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule,
MatTabsModule, MatTabsModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup;
public accounts: Account[]; public accounts: Account[];
public activities: Activity[]; public activities: Activity[];
public assetClass: string; public assetClass: string;
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendInBaseCurrencyPrecision = 2; public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public filteredTagsObservable: Observable<Tag[]> = of([]);
public firstBuyDate: string; public firstBuyDate: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public separatorKeysCodes: number[] = [COMMA, ENTER];
public sortColumn = 'date'; public sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; public tags: Tag[];
public tagsAvailable: Tag[];
public totalItems: number; public totalItems: number;
public transactionCount: number; public transactionCount: number;
public user: User; public user: User;
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.activityForm = this.formBuilder.group({
tags: <string[]>[]
});
this.tagsAvailable = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
};
});
this.activityForm
.get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.dataService
.putHoldingTags({
tags,
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
this.dataService this.dataService
.fetchHoldingDetail({ .fetchHoldingDetail({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -248,12 +293,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {}; this.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
this.tags = tags.map(({ id, name }) => { this.tags = tags.map(({ id, name }) => {
return { return {
id, id,
name: translate(name) name: translate(name)
}; };
}); });
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
this.filteredTagsObservable = this.activityForm.controls[
'tags'
].valueChanges.pipe(
startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => {
return aTags
? this.filterTags(aTags)
: this.tagsAvailable.slice();
})
);
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.totalItems = transactionCount; this.totalItems = transactionCount;
this.value = value; this.value = value;
@ -353,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []),
this.tagsAvailable.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -377,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public onRemoveTag(aTag: Tag) {
this.activityForm.get('tags').setValue(
this.activityForm.get('tags').value.filter(({ id }) => {
return id !== aTag.id;
})
);
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(id);
});
}
} }

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

@ -325,7 +325,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0" animationDuration="0"
class="mb-3" class="mb-5"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }" [ngClass]="{ 'd-none': !activities?.length }"
> >
@ -375,7 +375,49 @@
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
@if (tags?.length > 0) { <div
class="row"
[ngClass]="{
'd-none': !data.hasPermissionToUpdateOrder
}"
>
<div class="col">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag.id) {
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline" />
</mat-chip-row>
}
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
</div>
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="h5" i18n>Tags</div> <div class="h5" i18n>Tags</div>

1
apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts

@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
deviceType: string; deviceType: string;
hasImpersonationId: boolean; hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean; hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
locale: string; locale: string;
symbol: string; symbol: string;
} }

44
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -2,14 +2,14 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
HoldingType, HoldingType,
HoldingViewMode, HoldingsViewMode,
ToggleOption ToggleOption
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -18,7 +18,7 @@ import { FormControl } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { skip, takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-home-holdings', selector: 'gf-home-holdings',
@ -26,6 +26,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-holdings.html' templateUrl: './home-holdings.html'
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean; public hasPermissionToAccessHoldingsChart: boolean;
@ -37,7 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
public user: User; public user: User;
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE'); public viewModeFormControl = new FormControl<HoldingsViewMode>(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -81,6 +85,21 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
this.viewModeFormControl.valueChanges
.pipe(
// Skip inizialization: "new FormControl"
skip(1),
takeUntil(this.unsubscribeSubject)
)
.subscribe((holdingsViewMode) => {
this.dataService
.putUserSetting({ holdingsViewMode })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
});
});
} }
public onChangeHoldingType(aHoldingType: HoldingType) { public onChangeHoldingType(aHoldingType: HoldingType) {
@ -89,7 +108,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.initialize(); this.initialize();
} }
public onSymbolClicked({ dataSource, symbol }: UniqueAsset) { public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -122,9 +141,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.hasPermissionToAccessHoldingsChart && this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE' this.holdingType === 'ACTIVE'
) { ) {
this.viewModeFormControl.enable(); this.viewModeFormControl.enable({ emitEvent: false });
this.viewModeFormControl.setValue(
this.deviceType === 'mobile'
? HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: this.user?.settings?.holdingsViewMode ||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
} else if (this.holdingType === 'CLOSED') { } else if (this.holdingType === 'CLOSED') {
this.viewModeFormControl.setValue('TABLE'); this.viewModeFormControl.setValue(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
} }
this.holdings = undefined; this.holdings = undefined;

4
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -5,9 +5,9 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config'; import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier,
LineChartItem, LineChartItem,
PortfolioPerformance, PortfolioPerformance,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeOverviewComponent implements OnDestroy, OnInit { export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public errors: UniqueAsset[]; public errors: AssetProfileIdentifier[];
public hasError: boolean; public hasError: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;

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

@ -70,16 +70,16 @@
" "
> >
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<!-- <!--
<mat-option value="de" <mat-option value="ca"
>Català (<ng-container i18n>Community</ng-container >Català (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
--> -->
} }
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<mat-option value="zh" <mat-option value="zh"
>Chinese (<ng-container i18n>Community</ng-container >Chinese (<ng-container i18n>Community</ng-container

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

@ -33,7 +33,6 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
templateUrl: './activities-page.html' templateUrl: './activities-page.html'
}) })
export class ActivitiesPageComponent implements OnDestroy, OnInit { export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[];
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
@ -66,13 +65,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateActivityDialog(); this.openCreateActivityDialog();
} else if (params['editDialog']) { } else if (params['editDialog']) {
if (this.activities) { if (this.dataSource && params['activityId']) {
const activity = this.activities.find(({ id }) => {
return id === params['activityId'];
});
this.openUpdateActivityDialog(activity);
} else if (this.dataSource) {
const activity = this.dataSource.data.find(({ id }) => { const activity = this.dataSource.data.find(({ id }) => {
return id === params['activityId']; return id === params['activityId'];
}); });

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

@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public isToday = isToday; public isToday = isToday;
public mode: 'create' | 'update'; public mode: 'create' | 'update';
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [COMMA, ENTER];
public tags: Tag[] = []; public tagsAvailable: Tag[] = [];
public total = 0; public total = 0;
public typesTranslationMap = new Map<Type, string>(); public typesTranslationMap = new Map<Type, string>();
public Validators = Validators; public Validators = Validators;
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms; this.platforms = platforms;
this.tags = tags.map(({ id, name }) => { this.tagsAvailable = tags.map(({ id, name }) => {
return { return {
id, id,
name: translate(name) name: translate(name)
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
].valueChanges.pipe( ].valueChanges.pipe(
startWith(this.activityForm.get('tags').value), startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => { map((aTags: Tag[] | null) => {
return aTags ? this.filterTags(aTags) : this.tags.slice(); return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
}) })
); );
@ -441,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public onAddTag(event: MatAutocompleteSelectedEvent) { public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.get('tags').setValue([ this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []), ...(this.activityForm.get('tags').value ?? []),
this.tags.find(({ id }) => { this.tagsAvailable.find(({ id }) => {
return id === event.option.value; return id === event.option.value;
}) })
]); ]);
this.tagInput.nativeElement.value = ''; this.tagInput.nativeElement.value = '';
} }
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
} }
private filterTags(aTags: Tag[]) { private filterTags(aTags: Tag[]) {
const tagIds = aTags.map((tag) => { const tagIds = aTags.map(({ id }) => {
return tag.id; return id;
}); });
return this.tags.filter((tag) => { return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(tag.id); return !tagIds.includes(id);
}); });
} }

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

@ -378,11 +378,11 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }"> <div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList> <mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag) { @for (tag of activityForm.get('tags')?.value; track tag.id) {
<mat-chip-row <mat-chip-row
matChipRemove matChipRemove
[removable]="true" [removable]="true"
@ -404,7 +404,7 @@
#autocompleteTags="matAutocomplete" #autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)" (optionSelected)="onAddTag($event)"
> >
@for (tag of filteredTagsObservable | async; track tag) { @for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id"> <mat-option [value]="tag.id">
{{ tag.name }} {{ tag.name }}
</mat-option> </mat-option>

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

@ -38,6 +38,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
export class ImportActivitiesDialog implements OnDestroy { export class ImportActivitiesDialog implements OnDestroy {
public accounts: CreateAccountDto[] = []; public accounts: CreateAccountDto[] = [];
public activities: Activity[] = []; public activities: Activity[] = [];
public assetProfileForm: FormGroup;
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public details: any[] = []; public details: any[] = [];
public deviceType: string; public deviceType: string;
@ -53,7 +54,6 @@ export class ImportActivitiesDialog implements OnDestroy {
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public stepperOrientation: StepperOrientation; public stepperOrientation: StepperOrientation;
public totalItems: number; public totalItems: number;
public uniqueAssetForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -73,8 +73,8 @@ export class ImportActivitiesDialog implements OnDestroy {
this.stepperOrientation = this.stepperOrientation =
this.deviceType === 'mobile' ? 'vertical' : 'horizontal'; this.deviceType === 'mobile' ? 'vertical' : 'horizontal';
this.uniqueAssetForm = this.formBuilder.group({ this.assetProfileForm = this.formBuilder.group({
uniqueAsset: [undefined, Validators.required] assetProfileIdentifier: [undefined, Validators.required]
}); });
if ( if (
@ -85,7 +85,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.dialogTitle = $localize`Import Dividends`; this.dialogTitle = $localize`Import Dividends`;
this.mode = 'DIVIDEND'; this.mode = 'DIVIDEND';
this.uniqueAssetForm.get('uniqueAsset').disable(); this.assetProfileForm.get('assetProfileIdentifier').disable();
this.dataService this.dataService
.fetchPortfolioHoldings({ .fetchPortfolioHoldings({
@ -102,7 +102,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.holdings = sortBy(holdings, ({ name }) => { this.holdings = sortBy(holdings, ({ name }) => {
return name.toLowerCase(); return name.toLowerCase();
}); });
this.uniqueAssetForm.get('uniqueAsset').enable(); this.assetProfileForm.get('assetProfileIdentifier').enable();
this.isLoading = false; this.isLoading = false;
@ -167,10 +167,11 @@ export class ImportActivitiesDialog implements OnDestroy {
} }
public onLoadDividends(aStepper: MatStepper) { public onLoadDividends(aStepper: MatStepper) {
this.uniqueAssetForm.get('uniqueAsset').disable(); this.assetProfileForm.get('assetProfileIdentifier').disable();
const { dataSource, symbol } = const { dataSource, symbol } = this.assetProfileForm.get(
this.uniqueAssetForm.get('uniqueAsset').value; 'assetProfileIdentifier'
).value;
this.dataService this.dataService
.fetchDividendsImport({ .fetchDividendsImport({
@ -193,7 +194,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.details = []; this.details = [];
this.errorMessages = []; this.errorMessages = [];
this.importStep = ImportStep.SELECT_ACTIVITIES; this.importStep = ImportStep.SELECT_ACTIVITIES;
this.uniqueAssetForm.get('uniqueAsset').enable(); this.assetProfileForm.get('assetProfileIdentifier').enable();
aStepper.reset(); aStepper.reset();
} }

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

@ -25,14 +25,14 @@
<div class="pt-3"> <div class="pt-3">
@if (mode === 'DIVIDEND') { @if (mode === 'DIVIDEND') {
<form <form
[formGroup]="uniqueAssetForm" [formGroup]="assetProfileForm"
(ngSubmit)="onLoadDividends(stepper)" (ngSubmit)="onLoadDividends(stepper)"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label> <mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset"> <mat-select formControlName="assetProfileIdentifier">
<mat-select-trigger>{{ <mat-select-trigger>{{
uniqueAssetForm.get('uniqueAsset')?.value?.name assetProfileForm.get('assetProfileIdentifier')?.value?.name
}}</mat-select-trigger> }}</mat-select-trigger>
@for (holding of holdings; track holding) { @for (holding of holdings; track holding) {
<mat-option <mat-option
@ -63,7 +63,7 @@
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!uniqueAssetForm.valid" [disabled]="!assetProfileForm.valid"
> >
<span i18n>Load Dividends</span> <span i18n>Load Dividends</span>
</button> </button>

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

@ -6,10 +6,10 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
Holding, Holding,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced } from '@ghostfolio/common/types';
@ -161,7 +161,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.initialize(); this.initialize();
} }
public onAccountChartClicked({ symbol }: UniqueAsset) { public onAccountChartClicked({ symbol }: AssetProfileIdentifier) {
if (symbol && symbol !== UNKNOWN_KEY) { if (symbol && symbol !== UNKNOWN_KEY) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { accountId: symbol, accountDetailDialog: true } queryParams: { accountId: symbol, accountDetailDialog: true }
@ -169,7 +169,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
} }
} }
public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) { public onSymbolChartClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }

19
apps/client/src/app/services/admin.service.ts

@ -7,13 +7,13 @@ import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
AdminData, AdminData,
AdminJobs, AdminJobs,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
@ -35,7 +35,7 @@ export class AdminService {
private http: HttpClient private http: HttpClient
) {} ) {}
public addAssetProfile({ dataSource, symbol }: UniqueAsset) { public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.post<void>( return this.http.post<void>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/profile-data/${dataSource}/${symbol}`,
null null
@ -62,7 +62,7 @@ export class AdminService {
return this.http.delete<void>(`/api/v1/platform/${aId}`); return this.http.delete<void>(`/api/v1/platform/${aId}`);
} }
public deleteProfileData({ dataSource, symbol }: UniqueAsset) { public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<void>( return this.http.delete<void>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}` `/api/v1/admin/profile-data/${dataSource}/${symbol}`
); );
@ -167,7 +167,10 @@ export class AdminService {
return this.http.post<void>('/api/v1/admin/gather/profile-data', {}); return this.http.post<void>('/api/v1/admin/gather/profile-data', {});
} }
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public gatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.http.post<void>( return this.http.post<void>(
`/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`,
{} {}
@ -178,7 +181,7 @@ export class AdminService {
dataSource, dataSource,
date, date,
symbol symbol
}: UniqueAsset & { }: AssetProfileIdentifier & {
date?: Date; date?: Date;
}) { }) {
let url = `/api/v1/admin/gather/${dataSource}/${symbol}`; let url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
@ -217,7 +220,7 @@ export class AdminService {
symbol, symbol,
symbolMapping, symbolMapping,
url url
}: UniqueAsset & UpdateAssetProfileDto) { }: AssetProfileIdentifier & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>( return this.http.patch<EnhancedSymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/profile-data/${dataSource}/${symbol}`,
{ {
@ -272,7 +275,7 @@ export class AdminService {
dataSource, dataSource,
scraperConfiguration, scraperConfiguration,
symbol symbol
}: UniqueAsset & UpdateAssetProfileDto['scraperConfiguration']) { }: AssetProfileIdentifier & UpdateAssetProfileDto['scraperConfiguration']) {
return this.http.post<any>( return this.http.post<any>(
`/api/v1/admin/market-data/${dataSource}/${symbol}/test`, `/api/v1/admin/market-data/${dataSource}/${symbol}/test`,
{ {

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

@ -20,6 +20,7 @@ import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts, Accounts,
AdminMarketDataDetails, AdminMarketDataDetails,
AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
Export, Export,
@ -34,7 +35,6 @@ import {
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
import { import {
AccountBalance, AccountBalance,
DataSource, DataSource,
Order as OrderModel Order as OrderModel,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy, isNumber } from 'lodash'; import { cloneDeep, groupBy, isNumber } from 'lodash';
@ -229,7 +230,7 @@ export class DataService {
}); });
} }
public fetchDividendsImport({ dataSource, symbol }: UniqueAsset) { public fetchDividendsImport({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.get<ImportResponse>( return this.http.get<ImportResponse>(
`/api/v1/import/dividends/${dataSource}/${symbol}` `/api/v1/import/dividends/${dataSource}/${symbol}`
); );
@ -269,7 +270,7 @@ export class DataService {
return this.http.delete<any>(`/api/v1/order/${aId}`); return this.http.delete<any>(`/api/v1/order/${aId}`);
} }
public deleteBenchmark({ dataSource, symbol }: UniqueAsset) { public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`); return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
} }
@ -288,7 +289,7 @@ export class DataService {
public fetchAsset({ public fetchAsset({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Observable<AdminMarketDataDetails> { }: AssetProfileIdentifier): Observable<AdminMarketDataDetails> {
return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe( return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe(
map((data) => { map((data) => {
for (const item of data.marketData) { for (const item of data.marketData) {
@ -307,7 +308,7 @@ export class DataService {
}: { }: {
range: DateRange; range: DateRange;
startDate: Date; startDate: Date;
} & UniqueAsset): Observable<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> {
let params = new HttpParams(); let params = new HttpParams();
if (range) { if (range) {
@ -629,7 +630,7 @@ export class DataService {
); );
} }
public postBenchmark(benchmark: UniqueAsset) { public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post(`/api/v1/benchmark`, benchmark); return this.http.post(`/api/v1/benchmark`, benchmark);
} }
@ -649,6 +650,17 @@ export class DataService {
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData); return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
} }
public putHoldingTags({
dataSource,
symbol,
tags
}: { tags: Tag[] } & AssetProfileIdentifier) {
return this.http.put<void>(
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
{ tags }
);
}
public putOrder(aOrder: UpdateOrderDto) { public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder); return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
} }

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

20
apps/client/src/styles/theme.scss

@ -73,40 +73,40 @@ $gf-secondary: (
$gf-typography: mat.m2-define-typography-config(); $gf-typography: mat.m2-define-typography-config();
@include mat.core();
// Create default theme // Create default theme
$gf-theme-default: mat.m2-define-light-theme( $gf-theme-default: mat.m2-define-light-theme(
( (
color: ( color: (
primary: mat.m2-define-palette($gf-primary), accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100) primary: mat.m2-define-palette($gf-primary)
), ),
density: -3, density: -3,
typography: $gf-typography typography: $gf-typography
) )
); );
@include mat.all-component-themes($gf-theme-default); @include mat.all-component-themes($gf-theme-default);
@include mat.button-density(0);
@include mat.table-density(-1);
// Create dark theme // Create dark theme
$gf-theme-dark: mat.m2-define-dark-theme( $gf-theme-dark: mat.m2-define-dark-theme(
( (
color: ( color: (
primary: mat.m2-define-palette($gf-primary), accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100) primary: mat.m2-define-palette($gf-primary)
), ),
density: -3, density: -3,
typography: $gf-typography typography: $gf-typography
) )
); );
.is-dark-theme { .is-dark-theme {
@include mat.all-component-colors($gf-theme-dark); @include mat.all-component-colors($gf-theme-dark);
@include mat.button-density(0);
@include mat.table-density(-1);
} }
@include mat.button-density(0);
@include mat.core();
@include mat.table-density(-1);
:root { :root {
--gf-theme-alpha-hover: 0.04; --gf-theme-alpha-hover: 0.04;
--gf-theme-primary-500: #36cfcc; --gf-theme-primary-500: #36cfcc;

7
docker/docker-compose.build.yml

@ -6,7 +6,6 @@ services:
- ../.env - ../.env
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
NODE_ENV: production
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
ports: ports:
@ -16,8 +15,9 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
postgres: postgres:
image: postgres:15 image: docker.io/library/postgres:15
env_file: env_file:
- ../.env - ../.env
healthcheck: healthcheck:
@ -27,8 +27,9 @@ services:
retries: 5 retries: 5
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
redis: redis:
image: redis:alpine image: docker.io/library/redis:alpine
env_file: env_file:
- ../.env - ../.env
command: ['redis-server', '--requirepass', $REDIS_PASSWORD] command: ['redis-server', '--requirepass', $REDIS_PASSWORD]

5
docker/docker-compose.dev.yml

@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:15 image: docker.io/library/postgres:15
container_name: postgres container_name: postgres
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@ -9,8 +9,9 @@ services:
- ${POSTGRES_PORT:-5432}:5432 - ${POSTGRES_PORT:-5432}:5432
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
redis: redis:
image: redis:alpine image: docker.io/library/redis:alpine
container_name: redis container_name: redis
restart: unless-stopped restart: unless-stopped
env_file: env_file:

29
docker/docker-compose.yml

@ -1,12 +1,16 @@
services: services:
ghostfolio: ghostfolio:
image: ghostfolio/ghostfolio:latest image: docker.io/ghostfolio/ghostfolio:latest
init: true init: true
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file: env_file:
- ../.env - ../.env
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
NODE_ENV: production
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
ports: ports:
@ -16,8 +20,19 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
postgres: postgres:
image: postgres:15 image: docker.io/library/postgres:15
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_READ_SEARCH
- FOWNER
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
env_file: env_file:
- ../.env - ../.env
healthcheck: healthcheck:
@ -27,8 +42,14 @@ services:
retries: 5 retries: 5
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
redis: redis:
image: redis:alpine image: docker.io/library/redis:alpine
user: '999:1000'
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file: env_file:
- ../.env - ../.env
command: ['redis-server', '--requirepass', $REDIS_PASSWORD] command: ['redis-server', '--requirepass', $REDIS_PASSWORD]

50
libs/common/src/lib/calculation-helper.spec.ts

@ -0,0 +1,50 @@
import { Big } from 'big.js';
import { getAnnualizedPerformancePercent } from './calculation-helper';
describe('CalculationHelper', () => {
describe('annualized performance percentage', () => {
it('Get annualized performance', async () => {
expect(
getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercentage: new Big(0)
}).toNumber()
).toEqual(0);
expect(
getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercentage: new Big(0)
}).toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercentage: new Big(0.1025)
}).toNumber()
).toBeCloseTo(0.729705);
expect(
getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercentage: new Big(0.05)
}).toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercentage: new Big(0.2374)
}).toNumber()
).toBeCloseTo(0.145);
});
});
});

20
libs/common/src/lib/calculation-helper.ts

@ -0,0 +1,20 @@
import { Big } from 'big.js';
import { isNumber } from 'lodash';
export function getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage
}: {
daysInMarket: number;
netPerformancePercentage: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}

9
libs/common/src/lib/helper.ts

@ -19,7 +19,7 @@ import {
ghostfolioScraperApiSymbolPrefix, ghostfolioScraperApiSymbolPrefix,
locale locale
} from './config'; } from './config';
import { Benchmark, UniqueAsset } from './interfaces'; import { AssetProfileIdentifier, Benchmark } from './interfaces';
import { BenchmarkTrend, ColorScheme } from './types'; import { BenchmarkTrend, ColorScheme } from './types';
export const DATE_FORMAT = 'yyyy-MM-dd'; export const DATE_FORMAT = 'yyyy-MM-dd';
@ -147,7 +147,10 @@ export function getAllActivityTypes(): ActivityType[] {
return Object.values(ActivityType); return Object.values(ActivityType);
} }
export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) { export function getAssetProfileIdentifier({
dataSource,
symbol
}: AssetProfileIdentifier) {
return `${dataSource}-${symbol}`; return `${dataSource}-${symbol}`;
} }
@ -377,7 +380,7 @@ export function parseDate(date: string): Date | null {
return parseISO(date); return parseISO(date);
} }
export function parseSymbol({ dataSource, symbol }: UniqueAsset) { export function parseSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
const [ticker, exchange] = symbol.split('.'); const [ticker, exchange] = symbol.split('.');
return { return {

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

@ -1,13 +1,13 @@
import { Role } from '@prisma/client'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { UniqueAsset } from './unique-asset.interface'; import { Role } from '@prisma/client';
export interface AdminData { export interface AdminData {
exchangeRates: ({ exchangeRates: ({
label1: string; label1: string;
label2: string; label2: string;
value: number; value: number;
} & UniqueAsset)[]; } & AssetProfileIdentifier)[];
settings: { [key: string]: boolean | object | string | string[] }; settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number; transactionCount: number;
userCount: number; userCount: number;

2
libs/common/src/lib/interfaces/unique-asset.interface.ts → libs/common/src/lib/interfaces/asset-profile-identifier.interface.ts

@ -1,6 +1,6 @@
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface UniqueAsset { export interface AssetProfileIdentifier {
dataSource: DataSource; dataSource: DataSource;
symbol: string; symbol: string;
} }

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

@ -7,6 +7,7 @@ import type {
AdminMarketData, AdminMarketData,
AdminMarketDataItem AdminMarketDataItem
} from './admin-market-data.interface'; } from './admin-market-data.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface'; import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import type { BenchmarkProperty } from './benchmark-property.interface'; import type { BenchmarkProperty } from './benchmark-property.interface';
import type { Benchmark } from './benchmark.interface'; import type { Benchmark } from './benchmark.interface';
@ -48,7 +49,6 @@ import type { Subscription } from './subscription.interface';
import type { SymbolMetrics } from './symbol-metrics.interface'; import type { SymbolMetrics } from './symbol-metrics.interface';
import type { SystemMessage } from './system-message.interface'; import type { SystemMessage } from './system-message.interface';
import type { TabConfiguration } from './tab-configuration.interface'; import type { TabConfiguration } from './tab-configuration.interface';
import type { UniqueAsset } from './unique-asset.interface';
import type { UserSettings } from './user-settings.interface'; import type { UserSettings } from './user-settings.interface';
import type { User } from './user.interface'; import type { User } from './user.interface';
@ -61,6 +61,7 @@ export {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
@ -101,7 +102,6 @@ export {
Subscription, Subscription,
SymbolMetrics, SymbolMetrics,
TabConfiguration, TabConfiguration,
UniqueAsset,
User, User,
UserSettings UserSettings
}; };

4
libs/common/src/lib/interfaces/responses/errors.interface.ts

@ -1,6 +1,6 @@
import { UniqueAsset } from '../unique-asset.interface'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export interface ResponseError { export interface ResponseError {
errors?: UniqueAsset[]; errors?: AssetProfileIdentifier[];
hasErrors: boolean; hasErrors: boolean;
} }

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

@ -1,4 +1,9 @@
import { ColorScheme, DateRange, ViewMode } from '@ghostfolio/common/types'; import {
ColorScheme,
DateRange,
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
export interface UserSettings { export interface UserSettings {
annualInterestRate?: number; annualInterestRate?: number;
@ -9,6 +14,7 @@ export interface UserSettings {
emergencyFund?: number; emergencyFund?: number;
'filters.accounts'?: string[]; 'filters.accounts'?: string[];
'filters.tags'?: string[]; 'filters.tags'?: string[];
holdingsViewMode?: HoldingsViewMode;
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;
isRestrictedView?: boolean; isRestrictedView?: boolean;
language?: string; language?: string;

4
libs/common/src/lib/models/portfolio-snapshot.ts

@ -1,5 +1,5 @@
import { transformToBig } from '@ghostfolio/common/class-transformer'; import { transformToBig } from '@ghostfolio/common/class-transformer';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models'; import { TimelinePosition } from '@ghostfolio/common/models';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -9,7 +9,7 @@ export class PortfolioSnapshot {
@Transform(transformToBig, { toClassOnly: true }) @Transform(transformToBig, { toClassOnly: true })
@Type(() => Big) @Type(() => Big)
currentValueInBaseCurrency: Big; currentValueInBaseCurrency: Big;
errors?: UniqueAsset[]; errors?: AssetProfileIdentifier[];
@Transform(transformToBig, { toClassOnly: true }) @Transform(transformToBig, { toClassOnly: true })
@Type(() => Big) @Type(() => Big)

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

@ -15,7 +15,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'allvue-systems', key: 'allvue-systems',
name: 'Allvue Systems', name: 'Allvue Systems',
origin: `United States`, origin: 'United States',
slogan: 'Investment Software Suite' slogan: 'Investment Software Suite'
}, },
{ {
@ -30,7 +30,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'altoo', key: 'altoo',
name: 'Altoo Wealth Platform', name: 'Altoo Wealth Platform',
origin: `Switzerland`, origin: 'Switzerland',
slogan: 'Simplicity for Complex Wealth' slogan: 'Simplicity for Complex Wealth'
}, },
{ {
@ -40,7 +40,7 @@ export const personalFinanceTools: Product[] = [
key: 'anlage.app', key: 'anlage.app',
languages: ['English'], languages: ['English'],
name: 'Anlage.App', name: 'Anlage.App',
origin: `Austria`, origin: 'Austria',
pricingPerYear: '$120', pricingPerYear: '$120',
slogan: 'Analyze and track your portfolio.' slogan: 'Analyze and track your portfolio.'
}, },
@ -58,16 +58,27 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'beanvest', key: 'beanvest',
name: 'Beanvest', name: 'Beanvest',
origin: `France`, origin: 'France',
pricingPerYear: '$100', pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors' slogan: 'Stock Portfolio Tracker for Smart Investors'
}, },
{
founded: 2022,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'degiro-portfolio-tracker-by-capitalyse',
languages: ['English'],
name: 'DEGIRO Portfolio Tracker by Capitalyse',
origin: 'Netherlands',
pricingPerYear: '€24',
slogan: 'Democratizing Data Analytics'
},
{ {
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'capitally', key: 'capitally',
name: 'Capitally', name: 'Capitally',
origin: `Poland`, origin: 'Poland',
pricingPerYear: '€50', pricingPerYear: '€50',
slogan: 'Optimize your investments performance' slogan: 'Optimize your investments performance'
}, },
@ -75,7 +86,7 @@ export const personalFinanceTools: Product[] = [
founded: 2022, founded: 2022,
key: 'capmon', key: 'capmon',
name: 'CapMon.org', name: 'CapMon.org',
origin: `Germany`, origin: 'Germany',
note: 'CapMon.org was discontinued in 2023', note: 'CapMon.org was discontinued in 2023',
slogan: 'Next Generation Assets Tracking' slogan: 'Next Generation Assets Tracking'
}, },
@ -83,7 +94,7 @@ export const personalFinanceTools: Product[] = [
founded: 2019, founded: 2019,
key: 'compound-planning', key: 'compound-planning',
name: 'Compound Planning', name: 'Compound Planning',
origin: `United States`, origin: 'United States',
slogan: 'Modern Wealth & Investment Management' slogan: 'Modern Wealth & Investment Management'
}, },
{ {
@ -92,7 +103,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'copilot-money', key: 'copilot-money',
name: 'Copilot Money', name: 'Copilot Money',
origin: `United States`, origin: 'United States',
pricingPerYear: '$70', pricingPerYear: '$70',
slogan: 'Do money better with Copilot' slogan: 'Do money better with Copilot'
}, },
@ -110,7 +121,7 @@ export const personalFinanceTools: Product[] = [
key: 'delta', key: 'delta',
name: 'Delta Investment Tracker', name: 'Delta Investment Tracker',
note: 'Acquired by eToro', note: 'Acquired by eToro',
origin: `Belgium`, origin: 'Belgium',
slogan: 'The app to track all your investments. Make smart moves only.' slogan: 'The app to track all your investments. Make smart moves only.'
}, },
{ {
@ -120,7 +131,7 @@ export const personalFinanceTools: Product[] = [
key: 'divvydiary', key: 'divvydiary',
languages: ['Deutsch', 'English'], languages: ['Deutsch', 'English'],
name: 'DivvyDiary', name: 'DivvyDiary',
origin: `Germany`, origin: 'Germany',
pricingPerYear: '€65', pricingPerYear: '€65',
slogan: 'Your personal Dividend Calendar' slogan: 'Your personal Dividend Calendar'
}, },
@ -130,7 +141,7 @@ export const personalFinanceTools: Product[] = [
key: 'empower', key: 'empower',
name: 'Empower', name: 'Empower',
note: 'Originally named as Personal Capital', note: 'Originally named as Personal Capital',
origin: `United States`, origin: 'United States',
slogan: 'Get answers to your money questions' slogan: 'Get answers to your money questions'
}, },
{ {
@ -138,7 +149,7 @@ export const personalFinanceTools: Product[] = [
founded: 2022, founded: 2022,
key: 'eightfigures', key: 'eightfigures',
name: '8FIGURES', name: '8FIGURES',
origin: `United States`, origin: 'United States',
slogan: 'Portfolio Tracker Designed by Professional Investors' slogan: 'Portfolio Tracker Designed by Professional Investors'
}, },
{ {
@ -147,7 +158,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'exirio', key: 'exirio',
name: 'Exirio', name: 'Exirio',
origin: `United States`, origin: 'United States',
pricingPerYear: '$100', pricingPerYear: '$100',
slogan: 'All your wealth, in one place.' slogan: 'All your wealth, in one place.'
}, },
@ -158,7 +169,7 @@ export const personalFinanceTools: Product[] = [
key: 'fina', key: 'fina',
languages: ['English'], languages: ['English'],
name: 'Fina', name: 'Fina',
origin: `United States`, origin: 'United States',
pricingPerYear: '$115', pricingPerYear: '$115',
slogan: 'Flexible Financial Management' slogan: 'Flexible Financial Management'
}, },
@ -167,7 +178,7 @@ export const personalFinanceTools: Product[] = [
key: 'finary', key: 'finary',
languages: ['Deutsch', 'English', 'Français'], languages: ['Deutsch', 'English', 'Français'],
name: 'Finary', name: 'Finary',
origin: `United States`, origin: 'United States',
slogan: 'Real-Time Portfolio Tracker & Stock Tracker' slogan: 'Real-Time Portfolio Tracker & Stock Tracker'
}, },
{ {
@ -175,7 +186,7 @@ export const personalFinanceTools: Product[] = [
hasFreePlan: true, hasFreePlan: true,
key: 'finwise', key: 'finwise',
name: 'FinWise', name: 'FinWise',
origin: `South Africa`, origin: 'South Africa',
pricingPerYear: '€69.99', pricingPerYear: '€69.99',
slogan: 'Personal finances, simplified' slogan: 'Personal finances, simplified'
}, },
@ -185,7 +196,7 @@ export const personalFinanceTools: Product[] = [
key: 'folishare', key: 'folishare',
languages: ['Deutsch', 'English'], languages: ['Deutsch', 'English'],
name: 'folishare', name: 'folishare',
origin: `Austria`, origin: 'Austria',
pricingPerYear: '$65', pricingPerYear: '$65',
slogan: 'Take control over your investments' slogan: 'Take control over your investments'
}, },
@ -196,7 +207,7 @@ export const personalFinanceTools: Product[] = [
key: 'getquin', key: 'getquin',
languages: ['Deutsch', 'English'], languages: ['Deutsch', 'English'],
name: 'getquin', name: 'getquin',
origin: `Germany`, origin: 'Germany',
pricingPerYear: '€48', pricingPerYear: '€48',
slogan: 'Portfolio Tracker, Analysis & Community' slogan: 'Portfolio Tracker, Analysis & Community'
}, },
@ -205,7 +216,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'gospatz', key: 'gospatz',
name: 'goSPATZ', name: 'goSPATZ',
origin: `Germany`, origin: 'Germany',
slogan: 'Volle Kontrolle über deine Investitionen' slogan: 'Volle Kontrolle über deine Investitionen'
}, },
{ {
@ -214,7 +225,7 @@ export const personalFinanceTools: Product[] = [
key: 'holistic-capital', key: 'holistic-capital',
languages: ['Deutsch'], languages: ['Deutsch'],
name: 'Holistic', name: 'Holistic',
origin: `Germany`, origin: 'Germany',
slogan: 'Die All-in-One Lösung für dein Vermögen.', slogan: 'Die All-in-One Lösung für dein Vermögen.',
useAnonymously: true useAnonymously: true
}, },
@ -224,7 +235,7 @@ export const personalFinanceTools: Product[] = [
key: 'intuit-mint', key: 'intuit-mint',
name: 'Intuit Mint', name: 'Intuit Mint',
note: 'Intuit Mint was discontinued in 2023', note: 'Intuit Mint was discontinued in 2023',
origin: `United States`, origin: 'United States',
pricingPerYear: '$60', pricingPerYear: '$60',
slogan: 'Managing money, made simple' slogan: 'Managing money, made simple'
}, },
@ -234,7 +245,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'justetf', key: 'justetf',
name: 'justETF', name: 'justETF',
origin: `Germany`, origin: 'Germany',
pricingPerYear: '€119', pricingPerYear: '€119',
slogan: 'ETF portfolios made simple' slogan: 'ETF portfolios made simple'
}, },
@ -244,7 +255,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'koyfin', key: 'koyfin',
name: 'Koyfin', name: 'Koyfin',
origin: `United States`, origin: 'United States',
pricingPerYear: '$468', pricingPerYear: '$468',
slogan: 'Comprehensive financial data analysis' slogan: 'Comprehensive financial data analysis'
}, },
@ -254,7 +265,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'kubera', key: 'kubera',
name: 'Kubera®', name: 'Kubera®',
origin: `United States`, origin: 'United States',
pricingPerYear: '$150', pricingPerYear: '$150',
slogan: 'The Time Machine for your Net Worth' slogan: 'The Time Machine for your Net Worth'
}, },
@ -264,7 +275,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'magnifi', key: 'magnifi',
name: 'Magnifi', name: 'Magnifi',
origin: `United States`, origin: 'United States',
pricingPerYear: '$132', pricingPerYear: '$132',
slogan: 'AI Investing Assistant' slogan: 'AI Investing Assistant'
}, },
@ -275,9 +286,9 @@ export const personalFinanceTools: Product[] = [
key: 'markets.sh', key: 'markets.sh',
languages: ['English'], languages: ['English'],
name: 'markets.sh', name: 'markets.sh',
origin: `Germany`, origin: 'Germany',
pricingPerYear: '€168', pricingPerYear: '€168',
regions: [`Global`], regions: ['Global'],
slogan: 'Track your investments' slogan: 'Track your investments'
}, },
{ {
@ -287,9 +298,9 @@ export const personalFinanceTools: Product[] = [
languages: ['English'], languages: ['English'],
name: 'Maybe Finance', name: 'Maybe Finance',
note: 'Maybe Finance was discontinued in 2023', note: 'Maybe Finance was discontinued in 2023',
origin: `United States`, origin: 'United States',
pricingPerYear: '$145', pricingPerYear: '$145',
regions: [`United States`], regions: ['United States'],
slogan: 'Your financial future, in your control' slogan: 'Your financial future, in your control'
}, },
{ {
@ -298,7 +309,7 @@ export const personalFinanceTools: Product[] = [
key: 'merlincrypto', key: 'merlincrypto',
languages: ['English'], languages: ['English'],
name: 'Merlin', name: 'Merlin',
origin: `United States`, origin: 'United States',
pricingPerYear: '$204', pricingPerYear: '$204',
regions: ['Canada', 'United States'], regions: ['Canada', 'United States'],
slogan: 'The smartest way to track your crypto' slogan: 'The smartest way to track your crypto'
@ -309,7 +320,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'monarch-money', key: 'monarch-money',
name: 'Monarch Money', name: 'Monarch Money',
origin: `United States`, origin: 'United States',
pricingPerYear: '$99.99', pricingPerYear: '$99.99',
slogan: 'The modern way to manage your money' slogan: 'The modern way to manage your money'
}, },
@ -327,7 +338,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'navexa', key: 'navexa',
name: 'Navexa', name: 'Navexa',
origin: `Australia`, origin: 'Australia',
pricingPerYear: '$90', pricingPerYear: '$90',
slogan: 'The Intelligent Portfolio Tracker' slogan: 'The Intelligent Portfolio Tracker'
}, },
@ -338,7 +349,7 @@ export const personalFinanceTools: Product[] = [
key: 'parqet', key: 'parqet',
name: 'Parqet', name: 'Parqet',
note: 'Originally named as Tresor One', note: 'Originally named as Tresor One',
origin: `Germany`, origin: 'Germany',
pricingPerYear: '€88', pricingPerYear: '€88',
regions: ['Austria', 'Germany', 'Switzerland'], regions: ['Austria', 'Germany', 'Switzerland'],
slogan: 'Dein Vermögen immer im Blick' slogan: 'Dein Vermögen immer im Blick'
@ -348,7 +359,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'plannix', key: 'plannix',
name: 'Plannix', name: 'Plannix',
origin: `Italy`, origin: 'Italy',
slogan: 'Your Personal Finance Hub' slogan: 'Your Personal Finance Hub'
}, },
{ {
@ -358,9 +369,9 @@ export const personalFinanceTools: Product[] = [
key: 'pocketsmith', key: 'pocketsmith',
languages: ['English'], languages: ['English'],
name: 'PocketSmith', name: 'PocketSmith',
origin: `New Zealand`, origin: 'New Zealand',
pricingPerYear: '$120', pricingPerYear: '$120',
regions: [`Global`], regions: ['Global'],
slogan: 'Know where your money is going' slogan: 'Know where your money is going'
}, },
{ {
@ -369,7 +380,7 @@ export const personalFinanceTools: Product[] = [
key: 'portfolio-dividend-tracker', key: 'portfolio-dividend-tracker',
languages: ['English', 'Nederlands'], languages: ['English', 'Nederlands'],
name: 'Portfolio Dividend Tracker', name: 'Portfolio Dividend Tracker',
origin: `Netherlands`, origin: 'Netherlands',
pricingPerYear: '€60', pricingPerYear: '€60',
slogan: 'Manage all your portfolios' slogan: 'Manage all your portfolios'
}, },
@ -397,7 +408,7 @@ export const personalFinanceTools: Product[] = [
key: 'portseido', key: 'portseido',
languages: ['Deutsch', 'English', 'Français', 'Nederlands'], languages: ['Deutsch', 'English', 'Français', 'Nederlands'],
name: 'Portseido', name: 'Portseido',
origin: `Thailand`, origin: 'Thailand',
pricingPerYear: '$96', pricingPerYear: '$96',
slogan: 'Portfolio Performance and Dividend Tracker' slogan: 'Portfolio Performance and Dividend Tracker'
}, },
@ -407,7 +418,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
key: 'projectionlab', key: 'projectionlab',
name: 'ProjectionLab', name: 'ProjectionLab',
origin: `United States`, origin: 'United States',
pricingPerYear: '$108', pricingPerYear: '$108',
slogan: 'Build Financial Plans You Love.' slogan: 'Build Financial Plans You Love.'
}, },
@ -416,7 +427,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'rocket-money', key: 'rocket-money',
name: 'Rocket Money', name: 'Rocket Money',
origin: `United States`, origin: 'United States',
slogan: 'Track your net worth' slogan: 'Track your net worth'
}, },
{ {
@ -425,7 +436,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'seeking-alpha', key: 'seeking-alpha',
name: 'Seeking Alpha', name: 'Seeking Alpha',
origin: `United States`, origin: 'United States',
pricingPerYear: '$239', pricingPerYear: '$239',
slogan: 'Stock Market Analysis & Tools for Investors' slogan: 'Stock Market Analysis & Tools for Investors'
}, },
@ -433,7 +444,7 @@ export const personalFinanceTools: Product[] = [
founded: 2022, founded: 2022,
key: 'segmio', key: 'segmio',
name: 'Segmio', name: 'Segmio',
origin: `Romania`, origin: 'Romania',
slogan: 'Wealth Management and Net Worth Tracking' slogan: 'Wealth Management and Net Worth Tracking'
}, },
{ {
@ -442,9 +453,9 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'sharesight', key: 'sharesight',
name: 'Sharesight', name: 'Sharesight',
origin: `New Zealand`, origin: 'New Zealand',
pricingPerYear: '$135', pricingPerYear: '$135',
regions: [`Global`], regions: ['Global'],
slogan: 'Stock Portfolio Tracker' slogan: 'Stock Portfolio Tracker'
}, },
{ {
@ -459,7 +470,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'simple-portfolio', key: 'simple-portfolio',
name: 'Simple Portfolio', name: 'Simple Portfolio',
origin: `Czech Republic`, origin: 'Czech Republic',
pricingPerYear: '€80', pricingPerYear: '€80',
slogan: 'Stock Portfolio Tracker' slogan: 'Stock Portfolio Tracker'
}, },
@ -469,7 +480,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'snowball-analytics', key: 'snowball-analytics',
name: 'Snowball Analytics', name: 'Snowball Analytics',
origin: `France`, origin: 'France',
pricingPerYear: '$80', pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker' slogan: 'Simple and powerful portfolio tracker'
}, },
@ -478,20 +489,20 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'stock-events', key: 'stock-events',
name: 'Stock Events', name: 'Stock Events',
origin: `Germany`, origin: 'Germany',
slogan: 'Track all your Investments' slogan: 'Track all your Investments'
}, },
{ {
key: 'stockle', key: 'stockle',
name: 'Stockle', name: 'Stockle',
origin: `Finland`, origin: 'Finland',
slogan: 'Supercharge your investments tracking experience' slogan: 'Supercharge your investments tracking experience'
}, },
{ {
founded: 2008, founded: 2008,
key: 'stockmarketeye', key: 'stockmarketeye',
name: 'StockMarketEye', name: 'StockMarketEye',
origin: `France`, origin: 'France',
note: 'StockMarketEye was discontinued in 2023', note: 'StockMarketEye was discontinued in 2023',
slogan: 'A Powerful Portfolio & Investment Tracking App' slogan: 'A Powerful Portfolio & Investment Tracking App'
}, },
@ -501,7 +512,7 @@ export const personalFinanceTools: Product[] = [
key: 'stonksfolio', key: 'stonksfolio',
languages: ['English'], languages: ['English'],
name: 'Stonksfolio', name: 'Stonksfolio',
origin: `Bulgaria`, origin: 'Bulgaria',
pricingPerYear: '€49.90', pricingPerYear: '€49.90',
slogan: 'Visualize all of your portfolios' slogan: 'Visualize all of your portfolios'
}, },
@ -510,7 +521,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'sumio', key: 'sumio',
name: 'Sumio', name: 'Sumio',
origin: `Czech Republic`, origin: 'Czech Republic',
pricingPerYear: '$20', pricingPerYear: '$20',
slogan: 'Sum up and build your wealth.' slogan: 'Sum up and build your wealth.'
}, },
@ -519,7 +530,7 @@ export const personalFinanceTools: Product[] = [
hasFreePlan: false, hasFreePlan: false,
key: 'tiller', key: 'tiller',
name: 'Tiller', name: 'Tiller',
origin: `United States`, origin: 'United States',
pricingPerYear: '$79', pricingPerYear: '$79',
slogan: slogan:
'Your financial life in a spreadsheet, automatically updated each day' 'Your financial life in a spreadsheet, automatically updated each day'
@ -530,7 +541,7 @@ export const personalFinanceTools: Product[] = [
key: 'utluna', key: 'utluna',
languages: ['Deutsch', 'English', 'Français'], languages: ['Deutsch', 'English', 'Français'],
name: 'Utluna', name: 'Utluna',
origin: `Switzerland`, origin: 'Switzerland',
pricingPerYear: '$300', pricingPerYear: '$300',
slogan: 'Your Portfolio. Revealed.', slogan: 'Your Portfolio. Revealed.',
useAnonymously: true useAnonymously: true
@ -540,7 +551,7 @@ export const personalFinanceTools: Product[] = [
hasFreePlan: true, hasFreePlan: true,
key: 'vyzer', key: 'vyzer',
name: 'Vyzer', name: 'Vyzer',
origin: `United States`, origin: 'United States',
pricingPerYear: '$348', pricingPerYear: '$348',
slogan: 'Virtual Family Office for Smart Wealth Management' slogan: 'Virtual Family Office for Smart Wealth Management'
}, },
@ -549,7 +560,7 @@ export const personalFinanceTools: Product[] = [
key: 'wallmine', key: 'wallmine',
languages: ['English'], languages: ['English'],
name: 'wallmine', name: 'wallmine',
origin: `Czech Republic`, origin: 'Czech Republic',
pricingPerYear: '$600', pricingPerYear: '$600',
slogan: 'Make Smarter Investments' slogan: 'Make Smarter Investments'
}, },
@ -567,7 +578,7 @@ export const personalFinanceTools: Product[] = [
key: 'wealthica', key: 'wealthica',
languages: ['English', 'Français'], languages: ['English', 'Français'],
name: 'Wealthica', name: 'Wealthica',
origin: `Canada`, origin: 'Canada',
pricingPerYear: '$50', pricingPerYear: '$50',
slogan: 'See all your investments in one place' slogan: 'See all your investments in one place'
}, },
@ -577,13 +588,13 @@ export const personalFinanceTools: Product[] = [
key: 'wealthy-tracker', key: 'wealthy-tracker',
languages: ['English'], languages: ['English'],
name: 'Wealthy Tracker', name: 'Wealthy Tracker',
origin: `India`, origin: 'India',
slogan: 'One app to manage all your investments' slogan: 'One app to manage all your investments'
}, },
{ {
key: 'whal', key: 'whal',
name: 'Whal', name: 'Whal',
origin: `United States`, origin: 'United States',
slogan: 'Manage your investments in one place' slogan: 'Manage your investments in one place'
}, },
{ {
@ -594,8 +605,8 @@ export const personalFinanceTools: Product[] = [
languages: ['Deutsch', 'English', 'Español', 'Français', 'Italiano'], languages: ['Deutsch', 'English', 'Español', 'Français', 'Italiano'],
name: 'yeekatee', name: 'yeekatee',
note: 'yeekatee was discontinued in 2024', note: 'yeekatee was discontinued in 2024',
origin: `Switzerland`, origin: 'Switzerland',
regions: [`Global`], regions: ['Global'],
slogan: 'Connect. Share. Invest.' slogan: 'Connect. Share. Invest.'
}, },
{ {
@ -604,7 +615,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
key: 'ynab', key: 'ynab',
name: 'YNAB (You Need a Budget)', name: 'YNAB (You Need a Budget)',
origin: `United States`, origin: 'United States',
pricingPerYear: '$99', pricingPerYear: '$99',
slogan: 'Change Your Relationship With Money' slogan: 'Change Your Relationship With Money'
} }

1
libs/common/src/lib/types/holding-view-mode.type.ts

@ -1 +0,0 @@
export type HoldingViewMode = 'CHART' | 'TABLE';

1
libs/common/src/lib/types/holdings-view-mode.type.ts

@ -0,0 +1 @@
export type HoldingsViewMode = 'CHART' | 'TABLE';

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

@ -8,7 +8,7 @@ import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type'; import type { Granularity } from './granularity.type';
import type { GroupBy } from './group-by.type'; import type { GroupBy } from './group-by.type';
import type { HoldingType } from './holding-type.type'; import type { HoldingType } from './holding-type.type';
import type { HoldingViewMode } from './holding-view-mode.type'; import type { HoldingsViewMode } from './holdings-view-mode.type';
import type { MarketAdvanced } from './market-advanced.type'; import type { MarketAdvanced } from './market-advanced.type';
import type { MarketDataPreset } from './market-data-preset.type'; import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type'; import type { MarketState } from './market-state.type';
@ -31,7 +31,7 @@ export type {
Granularity, Granularity,
GroupBy, GroupBy,
HoldingType, HoldingType,
HoldingViewMode, HoldingsViewMode,
Market, Market,
MarketAdvanced, MarketAdvanced,
MarketDataPreset, MarketDataPreset,

6
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -3,7 +3,7 @@ import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString, getLocale } from '@ghostfolio/common/helper'; import { getDateFormatString, getLocale } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { GfActivityTypeComponent } from '@ghostfolio/ui/activity-type'; import { GfActivityTypeComponent } from '@ghostfolio/ui/activity-type';
import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info'; import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info';
@ -99,7 +99,7 @@ export class GfActivitiesTableComponent
@Output() export = new EventEmitter<void>(); @Output() export = new EventEmitter<void>();
@Output() exportDrafts = new EventEmitter<string[]>(); @Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@Output() importDividends = new EventEmitter<UniqueAsset>(); @Output() importDividends = new EventEmitter<AssetProfileIdentifier>();
@Output() pageChanged = new EventEmitter<PageEvent>(); @Output() pageChanged = new EventEmitter<PageEvent>();
@Output() selectedActivities = new EventEmitter<Activity[]>(); @Output() selectedActivities = new EventEmitter<Activity[]>();
@Output() sortChanged = new EventEmitter<Sort>(); @Output() sortChanged = new EventEmitter<Sort>();
@ -263,7 +263,7 @@ export class GfActivitiesTableComponent
alert(aComment); alert(aComment);
} }
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset) { public onOpenPositionDialog({ dataSource, symbol }: AssetProfileIdentifier) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
}); });

4
libs/ui/src/lib/assistant/interfaces/interfaces.ts

@ -1,4 +1,4 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
export interface IDateRangeOption { export interface IDateRangeOption {
@ -6,7 +6,7 @@ export interface IDateRangeOption {
value: DateRange; value: DateRange;
} }
export interface ISearchResultItem extends UniqueAsset { export interface ISearchResultItem extends AssetProfileIdentifier {
assetSubClassString: string; assetSubClassString: string;
currency: string; currency: string;
name: string; name: string;

13
libs/ui/src/lib/benchmark/benchmark.component.ts

@ -1,5 +1,9 @@
import { getLocale, resolveMarketCondition } from '@ghostfolio/common/helper'; import { getLocale, resolveMarketCondition } from '@ghostfolio/common/helper';
import { Benchmark, UniqueAsset, User } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
Benchmark,
User
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator'; import { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -84,7 +88,7 @@ export class GfBenchmarkComponent implements OnChanges, OnDestroy {
} }
} }
public onOpenBenchmarkDialog({ dataSource, symbol }: UniqueAsset) { public onOpenBenchmarkDialog({ dataSource, symbol }: AssetProfileIdentifier) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, benchmarkDetailDialog: true } queryParams: { dataSource, symbol, benchmarkDetailDialog: true }
}); });
@ -95,7 +99,10 @@ export class GfBenchmarkComponent implements OnChanges, OnDestroy {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openBenchmarkDetailDialog({ dataSource, symbol }: UniqueAsset) { private openBenchmarkDetailDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
const dialogRef = this.dialog.open(GfBenchmarkDetailDialogComponent, { const dialogRef = this.dialog.open(GfBenchmarkDetailDialogComponent, {
data: <BenchmarkDetailDialogParams>{ data: <BenchmarkDetailDialogParams>{
dataSource, dataSource,

7
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -2,7 +2,10 @@ import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset
import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/holding-detail-dialog/holding-detail-dialog.component'; import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/holding-detail-dialog/holding-detail-dialog.component';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale } from '@ghostfolio/common/helper';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info'; import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -102,7 +105,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
public onOpenHoldingDialog({ dataSource, symbol }: UniqueAsset) { public onOpenHoldingDialog({ dataSource, symbol }: AssetProfileIdentifier) {
if (this.hasPermissionToOpenDetails) { if (this.hasPermissionToOpenDetails) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }

7
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -1,7 +1,10 @@
import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale, getTextColor } from '@ghostfolio/common/helper'; import { getLocale, getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -71,7 +74,7 @@ export class GfPortfolioProportionChartComponent
}; };
} = {}; } = {};
@Output() proportionChartClicked = new EventEmitter<UniqueAsset>(); @Output() proportionChartClicked = new EventEmitter<AssetProfileIdentifier>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;

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

@ -1,4 +1,8 @@
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import {
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
@ -14,10 +18,12 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { ChartConfiguration } from 'chart.js'; import { ChartConfiguration } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
import { differenceInDays } from 'date-fns';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -37,10 +43,12 @@ export class GfTreemapChartComponent
@Input() cursor: string; @Input() cursor: string;
@Input() holdings: PortfolioPosition[]; @Input() holdings: PortfolioPosition[];
@Output() treemapChartClicked = new EventEmitter<UniqueAsset>(); @Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public static readonly HEAT_MULTIPLIER = 5;
public chart: Chart<'treemap'>; public chart: Chart<'treemap'>;
public isLoading = true; public isLoading = true;
@ -71,24 +79,52 @@ export class GfTreemapChartComponent
datasets: [ datasets: [
{ {
backgroundColor(ctx) { backgroundColor(ctx) {
const netPerformancePercentWithCurrencyEffect = const annualizedNetPerformancePercentWithCurrencyEffect =
ctx.raw._data.netPerformancePercentWithCurrencyEffect; getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
if (netPerformancePercentWithCurrencyEffect > 0.03) { new Date(),
ctx.raw._data.dateOfFirstActivity
),
netPerformancePercentage: new Big(
ctx.raw._data.netPerformancePercentWithCurrencyEffect
)
}).toNumber();
if (
annualizedNetPerformancePercentWithCurrencyEffect >
0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
) {
return green[9]; return green[9];
} else if (netPerformancePercentWithCurrencyEffect > 0.02) { } else if (
annualizedNetPerformancePercentWithCurrencyEffect >
0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
) {
return green[7]; return green[7];
} else if (netPerformancePercentWithCurrencyEffect > 0.01) { } else if (
annualizedNetPerformancePercentWithCurrencyEffect >
0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
) {
return green[5]; return green[5];
} else if (netPerformancePercentWithCurrencyEffect > 0) { } else if (annualizedNetPerformancePercentWithCurrencyEffect > 0) {
return green[3]; return green[3];
} else if (netPerformancePercentWithCurrencyEffect === 0) { } else if (
annualizedNetPerformancePercentWithCurrencyEffect === 0
) {
return gray[3]; return gray[3];
} else if (netPerformancePercentWithCurrencyEffect > -0.01) { } else if (
annualizedNetPerformancePercentWithCurrencyEffect >
-0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
) {
return red[3]; return red[3];
} else if (netPerformancePercentWithCurrencyEffect > -0.02) { } else if (
annualizedNetPerformancePercentWithCurrencyEffect >
-0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
) {
return red[5]; return red[5];
} else if (netPerformancePercentWithCurrencyEffect > -0.03) { } else if (
annualizedNetPerformancePercentWithCurrencyEffect >
-0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
) {
return red[7]; return red[7];
} else { } else {
return red[9]; return red[9];

882
package-lock.json

File diff suppressed because it is too large

24
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.99.0", "version": "2.101.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -152,16 +152,16 @@
"@angular/pwa": "18.1.1", "@angular/pwa": "18.1.1",
"@nestjs/schematics": "10.0.1", "@nestjs/schematics": "10.0.1",
"@nestjs/testing": "10.1.3", "@nestjs/testing": "10.1.3",
"@nx/angular": "19.5.1", "@nx/angular": "19.5.6",
"@nx/cypress": "19.5.1", "@nx/cypress": "19.5.6",
"@nx/eslint-plugin": "19.5.1", "@nx/eslint-plugin": "19.5.6",
"@nx/jest": "19.5.1", "@nx/jest": "19.5.6",
"@nx/js": "19.5.1", "@nx/js": "19.5.6",
"@nx/nest": "19.5.1", "@nx/nest": "19.5.6",
"@nx/node": "19.5.1", "@nx/node": "19.5.6",
"@nx/storybook": "19.5.1", "@nx/storybook": "19.5.6",
"@nx/web": "19.5.1", "@nx/web": "19.5.6",
"@nx/workspace": "19.5.1", "@nx/workspace": "19.5.6",
"@schematics/angular": "18.1.1", "@schematics/angular": "18.1.1",
"@simplewebauthn/types": "9.0.1", "@simplewebauthn/types": "9.0.1",
"@storybook/addon-essentials": "8.2.6", "@storybook/addon-essentials": "8.2.6",
@ -190,7 +190,7 @@
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0", "jest-preset-angular": "14.1.0",
"nx": "19.5.1", "nx": "19.5.6",
"prettier": "3.3.3", "prettier": "3.3.3",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"react": "18.2.0", "react": "18.2.0",

Loading…
Cancel
Save