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({ return this.assetProfilesService.createSplit({
symbolProfileId, symbolProfileId,
date: parseISO(data.date), 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({ public async createSplit({
date, date,
factor, denominator,
numerator,
symbolProfileId symbolProfileId
}: { }: {
date: Date; date: Date;
factor: number; denominator: number;
numerator: number;
symbolProfileId: string; symbolProfileId: string;
}) { }) {
return this.assetProfileSplitService.upsert({ return this.assetProfileSplitService.upsert({
date, date,
factor, denominator,
numerator,
symbolProfileId symbolProfileId
}); });
} }
@ -105,9 +108,7 @@ export class AssetProfilesService {
symbol symbol
} }
}), }),
this.assetProfileSplitService.getSplits({ this.assetProfileSplitService.getSplits({ dataSource, symbol })
assetProfileIdentifiers: [{ dataSource, symbol }]
})
]); ]);
if (assetProfile) { 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 { 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) {}
@ -35,28 +31,13 @@ export class AssetProfileSplitService {
} }
/** /**
* Returns the splits of the given asset profiles in ascending order by date. * Returns the splits of the given asset profile 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 dataSource,
}: { symbol
assetProfileIdentifiers: AssetProfileIdentifier[]; }: AssetProfileIdentifier): Promise<AssetProfileSplit[]> {
}): Promise<AssetProfileSplitWithAssetProfileIdentifier[]> {
if (assetProfileIdentifiers.length === 0) {
return [];
}
return this.prismaService.assetProfileSplit.findMany({ return this.prismaService.assetProfileSplit.findMany({
include: {
symbolProfile: {
select: {
dataSource: true,
symbol: true
}
}
},
orderBy: [ orderBy: [
{ {
date: 'asc' date: 'asc'
@ -64,12 +45,8 @@ export class AssetProfileSplitService {
], ],
where: { where: {
symbolProfile: { symbolProfile: {
OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource, dataSource,
symbol symbol
};
})
} }
} }
}); });
@ -77,23 +54,27 @@ export class AssetProfileSplitService {
public async upsert({ public async upsert({
date, date,
factor, denominator,
numerator,
symbolProfileId symbolProfileId
}: { }: {
date: Date; date: Date;
factor: number; denominator: number;
numerator: number;
symbolProfileId: string; symbolProfileId: string;
}): Promise<AssetProfileSplit> { }): Promise<AssetProfileSplit> {
const dateOfSplit = resetHours(date); const dateOfSplit = resetHours(date);
return this.prismaService.assetProfileSplit.upsert({ return this.prismaService.assetProfileSplit.upsert({
create: { create: {
factor, denominator,
numerator,
symbolProfileId, symbolProfileId,
date: dateOfSplit date: dateOfSplit
}, },
update: { update: {
factor denominator,
numerator
}, },
where: { where: {
symbolProfileId_date: { 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, getStringOrNull,
getStringOrUndefined, getStringOrUndefined,
isCurrency, isCurrency,
isSplitFactor isSplitRatio
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, 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), date: new FormControl<Date | null>(null, Validators.required),
factor: new FormControl<number | null>(null, [ denominator: new FormControl<number | null>(null, Validators.required),
Validators.required, numerator: new FormControl<number | null>(null, Validators.required)
(control: AbstractControl): ValidationErrors | null => { },
return isSplitFactor(control.value) {
validators: (control: AbstractControl): ValidationErrors | null => {
const { denominator, numerator } = control.value as {
denominator: number;
numerator: number;
};
return isSplitRatio({ denominator, numerator })
? null ? null
: { invalidSplitFactor: true }; : { invalidSplitRatio: true };
} }
]) }
}); );
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true; protected canEditAssetProfile = true;
@ -550,9 +558,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
protected onAddSplit() { protected onAddSplit() {
const { date, factor } = this.assetProfileSplitForm.getRawValue(); const { date, denominator, numerator } =
this.assetProfileSplitForm.getRawValue();
if (!date || !factor) { if (!date || !denominator || !numerator) {
return; return;
} }
@ -560,7 +569,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
.postAssetProfileSplit({ .postAssetProfileSplit({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
split: { split: {
factor, denominator,
numerator,
date: format(date, DATE_FORMAT) date: format(date, DATE_FORMAT)
}, },
symbol: this.data.symbol 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" class="mat-mdc-header-cell px-1 py-2 text-right"
i18n i18n
> >
Split Factor Split Ratio
</th> </th>
<th class="mat-mdc-header-cell px-1 py-2"></th> <th class="mat-mdc-header-cell px-1 py-2"></th>
</tr> </tr>
@ -539,7 +539,7 @@
{{ split.date | date: defaultDateFormat }} {{ split.date | date: defaultDateFormat }}
</td> </td>
<td class="mat-mdc-cell px-1 py-2 text-right"> <td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.factor }} {{ split.numerator }}:{{ split.denominator }}
</td> </td>
<td class="mat-mdc-cell px-1 py-2 text-right"> <td class="mat-mdc-cell px-1 py-2 text-right">
<button <button
@ -585,16 +585,29 @@
<mat-datepicker #dateOfSplit /> <mat-datepicker #dateOfSplit />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="mr-3"> <mat-form-field appearance="outline" class="mr-3">
<mat-label i18n>Split Factor</mat-label> <mat-label i18n>Shares After</mat-label>
<input <input
formControlName="factor" formControlName="numerator"
matInput matInput
step="any" step="1"
type="number" type="number"
/> />
<mat-hint i18n <mat-hint i18n
>Shares after per 1 share before, e.g. 4 for a 4:1 split >Numerator, e.g. 4 for a 4:1 split or 1 for a 1:10 reverse
or 0.1 for a 1:10 reverse split</mat-hint 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> </mat-form-field>
<button <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 { 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 { export class CreateAssetProfileSplitDto {
/** /**
* The date the split becomes effective. Activities before this date are * The date the split becomes effective. Activities before this date are
* adjusted by the factor. * adjusted by the ratio.
*/ */
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint) @Validate(IsAfter1970Constraint)
date: string; date: string;
/** /**
* The number of shares held after the split per 1 share held before, for * The number of shares held before the split, for example 1 for a 4:1 split
* example 4 for a 4:1 split or 0.1 for a 1:10 reverse 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 * already split-adjusted by the data providers and must not be adjusted
* again. * again.
*/ */
@IsNumber() @IsInt()
@Validate(IsSplitFactorConstraint) @Validate(IsSplitRatioConstraint)
factor: number; numerator: number;
} }

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

@ -10,7 +10,7 @@ import {
isAccountExcluded, isAccountExcluded,
isCurrency, isCurrency,
isCurrencySymbol, isCurrencySymbol,
isSplitFactor isSplitRatio
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
describe('Helper', () => { describe('Helper', () => {
@ -282,34 +282,48 @@ describe('Helper', () => {
}); });
}); });
describe('Is split factor', () => { describe('Is split ratio', () => {
it('Forward split', () => { it('Forward split', () => {
expect(isSplitFactor(2)).toEqual(true); expect(isSplitRatio({ denominator: 1, numerator: 2 })).toEqual(true);
expect(isSplitFactor(4)).toEqual(true); expect(isSplitRatio({ denominator: 1, numerator: 4 })).toEqual(true);
expect(isSplitRatio({ denominator: 2, numerator: 3 })).toEqual(true);
}); });
it('Reverse split', () => { it('Reverse split', () => {
expect(isSplitFactor(0.1)).toEqual(true); expect(isSplitRatio({ denominator: 10, numerator: 1 })).toEqual(true);
expect(isSplitFactor(0.5)).toEqual(true); expect(isSplitRatio({ denominator: 3, numerator: 1 })).toEqual(true);
}); });
it('Factor without effect', () => { it('Ratio without effect', () => {
expect(isSplitFactor(1)).toEqual(false); expect(isSplitRatio({ denominator: 1, numerator: 1 })).toEqual(false);
expect(isSplitRatio({ denominator: 3, numerator: 3 })).toEqual(false);
}); });
it('Zero or negative factor', () => { it('Zero or negative ratio', () => {
expect(isSplitFactor(0)).toEqual(false); expect(isSplitRatio({ denominator: 1, numerator: 0 })).toEqual(false);
expect(isSplitFactor(-2)).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', () => { it('Non-integer ratio', () => {
expect(isSplitFactor(Number.NaN)).toEqual(false); expect(isSplitRatio({ denominator: 1, numerator: 1.5 })).toEqual(false);
expect(isSplitFactor(Number.POSITIVE_INFINITY)).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', () => { it('Missing ratio', () => {
expect(isSplitFactor(undefined)).toEqual(false); expect(
expect(isSplitFactor(null)).toEqual(false); 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 * Validates the ratio of a stock split, expressed as the number of shares held
* held after the split per 1 share held before, for example 4 for a 4:1 split * after the split (numerator) per number of shares held before (denominator),
* or 0.1 for a 1:10 reverse split. A factor of 1 would be a no-op. * 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) { export function isSplitRatio({
return Number.isFinite(aFactor) && aFactor > 0 && aFactor !== 1; denominator,
numerator
}: {
denominator: number;
numerator: number;
}) {
return (
Number.isSafeInteger(numerator) &&
Number.isSafeInteger(denominator) &&
numerator > 0 &&
denominator > 0 &&
numerator !== denominator
);
} }
export function isValidSearchQuery(aQuery: string) { 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" ( CREATE TABLE "AssetProfileSplit" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date" TIMESTAMP(3) NOT NULL, "date" TIMESTAMP(3) NOT NULL,
"factor" DOUBLE PRECISION NOT NULL, "denominator" INTEGER NOT NULL,
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"numerator" INTEGER NOT NULL,
"symbolProfileId" TEXT NOT NULL, "symbolProfileId" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,

14
prisma/schema.prisma

@ -116,18 +116,22 @@ model AssetProfileResolution {
@@unique([dataSourceOrigin, symbolOrigin]) @@unique([dataSourceOrigin, symbolOrigin])
} }
/// A stock split of an asset profile. The factor is the number of shares held /// A stock split of an asset profile. The numerator is the number of shares
/// after the split per 1 share held before, for example 4 for a 4:1 split or /// held after the split, the denominator the number of shares held before, for
/// 0.1 for a 1:10 reverse split. /// 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 /// Market data is already split-adjusted by the data providers and must not be
/// adjusted again. /// adjusted again.
model AssetProfileSplit { model AssetProfileSplit {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
date DateTime date DateTime
factor Float denominator Int
id String @id @default(uuid()) id String @id @default(uuid())
numerator Int
symbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id]) symbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id])
symbolProfileId String symbolProfileId String
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

Loading…
Cancel
Save