From 24c9ef39a688173258017961e42d857c48cdc165 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:37:39 +0200 Subject: [PATCH] Refactoring --- .../asset-profiles.controller.ts | 2 ++ .../asset-profiles/asset-profiles.service.ts | 8 +++-- .../asset-profile-split.service.ts | 19 ++++++++++- .../asset-profile-dialog.component.ts | 5 +-- .../dtos/create-asset-profile-split.dto.ts | 18 ++++++++-- libs/common/src/lib/helper.spec.ts | 34 ++++++++++++++++++- libs/common/src/lib/helper.ts | 9 +++++ .../validator-constraints/is-split-factor.ts | 4 ++- .../migration.sql | 3 -- prisma/schema.prisma | 8 ++++- 10 files changed, 96 insertions(+), 14 deletions(-) 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 7e8ae19812..62c1b15bac 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 @@ -127,6 +127,7 @@ export class AssetProfilesController { @Post(':dataSource/:symbol/splits') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async createSplit( @Body() data: CreateAssetProfileSplitDto, @Param('dataSource') dataSource: DataSource, @@ -149,6 +150,7 @@ export class AssetProfilesController { @Delete(':dataSource/:symbol/splits/:id') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async deleteSplit( @Param('dataSource') dataSource: DataSource, @Param('id') id: string, 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 9772e6cfe3..a3bc59f755 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 @@ -25,7 +25,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types'; import { Injectable, NotFoundException } from '@nestjs/common'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; -import { groupBy } from 'lodash'; +import { groupBy, omit } from 'lodash'; @Injectable() export class AssetProfilesService { @@ -118,7 +118,6 @@ export class AssetProfilesService { return { marketData, - splits, assetProfile: assetProfile ?? { activitiesCount, currency, @@ -128,7 +127,10 @@ export class AssetProfilesService { assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined, assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined, isActive: true - } + }, + splits: splits.map((split) => { + return omit(split, 'symbolProfile'); + }) }; } public async getAssetProfiles({ 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 index d505f542c1..46023fccde 100644 --- 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 @@ -5,6 +5,10 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { AssetProfileSplit } from '@prisma/client'; +export type AssetProfileSplitWithAssetProfileIdentifier = AssetProfileSplit & { + symbolProfile: AssetProfileIdentifier; +}; + @Injectable() export class AssetProfileSplitService { public constructor(private readonly prismaService: PrismaService) {} @@ -30,16 +34,29 @@ export class AssetProfileSplitService { return count > 0; } + /** + * Returns the splits of the given asset profiles in ascending order by date. + * Each split carries the identifier of its asset profile so that the result + * can be grouped when querying multiple asset profiles at once. + */ public async getSplits({ assetProfileIdentifiers }: { assetProfileIdentifiers: AssetProfileIdentifier[]; - }): Promise { + }): Promise { if (assetProfileIdentifiers.length === 0) { return []; } return this.prismaService.assetProfileSplit.findMany({ + include: { + symbolProfile: { + select: { + dataSource: true, + symbol: true + } + } + }, orderBy: [ { date: 'asc' 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 21e12be0e7..33ed6f15fb 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 @@ -14,7 +14,8 @@ import { getDateFormatString, getStringOrNull, getStringOrUndefined, - isCurrency + isCurrency, + isSplitFactor } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, @@ -208,7 +209,7 @@ export class GfAssetProfileDialogComponent implements OnInit { factor: new FormControl(null, [ Validators.required, (control: AbstractControl): ValidationErrors | null => { - return control.value > 0 && control.value !== 1 + return isSplitFactor(control.value) ? null : { invalidSplitFactor: true }; } 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 index 0f4f1367b0..27bd1364f2 100644 --- a/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts @@ -1,11 +1,25 @@ -import { IsISO8601, IsNumber, Validate } from 'class-validator'; +import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; +import { IsSplitFactorConstraint } from '@ghostfolio/common/validator-constraints/is-split-factor'; -import { IsSplitFactorConstraint } from '../validator-constraints/is-split-factor'; +import { IsISO8601, IsNumber, Validate } from 'class-validator'; export class CreateAssetProfileSplitDto { + /** + * The date the split becomes effective. Activities before this date are + * adjusted by the factor. + */ @IsISO8601() + @Validate(IsAfter1970Constraint) date: string; + /** + * The number of shares held after the split per 1 share held before, for + * example 4 for a 4:1 split or 0.1 for a 1:10 reverse split. + * + * Only the quantity of activities is adjusted by this factor. Market data is + * already split-adjusted by the data providers and must not be adjusted + * again. + */ @IsNumber() @Validate(IsSplitFactorConstraint) factor: number; diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index c4947394f6..d123e90f3a 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, + isSplitFactor } from '@ghostfolio/common/helper'; describe('Helper', () => { @@ -280,4 +281,35 @@ describe('Helper', () => { expect(isCurrencySymbol('')).toEqual(false); }); }); + + describe('Is split factor', () => { + it('Forward split', () => { + expect(isSplitFactor(2)).toEqual(true); + expect(isSplitFactor(4)).toEqual(true); + }); + + it('Reverse split', () => { + expect(isSplitFactor(0.1)).toEqual(true); + expect(isSplitFactor(0.5)).toEqual(true); + }); + + it('Factor without effect', () => { + expect(isSplitFactor(1)).toEqual(false); + }); + + it('Zero or negative factor', () => { + expect(isSplitFactor(0)).toEqual(false); + expect(isSplitFactor(-2)).toEqual(false); + }); + + it('Non-finite factor', () => { + expect(isSplitFactor(Number.NaN)).toEqual(false); + expect(isSplitFactor(Number.POSITIVE_INFINITY)).toEqual(false); + }); + + it('Missing factor', () => { + expect(isSplitFactor(undefined)).toEqual(false); + expect(isSplitFactor(null)).toEqual(false); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 58927337ee..1b41356cb8 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -526,6 +526,15 @@ export function isRootCurrency(aCurrency: string) { }); } +/** + * Validates the factor of a stock split, expressed as the number of shares + * held after the split per 1 share held before, for example 4 for a 4:1 split + * or 0.1 for a 1:10 reverse split. A factor of 1 would be a no-op. + */ +export function isSplitFactor(aFactor: number) { + return Number.isFinite(aFactor) && aFactor > 0 && aFactor !== 1; +} + export function isValidSearchQuery(aQuery: string) { return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; } diff --git a/libs/common/src/lib/validator-constraints/is-split-factor.ts b/libs/common/src/lib/validator-constraints/is-split-factor.ts index 40e13eb55e..fd37acd238 100644 --- a/libs/common/src/lib/validator-constraints/is-split-factor.ts +++ b/libs/common/src/lib/validator-constraints/is-split-factor.ts @@ -1,3 +1,5 @@ +import { isSplitFactor } from '@ghostfolio/common/helper'; + import { ValidatorConstraint, ValidatorConstraintInterface @@ -10,6 +12,6 @@ export class IsSplitFactorConstraint implements ValidatorConstraintInterface { } public validate(aFactor: number) { - return typeof aFactor === 'number' && aFactor > 0 && aFactor !== 1; + return isSplitFactor(aFactor); } } diff --git a/prisma/migrations/20260802000000_added_asset_profile_split/migration.sql b/prisma/migrations/20260802000000_added_asset_profile_split/migration.sql index af400a846e..dd98186571 100644 --- a/prisma/migrations/20260802000000_added_asset_profile_split/migration.sql +++ b/prisma/migrations/20260802000000_added_asset_profile_split/migration.sql @@ -13,9 +13,6 @@ CREATE TABLE "AssetProfileSplit" ( -- CreateIndex CREATE INDEX "AssetProfileSplit_date_idx" ON "AssetProfileSplit"("date"); --- CreateIndex -CREATE INDEX "AssetProfileSplit_symbolProfileId_idx" ON "AssetProfileSplit"("symbolProfileId"); - -- CreateIndex CREATE UNIQUE INDEX "AssetProfileSplit_symbolProfileId_date_key" ON "AssetProfileSplit"("symbolProfileId", "date"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eb6bc7feab..359f4332ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,13 @@ model AssetProfileResolution { @@unique([dataSourceOrigin, symbolOrigin]) } +/// A stock split of an asset profile. The factor is the number of shares held +/// after the split per 1 share held before, for example 4 for a 4:1 split or +/// 0.1 for a 1:10 reverse split. +/// +/// Only the quantity of activities before the date is adjusted by the factor. +/// Market data is already split-adjusted by the data providers and must not be +/// adjusted again. model AssetProfileSplit { createdAt DateTime @default(now()) date DateTime @@ -127,7 +134,6 @@ model AssetProfileSplit { @@unique([symbolProfileId, date]) @@index([date]) - @@index([symbolProfileId]) } model AuthDevice {