Feature/activity in custom currency (#4486)
* Activity in custom currency * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
This commit is contained in:
parent
53122b09ab
commit
3361666f63
CHANGELOG.md
apps
api/src/app
export
import
order
portfolio
calculator
portfolio-calculator-test-utils.tsportfolio-calculator.ts
portfolio.service.tsroai
portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.tsportfolio-calculator-baln-buy-and-sell.spec.tsportfolio-calculator-baln-buy.spec.tsportfolio-calculator-btcusd-buy-and-sell-partially.spec.tsportfolio-calculator-fee.spec.tsportfolio-calculator-googl-buy.spec.tsportfolio-calculator-item.spec.tsportfolio-calculator-liability.spec.tsportfolio-calculator-msft-buy-with-dividend.spec.tsportfolio-calculator-novn-buy-and-sell-partially.spec.tsportfolio-calculator-novn-buy-and-sell.spec.ts
client/src/app
pages/portfolio/activities/create-or-update-activity-dialog
services
libs/ui/src/lib/activities-table
prisma/migrations/20250401084916_set_value_of_currency_to_null_in_order
test/import
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Added support for activities in a custom currency
|
||||
|
||||
## 2.152.1 - 2025-04-17
|
||||
|
||||
### Changed
|
||||
|
@ -120,6 +120,7 @@ export class ExportService {
|
||||
({
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
@ -137,7 +138,7 @@ export class ExportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
currency: SymbolProfile.currency,
|
||||
currency: currency ?? SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
|
||||
|
@ -98,12 +98,9 @@ export class ImportController {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<ImportResponse> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.importService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
symbol
|
||||
});
|
||||
|
||||
return { activities };
|
||||
|
@ -10,12 +10,10 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAssetProfileIdentifier,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -29,8 +27,8 @@ import {
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { isNumber, uniqBy } from 'lodash';
|
||||
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
@ -40,7 +38,6 @@ export class ImportService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -49,9 +46,8 @@ export class ImportService {
|
||||
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
|
||||
symbol
|
||||
}: AssetProfileIdentifier): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
@ -121,22 +117,16 @@ export class ImportService {
|
||||
currency: undefined,
|
||||
createdAt: undefined,
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: assetProfile,
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
unitPriceInAssetProfileCurrency: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
valueInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency,
|
||||
date
|
||||
)
|
||||
userId: Account?.userId
|
||||
};
|
||||
})
|
||||
);
|
||||
@ -266,17 +256,17 @@ export class ImportService {
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const [index, activity] of activitiesExtendedWithErrors.entries()) {
|
||||
for (const activity of activitiesExtendedWithErrors) {
|
||||
const accountId = activity.accountId;
|
||||
const comment = activity.comment;
|
||||
const currency = activity.currency;
|
||||
const date = activity.date;
|
||||
const error = activity.error;
|
||||
let fee = activity.fee;
|
||||
const fee = activity.fee;
|
||||
const quantity = activity.quantity;
|
||||
const SymbolProfile = activity.SymbolProfile;
|
||||
const type = activity.type;
|
||||
let unitPrice = activity.unitPrice;
|
||||
const unitPrice = activity.unitPrice;
|
||||
|
||||
const assetProfile = assetProfiles[
|
||||
getAssetProfileIdentifier({
|
||||
@ -284,7 +274,6 @@ export class ImportService {
|
||||
symbol: SymbolProfile.symbol
|
||||
})
|
||||
] ?? {
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
@ -320,35 +309,6 @@ export class ImportService {
|
||||
Account?: { id: string; name: string };
|
||||
});
|
||||
|
||||
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||
// Convert the unit price and fee to the asset currency if the imported
|
||||
// activity is in a different currency
|
||||
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
unitPrice,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
|
||||
if (!isNumber(unitPrice)) {
|
||||
throw new Error(
|
||||
`activities.${index} historical exchange rate at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)} is not available from "${SymbolProfile.currency}" to "${
|
||||
assetProfile.currency
|
||||
}"`
|
||||
);
|
||||
}
|
||||
|
||||
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
@ -400,6 +360,7 @@ export class ImportService {
|
||||
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
@ -439,21 +400,8 @@ export class ImportService {
|
||||
...order,
|
||||
error,
|
||||
value,
|
||||
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
fee,
|
||||
assetProfile.currency,
|
||||
userCurrency,
|
||||
date
|
||||
),
|
||||
// @ts-ignore
|
||||
SymbolProfile: assetProfile,
|
||||
valueInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency,
|
||||
date
|
||||
)
|
||||
SymbolProfile: assetProfile
|
||||
});
|
||||
}
|
||||
|
||||
@ -520,7 +468,8 @@ export class ImportService {
|
||||
return (
|
||||
activity.accountId === accountId &&
|
||||
activity.comment === comment &&
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
(activity.currency === currency ||
|
||||
activity.SymbolProfile.currency === currency) &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.fee === fee &&
|
||||
@ -538,6 +487,7 @@ export class ImportService {
|
||||
return {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
@ -545,7 +495,6 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice,
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
activitiesCount: undefined,
|
||||
@ -553,6 +502,7 @@ export class ImportService {
|
||||
assetSubClass: undefined,
|
||||
countries: undefined,
|
||||
createdAt: undefined,
|
||||
currency: undefined,
|
||||
holdings: undefined,
|
||||
id: undefined,
|
||||
isActive: true,
|
||||
@ -633,12 +583,6 @@ export class ImportService {
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
|
@ -11,12 +11,12 @@ export interface Activities {
|
||||
export interface Activity extends Order {
|
||||
Account?: AccountWithPlatform;
|
||||
error?: ActivityError;
|
||||
feeInBaseCurrency: number;
|
||||
feeInAssetProfileCurrency: number;
|
||||
SymbolProfile?: EnhancedSymbolProfile;
|
||||
tags?: Tag[];
|
||||
unitPriceInAssetProfileCurrency: number;
|
||||
updateAccountBalance?: boolean;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
||||
|
||||
export interface ActivityError {
|
||||
|
@ -534,18 +534,25 @@ export class OrderService {
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency:
|
||||
feeInAssetProfileCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.fee,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
),
|
||||
SymbolProfile: assetProfile,
|
||||
unitPriceInAssetProfileCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.unitPrice,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
order.SymbolProfile.currency,
|
||||
order.date
|
||||
),
|
||||
valueInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
)
|
||||
|
@ -6,10 +6,11 @@ export const activityDummyData = {
|
||||
comment: undefined,
|
||||
createdAt: new Date(),
|
||||
currency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
fee: undefined,
|
||||
id: undefined,
|
||||
isDraft: false,
|
||||
symbolProfileId: undefined,
|
||||
unitPrice: undefined,
|
||||
updatedAt: new Date(),
|
||||
userId: undefined,
|
||||
value: undefined,
|
||||
|
@ -112,12 +112,12 @@ export abstract class PortfolioCalculator {
|
||||
.map(
|
||||
({
|
||||
date,
|
||||
fee,
|
||||
feeInAssetProfileCurrency,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
tags = [],
|
||||
type,
|
||||
unitPrice
|
||||
unitPriceInAssetProfileCurrency
|
||||
}) => {
|
||||
if (isBefore(date, dateOfFirstActivity)) {
|
||||
dateOfFirstActivity = date;
|
||||
@ -134,9 +134,9 @@ export abstract class PortfolioCalculator {
|
||||
tags,
|
||||
type,
|
||||
date: format(date, DATE_FORMAT),
|
||||
fee: new Big(fee),
|
||||
fee: new Big(feeInAssetProfileCurrency),
|
||||
quantity: new Big(quantity),
|
||||
unitPrice: new Big(unitPrice)
|
||||
unitPrice: new Big(unitPriceInAssetProfileCurrency)
|
||||
};
|
||||
}
|
||||
)
|
||||
|
@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-22'),
|
||||
fee: 1.55,
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,12 +101,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 142.9
|
||||
unitPriceInAssetProfileCurrency: 142.9
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.65,
|
||||
feeInAssetProfileCurrency: 1.65,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -116,12 +116,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -131,7 +131,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-22'),
|
||||
fee: 1.55,
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,12 +101,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 142.9
|
||||
unitPriceInAssetProfileCurrency: 142.9
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.65,
|
||||
feeInAssetProfileCurrency: 1.65,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -116,7 +116,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.55,
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,7 +101,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -105,7 +105,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2015-01-01'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -115,12 +115,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 320.43
|
||||
unitPriceInAssetProfileCurrency: 320.43
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2017-12-31'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -130,7 +130,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 14156.4
|
||||
unitPriceInAssetProfileCurrency: 14156.4
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-09-01'),
|
||||
fee: 49,
|
||||
feeInAssetProfileCurrency: 49,
|
||||
quantity: 0,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,7 +101,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
|
||||
},
|
||||
type: 'FEE',
|
||||
unitPrice: 0
|
||||
unitPriceInAssetProfileCurrency: 0
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -104,7 +104,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-03'),
|
||||
fee: 1,
|
||||
feeInAssetProfileCurrency: 1,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -114,7 +114,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'GOOGL'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 89.12
|
||||
unitPriceInAssetProfileCurrency: 89.12
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-01-01'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,7 +101,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
|
||||
},
|
||||
type: 'ITEM',
|
||||
unitPrice: 500000
|
||||
unitPriceInAssetProfileCurrency: 500000
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -91,7 +91,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-01'), // Date in future
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,7 +101,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
|
||||
},
|
||||
type: 'LIABILITY',
|
||||
unitPrice: 3000
|
||||
unitPriceInAssetProfileCurrency: 3000
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -104,7 +104,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-09-16'),
|
||||
fee: 19,
|
||||
feeInAssetProfileCurrency: 19,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -114,12 +114,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'MSFT'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 298.58
|
||||
unitPriceInAssetProfileCurrency: 298.58
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-16'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -129,7 +129,7 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'MSFT'
|
||||
},
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: 0.62
|
||||
unitPriceInAssetProfileCurrency: 0.62
|
||||
}
|
||||
];
|
||||
|
||||
|
4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
@ -105,13 +105,15 @@ describe('PortfolioCalculator', () => {
|
||||
...activityDummyData,
|
||||
...activity,
|
||||
date: parseDate(activity.date),
|
||||
feeInAssetProfileCurrency: activity.fee,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: activity.currency,
|
||||
dataSource: activity.dataSource,
|
||||
name: 'Novartis AG',
|
||||
symbol: activity.symbol
|
||||
}
|
||||
},
|
||||
unitPriceInAssetProfileCurrency: activity.unitPrice
|
||||
}));
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
|
@ -105,13 +105,15 @@ describe('PortfolioCalculator', () => {
|
||||
...activityDummyData,
|
||||
...activity,
|
||||
date: parseDate(activity.date),
|
||||
feeInAssetProfileCurrency: activity.fee,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: activity.currency,
|
||||
dataSource: activity.dataSource,
|
||||
name: 'Novartis AG',
|
||||
symbol: activity.symbol
|
||||
}
|
||||
},
|
||||
unitPriceInAssetProfileCurrency: activity.unitPrice
|
||||
}));
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
|
@ -246,10 +246,14 @@ export class PortfolioService {
|
||||
activities: Activity[];
|
||||
groupBy?: GroupBy;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
let dividends = activities.map(({ date, valueInBaseCurrency }) => {
|
||||
let dividends = activities.map(({ currency, date, value }) => {
|
||||
return {
|
||||
date: format(date, DATE_FORMAT),
|
||||
investment: valueInBaseCurrency
|
||||
investment: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
this.getUserCurrency()
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@ import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
|
||||
import { isAfter, isToday } from 'date-fns';
|
||||
import { EMPTY, Subject, lastValueFrom } from 'rxjs';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, delay, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { DataService } from '../../../../services/data.service';
|
||||
@ -102,7 +102,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
Validators.required
|
||||
],
|
||||
currencyOfUnitPrice: [
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
this.data.activity?.currency ??
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
Validators.required
|
||||
],
|
||||
dataSource: [
|
||||
@ -111,7 +112,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
],
|
||||
date: [this.data.activity?.date, Validators.required],
|
||||
fee: [this.data.activity?.fee, Validators.required],
|
||||
feeInCustomCurrency: [this.data.activity?.fee, Validators.required],
|
||||
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
|
||||
quantity: [this.data.activity?.quantity, Validators.required],
|
||||
searchSymbol: [
|
||||
@ -133,10 +133,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
],
|
||||
type: [undefined, Validators.required], // Set after value changes subscription
|
||||
unitPrice: [this.data.activity?.unitPrice, Validators.required],
|
||||
unitPriceInCustomCurrency: [
|
||||
this.data.activity?.unitPrice,
|
||||
Validators.required
|
||||
],
|
||||
updateAccountBalance: [false]
|
||||
});
|
||||
|
||||
@ -148,57 +144,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(async () => {
|
||||
let exchangeRateOfUnitPrice = 1;
|
||||
|
||||
this.activityForm.get('feeInCustomCurrency').setErrors(null);
|
||||
this.activityForm.get('unitPriceInCustomCurrency').setErrors(null);
|
||||
|
||||
const currency = this.activityForm.get('currency').value;
|
||||
const currencyOfUnitPrice = this.activityForm.get(
|
||||
'currencyOfUnitPrice'
|
||||
).value;
|
||||
const date = this.activityForm.get('date').value;
|
||||
|
||||
if (
|
||||
currency &&
|
||||
currencyOfUnitPrice &&
|
||||
currency !== currencyOfUnitPrice &&
|
||||
date
|
||||
) {
|
||||
try {
|
||||
const { marketPrice } = await lastValueFrom(
|
||||
this.dataService
|
||||
.fetchExchangeRateForDate({
|
||||
date,
|
||||
symbol: `${currencyOfUnitPrice}-${currency}`
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
);
|
||||
|
||||
exchangeRateOfUnitPrice = marketPrice;
|
||||
} catch {
|
||||
this.activityForm.get('unitPriceInCustomCurrency').setErrors({
|
||||
invalid: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const feeInCustomCurrency =
|
||||
this.activityForm.get('feeInCustomCurrency').value *
|
||||
exchangeRateOfUnitPrice;
|
||||
|
||||
const unitPriceInCustomCurrency =
|
||||
this.activityForm.get('unitPriceInCustomCurrency').value *
|
||||
exchangeRateOfUnitPrice;
|
||||
|
||||
this.activityForm.get('fee').setValue(feeInCustomCurrency, {
|
||||
emitEvent: false
|
||||
});
|
||||
|
||||
this.activityForm.get('unitPrice').setValue(unitPriceInCustomCurrency, {
|
||||
emitEvent: false
|
||||
});
|
||||
|
||||
if (
|
||||
this.activityForm.get('type').value === 'BUY' ||
|
||||
this.activityForm.get('type').value === 'FEE' ||
|
||||
@ -265,10 +210,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.get('type').value
|
||||
)
|
||||
) {
|
||||
this.activityForm
|
||||
.get('dataSource')
|
||||
.setValue(this.activityForm.get('searchSymbol').value.dataSource);
|
||||
|
||||
this.updateSymbol();
|
||||
}
|
||||
|
||||
@ -297,7 +238,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
.get('dataSource')
|
||||
.removeValidators(Validators.required);
|
||||
this.activityForm.get('dataSource').updateValueAndValidity();
|
||||
this.activityForm.get('feeInCustomCurrency').reset();
|
||||
this.activityForm.get('fee').reset();
|
||||
this.activityForm.get('name').setValidators(Validators.required);
|
||||
this.activityForm.get('name').updateValueAndValidity();
|
||||
this.activityForm.get('quantity').setValue(1);
|
||||
@ -331,12 +272,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.get('dataSource').updateValueAndValidity();
|
||||
|
||||
if (
|
||||
(type === 'FEE' &&
|
||||
this.activityForm.get('feeInCustomCurrency').value === 0) ||
|
||||
(type === 'FEE' && this.activityForm.get('fee').value === 0) ||
|
||||
type === 'INTEREST' ||
|
||||
type === 'LIABILITY'
|
||||
) {
|
||||
this.activityForm.get('feeInCustomCurrency').reset();
|
||||
this.activityForm.get('fee').reset();
|
||||
}
|
||||
|
||||
this.activityForm.get('name').setValidators(Validators.required);
|
||||
@ -354,7 +294,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.get('searchSymbol').updateValueAndValidity();
|
||||
|
||||
if (type === 'FEE') {
|
||||
this.activityForm.get('unitPriceInCustomCurrency').setValue(0);
|
||||
this.activityForm.get('unitPrice').setValue(0);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -410,7 +350,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public applyCurrentMarketPrice() {
|
||||
this.activityForm.patchValue({
|
||||
currencyOfUnitPrice: this.activityForm.get('currency').value,
|
||||
unitPriceInCustomCurrency: this.currentMarketPrice
|
||||
unitPrice: this.currentMarketPrice
|
||||
});
|
||||
}
|
||||
|
||||
@ -496,7 +436,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: this.activityForm.get('dataSource').value,
|
||||
dataSource: this.activityForm.get('searchSymbol').value.dataSource,
|
||||
symbol: this.activityForm.get('searchSymbol').value.symbol
|
||||
})
|
||||
.pipe(
|
||||
@ -512,9 +452,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.activityForm.get('currency').setValue(currency);
|
||||
this.activityForm.get('currencyOfUnitPrice').setValue(currency);
|
||||
this.activityForm.get('dataSource').setValue(dataSource);
|
||||
if (this.mode === 'create') {
|
||||
this.activityForm.get('currency').setValue(currency);
|
||||
this.activityForm.get('currencyOfUnitPrice').setValue(currency);
|
||||
this.activityForm.get('dataSource').setValue(dataSource);
|
||||
}
|
||||
|
||||
this.currentMarketPrice = marketPrice;
|
||||
|
||||
|
@ -214,11 +214,7 @@
|
||||
}
|
||||
}
|
||||
</mat-label>
|
||||
<input
|
||||
formControlName="unitPriceInCustomCurrency"
|
||||
matInput
|
||||
type="number"
|
||||
/>
|
||||
<input formControlName="unitPrice" matInput type="number" />
|
||||
<div
|
||||
class="ml-2"
|
||||
matTextSuffix
|
||||
@ -232,19 +228,6 @@
|
||||
}
|
||||
</mat-select>
|
||||
</div>
|
||||
@if (
|
||||
activityForm.get('unitPriceInCustomCurrency').hasError('invalid')
|
||||
) {
|
||||
<mat-error
|
||||
><ng-container i18n
|
||||
>Oops! Could not get the historical exchange rate
|
||||
from</ng-container
|
||||
>
|
||||
{{
|
||||
activityForm.get('date')?.value | date: defaultDateFormat
|
||||
}}</mat-error
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
@if (
|
||||
currentMarketPrice &&
|
||||
@ -263,36 +246,6 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label>
|
||||
@switch (activityForm.get('type')?.value) {
|
||||
@case ('DIVIDEND') {
|
||||
<ng-container i18n>Dividend</ng-container>
|
||||
}
|
||||
@case ('FEE') {
|
||||
<ng-container i18n>Value</ng-container>
|
||||
}
|
||||
@case ('INTEREST') {
|
||||
<ng-container i18n>Value</ng-container>
|
||||
}
|
||||
@case ('ITEM') {
|
||||
<ng-container i18n>Value</ng-container>
|
||||
}
|
||||
@case ('LIABILITY') {
|
||||
<ng-container i18n>Value</ng-container>
|
||||
}
|
||||
@default {
|
||||
<ng-container i18n>Unit Price</ng-container>
|
||||
}
|
||||
}
|
||||
</mat-label>
|
||||
<input formControlName="unitPrice" matInput type="number" />
|
||||
<span class="ml-2" matTextSuffix>{{
|
||||
activityForm.get('currency').value
|
||||
}}</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div
|
||||
class="mb-3"
|
||||
[ngClass]="{
|
||||
@ -304,7 +257,7 @@
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input formControlName="feeInCustomCurrency" matInput type="number" />
|
||||
<input formControlName="fee" matInput type="number" />
|
||||
<div
|
||||
class="ml-2"
|
||||
matTextSuffix
|
||||
@ -312,26 +265,6 @@
|
||||
>
|
||||
{{ activityForm.get('currencyOfUnitPrice').value }}
|
||||
</div>
|
||||
@if (activityForm.get('feeInCustomCurrency').hasError('invalid')) {
|
||||
<mat-error
|
||||
><ng-container i18n
|
||||
>Oops! Could not get the historical exchange rate
|
||||
from</ng-container
|
||||
>
|
||||
{{
|
||||
activityForm.get('date')?.value | date: defaultDateFormat
|
||||
}}</mat-error
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input formControlName="fee" matInput type="number" />
|
||||
<span class="ml-2" matTextSuffix>{{
|
||||
activityForm.get('currency').value
|
||||
}}</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@ -392,7 +325,8 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="data.user?.settings?.locale"
|
||||
[unit]="
|
||||
activityForm.get('currency')?.value ?? data.user?.settings?.baseCurrency
|
||||
activityForm.get('currencyOfUnitPrice')?.value ??
|
||||
data.user?.settings?.baseCurrency
|
||||
"
|
||||
[value]="total"
|
||||
/>
|
||||
|
@ -126,6 +126,7 @@ export class ImportActivitiesService {
|
||||
private convertToCreateOrderDto({
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
@ -142,7 +143,7 @@ export class ImportActivitiesService {
|
||||
type,
|
||||
unitPrice,
|
||||
updateAccountBalance,
|
||||
currency: SymbolProfile.currency,
|
||||
currency: currency ?? SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toString(),
|
||||
symbol: SymbolProfile.symbol
|
||||
|
@ -280,7 +280,7 @@
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
{{ element.SymbolProfile?.currency }}
|
||||
{{ element.currency ?? element.SymbolProfile?.currency }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
UPDATE "Order" SET "currency" = NULL;
|
29
test/import/ok-btceur.json
Normal file
29
test/import/ok-btceur.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"meta": {
|
||||
"date": "2021-12-12T00:00:00.000Z",
|
||||
"version": "dev"
|
||||
},
|
||||
"accounts": [],
|
||||
"platforms": [],
|
||||
"tags": [],
|
||||
"activities": [
|
||||
{
|
||||
"accountId": null,
|
||||
"comment": null,
|
||||
"fee": 3.94,
|
||||
"quantity": 1,
|
||||
"type": "BUY",
|
||||
"unitPrice": 39378.5,
|
||||
"currency": "EUR",
|
||||
"dataSource": "YAHOO",
|
||||
"date": "2021-12-12T00:00:00.000Z",
|
||||
"symbol": "BTCUSD",
|
||||
"tags": []
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"settings": {
|
||||
"currency": "USD"
|
||||
}
|
||||
}
|
||||
}
|
29
test/import/ok-btcusd.json
Normal file
29
test/import/ok-btcusd.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"meta": {
|
||||
"date": "2021-12-12T00:00:00.000Z",
|
||||
"version": "dev"
|
||||
},
|
||||
"accounts": [],
|
||||
"platforms": [],
|
||||
"tags": [],
|
||||
"activities": [
|
||||
{
|
||||
"accountId": null,
|
||||
"comment": null,
|
||||
"fee": 4.46,
|
||||
"quantity": 1,
|
||||
"type": "BUY",
|
||||
"unitPrice": 44558.42,
|
||||
"currency": "USD",
|
||||
"dataSource": "YAHOO",
|
||||
"date": "2021-12-12T00:00:00.000Z",
|
||||
"symbol": "BTCUSD",
|
||||
"tags": []
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"settings": {
|
||||
"currency": "USD"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user