Browse Source

Merge remote-tracking branch 'origin/main' into task/7393-activities-table-input-types

pull/8016/head
KenTandrian 2 weeks ago
parent
commit
0804bd6a23
  1. 7
      CHANGELOG.md
  2. 89
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  3. 24
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  4. 114
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 31
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  6. 7
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  7. 33
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  8. 170
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts
  9. 14
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  10. 123
      apps/api/src/helper/data-source.helper.spec.ts
  11. 65
      apps/api/src/helper/data-source.helper.ts
  12. 5
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  13. 10
      apps/api/src/services/benchmark/benchmark.service.spec.ts
  14. 13
      apps/api/src/services/benchmark/benchmark.service.ts
  15. 5
      apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts
  16. 4
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  17. 9
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  18. 196
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  19. 142
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  20. 3
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  21. 3
      apps/client/src/app/pages/public/public-page.component.ts
  22. 8
      libs/common/src/lib/dtos/create-asset-profile.dto.ts
  23. 5
      libs/common/src/lib/dtos/create-order.dto.ts
  24. 5
      libs/common/src/lib/dtos/update-order.dto.ts
  25. 47
      libs/common/src/lib/helper.spec.ts
  26. 16
      libs/common/src/lib/helper.ts
  27. 13
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  28. 12
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  29. 14
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

7
CHANGELOG.md

@ -9,10 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the label of the cash positions in the holdings charts and table
- Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental)
- Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0`
- Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `ai` from version `7.0.37` to `7.0.114`
- Upgraded `dotenv` from version `17.4.2` to `18.0.3` - Upgraded `dotenv` from version `17.4.2` to `18.0.3`
### Fixed
- Fixed the positive performance from the all time high in the watchlist
- Fixed the portfolio calculation for holdings with historical market prices between the chart dates
## 3.77.0 - 2026-10-02 ## 3.77.0 - 2026-10-02
### Added ### Added

89
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -1,3 +1,4 @@
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { import {
COMMENT_MAXIMUM_LENGTH, COMMENT_MAXIMUM_LENGTH,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
@ -18,6 +19,8 @@ import {
} from './mcp.schemas'; } from './mcp.schemas';
import { createActivity } from './mcp.test-utils'; import { createActivity } from './mcp.test-utils';
const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff';
describe('GET_ACCOUNTS_PARAMETERS', () => { describe('GET_ACCOUNTS_PARAMETERS', () => {
it('Refuses an empty symbol of the holding', () => { it('Refuses an empty symbol of the holding', () => {
expect( expect(
@ -34,6 +37,40 @@ describe('GET_ACCOUNTS_PARAMETERS', () => {
}).success }).success
).toBe(false); ).toBe(false);
}); });
it('Accepts an encoded data source of the holding', () => {
expect(
GET_ACCOUNTS_PARAMETERS.safeParse({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
}).success
).toBe(true);
});
it('Refuses an unknown encoded data source of the holding', () => {
expect(
GET_ACCOUNTS_PARAMETERS.safeParse({
holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' }
}).success
).toBe(false);
});
it('Gives the valid data sources in the error of an unknown data source of the holding', () => {
for (const dataSource of ['yahoo', 1]) {
const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({
holding: { dataSource, symbol: 'AAPL' }
});
expect(error.issues).toEqual([
expect.objectContaining({
message: expect.stringContaining(`|${DataSource.YAHOO}`),
path: ['holding', 'dataSource']
})
]);
}
});
}); });
describe('GET_ACTIVITIES_PARAMETERS', () => { describe('GET_ACTIVITIES_PARAMETERS', () => {
@ -52,6 +89,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => {
}).success }).success
).toBe(false); ).toBe(false);
}); });
it('Accepts an encoded data source of the holding', () => {
expect(
GET_ACTIVITIES_PARAMETERS.safeParse({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
}).success
).toBe(true);
});
it('Refuses an unknown encoded data source of the holding', () => {
expect(
GET_ACTIVITIES_PARAMETERS.safeParse({
holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' }
}).success
).toBe(false);
});
}); });
describe('GET_PERFORMANCE_PARAMETERS', () => { describe('GET_PERFORMANCE_PARAMETERS', () => {
@ -86,6 +142,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => {
}).success }).success
).toBe(false); ).toBe(false);
}); });
it('Accepts an encoded data source of the holding', () => {
expect(
GET_PERFORMANCE_PARAMETERS.safeParse({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
}).success
).toBe(true);
});
it('Refuses an unknown encoded data source of the holding', () => {
expect(
GET_PERFORMANCE_PARAMETERS.safeParse({
holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' }
}).success
).toBe(false);
});
}); });
describe('IMPORT_ACTIVITIES_PARAMETERS', () => { describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
@ -125,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true); expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
}); });
it('Accepts an encoded data source', () => {
expect(
parse([
createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) })
])
).toBe(true);
});
it('Refuses an unknown encoded data source', () => {
expect(
parse([createActivity({ dataSource: UNKNOWN_ENCODED_DATA_SOURCE })])
).toBe(false);
});
it('Refuses a date at or before the epoch', () => { it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
}); });

24
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -1,4 +1,5 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { import {
COMMENT_MAXIMUM_LENGTH, COMMENT_MAXIMUM_LENGTH,
DATE_RANGES, DATE_RANGES,
@ -17,6 +18,20 @@ import {
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`;
const DATA_SOURCE_PARAMETER = z
.union(
[
z.enum(DataSource),
z.string().refine(isValidEncodedDataSource, {
error: DATA_SOURCE_PARAMETER_ERROR
})
],
{ error: DATA_SOURCE_PARAMETER_ERROR }
)
.describe('The data source of the asset profile');
const SYMBOL_PARAMETER = z const SYMBOL_PARAMETER = z
.string() .string()
.trim() .trim()
@ -25,9 +40,7 @@ const SYMBOL_PARAMETER = z
.describe('The symbol of the asset profile'); .describe('The symbol of the asset profile');
const HOLDING_PARAMETER = z.object({ const HOLDING_PARAMETER = z.object({
dataSource: z dataSource: DATA_SOURCE_PARAMETER,
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: SYMBOL_PARAMETER symbol: SYMBOL_PARAMETER
}); });
@ -137,10 +150,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
.describe( .describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
), ),
dataSource: z dataSource: DATA_SOURCE_PARAMETER.optional(),
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
date: z date: z
.string() .string()
.refine(isValidDateAfter1970) .refine(isValidDateAfter1970)

114
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -2,6 +2,7 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-
import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
@ -128,6 +129,22 @@ describe('McpService', () => {
); );
}); });
it('Decodes an encoded data source', async () => {
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.getAccounts({
userId,
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith(
expect.objectContaining({ filterByDataSource: DataSource.YAHOO })
);
});
it('Keeps the data source if the subscription is not enabled', async () => { it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -192,6 +209,21 @@ describe('McpService', () => {
); );
}); });
it('Decodes an encoded data source', async () => {
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await getActivities({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith(
expect.objectContaining({ filterByDataSource: DataSource.YAHOO })
);
});
it('Keeps the data source if the subscription is not enabled', async () => { it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -275,6 +307,23 @@ describe('McpService', () => {
); );
}); });
it('Decodes an encoded data source', async () => {
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.getPerformance({
userId,
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
},
range: 'max'
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith(
expect.objectContaining({ filterByDataSource: DataSource.YAHOO })
);
});
it('Keeps the data source if the subscription is not enabled', async () => { it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -304,14 +353,15 @@ describe('McpService', () => {
}); });
describe('getPortfolio', () => { describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => { it('Gives the table of the holdings with the data source in the default language', async () => {
expect(await mcpService.getPortfolio({ userId })).toEqual({ expect(await mcpService.getPortfolio({ userId })).toEqual({
content: [{ text: '## Holdings', type: 'text' }] content: [{ text: '## Holdings', type: 'text' }]
}); });
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
userId, userId,
languageCode: DEFAULT_LANGUAGE_CODE languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
}); });
}); });
}); });
@ -342,9 +392,6 @@ describe('McpService', () => {
it('Gives the import-ready asset profiles available to the user', async () => { it('Gives the import-ready asset profiles available to the user', async () => {
const user = setupUser([permissions.createActivity]); const user = setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest.spyOn(symbolService, 'lookup').mockResolvedValue({ jest.spyOn(symbolService, 'lookup').mockResolvedValue({
items: [ items: [
{ {
@ -383,12 +430,46 @@ describe('McpService', () => {
assetClass: AssetClass.EQUITY, assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK, assetSubClass: AssetSubClass.STOCK,
currency: 'USD', currency: 'USD',
dataSource: DataSource.GHOSTFOLIO, dataSource: DataSource.YAHOO,
name: 'Apple Inc.',
symbol: 'AAPL'
}
]
});
});
it('Encodes the data source if the subscription is enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest.spyOn(symbolService, 'lookup').mockResolvedValue({
items: [
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataProviderInfo: { isPremium: false },
dataSource: DataSource.YAHOO,
name: 'Apple Inc.', name: 'Apple Inc.',
symbol: 'AAPL' symbol: 'AAPL'
} }
] ]
}); });
const result = await mcpService.searchAssetProfiles({
query: 'Apple',
userId
});
expect(JSON.parse(result.content[0].text)).toEqual({
assetProfiles: [
expect.objectContaining({
dataSource: encodeDataSource(DataSource.YAHOO)
})
]
});
}); });
}); });
@ -448,6 +529,27 @@ describe('McpService', () => {
); );
}); });
it('Decodes an encoded data source', async () => {
setupUser([permissions.createActivity]);
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.importActivities({
userId,
activities: [
createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) })
]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => { it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]); setupUser([permissions.createActivity]);

31
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -2,8 +2,8 @@ import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
getMaskedGhostfolioDataSource, transformDataSourceInRequest,
getUnmaskedGhostfolioDataSource transformDataSourceInResponse
} from '@ghostfolio/api/helper/data-source.helper'; } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -121,7 +121,8 @@ export class McpService {
public async getPortfolio({ userId }: { userId: string }) { public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({ const table = await this.portfolioTableService.getHoldingsTable({
userId, userId,
languageCode: DEFAULT_LANGUAGE_CODE languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
}); });
return this.getTextResult(table); return this.getTextResult(table);
@ -144,13 +145,11 @@ export class McpService {
permission: permissions.createActivity permission: permissions.createActivity
}); });
const ghostfolioDataSources = this.getGhostfolioDataSources();
const activitiesDto = activities.map((activity) => { const activitiesDto = activities.map((activity) => {
return { return {
...activity, ...activity,
dataSource: getUnmaskedGhostfolioDataSource({ dataSource: transformDataSourceInRequest({
ghostfolioDataSources, configurationService: this.configurationService,
dataSource: activity.dataSource dataSource: activity.dataSource
}) })
}; };
@ -188,8 +187,6 @@ export class McpService {
const { items } = await this.symbolService.lookup({ query, user }); const { items } = await this.symbolService.lookup({ query, user });
const ghostfolioDataSources = this.getGhostfolioDataSources();
const assetProfiles = items.flatMap( const assetProfiles = items.flatMap(
({ ({
assetClass, assetClass,
@ -211,9 +208,9 @@ export class McpService {
currency, currency,
name, name,
symbol, symbol,
dataSource: getMaskedGhostfolioDataSource({ dataSource: transformDataSourceInResponse({
dataSource, dataSource,
ghostfolioDataSources configurationService: this.configurationService
}) })
} }
]; ];
@ -223,19 +220,13 @@ export class McpService {
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2));
} }
private getGhostfolioDataSources() {
return this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
}
private getHoldingFilterParameters({ private getHoldingFilterParameters({
holding holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) { }: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {
return { return {
filterByDataSource: getUnmaskedGhostfolioDataSource({ filterByDataSource: transformDataSourceInRequest({
dataSource: holding?.dataSource, configurationService: this.configurationService,
ghostfolioDataSources: this.getGhostfolioDataSources() dataSource: holding?.dataSource
}), }),
filterBySymbol: holding?.symbol filterBySymbol: holding?.symbol
}; };

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

@ -140,8 +140,11 @@ export class WatchlistService {
name: assetProfile?.name, name: assetProfile?.name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
performancePercent, date: allTimeHigh?.date,
date: allTimeHigh?.date performancePercent:
this.benchmarkService.getPerformancePercentFromAllTimeHigh(
performancePercent
)
} }
}, },
trend50d: trends.trend50d, trend50d: trends.trend50d,

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

@ -791,6 +791,16 @@ export abstract class PortfolioCalculator {
activitiesByDate[activity.date].push(activity); activitiesByDate[activity.date].push(activity);
} }
// Carry forward the market prices of all dates, not only of the chart
// dates, as sparse market data (e.g. MANUAL) can be between two chart dates
const marketPriceDates = Object.keys(marketSymbolMap)
.filter((date) => {
return !!marketSymbolMap[date][assetProfileIdentifier];
})
.sort();
let indexOfMarketPriceDate = 0;
for (const dateString of chartDates) { for (const dateString of chartDates) {
if (dateString < startDateString) { if (dateString < startDateString) {
continue; continue;
@ -798,6 +808,18 @@ export abstract class PortfolioCalculator {
break; break;
} }
while (
indexOfMarketPriceDate < marketPriceDates.length &&
marketPriceDates[indexOfMarketPriceDate] <= dateString
) {
lastMarketPrice =
marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][
assetProfileIdentifier
];
indexOfMarketPriceDate += 1;
}
const activitiesOfDate = activitiesByDate[dateString]; const activitiesOfDate = activitiesByDate[dateString];
if (!lastMarketPrice && activitiesOfDate?.length > 0) { if (!lastMarketPrice && activitiesOfDate?.length > 0) {
@ -808,13 +830,8 @@ export abstract class PortfolioCalculator {
} }
} }
const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier];
const unitPrice = const unitPrice =
marketPrice ?? lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate;
lastMarketPrice ??
lastActivityUnitPrice ??
unitPriceAtEndDate;
if (activitiesOfDate?.length > 0) { if (activitiesOfDate?.length > 0) {
for (const activity of activitiesOfDate) { for (const activity of activitiesOfDate) {
@ -832,10 +849,6 @@ export abstract class PortfolioCalculator {
unitPriceFromMarketData: unitPrice unitPriceFromMarketData: unitPrice
}); });
} }
if (marketPrice) {
lastMarketPrice = marketPrice;
}
} }
// Sort the activities so that the start and end placeholder activities // Sort the activities so that the start and end placeholder activities

170
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts

@ -0,0 +1,170 @@
import {
activityDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it('with MANUAL buy and market price between two chart dates', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Private Investment',
symbol: '6c0c5cee-0208-4975-b473-03baf2518497'
},
date: parseDate('2021-01-04'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 10,
type: 'BUY',
unitPriceInAssetProfileCurrency: 50
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataByDate = Object.fromEntries(
portfolioSnapshot.historicalData.map((historicalDataItem) => {
return [historicalDataItem.date, historicalDataItem];
})
);
/**
* The only historical market prices are on 2023-06-14 and 2023-06-15,
* which are not chart dates (every third day from 2021-01-03)
*/
expect(historicalDataByDate['2023-06-14']).toBeUndefined();
expect(historicalDataByDate['2023-06-15']).toBeUndefined();
/**
* The chart dates before the market prices use the unit price of the
* activity: 50
*/
expect(historicalDataByDate['2023-06-13']).toMatchObject({
netPerformance: 0, // 10 * (50 - 50) = 0
totalInvestment: 500,
value: 500 // 10 * 50 = 500
});
/**
* The chart dates after the market prices carry the latest one forward
* (100), not the first one (80)
*/
expect(historicalDataByDate['2023-06-16']).toMatchObject({
netPerformance: 500, // 10 * (100 - 50) = 500
totalInvestment: 500,
value: 1000 // 10 * 100 = 1000
});
expect(historicalDataByDate['2024-01-30']).toMatchObject({
netPerformance: 500, // 10 * (100 - 50) = 500
totalInvestment: 500,
value: 1000 // 10 * 100 = 1000
});
/**
* The market price is unchanged since 2023-06-15, hence there is no
* performance today
*/
expect(portfolioSnapshot.positions[0]).toMatchObject({
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big(0)
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big(0),
max: new Big(500) // 10 * (100 - 50) = 500
}
});
});
});
});

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

@ -30,6 +30,20 @@ function mockGetValue({
return { marketPrice: 0 }; return { marketPrice: 0 };
case '6c0c5cee-0208-4975-b473-03baf2518497':
// Sparse market data with the quote of today, which falls back to the
// latest market price
if (isSameDay(parseDate('2023-06-14'), date)) {
return { marketPrice: 80 };
} else if (
isSameDay(parseDate('2023-06-15'), date) ||
isSameDay(parseDate('2024-01-31'), date)
) {
return { marketPrice: 100 };
}
return { marketPrice: 0 };
case 'BALN.SW': case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) { if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 }; return { marketPrice: 146 };

123
apps/api/src/helper/data-source.helper.spec.ts

@ -0,0 +1,123 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataSource } from '@prisma/client';
import {
encodeDataSource,
isValidEncodedDataSource,
transformDataSourceInRequest,
transformDataSourceInResponse
} from './data-source.helper';
function createConfigurationService({
isSubscriptionEnabled
}: {
isSubscriptionEnabled: boolean;
}) {
const configuration: Record<string, unknown> = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [DataSource.YAHOO],
ENABLE_FEATURE_SUBSCRIPTION: isSubscriptionEnabled
};
return {
get: jest.fn((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
}
describe('DataSourceHelper', () => {
describe('isValidEncodedDataSource', () => {
it('Accepts an encoded data source', () => {
expect(isValidEncodedDataSource(encodeDataSource(DataSource.YAHOO))).toBe(
true
);
});
it('Refuses a data source which is not encoded', () => {
expect(isValidEncodedDataSource(DataSource.YAHOO)).toBe(false);
});
it('Refuses an unknown encoded data source', () => {
expect(isValidEncodedDataSource('ffffffff')).toBe(false);
});
});
describe('transformDataSourceInRequest', () => {
const configurationService = createConfigurationService({
isSubscriptionEnabled: true
});
it('Decodes an encoded data source', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: encodeDataSource(DataSource.COINGECKO)
})
).toBe(DataSource.COINGECKO);
});
it('Resolves the mask of the data source of the Ghostfolio data provider', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: DataSource.GHOSTFOLIO
})
).toBe(DataSource.YAHOO);
});
it('Keeps a data source which is not encoded', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: DataSource.COINGECKO
})
).toBe(DataSource.COINGECKO);
});
it('Keeps an absent data source', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: undefined
})
).toBeUndefined();
});
});
describe('transformDataSourceInResponse', () => {
it('Encodes the data source if the subscription is enabled', () => {
expect(
transformDataSourceInResponse({
configurationService: createConfigurationService({
isSubscriptionEnabled: true
}),
dataSource: DataSource.YAHOO
})
).toBe(encodeDataSource(DataSource.YAHOO));
});
it('Keeps the data sources GHOSTFOLIO and MANUAL if the subscription is enabled', () => {
const configurationService = createConfigurationService({
isSubscriptionEnabled: true
});
for (const dataSource of [DataSource.GHOSTFOLIO, DataSource.MANUAL]) {
expect(
transformDataSourceInResponse({ configurationService, dataSource })
).toBe(dataSource);
}
});
it('Keeps the data source if the subscription is not enabled', () => {
expect(
transformDataSourceInResponse({
configurationService: createConfigurationService({
isSubscriptionEnabled: false
}),
dataSource: DataSource.YAHOO
})
).toBe(DataSource.YAHOO);
});
});
});

65
apps/api/src/helper/data-source.helper.ts

@ -1,6 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [
DataSource.GHOSTFOLIO,
DataSource.MANUAL
];
const encodedDataSourceByDataSource = new Map<DataSource, string>( const encodedDataSourceByDataSource = new Map<DataSource, string>(
Object.values(DataSource).map((dataSource) => { Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)]; return [dataSource, hashDataSource(dataSource)];
@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) {
return Buffer.from(dataSource, 'utf-8').toString('hex'); return Buffer.from(dataSource, 'utf-8').toString('hex');
} }
function getGhostfolioDataSources({
configurationService
}: {
configurationService: ConfigurationService;
}) {
return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')
? configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
}
function hashDataSource(dataSource: DataSource) { function hashDataSource(dataSource: DataSource) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
} }
@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({
? (ghostfolioDataSources[0] as DataSource) ? (ghostfolioDataSources[0] as DataSource)
: dataSource; : dataSource;
} }
export function isDataSourceEncodedInResponse(dataSource: DataSource) {
return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource);
}
export function isValidEncodedDataSource(encodedDataSource: string) {
return dataSourceByEncodedDataSource.has(encodedDataSource);
}
/**
* Gives the data source of a request without a transformation: an encoded data
* source is decoded, and the mask of the Ghostfolio data provider is resolved
* (see the TransformDataSourceInRequestInterceptor)
*/
export function transformDataSourceInRequest({
configurationService,
dataSource
}: {
configurationService: ConfigurationService;
dataSource?: string;
}) {
if (Object.hasOwn(DataSource, dataSource)) {
return getUnmaskedGhostfolioDataSource({
dataSource: dataSource as DataSource,
ghostfolioDataSources: getGhostfolioDataSources({ configurationService })
});
}
return decodeDataSource(dataSource) as DataSource;
}
/**
* Gives the data source as a response gives it to a user who is not an admin:
* encoded if the subscription is enabled, except the data sources GHOSTFOLIO
* and MANUAL (see the TransformDataSourceInResponseInterceptor)
*/
export function transformDataSourceInResponse({
configurationService,
dataSource
}: {
configurationService: ConfigurationService;
dataSource: DataSource;
}) {
return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
isDataSourceEncodedInResponse(dataSource)
? encodeDataSource(dataSource)
: dataSource;
}

5
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -1,6 +1,7 @@
import { import {
encodeDataSource, encodeDataSource,
getMaskedGhostfolioDataSource getMaskedGhostfolioDataSource,
isDataSourceEncodedInResponse
} from '@ghostfolio/api/helper/data-source.helper'; } from '@ghostfolio/api/helper/data-source.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor<
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.encodedDataSourceMap = Object.keys(DataSource).reduce( this.encodedDataSourceMap = Object.keys(DataSource).reduce(
(encodedDataSourceMap, dataSource) => { (encodedDataSourceMap, dataSource) => {
if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { if (isDataSourceEncodedInResponse(DataSource[dataSource])) {
encodedDataSourceMap[dataSource] = encodeDataSource( encodedDataSourceMap[dataSource] = encodeDataSource(
DataSource[dataSource] DataSource[dataSource]
); );

10
apps/api/src/services/benchmark/benchmark.service.spec.ts

@ -24,4 +24,14 @@ describe('BenchmarkService', () => {
); );
expect(benchmarkService.getMarketCondition(-0.2)).toEqual('BEAR_MARKET'); expect(benchmarkService.getMarketCondition(-0.2)).toEqual('BEAR_MARKET');
}); });
it('getPerformancePercentFromAllTimeHigh', async () => {
expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0.1)).toEqual(
0
);
expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0)).toEqual(0);
expect(benchmarkService.getPerformancePercentFromAllTimeHigh(-0.1)).toEqual(
-0.1
);
});
}); });

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

@ -216,6 +216,12 @@ export class BenchmarkService {
} }
} }
public getPerformancePercentFromAllTimeHigh(performancePercent: number) {
const isAtAllTimeHigh = performancePercent >= 0;
return isAtAllTimeHigh ? 0 : performancePercent;
}
public async isBenchmark(symbolProfileId: string): Promise<boolean> { public async isBenchmark(symbolProfileId: string): Promise<boolean> {
const benchmarks = await this.getBenchmarksProperty(); const benchmarks = await this.getBenchmarksProperty();
@ -288,10 +294,9 @@ export class BenchmarkService {
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh?.date, date: allTimeHigh?.date,
performancePercent: performancePercent: this.getPerformancePercentFromAllTimeHigh(
performancePercentFromAllTimeHigh >= 0 performancePercentFromAllTimeHigh
? 0 )
: performancePercentFromAllTimeHigh
} }
}, },
symbol: benchmarkAssetProfiles[index].symbol, symbol: benchmarkAssetProfiles[index].symbol,

5
apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts

@ -0,0 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
export interface DataSourceTableContext {
configurationService: ConfigurationService;
}

4
apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts

@ -1,6 +1,8 @@
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass } from '@prisma/client';
export interface HoldingsTableContext { import { DataSourceTableContext } from './data-source-table-context.interface';
export interface HoldingsTableContext extends DataSourceTableContext {
assetClassTranslations: Record<AssetClass, string>; assetClassTranslations: Record<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, string>; assetSubClassTranslations: Record<AssetSubClass, string>;
} }

9
apps/api/src/services/portfolio-table/portfolio-table.module.ts

@ -1,6 +1,7 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service';
@Module({ @Module({
exports: [PortfolioTableService], exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], imports: [
ActivitiesModule,
ConfigurationModule,
I18nModule,
PortfolioModule,
WatchlistModule
],
providers: [PortfolioTableService] providers: [PortfolioTableService]
}) })
export class PortfolioTableModule {} export class PortfolioTableModule {}

196
apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts

@ -1,12 +1,16 @@
import type { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
Activity,
HistoricalDataItem, HistoricalDataItem,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
}; };
}); });
const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true };
function createAccount({ function createAccount({
id = 'account-a-id', id = 'account-a-id',
isExcluded = false, isExcluded = false,
@ -82,15 +88,39 @@ function createAccount({
} as unknown as AccountWithValue; } as unknown as AccountWithValue;
} }
function createActivity({
dataSource = DataSource.YAHOO,
symbol = 'AAPL'
}: {
dataSource?: DataSource;
symbol?: string;
} = {}) {
return {
account: { name: 'Account A' },
assetProfile: {
dataSource,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
},
currency: 'CHF',
date: new Date('2024-01-01'),
type: 'BUY',
unitPrice: 100
} as unknown as Activity;
}
function createHolding({ function createHolding({
allocationInPercentage = 0.75, allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY, assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK, assetSubClass = AssetSubClass.STOCK,
dataSource = DataSource.YAHOO,
symbol = 'AAPL' symbol = 'AAPL'
}: { }: {
allocationInPercentage?: number; allocationInPercentage?: number;
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
dataSource?: DataSource;
symbol?: string; symbol?: string;
} = {}) { } = {}) {
return { return {
@ -99,6 +129,7 @@ function createHolding({
assetProfile: { assetProfile: {
assetClass, assetClass,
assetSubClass, assetSubClass,
dataSource,
symbol, symbol,
currency: 'CHF', currency: 'CHF',
name: `Name of ${symbol}` name: `Name of ${symbol}`
@ -132,18 +163,20 @@ function createPerformance({
} }
function createWatchlistItem({ function createWatchlistItem({
dataSource = DataSource.YAHOO,
name = 'Name of AAPL', name = 'Name of AAPL',
performancePercent = -0.25, performancePercent = -0.25,
symbol = 'AAPL' symbol = 'AAPL'
}: { }: {
dataSource?: DataSource;
name?: string; name?: string;
performancePercent?: number; performancePercent?: number;
symbol?: string; symbol?: string;
} = {}): WatchlistResponse['watchlist'][number] { } = {}): WatchlistResponse['watchlist'][number] {
return { return {
dataSource,
name, name,
symbol, symbol,
dataSource: DataSource.YAHOO,
marketCondition: 'BEAR_MARKET', marketCondition: 'BEAR_MARKET',
performances: { performances: {
allTimeHigh: { allTimeHigh: {
@ -158,17 +191,33 @@ function createWatchlistItem({
function createPortfolioTableService({ function createPortfolioTableService({
accounts = [], accounts = [],
activities = [],
chart = [{ date: '2024-01-01' }], chart = [{ date: '2024-01-01' }],
configuration = { ENABLE_FEATURE_SUBSCRIPTION: false },
holdings = [], holdings = [],
performance = createPerformance(), performance = createPerformance(),
watchlist = [] watchlist = []
}: { }: {
accounts?: AccountWithValue[]; accounts?: AccountWithValue[];
activities?: Activity[];
chart?: HistoricalDataItem[]; chart?: HistoricalDataItem[];
configuration?: Record<string, unknown>;
holdings?: PortfolioPosition[]; holdings?: PortfolioPosition[];
performance?: PortfolioPerformanceResponse['performance']; performance?: PortfolioPerformanceResponse['performance'];
watchlist?: WatchlistResponse['watchlist']; watchlist?: WatchlistResponse['watchlist'];
} = {}) { } = {}) {
const activitiesService = {
getActivities: jest
.fn()
.mockResolvedValue({ activities, count: activities.length })
} as unknown as ActivitiesService;
const configurationService = {
get: jest.fn((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
// The mock gives the identifier of the translation, so that a test can tell // The mock gives the identifier of the translation, so that a test can tell
// the translation of the asset class from that of the asset sub class // the translation of the asset class from that of the asset sub class
const i18nService = { const i18nService = {
@ -188,7 +237,8 @@ function createPortfolioTableService({
} as unknown as WatchlistService; } as unknown as WatchlistService;
return new PortfolioTableService( return new PortfolioTableService(
null, activitiesService,
configurationService,
i18nService, i18nService,
portfolioService, portfolioService,
watchlistService watchlistService
@ -220,6 +270,7 @@ describe('PortfolioTableService', () => {
'Type', 'Type',
'Name', 'Name',
'Symbol', 'Symbol',
'Data Source',
'Currency', 'Currency',
'Unit Price', 'Unit Price',
'Account' 'Account'
@ -232,6 +283,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name', 'Name',
'Symbol', 'Symbol',
'Data Source',
'Currency', 'Currency',
'Asset Class', 'Asset Class',
'Asset Sub Class', 'Asset Sub Class',
@ -257,6 +309,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
'Name', 'Name',
'Symbol', 'Symbol',
'Data Source',
'Trend 50 Days', 'Trend 50 Days',
'Trend 200 Days', 'Trend 200 Days',
'Date of Last All Time High', 'Date of Last All Time High',
@ -327,14 +380,118 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.'); expect(result).toContain('No accounts found.');
}); });
}); });
describe('getActivitiesTable', () => {
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await createPortfolioTableService({
activities: [
createActivity({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }),
createActivity({
dataSource: DataSource.MANUAL,
symbol: 'GF_GOLD'
})
],
configuration: SUBSCRIPTION_CONFIGURATION
}).getActivitiesTable({
take: 50,
userCurrency: 'CHF',
userId: 'user-id'
});
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
return line.startsWith('| 2024-01-01');
});
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
});
});
describe('getHoldingsTable', () => { describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) { function getHoldingsTable(
return createPortfolioTableService({ holdings }).getHoldingsTable({ holdings: PortfolioPosition[],
{
configuration,
withDataSource
}: {
configuration?: Record<string, unknown>;
withDataSource?: boolean;
} = {}
) {
return createPortfolioTableService({
configuration,
holdings
}).getHoldingsTable({
withDataSource,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id' userId: 'user-id'
}); });
} }
it('gives no data source by default', async () => {
const result = await getHoldingsTable([createHolding()], {
configuration: SUBSCRIPTION_CONFIGURATION
});
expect(result).not.toContain('Data Source');
expect(result).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await getHoldingsTable(
[
createHolding({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }),
createHolding({
allocationInPercentage: 0.25,
dataSource: DataSource.MANUAL,
symbol: 'GF_GOLD'
})
],
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
});
it('gives the data source unencoded if the subscription is disabled', async () => {
const result = await getHoldingsTable([createHolding()], {
configuration: { ENABLE_FEATURE_SUBSCRIPTION: false },
withDataSource: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain(`| ${DataSource.YAHOO} |`);
});
it('gives no data source of a cash position', async () => {
const result = await getHoldingsTable(
[
createHolding({
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
dataSource: DataSource.YAHOO,
symbol: 'USD'
})
],
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of USD');
});
expect(row).toContain('| USD | | CHF |');
expect(row).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the translation of the asset class and of the asset sub class', async () => { it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]); const result = await getHoldingsTable([createHolding()]);
@ -417,12 +574,39 @@ describe('PortfolioTableService', () => {
}); });
describe('getWatchlistTable', () => { describe('getWatchlistTable', () => {
function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { function getWatchlistTable(
return createPortfolioTableService({ watchlist }).getWatchlistTable({ watchlist: WatchlistResponse['watchlist'],
configuration?: Record<string, unknown>
) {
return createPortfolioTableService({
configuration,
watchlist
}).getWatchlistTable({
userId: 'user-id' userId: 'user-id'
}); });
} }
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await getWatchlistTable(
[
createWatchlistItem(),
createWatchlistItem({
dataSource: DataSource.MANUAL,
name: 'Name of GF_GOLD',
symbol: 'GF_GOLD'
})
],
SUBSCRIPTION_CONFIGURATION
);
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
});
it('gives the date and the change of the all time high', async () => { it('gives the date and the change of the all time high', async () => {
const result = await getWatchlistTable([createWatchlistItem()]); const result = await getWatchlistTable([createWatchlistItem()]);

142
apps/api/src/services/portfolio-table/portfolio-table.service.ts

@ -1,10 +1,16 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper';
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
isAccountExcluded,
isCashPosition
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
Filter, Filter,
@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource,
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
const DATA_SOURCE_COLUMN_NAME = 'Data Source';
function getDataSourceColumnDefinition<T>(
getDataSource: (row: T) => DataSource | undefined
): TableColumnDefinition<T, DataSourceTableContext> {
return {
getValue: (row, { configurationService }) => {
const dataSource = getDataSource(row);
return dataSource
? transformDataSourceInResponse({ configurationService, dataSource })
: '';
},
name: DATA_SOURCE_COLUMN_NAME
};
}
function getPercentage(value: number) { function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`; return `${(value * 100).toFixed(3)}%`;
} }
@ -83,52 +108,57 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] = private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
[ Activity,
{ DataSourceTableContext
getValue: ({ date }) => { >[] = [
return format(date, DATE_FORMAT); {
}, getValue: ({ date }) => {
name: 'Date' return format(date, DATE_FORMAT);
}, },
{ name: 'Date'
getValue: ({ type }) => { },
return type; {
}, getValue: ({ type }) => {
name: 'Type' return type;
}, },
{ name: 'Type'
getValue: ({ assetProfile }) => { },
return assetProfile.name ?? ''; {
}, getValue: ({ assetProfile }) => {
name: 'Name' return assetProfile.name ?? '';
}, },
{ name: 'Name'
getValue: ({ assetProfile }) => { },
return assetProfile.symbol; {
}, getValue: ({ assetProfile }) => {
name: 'Symbol' return assetProfile.symbol;
}, },
{ name: 'Symbol'
getValue: ({ assetProfile, currency }) => { },
return currency ?? assetProfile.currency; getDataSourceColumnDefinition(({ assetProfile }) => {
}, return assetProfile.dataSource;
name: 'Currency' }),
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
}, },
{ name: 'Currency'
align: 'right', },
getValue: ({ unitPrice }) => { {
return unitPrice.toString(); align: 'right',
}, getValue: ({ unitPrice }) => {
name: 'Unit Price' return unitPrice.toString();
}, },
{ name: 'Unit Price'
getValue: ({ account }) => { },
return account?.name ?? ''; {
}, getValue: ({ account }) => {
name: 'Account' return account?.name ?? '';
} },
]; name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[ [
@ -144,6 +174,11 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
getDataSourceColumnDefinition(({ assetProfile }) => {
return isCashPosition(assetProfile)
? undefined
: assetProfile.dataSource;
}),
{ {
getValue: ({ assetProfile }) => { getValue: ({ assetProfile }) => {
return assetProfile.currency; return assetProfile.currency;
@ -222,7 +257,8 @@ export class PortfolioTableService {
]; ];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number] WatchlistResponse['watchlist'][number],
DataSourceTableContext
>[] = [ >[] = [
{ {
getValue: ({ name }) => { getValue: ({ name }) => {
@ -236,6 +272,9 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
getDataSourceColumnDefinition(({ dataSource }) => {
return dataSource;
}),
{ {
getValue: ({ trend50d }) => { getValue: ({ trend50d }) => {
return trend50d; return trend50d;
@ -273,6 +312,7 @@ export class PortfolioTableService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly watchlistService: WatchlistService private readonly watchlistService: WatchlistService
@ -399,6 +439,7 @@ export class PortfolioTableService {
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: activities rows: activities
}) })
); );
@ -410,11 +451,13 @@ export class PortfolioTableService {
public async getHoldingsTable({ public async getHoldingsTable({
filters, filters,
languageCode, languageCode,
userId userId,
withDataSource = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
languageCode: string; languageCode: string;
userId: string; userId: string;
withDataSource?: boolean;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
@ -442,8 +485,16 @@ export class PortfolioTableService {
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter(
context: { assetClassTranslations, assetSubClassTranslations }, ({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}
),
context: {
assetClassTranslations,
assetSubClassTranslations,
configurationService: this.configurationService
},
rows: sortedHoldings rows: sortedHoldings
}) })
].join('\n'); ].join('\n');
@ -491,6 +542,7 @@ export class PortfolioTableService {
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: watchlist rows: watchlist
}) })
); );

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

@ -11,6 +11,7 @@ import {
convertValuesToPercentagesOfTotal, convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCountryName, getCountryName,
getHoldingName,
isCashPosition isCashPosition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -533,7 +534,7 @@ export class GfAllocationsPageComponent implements OnInit {
value, value,
dataSource: position.assetProfile.dataSource, dataSource: position.assetProfile.dataSource,
isClickable: canOpenHoldingDetail(position), isClickable: canOpenHoldingDetail(position),
name: position.assetProfile.name ?? '' name: getHoldingName(position.assetProfile)
}; };
} }
} }

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

@ -3,6 +3,7 @@ import {
convertValuesToPercentagesOfTotal, convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCountryName, getCountryName,
getHoldingName,
isCashPosition isCashPosition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -260,7 +261,7 @@ export class GfPublicPageComponent implements OnInit {
this.symbols[symbol] = { this.symbols[symbol] = {
symbol, symbol,
value, value,
name: position.assetProfile.name ?? symbol name: getHoldingName(position.assetProfile)
}; };
} }
} }

8
libs/common/src/lib/dtos/create-asset-profile.dto.ts

@ -10,17 +10,19 @@ import {
DataSource DataSource
} from '@ghostfolio/prisma/enums'; } from '@ghostfolio/prisma/enums';
import { Type } from 'class-transformer'; import { Transform, TransformFnParams, Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
MaxLength, MaxLength,
ValidateNested ValidateNested
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash-es';
import { CountryDto } from './country.dto'; import { CountryDto } from './country.dto';
import { HoldingDto } from './holding.dto'; import { HoldingDto } from './holding.dto';
@ -92,8 +94,12 @@ export class CreateAssetProfileDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
sectors?: Prisma.InputJsonArray; sectors?: Prisma.InputJsonArray;
@IsNotEmpty()
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH) @MaxLength(SYMBOL_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
symbol: string; symbol: string;
@IsOptional() @IsOptional()

5
libs/common/src/lib/dtos/create-order.dto.ts

@ -18,6 +18,7 @@ import {
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNotEmpty,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -71,8 +72,12 @@ export class CreateOrderDto {
@Min(0) @Min(0)
quantity: number; quantity: number;
@IsNotEmpty()
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH) @MaxLength(SYMBOL_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
symbol: string; symbol: string;
@ArrayUnique() @ArrayUnique()

5
libs/common/src/lib/dtos/update-order.dto.ts

@ -17,6 +17,7 @@ import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNotEmpty,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -72,8 +73,12 @@ export class UpdateOrderDto {
@Min(0) @Min(0)
quantity: number; quantity: number;
@IsNotEmpty()
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH) @MaxLength(SYMBOL_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
symbol: string; symbol: string;
@ArrayUnique() @ArrayUnique()

47
libs/common/src/lib/helper.spec.ts

@ -7,6 +7,7 @@ import {
extractNumberFromString, extractNumberFromString,
getCountryCodeFromCurrency, getCountryCodeFromCurrency,
getEmojiFlag, getEmojiFlag,
getHoldingName,
getNumberFormatGroup, getNumberFormatGroup,
getStringOrNull, getStringOrNull,
getStringOrUndefined, getStringOrUndefined,
@ -143,6 +144,52 @@ describe('Helper', () => {
}); });
}); });
describe('Get holding name', () => {
it('Cash position', () => {
expect(
getHoldingName({
assetSubClass: 'CASH',
assetSubClassLabel: 'Cash',
name: 'CHF',
symbol: 'CHF'
})
).toEqual('Cash (CHF)');
});
it('Cash position without asset sub class label', () => {
expect(
getHoldingName({
assetSubClass: 'CASH',
assetSubClassLabel: undefined,
name: 'CHF',
symbol: 'CHF'
})
).toEqual('CHF');
});
it('Holding with name', () => {
expect(
getHoldingName({
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
name: 'Apple Inc.',
symbol: 'AAPL'
})
).toEqual('Apple Inc.');
});
it('Holding without name', () => {
expect(
getHoldingName({
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
name: undefined,
symbol: 'AAPL'
})
).toEqual('AAPL');
});
});
describe('Get number format group', () => { describe('Get number format group', () => {
let languageGetter: jest.SpyInstance<string, [], any>; let languageGetter: jest.SpyInstance<string, [], any>;

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

@ -453,6 +453,22 @@ export function getEmojiFlag(aCountryCode: string) {
}); });
} }
export function getHoldingName({
assetSubClass,
assetSubClassLabel,
name,
symbol
}: Pick<
PortfolioPosition['assetProfile'],
'assetSubClass' | 'assetSubClassLabel' | 'name' | 'symbol'
>) {
if (isCashPosition({ assetSubClass }) && assetSubClassLabel) {
return `${assetSubClassLabel} (${symbol})`;
}
return name ?? symbol;
}
export function getLocale() { export function getLocale() {
return navigator.language ?? DEFAULT_LOCALE; return navigator.language ?? DEFAULT_LOCALE;
} }

13
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -20,7 +20,7 @@
[countryCode]="getCountryCodeForCashPosition(element)" [countryCode]="getCountryCodeForCashPosition(element)"
[dataSource]="element.assetProfile.dataSource" [dataSource]="element.assetProfile.dataSource"
[symbol]="element.assetProfile.symbol" [symbol]="element.assetProfile.symbol"
[tooltip]="element.assetProfile.name" [tooltip]="getHoldingName(element.assetProfile)"
/> />
</td> </td>
</ng-container> </ng-container>
@ -40,9 +40,14 @@
mat-cell mat-cell
[class.w-100]="mode() === 'simple'" [class.w-100]="mode() === 'simple'"
> >
<div class="text-truncate" [title]="element.assetProfile.name"> @let holdingName = getHoldingName(element.assetProfile);
{{ element.assetProfile.name }}
@if (element.assetProfile.name === element.assetProfile.symbol) { <div class="text-truncate" [title]="holdingName">
{{ holdingName }}
@if (
!isCashPosition(element.assetProfile) &&
element.assetProfile.name === element.assetProfile.symbol
) {
<span>({{ element.assetProfile.assetSubClassLabel }})</span> <span>({{ element.assetProfile.assetSubClassLabel }})</span>
} }
</div> </div>

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

@ -1,6 +1,7 @@
import { import {
canOpenHoldingDetail, canOpenHoldingDetail,
getCountryCodeFromCurrency, getCountryCodeFromCurrency,
getHoldingName,
getLocale, getLocale,
getLowercase, getLowercase,
isCashPosition isCashPosition
@ -89,6 +90,9 @@ export class GfHoldingsTableComponent {
return columns; return columns;
}); });
protected readonly getHoldingName = getHoldingName;
protected readonly isCashPosition = isCashPosition;
protected readonly isLoading = computed(() => { protected readonly isLoading = computed(() => {
return !this.holdings(); return !this.holdings();
}); });
@ -104,7 +108,13 @@ export class GfHoldingsTableComponent {
}); });
public constructor() { public constructor() {
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = (holding, path) => {
if (path === 'assetProfile.name') {
return getHoldingName(holding.assetProfile).toLocaleLowerCase();
}
return getLowercase(holding, path) as number | string;
};
// Reactive data update // Reactive data update
effect(() => { effect(() => {

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

@ -3,7 +3,11 @@ import {
getIntervalFromDateRange getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper'; } from '@ghostfolio/common/calculation-helper';
import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { canOpenHoldingDetail, getLocale } from '@ghostfolio/common/helper'; import {
canOpenHoldingDetail,
getHoldingName,
getLocale
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition PortfolioPosition
@ -399,20 +403,18 @@ export class GfTreemapChartComponent
const raw = context.raw as GfTreemapDataPoint; const raw = context.raw as GfTreemapDataPoint;
const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`;
const name = raw._data.assetProfile.name; const name = getHoldingName(raw._data.assetProfile);
const sign = const sign =
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : '';
const symbol = raw._data.assetProfile.symbol;
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`;
if (raw._data.valueInBaseCurrency !== null) { if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency; const value = raw._data.valueInBaseCurrency;
return [ return [
`${name ?? symbol} (${allocationInPercentage})`, `${name} (${allocationInPercentage})`,
`${value?.toLocaleString(this.locale(), { `${value?.toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
@ -429,7 +431,7 @@ export class GfTreemapChartComponent
]; ];
} else { } else {
return [ return [
`${name ?? symbol} (${allocationInPercentage})`, `${name} (${allocationInPercentage})`,
'', '',
$localize`Performance`, $localize`Performance`,
netPerformanceInPercentageWithSign netPerformanceInPercentageWithSign

Loading…
Cancel
Save