Compare commits
1 Commits
main
...
feature/da
Author | SHA1 | Date | |
---|---|---|---|
|
a91f6c783e |
@ -2,8 +2,10 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { DateQuery } from './interfaces/date-query.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -57,6 +59,26 @@ jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
|||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
initialize: () => Promise.resolve(),
|
initialize: () => Promise.resolve(),
|
||||||
|
getExchangeRates: ({
|
||||||
|
dateQuery,
|
||||||
|
sourceCurrencies,
|
||||||
|
destinationCurrency
|
||||||
|
}: {
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
sourceCurrencies: string[];
|
||||||
|
destinationCurrency: string;
|
||||||
|
}) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
exchangeRates: {
|
||||||
|
USD: new Big(1),
|
||||||
|
CHF: new Big(1),
|
||||||
|
EUR: new Big(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
toCurrency: (value: number) => {
|
toCurrency: (value: number) => {
|
||||||
return 1 * value;
|
return 1 * value;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { Big } from 'big.js';
|
||||||
|
import { format, isAfter, isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
@ -77,6 +78,13 @@ export class CurrentRateService {
|
|||||||
}[]
|
}[]
|
||||||
>[] = [];
|
>[] = [];
|
||||||
|
|
||||||
|
const sourceCurrencies = Object.values(currencies);
|
||||||
|
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery,
|
||||||
|
sourceCurrencies,
|
||||||
|
destinationCurrency: userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
promises.push(
|
promises.push(
|
||||||
@ -112,17 +120,59 @@ export class CurrentRateService {
|
|||||||
symbols
|
symbols
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data.map((marketDataItem) => {
|
const result = [];
|
||||||
return {
|
let j = 0;
|
||||||
date: marketDataItem.date,
|
for (const marketDataItem of data) {
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
const currency = currencies[marketDataItem.symbol];
|
||||||
|
while (
|
||||||
|
j + 1 < exchangeRates.length &&
|
||||||
|
!isAfter(exchangeRates[j + 1].date, marketDataItem.date)
|
||||||
|
) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
let exchangeRate: Big;
|
||||||
|
if (currency !== userCurrency) {
|
||||||
|
exchangeRate = exchangeRates[j]?.exchangeRates[currency];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let k = j;
|
||||||
|
k >= 0 && !exchangeRates[k]?.exchangeRates[currency];
|
||||||
|
k--
|
||||||
|
) {
|
||||||
|
exchangeRate = exchangeRates[k]?.exchangeRates[currency];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exchangeRate = new Big(1);
|
||||||
|
}
|
||||||
|
let marketPrice: number;
|
||||||
|
if (exchangeRate) {
|
||||||
|
marketPrice = exchangeRate
|
||||||
|
.mul(marketDataItem.marketPrice)
|
||||||
|
.toNumber();
|
||||||
|
} else {
|
||||||
|
if (!isToday(marketDataItem.date)) {
|
||||||
|
Logger.error(
|
||||||
|
`Failed to get exchange rate for ${
|
||||||
|
currencies[marketDataItem.symbol]
|
||||||
|
} to ${userCurrency} at ${format(
|
||||||
|
marketDataItem.date,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}, using today's exchange rate as a fallback`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
marketPrice = this.exchangeRateDataService.toCurrency(
|
||||||
marketDataItem.marketPrice,
|
marketDataItem.marketPrice,
|
||||||
currencies[marketDataItem.symbol],
|
currencies[marketDataItem.symbol],
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
date: marketDataItem.date,
|
||||||
|
marketPrice: marketPrice,
|
||||||
symbol: marketDataItem.symbol
|
symbol: marketDataItem.symbol
|
||||||
};
|
});
|
||||||
});
|
}
|
||||||
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ import {
|
|||||||
baseCurrency,
|
baseCurrency,
|
||||||
ghostfolioCashSymbol
|
ghostfolioCashSymbol
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Accounts,
|
Accounts,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -43,7 +43,7 @@ import type {
|
|||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -52,6 +52,7 @@ import {
|
|||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
isBefore,
|
isBefore,
|
||||||
|
isToday,
|
||||||
max,
|
max,
|
||||||
parse,
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
@ -735,20 +736,24 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
public getFees(
|
||||||
|
orders: OrderWithAccount[],
|
||||||
|
exchangeRates: { [date: string]: { [currency: string]: Big } },
|
||||||
|
date = new Date(0)
|
||||||
|
) {
|
||||||
return orders
|
return orders
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
// Filter out all orders before given date
|
// Filter out all orders before given date
|
||||||
return isBefore(date, new Date(order.date));
|
return isBefore(date, new Date(order.date));
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map((order) =>
|
||||||
return this.exchangeRateDataService.toCurrency(
|
this.convertCurrency({
|
||||||
order.fee,
|
exchangeRates,
|
||||||
order.currency,
|
...order,
|
||||||
this.request.user.Settings.currency
|
value: order.fee
|
||||||
);
|
})
|
||||||
})
|
)
|
||||||
.reduce((previous, current) => previous + current, 0);
|
.reduce((previous, current) => current.plus(previous), new Big(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
@ -786,6 +791,7 @@ export class PortfolioService {
|
|||||||
currency,
|
currency,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
const exchangeRates = await this.exchangeRateForOrders(orders);
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
@ -831,7 +837,7 @@ export class PortfolioService {
|
|||||||
new FeeRatioInitialInvestment(
|
new FeeRatioInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions.totalInvestment.toNumber(),
|
currentPositions.totalInvestment.toNumber(),
|
||||||
this.getFees(orders)
|
this.getFees(orders, exchangeRates).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency: currency }
|
{ baseCurrency: currency }
|
||||||
@ -851,11 +857,20 @@ export class PortfolioService {
|
|||||||
currency
|
currency
|
||||||
);
|
);
|
||||||
const orders = await this.orderService.getOrders({ userId });
|
const orders = await this.orderService.getOrders({ userId });
|
||||||
const fees = this.getFees(orders);
|
const exchangeRates = await this.exchangeRateForOrders(orders);
|
||||||
|
const fees = this.getFees(orders, exchangeRates);
|
||||||
const firstOrderDate = orders[0]?.date;
|
const firstOrderDate = orders[0]?.date;
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
const totalBuy = this.getTotalByType(
|
||||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
orders,
|
||||||
|
TypeOfOrder.BUY,
|
||||||
|
exchangeRates
|
||||||
|
);
|
||||||
|
const totalSell = this.getTotalByType(
|
||||||
|
orders,
|
||||||
|
TypeOfOrder.SELL,
|
||||||
|
exchangeRates
|
||||||
|
);
|
||||||
|
|
||||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||||
|
|
||||||
@ -865,14 +880,14 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...performanceInformation.performance,
|
...performanceInformation.performance,
|
||||||
fees,
|
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
netWorth,
|
netWorth,
|
||||||
cash: balance,
|
cash: balance,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
|
fees: fees.toNumber(),
|
||||||
ordersCount: orders.length,
|
ordersCount: orders.length,
|
||||||
totalBuy: totalBuy,
|
totalBuy: totalBuy.toNumber(),
|
||||||
totalSell: totalSell
|
totalSell: totalSell.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -980,28 +995,25 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
const exchangeRates = await this.exchangeRateForOrders(orders);
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
dataSource: order.dataSource,
|
dataSource: order.dataSource,
|
||||||
date: format(order.date, DATE_FORMAT),
|
date: format(order.date, DATE_FORMAT),
|
||||||
fee: new Big(
|
fee: this.convertCurrency({
|
||||||
this.exchangeRateDataService.toCurrency(
|
exchangeRates,
|
||||||
order.fee,
|
...order,
|
||||||
order.currency,
|
value: order.fee
|
||||||
userCurrency
|
}),
|
||||||
)
|
|
||||||
),
|
|
||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
type: <OrderType>order.type,
|
type: <OrderType>order.type,
|
||||||
unitPrice: new Big(
|
unitPrice: this.convertCurrency({
|
||||||
this.exchangeRateDataService.toCurrency(
|
exchangeRates,
|
||||||
order.unitPrice,
|
...order,
|
||||||
order.currency,
|
value: order.unitPrice
|
||||||
userCurrency
|
})
|
||||||
)
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
@ -1071,6 +1083,60 @@ export class PortfolioService {
|
|||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private convertCurrency({
|
||||||
|
exchangeRates,
|
||||||
|
date,
|
||||||
|
currency,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
exchangeRates: { [date: string]: { [currency: string]: Big } };
|
||||||
|
date: Date;
|
||||||
|
currency: string;
|
||||||
|
value: number | Big;
|
||||||
|
}): Big {
|
||||||
|
const exchangeRate = exchangeRates[format(date, DATE_FORMAT)]?.[currency];
|
||||||
|
if (exchangeRate) {
|
||||||
|
return exchangeRate?.mul(value);
|
||||||
|
}
|
||||||
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
if (!isToday(date)) {
|
||||||
|
Logger.error(
|
||||||
|
`Failed to convert value for date ${format(
|
||||||
|
date,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} from ${currency} to ${userCurrency}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Big(
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(value).toNumber(),
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeRateForOrders(
|
||||||
|
orders: OrderWithAccount[]
|
||||||
|
): Promise<{ [date: string]: { [currency: string]: Big } }> {
|
||||||
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
|
||||||
|
const dates = orders.map((order) => resetHours(order.date));
|
||||||
|
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
sourceCurrencies: orders.map((order) => order.currency),
|
||||||
|
destinationCurrency: userCurrency
|
||||||
|
});
|
||||||
|
const exchangeRateLookupMap = {};
|
||||||
|
for (const exchangeRate of exchangeRates) {
|
||||||
|
exchangeRateLookupMap[format(exchangeRate.date, DATE_FORMAT)] =
|
||||||
|
exchangeRate.exchangeRates;
|
||||||
|
}
|
||||||
|
return exchangeRateLookupMap;
|
||||||
|
}
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
@ -1083,20 +1149,20 @@ export class PortfolioService {
|
|||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
orders: OrderWithAccount[],
|
orders: OrderWithAccount[],
|
||||||
currency: string,
|
type: TypeOfOrder,
|
||||||
type: TypeOfOrder
|
exchangeRates: { [date: string]: { [currency: string]: Big } }
|
||||||
) {
|
) {
|
||||||
return orders
|
return orders
|
||||||
.filter(
|
.filter(
|
||||||
(order) => !isAfter(order.date, endOfToday()) && order.type === type
|
(order) => !isAfter(order.date, endOfToday()) && order.type === type
|
||||||
)
|
)
|
||||||
.map((order) => {
|
.map((order) =>
|
||||||
return this.exchangeRateDataService.toCurrency(
|
this.convertCurrency({
|
||||||
order.quantity * order.unitPrice,
|
exchangeRates,
|
||||||
order.currency,
|
...order,
|
||||||
currency
|
value: order.quantity * order.unitPrice
|
||||||
);
|
})
|
||||||
})
|
)
|
||||||
.reduce((previous, current) => previous + current, 0);
|
.reduce((previous, current) => current.plus(previous), new Big(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PrismaModule } from './prisma.module';
|
import { PrismaModule } from './prisma.module';
|
||||||
@ -7,7 +8,7 @@ import { PropertyModule } from './property/property.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DataProviderModule, PrismaModule, PropertyModule],
|
imports: [DataProviderModule, PrismaModule, PropertyModule],
|
||||||
providers: [ExchangeRateDataService],
|
providers: [ExchangeRateDataService, MarketDataService],
|
||||||
exports: [ExchangeRateDataService]
|
exports: [ExchangeRateDataService]
|
||||||
})
|
})
|
||||||
export class ExchangeRateDataModule {}
|
export class ExchangeRateDataModule {}
|
||||||
|
201
apps/api/src/services/exchange-rate-data.service.spec.ts
Normal file
201
apps/api/src/services/exchange-rate-data.service.spec.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { MarketData } from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { addDays, endOfDay, isBefore } from 'date-fns';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
|
return {
|
||||||
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
getRange: ({
|
||||||
|
dateQuery,
|
||||||
|
symbols
|
||||||
|
}: {
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
symbols: string[];
|
||||||
|
}) => {
|
||||||
|
const exchangeRateMap = {
|
||||||
|
USDEUR: 1,
|
||||||
|
USDCHF: 2,
|
||||||
|
USDUSD: 0
|
||||||
|
};
|
||||||
|
const result = [];
|
||||||
|
let j = 1;
|
||||||
|
for (
|
||||||
|
let i = dateQuery.gte;
|
||||||
|
isBefore(i, dateQuery.lt);
|
||||||
|
i = addDays(i, 1)
|
||||||
|
) {
|
||||||
|
const marketPrice = j++;
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
result.push({
|
||||||
|
createdAt: i,
|
||||||
|
date: i,
|
||||||
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
|
marketPrice: marketPrice * exchangeRateMap[symbol] + 1,
|
||||||
|
symbol: symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve<MarketData[]>(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExchangeRateDataService', () => {
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let marketDataService: MarketDataService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
marketDataService = new MarketDataService(null);
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
marketDataService,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExchangeRates', () => {
|
||||||
|
it('source and destination USD', async () => {
|
||||||
|
const startDate = new Date(2021, 0, 1);
|
||||||
|
const exchangeRates = await exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(startDate)
|
||||||
|
},
|
||||||
|
sourceCurrencies: ['USD'],
|
||||||
|
destinationCurrency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exchangeRates).toEqual([
|
||||||
|
{
|
||||||
|
date: startDate,
|
||||||
|
exchangeRates: {
|
||||||
|
USD: new Big(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source USD and destination CHF', async () => {
|
||||||
|
const startDate = new Date(2021, 0, 1);
|
||||||
|
const exchangeRates = await exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(startDate)
|
||||||
|
},
|
||||||
|
sourceCurrencies: ['USD'],
|
||||||
|
destinationCurrency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exchangeRates).toEqual([
|
||||||
|
{
|
||||||
|
date: startDate,
|
||||||
|
exchangeRates: {
|
||||||
|
USD: new Big(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source CHF and destination USD', async () => {
|
||||||
|
const startDate = new Date(2021, 0, 1);
|
||||||
|
const exchangeRates = await exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(startDate)
|
||||||
|
},
|
||||||
|
sourceCurrencies: ['CHF'],
|
||||||
|
destinationCurrency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exchangeRates).toEqual([
|
||||||
|
{
|
||||||
|
date: startDate,
|
||||||
|
exchangeRates: {
|
||||||
|
CHF: new Big(1).div(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source CHF and destination EUR', async () => {
|
||||||
|
const startDate = new Date(2021, 0, 1);
|
||||||
|
const exchangeRates = await exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(startDate)
|
||||||
|
},
|
||||||
|
sourceCurrencies: ['CHF'],
|
||||||
|
destinationCurrency: 'EUR'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exchangeRates).toEqual([
|
||||||
|
{
|
||||||
|
date: startDate,
|
||||||
|
exchangeRates: {
|
||||||
|
CHF: new Big(2).div(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source CHF,EUR,USD and destination EUR', async () => {
|
||||||
|
const startDate = new Date(2021, 0, 1);
|
||||||
|
const exchangeRates = await exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(startDate)
|
||||||
|
},
|
||||||
|
sourceCurrencies: ['CHF', 'USD', 'EUR'],
|
||||||
|
destinationCurrency: 'EUR'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exchangeRates).toEqual([
|
||||||
|
{
|
||||||
|
date: startDate,
|
||||||
|
exchangeRates: {
|
||||||
|
CHF: new Big(2).div(3),
|
||||||
|
USD: new Big(2),
|
||||||
|
EUR: new Big(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with multiple days', async () => {
|
||||||
|
const startDate = new Date(2021, 0, 1);
|
||||||
|
const exchangeRates = await exchangeRateDataService.getExchangeRates({
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(addDays(startDate, 1))
|
||||||
|
},
|
||||||
|
sourceCurrencies: ['CHF', 'USD', 'EUR'],
|
||||||
|
destinationCurrency: 'EUR'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exchangeRates).toEqual([
|
||||||
|
{
|
||||||
|
date: startDate,
|
||||||
|
exchangeRates: {
|
||||||
|
CHF: new Big(2).div(3),
|
||||||
|
USD: new Big(2),
|
||||||
|
EUR: new Big(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: addDays(startDate, 1),
|
||||||
|
exchangeRates: {
|
||||||
|
CHF: new Big(3).div(5),
|
||||||
|
USD: new Big(3),
|
||||||
|
EUR: new Big(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,7 +1,11 @@
|
|||||||
|
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||||
|
import { DateBasedExchangeRate } from '@ghostfolio/api/services/interfaces/date-based-exchange-rate.interface';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format } from 'date-fns';
|
import Big from 'big.js';
|
||||||
|
import { format, isSameDay } from 'date-fns';
|
||||||
import { isEmpty, isNumber, uniq } from 'lodash';
|
import { isEmpty, isNumber, uniq } from 'lodash';
|
||||||
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
@ -17,6 +21,7 @@ export class ExchangeRateDataService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService
|
||||||
) {
|
) {
|
||||||
@ -31,6 +36,55 @@ export class ExchangeRateDataService {
|
|||||||
return this.currencyPairs;
|
return this.currencyPairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getExchangeRates({
|
||||||
|
dateQuery,
|
||||||
|
sourceCurrencies,
|
||||||
|
destinationCurrency
|
||||||
|
}: {
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
sourceCurrencies: string[];
|
||||||
|
destinationCurrency: string;
|
||||||
|
}): Promise<DateBasedExchangeRate[]> {
|
||||||
|
const symbols = [...sourceCurrencies, destinationCurrency]
|
||||||
|
.map((currency) => `${baseCurrency}${currency}`)
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i);
|
||||||
|
const exchangeRates = await this.marketDataService.getRange({
|
||||||
|
dateQuery,
|
||||||
|
symbols
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exchangeRates.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const results: DateBasedExchangeRate[] = [];
|
||||||
|
let currentDate = exchangeRates[0].date;
|
||||||
|
let currentRates: { [symbol: string]: Big } = {};
|
||||||
|
for (const exchangeRate of exchangeRates) {
|
||||||
|
if (!isSameDay(currentDate, exchangeRate.date)) {
|
||||||
|
results.push({
|
||||||
|
date: currentDate,
|
||||||
|
exchangeRates: this.getUserExchangeRates(
|
||||||
|
currentRates,
|
||||||
|
destinationCurrency,
|
||||||
|
sourceCurrencies
|
||||||
|
)
|
||||||
|
});
|
||||||
|
currentDate = exchangeRate.date;
|
||||||
|
currentRates = {};
|
||||||
|
}
|
||||||
|
currentRates[exchangeRate.symbol] = new Big(exchangeRate.marketPrice);
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
date: currentDate,
|
||||||
|
exchangeRates: this.getUserExchangeRates(
|
||||||
|
currentRates,
|
||||||
|
destinationCurrency,
|
||||||
|
sourceCurrencies
|
||||||
|
)
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
public async initialize() {
|
public async initialize() {
|
||||||
this.currencies = await this.prepareCurrencies();
|
this.currencies = await this.prepareCurrencies();
|
||||||
this.currencyPairs = [];
|
this.currencyPairs = [];
|
||||||
@ -97,10 +151,10 @@ export class ExchangeRateDataService {
|
|||||||
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
|
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
|
||||||
|
|
||||||
if (!this.exchangeRates[symbol]) {
|
if (!this.exchangeRates[symbol]) {
|
||||||
// Not found, calculate indirectly via USD
|
// Not found, calculate indirectly via base currency
|
||||||
this.exchangeRates[symbol] =
|
this.exchangeRates[symbol] =
|
||||||
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
|
resultExtended[`${currency1}${baseCurrency}`]?.[date]?.marketPrice *
|
||||||
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
|
resultExtended[`${baseCurrency}${currency2}`]?.[date]?.marketPrice;
|
||||||
|
|
||||||
// Calculate the opposite direction
|
// Calculate the opposite direction
|
||||||
this.exchangeRates[`${currency2}${currency1}`] =
|
this.exchangeRates[`${currency2}${currency1}`] =
|
||||||
@ -129,9 +183,9 @@ export class ExchangeRateDataService {
|
|||||||
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
||||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||||
} else {
|
} else {
|
||||||
// Calculate indirectly via USD
|
// Calculate indirectly via base currency
|
||||||
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
|
const factor1 = this.exchangeRates[`${aFromCurrency}${baseCurrency}`];
|
||||||
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
|
const factor2 = this.exchangeRates[`${baseCurrency}${aToCurrency}`];
|
||||||
|
|
||||||
factor = factor1 * factor2;
|
factor = factor1 * factor2;
|
||||||
|
|
||||||
@ -194,6 +248,46 @@ export class ExchangeRateDataService {
|
|||||||
return uniq(currencies).sort();
|
return uniq(currencies).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUserExchangeRates(
|
||||||
|
currentRates: { [symbol: string]: Big },
|
||||||
|
destinationCurrency: string,
|
||||||
|
sourceCurrencies: string[]
|
||||||
|
): { [currency: string]: Big } {
|
||||||
|
const result: { [currency: string]: Big } = {};
|
||||||
|
|
||||||
|
for (const sourceCurrency of sourceCurrencies) {
|
||||||
|
let exchangeRate: Big;
|
||||||
|
if (sourceCurrency === destinationCurrency) {
|
||||||
|
exchangeRate = new Big(1);
|
||||||
|
} else if (
|
||||||
|
destinationCurrency === baseCurrency &&
|
||||||
|
currentRates[`${destinationCurrency}${sourceCurrency}`]
|
||||||
|
) {
|
||||||
|
exchangeRate = new Big(1).div(
|
||||||
|
currentRates[`${destinationCurrency}${sourceCurrency}`]
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
sourceCurrency === baseCurrency &&
|
||||||
|
currentRates[`${sourceCurrency}${destinationCurrency}`]
|
||||||
|
) {
|
||||||
|
exchangeRate = currentRates[`${sourceCurrency}${destinationCurrency}`];
|
||||||
|
} else if (
|
||||||
|
currentRates[`${baseCurrency}${destinationCurrency}`] &&
|
||||||
|
currentRates[`${baseCurrency}${sourceCurrency}`]
|
||||||
|
) {
|
||||||
|
exchangeRate = currentRates[
|
||||||
|
`${baseCurrency}${destinationCurrency}`
|
||||||
|
].div(currentRates[`${baseCurrency}${sourceCurrency}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchangeRate) {
|
||||||
|
result[sourceCurrency] = exchangeRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private prepareCurrencyPairs(aCurrencies: string[]) {
|
private prepareCurrencyPairs(aCurrencies: string[]) {
|
||||||
return aCurrencies
|
return aCurrencies
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface DateBasedExchangeRate {
|
||||||
|
date: Date;
|
||||||
|
exchangeRates: { [currency: string]: Big };
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user