Browse Source

Merge branch 'main' into task/upgrade-nx-to-version-22.7.5

pull/6962/head
Thomas Kaul 3 months ago
committed by GitHub
parent
commit
b315260d44
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 44
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 272
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  4. 34
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 3
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  6. 2
      apps/api/src/services/fetch/fetch.module.ts
  7. 154
      apps/api/src/services/fetch/fetch.service.ts
  8. 19
      apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts
  9. 24
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  10. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  11. 3
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  12. 50
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  13. 16
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  14. 5
      apps/client/src/app/pages/public/public-page.component.ts
  15. 1
      libs/common/src/lib/config.ts
  16. 47
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  17. 41
      libs/ui/src/lib/assistant/assistant.component.ts
  18. 200
      libs/ui/src/lib/mocks/holdings.ts
  19. 9
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  20. 3
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

6
CHANGELOG.md

@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService`
### Changed
- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_
- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response
- Upgraded `Nx` from version `22.7.2` to `22.7.5`
## 3.6.0 - 2026-05-28

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

@ -144,10 +144,10 @@ export class PortfolioController {
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => {
.filter(({ assetProfile }) => {
return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
assetProfile.assetClass !== AssetClass.LIQUIDITY &&
assetProfile.assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
@ -217,37 +217,41 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
assetProfile: {
...portfolioPosition.assetProfile,
assetClass:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel
: undefined,
...(hasDetails
? {}
: {
assetClass: undefined,
assetClassLabel: undefined,
assetSubClass: undefined,
assetSubClassLabel: undefined,
countries: [],
currency: undefined,
holdings: [],
sectors: []
})
},
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
: undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
: undefined
};
}

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

@ -0,0 +1,272 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let impersonationService: ImpersonationService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioService: PortfolioService;
let symbolProfileService: SymbolProfileService;
let userService: UserService;
beforeEach(() => {
configurationService = new ConfigurationService();
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountService = new AccountService(
null,
null,
exchangeRateDataService,
null
);
activitiesService = new ActivitiesService(
null,
accountService,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null
);
impersonationService = new ImpersonationService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
null,
exchangeRateDataService,
null,
null
);
symbolProfileService = new SymbolProfileService(null);
userService = new UserService(
null,
null,
null,
null,
null,
null,
null,
null
);
portfolioService = new PortfolioService(
null,
accountService,
activitiesService,
null,
portfolioCalculatorFactory,
dataProviderService,
exchangeRateDataService,
null,
impersonationService,
null,
null,
symbolProfileService,
userService
);
});
describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
const cashDetails: CashDetails = {
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
};
const assetProfiles = (
portfolioService as unknown as {
getCashSymbolProfiles: (
aCashDetails: CashDetails
) => { dataSource: DataSource; symbol: string }[];
}
).getCashSymbolProfiles(cashDetails);
expect(assetProfiles).toHaveLength(1);
expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO);
expect(assetProfiles[0].symbol).toBe('USD');
});
});
describe('getDetails', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID();
const cashAccount: Account = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
};
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [cashAccount],
balanceInBaseCurrency: 1820
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest
.spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]);
jest.spyOn(userService, 'user').mockResolvedValue({
accessesGet: [],
accounts: [],
activityCount: 0,
dataProviderGhostfolioDailyRequests: 0,
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
const usdPosition = {
activitiesCount: 1,
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dateOfFirstActivity: '2024-01-01',
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1820),
marketPrice: 1,
marketPriceInBaseCurrency: 0.91,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000),
symbol: 'USD',
tags: [],
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
valueInBaseCurrency: new Big(1820)
};
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({
activitiesCount: 1,
createdAt: parseDate('2024-01-01'),
currentValueInBaseCurrency: new Big(1820),
errors: [],
hasErrors: false,
historicalData: [],
positions: [usdPosition],
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
totalInvestmentWithCurrencyEffect: new Big(1820),
totalLiabilitiesWithCurrencyEffect: new Big(0)
})
} as unknown as ReturnType<
typeof portfolioCalculatorFactory.createCalculator
>);
jest
.spyOn(
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: () => Promise<{
accounts: object;
platforms: object;
}>;
},
'getValueOfAccountsAndPlatforms'
)
.mockResolvedValue({ accounts: {}, platforms: {} });
const { holdings } = await portfolioService.getDetails({
filters: [],
impersonationId: userDummyData.id,
userId: userDummyData.id
});
expect(holdings['USD']).toBeDefined();
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO);
expect(holdings['USD'].assetProfile.symbol).toBe('USD');
});
});
});

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

@ -584,7 +584,6 @@ export class PortfolioService {
for (const {
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
dividend,
@ -638,16 +637,13 @@ export class PortfolioService {
holdings[symbol] = {
activitiesCount,
currency,
markets,
marketsAdvanced,
marketPrice,
symbol,
tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
@ -670,9 +666,6 @@ export class PortfolioService {
symbol: assetProfile.symbol,
url: assetProfile.url
},
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0,
@ -681,19 +674,7 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(),
name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
@ -703,8 +684,6 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
valueInBaseCurrency: valueInBaseCurrency.toNumber()
};
}
@ -1472,8 +1451,8 @@ export class PortfolioService {
for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
if (position.assetProfile.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency +=
@ -1719,11 +1698,8 @@ export class PortfolioService {
currency: string;
}): PortfolioPosition {
return {
currency,
activitiesCount: 0,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
@ -1735,25 +1711,19 @@ export class PortfolioService {
sectors: [],
symbol: currency
},
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
valueInBaseCurrency: balance
};

3
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -13,7 +13,8 @@ import { countries } from 'countries-list';
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = {
'Russian Federation': 'Russia'
'Russian Federation': 'Russia',
USA: 'United States'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = {

2
apps/api/src/services/fetch/fetch.module.ts

@ -1,9 +1,11 @@
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
@Module({
exports: [FetchService],
imports: [PropertyModule],
providers: [FetchService]
})
export class FetchModule {}

154
apps/api/src/services/fetch/fetch.service.ts

@ -1,15 +1,35 @@
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL,
PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText, jsonSchema, tool } from 'ai';
import ms from 'ms';
import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable()
export class FetchService {
export class FetchService implements OnModuleInit {
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
private webFetchRoutes: WebFetchRoute[] = [];
public constructor(private readonly propertyService: PropertyService) {}
public async fetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
public async onModuleInit() {
this.webFetchRoutes =
(await this.propertyService.getByKey<WebFetchRoute[]>(
PROPERTY_WEB_FETCH_ROUTES
)) ?? [];
}
public async fetch(input: RequestInfo | URL, init?: RequestInit) {
const method = (
init?.method ??
(input instanceof Request ? input.method : undefined) ??
@ -21,6 +41,21 @@ export class FetchService {
Logger.debug(`${method} ${urlRedacted}`, 'FetchService');
if (method === 'GET') {
const webFetchRoute = this.getMatchingWebFetchRoute(url);
if (webFetchRoute) {
const response = await this.fetchViaWebFetchTool({
url,
webFetchRoute
});
if (response) {
return response;
}
}
}
try {
return await globalThis.fetch(input, init);
} catch (error) {
@ -40,6 +75,113 @@ export class FetchService {
}
}
private async fetchViaWebFetchTool({
url,
webFetchRoute
}: {
url: string;
webFetchRoute: WebFetchRoute;
}) {
const [openRouterApiKey, openRouterModel] = await Promise.all([
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL)
]);
if (!openRouterApiKey || !openRouterModel) {
return undefined;
}
try {
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
const { sources, text } = await generateText({
model: openRouterService.chat(openRouterModel),
prompt: [
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.',
'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.',
`URL: ${url}`
].join('\n'),
timeout: FetchService.WEB_FETCH_TIMEOUT,
tools: {
// Provider-defined tool: lets OpenRouter perform the actual web
// request server-side via its `web_fetch` engine. `id` and `args`
// are the OpenRouter-specific identifiers; the input schema is left
// open as the arguments are supplied by the model.
web_fetch: tool({
args: { engine: 'openrouter' },
id: 'openrouter.web_fetch',
inputSchema: jsonSchema({
additionalProperties: true,
type: 'object'
}),
type: 'provider'
})
}
});
const candidates = [
...(sources ?? []).map((source) => {
return source.providerMetadata?.openrouter?.content;
}),
text
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') {
continue;
}
const body = candidate.trim();
if (!body) {
continue;
}
if (webFetchRoute.responseContentType?.includes('application/json')) {
try {
JSON.parse(body);
} catch {
continue;
}
}
Logger.debug(
`Routed ${this.redactUrl(url)} via web fetch tool`,
'FetchService'
);
return new Response(body, {
headers: webFetchRoute.responseContentType
? { 'content-type': webFetchRoute.responseContentType }
: undefined
});
}
return undefined;
} catch (error) {
Logger.error(
`Web fetch tool failed for ${this.redactUrl(url)}: ${
error instanceof Error ? error.message : String(error)
}`,
'FetchService'
);
return undefined;
}
}
private getMatchingWebFetchRoute(url: string) {
try {
const { hostname } = new URL(url);
return this.webFetchRoutes.find(({ domain }) => {
return hostname === domain || hostname.endsWith(`.${domain}`);
});
} catch {
return undefined;
}
}
private redactUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);

19
apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts

@ -0,0 +1,19 @@
/**
* Routes outgoing GET requests for a given domain through the OpenRouter
* `web_fetch` tool instead of a direct network request.
*
* Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g.
*
* [
* {
* "domain": "example.com",
* "responseContentType": "application/json"
* }
* ]
*
* Matches the domain itself and its subdomains (e.g. `api.example.com`).
*/
export interface WebFetchRoute {
domain: string;
responseContentType?: string;
}

24
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,28 +1,34 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
import { Directive, output } from '@angular/core';
@Directive({
host: {
'(dragenter)': 'onDragEnter($event)',
'(dragover)': 'onDragOver($event)',
'(drop)': 'onDrop($event)'
},
selector: '[gfFileDrop]'
})
export class GfFileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>();
public readonly filesDropped = output<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) {
public onDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) {
public onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('drop', ['$event']) onDrop(event: DragEvent) {
public onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files);
if (event.dataTransfer) {
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files);
}
}
}

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

@ -139,7 +139,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
return !['CASH'].includes(assetProfile.assetSubClass);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
return a.assetProfile.name?.localeCompare(b.assetProfile.name);
})
.map(({ assetProfile }) => {
return {

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

@ -226,7 +226,8 @@ export class GfImportActivitiesDialogComponent {
this.assetProfileForm.controls.assetProfileIdentifier.disable();
const { dataSource, symbol } =
this.assetProfileForm.controls.assetProfileIdentifier.value ?? {};
this.assetProfileForm.controls.assetProfileIdentifier.value
?.assetProfile ?? {};
if (!dataSource || !symbol) {
return;

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

@ -73,15 +73,14 @@ export class GfAllocationsPageComponent implements OnInit {
public hasImpersonationId: boolean;
public holdings: {
[symbol: string]: Pick<
PortfolioPosition,
PortfolioPosition['assetProfile'],
| 'assetClass'
| 'assetClassLabel'
| 'assetSubClass'
| 'assetSubClassLabel'
| 'currency'
| 'exchange'
| 'name'
> & { etfProvider: string; value: number };
> & { etfProvider: string; exchange?: string; value: number };
};
public isLoading = false;
public markets: {
@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit {
assetSubClass,
name
}: {
assetSubClass: PortfolioPosition['assetSubClass'];
assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
name: string;
}) {
if (assetSubClass === 'ETF') {
@ -333,24 +332,27 @@ export class GfAllocationsPageComponent implements OnInit {
this.holdings[symbol] = {
value,
assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetClassLabel || UNKNOWN_KEY,
assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY,
currency: position.currency,
assetClass:
position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY,
assetSubClass:
position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel:
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: position.assetProfile.currency,
etfProvider: this.extractEtfProvider({
assetSubClass: position.assetSubClass,
name: position.name
assetSubClass: position.assetProfile.assetSubClass,
name: position.assetProfile.name
}),
exchange: position.exchange,
name: position.name
name: position.assetProfile.name
};
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (position.countries.length > 0) {
for (const country of position.countries) {
if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) {
const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) {
@ -401,12 +403,12 @@ export class GfAllocationsPageComponent implements OnInit {
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
if (position.holdings.length > 0) {
if (position.assetProfile.holdings.length > 0) {
for (const {
allocationInPercentage,
name,
valueInBaseCurrency
} of position.holdings) {
} of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) {
@ -428,8 +430,8 @@ export class GfAllocationsPageComponent implements OnInit {
}
}
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
@ -463,8 +465,8 @@ export class GfAllocationsPageComponent implements OnInit {
}
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource,
name: position.name,
dataSource: position.assetProfile.dataSource,
name: position.assetProfile.name,
symbol: prettifySymbol(symbol),
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
@ -517,8 +519,8 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => {
if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find(
if (holding.assetProfile.holdings.length > 0) {
const currentParentHolding = holding.assetProfile.holdings.find(
(parentHolding) => {
return (
this.normalizeAssetName(parentHolding.name) ===
@ -531,7 +533,7 @@ export class GfAllocationsPageComponent implements OnInit {
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.name,
name: holding.assetProfile.name,
position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency:

16
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -310,13 +310,15 @@
<a
class="d-flex"
[queryParams]="{
dataSource: holding.dataSource,
dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true,
symbol: holding.symbol
symbol: holding.assetProfile.symbol
}"
[routerLink]="[]"
>
<div class="flex-grow-1 mr-2">{{ holding.name }}</div>
<div class="flex-grow-1 mr-2">
{{ holding.assetProfile.name }}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
@ -359,13 +361,15 @@
<a
class="d-flex"
[queryParams]="{
dataSource: holding.dataSource,
dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true,
symbol: holding.symbol
symbol: holding.assetProfile.symbol
}"
[routerLink]="[]"
>
<div class="flex-grow-1 mr-2">{{ holding.name }}</div>
<div class="flex-grow-1 mr-2">
{{ holding.assetProfile.name }}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"

5
apps/client/src/app/pages/public/public-page.component.ts

@ -74,7 +74,10 @@ export class GfPublicPageComponent implements OnInit {
};
protected readonly pageSize = Number.MAX_SAFE_INTEGER;
protected positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
[symbol: string]: Pick<
PortfolioPosition['assetProfile'],
'currency' | 'name'
> & {
value: number;
};
};

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

@ -256,6 +256,7 @@ export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
export const PROPERTY_UPTIME = 'UPTIME';
export const PROPERTY_WEB_FETCH_ROUTES = 'WEB_FETCH_ROUTES';
export const QUEUE_JOB_STATUS_LIST = [
'active',

47
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -1,22 +1,12 @@
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
import { Country } from './country.interface';
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
import { Holding } from './holding.interface';
import { Sector } from './sector.interface';
export interface PortfolioPosition {
activitiesCount: number;
allocationInPercentage: number;
/** @deprecated */
assetClass?: AssetClass;
/** @deprecated */
assetClassLabel?: string;
assetProfile: Pick<
EnhancedSymbolProfile,
| 'assetClass'
@ -33,22 +23,6 @@ export interface PortfolioPosition {
assetClassLabel?: string;
assetSubClassLabel?: string;
};
/** @deprecated */
assetSubClass?: AssetSubClass;
/** @deprecated */
assetSubClassLabel?: string;
/** @deprecated */
countries: Country[];
/** @deprecated */
currency: string;
/** @deprecated */
dataSource: DataSource;
dateOfFirstActivity: Date;
dividend: number;
exchange?: string;
@ -56,38 +30,19 @@ export interface PortfolioPosition {
grossPerformancePercent: number;
grossPerformancePercentWithCurrencyEffect: number;
grossPerformanceWithCurrencyEffect: number;
/** @deprecated */
holdings: Holding[];
investment: number;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number;
markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: number };
/** @deprecated */
name: string;
netPerformance: number;
netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number;
quantity: number;
/** @deprecated */
sectors: Sector[];
/** @deprecated */
symbol: string;
tags?: Tag[];
type?: string;
/** @deprecated */
url?: string;
valueInBaseCurrency?: number;
valueInPercentage?: number;
}

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

@ -504,11 +504,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = holdings
.filter(({ assetSubClass }) => {
return assetSubClass && !['CASH'].includes(assetSubClass);
.filter(({ assetProfile }) => {
return (
assetProfile.assetSubClass &&
!['CASH'].includes(assetProfile.assetSubClass)
);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
return (a.assetProfile.name ?? '').localeCompare(
b.assetProfile.name ?? ''
);
});
this.setPortfolioFilterFormValues();
@ -530,11 +535,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
type: 'ASSET_CLASS'
},
{
id: filterValue?.holding?.dataSource ?? '',
id: filterValue?.holding?.assetProfile?.dataSource ?? '',
type: 'DATA_SOURCE'
},
{
id: filterValue?.holding?.symbol ?? '',
id: filterValue?.holding?.assetProfile?.symbol ?? '',
type: 'SYMBOL'
},
{
@ -718,18 +723,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return EMPTY;
}),
map(({ holdings }) => {
return holdings.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass ?? ''),
mode: SearchMode.HOLDING as const
};
}
);
return holdings.map(({ assetProfile }) => {
return {
assetSubClassString: translate(assetProfile.assetSubClass ?? ''),
currency: assetProfile.currency ?? '',
dataSource: assetProfile.dataSource,
mode: SearchMode.HOLDING as const,
name: assetProfile.name ?? '',
symbol: assetProfile.symbol
};
});
}),
takeUntilDestroyed(this.destroyRef)
);
@ -777,8 +780,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return (
!!(dataSource && symbol) &&
getAssetProfileIdentifier({
dataSource: holding.dataSource,
symbol: holding.symbol
dataSource: holding.assetProfile.dataSource,
symbol: holding.assetProfile.symbol
}) === getAssetProfileIdentifier({ dataSource, symbol })
);
});

200
libs/ui/src/lib/mocks/holdings.ts

@ -4,11 +4,11 @@ export const holdings: PortfolioPosition[] = [
{
activitiesCount: 1,
allocationInPercentage: 0.042990776363386086,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -20,60 +20,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Apple Inc',
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'AAPL'
symbol: 'AAPL',
url: 'https://www.apple.com'
},
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 3856,
grossPerformancePercent: 0.46047289228564603,
grossPerformancePercentWithCurrencyEffect: 0.46047289228564603,
grossPerformanceWithCurrencyEffect: 3856,
holdings: [],
investment: 8374,
marketPrice: 244.6,
name: 'Apple Inc',
netPerformance: 3855,
netPerformancePercent: 0.460353475041796,
netPerformancePercentWithCurrencyEffect: 0.036440677966101696,
netPerformanceWithCurrencyEffect: 430,
quantity: 50,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'AAPL',
tags: [],
url: 'https://www.apple.com',
valueInBaseCurrency: 12230
},
{
activitiesCount: 2,
allocationInPercentage: 0.02377401948293552,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'DE',
@ -85,60 +65,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'EUR',
dataSource: 'YAHOO',
holdings: [],
name: 'Allianz SE',
sectors: [
{
name: 'Financial Services',
weight: 1
}
],
symbol: 'ALV.DE'
symbol: 'ALV.DE',
url: 'https://www.allianz.com'
},
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'DE',
continent: 'Europe',
name: 'Germany',
weight: 1
}
],
currency: 'EUR',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'),
dividend: 192,
grossPerformance: 2226.700251889169,
grossPerformancePercent: 0.49083842309827874,
grossPerformancePercentWithCurrencyEffect: 0.29306136948826367,
grossPerformanceWithCurrencyEffect: 1532.8272791336772,
holdings: [],
investment: 4536.523929471033,
marketPrice: 322.2,
name: 'Allianz SE',
netPerformance: 2222.2921914357685,
netPerformancePercent: 0.48986674069961134,
netPerformancePercentWithCurrencyEffect: 0.034489367670592026,
netPerformanceWithCurrencyEffect: 225.48257403052068,
quantity: 20,
sectors: [
{
name: 'Financial Services',
weight: 1
}
],
symbol: 'ALV.DE',
tags: [],
url: 'https://www.allianz.com',
valueInBaseCurrency: 6763.224181360202
},
{
activitiesCount: 1,
allocationInPercentage: 0.08038536990007467,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -150,101 +110,73 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Amazon.com, Inc.',
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'AMZN'
symbol: 'AMZN',
url: 'https://www.aboutamazon.com'
},
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 12758.05,
grossPerformancePercent: 1.2619300787837724,
grossPerformancePercentWithCurrencyEffect: 1.2619300787837724,
grossPerformanceWithCurrencyEffect: 12758.05,
holdings: [],
investment: 10109.95,
marketPrice: 228.68,
name: 'Amazon.com, Inc.',
netPerformance: 12677.26,
netPerformancePercent: 1.253938941339967,
netPerformancePercentWithCurrencyEffect: -0.037866008722316276,
netPerformanceWithCurrencyEffect: -899.99926757812,
quantity: 100,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'AMZN',
tags: [],
url: 'https://www.aboutamazon.com',
valueInBaseCurrency: 22868
},
{
activitiesCount: 1,
allocationInPercentage: 0.19216416482928922,
assetClass: 'LIQUIDITY',
assetClassLabel: 'Liquidity',
assetProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CASH',
assetClassLabel: 'Liquidity',
assetSubClass: 'CRYPTOCURRENCY',
assetSubClassLabel: 'Cryptocurrency',
countries: [],
currency: 'USD',
dataSource: 'COINGECKO',
holdings: [],
name: 'Bitcoin',
sectors: [],
symbol: 'bitcoin'
symbol: 'bitcoin',
url: undefined
},
assetSubClass: 'CRYPTOCURRENCY',
assetSubClassLabel: 'Cryptocurrency',
countries: [],
currency: 'USD',
dataSource: 'COINGECKO',
dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'),
dividend: 0,
grossPerformance: 52666.7898248,
grossPerformancePercent: 26.333394912400003,
grossPerformancePercentWithCurrencyEffect: 26.333394912400003,
grossPerformanceWithCurrencyEffect: 52666.7898248,
holdings: [],
investment: 1999.9999999999998,
marketPrice: 97364,
name: 'Bitcoin',
netPerformance: 52636.8898248,
netPerformancePercent: 26.3184449124,
netPerformancePercentWithCurrencyEffect: -0.04760906442310894,
netPerformanceWithCurrencyEffect: -2732.737808972287,
quantity: 0.5614682,
sectors: [],
symbol: 'bitcoin',
tags: [],
url: undefined,
valueInBaseCurrency: 54666.7898248
},
{
activitiesCount: 1,
allocationInPercentage: 0.04307127421937313,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -256,60 +188,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Microsoft Corporation',
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'MSFT'
symbol: 'MSFT',
url: 'https://www.microsoft.com'
},
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'),
dividend: 0,
grossPerformance: 5065.5,
grossPerformancePercent: 0.7047750229568411,
grossPerformancePercentWithCurrencyEffect: 0.7047750229568411,
grossPerformanceWithCurrencyEffect: 5065.5,
holdings: [],
investment: 7187.4,
marketPrice: 408.43,
name: 'Microsoft Corporation',
netPerformance: 5065.5,
netPerformancePercent: 0.7047750229568411,
netPerformancePercentWithCurrencyEffect: -0.015973588391056275,
netPerformanceWithCurrencyEffect: -198.899926757814,
quantity: 30,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'MSFT',
tags: [],
url: 'https://www.microsoft.com',
valueInBaseCurrency: 12252.9
},
{
activitiesCount: 1,
allocationInPercentage: 0.18762679306394897,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -321,60 +233,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Tesla, Inc.',
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'TSLA'
symbol: 'TSLA',
url: 'https://www.tesla.com'
},
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'),
dividend: 0,
grossPerformance: 51227.500000005,
grossPerformancePercent: 23.843379101756675,
grossPerformancePercentWithCurrencyEffect: 23.843379101756675,
grossPerformanceWithCurrencyEffect: 51227.500000005,
holdings: [],
investment: 2148.499999995,
marketPrice: 355.84,
name: 'Tesla, Inc.',
netPerformance: 51197.500000005,
netPerformancePercent: 23.829415871596066,
netPerformancePercentWithCurrencyEffect: -0.12051410125545206,
netPerformanceWithCurrencyEffect: -7314.00091552734,
quantity: 150,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'TSLA',
tags: [],
url: 'https://www.tesla.com',
valueInBaseCurrency: 53376
},
{
activitiesCount: 5,
allocationInPercentage: 0.053051250766657634,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'ETF',
assetSubClassLabel: 'ETF',
countries: [
{
code: 'US',
@ -386,50 +278,30 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
sectors: [
{
name: 'Equity',
weight: 1
}
],
symbol: 'VTI'
symbol: 'VTI',
url: 'https://www.vanguard.com'
},
assetSubClass: 'ETF',
assetSubClassLabel: 'ETF',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 6845.8,
grossPerformancePercent: 1.0164758094605268,
grossPerformancePercentWithCurrencyEffect: 1.0164758094605268,
grossPerformanceWithCurrencyEffect: 6845.8,
holdings: [],
investment: 8246.2,
marketPrice: 301.84,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
netPerformance: 6746.3,
netPerformancePercent: 1.0017018833976383,
netPerformancePercentWithCurrencyEffect: 0.01085061564051406,
netPerformanceWithCurrencyEffect: 161.99969482422,
quantity: 50,
sectors: [
{
name: 'Equity',
weight: 1
}
],
symbol: 'VTI',
tags: [],
url: 'https://www.vanguard.com',
valueInBaseCurrency: 15092
}
];

9
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html

@ -29,18 +29,19 @@
[compareWith]="holdingComparisonFunction"
>
<mat-select-trigger>{{
filterForm.get('holding')?.value?.name
filterForm.get('holding')?.value?.assetProfile?.name
}}</mat-select-trigger>
<mat-option [value]="null" />
@for (holding of holdings(); track holding.name) {
@for (holding of holdings(); track holding.assetProfile.name) {
<mat-option [value]="holding">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
><b>{{ holding.assetProfile.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} · {{ holding.currency }}</small
>{{ holding.assetProfile.symbol | gfSymbol }} ·
{{ holding.assetProfile.currency }}</small
>
</div>
</mat-option>

3
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

@ -109,7 +109,8 @@ export class GfPortfolioFilterFormComponent
}
return (
getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value)
getAssetProfileIdentifier(option.assetProfile) ===
getAssetProfileIdentifier(value.assetProfile)
);
}

Loading…
Cancel
Save