almtools
Version:
Tools Downloader For WhatsApp Bot
512 lines (428 loc) • 15.9 kB
JavaScript
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;