diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 661163ff1..6b8f5ecfa 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/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 { 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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -25,6 +26,7 @@ import { ActivitiesService } from './activities.service'; exports: [ActivitiesService], imports: [ ApiModule, + AssetProfileSplitModule, BenchmarkModule, CacheModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index b404b6a37..5fdcbedde 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/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 { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; 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 { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -54,6 +56,7 @@ export class ActivitiesService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly assetProfileSplitService: AssetProfileSplitService, private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, @@ -863,6 +866,25 @@ export class ActivitiesService { 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)) { const cashDetails = await this.accountService.getCashDetails({ filters, diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 2d5c734dc..b14ab81f5 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; @@ -18,6 +19,7 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ + AssetProfileSplitModule, BenchmarkModule, ConfigurationModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index e6f5fd700..70502ce0d 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,4 +1,5 @@ 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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() export class AdminService { public constructor( + private readonly assetProfileSplitService: AssetProfileSplitService, private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -102,6 +104,7 @@ export class AdminService { dataSource, symbol }: AssetProfileIdentifier) { + await this.assetProfileSplitService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol }); const currency = getCurrencyFromSymbol(symbol); @@ -280,6 +283,16 @@ export class AdminService { dataSource: DataSource[newDataSource.toString()], symbol: newSymbol as string } + ), + this.assetProfileSplitService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + { + dataSource: DataSource[newDataSource.toString()], + symbol: newSymbol as string + } ) ]); diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/asset/asset.controller.ts index de04b5c81..49afb43d7 100644 --- a/apps/api/src/app/asset/asset.controller.ts +++ b/apps/api/src/app/asset/asset.controller.ts @@ -20,7 +20,7 @@ export class AssetController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - const { assetProfile, marketData } = + const { assetProfile, marketData, splits } = await this.assetProfilesService.getAssetProfile({ dataSource, symbol @@ -28,6 +28,7 @@ export class AssetController { return { marketData, + splits, assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) }; } diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index d74f167a6..f5e032196 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/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 { ApiService } from '@ghostfolio/api/services/api/api.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 { AssetProfileResponse } from '@ghostfolio/common/interfaces'; import { @@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, + Delete, Get, HttpException, Inject, Param, ParseIntPipe, Patch, + Post, Query, UseGuards, UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; 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 { 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 { + 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 { + await this.validateCanManageSplits({ dataSource, symbol }); + + return this.assetProfilesService.deleteSplit({ dataSource, id, symbol }); + } + @HasPermission(permissions.accessAdminControl) @Patch(':dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -139,4 +176,51 @@ export class AssetProfilesController { 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 + ); + } + } } diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts index f96d92eb3..98cfde3f9 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts +++ b/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 { 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 { 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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -20,11 +22,13 @@ import { AssetProfilesService } from './asset-profiles.service'; imports: [ ActivitiesModule, ApiModule, + AssetProfileSplitModule, BenchmarkModule, DataProviderModule, ExchangeRateDataModule, MarketDataModule, PrismaModule, + RedisCacheModule, SymbolProfileModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index 68b8d5627..66ecc2aff 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/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 { 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { public constructor( private readonly activitiesService: ActivitiesService, + private readonly assetProfileSplitService: AssetProfileSplitService, private readonly benchmarkService: BenchmarkService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, + private readonly redisCacheService: RedisCacheService, 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({ dataSource, symbol @@ -54,7 +92,7 @@ export class AssetProfilesService { await this.activitiesService.getStatisticsByCurrency(currency)); } - const [[assetProfile], marketData] = await Promise.all([ + const [[assetProfile], marketData, splits] = await Promise.all([ this.symbolProfileService.getSymbolProfiles([ { dataSource, @@ -69,6 +107,9 @@ export class AssetProfilesService { dataSource, symbol } + }), + this.assetProfileSplitService.getSplits({ + assetProfileIdentifiers: [{ dataSource, symbol }] }) ]); @@ -80,6 +121,7 @@ export class AssetProfilesService { return { marketData, + splits, assetProfile: assetProfile ?? { activitiesCount, currency, @@ -535,4 +577,27 @@ export class AssetProfilesService { 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 + }); + }) + ); + } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 7774ee629..1c5e2ec43 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -115,6 +116,7 @@ describe('PortfolioCalculator', () => { activitiesService = new ActivitiesService( accountBalanceService, accountService, + new AssetProfileSplitService(null), null, null, dataProviderService, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-nvda-buy-with-split-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-nvda-buy-with-split-and-sell.spec.ts new file mode 100644 index 000000000..fedc36da4 --- /dev/null +++ b/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') } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 8e027f971..2e634c26c 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -95,6 +95,16 @@ function mockGetValue(symbol: string, date: Date) { 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: return { marketPrice: 0 }; } diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 04142c92b..133587c3b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -60,6 +61,7 @@ describe('PortfolioService', () => { activitiesService = new ActivitiesService( null, accountService, + new AssetProfileSplitService(null), null, null, dataProviderService, diff --git a/apps/api/src/helper/portfolio.helper.spec.ts b/apps/api/src/helper/portfolio.helper.spec.ts new file mode 100644 index 000000000..32f2f176a --- /dev/null +++ b/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); + }); +}); diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 6ebe48d3c..86b8dc2fa 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/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 { Big } from 'big.js'; +import { isBefore } from 'date-fns'; export function getFactor(activityType: ActivityType) { let factor: number; @@ -17,3 +22,88 @@ export function getFactor(activityType: ActivityType) { 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() + }) + }; + }); +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.module.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.module.ts new file mode 100644 index 000000000..1219a2f98 --- /dev/null +++ b/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 {} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts new file mode 100644 index 000000000..0823c6fff --- /dev/null +++ b/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 { + 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 { + 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 + } + }); + } +} diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 967a289ef..74a2bf791 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -10,6 +10,7 @@ import { DATE_FORMAT, getCountryName, getCurrencyFromSymbol, + getDateFormatString, isCurrency } from '@ghostfolio/common/helper'; import { @@ -60,6 +61,7 @@ import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -73,6 +75,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { IonIcon } from '@ionic/angular/standalone'; import { AssetClass, + AssetProfileSplit, AssetSubClass, DataGatheringFrequency, MarketData, @@ -84,11 +87,14 @@ import { format } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { addIcons } from 'ionicons'; import { + calendarClearOutline, codeSlashOutline, createOutline, ellipsisVertical, + gitCompareOutline, readerOutline, - serverOutline + serverOutline, + trashOutline } from 'ionicons/icons'; import { isBoolean } from 'lodash'; import ms from 'ms'; @@ -112,6 +118,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; IonIcon, MatButtonModule, MatCheckboxModule, + MatDatepickerModule, MatDialogModule, MatInputModule, MatMenuModule, @@ -192,6 +199,18 @@ export class GfAssetProfileDialogComponent implements OnInit { } ); + protected readonly assetProfileSplitForm = this.formBuilder.group({ + date: new FormControl(null, Validators.required), + factor: new FormControl(null, [ + Validators.required, + (control: AbstractControl): ValidationErrors | null => { + return control.value > 0 && control.value !== 1 + ? null + : { invalidSplitFactor: true }; + } + ]) + }); + protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected canEditAssetProfile = true; @@ -241,6 +260,7 @@ export class GfAssetProfileDialogComponent implements OnInit { value: 'max' } ]; + protected defaultDateFormat: string; protected readonly getCountryName = getCountryName; protected historicalDataItems: LineChartItem[]; protected isBenchmark = false; @@ -264,6 +284,8 @@ export class GfAssetProfileDialogComponent implements OnInit { [name: string]: { name: string; value: number }; }; + protected splits: AssetProfileSplit[] = []; + protected readonly translate = translate; protected user: User; @@ -284,11 +306,14 @@ export class GfAssetProfileDialogComponent implements OnInit { private userService: UserService ) { addIcons({ + calendarClearOutline, codeSlashOutline, createOutline, ellipsisVertical, + gitCompareOutline, readerOutline, - serverOutline + serverOutline, + trashOutline }); } @@ -301,6 +326,7 @@ export class GfAssetProfileDialogComponent implements OnInit { this.benchmarks = benchmarks; this.currencies = currencies; + this.defaultDateFormat = getDateFormatString(this.data.locale); this.initialize(); } @@ -355,8 +381,9 @@ export class GfAssetProfileDialogComponent implements OnInit { symbol: this.data.symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ assetProfile, marketData }) => { + .subscribe(({ assetProfile, marketData, splits }) => { this.assetProfile = assetProfile; + this.splits = splits ?? []; this.assetClassLabel = translate(this.assetProfile?.assetClass ?? ''); this.assetSubClassLabel = translate( @@ -499,6 +526,39 @@ export class GfAssetProfileDialogComponent implements OnInit { .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) { if (withRefresh) { this.initialize(); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 9b69ef6fc..93fb24f03 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -489,6 +489,107 @@ + + + +
Splits
+
+
+
+
+

+ Activities dated before a split are adjusted at calculation + time. After adding a split, gather the historical market data of + this asset profile again. +

+ @if (splits.length > 0) { + + + + + + + + + + @for (split of splits; track split.id) { + + + + + + } + +
Date + Split Factor +
+ {{ split.date | date: defaultDateFormat }} + + {{ split.factor }} + + +
+ } +
+ + Date + + + + + + + + Split Factor + + Shares after per 1 share before, e.g. 4 for a 4:1 split or + 0.1 for a 1:10 reverse split + + +
+
+
+
+
@if (assetProfile?.dataSource === 'MANUAL') { diff --git a/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts b/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts new file mode 100644 index 000000000..0f4f1367b --- /dev/null +++ b/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; +} diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index cf0ce6f57..7a67c387c 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/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 { CreateAccountWithBalancesDto } from './create-account-with-balances.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 { CreateAssetProfileDto } from './create-asset-profile.dto'; import { CreateOrderDto } from './create-order.dto'; @@ -31,6 +32,7 @@ export { CreateAccountDto, CreateAccountWithBalancesDto, CreateAssetProfileDto, + CreateAssetProfileSplitDto, CreateAssetProfileWithMarketDataDto, CreateOrderDto, CreatePlatformDto, diff --git a/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts b/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts index 441643f81..fc0aec007 100644 --- a/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts +++ b/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'; export interface AdminMarketDataDetails { assetProfile: Partial; marketData: MarketData[]; + splits: AssetProfileSplit[]; } diff --git a/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts b/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts index 884b0d411..8cb241cb8 100644 --- a/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts +++ b/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'; export interface AssetProfileResponse { assetProfile: Partial; marketData: MarketData[]; + splits: AssetProfileSplit[]; } diff --git a/libs/common/src/lib/validator-constraints/is-split-factor.ts b/libs/common/src/lib/validator-constraints/is-split-factor.ts new file mode 100644 index 000000000..40e13eb55 --- /dev/null +++ b/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; + } +} diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts index 4d2f6e48f..075397308 100644 --- a/libs/ui/src/lib/services/admin.service.ts +++ b/libs/ui/src/lib/services/admin.service.ts @@ -4,6 +4,7 @@ import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; import { + CreateAssetProfileSplitDto, CreatePlatformDto, UpdateAssetProfileDto, UpdatePlatformDto @@ -23,7 +24,7 @@ import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { MarketData, Platform } from '@prisma/client'; +import { AssetProfileSplit, MarketData, Platform } from '@prisma/client'; import { JobStatus } from 'bull'; import { isNumber } from 'lodash'; @@ -61,6 +62,16 @@ export class AdminService { return this.http.delete(`/api/v1/platform/${aId}`); } + public deleteAssetProfileSplit({ + dataSource, + id, + symbol + }: AssetProfileIdentifier & { id: string }) { + return this.http.delete( + `/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits/${id}` + ); + } + public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { return this.http.delete( `/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( + `/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits`, + split + ); + } + public postPlatform(aPlatform: CreatePlatformDto) { return this.http.post(`/api/v1/platform`, aPlatform); } diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 5492794d7..81a8fa729 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -553,6 +553,10 @@ export class DataService { item.date = parseISO(item.date); } + for (const item of data.splits ?? []) { + item.date = parseISO(item.date); + } + return data; }) ); diff --git a/prisma/migrations/20260705000000_added_asset_profile_split/migration.sql b/prisma/migrations/20260705000000_added_asset_profile_split/migration.sql new file mode 100644 index 000000000..93f889e15 --- /dev/null +++ b/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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 233a63688..2525c3668 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,6 +104,20 @@ model AssetProfileResolution { @@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 { createdAt DateTime @default(now()) credentialId Bytes diff --git a/test/import/ok/nvda-buy-with-split-and-sell.json b/test/import/ok/nvda-buy-with-split-and-sell.json new file mode 100644 index 000000000..550c63da6 --- /dev/null +++ b/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" + } + } +}