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. 24
      Dockerfile
  3. 59
      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. 59
      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. 43
      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. 18
      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/),
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
### Changed

24
Dockerfile

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

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

@ -21,9 +21,9 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter,
UniqueAsset
Filter
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
@ -59,7 +59,9 @@ export class AdminService {
currency,
dataSource,
symbol
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
}: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try {
if (dataSource === 'MANUAL') {
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.symbolProfileService.delete({ dataSource, symbol });
}
@ -325,7 +330,7 @@ export class AdminService {
public async getMarketDataBySymbol({
dataSource,
symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> {
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
@ -386,7 +391,7 @@ export class AdminService {
symbol,
symbolMapping,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
@ -394,28 +399,28 @@ export class AdminService {
url: url as string
};
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
{
comment,
countries,
currency,
dataSource,
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
})
};
}
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);

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

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

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

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

@ -19,7 +19,7 @@ import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
OrderWithAccount,
@ -51,7 +51,7 @@ export class ImportService {
dataSource,
symbol,
userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
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 { 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 { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class LogoService {
public async getLogoByDataSourceAndSymbol({
dataSource,
symbol
}: UniqueAsset) {
}: AssetProfileIdentifier) {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),

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

@ -11,9 +11,9 @@ import {
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter,
UniqueAsset
Filter
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
@ -46,6 +46,39 @@ export class OrderService {
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(
data: Prisma.OrderCreateInput & {
accountId?: string;
@ -252,7 +285,7 @@ export class OrderService {
return count;
}
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
@ -431,7 +464,7 @@ export class OrderService {
this.prismaService.order.count({ where })
]);
const uniqueAssets = uniqBy(
const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => {
return {
dataSource: SymbolProfile.dataSource,
@ -446,8 +479,9 @@ export class OrderService {
}
);
const assetProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
const activities = orders.map((order) => {
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 { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
@ -27,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

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

@ -19,12 +19,12 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
DataProviderInfo,
HistoricalDataItem,
InvestmentItem,
ResponseError,
SymbolMetrics,
UniqueAsset
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types';
@ -356,15 +356,15 @@ export abstract class PortfolioCalculator {
dataSource: item.dataSource,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
? (grossPerformancePercentage ?? null)
: null,
grossPerformancePercentageWithCurrencyEffect: !hasErrors
? grossPerformancePercentageWithCurrencyEffect ?? null
? (grossPerformancePercentageWithCurrencyEffect ?? null)
: null,
grossPerformanceWithCurrencyEffect: !hasErrors
? grossPerformanceWithCurrencyEffect ?? null
? (grossPerformanceWithCurrencyEffect ?? null)
: null,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
@ -372,15 +372,15 @@ export abstract class PortfolioCalculator {
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
? (netPerformancePercentage ?? null)
: null,
netPerformancePercentageWithCurrencyEffect: !hasErrors
? netPerformancePercentageWithCurrencyEffect ?? null
? (netPerformancePercentageWithCurrencyEffect ?? null)
: null,
netPerformanceWithCurrencyEffect: !hasErrors
? netPerformanceWithCurrencyEffect ?? null
? (netPerformanceWithCurrencyEffect ?? null)
: null,
quantity: item.quantity,
symbol: item.symbol,
@ -905,7 +905,7 @@ export abstract class PortfolioCalculator {
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics;
} & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() {
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 { getFactor } from '@ghostfolio/api/helper/portfolio.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 { Logger } from '@nestjs/common';
@ -151,7 +154,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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';
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
});
},
getRange: ({
assetProfileIdentifiers,
dateRangeEnd,
dateRangeStart,
uniqueAssets
dateRangeStart
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
uniqueAssets: UniqueAsset[];
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
dataSource: uniqueAssets[0].dataSource,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: uniqueAssets[0].symbol
symbol: assetProfileIdentifiers[0].symbol
},
{
createdAt: dateRangeEnd,
dataSource: uniqueAssets[0].dataSource,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
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 { resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
DataProviderInfo,
ResponseError,
UniqueAsset
ResponseError
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -80,17 +80,16 @@ export class CurrentRateService {
);
}
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
({ dataSource, symbol }) => {
const assetProfileIdentifiers: AssetProfileIdentifier[] =
dataGatheringItems.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}
);
});
promises.push(
this.marketDataService
.getRange({
dateQuery,
uniqueAssets
assetProfileIdentifiers,
dateQuery
})
.then((data) => {
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;
marketPrice: number;
}

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

@ -1,6 +1,7 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.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 {
hasNotDefinedValuesInObject,
@ -29,7 +30,8 @@ import {
} from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
isRestrictedView,
permissions
} from '@ghostfolio/common/permissions';
import type {
DateRange,
@ -38,12 +40,14 @@ import type {
} from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Put,
Query,
UseGuards,
UseInterceptors,
@ -51,12 +55,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio')
export class PortfolioController {
@ -566,23 +571,23 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition(
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
return position;
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
return holding;
}
@Get('report')
@ -605,4 +610,36 @@ export class PortfolioController {
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID,
@ -58,7 +59,8 @@ import {
DataSource,
Order,
Platform,
Prisma
Prisma,
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import {
@ -70,7 +72,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
import { isEmpty, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
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({
activities,
groupBy
@ -713,7 +697,7 @@ export class PortfolioService {
return Account;
});
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
@ -721,7 +705,7 @@ export class PortfolioService {
});
const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
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({
cashDetails,
userCurrency,
@ -1724,13 +1726,13 @@ export class PortfolioService {
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: new Big(
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 { 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 { Inject, Injectable, Logger } from '@nestjs/common';
@ -28,7 +28,7 @@ export class RedisCacheService {
return `portfolio-snapshot-${userId}`;
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
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;
historicalData: HistoricalDataItem[];
marketPrice: number;

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

@ -40,13 +40,13 @@ export class SymbolService {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}
]
],
dateQuery: { gte: subDays(new Date(), days) }
});
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 {
ColorScheme,
DateRange,
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
@IsOptional()
'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@IsBoolean()
@IsOptional()
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).viewMode === 'ZEN'
? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
@ -243,6 +243,9 @@ export class UserService {
// Reset benchmark
user.Settings.settings.benchmark = undefined;
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
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
} from '@ghostfolio/common/config';
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 { Injectable, Logger } from '@nestjs/common';
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) {
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try {
Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,

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

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

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

@ -20,7 +20,7 @@ import {
getStartOfUtcDate,
isDerivedCurrency
} 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 { Inject, Injectable, Logger } from '@nestjs/common';
@ -75,7 +75,7 @@ export class DataProviderService {
return false;
}
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
@ -173,7 +173,7 @@ export class DataProviderService {
}
public async getHistorical(
aItems: UniqueAsset[],
aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month',
from: Date,
to: Date
@ -243,7 +243,7 @@ export class DataProviderService {
from,
to
}: {
dataGatheringItems: UniqueAsset[];
dataGatheringItems: AssetProfileIdentifier[];
from: Date;
to: Date;
}): Promise<{
@ -350,7 +350,7 @@ export class DataProviderService {
useCache = true,
user
}: {
items: UniqueAsset[];
items: AssetProfileIdentifier[];
requestTimeout?: number;
useCache?: boolean;
user?: UserWithSettings;
@ -376,7 +376,7 @@ export class DataProviderService {
}
// Get items from cache
const itemsToFetch: UniqueAsset[] = [];
const itemsToFetch: AssetProfileIdentifier[] = [];
for (const { dataSource, symbol } of items) {
if (useCache) {
@ -633,7 +633,7 @@ export class DataProviderService {
dataGatheringItems
}: {
currency: string;
dataGatheringItems: UniqueAsset[];
dataGatheringItems: AssetProfileIdentifier[];
}) {
return dataGatheringItems.some(({ dataSource, symbol }) => {
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 marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource,
symbol
}
]
],
dateQuery: { gte: startDate, lt: endDate }
});
if (marketData?.length > 0) {
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
}
]
],
dateQuery: { gte: startDate, lt: endDate }
});
for (const { date, marketPrice } of marketData) {
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: {
gte: startDate,
lt: endDate
},
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
}
]
],
dateQuery: {
gte: startDate,
lt: endDate
}
});
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 {
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
marketState: MarketState;
}
export interface IDataGatheringItem extends UniqueAsset {
export interface IDataGatheringItem extends AssetProfileIdentifier {
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import {
@ -17,7 +17,7 @@ import {
export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({ dataSource, symbol }: UniqueAsset) {
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({
where: {
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({
select: {
date: true,
@ -59,11 +59,11 @@ export class MarketDataService {
}
public async getRange({
dateQuery,
uniqueAssets
assetProfileIdentifiers,
dateQuery
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery;
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> {
return this.prismaService.marketData.findMany({
orderBy: [
@ -76,13 +76,13 @@ export class MarketDataService {
],
where: {
dataSource: {
in: uniqueAssets.map(({ dataSource }) => {
in: assetProfileIdentifiers.map(({ dataSource }) => {
return dataSource;
})
},
date: dateQuery,
symbol: {
in: uniqueAssets.map(({ symbol }) => {
in: assetProfileIdentifiers.map(({ 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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
EnhancedSymbolProfile,
Holding,
ScraperConfiguration,
UniqueAsset
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -23,7 +23,7 @@ export class SymbolProfileService {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: UniqueAsset) {
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
@ -36,7 +36,7 @@ export class SymbolProfileService {
}
public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[]
aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
@ -54,7 +54,7 @@ export class SymbolProfileService {
SymbolProfileOverrides: true
},
where: {
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
@ -140,7 +140,7 @@ export class SymbolProfileService {
symbolMapping,
SymbolProfileOverrides,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
return this.prismaService.symbolProfile.update({
data: {
assetClass,

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

@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
this.user?.permissions,
permissions.reportDataGlitch
),
hasPermissionToUpdateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) &&
!user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale
},
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';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Filter,
InfoItem,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
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 });
}
@ -266,21 +266,27 @@ export class AdminMarketDataComponent
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
public onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
public onOpenAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.router.navigate([], {
queryParams: {
dataSource,

18
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 { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
AdminMarketDataItem,
UniqueAsset
AssetProfileIdentifier,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core';
@ -13,7 +13,7 @@ import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
export class AdminMarketDataService {
public constructor(private adminService: AdminService) {}
public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const confirmation = confirm(
$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(
$localize`Do you really want to delete these profiles?`
);
if (confirmation) {
const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => {
return this.adminService.deleteProfileData({ dataSource, symbol });
});
const deleteRequests = aAssetProfileIdentifiers.map(
({ dataSource, symbol }) => {
return this.adminService.deleteProfileData({ dataSource, symbol });
}
);
forkJoin(deleteRequests)
.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 {
AdminMarketDataDetails,
UniqueAsset
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -175,20 +175,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.dialogRef.close();
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close();
}
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
public onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.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
.postBenchmark({ dataSource, symbol })
.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
.deleteBenchmark({ dataSource, symbol })
.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 { GfValueComponent } from '@ghostfolio/ui/value';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnDestroy,
OnInit
OnInit,
ViewChild
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import {
MatAutocompleteModule,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import {
@ -36,14 +44,15 @@ import {
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Observable, of, Subject } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfLineChartComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
MatAutocompleteModule,
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatFormFieldModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html'
})
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup;
public accounts: Account[];
public activities: Activity[];
public assetClass: string;
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
public filteredTagsObservable: Observable<Tag[]> = of([]);
public firstBuyDate: string;
public historicalDataItems: LineChartItem[];
public investment: number;
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public sectors: {
[name: string]: { name: string; value: number };
};
public separatorKeysCodes: number[] = [COMMA, ENTER];
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public tagsAvailable: Tag[];
public totalItems: number;
public transactionCount: number;
public user: User;
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
private dataService: DataService,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
private userService: UserService
) {}
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
.fetchHoldingDetail({
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.sectors = {};
this.SymbolProfile = SymbolProfile;
this.tags = tags.map(({ id, name }) => {
return {
id,
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.totalItems = transactionCount;
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() {
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() {
this.unsubscribeSubject.next();
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
animationDuration="0"
class="mb-3"
class="mb-5"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }"
>
@ -375,7 +375,49 @@
</mat-tab>
</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="col">
<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;
hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
locale: 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import {
AssetProfileIdentifier,
PortfolioPosition,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
HoldingType,
HoldingViewMode,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
@ -18,7 +18,7 @@ import { FormControl } from '@angular/forms';
import { Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { skip, takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-home-holdings',
@ -26,6 +26,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-holdings.html'
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean;
@ -37,7 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
{ label: $localize`Closed`, value: 'CLOSED' }
];
public user: User;
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
public viewModeFormControl = new FormControl<HoldingsViewMode>(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private unsubscribeSubject = new Subject<void>();
@ -81,6 +85,21 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
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) {
@ -89,7 +108,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.initialize();
}
public onSymbolClicked({ dataSource, symbol }: UniqueAsset) {
public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -122,9 +141,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.hasPermissionToAccessHoldingsChart &&
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') {
this.viewModeFormControl.setValue('TABLE');
this.viewModeFormControl.setValue(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
}
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 { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
LineChartItem,
PortfolioPerformance,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public errors: UniqueAsset[];
public errors: AssetProfileIdentifier[];
public hasError: boolean;
public hasImpersonationId: 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="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="de"
<mat-option value="ca"
>Català (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
}
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="zh"
>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'
})
export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[];
public dataSource: MatTableDataSource<Activity>;
public deviceType: string;
public hasImpersonationId: boolean;
@ -66,13 +65,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
if (params['createDialog']) {
this.openCreateActivityDialog();
} else if (params['editDialog']) {
if (this.activities) {
const activity = this.activities.find(({ id }) => {
return id === params['activityId'];
});
this.openUpdateActivityDialog(activity);
} else if (this.dataSource) {
if (this.dataSource && params['activityId']) {
const activity = this.dataSource.data.find(({ id }) => {
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 mode: 'create' | 'update';
public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = [];
public separatorKeysCodes: number[] = [COMMA, ENTER];
public tagsAvailable: Tag[] = [];
public total = 0;
public typesTranslationMap = new Map<Type, string>();
public Validators = Validators;
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms;
this.tags = tags.map(({ id, name }) => {
this.tagsAvailable = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
].valueChanges.pipe(
startWith(this.activityForm.get('tags').value),
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) {
this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []),
this.tags.find(({ id }) => {
this.tagsAvailable.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
}
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map((tag) => {
return tag.id;
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tags.filter((tag) => {
return !tagIds.includes(tag.id);
return this.tagsAvailable.filter(({ 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-form-field>
</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-label i18n>Tags</mat-label>
<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
matChipRemove
[removable]="true"
@ -404,7 +404,7 @@
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag) {
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</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 {
public accounts: CreateAccountDto[] = [];
public activities: Activity[] = [];
public assetProfileForm: FormGroup;
public dataSource: MatTableDataSource<Activity>;
public details: any[] = [];
public deviceType: string;
@ -53,7 +54,6 @@ export class ImportActivitiesDialog implements OnDestroy {
public sortDirection: SortDirection = 'desc';
public stepperOrientation: StepperOrientation;
public totalItems: number;
public uniqueAssetForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
@ -73,8 +73,8 @@ export class ImportActivitiesDialog implements OnDestroy {
this.stepperOrientation =
this.deviceType === 'mobile' ? 'vertical' : 'horizontal';
this.uniqueAssetForm = this.formBuilder.group({
uniqueAsset: [undefined, Validators.required]
this.assetProfileForm = this.formBuilder.group({
assetProfileIdentifier: [undefined, Validators.required]
});
if (
@ -85,7 +85,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.dialogTitle = $localize`Import Dividends`;
this.mode = 'DIVIDEND';
this.uniqueAssetForm.get('uniqueAsset').disable();
this.assetProfileForm.get('assetProfileIdentifier').disable();
this.dataService
.fetchPortfolioHoldings({
@ -102,7 +102,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.holdings = sortBy(holdings, ({ name }) => {
return name.toLowerCase();
});
this.uniqueAssetForm.get('uniqueAsset').enable();
this.assetProfileForm.get('assetProfileIdentifier').enable();
this.isLoading = false;
@ -167,10 +167,11 @@ export class ImportActivitiesDialog implements OnDestroy {
}
public onLoadDividends(aStepper: MatStepper) {
this.uniqueAssetForm.get('uniqueAsset').disable();
this.assetProfileForm.get('assetProfileIdentifier').disable();
const { dataSource, symbol } =
this.uniqueAssetForm.get('uniqueAsset').value;
const { dataSource, symbol } = this.assetProfileForm.get(
'assetProfileIdentifier'
).value;
this.dataService
.fetchDividendsImport({
@ -193,7 +194,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.details = [];
this.errorMessages = [];
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.uniqueAssetForm.get('uniqueAsset').enable();
this.assetProfileForm.get('assetProfileIdentifier').enable();
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">
@if (mode === 'DIVIDEND') {
<form
[formGroup]="uniqueAssetForm"
[formGroup]="assetProfileForm"
(ngSubmit)="onLoadDividends(stepper)"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset">
<mat-select formControlName="assetProfileIdentifier">
<mat-select-trigger>{{
uniqueAssetForm.get('uniqueAsset')?.value?.name
assetProfileForm.get('assetProfileIdentifier')?.value?.name
}}</mat-select-trigger>
@for (holding of holdings; track holding) {
<mat-option
@ -63,7 +63,7 @@
color="primary"
mat-flat-button
type="submit"
[disabled]="!uniqueAssetForm.valid"
[disabled]="!assetProfileForm.valid"
>
<span i18n>Load Dividends</span>
</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 { prettifySymbol } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Holding,
PortfolioDetails,
PortfolioPosition,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
@ -161,7 +161,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.initialize();
}
public onAccountChartClicked({ symbol }: UniqueAsset) {
public onAccountChartClicked({ symbol }: AssetProfileIdentifier) {
if (symbol && symbol !== UNKNOWN_KEY) {
this.router.navigate([], {
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) {
this.router.navigate([], {
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
AdminData,
AdminJobs,
AdminMarketData,
AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter,
UniqueAsset
Filter
} from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http';
@ -35,7 +35,7 @@ export class AdminService {
private http: HttpClient
) {}
public addAssetProfile({ dataSource, symbol }: UniqueAsset) {
public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.post<void>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
null
@ -62,7 +62,7 @@ export class AdminService {
return this.http.delete<void>(`/api/v1/platform/${aId}`);
}
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<void>(
`/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', {});
}
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
public gatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.http.post<void>(
`/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`,
{}
@ -178,7 +181,7 @@ export class AdminService {
dataSource,
date,
symbol
}: UniqueAsset & {
}: AssetProfileIdentifier & {
date?: Date;
}) {
let url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
@ -217,7 +220,7 @@ export class AdminService {
symbol,
symbolMapping,
url
}: UniqueAsset & UpdateAssetProfileDto) {
}: AssetProfileIdentifier & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
{
@ -272,7 +275,7 @@ export class AdminService {
dataSource,
scraperConfiguration,
symbol
}: UniqueAsset & UpdateAssetProfileDto['scraperConfiguration']) {
}: AssetProfileIdentifier & UpdateAssetProfileDto['scraperConfiguration']) {
return this.http.post<any>(
`/api/v1/admin/market-data/${dataSource}/${symbol}/test`,
{

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

@ -20,6 +20,7 @@ import {
AccountBalancesResponse,
Accounts,
AdminMarketDataDetails,
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkResponse,
Export,
@ -34,7 +35,6 @@ import {
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
import {
AccountBalance,
DataSource,
Order as OrderModel
Order as OrderModel,
Tag
} from '@prisma/client';
import { format, parseISO } from 'date-fns';
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>(
`/api/v1/import/dividends/${dataSource}/${symbol}`
);
@ -269,7 +270,7 @@ export class DataService {
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}`);
}
@ -288,7 +289,7 @@ export class DataService {
public fetchAsset({
dataSource,
symbol
}: UniqueAsset): Observable<AdminMarketDataDetails> {
}: AssetProfileIdentifier): Observable<AdminMarketDataDetails> {
return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe(
map((data) => {
for (const item of data.marketData) {
@ -307,7 +308,7 @@ export class DataService {
}: {
range: DateRange;
startDate: Date;
} & UniqueAsset): Observable<BenchmarkMarketDataDetails> {
} & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> {
let params = new HttpParams();
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);
}
@ -649,6 +650,17 @@ export class DataService {
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) {
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();
@include mat.core();
// Create default theme
$gf-theme-default: mat.m2-define-light-theme(
(
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,
typography: $gf-typography
)
);
@include mat.all-component-themes($gf-theme-default);
@include mat.button-density(0);
@include mat.table-density(-1);
// Create dark theme
$gf-theme-dark: mat.m2-define-dark-theme(
(
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,
typography: $gf-typography
)
);
.is-dark-theme {
@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 {
--gf-theme-alpha-hover: 0.04;
--gf-theme-primary-500: #36cfcc;

7
docker/docker-compose.build.yml

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

5
docker/docker-compose.dev.yml

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

29
docker/docker-compose.yml

@ -1,12 +1,16 @@
services:
ghostfolio:
image: ghostfolio/ghostfolio:latest
image: docker.io/ghostfolio/ghostfolio:latest
init: true
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- ../.env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
NODE_ENV: production
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports:
@ -16,8 +20,19 @@ services:
condition: service_healthy
redis:
condition: service_healthy
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
healthcheck:
@ -27,8 +42,14 @@ services:
retries: 5
volumes:
- postgres:/var/lib/postgresql/data
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
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,
locale
} from './config';
import { Benchmark, UniqueAsset } from './interfaces';
import { AssetProfileIdentifier, Benchmark } from './interfaces';
import { BenchmarkTrend, ColorScheme } from './types';
export const DATE_FORMAT = 'yyyy-MM-dd';
@ -147,7 +147,10 @@ export function getAllActivityTypes(): ActivityType[] {
return Object.values(ActivityType);
}
export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) {
export function getAssetProfileIdentifier({
dataSource,
symbol
}: AssetProfileIdentifier) {
return `${dataSource}-${symbol}`;
}
@ -377,7 +380,7 @@ export function parseDate(date: string): Date | null {
return parseISO(date);
}
export function parseSymbol({ dataSource, symbol }: UniqueAsset) {
export function parseSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
const [ticker, exchange] = symbol.split('.');
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 {
exchangeRates: ({
label1: string;
label2: string;
value: number;
} & UniqueAsset)[];
} & AssetProfileIdentifier)[];
settings: { [key: string]: boolean | object | string | string[] };
transactionCount: 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';
export interface UniqueAsset {
export interface AssetProfileIdentifier {
dataSource: DataSource;
symbol: string;
}

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

@ -7,6 +7,7 @@ import type {
AdminMarketData,
AdminMarketDataItem
} from './admin-market-data.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import type { BenchmarkProperty } from './benchmark-property.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 { SystemMessage } from './system-message.interface';
import type { TabConfiguration } from './tab-configuration.interface';
import type { UniqueAsset } from './unique-asset.interface';
import type { UserSettings } from './user-settings.interface';
import type { User } from './user.interface';
@ -61,6 +61,7 @@ export {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty,
@ -101,7 +102,6 @@ export {
Subscription,
SymbolMetrics,
TabConfiguration,
UniqueAsset,
User,
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 {
errors?: UniqueAsset[];
errors?: AssetProfileIdentifier[];
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 {
annualInterestRate?: number;
@ -9,6 +14,7 @@ export interface UserSettings {
emergencyFund?: number;
'filters.accounts'?: string[];
'filters.tags'?: string[];
holdingsViewMode?: HoldingsViewMode;
isExperimentalFeatures?: boolean;
isRestrictedView?: boolean;
language?: string;

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

@ -1,5 +1,5 @@
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 { Big } from 'big.js';
@ -9,7 +9,7 @@ export class PortfolioSnapshot {
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
currentValueInBaseCurrency: Big;
errors?: UniqueAsset[];
errors?: AssetProfileIdentifier[];
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)

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

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

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

@ -1,5 +1,9 @@
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 { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator';
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([], {
queryParams: { dataSource, symbol, benchmarkDetailDialog: true }
});
@ -95,7 +99,10 @@ export class GfBenchmarkComponent implements OnChanges, OnDestroy {
this.unsubscribeSubject.complete();
}
private openBenchmarkDetailDialog({ dataSource, symbol }: UniqueAsset) {
private openBenchmarkDetailDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
const dialogRef = this.dialog.open(GfBenchmarkDetailDialogComponent, {
data: <BenchmarkDetailDialogParams>{
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 { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
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 { 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) {
this.router.navigate([], {
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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
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 { 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>;

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

882
package-lock.json

File diff suppressed because it is too large

24
package.json

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

Loading…
Cancel
Save