Browse Source

Merge 39b19abd4b into 0d58df7ce7

pull/6901/merge
Andrea Bugeja 2 days ago
committed by GitHub
parent
commit
e6bf06d31d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 93
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.spec.ts
  3. 98
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

6
CHANGELOG.md

@ -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
- Improved the performance of the historical currency conversion by loading the market data of each currency pair once per minute instead of once per date
## 3.90.0 - 2026-10-21
### Changed

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

@ -0,0 +1,93 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import { eachDayOfInterval } from 'date-fns';
import ms from 'ms';
import { ExchangeRateDataService } from './exchange-rate-data.service';
describe('ExchangeRateDataService', () => {
let exchangeRateDataService: ExchangeRateDataService;
let getRange: jest.Mock;
beforeEach(() => {
getRange = jest.fn().mockResolvedValue([
{ date: parseDate('2026-01-02'), marketPrice: 0.9 },
{ date: parseDate('2026-01-05'), marketPrice: 0.8 }
]);
exchangeRateDataService = new ExchangeRateDataService(
{
getDataSourceForExchangeRates: () => {
return DataSource.YAHOO;
}
} as unknown as DataProviderService,
{ getRange } as unknown as MarketDataService,
null,
null
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('toCurrencyAtDate', () => {
it('loads the market data of a currency pair once for all dates', async () => {
const dates = eachDayOfInterval({
end: parseDate('2026-01-05'),
start: parseDate('2026-01-02')
});
await Promise.all(
dates.map((date) => {
return exchangeRateDataService.toCurrencyAtDate(
100,
'USD',
'EUR',
date
);
})
);
expect(getRange).toHaveBeenCalledTimes(1);
});
it('converts with the market price of the date', async () => {
await expect(
exchangeRateDataService.toCurrencyAtDate(
100,
'USD',
'EUR',
parseDate('2026-01-05')
)
).resolves.toBeCloseTo(80);
});
it('returns undefined for a date without market data', async () => {
await expect(
exchangeRateDataService.toCurrencyAtDate(
100,
'USD',
'EUR',
parseDate('2026-01-03')
)
).resolves.toBeUndefined();
});
it('reloads the market data after a minute', async () => {
const now = Date.now();
const date = parseDate('2026-01-02');
await exchangeRateDataService.toCurrencyAtDate(100, 'USD', 'EUR', date);
jest.spyOn(Date, 'now').mockReturnValue(now + ms('2 minutes'));
await exchangeRateDataService.toCurrencyAtDate(100, 'USD', 'EUR', date);
expect(getRange).toHaveBeenCalledTimes(2);
});
});
});

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

@ -39,6 +39,10 @@ export class ExchangeRateDataService {
private currencyPairs: DataGatheringItem[] = [];
private derivedCurrencyFactors: { [currencyPair: string]: number } = {};
private exchangeRates: { [currencyPair: string]: number } = {};
private marketPricesBySymbol = new Map<
string,
{ expiresAt: number; marketPrices: Promise<Float64Array> }
>();
public constructor(
private readonly dataProviderService: DataProviderService,
@ -309,18 +313,13 @@ export class ExchangeRateDataService {
} else if (derivedCurrencyFactor) {
factor = derivedCurrencyFactor;
} else {
const dataSource =
this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${aFromCurrency}${aToCurrency}`;
const marketData = await this.marketDataService.get({
dataSource,
symbol,
date: aDate
const marketPrice = await this.getMarketPriceAtDate({
date: aDate,
symbol: `${aFromCurrency}${aToCurrency}`
});
if (marketData?.marketPrice) {
factor = marketData?.marketPrice;
if (marketPrice) {
factor = marketPrice;
} else {
// Calculate indirectly via base currency
@ -331,13 +330,11 @@ export class ExchangeRateDataService {
if (aFromCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyFromCurrency = 1;
} else {
marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({
dataSource,
marketPriceBaseCurrencyFromCurrency =
await this.getMarketPriceAtDate({
date: aDate,
symbol: `${DEFAULT_CURRENCY}${aFromCurrency}`
})
)?.marketPrice;
});
}
} catch {}
@ -345,13 +342,12 @@ export class ExchangeRateDataService {
if (aToCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyToCurrency = 1;
} else {
marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({
dataSource,
marketPriceBaseCurrencyToCurrency = await this.getMarketPriceAtDate(
{
date: aDate,
symbol: `${DEFAULT_CURRENCY}${aToCurrency}`
})
)?.marketPrice;
}
);
}
} catch {}
@ -515,6 +511,68 @@ export class ExchangeRateDataService {
return factors;
}
/**
* Looks up the market price of a currency pair in its full history, which is
* loaded once per minute instead of querying the database for every date
*/
private async getMarketPriceAtDate({
date,
symbol
}: {
date: Date;
symbol: string;
}) {
let item = this.marketPricesBySymbol.get(symbol);
if (!item || item.expiresAt < Date.now()) {
item = {
expiresAt: Date.now() + ms('1 minute'),
marketPrices: this.loadMarketPrices(symbol)
};
this.marketPricesBySymbol.set(symbol, item);
item.marketPrices.catch(() => {
if (this.marketPricesBySymbol.get(symbol) === item) {
this.marketPricesBySymbol.delete(symbol);
}
});
}
const marketPrices = await item.marketPrices;
// Days without market data are 0 or out of bounds
return marketPrices[this.getUtcDaysSinceEpoch(date)] || undefined;
}
private getUtcDaysSinceEpoch(aDate: Date) {
return Math.floor(aDate.getTime() / ms('1 day'));
}
private async loadMarketPrices(aSymbol: string) {
const marketData = await this.marketDataService.getRange({
assetProfileIdentifiers: [
{
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
symbol: aSymbol
}
],
dateQuery: { lt: new Date() }
});
const marketPrices = new Float64Array(
marketData.length > 0
? this.getUtcDaysSinceEpoch(marketData.at(-1).date) + 1
: 0
);
for (const { date, marketPrice } of marketData) {
marketPrices[this.getUtcDaysSinceEpoch(date)] = marketPrice;
}
return marketPrices;
}
private async prepareCurrencies(): Promise<string[]> {
let currencies: string[] = [DEFAULT_CURRENCY];

Loading…
Cancel
Save