Refactor rates to use traits for historical data and be more event based. Add Bitpay.
This commit is contained in:
@@ -1,43 +1,253 @@
|
||||
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;
|
||||
import { BaseRates } from './base-rates.js';
|
||||
|
||||
/**
|
||||
* A composite rate that averages the rates of multiple rates.
|
||||
* A composite rate adapter that combines rates from multiple rate sources
|
||||
* using a configurable policy.
|
||||
*/
|
||||
export class RatesComposite extends BaseRates {
|
||||
constructor(private rates: BaseRates[], private policy: RatesCompositePolicy = ratesAveragePolicy) {
|
||||
// Track available pairs across all rate adapters
|
||||
private availablePairs: Set<string> = new Set();
|
||||
|
||||
// Cache of the latest rates from each adapter
|
||||
private rateCache: Map<string, Map<string, Map<number, number>>> = new Map();
|
||||
|
||||
// Off callbacks for unsubscribing from events
|
||||
private unsubscribeFunctions: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* Creates a new RatesComposite instance
|
||||
*
|
||||
* @param adapters Array of BaseRates adapters to combine
|
||||
* @param policy Policy to determine how to combine rates (defaults to average)
|
||||
* @param refreshIntervalMs How often to emit combined rates (defaults to 1000ms)
|
||||
*/
|
||||
constructor(
|
||||
private adapters: BaseRates[],
|
||||
private policy: RatesCompositePolicy = ratesAveragePolicy,
|
||||
private refreshIntervalMs: number = 1000,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Initialize rate cache
|
||||
this.rateCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all rate adapters and setup event listeners
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
await Promise.all(this.rates.map((rate) => rate.start()));
|
||||
// Start all adapters
|
||||
await Promise.all(this.adapters.map((adapter) => adapter.start()));
|
||||
|
||||
// Setup event listeners for all adapters
|
||||
for (let i = 0; i < this.adapters.length; i++) {
|
||||
const adapter = this.adapters[i];
|
||||
const adapterIndex = i;
|
||||
|
||||
const listener = (event: {
|
||||
denominatorUnitCode: string;
|
||||
numeratorUnitCode: string;
|
||||
price: number;
|
||||
}) => {
|
||||
this.handleRateUpdate(adapterIndex, event);
|
||||
};
|
||||
|
||||
// Use the returned unsubscribe function from the custom EventEmitter
|
||||
const unsubscribe = adapter.on('rateUpdated', listener);
|
||||
|
||||
// Store unsubscribe function for cleanup
|
||||
this.unsubscribeFunctions.push(unsubscribe);
|
||||
}
|
||||
|
||||
// Start periodic refresh to emit combined rates
|
||||
this.startPeriodicRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all rate adapters and clean up event listeners
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await Promise.all(this.rates.map((rate) => rate.stop()));
|
||||
// Stop periodic refresh
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = undefined;
|
||||
}
|
||||
|
||||
// Clean up all event listeners by calling their unsubscribe functions
|
||||
for (const unsubscribe of this.unsubscribeFunctions) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.unsubscribeFunctions = [];
|
||||
|
||||
// Stop all adapters
|
||||
await Promise.all(this.adapters.map((adapter) => adapter.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);
|
||||
/**
|
||||
* Returns a set of all available currency pairs across all adapters
|
||||
*/
|
||||
public async listPairs(): Promise<Set<string>> {
|
||||
// Get available pairs from all adapters
|
||||
const pairPromises = this.adapters.map((adapter) => adapter.listPairs());
|
||||
const pairSets = await Promise.all(pairPromises);
|
||||
|
||||
// Combine all pairs
|
||||
const allPairs = new Set<string>();
|
||||
for (const pairSet of pairSets) {
|
||||
for (const pair of pairSet) {
|
||||
allPairs.add(pair);
|
||||
}
|
||||
}
|
||||
|
||||
return allPairs;
|
||||
}
|
||||
|
||||
public async toBCH(amount: number, fromCurrency: string): Promise<Satoshis> {
|
||||
const rate = await this.getRate(fromCurrency);
|
||||
// Timer for periodic refresh
|
||||
private refreshTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
const bchAmount = amount / rate;
|
||||
return Satoshis.fromBCH(bchAmount);
|
||||
/**
|
||||
* Starts periodic refresh to emit combined rates
|
||||
*/
|
||||
private startPeriodicRefresh(): void {
|
||||
// Clear any existing timer
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
|
||||
// Set up new timer
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.emitCombinedRates();
|
||||
}, this.refreshIntervalMs);
|
||||
|
||||
// Emit initial rates immediately
|
||||
this.emitCombinedRates();
|
||||
}
|
||||
|
||||
public async fromBCH(amount: Satoshis, toCurrency: string): Promise<number> {
|
||||
const rate = await this.getRate(toCurrency);
|
||||
/**
|
||||
* Handles a rate update from an adapter
|
||||
*/
|
||||
private handleRateUpdate(
|
||||
adapterIndex: number,
|
||||
event: {
|
||||
denominatorUnitCode: string;
|
||||
numeratorUnitCode: string;
|
||||
price: number;
|
||||
},
|
||||
): void {
|
||||
const { denominatorUnitCode, numeratorUnitCode, price } = event;
|
||||
|
||||
const result = amount.toBCH() * rate;
|
||||
return result;
|
||||
// Initialize maps if they don't exist
|
||||
if (!this.rateCache.has(denominatorUnitCode)) {
|
||||
this.rateCache.set(denominatorUnitCode, new Map());
|
||||
}
|
||||
|
||||
const denomMap = this.rateCache.get(denominatorUnitCode)!;
|
||||
if (!denomMap.has(numeratorUnitCode)) {
|
||||
denomMap.set(numeratorUnitCode, new Map());
|
||||
}
|
||||
|
||||
// Store the rate in the cache
|
||||
const rateMap = denomMap.get(numeratorUnitCode)!;
|
||||
rateMap.set(adapterIndex, price);
|
||||
|
||||
// Add pair to available pairs
|
||||
this.availablePairs.add(`${numeratorUnitCode}/${denominatorUnitCode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits combined rates for all available pairs
|
||||
*/
|
||||
private emitCombinedRates(): void {
|
||||
// Process each denominator currency
|
||||
for (const [denominator, numeratorMap] of this.rateCache.entries()) {
|
||||
// Process each numerator currency
|
||||
for (const [numerator, rateMap] of numeratorMap.entries()) {
|
||||
// Get all rates for this pair
|
||||
const rates = Array.from(rateMap.values());
|
||||
|
||||
// Apply policy to get combined rate
|
||||
const combinedRate = this.policy(rates);
|
||||
|
||||
// Only emit if we have a valid rate
|
||||
if (combinedRate !== null) {
|
||||
this.emit('rateUpdated', {
|
||||
denominatorUnitCode: denominator,
|
||||
numeratorUnitCode: numerator,
|
||||
price: combinedRate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RatesCompositePolicy = (rates: number[]) => number | null;
|
||||
|
||||
/**
|
||||
* Average policy: returns the average of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesAveragePolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates.filter((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRates.length > 0
|
||||
? validRates.reduce((acc, rate) => acc + rate, 0) / validRates.length
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Median policy: returns the median of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesMedianPolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates
|
||||
.filter((rate) => !isNaN(rate) && isFinite(rate))
|
||||
.sort((a, b) => a - b);
|
||||
if (validRates.length === 0) return null;
|
||||
|
||||
const mid = Math.floor(validRates.length / 2);
|
||||
return validRates.length % 2 === 0
|
||||
? (validRates[mid - 1] + validRates[mid]) / 2
|
||||
: validRates[mid];
|
||||
};
|
||||
|
||||
/**
|
||||
* Min policy: returns the minimum of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesMinPolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates.filter((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRates.length > 0 ? Math.min(...validRates) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Max policy: returns the maximum of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesMaxPolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates.filter((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRates.length > 0 ? Math.max(...validRates) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* First Available policy: returns the first valid rate found
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesFirstAvailablePolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRate = rates.find((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRate !== undefined ? validRate : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Priority policy factory: returns a policy that uses rates in order of priority
|
||||
* Returns the first valid rate from the adapters in the order they were provided
|
||||
*/
|
||||
export const ratesPriorityPolicyFactory = (): RatesCompositePolicy => {
|
||||
return (rates) => {
|
||||
for (const rate of rates) {
|
||||
if (!isNaN(rate) && isFinite(rate)) {
|
||||
return rate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user