Browse Source

Feature/add FXMacroData data provider for exchange rates

Every multi-currency portfolio needs exchange rates, and the provider
supplying them is configurable through DATA_SOURCE_EXCHANGE_RATES. This adds
an option backed by official reference rates published by central banks and
statistical authorities rather than scraped from a market data site.

The provider handles currency pairs only, so any other symbol is left to the
other providers. The covered currencies are discovered from the API rather
than hardcoded: a pair is available whenever both of its currencies are
covered, because the API stores each pair in one direction only and derives
the inverse or the cross itself. That means the provider asks for the
direction it wants instead of inverting a rate locally, and search can offer
a cross the API derives but does not store.

A rate is documented as anyOf[number, null], so a missing figure is skipped
rather than recorded as a zero market price, and one unavailable pair does
not lose the quotes of the others. Historical ranges are walked page by page
because the endpoint caps a page at 100 rows.

The API key is read from API_KEY_FXMACRODATA and sent as a header, so it is
not written to request logs or a proxy's access log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/7865/head
Robert Tidball 2 weeks ago
parent
commit
acf5f19b6e
  1. 1
      CHANGELOG.md
  2. 1
      apps/api/src/services/configuration/configuration.service.ts
  3. 5
      apps/api/src/services/data-provider/data-provider.module.ts
  4. 290
      apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts
  5. 326
      apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts
  6. 15
      apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts
  7. 1
      apps/api/src/services/interfaces/environment.interface.ts
  8. 2
      prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql
  9. 1
      prisma/schema.prisma

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a tool to search for asset profiles to the server of the Model Context Protocol (MCP) (experimental) - Added a tool to search for asset profiles to the server of the Model Context Protocol (MCP) (experimental)
- Added a data provider for official foreign exchange reference rates (FXMacroData)
### Changed ### Changed

1
apps/api/src/services/configuration/configuration.service.ts

@ -51,6 +51,7 @@ export class ConfigurationService {
API_KEY_COINGECKO_PRO: str({ default: '' }), API_KEY_COINGECKO_PRO: str({ default: '' }),
API_KEY_EOD_HISTORICAL_DATA: str({ default: '' }), API_KEY_EOD_HISTORICAL_DATA: str({ default: '' }),
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_FXMACRODATA: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),

5
apps/api/src/services/data-provider/data-provider.module.ts

@ -5,6 +5,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { FXMacroDataService } from '@ghostfolio/api/services/data-provider/fxmacrodata/fxmacrodata.service';
import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
@ -40,6 +41,7 @@ import { DataProviderService } from './data-provider.service';
DataProviderService, DataProviderService,
EodHistoricalDataService, EodHistoricalDataService,
FinancialModelingPrepService, FinancialModelingPrepService,
FXMacroDataService,
GhostfolioService, GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -51,6 +53,7 @@ import { DataProviderService } from './data-provider.service';
CoinGeckoService, CoinGeckoService,
EodHistoricalDataService, EodHistoricalDataService,
FinancialModelingPrepService, FinancialModelingPrepService,
FXMacroDataService,
GhostfolioService, GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -63,6 +66,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService, coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
financialModelingPrepService, financialModelingPrepService,
fxMacroDataService,
ghostfolioService, ghostfolioService,
googleSheetsService, googleSheetsService,
manualService, manualService,
@ -73,6 +77,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService, coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
financialModelingPrepService, financialModelingPrepService,
fxMacroDataService,
ghostfolioService, ghostfolioService,
googleSheetsService, googleSheetsService,
manualService, manualService,

290
apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts

@ -0,0 +1,290 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { DataSource } from '@prisma/client';
import { FXMacroDataService } from './fxmacrodata.service';
// FetchService pulls in @openrouter/ai-sdk-provider, which ships ESM only and
// is not transformed for Jest. This suite injects its own stub, so the real
// implementation is never needed here.
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => {
return { FetchService: class {} };
});
const SOURCES_RESPONSE = {
sources: [{ served_pairs: ['EUR/USD', 'USD/JPY', 'GBP/USD'] }]
};
describe('FXMacroDataService', () => {
let configurationService: ConfigurationService;
let fetchService: FetchService;
let fxMacroDataService: FXMacroDataService;
let responses: { [url: string]: unknown };
const jsonResponse = (body: unknown) => {
return Promise.resolve({
ok: true,
json: () => {
return Promise.resolve(body);
}
});
};
beforeEach(() => {
responses = { 'fx/sources': SOURCES_RESPONSE };
configurationService = {
get: jest.fn((key: string) => {
return key === 'API_KEY_FXMACRODATA' ? 'test-key' : 30_000;
})
} as unknown as ConfigurationService;
fetchService = {
fetch: jest.fn((url: string) => {
const match = Object.keys(responses).find((path) => {
return url.includes(path);
});
if (!match) {
return Promise.resolve({ ok: false, status: 404 });
}
const body = responses[match];
if (body instanceof Error) {
return Promise.reject(body);
}
return jsonResponse(body);
})
} as unknown as FetchService;
fxMacroDataService = new FXMacroDataService(
configurationService,
fetchService
);
});
describe('canHandle', () => {
it('handles currency pairs', () => {
expect(fxMacroDataService.canHandle('EURUSD')).toBe(true);
});
it('leaves non-currency symbols to the other providers', () => {
expect(fxMacroDataService.canHandle('AAPL')).toBe(false);
});
it('is disabled without an API key', () => {
configurationService.get = jest.fn(() => {
return '';
}) as unknown as ConfigurationService['get'];
expect(fxMacroDataService.canHandle('EURUSD')).toBe(false);
});
});
describe('getName', () => {
it('reports its data source', () => {
expect(fxMacroDataService.getName()).toEqual(DataSource.FXMACRODATA);
});
});
describe('getQuotes', () => {
it('returns the latest rate of each requested pair', async () => {
responses['forex/eur/usd'] = {
data: [{ date: '2026-09-10', val: 1.16 }]
};
responses['forex/gbp/usd'] = {
data: [{ date: '2026-09-10', val: 1.35 }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['EURUSD', 'GBPUSD']
});
expect(quotes['EURUSD']).toEqual({
currency: 'USD',
dataSource: DataSource.FXMACRODATA,
marketPrice: 1.16,
marketState: 'open'
});
expect(quotes['GBPUSD'].marketPrice).toEqual(1.35);
});
it('asks for a pair in the direction wanted rather than inverting locally', async () => {
// FXMacroData stores USD/JPY, not JPY/USD, and derives the inverse
// itself, so the request must be for the wanted direction.
responses['forex/jpy/usd'] = {
data: [{ date: '2026-09-10', val: 0.00678 }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['JPYUSD']
});
expect(quotes['JPYUSD'].marketPrice).toEqual(0.00678);
expect(fetchService.fetch).toHaveBeenCalledWith(
expect.stringContaining('forex/jpy/usd'),
expect.anything()
);
});
it('sends the API key as a header, never in the URL', async () => {
responses['forex/eur/usd'] = {
data: [{ date: '2026-09-10', val: 1.16 }]
};
await fxMacroDataService.getQuotes({ symbols: ['EURUSD'] });
const [url, init] = (fetchService.fetch as jest.Mock).mock.calls[0] as [
string,
{ headers: Record<string, string> }
];
expect(url).not.toContain('test-key');
expect(init.headers['X-API-Key']).toEqual('test-key');
});
it('drops a null rate rather than publishing it as zero', async () => {
responses['forex/eur/usd'] = {
data: [{ date: '2026-09-10', val: null }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['EURUSD']
});
expect(quotes).toEqual({});
});
it('does not lose the other pairs when one fails', async () => {
responses['forex/eur/usd'] = new Error('unavailable');
responses['forex/gbp/usd'] = {
data: [{ date: '2026-09-10', val: 1.35 }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['EURUSD', 'GBPUSD']
});
expect(Object.keys(quotes)).toEqual(['GBPUSD']);
});
it('ignores a symbol that is not a currency pair', async () => {
const quotes = await fxMacroDataService.getQuotes({ symbols: ['AAPL'] });
expect(quotes).toEqual({});
expect(fetchService.fetch).not.toHaveBeenCalled();
});
it('makes no request for an empty symbol list', async () => {
const quotes = await fxMacroDataService.getQuotes({ symbols: [] });
expect(quotes).toEqual({});
expect(fetchService.fetch).not.toHaveBeenCalled();
});
});
describe('getHistorical', () => {
it('returns a market price per date', async () => {
responses['forex/eur/usd'] = {
data: [
{ date: '2026-09-09', val: 1.15 },
{ date: '2026-09-10', val: 1.16 }
]
};
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-09'),
symbol: 'EURUSD',
to: new Date('2026-09-10')
});
expect(historical).toEqual({
'2026-09-09': { marketPrice: 1.15 },
'2026-09-10': { marketPrice: 1.16 }
});
});
it('skips a date whose rate is null', async () => {
responses['forex/eur/usd'] = {
data: [
{ date: '2026-09-09', val: null },
{ date: '2026-09-10', val: 1.16 }
]
};
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-09'),
symbol: 'EURUSD',
to: new Date('2026-09-10')
});
expect(historical).toEqual({ '2026-09-10': { marketPrice: 1.16 } });
});
it('returns nothing for a symbol that is not a currency pair', async () => {
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-09'),
symbol: 'AAPL',
to: new Date('2026-09-10')
});
expect(historical).toEqual({});
expect(fetchService.fetch).not.toHaveBeenCalled();
});
});
describe('search', () => {
it('offers pairs built from the covered currencies', async () => {
const { items } = await fxMacroDataService.search({ query: 'eurus' });
expect(items).toEqual([
expect.objectContaining({
currency: 'USD',
dataSource: DataSource.FXMACRODATA,
name: 'EUR/USD',
symbol: 'EURUSD'
})
]);
});
it('offers a cross the API derives but does not store', async () => {
// GBP/JPY is in neither served_pairs entry, but both currencies are
// covered, so the API serves the cross.
const { items } = await fxMacroDataService.search({ query: 'GBPJPY' });
expect(items.map(({ symbol }) => symbol)).toEqual(['GBPJPY']);
});
it('never offers a currency against itself', async () => {
const { items } = await fxMacroDataService.search({ query: 'USD' });
expect(items.map(({ symbol }) => symbol)).not.toContain('USDUSD');
});
it('returns nothing for an empty query', async () => {
const { items } = await fxMacroDataService.search({ query: ' ' });
expect(items).toEqual([]);
});
});
describe('getAssetProfile', () => {
it('describes the pair as liquidity in the quote currency', async () => {
const profile = await fxMacroDataService.getAssetProfile({
symbol: 'EURUSD'
});
expect(profile).toEqual(
expect.objectContaining({
currency: 'USD',
dataSource: DataSource.FXMACRODATA,
name: 'EUR/USD',
symbol: 'EURUSD'
})
);
});
});
});

326
apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts

@ -0,0 +1,326 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrencySymbol } from '@ghostfolio/common/helper';
import {
DataProviderHistoricalResponse,
DataProviderInfo,
DataProviderResponse,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
import {
FXMacroDataForexResponse,
FXMacroDataSourcesResponse
} from './interfaces/interfaces';
@Injectable()
export class FXMacroDataService implements DataProviderInterface {
private readonly baseUrl = 'https://api.fxmacrodata.com/v1';
private readonly logger = new Logger(FXMacroDataService.name);
private currencies: string[];
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
/**
* FXMacroData publishes official reference rates, so this provider handles
* currency pairs only. Any other symbol is left to the other providers.
*/
public canHandle(symbol?: string) {
if (!this.configurationService.get('API_KEY_FXMACRODATA')) {
return false;
}
return symbol ? isCurrencySymbol(symbol) : true;
}
public async getAssetProfile({
symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const { base, quote } = this.splitCurrencyPair(symbol);
if (!base || !quote) {
return { symbol, dataSource: this.getName() };
}
return {
symbol,
assetClass: AssetClass.LIQUIDITY,
currency: quote,
dataSource: this.getName(),
name: `${base}/${quote}`
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
dataSource: DataSource.FXMACRODATA,
isPremium: true,
name: 'FXMacroData',
url: 'https://fxmacrodata.com'
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
public async getHistorical({
from,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetHistoricalParams): Promise<{
[date: string]: DataProviderHistoricalResponse;
}> {
const { base, quote } = this.splitCurrencyPair(symbol);
if (!base || !quote) {
return {};
}
try {
const response: { [date: string]: DataProviderHistoricalResponse } = {};
// The endpoint caps a page at 100 rows and orders most-recent-first, so
// longer ranges are walked page by page rather than silently truncated.
for (let page = 1; ; page++) {
const { data } = await this.get<FXMacroDataForexResponse>({
requestTimeout,
path: `forex/${base.toLowerCase()}/${quote.toLowerCase()}`,
searchParams: {
end_date: format(to, DATE_FORMAT),
limit: '100',
page: page.toString(),
start_date: format(from, DATE_FORMAT)
}
});
if (!data?.length) {
break;
}
for (const { date, val } of data) {
// val is documented as anyOf[number, null]; a missing rate must not
// become a zero market price.
if (date && typeof val === 'number') {
response[date] = { marketPrice: val };
}
}
if (data.length < 100) {
break;
}
}
return response;
} catch (error) {
throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.FXMACRODATA;
}
public async getQuotes({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> {
const response: { [symbol: string]: DataProviderResponse } = {};
if (!symbols.length) {
return response;
}
const results = await Promise.all(
symbols.map(async (symbol) => {
const { base, quote } = this.splitCurrencyPair(symbol);
if (!base || !quote) {
return undefined;
}
try {
const { data } = await this.get<FXMacroDataForexResponse>({
requestTimeout,
path: `forex/${base.toLowerCase()}/${quote.toLowerCase()}`,
searchParams: { limit: '1' }
});
const [latest] = data ?? [];
if (typeof latest?.val !== 'number') {
return undefined;
}
return { currency: quote, marketPrice: latest.val, symbol };
} catch (error) {
// One unavailable pair must not lose the rates of the others.
this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
);
return undefined;
}
})
);
for (const result of results) {
if (result) {
response[result.symbol] = {
currency: result.currency,
dataSource: this.getName(),
marketPrice: result.marketPrice,
marketState: 'open'
};
}
}
return response;
}
public getTestSymbol() {
return `EUR${DEFAULT_CURRENCY}`;
}
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
const currencies = await this.getCoveredCurrencies();
const normalizedQuery = query.trim().toUpperCase().replace('/', '');
if (!normalizedQuery) {
return { items: [] };
}
const items = currencies
.flatMap((base) => {
return currencies
.filter((quote) => {
return quote !== base;
})
.map((quote) => {
return { base, quote, symbol: `${base}${quote}` };
});
})
.filter(({ symbol }) => {
return symbol.includes(normalizedQuery);
})
.map(({ base, quote, symbol }) => {
return {
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: undefined,
currency: quote,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: `${base}/${quote}`
};
});
return { items };
}
/**
* The currencies FXMacroData covers. A pair is available whenever both of its
* currencies are covered: the API stores each pair in one direction only and
* derives the inverse or the cross itself, so the covered set - not the list
* of stored pairs - is what determines availability.
*/
private async getCoveredCurrencies(): Promise<string[]> {
if (this.currencies) {
return this.currencies;
}
try {
const { sources } = await this.get<FXMacroDataSourcesResponse>({
path: 'fx/sources',
requestTimeout: this.configurationService.get('REQUEST_TIMEOUT')
});
const currencies = new Set<string>();
for (const { served_pairs } of sources ?? []) {
for (const pair of served_pairs ?? []) {
const [base, quote] = pair.split('/');
if (base && quote) {
currencies.add(base.toUpperCase());
currencies.add(quote.toUpperCase());
}
}
}
this.currencies = [...currencies].sort();
} catch (error) {
this.logger.error(
`Could not get the covered currencies (${this.getName()}): [${error.name}] ${error.message}`
);
return [];
}
return this.currencies;
}
private async get<T>({
path,
requestTimeout,
searchParams = {}
}: {
path: string;
requestTimeout: number;
searchParams?: { [key: string]: string };
}): Promise<T> {
const url = `${this.baseUrl}/${path}?${new URLSearchParams(searchParams).toString()}`;
const response = await this.fetchService.fetch(url, {
// The key travels in a header rather than a query parameter so it is not
// written to request logs or a proxy's access log.
headers: {
'X-API-Key': this.configurationService.get('API_KEY_FXMACRODATA')
},
signal: AbortSignal.timeout(requestTimeout)
});
if (!response.ok) {
throw new Error(`${url.split('?')[0]} returned HTTP ${response.status}`);
}
return (await response.json()) as T;
}
private splitCurrencyPair(symbol: string): {
base?: string;
quote?: string;
} {
if (!isCurrencySymbol(symbol)) {
return {};
}
return {
base: symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length),
quote: symbol.substring(symbol.length - DEFAULT_CURRENCY.length)
};
}
}

15
apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts

@ -0,0 +1,15 @@
export interface FXMacroDataForexResponse {
base: string;
data: {
date: string;
// The rate is documented as anyOf[number, null].
val: number | null;
}[];
quote: string;
}
export interface FXMacroDataSourcesResponse {
sources: {
served_pairs: string[];
}[];
}

1
apps/api/src/services/interfaces/environment.interface.ts

@ -8,6 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_COINGECKO_PRO: string; API_KEY_COINGECKO_PRO: string;
API_KEY_EOD_HISTORICAL_DATA: string; API_KEY_EOD_HISTORICAL_DATA: string;
API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_FXMACRODATA: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;

2
prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DataSource" ADD VALUE 'FXMACRODATA';

1
prisma/schema.prisma

@ -374,6 +374,7 @@ enum DataSource {
COINGECKO COINGECKO
EOD_HISTORICAL_DATA EOD_HISTORICAL_DATA
FINANCIAL_MODELING_PREP FINANCIAL_MODELING_PREP
FXMACRODATA
GHOSTFOLIO GHOSTFOLIO
GOOGLE_SHEETS GOOGLE_SHEETS
MANUAL MANUAL

Loading…
Cancel
Save