Browse Source

Merge 1d27cd0b38 into b6dbfeaa26

pull/7251/merge
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
53a823f2e0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 2
      apps/api/src/app/activities/activities.module.ts
  3. 22
      apps/api/src/app/activities/activities.service.ts
  4. 2
      apps/api/src/app/admin/admin.module.ts
  5. 13
      apps/api/src/app/admin/admin.service.ts
  6. 3
      apps/api/src/app/asset/asset.controller.ts
  7. 88
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  8. 4
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  9. 67
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  10. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  11. 188
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-nvda-buy-with-split-and-sell.spec.ts
  12. 10
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  13. 2
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  14. 176
      apps/api/src/helper/portfolio.helper.spec.ts
  15. 90
      apps/api/src/helper/portfolio.helper.ts
  16. 12
      apps/api/src/services/asset-profile-split/asset-profile-split.module.ts
  17. 104
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  18. 66
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  19. 109
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  20. 12
      libs/common/src/lib/dtos/create-asset-profile-split.dto.ts
  21. 2
      libs/common/src/lib/dtos/index.ts
  22. 3
      libs/common/src/lib/interfaces/admin-market-data-details.interface.ts
  23. 3
      libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts
  24. 15
      libs/common/src/lib/validator-constraints/is-split-factor.ts
  25. 24
      libs/ui/src/lib/services/admin.service.ts
  26. 4
      libs/ui/src/lib/services/data.service.ts
  27. 21
      prisma/migrations/20260705000000_added_asset_profile_split/migration.sql
  28. 14
      prisma/schema.prisma
  29. 34
      test/import/ok/nvda-buy-with-split-and-sell.json

6
CHANGELOG.md

@ -5,6 +5,12 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added support for stock splits (experimental)
## 3.21.0 - 2026-07-05 ## 3.21.0 - 2026-07-05
### Added ### Added

2
apps/api/src/app/activities/activities.module.ts

@ -6,6 +6,7 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -25,6 +26,7 @@ import { ActivitiesService } from './activities.service';
exports: [ActivitiesService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
AssetProfileSplitModule,
BenchmarkModule, BenchmarkModule,
CacheModule, CacheModule,
DataGatheringQueueModule, DataGatheringQueueModule,

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

@ -4,7 +4,9 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
import { applySplitAdjustments } from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -54,6 +56,7 @@ export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -863,6 +866,25 @@ export class ActivitiesService {
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities: false // TODO
}); });
const splits = await this.assetProfileSplitService.getSplits({
assetProfileIdentifiers: uniqBy(
activities.activities.map(({ SymbolProfile }) => {
return {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
}),
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
)
});
activities.activities = applySplitAdjustments({
splits,
activities: activities.activities
});
if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) { if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) {
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
filters, filters,

2
apps/api/src/app/admin/admin.module.ts

@ -1,4 +1,5 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -18,6 +19,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
AssetProfileSplitModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,

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

@ -1,4 +1,5 @@
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -43,6 +44,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -102,6 +104,7 @@ export class AdminService {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
await this.assetProfileSplitService.deleteMany({ dataSource, symbol });
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
@ -280,6 +283,16 @@ export class AdminService {
dataSource: DataSource[newDataSource.toString()], dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string symbol: newSymbol as string
} }
),
this.assetProfileSplitService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
) )
]); ]);

3
apps/api/src/app/asset/asset.controller.ts

@ -20,7 +20,7 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AssetResponse> { ): Promise<AssetResponse> {
const { assetProfile, marketData } = const { assetProfile, marketData, splits } =
await this.assetProfilesService.getAssetProfile({ await this.assetProfilesService.getAssetProfile({
dataSource, dataSource,
symbol symbol
@ -28,6 +28,7 @@ export class AssetController {
return { return {
marketData, marketData,
splits,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
}; };
} }

88
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -4,7 +4,10 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import {
CreateAssetProfileSplitDto,
UpdateAssetProfileDataDto
} from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces'; import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import { import {
@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe, ParseIntPipe,
Patch, Patch,
Post,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, Prisma } from '@prisma/client'; import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
@ -119,6 +125,37 @@ export class AssetProfilesController {
}); });
} }
@Post(':dataSource/:symbol/splits')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createSplit(
@Body() data: CreateAssetProfileSplitDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileSplit> {
await this.validateCanManageSplits({ dataSource, symbol });
return this.assetProfilesService.createSplit({
dataSource,
symbol,
date: parseISO(data.date),
factor: data.factor
});
}
@Delete(':dataSource/:symbol/splits/:id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteSplit(
@Param('dataSource') dataSource: DataSource,
@Param('id') id: string,
@Param('symbol') symbol: string
): Promise<void> {
await this.validateCanManageSplits({ dataSource, symbol });
return this.assetProfilesService.deleteSplit({ dataSource, id, symbol });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol') @Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -139,4 +176,51 @@ export class AssetProfilesController {
assetProfileData assetProfileData
); );
} }
private async validateCanManageSplits({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canManageAllAssetProfiles =
hasPermission(
this.request.user.permissions,
permissions.createMarketData
) &&
hasPermission(
this.request.user.permissions,
permissions.updateMarketData
);
const canManageOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.createMarketDataOfOwnAssetProfile
) &&
hasPermission(
this.request.user.permissions,
permissions.updateMarketDataOfOwnAssetProfile
);
if (!canManageAllAssetProfiles && !canManageOwnAssetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
} }

4
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -1,7 +1,9 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -20,11 +22,13 @@ import { AssetProfilesService } from './asset-profiles.service';
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule, ApiModule,
AssetProfileSplitModule,
BenchmarkModule, BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule

67
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -1,4 +1,6 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -30,14 +32,50 @@ import { groupBy } from 'lodash';
export class AssetProfilesService { export class AssetProfilesService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async createSplit({
dataSource,
date,
factor,
symbol
}: AssetProfileIdentifier & { date: Date; factor: number }) {
const split = await this.assetProfileSplitService.upsert({
dataSource,
date,
factor,
symbol
});
await this.invalidatePortfolioSnapshotsOfUsersWithActivities({
dataSource,
symbol
});
return split;
}
public async deleteSplit({
dataSource,
id,
symbol
}: AssetProfileIdentifier & { id: string }) {
await this.assetProfileSplitService.deleteById({ dataSource, id, symbol });
await this.invalidatePortfolioSnapshotsOfUsersWithActivities({
dataSource,
symbol
});
}
public async getAssetProfile({ public async getAssetProfile({
dataSource, dataSource,
symbol symbol
@ -54,7 +92,7 @@ export class AssetProfilesService {
await this.activitiesService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData, splits] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
dataSource, dataSource,
@ -69,6 +107,9 @@ export class AssetProfilesService {
dataSource, dataSource,
symbol symbol
} }
}),
this.assetProfileSplitService.getSplits({
assetProfileIdentifiers: [{ dataSource, symbol }]
}) })
]); ]);
@ -80,6 +121,7 @@ export class AssetProfilesService {
return { return {
marketData, marketData,
splits,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
activitiesCount, activitiesCount,
currency, currency,
@ -535,4 +577,27 @@ export class AssetProfilesService {
return this.prismaService.$extends(symbolProfileExtension); return this.prismaService.$extends(symbolProfileExtension);
} }
private async invalidatePortfolioSnapshotsOfUsersWithActivities({
dataSource,
symbol
}: AssetProfileIdentifier) {
const usersWithActivities = await this.prismaService.order.groupBy({
by: ['userId'],
where: {
SymbolProfile: {
dataSource,
symbol
}
}
});
await Promise.all(
usersWithActivities.map(({ userId }) => {
return this.redisCacheService.removePortfolioSnapshotsByUserId({
userId
});
})
);
}
} }

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -7,6 +7,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -115,6 +116,7 @@ describe('PortfolioCalculator', () => {
activitiesService = new ActivitiesService( activitiesService = new ActivitiesService(
accountBalanceService, accountBalanceService,
accountService, accountService,
new AssetProfileSplitService(null),
null, null,
null, null,
dataProviderService, dataProviderService,

188
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-nvda-buy-with-split-and-sell.spec.ts

@ -0,0 +1,188 @@
import {
activityDummyData,
loadExportFile,
symbolProfileDummyData,
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 { applySplitAdjustments } from '@ghostfolio/api/helper/portfolio.helper';
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, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
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 exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/nvda-buy-with-split-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with NVDA buy (before a 10:1 split) and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-06-14').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'NVIDIA Corporation',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
// Mirrors the production flow in
// ActivitiesService.getActivitiesForPortfolioCalculator
const adjustedActivities = applySplitAdjustments({
activities,
splits: [
{
dataSource: 'YAHOO',
date: parseDate('2024-06-10'),
factor: 10,
symbol: 'NVDA'
}
]
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: adjustedActivities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2024-06-03',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('50'), // 10 × (125 - 120) = 50
grossPerformanceWithCurrencyEffect: new Big('50'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('50'),
netPerformanceWithCurrencyEffectMap: {
max: new Big('50')
},
marketPrice: 125,
marketPriceInBaseCurrency: 125,
quantity: new Big('0'),
symbol: 'NVDA',
tags: [],
timeWeightedInvestment: new Big('1200'), // 10 × 120 = 1200
timeWeightedInvestmentWithCurrencyEffect: new Big('1200'),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2024-06-03', investment: new Big('1200') },
{ date: '2024-06-14', investment: new Big('0') }
]);
});
});
});

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

@ -95,6 +95,16 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'NVDA':
// Split-adjusted market prices (10:1 split on 2024-06-10)
if (isSameDay(parseDate('2024-06-03'), date)) {
return { marketPrice: 120 };
} else if (isSameDay(parseDate('2024-06-14'), date)) {
return { marketPrice: 125 };
}
return { marketPrice: 0 };
default: default:
return { marketPrice: 0 }; return { marketPrice: 0 };
} }

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

@ -4,6 +4,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -60,6 +61,7 @@ describe('PortfolioService', () => {
activitiesService = new ActivitiesService( activitiesService = new ActivitiesService(
null, null,
accountService, accountService,
new AssetProfileSplitService(null),
null, null,
null, null,
dataProviderService, dataProviderService,

176
apps/api/src/helper/portfolio.helper.spec.ts

@ -0,0 +1,176 @@
import { DataSource } from '@prisma/client';
import { applySplitAdjustments } from './portfolio.helper';
describe('applySplitAdjustments', () => {
const symbolProfile = {
dataSource: DataSource.YAHOO,
symbol: 'NVDA'
};
const otherSymbolProfile = {
dataSource: DataSource.YAHOO,
symbol: 'AAPL'
};
const createActivity = ({
date,
fee = 1,
quantity,
SymbolProfile = symbolProfile,
unitPrice
}: {
date: Date;
fee?: number;
quantity: number;
SymbolProfile?: typeof symbolProfile;
unitPrice: number;
}) => {
return {
date,
fee,
quantity,
SymbolProfile,
unitPrice,
unitPriceInAssetProfileCurrency: unitPrice
};
};
it('should return activities unchanged if there are no splits', () => {
const activities = [
createActivity({
date: new Date('2024-01-15'),
quantity: 10,
unitPrice: 400
})
];
expect(applySplitAdjustments({ activities, splits: [] })).toStrictEqual(
activities
);
});
it('should adjust an activity before a split', () => {
const activities = [
createActivity({
date: new Date('2024-01-15'),
quantity: 10,
unitPrice: 400
})
];
const [activity] = applySplitAdjustments({
activities,
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }]
});
expect(activity.quantity).toBe(40);
expect(activity.unitPrice).toBe(100);
expect(activity.unitPriceInAssetProfileCurrency).toBe(100);
});
it('should keep the value and the fee unchanged', () => {
const activities = [
createActivity({
date: new Date('2024-01-15'),
fee: 7,
quantity: 3,
unitPrice: 700
})
];
const [activity] = applySplitAdjustments({
activities,
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }]
});
expect(activity.quantity * activity.unitPrice).toBe(3 * 700);
expect(activity.fee).toBe(7);
});
it('should not adjust an activity on or after the split date', () => {
const activities = [
createActivity({
date: new Date('2024-06-10'),
quantity: 10,
unitPrice: 100
}),
createActivity({
date: new Date('2024-07-01'),
quantity: 20,
unitPrice: 110
})
];
const adjustedActivities = applySplitAdjustments({
activities,
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }]
});
expect(adjustedActivities).toStrictEqual(activities);
});
it('should adjust for a reverse split', () => {
const activities = [
createActivity({
date: new Date('2024-01-15'),
quantity: 100,
unitPrice: 2
})
];
const [activity] = applySplitAdjustments({
activities,
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 0.1 }]
});
expect(activity.quantity).toBe(10);
expect(activity.unitPrice).toBe(20);
});
it('should compound multiple splits', () => {
const activities = [
createActivity({
date: new Date('2023-01-15'),
quantity: 10,
unitPrice: 600
}),
createActivity({
date: new Date('2024-01-15'),
quantity: 10,
unitPrice: 300
})
];
const adjustedActivities = applySplitAdjustments({
activities,
splits: [
{ ...symbolProfile, date: new Date('2023-06-10'), factor: 2 },
{ ...symbolProfile, date: new Date('2024-06-10'), factor: 3 }
]
});
expect(adjustedActivities[0].quantity).toBe(60);
expect(adjustedActivities[0].unitPrice).toBe(100);
expect(adjustedActivities[1].quantity).toBe(30);
expect(adjustedActivities[1].unitPrice).toBe(100);
});
it('should not adjust activities of other symbols', () => {
const activities = [
createActivity({
date: new Date('2024-01-15'),
quantity: 10,
SymbolProfile: otherSymbolProfile,
unitPrice: 190
})
];
const adjustedActivities = applySplitAdjustments({
activities,
splits: [{ ...symbolProfile, date: new Date('2024-06-10'), factor: 4 }]
});
expect(adjustedActivities).toStrictEqual(activities);
});
});

90
apps/api/src/helper/portfolio.helper.ts

@ -1,4 +1,9 @@
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js';
import { isBefore } from 'date-fns';
export function getFactor(activityType: ActivityType) { export function getFactor(activityType: ActivityType) {
let factor: number; let factor: number;
@ -17,3 +22,88 @@ export function getFactor(activityType: ActivityType) {
return factor; return factor;
} }
/**
* Adjusts activities for splits so that their quantity and unit price are in
* the same share basis as the (split-adjusted) market data of the data
* providers.
*
* Activities dated strictly before a split are considered to be in pre-split
* terms: their quantity is multiplied and their unit price is divided by the
* split factor (shares after per 1 share before). Activities dated on or
* after the split date are already in post-split terms. The value
* (quantity × unit price) and the fee remain unchanged.
*/
export function applySplitAdjustments<
T extends {
date: Date;
quantity: number;
SymbolProfile: AssetProfileIdentifier;
unitPrice: number;
unitPriceInAssetProfileCurrency?: number;
}
>({
activities,
splits
}: {
activities: T[];
splits: (AssetProfileIdentifier & { date: Date; factor: number })[];
}): T[] {
if (splits.length === 0) {
return activities;
}
const splitsByAssetProfile = new Map<
string,
{ date: Date; factor: number }[]
>();
for (const split of splits) {
const key = getAssetProfileIdentifier(split);
if (!splitsByAssetProfile.has(key)) {
splitsByAssetProfile.set(key, []);
}
splitsByAssetProfile.get(key).push(split);
}
return activities.map((activity) => {
const splitsOfAssetProfile = activity.SymbolProfile
? splitsByAssetProfile.get(
getAssetProfileIdentifier(activity.SymbolProfile)
)
: undefined;
if (!splitsOfAssetProfile) {
return activity;
}
let factor = new Big(1);
for (const split of splitsOfAssetProfile) {
if (isBefore(activity.date, split.date)) {
factor = factor.mul(split.factor);
}
}
if (factor.eq(1)) {
return activity;
}
return {
...activity,
quantity: factor.mul(activity.quantity).toNumber(),
unitPrice: new Big(activity.unitPrice).div(factor).toNumber(),
...(activity.unitPriceInAssetProfileCurrency === undefined
? {}
: {
unitPriceInAssetProfileCurrency: new Big(
activity.unitPriceInAssetProfileCurrency
)
.div(factor)
.toNumber()
})
};
});
}

12
apps/api/src/services/asset-profile-split/asset-profile-split.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AssetProfileSplitService } from './asset-profile-split.service';
@Module({
exports: [AssetProfileSplitService],
imports: [PrismaModule],
providers: [AssetProfileSplitService]
})
export class AssetProfileSplitModule {}

104
apps/api/src/services/asset-profile-split/asset-profile-split.service.ts

@ -0,0 +1,104 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AssetProfileSplit } from '@prisma/client';
@Injectable()
export class AssetProfileSplitService {
public constructor(private readonly prismaService: PrismaService) {}
public async deleteById({
dataSource,
id,
symbol
}: AssetProfileIdentifier & { id: string }) {
return this.prismaService.assetProfileSplit.deleteMany({
where: {
dataSource,
id,
symbol
}
});
}
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.assetProfileSplit.deleteMany({
where: {
dataSource,
symbol
}
});
}
public async getSplits({
assetProfileIdentifiers
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
}): Promise<AssetProfileSplit[]> {
if (assetProfileIdentifiers.length === 0) {
return [];
}
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
}
});
}
public async upsert({
dataSource,
date,
factor,
symbol
}: AssetProfileIdentifier & {
date: Date;
factor: number;
}): Promise<AssetProfileSplit> {
return this.prismaService.assetProfileSplit.upsert({
create: {
dataSource,
date,
factor,
symbol
},
update: {
factor
},
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
public async updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier
) {
return this.prismaService.assetProfileSplit.updateMany({
data: {
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
},
where: {
dataSource: oldAssetProfileIdentifier.dataSource,
symbol: oldAssetProfileIdentifier.symbol
}
});
}
}

66
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -10,6 +10,7 @@ import {
DATE_FORMAT, DATE_FORMAT,
getCountryName, getCountryName,
getCurrencyFromSymbol, getCurrencyFromSymbol,
getDateFormatString,
isCurrency isCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -60,6 +61,7 @@ import {
MatCheckboxChange, MatCheckboxChange,
MatCheckboxModule MatCheckboxModule
} from '@angular/material/checkbox'; } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -73,6 +75,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { import {
AssetClass, AssetClass,
AssetProfileSplit,
AssetSubClass, AssetSubClass,
DataGatheringFrequency, DataGatheringFrequency,
MarketData, MarketData,
@ -84,11 +87,14 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
calendarClearOutline,
codeSlashOutline, codeSlashOutline,
createOutline, createOutline,
ellipsisVertical, ellipsisVertical,
gitCompareOutline,
readerOutline, readerOutline,
serverOutline serverOutline,
trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isBoolean } from 'lodash'; import { isBoolean } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -112,6 +118,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
@ -192,6 +199,18 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
); );
protected readonly assetProfileSplitForm = this.formBuilder.group({
date: new FormControl<Date | null>(null, Validators.required),
factor: new FormControl<number | null>(null, [
Validators.required,
(control: AbstractControl): ValidationErrors | null => {
return control.value > 0 && control.value !== 1
? null
: { invalidSplitFactor: true };
}
])
});
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true; protected canEditAssetProfile = true;
@ -241,6 +260,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
protected defaultDateFormat: string;
protected readonly getCountryName = getCountryName; protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
protected isBenchmark = false; protected isBenchmark = false;
@ -264,6 +284,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected splits: AssetProfileSplit[] = [];
protected readonly translate = translate; protected readonly translate = translate;
protected user: User; protected user: User;
@ -284,11 +306,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
private userService: UserService private userService: UserService
) { ) {
addIcons({ addIcons({
calendarClearOutline,
codeSlashOutline, codeSlashOutline,
createOutline, createOutline,
ellipsisVertical, ellipsisVertical,
gitCompareOutline,
readerOutline, readerOutline,
serverOutline serverOutline,
trashOutline
}); });
} }
@ -301,6 +326,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.data.locale);
this.initialize(); this.initialize();
} }
@ -355,8 +381,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData, splits }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.splits = splits ?? [];
this.assetClassLabel = translate(this.assetProfile?.assetClass ?? ''); this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate( this.assetSubClassLabel = translate(
@ -499,6 +526,39 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
protected onAddSplit() {
const { date, factor } = this.assetProfileSplitForm.getRawValue();
this.adminService
.postAssetProfileSplit({
dataSource: this.data.dataSource,
split: {
factor,
date: format(date, DATE_FORMAT)
},
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.assetProfileSplitForm.reset();
this.initialize();
});
}
protected onDeleteSplit(aId: string) {
this.adminService
.deleteAssetProfileSplit({
id: aId,
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onMarketDataChanged(withRefresh: boolean = false) { protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();

109
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -489,6 +489,115 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
@if (user?.settings?.isExperimentalFeatures) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="git-compare-outline" />
<div class="d-none d-sm-block ml-2" i18n>Splits</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="no-gutters row w-100">
<div class="col-12">
<p class="text-muted" i18n>
Activities dated before a split are adjusted at calculation
time. After adding a split, gather the historical market data
of this asset profile again.
</p>
@if (splits.length > 0) {
<table class="gf-table mb-3 w-100">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2" i18n>Date</th>
<th
class="mat-mdc-header-cell px-1 py-2 text-right"
i18n
>
Split Factor
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
@for (split of splits; track split.id) {
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-1 py-2">
{{ split.date | date: defaultDateFormat }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.factor }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<button
mat-icon-button
type="button"
[disabled]="!canEditAssetProfile"
(click)="onDeleteSplit(split.id)"
>
<ion-icon name="trash-outline" />
</button>
</td>
</tr>
}
</tbody>
</table>
}
<form
class="align-items-center d-flex"
[formGroup]="assetProfileSplitForm"
(ngSubmit)="onAddSplit()"
>
<mat-form-field
appearance="outline"
class="mr-3 without-hint"
>
<mat-label i18n>Date</mat-label>
<input
formControlName="date"
matInput
[matDatepicker]="dateOfSplit"
/>
<mat-datepicker-toggle
class="mr-2"
matSuffix
[for]="dateOfSplit"
>
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #dateOfSplit />
</mat-form-field>
<mat-form-field appearance="outline" class="mr-3">
<mat-label i18n>Split Factor</mat-label>
<input
formControlName="factor"
matInput
step="any"
type="number"
/>
<mat-hint i18n
>Shares after per 1 share before, e.g. 4 for a 4:1 split
or 0.1 for a 1:10 reverse split</mat-hint
>
</mat-form-field>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="
!assetProfileSplitForm.valid || !canEditAssetProfile
"
>
<ng-container i18n>Add</ng-container>
</button>
</form>
</div>
</div>
</div>
</mat-tab>
}
@if (assetProfile?.dataSource === 'MANUAL') { @if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>

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

@ -0,0 +1,12 @@
import { IsISO8601, IsNumber, Validate } from 'class-validator';
import { IsSplitFactorConstraint } from '../validator-constraints/is-split-factor';
export class CreateAssetProfileSplitDto {
@IsISO8601()
date: string;
@IsNumber()
@Validate(IsSplitFactorConstraint)
factor: number;
}

2
libs/common/src/lib/dtos/index.ts

@ -3,6 +3,7 @@ import { CreateAccessDto } from './create-access.dto';
import { CreateAccountBalanceDto } from './create-account-balance.dto'; import { CreateAccountBalanceDto } from './create-account-balance.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAccountDto } from './create-account.dto'; import { CreateAccountDto } from './create-account.dto';
import { CreateAssetProfileSplitDto } from './create-asset-profile-split.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
import { CreateAssetProfileDto } from './create-asset-profile.dto'; import { CreateAssetProfileDto } from './create-asset-profile.dto';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
@ -31,6 +32,7 @@ export {
CreateAccountDto, CreateAccountDto,
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
CreateAssetProfileDto, CreateAssetProfileDto,
CreateAssetProfileSplitDto,
CreateAssetProfileWithMarketDataDto, CreateAssetProfileWithMarketDataDto,
CreateOrderDto, CreateOrderDto,
CreatePlatformDto, CreatePlatformDto,

3
libs/common/src/lib/interfaces/admin-market-data-details.interface.ts

@ -1,8 +1,9 @@
import { MarketData } from '@prisma/client'; import { AssetProfileSplit, MarketData } from '@prisma/client';
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
export interface AdminMarketDataDetails { export interface AdminMarketDataDetails {
assetProfile: Partial<EnhancedSymbolProfile>; assetProfile: Partial<EnhancedSymbolProfile>;
marketData: MarketData[]; marketData: MarketData[];
splits: AssetProfileSplit[];
} }

3
libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts

@ -1,8 +1,9 @@
import { MarketData } from '@prisma/client'; import { AssetProfileSplit, MarketData } from '@prisma/client';
import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface'; import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface';
export interface AssetProfileResponse { export interface AssetProfileResponse {
assetProfile: Partial<EnhancedSymbolProfile>; assetProfile: Partial<EnhancedSymbolProfile>;
marketData: MarketData[]; marketData: MarketData[];
splits: AssetProfileSplit[];
} }

15
libs/common/src/lib/validator-constraints/is-split-factor.ts

@ -0,0 +1,15 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
@ValidatorConstraint({ name: 'isSplitFactor' })
export class IsSplitFactorConstraint implements ValidatorConstraintInterface {
public defaultMessage() {
return 'factor must be a positive number other than 1';
}
public validate(aFactor: number) {
return typeof aFactor === 'number' && aFactor > 0 && aFactor !== 1;
}
}

24
libs/ui/src/lib/services/admin.service.ts

@ -4,6 +4,7 @@ import {
HEADER_KEY_TOKEN HEADER_KEY_TOKEN
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
CreateAssetProfileSplitDto,
CreatePlatformDto, CreatePlatformDto,
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdatePlatformDto UpdatePlatformDto
@ -23,7 +24,7 @@ import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { MarketData, Platform } from '@prisma/client'; import { AssetProfileSplit, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -61,6 +62,16 @@ export class AdminService {
return this.http.delete<void>(`/api/v1/platform/${aId}`); return this.http.delete<void>(`/api/v1/platform/${aId}`);
} }
public deleteAssetProfileSplit({
dataSource,
id,
symbol
}: AssetProfileIdentifier & { id: string }) {
return this.http.delete<void>(
`/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits/${id}`
);
}
public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<void>( return this.http.delete<void>(
`/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}` `/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}`
@ -217,6 +228,17 @@ export class AdminService {
); );
} }
public postAssetProfileSplit({
dataSource,
split,
symbol
}: AssetProfileIdentifier & { split: CreateAssetProfileSplitDto }) {
return this.http.post<AssetProfileSplit>(
`/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits`,
split
);
}
public postPlatform(aPlatform: CreatePlatformDto) { public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform); return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
} }

4
libs/ui/src/lib/services/data.service.ts

@ -553,6 +553,10 @@ export class DataService {
item.date = parseISO(item.date); item.date = parseISO(item.date);
} }
for (const item of data.splits ?? []) {
item.date = parseISO(item.date);
}
return data; return data;
}) })
); );

21
prisma/migrations/20260705000000_added_asset_profile_split/migration.sql

@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "public"."AssetProfileSplit" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dataSource" "public"."DataSource" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"factor" DOUBLE PRECISION NOT NULL,
"id" TEXT NOT NULL,
"symbol" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AssetProfileSplit_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AssetProfileSplit_dataSource_date_symbol_key" ON "public"."AssetProfileSplit"("dataSource", "date", "symbol");
-- CreateIndex
CREATE INDEX "AssetProfileSplit_dataSource_symbol_idx" ON "public"."AssetProfileSplit"("dataSource", "symbol");
-- CreateIndex
CREATE INDEX "AssetProfileSplit_date_idx" ON "public"."AssetProfileSplit"("date");

14
prisma/schema.prisma

@ -104,6 +104,20 @@ model AssetProfileResolution {
@@unique([dataSourceOrigin, symbolOrigin]) @@unique([dataSourceOrigin, symbolOrigin])
} }
model AssetProfileSplit {
createdAt DateTime @default(now())
dataSource DataSource
date DateTime
factor Float
id String @id @default(uuid())
symbol String
updatedAt DateTime @updatedAt
@@unique([dataSource, date, symbol])
@@index([dataSource, symbol])
@@index([date])
}
model AuthDevice { model AuthDevice {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
credentialId Bytes credentialId Bytes

34
test/import/ok/nvda-buy-with-split-and-sell.json

@ -0,0 +1,34 @@
{
"meta": {
"date": "2024-06-14T21:28:05.857Z",
"version": "dev"
},
"activities": [
{
"fee": 0,
"quantity": 10,
"type": "SELL",
"unitPrice": 125,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2024-06-14T00:00:00.000Z",
"symbol": "NVDA"
},
{
"fee": 0,
"quantity": 1,
"type": "BUY",
"unitPrice": 1200,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2024-06-03T00:00:00.000Z",
"symbol": "NVDA"
}
],
"user": {
"settings": {
"currency": "USD",
"performanceCalculationType": "ROAI"
}
}
}
Loading…
Cancel
Save