Added Rates Service: Base, Coinbase, Composite
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import type { Satoshis } from '@xocash/primitives';
|
||||
|
||||
import { EventEmitter } from '../utils/index.js';
|
||||
|
||||
export type RateOptions = {
|
||||
// minimum timestamp to use for the rate
|
||||
minTimestamp: number;
|
||||
|
||||
// maximum timestamp to use for the rate
|
||||
maxTimestamp: number;
|
||||
}
|
||||
|
||||
export type RatesEventMap = {
|
||||
'price-updated': {
|
||||
currency: string;
|
||||
price: number;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class BaseRates extends EventEmitter<RatesEventMap> {
|
||||
public abstract start(): Promise<void>;
|
||||
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Convert an amount in a source currency to BCH.
|
||||
*
|
||||
* @param {number} amount - The amount to convert.
|
||||
* @param {string} fromCurrency - The source currency.
|
||||
*
|
||||
* @returns The converted amount in BCH.
|
||||
*/
|
||||
public abstract toBCH(amount: number, fromCurrency: string, options?: Partial<RateOptions>): Promise<Satoshis>;
|
||||
|
||||
/**
|
||||
* Convert an amount in BCH to a target currency.
|
||||
*
|
||||
* @param {Satoshis} amount - The amount to convert.
|
||||
* @param {string} targetCurrency - The target currency.
|
||||
*
|
||||
* @returns The converted amount in the target currency.
|
||||
*/
|
||||
public abstract fromBCH(amount: Satoshis, targetCurrency: string, options?: Partial<RateOptions>): Promise<number>;
|
||||
|
||||
/**
|
||||
* Get the raw exchange rate for a currency pair.
|
||||
*
|
||||
* @param {string} currency - The currency to get the rate for.
|
||||
*
|
||||
* @returns The raw exchange rate for the currency.
|
||||
*/
|
||||
public abstract getRate(currency: string, options?: Partial<RateOptions>): Promise<number>;
|
||||
|
||||
/**
|
||||
* Format the amount in the target currency to the correct number of decimal places.
|
||||
*
|
||||
* @param {number} amount - The amount to format.
|
||||
* @param {string} targetCurrency - The target currency.
|
||||
*
|
||||
* @returns The formatted amount.
|
||||
*/
|
||||
public formatCurrency(amount: number, targetCurrency: string): string {
|
||||
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
||||
AUD: 2,
|
||||
BCH: 8,
|
||||
USD: 2,
|
||||
};
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: targetCurrency,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
|
||||
});
|
||||
|
||||
return formatter.format(amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './base-rates.js';
|
||||
export * from './rates-composite.js';
|
||||
export * from './rates-coinbase.js';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Satoshis } from '@xocash/primitives';
|
||||
|
||||
import { BaseRates, type RateOptions } from './base-rates.js';
|
||||
|
||||
import { Memoizer } from '../utils/index.js';
|
||||
|
||||
/**
|
||||
* Represents a single market product (pair) on Coinbase Exchange.
|
||||
*/
|
||||
export interface CoinbaseProduct {
|
||||
id: string;
|
||||
base_currency: string;
|
||||
quote_currency: string;
|
||||
base_min_size: string;
|
||||
base_max_size: string;
|
||||
quote_increment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies for RatesCoinbase.
|
||||
*/
|
||||
export type RatesCoinbaseDependencies = {
|
||||
/**
|
||||
* Base URL for Coinbase Exchange API (defaults to prod).
|
||||
*/
|
||||
apiBaseUrl: string;
|
||||
|
||||
/**
|
||||
* Optional cache instance for rate lookups.
|
||||
*/
|
||||
cache: Memoizer;
|
||||
|
||||
/**
|
||||
* Optional cache TTL for rate lookups.
|
||||
*/
|
||||
cacheTTL: number;
|
||||
|
||||
/**
|
||||
* Optional fetch-like function (e.g. global fetch) for HTTP calls.
|
||||
*/
|
||||
fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
/**
|
||||
* CoinbaseRates
|
||||
*
|
||||
* Implements BaseRates by calling Coinbase Exchange's public REST endpoints.
|
||||
*/
|
||||
export class RatesCoinbase extends BaseRates {
|
||||
private apiBaseUrl: string;
|
||||
private cache: Memoizer;
|
||||
private cacheTTL: number;
|
||||
private fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
private constructor(deps: Partial<RatesCoinbaseDependencies>) {
|
||||
super();
|
||||
this.apiBaseUrl = deps.apiBaseUrl ?? 'https://api.exchange.coinbase.com';
|
||||
this.cache = deps.cache ?? new Memoizer();
|
||||
this.cacheTTL = deps.cacheTTL ?? 60 * 1000;
|
||||
this.fetch = deps.fetch ?? fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create a CoinbaseRates instance.
|
||||
*/
|
||||
public static from(
|
||||
apiBaseUrl: string = 'https://api.exchange.coinbase.com',
|
||||
cacheTTL: number = 60 * 1000,
|
||||
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>,
|
||||
): RatesCoinbase {
|
||||
const cache = new Memoizer();
|
||||
return new RatesCoinbase({ apiBaseUrl, cache, cacheTTL, fetch });
|
||||
}
|
||||
|
||||
/** No persistent connection to open. */
|
||||
public async start(): Promise<void> {}
|
||||
|
||||
/** No persistent connection to close. */
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* List all available market products (pairs).
|
||||
*/
|
||||
public async listProducts(): Promise<CoinbaseProduct[]> {
|
||||
const url = `${this.apiBaseUrl}/products`;
|
||||
const res = await this.fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch products (${res.status}): ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as CoinbaseProduct[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw exchange rate for a currency pair.
|
||||
*/
|
||||
public async getRate(
|
||||
currency: string,
|
||||
options?: Partial<RateOptions>,
|
||||
): Promise<number> {
|
||||
const fetchPair = async (pair: string): Promise<number> => {
|
||||
return await this.cache.get(pair, async () => {
|
||||
const price = await this.fetchTickerPrice(pair);
|
||||
this.cache.set(pair, price);
|
||||
return price;
|
||||
}, this.cacheTTL);
|
||||
};
|
||||
|
||||
const direct = `${currency}-BCH`;
|
||||
try {
|
||||
const denominatorRate = await fetchPair(direct);
|
||||
return 1 / denominatorRate;
|
||||
} catch {
|
||||
const inverse = `BCH-${currency}`;
|
||||
const invPrice = await fetchPair(inverse);
|
||||
return invPrice;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an amount in a foreign currency to BCH.
|
||||
*/
|
||||
public async toBCH(
|
||||
amount: number,
|
||||
fromCurrency: string,
|
||||
options?: Partial<RateOptions>,
|
||||
): Promise<Satoshis> {
|
||||
const rate = await this.getRate(fromCurrency, options);
|
||||
return Satoshis.fromBCH(amount / rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BCH amount to a target currency.
|
||||
*/
|
||||
public async fromBCH(
|
||||
amount: Satoshis,
|
||||
targetCurrency: string,
|
||||
options?: Partial<RateOptions>,
|
||||
): Promise<number> {
|
||||
const bchAmount = amount.toBCH();
|
||||
const rate = await this.getRate(targetCurrency, options);
|
||||
return bchAmount * rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current ticker price for a given pair.
|
||||
*/
|
||||
private async fetchTickerPrice(pair: string): Promise<number> {
|
||||
const url = `${this.apiBaseUrl}/products/${pair}/ticker`;
|
||||
const res = await this.fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ticker API error (${res.status}): ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as { price: string };
|
||||
return parseFloat(data.price);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Satoshis } from "@xocash/primitives";
|
||||
|
||||
import { BaseRates, type RateOptions } from "./base-rates.js";
|
||||
|
||||
export type RatesCompositePolicy = (rates: number[]) => number;
|
||||
|
||||
const ratesAveragePolicy: RatesCompositePolicy = (rates) => rates.reduce((acc, rate) => acc + rate, 0) / rates.length;
|
||||
|
||||
/**
|
||||
* A composite rate that averages the rates of multiple rates.
|
||||
*/
|
||||
export class RatesComposite extends BaseRates {
|
||||
constructor(private rates: BaseRates[], private policy: RatesCompositePolicy = ratesAveragePolicy) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await Promise.all(this.rates.map((rate) => rate.start()));
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await Promise.all(this.rates.map((rate) => rate.stop()));
|
||||
}
|
||||
|
||||
public async getRate(currency: string, options?: Partial<RateOptions>): Promise<number> {
|
||||
const rates = await Promise.all(this.rates.map((rate) => rate.getRate(currency, options)));
|
||||
return this.policy(rates);
|
||||
}
|
||||
|
||||
public async toBCH(amount: number, fromCurrency: string): Promise<Satoshis> {
|
||||
const rate = await this.getRate(fromCurrency);
|
||||
|
||||
const bchAmount = amount / rate;
|
||||
return Satoshis.fromBCH(bchAmount);
|
||||
}
|
||||
|
||||
public async fromBCH(amount: Satoshis, toCurrency: string): Promise<number> {
|
||||
const rate = await this.getRate(toCurrency);
|
||||
|
||||
const result = amount.toBCH() * rate;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user