Browse Source

Merge remote-tracking branch 'origin/main' into task/enable-strict-templates-client

pull/8108/head
KenTandrian 5 days ago
parent
commit
c0cc20667b
  1. 9
      CHANGELOG.md
  2. 96
      apps/api/src/app/activities/activities.service.spec.ts
  3. 27
      apps/api/src/app/activities/activities.service.ts
  4. 178
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts
  5. 63
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  6. 164
      apps/api/src/app/import/import.service.spec.ts
  7. 21
      apps/api/src/app/import/import.service.ts
  8. 11
      apps/api/src/services/data-provider/data-provider.service.ts
  9. 22
      apps/client/src/app/components/home-overview/home-overview.html
  10. 4
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  11. 1
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.scss
  12. 45
      libs/common/src/lib/helper.spec.ts
  13. 23
      libs/common/src/lib/helper.ts
  14. 4
      package-lock.json
  15. 2
      package.json

9
CHANGELOG.md

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 3.83.0 - 2026-10-10
### Added
@ -14,9 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Extended the benchmark comparator to support benchmarks whose market data starts later than the portfolio
- Harmonized the colors in the investment chart component
- Improved the performance of getting the historical market data by loading only the requested asset profiles
- Improved the language localization for Chinese (`zh`)
### Fixed
- Fixed an issue where a user could create a custom asset profile with a symbol of the prefix `GF_`
- Fixed the horizontal overflow on the overview tab of the home page on mobile
## 3.82.0 - 2026-10-09
### Added

96
apps/api/src/app/activities/activities.service.spec.ts

@ -8,6 +8,7 @@ import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
INVESTMENT_ACTIVITY_TYPES,
NON_INVESTMENT_ACTIVITY_TYPES
@ -15,12 +16,15 @@ import {
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AssetProfileSplit,
DataSource,
Prisma,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { ActivitiesService } from './activities.service';
@ -49,6 +53,98 @@ describe('ActivitiesService', () => {
);
});
describe('createActivity', () => {
it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' });
expect(isUUID(assetProfile.symbol)).toBe(true);
expect(assetProfile.name).toBe('GF_COPX');
});
it('connects to the existing asset profile of a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { id: 'asset-profile-id' },
symbol: 'GF_COPX'
});
expect(assetProfile.symbol).toBe('GF_COPX');
});
it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => {
const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
const assetProfile = await getCreatedAssetProfile({ symbol });
expect(assetProfile.symbol).toBe(symbol);
});
async function getCreatedAssetProfile({
existingAssetProfile,
symbol
}: {
existingAssetProfile?: { id: string };
symbol: string;
}) {
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
return {
...data,
SymbolProfile: data.SymbolProfile.connectOrCreate.create
};
});
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
{ emit: jest.fn() } as unknown as EventEmitter2,
null,
null,
{
order: { create },
symbolProfile: {
findUnique: jest
.fn()
.mockResolvedValue(existingAssetProfile ?? null)
}
} as unknown as PrismaService,
null,
{ validateTagIds: jest.fn() } as unknown as TagService
);
await service.createActivity({
currency: 'USD',
date: parseDate('2024-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
connectOrCreate: {
create: { symbol, currency: 'USD', dataSource: DataSource.MANUAL },
where: {
dataSource_symbol: { symbol, dataSource: DataSource.MANUAL }
}
}
},
type: 'BUY',
unitPrice: 100,
user: { connect: { id: 'user-id' } },
userId: 'user-id'
});
const { create: assetProfile, where } =
create.mock.calls[0][0].data.SymbolProfile.connectOrCreate;
expect(where.dataSource_symbol).toEqual({
dataSource: DataSource.MANUAL,
symbol: assetProfile.symbol
});
return assetProfile;
}
});
describe('getActivities', () => {
it('returns the activities with the asset profile but without the relation to the symbol profile', async () => {
const assetProfile = {

27
apps/api/src/app/activities/activities.service.ts

@ -34,10 +34,11 @@ import {
} from '@ghostfolio/common/config';
import {
canDeleteAssetProfile,
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier,
getStartOfUtcDateOfTomorrow,
isDraftActivity,
isValidCustomAssetProfileSymbol
hasGhostfolioPrefix,
isDraftActivity
} from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
@ -220,19 +221,31 @@ export class ActivitiesService {
const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL';
const requestedSymbol = data.SymbolProfile.connectOrCreate.create.symbol;
let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string;
const existingAssetProfile = hasGhostfolioPrefix(requestedSymbol)
? await this.prismaService.symbolProfile.findUnique({
select: { id: true },
where: {
dataSource_symbol: { dataSource, symbol: requestedSymbol }
}
})
: null;
if (
isValidCustomAssetProfileSymbol(
data.SymbolProfile.connectOrCreate.create.symbol
)
canUseCustomAssetProfileSymbol({
assetProfile: existingAssetProfile,
symbol: requestedSymbol
})
) {
// Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
symbol = requestedSymbol;
} else {
// Create custom asset profile
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol;
name = name ?? requestedSymbol;
symbol = randomUUID();
}

178
apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts

@ -0,0 +1,178 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import { BenchmarksService } from './benchmarks.service';
describe('BenchmarksService', () => {
let benchmarksService: BenchmarksService;
let get: jest.Mock;
let getExchangeRatesByCurrency: jest.Mock;
let getPerformance: jest.Mock;
let getRange: jest.Mock;
let marketDataItems: jest.Mock;
beforeEach(() => {
get = jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 });
getExchangeRatesByCurrency = jest.fn().mockResolvedValue({});
getPerformance = jest.fn().mockResolvedValue({
chart: [
{ date: '2009-01-01' },
{ date: '2009-06-01' },
{ date: '2010-07-18' },
{ date: '2010-07-19' }
]
});
getRange = jest.fn().mockResolvedValue([]);
marketDataItems = jest.fn();
benchmarksService = new BenchmarksService(
new BenchmarkService(null, null, null, null, null, null),
{ getExchangeRatesByCurrency } as unknown as ExchangeRateDataService,
{ getRange, marketDataItems } as unknown as MarketDataService,
{ getPerformance } as unknown as PortfolioService,
{ get } as unknown as SymbolService
);
});
describe('getMarketDataForUser', () => {
const getMarketDataForUser = ({
endDate = parseDate('2010-07-19')
}: { endDate?: Date } = {}) => {
return benchmarksService.getMarketDataForUser({
endDate,
dataSource: DataSource.COINGECKO,
dateRange: 'max',
startDate: parseDate('2009-01-02'),
symbol: 'bitcoin',
userId: 'user-id',
userSettings: { baseCurrency: 'CHF' }
});
};
it('calculates the change since the baseline date', async () => {
marketDataItems.mockResolvedValue([
{ date: new Date('2009-01-01'), marketPrice: 50 },
{ date: new Date('2009-06-01'), marketPrice: 25 },
{ date: new Date('2010-07-18'), marketPrice: 75 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: [
{ date: '2009-01-01', value: 0 },
{ date: '2009-06-01', value: -50 },
{ date: '2010-07-18', value: 50 },
{ date: '2010-07-19', value: 100 }
]
});
expect(getRange).not.toHaveBeenCalled();
});
it('returns no values before the market data of the benchmark starts', async () => {
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: [
{ date: '2010-07-18', value: 0 },
{ date: '2010-07-19', value: 100 }
]
});
expect(getRange).toHaveBeenCalledWith({
assetProfileIdentifiers: [
{ dataSource: DataSource.COINGECKO, symbol: 'bitcoin' }
],
dateQuery: { lt: new Date('2009-01-01') },
take: 1
});
expect(marketDataItems).toHaveBeenCalledWith(
expect.objectContaining({ orderBy: { date: 'asc' } })
);
});
it('uses the exchange rate at the start of the market data', async () => {
getExchangeRatesByCurrency.mockResolvedValue({
USDCHF: {
'2009-01-01': 2,
'2009-06-01': 2,
'2010-07-18': 1,
'2010-07-19': 1.5
}
});
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: [
{ date: '2010-07-18', value: 0 },
{ date: '2010-07-19', value: 200 }
]
});
});
it('calculates the value at the end date since the start of the market data', async () => {
getExchangeRatesByCurrency.mockResolvedValue({
USDCHF: {
'2009-01-01': 2,
'2010-07-18': 1,
'2010-07-19': 1,
'2010-07-20': 1.5
}
});
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(
getMarketDataForUser({ endDate: parseDate('2010-07-20') })
).resolves.toEqual({
marketData: [
{ date: '2010-07-18', value: 0 },
{ date: '2010-07-19', value: 100 },
{ date: '2010-07-20', value: 500 }
]
});
});
it('returns no market data if the market data at the baseline date is missing', async () => {
getRange.mockResolvedValue([
{ date: new Date('2008-12-31'), marketPrice: 40 }
]);
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: []
});
expect(getExchangeRatesByCurrency).not.toHaveBeenCalled();
});
it('returns no market data if the benchmark has none', async () => {
marketDataItems.mockResolvedValue([]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: []
});
});
});
});

63
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -94,25 +94,15 @@ export class BenchmarksService {
const baselineDate = resetHours(parseDate(chart[0].date));
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: [currentSymbolItem.currency],
startDate: min([baselineDate, startDate]),
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(baselineDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, baselineDate);
})?.marketPrice;
// The market data of the benchmark can start after the baseline date.
// In this case, the first market data item is the start and there is no
// value before it.
const [firstMarketDataItem] = marketDataItems;
const marketPriceAtStartDate = firstMarketDataItem?.marketPrice;
if (!marketPriceAtStartDate) {
this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
`No historical market data has been found for ${symbol} (${dataSource}) since ${format(
baselineDate,
DATE_FORMAT
)}`
@ -121,6 +111,47 @@ export class BenchmarksService {
return { marketData };
}
if (!isSameDay(firstMarketDataItem.date, baselineDate)) {
// If market data exists before the baseline date, the market data at
// the baseline date is missing and the benchmark does not start later
const [marketDataItemBeforeBaselineDate] =
await this.marketDataService.getRange({
assetProfileIdentifiers: [{ dataSource, symbol }],
dateQuery: { lt: baselineDate },
take: 1
});
if (marketDataItemBeforeBaselineDate) {
this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
baselineDate,
DATE_FORMAT
)}`
);
return { marketData };
}
this.logger.warn(
`The market data of ${symbol} (${dataSource}) starts at ${format(
firstMarketDataItem.date,
DATE_FORMAT
)}, after the baseline date ${format(baselineDate, DATE_FORMAT)}`
);
}
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: [currentSymbolItem.currency],
startDate: min([baselineDate, startDate]),
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(firstMarketDataItem.date, DATE_FORMAT)
];
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[

164
apps/api/src/app/import/import.service.spec.ts

@ -3,10 +3,15 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import {
CreateAssetProfileWithMarketDataDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
Activity,
@ -15,6 +20,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator';
import { parseISO } from 'date-fns';
import { ImportService } from './import.service';
@ -22,9 +28,11 @@ import { ImportService } from './import.service';
const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
describe('ImportService', () => {
let addSymbolProfile: jest.Mock;
let createActivity: jest.Mock;
let gatherSymbols: jest.Mock;
let importService: ImportService;
let updateManyMarketData: jest.Mock;
beforeEach(() => {
const configuration = {
@ -33,8 +41,10 @@ describe('ImportService', () => {
MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER
};
addSymbolProfile = jest.fn();
createActivity = jest.fn();
gatherSymbols = jest.fn();
updateManyMarketData = jest.fn();
const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
@ -77,10 +87,14 @@ describe('ImportService', () => {
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
{ updateMany: updateManyMarketData } as unknown as MarketDataService,
null,
null,
null,
null,
{
add: addSymbolProfile,
getCustomSymbolProfilesByNames: jest.fn().mockResolvedValue([]),
getSymbolProfiles: jest.fn().mockResolvedValue([])
} as unknown as SymbolProfileService,
{
getTagsForUser: jest.fn().mockResolvedValue([])
} as unknown as TagService
@ -275,6 +289,146 @@ describe('ImportService', () => {
]);
});
it('creates a custom asset profile with a UUID instead of a symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(isUUID(symbol)).toBe(true);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('keeps the symbol with the prefix as the name of a custom asset profile without a name', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
symbol: 'GF_COPX'
}
]
});
expect(addSymbolProfile.mock.calls[0][0].name).toBe('GF_COPX');
});
it('merges the market data of asset profiles with the same symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-01', marketPrice: 100 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
},
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-02', marketPrice: 101 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(addSymbolProfile).toHaveBeenCalledTimes(1);
expect(updateManyMarketData.mock.calls[0][0].data).toMatchObject([
{ symbol, date: '2024-01-01', marketPrice: 100 },
{ symbol, date: '2024-01-02', marketPrice: 101 }
]);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('refers the next activities with a symbol with the prefix to the custom asset profile which createActivity() has created', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
}),
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-02T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
]
});
expect(
createActivity.mock.calls.map(([activity]) => {
return activity.SymbolProfile.connectOrCreate.create;
})
).toMatchObject([
{ dataSource: DataSource.MANUAL, symbol: 'GF_COPX' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
});
it('keeps the asset profiles of the activities in a dry run', async () => {
const activities = await importActivities({
activitiesDto: [
@ -306,16 +460,18 @@ describe('ImportService', () => {
function importActivities({
activitiesDto,
assetProfilesWithMarketDataDto = [],
isDryRun
}: {
activitiesDto: CreateOrderDto[];
assetProfilesWithMarketDataDto?: CreateAssetProfileWithMarketDataDto[];
isDryRun?: boolean;
}) {
return importService.import({
activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: [],
user: {

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

@ -27,6 +27,7 @@ import {
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
getAssetProfileIdentifier,
hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
@ -649,9 +650,23 @@ export class ImportService {
'marketData'
);
// Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile && !isDryRun) {
symbol = randomUUID();
if (
(existingAssetProfile || hasGhostfolioPrefix(symbol)) &&
!isDryRun
) {
const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some(
({ assetProfile: { symbol: symbolToCreate } }) => {
return symbolToCreate === assetProfileSymbolMapping[symbol];
}
);
if (hasGhostfolioPrefix(symbol)) {
assetProfile.name ??= symbol;
}
symbol = isSymbolOfAssetProfileToCreate
? assetProfileSymbolMapping[symbol]
: randomUUID();
}
assetProfile.symbol = symbol;

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

@ -403,12 +403,8 @@ export class DataProviderService implements OnModuleInit {
})}::timestamp`
: Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => {
return dataSource;
});
const symbols = aItems.map(({ symbol }) => {
return symbol;
const assetProfileIdentifiers = aItems.map(({ dataSource, symbol }) => {
return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`;
});
try {
@ -416,8 +412,7 @@ export class DataProviderService implements OnModuleInit {
.$queryRaw`
SELECT *
FROM "MarketData"
WHERE "dataSource"::text IN (${Prisma.join(dataSources)})
AND "symbol" IN (${Prisma.join(symbols)})
WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifiers)})
${granularityQuery}
${rangeQuery}
ORDER BY date;`;

22
apps/client/src/app/components/home-overview/home-overview.html

@ -83,18 +83,16 @@
[showYAxis]="false"
/>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="locale()"
[performance]="performance()"
[precision]="precision()"
[showDetails]="showDetails()"
[unit]="unit()"
/>
</div>
<div class="mt-1">
<gf-portfolio-performance
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="locale()"
[performance]="performance()"
[precision]="precision()"
[showDetails]="showDetails()"
[unit]="unit()"
/>
</div>
</div>
@if (showExperimentalHoldings()) {

4
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -1,6 +1,6 @@
<div class="container p-0">
<div class="no-gutters row">
<div class="status-container text-muted text-right">
<div class="px-1 status-container text-muted text-right">
@if ((errors()?.length ?? 0) > 0 && !isLoading()) {
<ion-icon
i18n-title
@ -33,7 +33,7 @@
</div>
</div>
@if (showDetails()) {
<div class="row">
<div class="mx-0 row">
<div class="d-flex col justify-content-end">
<gf-value
[colorizeSign]="true"

1
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.scss

@ -4,7 +4,6 @@
.currency-container,
.status-container {
flex: 1;
min-width: 2.5rem;
}
.status-container {

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

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config';
import {
canApplyFiltersToAccess,
canUseCustomAssetProfileSymbol,
extractNumberFromString,
getCountryCodeFromCurrency,
getEmojiFlag,
@ -39,6 +40,50 @@ describe('Helper', () => {
});
});
describe('Can use custom asset profile symbol', () => {
const assetProfile = { id: 'asset-profile-id' };
const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1';
it('Free-text symbol', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile: null,
symbol: 'Penthouse Apartment'
})
).toEqual(false);
});
it('New asset profile with UUID', () => {
expect(
canUseCustomAssetProfileSymbol({ symbol, assetProfile: null })
).toEqual(true);
});
it('New asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile: null,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(false);
});
it('Existing asset profile with UUID', () => {
expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual(
true
);
});
it('Existing asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
});
});
describe('Extract number from string', () => {
it('Get decimal number', () => {
expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99);

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

@ -237,6 +237,29 @@ export function canOpenHoldingDetail({
return assetProfile?.assetSubClass !== AssetSubClass.CASH;
}
/**
* Checks if the symbol of a custom asset profile can be used, either to connect
* to the existing asset profile or to create a new one. The prefix is reserved
* for the asset profiles of the admin, thus a new asset profile cannot use it.
*/
export function canUseCustomAssetProfileSymbol({
assetProfile,
symbol
}: {
assetProfile: Pick<SymbolProfile, 'id'> | null;
symbol: string;
}): boolean {
if (!isValidCustomAssetProfileSymbol(symbol)) {
return false;
}
if (assetProfile) {
return true;
}
return !hasGhostfolioPrefix(symbol);
}
export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
}

4
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.82.0",
"version": "3.83.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.82.0",
"version": "3.83.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

2
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.82.0",
"version": "3.83.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save