mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
9 changed files with 687 additions and 0 deletions
@ -0,0 +1,324 @@ |
|||||
|
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('follows the pagination offsets until the last page', async () => { |
||||
|
responses['offset=0'] = { |
||||
|
data: [ |
||||
|
{ date: '2026-09-10', val: 1.16 }, |
||||
|
{ date: '2026-09-09', val: 1.15 } |
||||
|
], |
||||
|
pagination: { has_more: true, next_offset: 2 } |
||||
|
}; |
||||
|
responses['offset=2'] = { |
||||
|
data: [{ date: '2026-09-08', val: 1.14 }], |
||||
|
pagination: { has_more: false, next_offset: null } |
||||
|
}; |
||||
|
|
||||
|
const historical = await fxMacroDataService.getHistorical({ |
||||
|
from: new Date('2026-09-08'), |
||||
|
symbol: 'EURUSD', |
||||
|
to: new Date('2026-09-10') |
||||
|
}); |
||||
|
|
||||
|
expect(historical).toEqual({ |
||||
|
'2026-09-08': { marketPrice: 1.14 }, |
||||
|
'2026-09-09': { marketPrice: 1.15 }, |
||||
|
'2026-09-10': { marketPrice: 1.16 } |
||||
|
}); |
||||
|
|
||||
|
const urls = (fetchService.fetch as jest.Mock).mock.calls.map(([url]) => { |
||||
|
return url as string; |
||||
|
}); |
||||
|
|
||||
|
expect(urls).toHaveLength(2); |
||||
|
expect(urls[0]).toContain('limit=100'); |
||||
|
expect(urls[1]).toContain('offset=2'); |
||||
|
}); |
||||
|
|
||||
|
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' |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,333 @@ |
|||||
|
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'; |
||||
|
|
||||
|
const PAGE_SIZE = 100; |
||||
|
const MAX_PAGES = 1000; |
||||
|
|
||||
|
@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.
|
||||
|
let offset = 0; |
||||
|
|
||||
|
for (let page = 0; page < MAX_PAGES; page++) { |
||||
|
const { data, pagination } = await this.get<FXMacroDataForexResponse>({ |
||||
|
requestTimeout, |
||||
|
path: `forex/${base.toLowerCase()}/${quote.toLowerCase()}`, |
||||
|
searchParams: { |
||||
|
end_date: format(to, DATE_FORMAT), |
||||
|
limit: PAGE_SIZE.toString(), |
||||
|
offset: offset.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 (!pagination?.has_more) { |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
offset = pagination.next_offset ?? offset + data.length; |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
export interface FXMacroDataForexResponse { |
||||
|
base: string; |
||||
|
data: { |
||||
|
date: string; |
||||
|
// The rate is documented as anyOf[number, null].
|
||||
|
val: number | null; |
||||
|
}[]; |
||||
|
pagination?: { |
||||
|
has_more: boolean; |
||||
|
next_offset: number | null; |
||||
|
}; |
||||
|
quote: string; |
||||
|
} |
||||
|
|
||||
|
export interface FXMacroDataSourcesResponse { |
||||
|
sources: { |
||||
|
served_pairs: string[]; |
||||
|
}[]; |
||||
|
} |
||||
@ -0,0 +1,2 @@ |
|||||
|
-- AlterEnum |
||||
|
ALTER TYPE "DataSource" ADD VALUE 'FXMACRODATA'; |
||||
Loading…
Reference in new issue