diff --git a/CHANGELOG.md b/CHANGELOG.md index be89cc1ed3..a07df0f27c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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 diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index baf1e9c62d..b121089eda 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -51,6 +51,7 @@ export class ConfigurationService { API_KEY_COINGECKO_PRO: str({ default: '' }), API_KEY_EOD_HISTORICAL_DATA: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), + API_KEY_FXMACRODATA: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index 2c6e9fce18..82edfb9210 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/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 { 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 { FXMacroDataService } from '@ghostfolio/api/services/data-provider/fxmacrodata/fxmacrodata.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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; @@ -40,6 +41,7 @@ import { DataProviderService } from './data-provider.service'; DataProviderService, EodHistoricalDataService, FinancialModelingPrepService, + FXMacroDataService, GhostfolioService, GoogleSheetsService, ManualService, @@ -51,6 +53,7 @@ import { DataProviderService } from './data-provider.service'; CoinGeckoService, EodHistoricalDataService, FinancialModelingPrepService, + FXMacroDataService, GhostfolioService, GoogleSheetsService, ManualService, @@ -63,6 +66,7 @@ import { DataProviderService } from './data-provider.service'; coinGeckoService, eodHistoricalDataService, financialModelingPrepService, + fxMacroDataService, ghostfolioService, googleSheetsService, manualService, @@ -73,6 +77,7 @@ import { DataProviderService } from './data-provider.service'; coinGeckoService, eodHistoricalDataService, financialModelingPrepService, + fxMacroDataService, ghostfolioService, googleSheetsService, manualService, diff --git a/apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts b/apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts new file mode 100644 index 0000000000..a7914e127e --- /dev/null +++ b/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 } + ]; + + 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' + }) + ); + }); + }); +}); diff --git a/apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts b/apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts new file mode 100644 index 0000000000..443c4032a4 --- /dev/null +++ b/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> { + 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({ + 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({ + 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 { + 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 { + if (this.currencies) { + return this.currencies; + } + + try { + const { sources } = await this.get({ + path: 'fx/sources', + requestTimeout: this.configurationService.get('REQUEST_TIMEOUT') + }); + + const currencies = new Set(); + + 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({ + path, + requestTimeout, + searchParams = {} + }: { + path: string; + requestTimeout: number; + searchParams?: { [key: string]: string }; + }): Promise { + 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) + }; + } +} diff --git a/apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts b/apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts new file mode 100644 index 0000000000..da851dfc7b --- /dev/null +++ b/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[]; + }[]; +} diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index b4e00ce57e..b23b5c7e59 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -8,6 +8,7 @@ export interface Environment extends CleanedEnvAccessors { API_KEY_COINGECKO_PRO: string; API_KEY_EOD_HISTORICAL_DATA: string; API_KEY_FINANCIAL_MODELING_PREP: string; + API_KEY_FXMACRODATA: string; API_KEY_OPEN_FIGI: string; API_KEY_RAPID_API: string; CACHE_QUOTES_TTL: number; diff --git a/prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql b/prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql new file mode 100644 index 0000000000..8b01556574 --- /dev/null +++ b/prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "DataSource" ADD VALUE 'FXMACRODATA'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e4b15bfb3..81839e3e00 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -374,6 +374,7 @@ enum DataSource { COINGECKO EOD_HISTORICAL_DATA FINANCIAL_MODELING_PREP + FXMACRODATA GHOSTFOLIO GOOGLE_SHEETS MANUAL