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
- 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 `ai` from version `7.0.37` to `7.0.114`
- 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
### 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 {
COMMENT_MAXIMUM_LENGTH,
DEFAULT_DATE_RANGE,
@ -18,6 +19,8 @@ import {
} from './mcp.schemas';
import { createActivity } from './mcp.test-utils';
const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff';
describe('GET_ACCOUNTS_PARAMETERS', () => {
it('Refuses an empty symbol of the holding', () => {
expect(
@ -34,6 +37,40 @@ describe('GET_ACCOUNTS_PARAMETERS', () => {
}).success
).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', () => {
@ -52,6 +89,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => {
}).success
).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', () => {
@ -86,6 +142,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => {
}).success
).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', () => {
@ -125,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
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', () => {
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 { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper';
import {
COMMENT_MAXIMUM_LENGTH,
DATE_RANGES,
@ -17,6 +18,20 @@ import {
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
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
.string()
.trim()
@ -25,9 +40,7 @@ const SYMBOL_PARAMETER = z
.describe('The symbol of the asset profile');
const HOLDING_PARAMETER = z.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
dataSource: DATA_SOURCE_PARAMETER,
symbol: SYMBOL_PARAMETER
});
@ -137,10 +150,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
.describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
),
dataSource: z
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
dataSource: DATA_SOURCE_PARAMETER.optional(),
date: z
.string()
.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 { SymbolService } from '@ghostfolio/api/app/symbol/symbol.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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
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 () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
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 () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -304,14 +353,15 @@ describe('McpService', () => {
});
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({
content: [{ text: '## Holdings', type: 'text' }]
});
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
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 () => {
const user = setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest.spyOn(symbolService, 'lookup').mockResolvedValue({
items: [
{
@ -383,12 +430,46 @@ describe('McpService', () => {
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
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.',
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 () => {
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 { UserService } from '@ghostfolio/api/app/user/user.service';
import {
getMaskedGhostfolioDataSource,
getUnmaskedGhostfolioDataSource
transformDataSourceInRequest,
transformDataSourceInResponse
} from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -121,7 +121,8 @@ export class McpService {
public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
});
return this.getTextResult(table);
@ -144,13 +145,11 @@ export class McpService {
permission: permissions.createActivity
});
const ghostfolioDataSources = this.getGhostfolioDataSources();
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: transformDataSourceInRequest({
configurationService: this.configurationService,
dataSource: activity.dataSource
})
};
@ -188,8 +187,6 @@ export class McpService {
const { items } = await this.symbolService.lookup({ query, user });
const ghostfolioDataSources = this.getGhostfolioDataSources();
const assetProfiles = items.flatMap(
({
assetClass,
@ -211,9 +208,9 @@ export class McpService {
currency,
name,
symbol,
dataSource: getMaskedGhostfolioDataSource({
dataSource: transformDataSourceInResponse({
dataSource,
ghostfolioDataSources
configurationService: this.configurationService
})
}
];
@ -223,19 +220,13 @@ export class McpService {
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({
holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {
return {
filterByDataSource: getUnmaskedGhostfolioDataSource({
dataSource: holding?.dataSource,
ghostfolioDataSources: this.getGhostfolioDataSources()
filterByDataSource: transformDataSourceInRequest({
configurationService: this.configurationService,
dataSource: holding?.dataSource
}),
filterBySymbol: holding?.symbol
};

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

@ -140,8 +140,11 @@ export class WatchlistService {
name: assetProfile?.name,
performances: {
allTimeHigh: {
performancePercent,
date: allTimeHigh?.date
date: allTimeHigh?.date,
performancePercent:
this.benchmarkService.getPerformancePercentFromAllTimeHigh(
performancePercent
)
}
},
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);
}
// 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) {
if (dateString < startDateString) {
continue;
@ -798,6 +808,18 @@ export abstract class PortfolioCalculator {
break;
}
while (
indexOfMarketPriceDate < marketPriceDates.length &&
marketPriceDates[indexOfMarketPriceDate] <= dateString
) {
lastMarketPrice =
marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][
assetProfileIdentifier
];
indexOfMarketPriceDate += 1;
}
const activitiesOfDate = activitiesByDate[dateString];
if (!lastMarketPrice && activitiesOfDate?.length > 0) {
@ -808,13 +830,8 @@ export abstract class PortfolioCalculator {
}
}
const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier];
const unitPrice =
marketPrice ??
lastMarketPrice ??
lastActivityUnitPrice ??
unitPriceAtEndDate;
lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate;
if (activitiesOfDate?.length > 0) {
for (const activity of activitiesOfDate) {
@ -832,10 +849,6 @@ export abstract class PortfolioCalculator {
unitPriceFromMarketData: unitPrice
});
}
if (marketPrice) {
lastMarketPrice = marketPrice;
}
}
// 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 };
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':
if (isSameDay(parseDate('2021-11-12'), date)) {
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 { createHash } from 'node:crypto';
const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [
DataSource.GHOSTFOLIO,
DataSource.MANUAL
];
const encodedDataSourceByDataSource = new Map<DataSource, string>(
Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)];
@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) {
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) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
}
@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({
? (ghostfolioDataSources[0] as 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 {
encodeDataSource,
getMaskedGhostfolioDataSource
getMaskedGhostfolioDataSource,
isDataSourceEncodedInResponse
} from '@ghostfolio/api/helper/data-source.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor<
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.encodedDataSourceMap = Object.keys(DataSource).reduce(
(encodedDataSourceMap, dataSource) => {
if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) {
if (isDataSourceEncodedInResponse(DataSource[dataSource])) {
encodedDataSourceMap[dataSource] = encodeDataSource(
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');
});
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> {
const benchmarks = await this.getBenchmarksProperty();
@ -288,10 +294,9 @@ export class BenchmarkService {
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
performancePercent: this.getPerformancePercentFromAllTimeHigh(
performancePercentFromAllTimeHigh
)
}
},
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';
export interface HoldingsTableContext {
import { DataSourceTableContext } from './data-source-table-context.interface';
export interface HoldingsTableContext extends DataSourceTableContext {
assetClassTranslations: Record<AssetClass, 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 { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.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 { Module } from '@nestjs/common';
@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service';
@Module({
exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule],
imports: [
ActivitiesModule,
ConfigurationModule,
I18nModule,
PortfolioModule,
WatchlistModule
],
providers: [PortfolioTableService]
})
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 { 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
Activity,
HistoricalDataItem,
PortfolioPerformanceResponse,
PortfolioPosition,
@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
};
});
const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true };
function createAccount({
id = 'account-a-id',
isExcluded = false,
@ -82,15 +88,39 @@ function createAccount({
} 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({
allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK,
dataSource = DataSource.YAHOO,
symbol = 'AAPL'
}: {
allocationInPercentage?: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
dataSource?: DataSource;
symbol?: string;
} = {}) {
return {
@ -99,6 +129,7 @@ function createHolding({
assetProfile: {
assetClass,
assetSubClass,
dataSource,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
@ -132,18 +163,20 @@ function createPerformance({
}
function createWatchlistItem({
dataSource = DataSource.YAHOO,
name = 'Name of AAPL',
performancePercent = -0.25,
symbol = 'AAPL'
}: {
dataSource?: DataSource;
name?: string;
performancePercent?: number;
symbol?: string;
} = {}): WatchlistResponse['watchlist'][number] {
return {
dataSource,
name,
symbol,
dataSource: DataSource.YAHOO,
marketCondition: 'BEAR_MARKET',
performances: {
allTimeHigh: {
@ -158,17 +191,33 @@ function createWatchlistItem({
function createPortfolioTableService({
accounts = [],
activities = [],
chart = [{ date: '2024-01-01' }],
configuration = { ENABLE_FEATURE_SUBSCRIPTION: false },
holdings = [],
performance = createPerformance(),
watchlist = []
}: {
accounts?: AccountWithValue[];
activities?: Activity[];
chart?: HistoricalDataItem[];
configuration?: Record<string, unknown>;
holdings?: PortfolioPosition[];
performance?: PortfolioPerformanceResponse['performance'];
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 translation of the asset class from that of the asset sub class
const i18nService = {
@ -188,7 +237,8 @@ function createPortfolioTableService({
} as unknown as WatchlistService;
return new PortfolioTableService(
null,
activitiesService,
configurationService,
i18nService,
portfolioService,
watchlistService
@ -220,6 +270,7 @@ describe('PortfolioTableService', () => {
'Type',
'Name',
'Symbol',
'Data Source',
'Currency',
'Unit Price',
'Account'
@ -232,6 +283,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name',
'Symbol',
'Data Source',
'Currency',
'Asset Class',
'Asset Sub Class',
@ -257,6 +309,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
'Name',
'Symbol',
'Data Source',
'Trend 50 Days',
'Trend 200 Days',
'Date of Last All Time High',
@ -327,14 +380,118 @@ describe('PortfolioTableService', () => {
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', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) {
return createPortfolioTableService({ holdings }).getHoldingsTable({
function getHoldingsTable(
holdings: PortfolioPosition[],
{
configuration,
withDataSource
}: {
configuration?: Record<string, unknown>;
withDataSource?: boolean;
} = {}
) {
return createPortfolioTableService({
configuration,
holdings
}).getHoldingsTable({
withDataSource,
languageCode: DEFAULT_LANGUAGE_CODE,
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 () => {
const result = await getHoldingsTable([createHolding()]);
@ -417,12 +574,39 @@ describe('PortfolioTableService', () => {
});
describe('getWatchlistTable', () => {
function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) {
return createPortfolioTableService({ watchlist }).getWatchlistTable({
function getWatchlistTable(
watchlist: WatchlistResponse['watchlist'],
configuration?: Record<string, unknown>
) {
return createPortfolioTableService({
configuration,
watchlist
}).getWatchlistTable({
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 () => {
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 { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.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 { 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 { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
isAccountExcluded,
isCashPosition
} from '@ghostfolio/common/helper';
import {
Activity,
Filter,
@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
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) {
return `${(value * 100).toFixed(3)}%`;
}
@ -83,52 +108,57 @@ export class PortfolioTableService {
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] =
[
{
getValue: ({ date }) => {
return format(date, DATE_FORMAT);
},
name: 'Date'
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
Activity,
DataSourceTableContext
>[] = [
{
getValue: ({ date }) => {
return format(date, DATE_FORMAT);
},
{
getValue: ({ type }) => {
return type;
},
name: 'Type'
name: 'Date'
},
{
getValue: ({ type }) => {
return type;
},
{
getValue: ({ assetProfile }) => {
return assetProfile.name ?? '';
},
name: 'Name'
name: 'Type'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.name ?? '';
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
name: 'Currency'
name: 'Symbol'
},
getDataSourceColumnDefinition(({ assetProfile }) => {
return assetProfile.dataSource;
}),
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
name: 'Unit Price'
name: 'Currency'
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
name: 'Unit Price'
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
@ -144,6 +174,11 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
getDataSourceColumnDefinition(({ assetProfile }) => {
return isCashPosition(assetProfile)
? undefined
: assetProfile.dataSource;
}),
{
getValue: ({ assetProfile }) => {
return assetProfile.currency;
@ -222,7 +257,8 @@ export class PortfolioTableService {
];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number]
WatchlistResponse['watchlist'][number],
DataSourceTableContext
>[] = [
{
getValue: ({ name }) => {
@ -236,6 +272,9 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
getDataSourceColumnDefinition(({ dataSource }) => {
return dataSource;
}),
{
getValue: ({ trend50d }) => {
return trend50d;
@ -273,6 +312,7 @@ export class PortfolioTableService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService,
private readonly watchlistService: WatchlistService
@ -399,6 +439,7 @@ export class PortfolioTableService {
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: activities
})
);
@ -410,11 +451,13 @@ export class PortfolioTableService {
public async getHoldingsTable({
filters,
languageCode,
userId
userId,
withDataSource = false
}: {
filters?: Filter[];
languageCode: string;
userId: string;
withDataSource?: boolean;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
@ -442,8 +485,16 @@ export class PortfolioTableService {
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
context: { assetClassTranslations, assetSubClassTranslations },
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter(
({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}
),
context: {
assetClassTranslations,
assetSubClassTranslations,
configurationService: this.configurationService
},
rows: sortedHoldings
})
].join('\n');
@ -491,6 +542,7 @@ export class PortfolioTableService {
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: watchlist
})
);

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

@ -11,6 +11,7 @@ import {
convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier,
getCountryName,
getHoldingName,
isCashPosition
} from '@ghostfolio/common/helper';
import {
@ -533,7 +534,7 @@ export class GfAllocationsPageComponent implements OnInit {
value,
dataSource: position.assetProfile.dataSource,
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,
getAssetProfileIdentifier,
getCountryName,
getHoldingName,
isCashPosition
} from '@ghostfolio/common/helper';
import {
@ -260,7 +261,7 @@ export class GfPublicPageComponent implements OnInit {
this.symbols[symbol] = {
symbol,
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
} from '@ghostfolio/prisma/enums';
import { Type } from 'class-transformer';
import { Transform, TransformFnParams, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
MaxLength,
ValidateNested
} from 'class-validator';
import { isString } from 'lodash-es';
import { CountryDto } from './country.dto';
import { HoldingDto } from './holding.dto';
@ -92,8 +94,12 @@ export class CreateAssetProfileDto {
@ValidateNested({ each: true })
sectors?: Prisma.InputJsonArray;
@IsNotEmpty()
@IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
symbol: string;
@IsOptional()

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

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

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

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

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

@ -7,6 +7,7 @@ import {
extractNumberFromString,
getCountryCodeFromCurrency,
getEmojiFlag,
getHoldingName,
getNumberFormatGroup,
getStringOrNull,
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', () => {
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() {
return navigator.language ?? DEFAULT_LOCALE;
}

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

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

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

@ -1,6 +1,7 @@
import {
canOpenHoldingDetail,
getCountryCodeFromCurrency,
getHoldingName,
getLocale,
getLowercase,
isCashPosition
@ -89,6 +90,9 @@ export class GfHoldingsTableComponent {
return columns;
});
protected readonly getHoldingName = getHoldingName;
protected readonly isCashPosition = isCashPosition;
protected readonly isLoading = computed(() => {
return !this.holdings();
});
@ -104,7 +108,13 @@ export class GfHoldingsTableComponent {
});
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
effect(() => {

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

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

Loading…
Cancel
Save