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