From acf5f19b6ec1908a26c3e4df5b1c3e257d507ff5 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Fri, 11 Sep 2026 16:06:16 +1000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../configuration/configuration.service.ts | 1 + .../data-provider/data-provider.module.ts | 5 + .../fxmacrodata/fxmacrodata.service.spec.ts | 290 ++++++++++++++++ .../fxmacrodata/fxmacrodata.service.ts | 326 ++++++++++++++++++ .../fxmacrodata/interfaces/interfaces.ts | 15 + .../interfaces/environment.interface.ts | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + 9 files changed, 642 insertions(+) create mode 100644 apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts create mode 100644 apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts create mode 100644 apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts create mode 100644 prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql 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