UNPKG

almtools

Version:

Tools Downloader For WhatsApp Bot

512 lines (428 loc) 15.9 kB
const fs = require('fs'); const EventEmitter = require('events'); /* ======================= ORDER BOOK ======================= */ class OrderBook extends EventEmitter { constructor(base, quote, feeRate = 0.001, initialState = null, maxMatchesPerTick = 50) { super(); this.base = base; this.quote = quote; this.feeRate = feeRate; this.maxMatchesPerTick = maxMatchesPerTick; this.orders = new Map(); this.bids = []; this.asks = []; this.ohlcStores = {}; this.pendingEvents = new Map(); this.batchTimeout = null; if (initialState) { this.restore(initialState); } } /* ===== EVENT BATCHING ===== */ queueEvent(event, data) { if (!this.pendingEvents.has(event)) { this.pendingEvents.set(event, []); } this.pendingEvents.get(event).push(data); if (this.batchTimeout) clearTimeout(this.batchTimeout); this.batchTimeout = setTimeout(() => { this.flushEvents(); }, 4); } flushEvents() { if (!this.pendingEvents.size) return; for (const [event, argsList] of this.pendingEvents) { for (const args of argsList) { super.emit(event, args); } } this.pendingEvents.clear(); } emit(event, ...args) { if (['trade', 'order_added', 'order_cancelled', 'order_filled'].includes(event)) { this.queueEvent(event, args[0]); } else { super.emit(event, ...args); } } /* ===== STATE MANAGEMENT ===== */ restore(state) { if (!state) return; this.orders.clear(); for (const order of state.orders) { this.orders.set(order.id, { ...order }); } this.bids = state.bids.map(o => ({ ...o })); this.asks = state.asks.map(o => ({ ...o })); if (state.ohlc) { for (const [tfStr, candles] of Object.entries(state.ohlc)) { const { OHLCStore } = require('./ohlc'); // Perlu diimpor ulang atau dilewatkan const timeframe = parseInt(tfStr, 10); const store = new OHLCStore(timeframe); store.restoreCandles(candles); this.ohlcStores[timeframe] = store; } } this.emit('state_restored'); } /* ===== OHLC ===== */ ensureOHLC(timeframe) { if (!this.ohlcStores[timeframe]) { const { OHLCStore } = require('./ohlc'); // Perlu diimpor ulang atau dilewatkan this.ohlcStores[timeframe] = new OHLCStore(timeframe); } } getOHLC(timeframe, limit = 100) { return this.ohlcStores[timeframe]?.getCandles(limit) || []; } /* ===== DEPTH SNAPSHOT ===== */ getDepth(levels = 10) { const bidsDepth = this.bids .slice(0, levels) .reduce((acc, o) => { acc[o.price] = (acc[o.price] || 0) + o.remaining; return acc; }, {}); const asksDepth = this.asks .slice(0, levels) .reduce((acc, o) => { acc[o.price] = (acc[o.price] || 0) + o.remaining; return acc; }, {}); return { bids: bidsDepth, asks: asksDepth }; } /* ===== BINARY INSERTION HELPERS ===== */ binaryInsert(list, order, compareFn) { let low = 0; let high = list.length; while (low < high) { const mid = (low + high) >>> 1; if (compareFn(order, list[mid])) { high = mid; } else { low = mid + 1; } } list.splice(low, 0, order); } removeByIndex(list, index) { list.splice(index, 1); } /* ===== ORDER API ===== */ addOrder({ id, user, side, price, qty, type = 'limit' }) { // Validasi awal if (!id || !user) { const error = 'INVALID_ORDER_DATA'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } if (!['buy', 'sell'].includes(side)) { const error = 'INVALID_SIDE'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } if (price <= 0 || qty <= 0) { const error = 'INVALID_PRICE_OR_QUANTITY'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } if (this.orders.has(id)) { const error = 'ORDER_ID_EXISTS'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } if (type === 'limit') { return this.addLimitOrder({ id, user, side, price, qty }); } else if (type === 'market') { return this.addMarketOrder({ id, user, side, qty }); } else { const error = 'INVALID_ORDER_TYPE'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } } addLimitOrder({ id, user, side, price, qty }) { try { let success = false; let lockedValue; if (side === 'buy') { lockedValue = price * qty; success = this.quote.lock(user, lockedValue); } else { lockedValue = qty; success = this.base.lock(user, lockedValue); } if (!success) { const error = 'INSUFFICIENT_BALANCE'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } const order = { id, user, side, price, qty, remaining: qty, locked: lockedValue, time: Date.now(), type: 'limit', status: 'open', fills: [] }; this.orders.set(id, order); if (side === 'buy') { this.binaryInsert(this.bids, order, (a, b) => a.price > b.price || (a.price === b.price && a.time < b.time) ); } else { this.binaryInsert(this.asks, order, (a, b) => a.price < b.price || (a.price === b.price && a.time < b.time) ); } this.emit('order_added', order); this._match(); return { success: true, order, error: null }; } catch (error) { console.error("Error in addLimitOrder:", error); const errorMessage = 'INTERNAL_ERROR'; this.emit('order_rejected', { id, user, reason: errorMessage }); return { success: false, order: null, error: errorMessage }; } } addMarketOrder({ id, user, side, qty }) { try { let lockedValue; if (side === 'buy') { // Untuk market buy, kita kunci semua saldo quote yang tersedia const maxPossibleValue = this.quote.state.balances[user] || 0; lockedValue = maxPossibleValue; if (lockedValue <= 0 || !this.quote.lock(user, lockedValue)) { const error = 'INSUFFICIENT_BALANCE'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } } else { lockedValue = qty; if (!this.base.lock(user, lockedValue)) { const error = 'INSUFFICIENT_BALANCE'; this.emit('order_rejected', { id, user, reason: error }); return { success: false, order: null, error }; } } const order = { id, user, side, price: 0, // Harga pasar nanti ditentukan saat cocok qty, remaining: qty, locked: lockedValue, spent: 0, // Untuk market buy, lacak berapa banyak yang sudah digunakan time: Date.now(), type: 'market', status: 'open', fills: [] }; this.orders.set(id, order); // Penyisipan biner untuk market order if (side === 'buy') { // Market buy masuk ke bid, disortir berdasarkan waktu (FIFO), dengan prioritas lebih tinggi dari limit buy // Gunakan Infinity sebagai placeholder untuk perbandingan harga this.binaryInsert(this.bids, order, (a, b) => (b.price || Infinity) < (a.price || Infinity) || ((b.price || Infinity) === (a.price || Infinity) && a.time < b.time) ); } else { // Market sell masuk ke ask, disortir berdasarkan waktu (FIFO), dengan prioritas lebih tinggi dari limit sell // Gunakan 0 sebagai placeholder untuk perbandingan harga this.binaryInsert(this.asks, order, (a, b) => (a.price || 0) < (b.price || 0) || ((a.price || 0) === (b.price || 0) && a.time < b.time) ); } this.emit('order_added', order); this._match(); return { success: true, order, error: null }; } catch (error) { console.error("Error in addMarketOrder:", error); const errorMessage = 'INTERNAL_ERROR'; this.emit('order_rejected', { id, user, reason: errorMessage }); return { success: false, order: null, error: errorMessage }; } } /* ===== MATCHING ENGINE ===== */ _match() { let matchCount = 0; while (this.bids.length && this.asks.length && matchCount < this.maxMatchesPerTick) { const buy = this.bids[0]; const sell = this.asks[0]; let canMatch = false; let matchedPrice = 0; if (buy.type === 'market' && sell.type === 'market') { // Tidak bisa mencocokkan market buy vs market sell break; } else if (buy.type === 'market' && sell.type === 'limit') { // Market buy vs Limit sell matchedPrice = sell.price; // Gunakan harga limit sell canMatch = buy.locked - buy.spent > 0; // Pastikan masih ada dana untuk dibelanjakan } else if (buy.type === 'limit' && sell.type === 'market') { // Limit buy vs Market sell matchedPrice = buy.price; // Gunakan harga limit buy canMatch = sell.remaining > 0; } else if (buy.type === 'limit' && sell.type === 'limit') { // Limit buy vs Limit sell canMatch = buy.price >= sell.price; matchedPrice = sell.price; // Atau bisa juga buy.price, tergantung aturan spesifik } else { // Kasus lain (seharusnya tidak terjadi karena logika penyisipan) break; } if (!canMatch) { break; // Tidak bisa mencocokkan pasangan pertama, hentikan loop } // Hitung jumlah yang bisa dicocokkan let availableToSpend = Infinity; let availableToSell = Infinity; if (buy.type === 'market') { availableToSpend = buy.locked - buy.spent; // Dana tersisa untuk market buy } else { availableToSpend = buy.remaining * matchedPrice; // Nilai maksimal dari sisa limit buy } if (sell.type === 'market') { availableToSell = sell.remaining; // Sisa kuantitas dari market sell } else { availableToSell = sell.remaining; // Sisa kuantitas dari limit sell } const qty = Math.min( buy.remaining, sell.remaining, Math.floor(availableToSpend / matchedPrice), // Batasi oleh dana pembeli availableToSell // Batasi oleh pasokan penjual ); if (qty <= 0) { console.warn("Match quantity is zero or negative, breaking match loop."); break; } const value = qty * matchedPrice; const fee = Math.floor(value * this.feeRate); // Lakukan transfer this.base.unlock(sell.user, qty); // Unlock dulu qty yang akan dipindahkan this.quote.transfer(buy.user, sell.user, value); this.quote.transfer(buy.user, 'treasury', fee); this.base.transfer(sell.user, buy.user, qty); // Update spent untuk market buy if (buy.type === 'market' && buy.side === 'buy') { buy.spent += value + fee; } const fill = { price: matchedPrice, qty, value, fee, time: Date.now() }; buy.fills.push(fill); sell.fills.push({ ...fill }); buy.remaining -= qty; sell.remaining -= qty; // Update status buy.status = buy.remaining <= 0 ? 'filled' : 'partial'; sell.status = sell.remaining <= 0 ? 'filled' : 'partial'; // Update OHLC this._updateOHLC(matchedPrice, qty, Date.now()); // Emit trade event this.emit('trade', { buyer: buy.user, seller: sell.user, price: matchedPrice, qty, value, fee, time: Date.now() }); // Hapus pesanan jika selesai if (buy.remaining <= 0) { this.bids.shift(); // Hapus dari array this.orders.set(buy.id, buy); // Simpan status terbaru } if (sell.remaining <= 0) { this.asks.shift(); // Hapus dari array this.orders.set(sell.id, sell); // Simpan status terbaru } matchCount++; } // Finalisasi market buy jika perlu (jika selesai atau dana habis) // Cek elemen pertama lagi setelah loop, karena bisa saja berpindah if (this.bids.length > 0) { const topBid = this.bids[0]; if (topBid.type === 'market' && topBid.side === 'buy') { // Jika market buy masih ada di atas, cek apakah harus difinalisasi if (topBid.remaining <= 0 || topBid.locked - topBid.spent <= 0) { this.bids.shift(); this._finalizeMarketBuy(topBid); this.orders.set(topBid.id, topBid); } } } if (this.asks.length > 0) { const topAsk = this.asks[0]; if (topAsk.type === 'market' && topAsk.side === 'sell') { if (topAsk.remaining <= 0) { this.asks.shift(); // Tidak perlu finalize khusus untuk sell seperti buy, unlock otomatis di atas this.orders.set(topAsk.id, topAsk); } } } } _finalizeMarketBuy(order) { if (order.type !== 'market' || order.side !== 'buy') return; const unused = order.locked - order.spent; if (unused > 0) { this.quote.unlock(order.user, unused); } // Sesuaikan nilai locked dengan jumlah yang benar-benar digunakan order.locked = order.spent; } _updateOHLC(price, qty, timestamp) { const frames = [60000, 300000]; // 1min, 5min for (const tf of frames) { this.ensureOHLC(tf); this.ohlcStores[tf].addPrice(price, qty, timestamp); } } /* ===== CANCEL ===== */ cancel(orderId, user) { const order = this.orders.get(orderId); if (!order) { this.emit('order_cancel_failed', { orderId, reason: 'NOT_FOUND' }); return { success: false, order: null, error: 'NOT_FOUND' }; } if (order.user !== user) { this.emit('order_cancel_failed', { orderId, reason: 'UNAUTHORIZED' }); return { success: false, order: null, error: 'UNAUTHORIZED' }; } if (order.status === 'filled' || order.status === 'cancelled') { this.emit('order_cancel_failed', { orderId, reason: 'ALREADY_FILLED_OR_CANCELLED' }); return { success: false, order: null, error: 'ALREADY_FILLED_OR_CANCELLED' }; } const asset = order.side === 'buy' ? this.quote : this.base; const refund = order.type === 'market' && order.side === 'buy' ? order.locked - order.spent : order.locked; if (refund > 0) { asset.unlock(order.user, refund); } const list = order.side === 'buy' ? this.bids : this.asks; const index = list.findIndex(o => o.id === orderId); if (index !== -1) { this.removeByIndex(list, index); } order.status = 'cancelled'; this.orders.set(orderId, order); this.emit('order_cancelled', order); return { success: true, order, error: null }; } /* ===== QUERY ORDER ===== */ getOrder(orderId) { return this.orders.get(orderId); } } module.exports = OrderBook;