Browse Source

Refactoring

pull/7251/head
Thomas Kaul 4 weeks ago
parent
commit
24c9ef39a6
  1. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  2. 8
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  3. 19
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  4. 5
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  5. 18
      libs/common/src/lib/dtos/create-asset-profile-split.dto.ts
  6. 34
      libs/common/src/lib/helper.spec.ts
  7. 9
      libs/common/src/lib/helper.ts
  8. 4
      libs/common/src/lib/validator-constraints/is-split-factor.ts
  9. 3
      prisma/migrations/20260802000000_added_asset_profile_split/migration.sql
  10. 8
      prisma/schema.prisma

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

@ -127,6 +127,7 @@ export class AssetProfilesController {
@Post(':dataSource/:symbol/splits') @Post(':dataSource/:symbol/splits')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createSplit( public async createSplit(
@Body() data: CreateAssetProfileSplitDto, @Body() data: CreateAssetProfileSplitDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@ -149,6 +150,7 @@ export class AssetProfilesController {
@Delete(':dataSource/:symbol/splits/:id') @Delete(':dataSource/:symbol/splits/:id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteSplit( public async deleteSplit(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('id') id: string, @Param('id') id: string,

8
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 { Injectable, NotFoundException } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash'; import { groupBy, omit } from 'lodash';
@Injectable() @Injectable()
export class AssetProfilesService { export class AssetProfilesService {
@ -118,7 +118,6 @@ export class AssetProfilesService {
return { return {
marketData, marketData,
splits,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
activitiesCount, activitiesCount,
currency, currency,
@ -128,7 +127,10 @@ export class AssetProfilesService {
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined, assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined, assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true isActive: true
} },
splits: splits.map((split) => {
return omit(split, 'symbolProfile');
})
}; };
} }
public async getAssetProfiles({ public async getAssetProfiles({

19
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 { Injectable } from '@nestjs/common';
import { AssetProfileSplit } from '@prisma/client'; import { AssetProfileSplit } from '@prisma/client';
export type AssetProfileSplitWithAssetProfileIdentifier = AssetProfileSplit & {
symbolProfile: AssetProfileIdentifier;
};
@Injectable() @Injectable()
export class AssetProfileSplitService { export class AssetProfileSplitService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
@ -30,16 +34,29 @@ export class AssetProfileSplitService {
return count > 0; 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({ public async getSplits({
assetProfileIdentifiers assetProfileIdentifiers
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[]; assetProfileIdentifiers: AssetProfileIdentifier[];
}): Promise<AssetProfileSplit[]> { }): Promise<AssetProfileSplitWithAssetProfileIdentifier[]> {
if (assetProfileIdentifiers.length === 0) { if (assetProfileIdentifiers.length === 0) {
return []; return [];
} }
return this.prismaService.assetProfileSplit.findMany({ return this.prismaService.assetProfileSplit.findMany({
include: {
symbolProfile: {
select: {
dataSource: true,
symbol: true
}
}
},
orderBy: [ orderBy: [
{ {
date: 'asc' date: 'asc'

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

@ -14,7 +14,8 @@ import {
getDateFormatString, getDateFormatString,
getStringOrNull, getStringOrNull,
getStringOrUndefined, getStringOrUndefined,
isCurrency isCurrency,
isSplitFactor
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
@ -208,7 +209,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
factor: new FormControl<number | null>(null, [ factor: new FormControl<number | null>(null, [
Validators.required, Validators.required,
(control: AbstractControl): ValidationErrors | null => { (control: AbstractControl): ValidationErrors | null => {
return control.value > 0 && control.value !== 1 return isSplitFactor(control.value)
? null ? null
: { invalidSplitFactor: true }; : { invalidSplitFactor: true };
} }

18
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 { export class CreateAssetProfileSplitDto {
/**
* The date the split becomes effective. Activities before this date are
* adjusted by the factor.
*/
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; 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() @IsNumber()
@Validate(IsSplitFactorConstraint) @Validate(IsSplitFactorConstraint)
factor: number; factor: number;

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

@ -9,7 +9,8 @@ import {
getStringOrUndefined, getStringOrUndefined,
isAccountExcluded, isAccountExcluded,
isCurrency, isCurrency,
isCurrencySymbol isCurrencySymbol,
isSplitFactor
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
describe('Helper', () => { describe('Helper', () => {
@ -280,4 +281,35 @@ describe('Helper', () => {
expect(isCurrencySymbol('')).toEqual(false); 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);
});
});
}); });

9
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) { export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
} }

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

@ -1,3 +1,5 @@
import { isSplitFactor } from '@ghostfolio/common/helper';
import { import {
ValidatorConstraint, ValidatorConstraint,
ValidatorConstraintInterface ValidatorConstraintInterface
@ -10,6 +12,6 @@ export class IsSplitFactorConstraint implements ValidatorConstraintInterface {
} }
public validate(aFactor: number) { public validate(aFactor: number) {
return typeof aFactor === 'number' && aFactor > 0 && aFactor !== 1; return isSplitFactor(aFactor);
} }
} }

3
prisma/migrations/20260802000000_added_asset_profile_split/migration.sql

@ -13,9 +13,6 @@ CREATE TABLE "AssetProfileSplit" (
-- CreateIndex -- CreateIndex
CREATE INDEX "AssetProfileSplit_date_idx" ON "AssetProfileSplit"("date"); CREATE INDEX "AssetProfileSplit_date_idx" ON "AssetProfileSplit"("date");
-- CreateIndex
CREATE INDEX "AssetProfileSplit_symbolProfileId_idx" ON "AssetProfileSplit"("symbolProfileId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "AssetProfileSplit_symbolProfileId_date_key" ON "AssetProfileSplit"("symbolProfileId", "date"); CREATE UNIQUE INDEX "AssetProfileSplit_symbolProfileId_date_key" ON "AssetProfileSplit"("symbolProfileId", "date");

8
prisma/schema.prisma

@ -116,6 +116,13 @@ model AssetProfileResolution {
@@unique([dataSourceOrigin, symbolOrigin]) @@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 { model AssetProfileSplit {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
date DateTime date DateTime
@ -127,7 +134,6 @@ model AssetProfileSplit {
@@unique([symbolProfileId, date]) @@unique([symbolProfileId, date])
@@index([date]) @@index([date])
@@index([symbolProfileId])
} }
model AuthDevice { model AuthDevice {

Loading…
Cancel
Save