Browse Source

Feature/stock splits setup (#7251)

* Stock splits setup

* Update changelog
pull/7516/head
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
ddf446f915
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 3
      apps/api/src/app/asset/asset.controller.ts
  3. 96
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  4. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  5. 45
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  6. 2
      apps/api/src/app/user/user.service.ts
  7. 12
      apps/api/src/services/asset-profile-split/asset-profile-split.module.ts
  8. 87
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  9. 85
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  10. 127
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  11. 39
      libs/common/src/lib/dtos/create-asset-profile-split.dto.ts
  12. 2
      libs/common/src/lib/dtos/index.ts
  13. 48
      libs/common/src/lib/helper.spec.ts
  14. 22
      libs/common/src/lib/helper.ts
  15. 3
      libs/common/src/lib/interfaces/admin-market-data-details.interface.ts
  16. 3
      libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts
  17. 12
      libs/common/src/lib/permissions.ts
  18. 18
      libs/common/src/lib/validator-constraints/is-split-ratio.ts
  19. 24
      libs/ui/src/lib/services/admin.service.ts
  20. 4
      libs/ui/src/lib/services/data.service.ts
  21. 21
      prisma/migrations/20260802000000_added_asset_profile_split/migration.sql
  22. 28
      prisma/schema.prisma

4
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

3
apps/api/src/app/asset/asset.controller.ts

@ -20,7 +20,7 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetResponse> {
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'])
};
}

96
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<AssetProfileSplit> {
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<void> {
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;
}
}

2
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,

45
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,

2
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

12
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 {}

87
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<AssetProfileSplit[]> {
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<AssetProfileSplit> {
const dateOfSplit = resetHours(date);
return this.prismaService.assetProfileSplit.upsert({
create: {
denominator,
numerator,
symbolProfileId,
date: dateOfSplit
},
update: {
denominator,
numerator
},
where: {
symbolProfileId_date: {
symbolProfileId,
date: dateOfSplit
}
}
});
}
}

85
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<Date | null>(null, Validators.required),
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
: { 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();

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

@ -503,6 +503,133 @@
</div>
</div>
</mat-tab>
<!-- Splits are not applied to the portfolio calculation yet, hence the
tab to manage them is disabled -->
@if (false && user?.settings?.isExperimentalFeatures) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="git-compare-outline" />
<div class="d-none d-sm-block ml-2" i18n>Splits</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="no-gutters row w-100">
<div class="col-12">
<p class="text-muted" i18n>
Splits are stored for this asset profile, but are not applied
to the portfolio calculation yet.
</p>
@if (splits.length > 0) {
<table class="gf-table mb-3 w-100">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2" i18n>Date</th>
<th
class="mat-mdc-header-cell px-1 py-2 text-right"
i18n
>
Split Ratio
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
@for (split of splits; track split.id) {
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-1 py-2">
{{ split.date | date: defaultDateFormat }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.numerator }}:{{ split.denominator }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<button
mat-icon-button
type="button"
[disabled]="!canEditAssetProfile"
(click)="onDeleteSplit(split.id)"
>
<ion-icon name="trash-outline" />
</button>
</td>
</tr>
}
</tbody>
</table>
}
<form
[formGroup]="assetProfileSplitForm"
(ngSubmit)="onAddSplit()"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input
formControlName="date"
matInput
[matDatepicker]="dateOfSplit"
/>
<mat-datepicker-toggle
class="mr-2"
matSuffix
[for]="dateOfSplit"
>
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #dateOfSplit />
</mat-form-field>
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Shares After</mat-label>
<input
formControlName="numerator"
matInput
step="1"
type="number"
/>
<mat-hint
><ng-container i18n>Numerator</ng-container>,
<ng-container i18n
>e.g. 4 for a 4:1 split or 1 for a 1:10 reverse
split</ng-container
></mat-hint
>
</mat-form-field>
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Shares Before</mat-label>
<input
formControlName="denominator"
matInput
step="1"
type="number"
/>
<mat-hint
><ng-container i18n>Denominator</ng-container>,
<ng-container i18n
>e.g. 1 for a 4:1 split or 10 for a 1:10 reverse
split</ng-container
></mat-hint
>
</mat-form-field>
<div class="text-right">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="
!assetProfileSplitForm.valid || !canEditAssetProfile
"
>
<ng-container i18n>Add</ng-container>
</button>
</div>
</form>
</div>
</div>
</div>
</mat-tab>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab>
<ng-template mat-tab-label>

39
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;
}

2
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,

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

22
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;
}

3
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<EnhancedAssetProfile>;
marketData: MarketData[];
splits: AssetProfileSplit[];
}

3
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<EnhancedAssetProfile>;
marketData: MarketData[];
splits: AssetProfileSplit[];
}

12
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,

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

24
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<void>(`/api/v1/platform/${aId}`);
}
public deleteAssetProfileSplit({
dataSource,
id,
symbol
}: AssetProfileIdentifier & { id: string }) {
return this.http.delete<void>(
`/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits/${id}`
);
}
public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<void>(
`/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<AssetProfileSplit>(
`/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits`,
split
);
}
public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}

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

21
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;

28
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?

Loading…
Cancel
Save