@cygnus-wealth/asset-valuator
Version:
Asset valuation library for retrieving and converting cryptocurrency prices
175 lines (174 loc) • 6.81 kB
JavaScript
import { CoinGeckoProvider } from './coingecko.js';
import { CoinPaprikaProvider } from './coinpaprika.js';
import { RateLimiter } from '../utils/rate-limiter.js';
import { EdgeCache } from '../utils/edge-cache.js';
import { hasIndexedDB, hasLocalStorage } from '../utils/browser-detect.js';
export class DecentralizedAggregator {
constructor(options = {}) {
// Default providers
this.providers = options.providers || [
new CoinGeckoProvider(),
new CoinPaprikaProvider(),
];
// Rate limiter with conservative defaults for edge devices
this.rateLimiter = new RateLimiter({
maxRequests: options.rateLimitOptions?.maxRequests || 5,
windowMs: options.rateLimitOptions?.windowMs || 60000, // 1 minute
retryAfterMs: 2000,
maxRetries: 3
});
// Edge cache with automatic storage selection
this.cache = new EdgeCache({
storage: options.cacheOptions?.storage || this.selectBestStorage(),
defaultTTL: options.cacheOptions?.ttl || 60000, // 60 seconds
maxSize: 500
});
this.consensusThreshold = options.consensusThreshold || 0.5;
}
selectBestStorage() {
if (hasIndexedDB()) {
return 'indexedDB';
}
else if (hasLocalStorage()) {
return 'localStorage';
}
return 'memory';
}
async fetchPrice(symbol, currency = 'usd') {
const cacheKey = `${symbol}-${currency}`;
// Check cache first
const cached = await this.cache.get(cacheKey);
if (cached) {
return cached;
}
// Use rate limiter to fetch from providers
return this.rateLimiter.execute(cacheKey, async () => {
const results = await this.fetchFromProviders(symbol, currency);
if (results.length === 0) {
throw new Error(`No price data available for ${symbol}`);
}
// Calculate consensus price
const consensusPrice = this.calculateConsensusPrice(results);
const priceData = {
symbol: symbol.toUpperCase(),
price: consensusPrice,
timestamp: new Date()
};
// Cache the result
await this.cache.set(cacheKey, priceData);
return priceData;
});
}
async fetchMultiplePrices(symbols, currency = 'usd') {
// Check cache for all symbols
const results = [];
const uncachedSymbols = [];
for (const symbol of symbols) {
const cached = await this.cache.get(`${symbol}-${currency}`);
if (cached) {
results.push(cached);
}
else {
uncachedSymbols.push(symbol);
}
}
// Fetch uncached symbols
if (uncachedSymbols.length > 0) {
const batchKey = `batch-${uncachedSymbols.join(',')}-${currency}`;
const fetchedPrices = await this.rateLimiter.execute(batchKey, async () => {
const allResults = [];
// Try each provider
for (const provider of this.providers) {
try {
const providerResults = await provider.fetchMultiplePrices(uncachedSymbols, currency);
allResults.push(...providerResults);
}
catch (error) {
console.warn(`Provider failed:`, error);
}
}
// Group by symbol and calculate consensus
const grouped = this.groupBySymbol(allResults);
const consensusPrices = [];
for (const [symbol, prices] of grouped.entries()) {
if (prices.length > 0) {
const consensusPrice = this.calculateConsensusPrice(prices);
const priceData = {
symbol,
price: consensusPrice,
timestamp: new Date()
};
consensusPrices.push(priceData);
// Cache individual results
await this.cache.set(`${symbol}-${currency}`, priceData);
}
}
return consensusPrices;
});
results.push(...fetchedPrices);
}
return results;
}
async fetchFromProviders(symbol, currency) {
const results = [];
// Try each provider sequentially with fallback
for (const provider of this.providers) {
try {
const price = await provider.fetchPrice(symbol, currency);
results.push(price);
// If we have enough providers for consensus, we can stop
if (results.length >= Math.ceil(this.providers.length * this.consensusThreshold)) {
break;
}
}
catch (error) {
console.warn(`Provider failed for ${symbol}:`, error);
// Continue to next provider
}
}
return results;
}
calculateConsensusPrice(prices) {
if (prices.length === 0) {
throw new Error('No prices available for consensus');
}
if (prices.length === 1) {
return prices[0].price;
}
// Sort prices
const sortedPrices = prices.map(p => p.price).sort((a, b) => a - b);
// Remove outliers (prices that deviate more than 10% from median)
const median = sortedPrices[Math.floor(sortedPrices.length / 2)];
const filtered = sortedPrices.filter(price => Math.abs(price - median) / median <= 0.1);
// If too many outliers, use median
if (filtered.length < prices.length * this.consensusThreshold) {
return median;
}
// Calculate average of filtered prices
return filtered.reduce((sum, price) => sum + price, 0) / filtered.length;
}
groupBySymbol(prices) {
const grouped = new Map();
for (const price of prices) {
const symbol = price.symbol.toUpperCase();
if (!grouped.has(symbol)) {
grouped.set(symbol, []);
}
grouped.get(symbol).push(price);
}
return grouped;
}
// Utility methods for managing the aggregator
addProvider(provider) {
this.providers.push(provider);
}
removeProvider(index) {
this.providers.splice(index, 1);
}
async clearCache() {
await this.cache.clear();
}
resetRateLimiter() {
this.rateLimiter.reset();
}
}