From ddf446f9156243a140b3bed6ab8a70215c81193e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:00:13 +0200 Subject: [PATCH] Feature/stock splits setup (#7251) * Stock splits setup * Update changelog --- CHANGELOG.md | 4 + apps/api/src/app/asset/asset.controller.ts | 3 +- .../asset-profiles.controller.ts | 96 ++++++++++++- .../asset-profiles/asset-profiles.module.ts | 2 + .../asset-profiles/asset-profiles.service.ts | 45 ++++++- apps/api/src/app/user/user.service.ts | 2 + .../asset-profile-split.module.ts | 12 ++ .../asset-profile-split.service.ts | 87 ++++++++++++ .../asset-profile-dialog.component.ts | 85 +++++++++++- .../asset-profile-dialog.html | 127 ++++++++++++++++++ .../dtos/create-asset-profile-split.dto.ts | 39 ++++++ libs/common/src/lib/dtos/index.ts | 2 + libs/common/src/lib/helper.spec.ts | 48 ++++++- libs/common/src/lib/helper.ts | 22 +++ .../admin-market-data-details.interface.ts | 3 +- .../asset-profile-response.interface.ts | 3 +- libs/common/src/lib/permissions.ts | 12 ++ .../validator-constraints/is-split-ratio.ts | 18 +++ libs/ui/src/lib/services/admin.service.ts | 24 +++- libs/ui/src/lib/services/data.service.ts | 4 + .../migration.sql | 21 +++ prisma/schema.prisma | 28 ++++ 22 files changed, 674 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/services/asset-profile-split/asset-profile-split.module.ts create mode 100644 apps/api/src/services/asset-profile-split/asset-profile-split.service.ts create mode 100644 libs/common/src/lib/dtos/create-asset-profile-split.dto.ts create mode 100644 libs/common/src/lib/validator-constraints/is-split-ratio.ts create mode 100644 prisma/migrations/20260802000000_added_asset_profile_split/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c32132bc9..53c443900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 3.40.0 - 2026-08-02 +### Added + +- Added the database model and endpoints to manage the stock splits of an asset profile (experimental) + ### Changed - Improved the style of the read-only tags in the tags selector component 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 6be23c269..6521665e1 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,49 @@ 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 { + const { id: symbolProfileId } = await this.validateAccessToSplits({ + dataSource, + symbol, + permission: permissions.createAssetProfileSplit, + permissionOfOwnAssetProfile: + permissions.createAssetProfileSplitOfOwnAssetProfile + }); + + return this.assetProfilesService.createSplit({ + symbolProfileId, + date: parseISO(data.date), + denominator: data.denominator, + numerator: data.numerator + }); + } + + @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 { + const { id: symbolProfileId } = await this.validateAccessToSplits({ + dataSource, + symbol, + permission: permissions.deleteAssetProfileSplit, + permissionOfOwnAssetProfile: + permissions.deleteAssetProfileSplitOfOwnAssetProfile + }); + + return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); + } + @HasPermission(permissions.accessAdminControl) @Patch(':dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -139,4 +188,47 @@ export class AssetProfilesController { assetProfileData ); } + + private async validateAccessToSplits({ + dataSource, + permission, + permissionOfOwnAssetProfile, + symbol + }: { + dataSource: DataSource; + permission: string; + permissionOfOwnAssetProfile: string; + symbol: string; + }) { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canAccessAllAssetProfiles = hasPermission( + this.request.user.permissions, + permission + ); + + const canAccessOwnAssetProfile = + assetProfile.userId === this.request.user.id && + hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile); + + if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) { + throw new HttpException( + assetProfile.userId + ? getReasonPhrase(StatusCodes.NOT_FOUND) + : getReasonPhrase(StatusCodes.FORBIDDEN), + assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + ); + } + + return assetProfile; + } } 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..e3906be9f 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 @@ -2,6 +2,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.modu 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,6 +21,7 @@ import { AssetProfilesService } from './asset-profiles.service'; imports: [ ActivitiesModule, ApiModule, + AssetProfileSplitModule, BenchmarkModule, DataProviderModule, ExchangeRateDataModule, 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 bc6aeff1e..7d75fc40b 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,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.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,6 +31,7 @@ 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, @@ -38,6 +40,42 @@ export class AssetProfilesService { private readonly symbolProfileService: SymbolProfileService ) {} + public async createSplit({ + date, + denominator, + numerator, + symbolProfileId + }: { + date: Date; + denominator: number; + numerator: number; + symbolProfileId: string; + }) { + return this.assetProfileSplitService.upsert({ + date, + denominator, + numerator, + symbolProfileId + }); + } + + public async deleteSplit({ + id, + symbolProfileId + }: { + id: string; + symbolProfileId: string; + }) { + const isDeleted = await this.assetProfileSplitService.deleteById({ + id, + symbolProfileId + }); + + if (!isDeleted) { + throw new NotFoundException(); + } + } + 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,7 +107,8 @@ export class AssetProfilesService { dataSource, symbol } - }) + }), + this.assetProfileSplitService.getSplits({ dataSource, symbol }) ]); if (assetProfile) { @@ -80,6 +119,7 @@ export class AssetProfilesService { return { marketData, + splits, assetProfile: assetProfile ?? { activitiesCount, currency, @@ -92,6 +132,7 @@ export class AssetProfilesService { } }; } + public async getAssetProfiles({ filters = [], presetId, diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index b706e4702..5d60c571d 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -549,9 +549,11 @@ export class UserService { currentPermissions, permissions.accessHoldingsChart, permissions.createAccess, + permissions.createAssetProfileSplitOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, permissions.createWatchlistItem, + permissions.deleteAssetProfileSplitOfOwnAssetProfile, permissions.readAiPrompt, permissions.readMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile 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..ec842caaf --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts @@ -0,0 +1,87 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { resetHours } from '@ghostfolio/common/helper'; +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) {} + + /** + * Deletes the split with the given id of an asset profile and returns + * whether it existed + */ + public async deleteById({ + id, + symbolProfileId + }: { + id: string; + symbolProfileId: string; + }) { + const { count } = await this.prismaService.assetProfileSplit.deleteMany({ + where: { + id, + symbolProfileId + } + }); + + return count > 0; + } + + /** + * Returns the splits of the given asset profile in ascending order by date + */ + public async getSplits({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise { + return this.prismaService.assetProfileSplit.findMany({ + orderBy: [ + { + date: 'asc' + } + ], + where: { + symbolProfile: { + dataSource, + symbol + } + } + }); + } + + public async upsert({ + date, + denominator, + numerator, + symbolProfileId + }: { + date: Date; + denominator: number; + numerator: number; + symbolProfileId: string; + }): Promise { + const dateOfSplit = resetHours(date); + + return this.prismaService.assetProfileSplit.upsert({ + create: { + denominator, + numerator, + symbolProfileId, + date: dateOfSplit + }, + update: { + denominator, + numerator + }, + where: { + symbolProfileId_date: { + symbolProfileId, + date: dateOfSplit + } + } + }); + } +} 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 330688da1..f0e8eaf17 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 @@ -11,9 +11,11 @@ import { DATE_FORMAT, getCountryName, getCurrencyFromSymbol, + getDateFormatString, getStringOrNull, getStringOrUndefined, - isCurrency + isCurrency, + isSplitRatio } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, @@ -37,6 +39,7 @@ import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplet import { GfValueComponent } from '@ghostfolio/ui/value'; import { TextFieldModule } from '@angular/cdk/text-field'; +import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -63,6 +66,7 @@ import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -76,6 +80,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { IonIcon } from '@ionic/angular/standalone'; import { AssetClass, + AssetProfileSplit, AssetSubClass, DataGatheringFrequency, DataSource, @@ -88,11 +93,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'; @@ -105,6 +113,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'd-flex flex-column h-100' }, imports: [ + CommonModule, FormsModule, GfCurrencySelectorComponent, GfEntityLogoComponent, @@ -116,6 +125,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; IonIcon, MatButtonModule, MatCheckboxModule, + MatDatepickerModule, MatDialogModule, MatInputModule, MatMenuModule, @@ -196,6 +206,26 @@ export class GfAssetProfileDialogComponent implements OnInit { } ); + protected readonly assetProfileSplitForm = this.formBuilder.group( + { + date: new FormControl(null, Validators.required), + denominator: new FormControl(null, Validators.required), + numerator: new FormControl(null, Validators.required) + }, + { + validators: (control: AbstractControl): ValidationErrors | null => { + const { denominator, numerator } = control.value as { + denominator: number; + numerator: number; + }; + + return isSplitRatio({ denominator, numerator }) + ? null + : { invalidSplitRatio: true }; + } + } + ); + protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected canEditAssetProfile = true; @@ -247,6 +277,7 @@ export class GfAssetProfileDialogComponent implements OnInit { value: 'max' } ]; + protected defaultDateFormat: string; protected readonly getCountryName = getCountryName; protected historicalDataItems: LineChartItem[]; protected isBenchmark = false; @@ -270,6 +301,8 @@ export class GfAssetProfileDialogComponent implements OnInit { [name: string]: { name: string; value: number }; }; + protected splits: AssetProfileSplit[] = []; + protected readonly translate = translate; protected user: User; @@ -293,11 +326,14 @@ export class GfAssetProfileDialogComponent implements OnInit { private userService: UserService ) { addIcons({ + calendarClearOutline, codeSlashOutline, createOutline, ellipsisVertical, + gitCompareOutline, readerOutline, - serverOutline + serverOutline, + trashOutline }); } @@ -310,6 +346,7 @@ export class GfAssetProfileDialogComponent implements OnInit { this.benchmarks = benchmarks; this.currencies = currencies; + this.defaultDateFormat = getDateFormatString(this.data.locale); this.initialize(); } @@ -364,8 +401,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( @@ -521,6 +559,45 @@ export class GfAssetProfileDialogComponent implements OnInit { .subscribe(); } + protected onAddSplit() { + const { date, denominator, numerator } = + this.assetProfileSplitForm.getRawValue(); + + if (!date || !denominator || !numerator) { + return; + } + + this.adminService + .postAssetProfileSplit({ + dataSource: this.data.dataSource, + split: { + denominator, + numerator, + 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({ + dataSource: this.data.dataSource, + id: aId, + 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 f0b914b1c..f5df34ab4 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 @@ -503,6 +503,133 @@ + + @if (false && user?.settings?.isExperimentalFeatures) { + + + +
Splits
+
+
+
+
+

+ Splits are stored for this asset profile, but are not applied + to the portfolio calculation yet. +

+ @if (splits.length > 0) { + + + + + + + + + + @for (split of splits; track split.id) { + + + + + + } + +
Date + Split Ratio +
+ {{ split.date | date: defaultDateFormat }} + + {{ split.numerator }}:{{ split.denominator }} + + +
+ } +
+ + Date + + + + + + + + Shares After + + Numerator, + e.g. 4 for a 4:1 split or 1 for a 1:10 reverse + split + + + Shares Before + + Denominator, + e.g. 1 for a 4:1 split or 10 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..a68f00883 --- /dev/null +++ b/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts @@ -0,0 +1,39 @@ +import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; +import { IsSplitRatioConstraint } from '@ghostfolio/common/validator-constraints/is-split-ratio'; + +import { IsInt, IsISO8601, Validate } from 'class-validator'; + +export class CreateAssetProfileSplitDto { + /** + * The date the split becomes effective. Activities before this date are + * adjusted by the ratio. + */ + @IsISO8601() + @Validate(IsAfter1970Constraint) + date: string; + + /** + * The number of shares held before the split, for example 1 for a 4:1 split + * or 10 for a 1:10 reverse split. + */ + @IsInt() + denominator: number; + + /** + * The number of shares held after the split, for example 4 for a 4:1 split + * or 1 for a 1:10 reverse split. + * + * The resulting split factor is numerator / denominator. Both parts are kept + * so that the ratio stays exact, for example 1/3 for a 1:3 reverse split. + * + * Activities are adjusted by this ratio: the quantity is multiplied by it and + * the unit price divided by it, so that the total value of an activity stays + * the same. + * + * Market data is already split-adjusted by the data providers and must not be + * adjusted again. + */ + @IsInt() + @Validate(IsSplitRatioConstraint) + numerator: number; +} diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index 785115356..7cf385cd7 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/libs/common/src/lib/dtos/index.ts @@ -4,6 +4,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'; @@ -36,6 +37,7 @@ export { CreateAccountDto, CreateAccountWithBalancesDto, CreateAssetProfileDto, + CreateAssetProfileSplitDto, CreateAssetProfileWithMarketDataDto, CreateOrderDto, CreatePlatformDto, diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index c4947394f..42441a619 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -9,7 +9,8 @@ import { getStringOrUndefined, isAccountExcluded, isCurrency, - isCurrencySymbol + isCurrencySymbol, + isSplitRatio } from '@ghostfolio/common/helper'; describe('Helper', () => { @@ -280,4 +281,49 @@ describe('Helper', () => { expect(isCurrencySymbol('')).toEqual(false); }); }); + + describe('Is split ratio', () => { + it('Forward split', () => { + expect(isSplitRatio({ denominator: 1, numerator: 2 })).toEqual(true); + expect(isSplitRatio({ denominator: 1, numerator: 4 })).toEqual(true); + expect(isSplitRatio({ denominator: 2, numerator: 3 })).toEqual(true); + }); + + it('Reverse split', () => { + expect(isSplitRatio({ denominator: 10, numerator: 1 })).toEqual(true); + expect(isSplitRatio({ denominator: 3, numerator: 1 })).toEqual(true); + }); + + it('Ratio without effect', () => { + expect(isSplitRatio({ denominator: 1, numerator: 1 })).toEqual(false); + expect(isSplitRatio({ denominator: 3, numerator: 3 })).toEqual(false); + }); + + it('Zero or negative ratio', () => { + expect(isSplitRatio({ denominator: 1, numerator: 0 })).toEqual(false); + expect(isSplitRatio({ denominator: 0, numerator: 1 })).toEqual(false); + expect(isSplitRatio({ denominator: 1, numerator: -2 })).toEqual(false); + expect(isSplitRatio({ denominator: -2, numerator: 1 })).toEqual(false); + }); + + it('Non-integer ratio', () => { + expect(isSplitRatio({ denominator: 1, numerator: 1.5 })).toEqual(false); + expect(isSplitRatio({ denominator: 2.5, numerator: 1 })).toEqual(false); + expect(isSplitRatio({ denominator: 1, numerator: Number.NaN })).toEqual( + false + ); + expect( + isSplitRatio({ denominator: 1, numerator: Number.POSITIVE_INFINITY }) + ).toEqual(false); + }); + + it('Missing ratio', () => { + expect( + isSplitRatio({ denominator: undefined, numerator: undefined }) + ).toEqual(false); + expect(isSplitRatio({ denominator: null, numerator: null })).toEqual( + false + ); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 58927337e..22f969d02 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -526,6 +526,28 @@ export function isRootCurrency(aCurrency: string) { }); } +/** + * Validates the ratio of a stock split, expressed as the number of shares held + * after the split (numerator) per number of shares held before (denominator), + * for example 4 and 1 for a 4:1 split or 1 and 10 for a 1:10 reverse split. An + * equal numerator and denominator would be a no-op. + */ +export function isSplitRatio({ + denominator, + numerator +}: { + denominator: number; + numerator: number; +}) { + return ( + Number.isSafeInteger(numerator) && + Number.isSafeInteger(denominator) && + numerator > 0 && + denominator > 0 && + numerator !== denominator + ); +} + export function isValidSearchQuery(aQuery: string) { return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; } 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 dc44d0382..8cfe70f9c 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 { EnhancedAssetProfile } from './enhanced-asset-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 d76188c73..26082c2c4 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 { EnhancedAssetProfile } from '../enhanced-asset-profile.interface'; export interface AssetProfileResponse { assetProfile: Partial; marketData: MarketData[]; + splits: AssetProfileSplit[]; } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 428de1788..811ded68c 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -12,6 +12,9 @@ export const permissions = { createAccountBalance: 'createAccountBalance', createActivity: 'createActivity', createApiKey: 'createApiKey', + createAssetProfileSplit: 'createAssetProfileSplit', + createAssetProfileSplitOfOwnAssetProfile: + 'createAssetProfileSplitOfOwnAssetProfile', createMarketData: 'createMarketData', createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', createOwnTag: 'createOwnTag', @@ -23,6 +26,9 @@ export const permissions = { deleteAccount: 'deleteAccount', deleteAccountBalance: 'deleteAccountBalance', deleteActivity: 'deleteActivity', + deleteAssetProfileSplit: 'deleteAssetProfileSplit', + deleteAssetProfileSplitOfOwnAssetProfile: + 'deleteAssetProfileSplitOfOwnAssetProfile', deleteAuthDevice: 'deleteAuthDevice', deleteOwnUser: 'deleteOwnUser', deletePlatform: 'deletePlatform', @@ -80,6 +86,8 @@ export function getPermissions(aRole: Role): string[] { permissions.createWatchlistItem, permissions.deleteAccountBalance, permissions.deleteWatchlistItem, + permissions.createAssetProfileSplit, + permissions.createAssetProfileSplitOfOwnAssetProfile, permissions.createMarketData, permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, @@ -88,6 +96,8 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccess, permissions.deleteAccount, permissions.deleteActivity, + permissions.deleteAssetProfileSplit, + permissions.deleteAssetProfileSplitOfOwnAssetProfile, permissions.deleteAuthDevice, permissions.deletePlatform, permissions.deleteTag, @@ -128,6 +138,7 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createAccountBalance, permissions.createActivity, + permissions.createAssetProfileSplitOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, permissions.createWatchlistItem, @@ -135,6 +146,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAccountBalance, permissions.deleteActivity, + permissions.deleteAssetProfileSplitOfOwnAssetProfile, permissions.deleteAuthDevice, permissions.deleteWatchlistItem, permissions.readAiPrompt, diff --git a/libs/common/src/lib/validator-constraints/is-split-ratio.ts b/libs/common/src/lib/validator-constraints/is-split-ratio.ts new file mode 100644 index 000000000..d3cd454e8 --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-split-ratio.ts @@ -0,0 +1,18 @@ +import { isSplitRatio } from '@ghostfolio/common/helper'; + +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isSplitRatio' }) +export class IsSplitRatioConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return 'numerator and denominator must be different positive integers'; + } + + public validate(_: unknown, { object }: ValidationArguments) { + return isSplitRatio(object as { denominator: number; numerator: number }); + } +} diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts index 6a06ef76b..8510f1729 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 bc1512550..7ccd9d1d9 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -589,6 +589,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/20260802000000_added_asset_profile_split/migration.sql b/prisma/migrations/20260802000000_added_asset_profile_split/migration.sql new file mode 100644 index 000000000..9003916b4 --- /dev/null +++ b/prisma/migrations/20260802000000_added_asset_profile_split/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "AssetProfileSplit" ( + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "date" TIMESTAMP(3) NOT NULL, + "denominator" INTEGER NOT NULL, + "id" TEXT NOT NULL, + "numerator" INTEGER NOT NULL, + "symbolProfileId" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AssetProfileSplit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AssetProfileSplit_date_idx" ON "AssetProfileSplit"("date"); + +-- CreateIndex +CREATE UNIQUE INDEX "AssetProfileSplit_symbolProfileId_date_key" ON "AssetProfileSplit"("symbolProfileId", "date"); + +-- AddForeignKey +ALTER TABLE "AssetProfileSplit" ADD CONSTRAINT "AssetProfileSplit_symbolProfileId_fkey" FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index faf7a9259..77faccd49 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,33 @@ model AssetProfileResolution { @@unique([dataSourceOrigin, symbolOrigin]) } +/// A stock split of an asset profile. The numerator is the number of shares +/// held after the split, the denominator the number of shares held before, for +/// example 4 and 1 for a 4:1 split or 1 and 10 for a 1:10 reverse split. +/// +/// The ratio is stored as two integers instead of the resulting factor, so that +/// it stays exact, for example 1/3 for a 1:3 reverse split. +/// +/// Activities before the date are adjusted by the ratio: the quantity is +/// multiplied by it and the unit price divided by it, so that the total value +/// of an activity stays the same. +/// +/// Market data is already split-adjusted by the data providers and must not be +/// adjusted again. +model AssetProfileSplit { + createdAt DateTime @default(now()) + date DateTime + denominator Int + id String @id @default(uuid()) + numerator Int + symbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id]) + symbolProfileId String + updatedAt DateTime @updatedAt + + @@unique([symbolProfileId, date]) + @@index([date]) +} + model AuthDevice { createdAt DateTime @default(now()) credentialId Bytes @@ -200,6 +227,7 @@ model SymbolProfile { activities Order[] assetClass AssetClass? assetProfileOverrides AssetProfileOverrides? + assetProfileSplits AssetProfileSplit[] assetSubClass AssetSubClass? comment String? countries Json?