Browse Source

Feature/add manual exchange rate to activity

* Add optional exchangeRate to Order (asset profile currency to base currency)
* Apply it to the cost basis, fees, dividends, interest and liabilities at the activity date
* Add exchange rate input to the create or update activity dialog
* Pass it through import and export
* Add toCurrencyAtDateWithExchangeRate to ExchangeRateDataService
pull/8046/head
Wai Cheng 2 weeks ago
parent
commit
64cbb1afdd
  1. 3
      apps/api/src/app/activities/activities.service.spec.ts
  2. 29
      apps/api/src/app/activities/activities.service.ts
  3. 2
      apps/api/src/app/export/export.service.ts
  4. 18
      apps/api/src/app/import/import.service.ts
  5. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  6. 16
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 1
      apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts
  8. 63
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  9. 29
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 36
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  11. 9
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  12. 19
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  13. 6
      libs/common/src/lib/dtos/create-order.dto.ts
  14. 6
      libs/common/src/lib/dtos/update-order.dto.ts
  15. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  16. 2
      prisma/migrations/20261004000000_added_exchange_rate_to_order/migration.sql
  17. 1
      prisma/schema.prisma

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

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

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

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

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

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

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

@ -169,6 +169,7 @@ export class ImportService {
comment: undefined, comment: undefined,
currency: undefined, currency: undefined,
createdAt: undefined, createdAt: undefined,
exchangeRate: null,
fee: 0, fee: 0,
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
@ -842,6 +843,7 @@ export class ImportService {
const currency = activity.currency; const currency = activity.currency;
const date = activity.date; const date = activity.date;
const error = activity.error; const error = activity.error;
const exchangeRate = activity.exchangeRate;
const fee = activity.fee; const fee = activity.fee;
const quantity = activity.quantity; const quantity = activity.quantity;
const tagIds = activity.tagIds ?? []; const tagIds = activity.tagIds ?? [];
@ -885,6 +887,7 @@ export class ImportService {
comment, comment,
currency, currency,
date, date,
exchangeRate,
fee, fee,
quantity, quantity,
type, type,
@ -910,6 +913,7 @@ export class ImportService {
comment, comment,
currency, currency,
date, date,
exchangeRate,
fee, fee,
quantity, quantity,
type, type,
@ -951,12 +955,14 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = const valueInBaseCurrency =
(await this.exchangeRateDataService.toCurrencyAtDate( (await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({
date,
exchangeRate,
value, value,
currency ?? assetProfile.currency, assetProfileCurrency: assetProfile.currency,
userCurrency, currency: currency ?? assetProfile.currency,
date targetCurrency: userCurrency
)) ?? 0; })) ?? 0;
activities.push({ activities.push({
...omit(order, ['SymbolProfile']), ...omit(order, ['SymbolProfile']),
@ -1020,6 +1026,7 @@ export class ImportService {
currency, currency,
dataSource, dataSource,
date: dateString, date: dateString,
exchangeRate,
fee, fee,
quantity, quantity,
symbol, symbol,
@ -1054,6 +1061,7 @@ export class ImportService {
currency, currency,
date, date,
error, error,
exchangeRate,
fee, fee,
quantity, quantity,
type, type,

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

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

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

@ -150,6 +150,7 @@ export abstract class PortfolioCalculator {
({ ({
assetProfile, assetProfile,
date, date,
exchangeRate,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency, feeInBaseCurrency,
quantity, quantity,
@ -172,6 +173,7 @@ export abstract class PortfolioCalculator {
tags, tags,
type, type,
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
exchangeRate,
fee: new Big(feeInAssetProfileCurrency), fee: new Big(feeInAssetProfileCurrency),
feeInBaseCurrency: new Big(feeInBaseCurrency), feeInBaseCurrency: new Big(feeInBaseCurrency),
quantity: new Big(quantity), quantity: new Big(quantity),
@ -1013,6 +1015,13 @@ export abstract class PortfolioCalculator {
const exchangeRateAtActivityDate = exchangeRates[activity.date]; 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') { if (activity.itemType === 'start') {
// Take the unit price of the activity as the market price if there are no // Take the unit price of the activity as the market price if there are no
// activities of this symbol before the start date // activities of this symbol before the start date
@ -1025,7 +1034,7 @@ export abstract class PortfolioCalculator {
if (activity.fee) { if (activity.fee) {
activity.feeInBaseCurrency = activity.fee.mul(currentExchangeRate ?? 1); activity.feeInBaseCurrency = activity.fee.mul(currentExchangeRate ?? 1);
activity.feeInBaseCurrencyWithCurrencyEffect = activity.fee.mul( activity.feeInBaseCurrencyWithCurrencyEffect = activity.fee.mul(
exchangeRateAtActivityDate ?? 1 exchangeRateAtActivityDateForCostBasis ?? 1
); );
} }
@ -1039,7 +1048,7 @@ export abstract class PortfolioCalculator {
); );
activity.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul( activity.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtActivityDate ?? 1 exchangeRateAtActivityDateForCostBasis ?? 1
); );
} }
@ -1486,7 +1495,8 @@ export abstract class PortfolioCalculator {
let totalLiabilitiesInBaseCurrency = new Big(0); let totalLiabilitiesInBaseCurrency = new Big(0);
for (const activity of activities) { for (const activity of activities) {
const exchangeRateAtActivityDate = exchangeRates[activity.date]; const exchangeRateAtActivityDate =
activity.exchangeRate ?? exchangeRates[activity.date];
if (activity.type === 'DIVIDEND') { if (activity.type === 'DIVIDEND') {
const dividend = activity.quantity.mul(activity.unitPrice); 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' 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>; >;
date: string; date: string;
exchangeRate?: number;
fee: Big; fee: Big;
feeInBaseCurrency: Big; feeInBaseCurrency: Big;
quantity: Big; quantity: Big;

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

@ -320,6 +320,69 @@ describe('PortfolioService', () => {
interestInBaseCurrency: 15 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', () => { describe('getAggregatedMarkets', () => {

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

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

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

@ -377,6 +377,42 @@ export class ExchangeRateDataService {
return undefined; 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({ private async getExchangeRates({
currencyFrom, currencyFrom,
currencyTo, 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 Validators.required
], ],
date: [this.data.activity?.date, Validators.required], date: [this.data.activity?.date, Validators.required],
exchangeRate: [this.data.activity?.exchangeRate ?? null],
fee: [this.data.activity?.fee, Validators.required], fee: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.assetProfile?.name, Validators.required], name: [this.data.activity?.assetProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required], quantity: [this.data.activity?.quantity, Validators.required],
@ -488,6 +489,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
} }
protected async onSubmit() { 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 = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.get('accountId')?.value, accountId: this.activityForm.get('accountId')?.value,
assetClass: this.activityForm.get('assetClass')?.value, assetClass: this.activityForm.get('assetClass')?.value,
@ -501,6 +506,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
? 'MANUAL' ? 'MANUAL'
: this.activityForm.get('dataSource')?.value, : this.activityForm.get('dataSource')?.value,
date: this.activityForm.get('date')?.value, date: this.activityForm.get('date')?.value,
exchangeRate:
assetCurrency && baseCurrency && assetCurrency !== baseCurrency
? (exchangeRateValue ?? null)
: null,
fee: this.activityForm.get('fee')?.value, fee: this.activityForm.get('fee')?.value,
quantity: this.activityForm.get('quantity')?.value, quantity: this.activityForm.get('quantity')?.value,
symbol: 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>
</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 <div
class="mb-3" class="mb-3"
[class.d-none]=" [class.d-none]="

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

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

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

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

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

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

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

Loading…
Cancel
Save