Browse Source

Merge 556d683b27 into f6ffc0bff3

pull/8046/merge
Wai Cheng 1 week ago
committed by GitHub
parent
commit
fef0dc69ec
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 3
      apps/api/src/app/activities/activities.service.spec.ts
  3. 29
      apps/api/src/app/activities/activities.service.ts
  4. 2
      apps/api/src/app/export/export.service.ts
  5. 3
      apps/api/src/app/import/import.service.spec.ts
  6. 18
      apps/api/src/app/import/import.service.ts
  7. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  8. 16
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  9. 1
      apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts
  10. 63
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  11. 29
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 36
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  13. 9
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  14. 19
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  15. 6
      libs/common/src/lib/dtos/create-order.dto.ts
  16. 6
      libs/common/src/lib/dtos/update-order.dto.ts
  17. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  18. 2
      prisma/migrations/20261004000000_added_exchange_rate_to_order/migration.sql
  19. 1
      prisma/schema.prisma

1
CHANGELOG.md

@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for a manual exchange rate in the create or update activity dialog
- Extended the _Public API_ with the liveness probe endpoint (`GET api/v1/health/liveness`) (experimental)
- Added the graceful shutdown of the server on `SIGINT` and `SIGTERM`
- Added `stop_grace_period` to the _Ghostfolio_ service in the `docker-compose` file (`docker-compose.yml`)

3
apps/api/src/app/activities/activities.service.spec.ts

@ -88,7 +88,8 @@ describe('ActivitiesService', () => {
null,
null,
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0)
toCurrencyAtDate: jest.fn().mockResolvedValue(0),
toCurrencyAtDateWithExchangeRate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
null,
{

29
apps/api/src/app/activities/activities.service.ts

@ -532,6 +532,7 @@ export class ActivitiesService {
createdAt: new Date(balanceItem.date),
currency: account.currency,
date: new Date(balanceItem.date),
exchangeRate: null,
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
@ -723,24 +724,28 @@ export class ActivitiesService {
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({
assetProfileCurrency: order.SymbolProfile.currency,
currency: order.currency ?? order.SymbolProfile.currency,
date: order.date,
exchangeRate: order.exchangeRate,
targetCurrency: userCurrency,
value: order.fee
}),
this.exchangeRateDataService.toCurrencyAtDate(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)
this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({
assetProfileCurrency: order.SymbolProfile.currency,
currency: order.currency ?? order.SymbolProfile.currency,
date: order.date,
exchangeRate: order.exchangeRate,
targetCurrency: userCurrency,
value
})
]);
return {

2
apps/api/src/app/export/export.service.ts

@ -237,6 +237,7 @@ export class ExportService {
comment,
currency,
date,
exchangeRate,
fee,
id,
quantity,
@ -247,6 +248,7 @@ export class ExportService {
return {
accountId,
comment,
exchangeRate,
fee,
id,
quantity,

3
apps/api/src/app/import/import.service.spec.ts

@ -75,7 +75,8 @@ describe('ImportService', () => {
getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL)
} as unknown as DataProviderService,
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0)
toCurrencyAtDate: jest.fn().mockResolvedValue(0),
toCurrencyAtDateWithExchangeRate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
null,
null,

18
apps/api/src/app/import/import.service.ts

@ -169,6 +169,7 @@ export class ImportService {
comment: undefined,
currency: undefined,
createdAt: undefined,
exchangeRate: null,
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
@ -846,6 +847,7 @@ export class ImportService {
const currency = activity.currency;
const date = activity.date;
const error = activity.error;
const exchangeRate = activity.exchangeRate;
const fee = activity.fee;
const quantity = activity.quantity;
const tagIds = activity.tagIds ?? [];
@ -889,6 +891,7 @@ export class ImportService {
comment,
currency,
date,
exchangeRate,
fee,
quantity,
type,
@ -921,6 +924,7 @@ export class ImportService {
comment,
currency,
date,
exchangeRate,
fee,
quantity,
type,
@ -968,12 +972,14 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency =
(await this.exchangeRateDataService.toCurrencyAtDate(
(await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({
date,
exchangeRate,
value,
currency ?? assetProfile.currency,
userCurrency,
date
)) ?? 0;
assetProfileCurrency: assetProfile.currency,
currency: currency ?? assetProfile.currency,
targetCurrency: userCurrency
})) ?? 0;
activities.push({
...omit(order, ['SymbolProfile']),
@ -1037,6 +1043,7 @@ export class ImportService {
currency,
dataSource,
date: dateString,
exchangeRate,
fee,
quantity,
symbol,
@ -1071,6 +1078,7 @@ export class ImportService {
currency,
date,
error,
exchangeRate,
fee,
quantity,
type,

1
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -17,6 +17,7 @@ export const activityDummyData = {
comment: undefined,
createdAt: new Date(),
currency: undefined,
exchangeRate: null,
fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined,

16
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -150,6 +150,7 @@ export abstract class PortfolioCalculator {
({
assetProfile,
date,
exchangeRate,
feeInAssetProfileCurrency,
feeInBaseCurrency,
quantity,
@ -172,6 +173,7 @@ export abstract class PortfolioCalculator {
tags,
type,
date: format(date, DATE_FORMAT),
exchangeRate,
fee: new Big(feeInAssetProfileCurrency),
feeInBaseCurrency: new Big(feeInBaseCurrency),
quantity: new Big(quantity),
@ -1023,6 +1025,13 @@ export abstract class PortfolioCalculator {
const exchangeRateAtActivityDate = exchangeRates[activity.date];
// Manual override of the asset to base currency rate at the activity
// date. It only applies to the cost-basis path (with currency effect of
// the activity itself). Current valuations and historical market prices
// stay on market rates.
const exchangeRateAtActivityDateForCostBasis =
activity.exchangeRate ?? exchangeRateAtActivityDate;
if (activity.itemType === 'start') {
// Take the unit price of the activity as the market price if there are no
// activities of this symbol before the start date
@ -1035,7 +1044,7 @@ export abstract class PortfolioCalculator {
if (activity.fee) {
activity.feeInBaseCurrency = activity.fee.mul(currentExchangeRate ?? 1);
activity.feeInBaseCurrencyWithCurrencyEffect = activity.fee.mul(
exchangeRateAtActivityDate ?? 1
exchangeRateAtActivityDateForCostBasis ?? 1
);
}
@ -1049,7 +1058,7 @@ export abstract class PortfolioCalculator {
);
activity.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtActivityDate ?? 1
exchangeRateAtActivityDateForCostBasis ?? 1
);
}
@ -1496,7 +1505,8 @@ export abstract class PortfolioCalculator {
let totalLiabilitiesInBaseCurrency = new Big(0);
for (const activity of activities) {
const exchangeRateAtActivityDate = exchangeRates[activity.date];
const exchangeRateAtActivityDate =
activity.exchangeRate ?? exchangeRates[activity.date];
if (activity.type === 'DIVIDEND') {
const dividend = activity.quantity.mul(activity.unitPrice);

1
apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts

@ -9,6 +9,7 @@ export interface PortfolioCalculatorActivity extends Pick<
'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>;
date: string;
exchangeRate?: number;
fee: Big;
feeInBaseCurrency: Big;
quantity: Big;

63
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -320,6 +320,69 @@ describe('PortfolioService', () => {
interestInBaseCurrency: 15
});
});
it('should apply the manual exchange rate to the dividend and the interest in the currency of the asset profile', async () => {
jest.spyOn(accountService, 'accounts').mockResolvedValue([
{
activities: [
{
currency: 'USD',
date: new Date('2024-01-02'),
exchangeRate: 1.35,
quantity: 2,
SymbolProfile: { currency: 'USD' },
tags: [],
type: 'DIVIDEND',
unitPrice: 10
},
{
currency: 'USD',
date: new Date('2024-01-03'),
exchangeRate: 1.3,
quantity: 3,
SymbolProfile: { currency: 'USD' },
tags: [],
type: 'INTEREST',
unitPrice: 5
}
],
balance: 0,
currency: 'SGD',
id: 'account-id',
name: 'Account'
}
] as unknown as Awaited<ReturnType<typeof accountService.accounts>>);
jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({
accounts: {}
} as unknown as Awaited<ReturnType<typeof portfolioService.getDetails>>);
jest.spyOn(userService, 'user').mockResolvedValue({
settings: { settings: { baseCurrency: 'SGD' } }
} as unknown as Awaited<ReturnType<typeof userService.user>>);
// Market rate USD to SGD of 2, which the manual rate must replace
jest
.spyOn(exchangeRateDataService, 'toCurrencyAtDate')
.mockImplementation(async (value, from, to) => {
return from === to ? value : value * 2;
});
jest
.spyOn(exchangeRateDataService, 'toCurrency')
.mockImplementation((value) => {
return value;
});
const [account] = await portfolioService.getAccounts({
userId: userDummyData.id
});
expect(account).toMatchObject({
dividendInBaseCurrency: 27,
interestInBaseCurrency: 19.5
});
});
});
describe('getAggregatedMarkets', () => {

29
apps/api/src/app/portfolio/portfolio.service.ts

@ -309,6 +309,7 @@ export class PortfolioService {
for (const {
currency,
date,
exchangeRate,
quantity,
SymbolProfile,
tags,
@ -324,20 +325,28 @@ export class PortfolioService {
switch (type) {
case ActivityType.DIVIDEND:
dividendInBaseCurrency +=
(await this.exchangeRateDataService.toCurrencyAtDate(
new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency,
userCurrency,
date
(await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate(
{
date,
exchangeRate,
assetProfileCurrency: SymbolProfile.currency,
currency: currency ?? SymbolProfile.currency,
targetCurrency: userCurrency,
value: new Big(quantity).mul(unitPrice).toNumber()
}
)) ?? 0;
break;
case ActivityType.INTEREST:
interestInBaseCurrency +=
(await this.exchangeRateDataService.toCurrencyAtDate(
new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency,
userCurrency,
date
(await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate(
{
date,
exchangeRate,
assetProfileCurrency: SymbolProfile.currency,
currency: currency ?? SymbolProfile.currency,
targetCurrency: userCurrency,
value: new Big(quantity).mul(unitPrice).toNumber()
}
)) ?? 0;
break;
}

36
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -377,6 +377,42 @@ export class ExchangeRateDataService {
return undefined;
}
/**
* Converts the value of an activity at its date. A manual exchange rate of
* the activity replaces the market rate from the currency of the asset
* profile to the target currency.
*/
public async toCurrencyAtDateWithExchangeRate({
assetProfileCurrency,
currency,
date,
exchangeRate,
targetCurrency,
value
}: {
assetProfileCurrency: string;
currency: string;
date: Date;
exchangeRate?: number | null;
targetCurrency: string;
value: number;
}) {
if (exchangeRate == null) {
return this.toCurrencyAtDate(value, currency, targetCurrency, date);
}
const valueInAssetProfileCurrency = await this.toCurrencyAtDate(
value,
currency,
assetProfileCurrency,
date
);
return valueInAssetProfileCurrency === undefined
? undefined
: valueInAssetProfileCurrency * exchangeRate;
}
private async getExchangeRates({
currencyFrom,
currencyTo,

9
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -224,6 +224,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
Validators.required
],
date: [this.data.activity?.date, Validators.required],
exchangeRate: [this.data.activity?.exchangeRate ?? null],
fee: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.assetProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required],
@ -488,6 +489,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
}
protected async onSubmit() {
const assetCurrency = this.activityForm.get('currency')?.value;
const baseCurrency = this.data.user?.settings?.baseCurrency;
const exchangeRateValue = this.activityForm.get('exchangeRate')?.value;
const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.get('accountId')?.value,
assetClass: this.activityForm.get('assetClass')?.value,
@ -501,6 +506,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
? 'MANUAL'
: this.activityForm.get('dataSource')?.value,
date: this.activityForm.get('date')?.value,
exchangeRate:
assetCurrency && baseCurrency && assetCurrency !== baseCurrency
? (exchangeRateValue ?? null)
: null,
fee: this.activityForm.get('fee')?.value,
quantity: this.activityForm.get('quantity')?.value,
symbol:

19
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -227,6 +227,25 @@
}
</div>
</div>
@if (
activityForm.get('currency')?.value &&
data.user?.settings?.baseCurrency &&
activityForm.get('currency')?.value !== data.user?.settings?.baseCurrency
) {
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n
>Exchange rate ({{ activityForm.get('currency')?.value }}
→
{{ data.user?.settings?.baseCurrency }})</mat-label
>
<input formControlName="exchangeRate" matInput type="number" />
<mat-hint i18n
>Optional. Leave empty to use the market rate.</mat-hint
>
</mat-form-field>
</div>
}
<div
class="mb-3"
[class.d-none]="

6
libs/common/src/lib/dtos/create-order.dto.ts

@ -21,6 +21,7 @@ import {
IsNotEmpty,
IsNumber,
IsOptional,
IsPositive,
IsString,
MaxLength,
Min,
@ -64,6 +65,11 @@ export class CreateOrderDto {
@Validate(IsAfter1970Constraint)
date: string;
@IsNumber()
@IsOptional()
@IsPositive()
exchangeRate?: number | null;
@IsNumber()
@Min(0)
fee: number;

6
libs/common/src/lib/dtos/update-order.dto.ts

@ -20,6 +20,7 @@ import {
IsNotEmpty,
IsNumber,
IsOptional,
IsPositive,
IsString,
MaxLength,
Min,
@ -62,6 +63,11 @@ export class UpdateOrderDto {
@Validate(IsAfter1970Constraint)
date: string;
@IsNumber()
@IsOptional()
@IsPositive()
exchangeRate?: number | null;
@IsNumber()
@Min(0)
fee: number;

5
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -28,6 +28,7 @@ const activities: Activity[] = [
comment: null,
createdAt: new Date('2025-04-09T13:47:33.133Z'),
currency: 'USD',
exchangeRate: null,
date: new Date('2025-04-09T13:45:45.504Z'),
fee: 1,
id: 'a76968ff-80a4-4453-81ed-c3627dea3919',
@ -92,6 +93,7 @@ const activities: Activity[] = [
comment: null,
createdAt: new Date('2024-08-07T13:40:39.103Z'),
currency: 'USD',
exchangeRate: null,
date: new Date('2024-08-07T13:38:06.289Z'),
fee: 2.97,
id: '0c2f4fbf-6edc-4adc-8f83-abf8148500ec',
@ -156,6 +158,7 @@ const activities: Activity[] = [
comment: null,
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
exchangeRate: null,
date: new Date('2024-03-12T15:14:38.597Z'),
fee: 45.29,
id: 'bfc92677-faf4-4d4f-9762-e0ec056525c2',
@ -220,6 +223,7 @@ const activities: Activity[] = [
comment: null,
createdAt: new Date('2024-02-23T15:53:46.907Z'),
currency: 'USD',
exchangeRate: null,
date: new Date('2024-02-23T15:53:15.745Z'),
fee: 3,
id: '7c9ceb54-acb1-4850-bfb1-adb41c29fd6a',
@ -284,6 +288,7 @@ const activities: Activity[] = [
comment: null,
createdAt: new Date('2023-01-11T14:35:22.325Z'),
currency: 'USD',
exchangeRate: null,
date: new Date('2023-01-11T14:34:55.174Z'),
fee: 7.38,
id: '3fe87b3f-78de-407a-bc02-4189b221051f',

2
prisma/migrations/20261004000000_added_exchange_rate_to_order/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "exchangeRate" DOUBLE PRECISION;

1
prisma/schema.prisma

@ -188,6 +188,7 @@ model Order {
createdAt DateTime @default(now())
currency String?
date DateTime
exchangeRate Float?
fee Float
id String @id @default(uuid())
quantity Float

Loading…
Cancel
Save