-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: Fetch fiat price #26
Changes from 6 commits
df8006f
d14b78f
1a9c783
e28dd3a
4bdac9f
72605bb
5a02575
47ce0c1
54240fa
fe8331b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,25 @@ | ||
import { Module } from '@nestjs/common'; | ||
|
||
import { CoinmarketcapClientService } from '@/clients/coinmarketcap-client.service'; | ||
import { HttpService } from '@/clients/http.service'; | ||
import { MdwHttpClientService } from '@/clients/mdw-http-client.service'; | ||
import { MdwWsClientService } from '@/clients/mdw-ws-client.service'; | ||
import { SdkClientService } from '@/clients/sdk-client.service'; | ||
|
||
@Module({ | ||
providers: [MdwHttpClientService, MdwWsClientService, SdkClientService], | ||
exports: [MdwHttpClientService, MdwWsClientService, SdkClientService], | ||
providers: [ | ||
CoinmarketcapClientService, | ||
HttpService, | ||
MdwHttpClientService, | ||
MdwWsClientService, | ||
SdkClientService, | ||
], | ||
exports: [ | ||
CoinmarketcapClientService, | ||
HttpService, | ||
MdwHttpClientService, | ||
MdwWsClientService, | ||
SdkClientService, | ||
], | ||
}) | ||
export class ClientsModule {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
export type AeUsdQuoteData = { | ||
1700: { | ||
id: number; | ||
name: string; | ||
symbol: string; | ||
is_active: 0 | 1; | ||
is_fiat: 0 | 1; | ||
quotes: [ | ||
{ | ||
timestamp: Date; | ||
quote: { | ||
USD: { | ||
percent_change_1h: number; | ||
percent_change_24h: number; | ||
percent_change_7d: number; | ||
percent_change_30d: number; | ||
price: number; | ||
volume_24h: number; | ||
market_cap: number; | ||
total_supply: number; | ||
circulating_supply: number; | ||
timestamp: Date; | ||
}; | ||
}; | ||
}, | ||
]; | ||
}; | ||
}; | ||
|
||
export type CoinmarketcapResponse<T> = { | ||
status: CoinmarketcapStatus; | ||
data: T; | ||
}; | ||
|
||
export type CoinmarketcapStatus = { | ||
timestamp: Date; | ||
error_code: number; | ||
error_message: string; | ||
elapsed: number; | ||
credit_count: number; | ||
notice: string; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
|
||
import { CoinmarketcapClientService } from '@/clients/coinmarketcap-client.service'; | ||
import { HttpService } from '@/clients/http.service'; | ||
import resetAllMocks = jest.resetAllMocks; | ||
import { RateLimiter } from 'limiter'; | ||
|
||
import { coinmarketcapResponseAeUsdQuoteData } from '@/test/mock-data/pair-liquidity-info-history-mock-data'; | ||
|
||
const mockHttpService = { | ||
get: jest.fn(), | ||
}; | ||
|
||
describe('CoinmarketcapClientService', () => { | ||
let service: CoinmarketcapClientService; | ||
|
||
beforeEach(async () => { | ||
const module: TestingModule = await Test.createTestingModule({ | ||
providers: [ | ||
CoinmarketcapClientService, | ||
{ provide: HttpService, useValue: mockHttpService }, | ||
], | ||
}).compile(); | ||
service = module.get<CoinmarketcapClientService>( | ||
CoinmarketcapClientService, | ||
); | ||
resetAllMocks(); | ||
}); | ||
|
||
describe('getHistoricalPriceDataThrottled', () => { | ||
it('should correctly calculate and fetch the latest 5 min interval for a given timestamp', async () => { | ||
// Mock functions | ||
mockHttpService.get.mockResolvedValue( | ||
coinmarketcapResponseAeUsdQuoteData, | ||
); | ||
// Call function | ||
await service.getHistoricalPriceDataThrottled(1704203935123); | ||
|
||
// Assertions | ||
expect(mockHttpService.get).toHaveBeenCalledWith( | ||
'https://pro-api.coinmarketcap.com/v3/cryptocurrency/quotes/historical?id=1700&interval=24h&count=1&time_end=2024-01-02T13:55:00.000Z', | ||
expect.anything(), | ||
); | ||
|
||
await service.getHistoricalPriceDataThrottled(1704203614123); | ||
|
||
expect(mockHttpService.get).toHaveBeenCalledWith( | ||
'https://pro-api.coinmarketcap.com/v3/cryptocurrency/quotes/historical?id=1700&interval=24h&count=1&time_end=2024-01-02T13:50:00.000Z', | ||
expect.anything(), | ||
); | ||
}); | ||
|
||
it('should be throtteled to defined rate limit', async () => { | ||
service['rateLimiter'] = new RateLimiter({ | ||
tokensPerInterval: 2, | ||
interval: 'minute', | ||
}); | ||
|
||
// Mock | ||
mockHttpService.get.mockResolvedValue({}); | ||
|
||
// Call function | ||
service.getHistoricalPriceDataThrottled(1704203935123); | ||
service.getHistoricalPriceDataThrottled(1704203935123); | ||
service.getHistoricalPriceDataThrottled(1704203935123); | ||
|
||
await new Promise((res) => setTimeout(res, 200)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. seems a bit flaky, could we await the first two calls and then for the third call use the timeout? |
||
|
||
// Assertion | ||
expect(mockHttpService.get).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { RateLimiter } from 'limiter'; | ||
|
||
import { | ||
AeUsdQuoteData, | ||
CoinmarketcapResponse, | ||
} from '@/clients/coinmarketcap-client.model'; | ||
import { HttpService } from '@/clients/http.service'; | ||
|
||
@Injectable() | ||
export class CoinmarketcapClientService { | ||
constructor(private httpService: HttpService) {} | ||
|
||
private readonly AUTH_HEADER = { | ||
'X-CMC_PRO_API_KEY': process.env.COIN_MARKET_CAP_API_KEY || '', | ||
}; | ||
private readonly AE_CURRENCY_ID = 1700; | ||
private readonly COUNT = 1; | ||
private readonly INTERVAL = '24h'; | ||
private readonly CALLS_LIMIT = 28; | ||
private readonly CALL_INTERVAL = 'minute'; | ||
private rateLimiter = new RateLimiter({ | ||
tokensPerInterval: this.CALLS_LIMIT, | ||
interval: this.CALL_INTERVAL, | ||
}); | ||
|
||
async getHistoricalPriceDataThrottled(microBlockTime: number) { | ||
await this.rateLimiter.removeTokens(1); | ||
const timeEnd = this.roundMicroBlockTimeDownTo5MinInterval(microBlockTime); | ||
const url = `https://pro-api.coinmarketcap.com/v3/cryptocurrency/quotes/historical?id=${this.AE_CURRENCY_ID}&interval=${this.INTERVAL}&count=${this.COUNT}&time_end=${timeEnd}`; | ||
return this.get<CoinmarketcapResponse<AeUsdQuoteData>>(url); | ||
} | ||
|
||
private async get<T>(url: string): Promise<T> { | ||
return this.httpService.get<T>(url, new Headers(this.AUTH_HEADER)); | ||
} | ||
|
||
private roundMicroBlockTimeDownTo5MinInterval( | ||
microBlockTime: number, | ||
): string { | ||
const date = new Date(microBlockTime); | ||
date.setMinutes(Math.floor(date.getMinutes() / 5) * 5); | ||
date.setSeconds(0); | ||
date.setMilliseconds(0); | ||
return date.toISOString(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
|
||
@Injectable() | ||
export class HttpService { | ||
async get<T>(url: string, headers?: Headers): Promise<T> { | ||
return fetch(url, { method: 'GET', headers: headers }).then(async (res) => { | ||
if (res.ok) { | ||
return (await res.json()) as Promise<T>; | ||
} else { | ||
throw new Error( | ||
`GET ${url} failed with status ${res.status}. Response body: ${await res.text()}`, | ||
); | ||
} | ||
}); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
|
||
import { HttpService } from '@/clients/http.service'; | ||
import { | ||
AccountBalance, | ||
Contract, | ||
|
@@ -19,6 +20,8 @@ import { nonNullable } from '@/lib/utils'; | |
|
||
@Injectable() | ||
export class MdwHttpClientService { | ||
constructor(private httpService: HttpService) {} | ||
|
||
private readonly LIMIT = 100; | ||
private readonly DIRECTION = 'forward'; | ||
private readonly INT_AS_STRING = true; | ||
|
@@ -81,23 +84,17 @@ export class MdwHttpClientService { | |
|
||
private async get<T>(url: string): Promise<T> { | ||
const fullUrl = `${NETWORKS[nonNullable(process.env.NETWORK_NAME)].middlewareHttpUrl}${url}`; | ||
return fetch(fullUrl).then(async (res) => { | ||
if (res.ok) { | ||
return (await res.json()) as Promise<T>; | ||
} else { | ||
throw new Error( | ||
`GET ${url} failed with status ${res.status}. Response body: ${await res.text()}`, | ||
); | ||
} | ||
}); | ||
return this.httpService.get<T>(fullUrl); | ||
} | ||
|
||
// Fetches pages from middleware until the page contains at least one entry that meets the condition | ||
private async getPagesUntilCondition<T>( | ||
condition: (data: T) => boolean, | ||
next: string, | ||
): Promise<T[]> { | ||
const result = await this.get<MdwPaginatedResponse<T>>(next); | ||
const result = await this.get<MdwPaginatedResponse<T>>( | ||
next + `&int-as-string=${this.INT_AS_STRING}`, | ||
); | ||
Comment on lines
+95
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor notes: should have been its own PR |
||
|
||
if (result.data.filter(condition).length === 0 && result.next) { | ||
return result.data.concat( | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we test what happens if we run in the rate limit error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I'd add it to the importer spec however, as in the CoinmarketcapClieht nothing much happens besides returning the error (we are using a mocked version of the API).