Browse Source

Refactoring

pull/7251/head
Thomas Kaul 4 weeks ago
parent
commit
34361e2f2f
  1. 3
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  2. 13
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  3. 43
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  4. 34
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  5. 27
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  6. 28
      libs/common/src/lib/dtos/create-asset-profile-split.dto.ts
  7. 48
      libs/common/src/lib/helper.spec.ts
  8. 23
      libs/common/src/lib/helper.ts
  9. 17
      libs/common/src/lib/validator-constraints/is-split-factor.ts
  10. 18
      libs/common/src/lib/validator-constraints/is-split-ratio.ts
  11. 3
      prisma/migrations/20260802000000_added_asset_profile_split/migration.sql
  12. 14
      prisma/schema.prisma

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

@ -144,7 +144,8 @@ export class AssetProfilesController {
return this.assetProfilesService.createSplit({
symbolProfileId,
date: parseISO(data.date),
factor: data.factor
denominator: data.denominator,
numerator: data.numerator
});
}

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

@ -42,16 +42,19 @@ export class AssetProfilesService {
public async createSplit({
date,
factor,
denominator,
numerator,
symbolProfileId
}: {
date: Date;
factor: number;
denominator: number;
numerator: number;
symbolProfileId: string;
}) {
return this.assetProfileSplitService.upsert({
date,
factor,
denominator,
numerator,
symbolProfileId
});
}
@ -105,9 +108,7 @@ export class AssetProfilesService {
symbol
}
}),
this.assetProfileSplitService.getSplits({
assetProfileIdentifiers: [{ dataSource, symbol }]
})
this.assetProfileSplitService.getSplits({ dataSource, symbol })
]);
if (assetProfile) {

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

@ -5,10 +5,6 @@ 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) {}
@ -35,28 +31,13 @@ export class AssetProfileSplitService {
}
/**
* 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.
* Returns the splits of the given asset profile in ascending order by date
*/
public async getSplits({
assetProfileIdentifiers
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
}): Promise<AssetProfileSplitWithAssetProfileIdentifier[]> {
if (assetProfileIdentifiers.length === 0) {
return [];
}
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
include: {
symbolProfile: {
select: {
dataSource: true,
symbol: true
}
}
},
orderBy: [
{
date: 'asc'
@ -64,12 +45,8 @@ export class AssetProfileSplitService {
],
where: {
symbolProfile: {
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
}
}
});
@ -77,23 +54,27 @@ export class AssetProfileSplitService {
public async upsert({
date,
factor,
denominator,
numerator,
symbolProfileId
}: {
date: Date;
factor: number;
denominator: number;
numerator: number;
symbolProfileId: string;
}): Promise<AssetProfileSplit> {
const dateOfSplit = resetHours(date);
return this.prismaService.assetProfileSplit.upsert({
create: {
factor,
denominator,
numerator,
symbolProfileId,
date: dateOfSplit
},
update: {
factor
denominator,
numerator
},
where: {
symbolProfileId_date: {

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

@ -15,7 +15,7 @@ import {
getStringOrNull,
getStringOrUndefined,
isCurrency,
isSplitFactor
isSplitRatio
} from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
@ -204,17 +204,25 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
);
protected readonly assetProfileSplitForm = this.formBuilder.group({
protected readonly assetProfileSplitForm = this.formBuilder.group(
{
date: new FormControl<Date | null>(null, Validators.required),
factor: new FormControl<number | null>(null, [
Validators.required,
(control: AbstractControl): ValidationErrors | null => {
return isSplitFactor(control.value)
denominator: new FormControl<number | null>(null, Validators.required),
numerator: new FormControl<number | null>(null, Validators.required)
},
{
validators: (control: AbstractControl): ValidationErrors | null => {
const { denominator, numerator } = control.value as {
denominator: number;
numerator: number;
};
return isSplitRatio({ denominator, numerator })
? null
: { invalidSplitFactor: true };
: { invalidSplitRatio: true };
}
])
});
}
);
protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true;
@ -550,9 +558,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
protected onAddSplit() {
const { date, factor } = this.assetProfileSplitForm.getRawValue();
const { date, denominator, numerator } =
this.assetProfileSplitForm.getRawValue();
if (!date || !factor) {
if (!date || !denominator || !numerator) {
return;
}
@ -560,7 +569,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
.postAssetProfileSplit({
dataSource: this.data.dataSource,
split: {
factor,
denominator,
numerator,
date: format(date, DATE_FORMAT)
},
symbol: this.data.symbol

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

@ -527,7 +527,7 @@
class="mat-mdc-header-cell px-1 py-2 text-right"
i18n
>
Split Factor
Split Ratio
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
@ -539,7 +539,7 @@
{{ split.date | date: defaultDateFormat }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.factor }}
{{ split.numerator }}:{{ split.denominator }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<button
@ -585,16 +585,29 @@
<mat-datepicker #dateOfSplit />
</mat-form-field>
<mat-form-field appearance="outline" class="mr-3">
<mat-label i18n>Split Factor</mat-label>
<mat-label i18n>Shares After</mat-label>
<input
formControlName="factor"
formControlName="numerator"
matInput
step="any"
step="1"
type="number"
/>
<mat-hint i18n
>Shares after per 1 share before, e.g. 4 for a 4:1 split
or 0.1 for a 1:10 reverse split</mat-hint
>Numerator, e.g. 4 for a 4:1 split or 1 for a 1:10 reverse
split</mat-hint
>
</mat-form-field>
<mat-form-field appearance="outline" class="mr-3">
<mat-label i18n>Shares Before</mat-label>
<input
formControlName="denominator"
matInput
step="1"
type="number"
/>
<mat-hint i18n
>Denominator, e.g. 1 for a 4:1 split or 10 for a 1:10
reverse split</mat-hint
>
</mat-form-field>
<button

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

@ -1,26 +1,36 @@
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { IsSplitFactorConstraint } from '@ghostfolio/common/validator-constraints/is-split-factor';
import { IsSplitRatioConstraint } from '@ghostfolio/common/validator-constraints/is-split-ratio';
import { IsISO8601, IsNumber, Validate } from 'class-validator';
import { IsInt, IsISO8601, Validate } from 'class-validator';
export class CreateAssetProfileSplitDto {
/**
* The date the split becomes effective. Activities before this date are
* adjusted by the factor.
* adjusted by the ratio.
*/
@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.
* 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.
*
* Only the quantity of activities is adjusted by this factor. Market data is
* Only the quantity of activities is adjusted by this ratio. Market data is
* already split-adjusted by the data providers and must not be adjusted
* again.
*/
@IsNumber()
@Validate(IsSplitFactorConstraint)
factor: number;
@IsInt()
@Validate(IsSplitRatioConstraint)
numerator: number;
}

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

@ -10,7 +10,7 @@ import {
isAccountExcluded,
isCurrency,
isCurrencySymbol,
isSplitFactor
isSplitRatio
} from '@ghostfolio/common/helper';
describe('Helper', () => {
@ -282,34 +282,48 @@ describe('Helper', () => {
});
});
describe('Is split factor', () => {
describe('Is split ratio', () => {
it('Forward split', () => {
expect(isSplitFactor(2)).toEqual(true);
expect(isSplitFactor(4)).toEqual(true);
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(isSplitFactor(0.1)).toEqual(true);
expect(isSplitFactor(0.5)).toEqual(true);
expect(isSplitRatio({ denominator: 10, numerator: 1 })).toEqual(true);
expect(isSplitRatio({ denominator: 3, numerator: 1 })).toEqual(true);
});
it('Factor without effect', () => {
expect(isSplitFactor(1)).toEqual(false);
it('Ratio without effect', () => {
expect(isSplitRatio({ denominator: 1, numerator: 1 })).toEqual(false);
expect(isSplitRatio({ denominator: 3, numerator: 3 })).toEqual(false);
});
it('Zero or negative factor', () => {
expect(isSplitFactor(0)).toEqual(false);
expect(isSplitFactor(-2)).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-finite factor', () => {
expect(isSplitFactor(Number.NaN)).toEqual(false);
expect(isSplitFactor(Number.POSITIVE_INFINITY)).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 factor', () => {
expect(isSplitFactor(undefined)).toEqual(false);
expect(isSplitFactor(null)).toEqual(false);
it('Missing ratio', () => {
expect(
isSplitRatio({ denominator: undefined, numerator: undefined })
).toEqual(false);
expect(isSplitRatio({ denominator: null, numerator: null })).toEqual(
false
);
});
});
});

23
libs/common/src/lib/helper.ts

@ -527,12 +527,25 @@ 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.
* 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 isSplitFactor(aFactor: number) {
return Number.isFinite(aFactor) && aFactor > 0 && aFactor !== 1;
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) {

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

@ -1,17 +0,0 @@
import { isSplitFactor } from '@ghostfolio/common/helper';
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 isSplitFactor(aFactor);
}
}

18
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 });
}
}

3
prisma/migrations/20260802000000_added_asset_profile_split/migration.sql

@ -2,8 +2,9 @@
CREATE TABLE "AssetProfileSplit" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date" TIMESTAMP(3) NOT NULL,
"factor" DOUBLE PRECISION NOT NULL,
"denominator" INTEGER NOT NULL,
"id" TEXT NOT NULL,
"numerator" INTEGER NOT NULL,
"symbolProfileId" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,

14
prisma/schema.prisma

@ -116,18 +116,22 @@ 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.
/// 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.
///
/// Only the quantity of activities before the date is adjusted by the factor.
/// 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.
///
/// Only the quantity of activities before the date is adjusted by the ratio.
/// Market data is already split-adjusted by the data providers and must not be
/// adjusted again.
model AssetProfileSplit {
createdAt DateTime @default(now())
date DateTime
factor Float
denominator Int
id String @id @default(uuid())
numerator Int
symbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id])
symbolProfileId String
updatedAt DateTime @updatedAt

Loading…
Cancel
Save