← Назадclass StateManager {
constructor() {
// symbol -> Map<price, {notional, firstSeen, lastUpdate, isMM}>
this.books = new Map();
// Cache to track dynamically created bins over time to detect robots
// cacheKey: "SYMBOL:SIDE:BIN_ANCHOR" -> { oldestSeen, maxNotional, lastUpdate, isMovingTowardPrice }
this.binHistory = new Map();
}
initBook(symbol, bids, asks) {
if (!this.books.has(symbol)) {
this.books.set(symbol, {
bids: new Map(),
asks: new Map(),
lastUpdateId: 0
});
}
const state = this.books.get(symbol);
const now = Date.now();
bids.forEach(([priceStr, qtyStr]) => {
const price = parseFloat(priceStr);
const notional = price * parseFloat(qtyStr);
state.bids.set(price, { notional, firstSeen: now, lastUpdate: now });
});
asks.forEach(([priceStr, qtyStr]) => {
const price = parseFloat(priceStr);
const notional = price * parseFloat(qtyStr);
state.asks.set(price, { notional, firstSeen: now, lastUpdate: now });
});
}
processDelta(symbol, payload) {
if (!this.books.has(symbol)) return;
const state = this.books.get(symbol);
// Binance sequence check (U <= lastUpdateId+1 AND u >= lastUpdateId+1)
// Simplified for now: just apply deltas blindly for simplicity if not strictly syncing
// To do strict sync: Drop out-of-order and fetch snapshot again
if (payload.u <= state.lastUpdateId) return;
const now = Date.now();
// Process Bids
payload.b.forEach(([priceStr, qtyStr]) => {
const price = parseFloat(priceStr);
const qty = parseFloat(qtyStr);
if (qty === 0) {
state.bids.delete(price);
} else {
const notional = price * qty;
const existing = state.bids.get(price);
if (existing) {
existing.notional = notional;
existing.lastUpdate = now;
} else {
state.bids.set(price, { notional, firstSeen: now, lastUpdate: now });
}
}
});
// Process Asks
payload.a.forEach(([priceStr, qtyStr]) => {
const price = parseFloat(priceStr);
const qty = parseFloat(qtyStr);
if (qty === 0) {
state.asks.delete(price);
} else {
const notional = price * qty;
const existing = state.asks.get(price);
if (existing) {
existing.notional = notional;
existing.lastUpdate = now;
} else {
state.asks.set(price, { notional, firstSeen: now, lastUpdate: now });
}
}
});
state.lastUpdateId = payload.u;
// Cleanup stale price levels not updated in 2 minutes
if (!state._lastCleanup || now - state._lastCleanup > 60000) {
state._lastCleanup = now;
const staleMs = 120000;
for (const [price, data] of state.bids.entries()) {
if (now - data.lastUpdate > staleMs) state.bids.delete(price);
}
for (const [price, data] of state.asks.entries()) {
if (now - data.lastUpdate > staleMs) state.asks.delete(price);
}
}
}
getTopLevels(symbol, side, markPrice, minNotional, limit, windowPct) {
if (!this.books.has(symbol)) return [];
const state = this.books.get(symbol);
const bookSide = side === 'bid' ? state.bids : state.asks;
const ArrayOfLevels = [];
const minPrice = markPrice * (1 - windowPct / 100);
const maxPrice = markPrice * (1 + windowPct / 100);
for (const [price, data] of bookSide.entries()) {
if (price >= minPrice && price <= maxPrice && data.notional >= minNotional) {
const distancePct = Math.abs(price - markPrice) / markPrice * 100;
ArrayOfLevels.push({
price,
notional: data.notional,
firstSeen: data.firstSeen,
lastUpdate: data.lastUpdate,
distancePct
});
}
}
// Sort heavily heavily heavily
ArrayOfLevels.sort((a, b) => b.notional - a.notional);
return ArrayOfLevels.slice(0, limit);
}
// --- Historical Bin Tracking for Robot Aggressor Detection ---
trackAndEnrichBins(symbol, side, currentBins, markPrice) {
const now = Date.now();
const enrichedBins = [];
const sideLabel = side.toUpperCase();
// Size tolerance to consider two bins as "the same order moving"
const NOTIONAL_TOLERANCE = 0.8; // current bin must be at least 80% of old bin
for (const bin of currentBins) {
const cacheKey = `${symbol}:${sideLabel}:${bin.anchorPrice}`;
let history = this.binHistory.get(cacheKey);
let isMovingTowardPrice = false;
if (!history) {
// Did it move from a previously active bin slightly further away?
for (const [k, v] of this.binHistory.entries()) {
// Check if same symbol and side
if (k.startsWith(`${symbol}:${sideLabel}:`)) {
const oldPrice = parseFloat(k.split(':')[2]);
const distBetweenPrices = Math.abs(bin.anchorPrice - oldPrice) / oldPrice * 100;
// Only consider recent bins (updated in last 5 seconds)
// that are within a small distance (0.5%) and have similar notional
if (distBetweenPrices > 0 && distBetweenPrices < 0.5 && (now - v.lastUpdate) < 5000) {
const oldDistToMark = Math.abs(oldPrice - markPrice);
const newDistToMark = Math.abs(bin.anchorPrice - markPrice);
// It moved closer to the mark price
if (newDistToMark < oldDistToMark && bin.notional >= v.maxNotional * NOTIONAL_TOLERANCE) {
isMovingTowardPrice = true;
// Inherit history, rename key
history = { ...v, lastUpdate: now, isMovingTowardPrice: true };
this.binHistory.delete(k);
break;
}
}
}
}
if (!history) {
// Completely new bin
history = {
oldestSeen: bin.oldestSeen || now,
maxNotional: bin.notional,
lastUpdate: now,
isMovingTowardPrice: false
};
}
} else {
// Bin exists at this exact price, update it
history.lastUpdate = now;
if (bin.notional > history.maxNotional) {
history.maxNotional = bin.notional;
}
}
this.binHistory.set(cacheKey, history);
enrichedBins.push({
...bin,
oldestSeen: history.oldestSeen, // Use the history oldest time
isMovingTowardPrice: history.isMovingTowardPrice
});
}
// Cleanup very old bins (not seen in 1 minute) — deterministic, every 30s
if (!this._lastBinCleanup || now - this._lastBinCleanup > 30000) {
this._lastBinCleanup = now;
for (const [k, v] of this.binHistory.entries()) {
if (now - v.lastUpdate > 60000) {
this.binHistory.delete(k);
}
}
}
return enrichedBins;
}
}
module.exports = new StateManager();