scripts/bot.js Repository BOT_EU Original path scripts/bot.jsRole SOURCE Size 54528 bytes Lines 1467 SHA-256 9d6d9d3a297c00b24abd313d73548ed0dfcfa95a4c47412b33595bfaa6784a8eDisplayed range 501–1000 Previous file/page · Project index · Next file/page
async function cancelAllUnified(state, ctx) {
if (IS_PAPER) {
paperCancelAll(state, "", "REGRID");
return;
}
await cancelAllOrders(CONFIG.CATEGORY, ctx.symbol);
await refreshOpenOrders(state, ctx);
}
async function cancelOrderByRef(state, ctx, { orderId, orderLinkId } = {}, info = "CANCEL_ONE") {
const oid = String(orderId || "").trim();
const olid = String(orderLinkId || "").trim();
if (!oid && !olid) return false;
if (IS_PAPER) {
if (oid) {
const ok = paperCancelOne(state, "", oid, info);
if (ok) return true;
}
if (olid) {
const hit = (state.openOrders || []).find((o) => String(o?.linkId || "") === olid);
if (hit?.id) return paperCancelOne(state, "", String(hit.id), info);
}
return false;
}
await cancelOneOrder(CONFIG.CATEGORY, ctx.symbol, oid, olid || null);
state.openOrders = (state.openOrders || []).filter(
(o) => !(String(o?.id || "") === oid || (olid && String(o?.linkId || "") === olid))
);
return true;
}
async function placeOneExact(state, ctx, { side, price, qty, info = "GRID", qtyRoundingMode = "floor" }) {
const p = Number(price);
const q = Number(qty);
if (!Number.isFinite(p) || p <= 0 || !Number.isFinite(q) || q <= 0) return null;
if (IS_PAPER) {
const out = paperPlaceOneExactQty(state, "", { side, price: p, qty: q, info, gridTag: "G1" });
if (!out) return null;
out.linkId = out.linkId || "";
out.id = out.id || crypto.randomUUID();
return out;
}
const linkId = `BOTEU_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const placed = await placeLimitOrder({
category: CONFIG.CATEGORY,
symbol: ctx.symbol,
side,
qty: q,
price: p,
orderLinkId: linkId,
qtyRoundingMode,
});
if (placed?.skipped) return null;
const orderId = String(placed?.orderId || "");
let finalPrice = Number(placed?.price || p);
let finalQty = Number(placed?.qty || q);
// Exchange can normalize qty/price on create. Try to read back canonical values.
if (orderId) {
try {
const rows = await getOpenOrders(CONFIG.CATEGORY, ctx.symbol);
const hit = (rows || []).find((r) => String(r?.orderId || "") === orderId);
if (hit) {
const hp = Number(hit?.price || 0);
const hq = Number(hit?.qty || 0);
if (Number.isFinite(hp) && hp > 0) finalPrice = hp;
if (Number.isFinite(hq) && hq > 0) finalQty = hq;
}
} catch {}
}
const local = {
id: orderId,
linkId,
side,
price: finalPrice,
qty: finalQty,
notionalQuote: finalPrice * finalQty,
status: "OPEN",
placedAt: nowMs(),
info,
};
state.openOrders.push(local);
return local;
}
function chooseCounterStepPct() {
const raw = Number(CONFIG.COUNTER_GRID_STEP_PCT ?? CONFIG.MICRO_SPACING_PCT ?? 0.5);
if (!Number.isFinite(raw) || raw <= 0) return 0.5;
return raw;
}
function rememberCounterRef(state, counterOrder, ref) {
if (!counterOrder) return;
const id = String(counterOrder.id || "");
const linkId = String(counterOrder.linkId || "");
const payload = { ...ref, createdAt: nowMs() };
if (id) state.counterRefs.set(id, payload);
if (linkId) state.counterRefs.set(linkId, payload);
}
async function prepareCounterOrder(state, ctx, { side, price, qty }) {
let out = { price: Number(price), qty: Number(qty) };
if (!IS_LIVE) return out;
const specs = await __debugGetSpecs(CONFIG.CATEGORY, ctx.symbol);
const adjusted = applySpecsToOrder({
price: out.price,
qty: out.qty,
specs,
side,
qtyRoundingMode: "round",
});
if (!Number.isFinite(adjusted?.price) || adjusted.price <= 0 || !Number.isFinite(adjusted?.qty) || adjusted.qty <= 0) {
console.log(`⚠ counter ${side} skipped: quantity rounded to zero or below exchange minimum`);
return null;
}
const finalNotional = adjusted.price * adjusted.qty;
const settleTimeoutMs = Math.max(1000, Number(CONFIG.COUNTER_BALANCE_TIMEOUT_MS || 12000));
const settlePollMs = Math.max(100, Number(CONFIG.COUNTER_BALANCE_POLL_MS || 300));
const startedAt = Date.now();
let freeBase = 0;
let freeQuote = 0;
while (true) {
await syncBalances(state, ctx.base, ctx.quote);
await refreshOpenOrders(state, ctx);
const liveFunds = deriveLiveFunds(state);
freeBase = liveFunds.free.base;
freeQuote = liveFunds.free.quote;
const withinFree = side === "Sell"
? adjusted.qty <= freeBase + 1e-12
: finalNotional <= freeQuote + 1e-12;
if (withinFree) break;
if (Date.now() - startedAt >= settleTimeoutMs) {
console.log(
`⚠ counter ${side} skipped after balance wait: rounded ${side === "Sell" ? "qty" : "notional"} exceeds free ` +
`${side === "Sell" ? freeBase.toFixed(8) + " " + ctx.base : freeQuote.toFixed(6) + " " + ctx.quote}`
);
return null;
}
await new Promise((resolve) => setTimeout(resolve, settlePollMs));
}
console.log(
`📐 counter ${side} sizing | requested=${out.qty.toFixed(8)} -> rounded=${adjusted.qty.toFixed(8)} ` +
`diff=${(adjusted.qty - out.qty >= 0 ? "+" : "")}${(adjusted.qty - out.qty).toFixed(8)} ` +
`price=${adjusted.price} notional=${finalNotional.toFixed(6)} ` +
`free=${side === "Sell" ? freeBase.toFixed(8) + " " + ctx.base : freeQuote.toFixed(6) + " " + ctx.quote}`
);
return { price: adjusted.price, qty: adjusted.qty };
}
function consumeCounterLoop(state, fill) {
const id = String(fill.orderId || fill.id || "");
const linkId = String(fill.orderLinkId || fill.linkId || "");
const ref = (linkId && state.counterRefs.get(linkId)) || (id && state.counterRefs.get(id));
if (!ref) return { wasCounter: false, profitQuote: 0 };
const fillPrice = Number(fill.fillPrice || fill.price || 0);
const fillQty = Number(fill.qty || 0);
const parentPrice = Number(ref.parentPrice || 0);
const parentQty = Number(ref.parentQty || 0);
if (!(fillPrice > 0) || !(fillQty > 0) || !(parentPrice > 0) || !(parentQty > 0)) {
if (id) state.counterRefs.delete(id);
if (linkId) state.counterRefs.delete(linkId);
return { wasCounter: true, profitQuote: 0 };
}
const parentNotional = Number(ref.parentNotional || parentPrice * parentQty);
const counterNotional = Number(fill.notionalQuote || fillPrice * fillQty);
const parentFee = Number(ref.parentFee || 0);
const counterFee = Number(fill.fee || 0);
const parentFeeInQuote = ref.parentFeeInQuote === true;
const counterFeeInQuote = fill.feeInQuote === true || String(fill.feeCurrency || "").toUpperCase() === "QUOTE";
const parentBuyCost = parentNotional + (parentFeeInQuote ? parentFee : 0);
const counterBuyCost = counterNotional + (counterFeeInQuote ? counterFee : 0);
const parentSellProceeds = parentNotional - (parentFeeInQuote ? parentFee : 0);
const counterSellProceeds = counterNotional - (counterFeeInQuote ? counterFee : 0);
let profitQuote = 0;
if (ref.parentSide === "Buy" && fill.side === "Sell") {
profitQuote = counterSellProceeds - parentBuyCost;
} else if (ref.parentSide === "Sell" && fill.side === "Buy") {
profitQuote = parentSellProceeds - counterBuyCost;
}
if (!Number.isFinite(profitQuote)) profitQuote = 0;
if (id) state.counterRefs.delete(id);
if (linkId) state.counterRefs.delete(linkId);
state.loopStats.loopsTotal += 1;
state.loopStats.profitQuoteTotal += profitQuote;
state.loopStats.lastLoopProfit = profitQuote;
state.loopStats.lastLoopAt = nowMs();
return { wasCounter: true, profitQuote };
}
async function placeCounterFromFill(state, ctx, fill) {
const side = String(fill.side || "");
const fillPrice = Number(fill.fillPrice || fill.price || 0);
const fillQty = Number(fill.qty || 0);
const fillNotional = Number(fill.notionalQuote || fillPrice * fillQty);
const fee = Number(fill.fee || 0);
const feeCurrency = String(fill.feeCurrency || "").toUpperCase();
const feeInQuote = fill.feeInQuote === true || feeCurrency === "QUOTE";
const feeInBase = fill.feeInBase === true || feeCurrency === "BASE";
const netBaseQty = Number(fill.netBaseQty || (
side === "Buy" && feeInBase ? Math.max(0, fillQty - fee) : fillQty
));
if (!(fillPrice > 0) || !(fillQty > 0)) return null;
const stepPct = chooseCounterStepPct() / 100;
if (side === "Buy") {
const sellPrice = fillPrice * (1 + stepPct);
const sellQty = netBaseQty;
if (!(sellQty > 0)) return null;
const counter = await prepareCounterOrder(state, ctx, { side: "Sell", price: sellPrice, qty: sellQty });
if (!counter) return null;
const placed = await placeOneExact(state, ctx, {
side: "Sell",
price: counter.price,
qty: counter.qty,
info: "COUNTER_FROM_BUY",
qtyRoundingMode: "round",
});
if (placed) {
placed.counterRequested = {
side: "Sell",
price: counter.price,
qty: counter.qty,
notionalQuote: counter.price * counter.qty,
};
rememberCounterRef(state, placed, {
parentSide: "Buy",
parentPrice: fillPrice,
parentQty: sellQty,
parentNotional: fillNotional,
parentFee: fee,
parentFeeInQuote: feeInQuote,
qty: counter.qty,
});
}
return placed;
}
if (side === "Sell") {
const buyPrice = fillPrice * (1 - stepPct);
const proceedsQuote = Math.max(0, fillNotional - (feeInQuote ? fee : 0));
// Full reinvest from sell proceeds into lower buy counter.
let buyNotional = proceedsQuote;
if (CONFIG.STRAT_COUNTER_COMPOUNDING_ENABLED) {
buyNotional *= Math.max(0, Number(CONFIG.STRAT_COUNTER_COMPOUNDING_FACTOR || 1));
}
const buyQty = buyPrice > 0 ? buyNotional / buyPrice : 0;
if (!(buyQty > 0)) return null;
const counter = await prepareCounterOrder(state, ctx, { side: "Buy", price: buyPrice, qty: buyQty });
if (!counter) return null;
const placed = await placeOneExact(state, ctx, {
side: "Buy",
price: counter.price,
qty: counter.qty,
info: "COUNTER_FROM_SELL",
qtyRoundingMode: "round",
});
if (placed) {
placed.counterRequested = {
side: "Buy",
price: counter.price,
qty: counter.qty,
notionalQuote: counter.price * counter.qty,
proceedsQuote,
};
state.compoundingStats.fromSellReinvestQuoteTotal += proceedsQuote;
rememberCounterRef(state, placed, {
parentSide: "Sell",
parentPrice: fillPrice,
parentQty: fillQty,
parentNotional: fillNotional,
parentFee: fee,
parentFeeInQuote: feeInQuote,
qty: Number(placed.qty || counter.qty),
});
}
return placed;
}
return null;
}
function liveMinFundsOk(state) {
const q = Number(state.balances.quote || 0);
const b = Number(state.balances.base || 0);
const minQ = Number(CONFIG.LIVE_MIN_QUOTE || 0);
const minB = Number(CONFIG.LIVE_MIN_BASE || 0);
return {
ok: q >= minQ && b >= minB,
q,
b,
minQ,
minB,
};
}
function isInvalidApiKeyError(err) {
const msg = String(err?.message || err || "");
return (
/retCode\s*=\s*10003/i.test(msg) ||
/retCode\s*=\s*10010/i.test(msg) ||
/status code\s*401/i.test(msg) ||
/api key is invalid/i.test(msg) ||
/invalid api key/i.test(msg) ||
/unmatched ip/i.test(msg)
);
}
function isPermissionDeniedError(err) {
const msg = String(err?.message || err || "");
return /retCode\s*=\s*10005/i.test(msg) || /permission denied/i.test(msg);
}
async function ensureLiveMarketContextStrict(initialCtx) {
const configured = String(initialCtx?.symbol || "").toUpperCase();
if (!configured) throw new Error("FATAL_NO_RETRY: missing market symbol");
const typoHint = configured.endsWith("USTD")
? ` Did you mean ${configured.slice(0, -4)}USDT?`
: "";
try {
await __debugGetSpecs(CONFIG.CATEGORY, configured);
} catch (e) {
const msg = String(e?.message || e || "");
if (/No instrument info/i.test(msg)) {
const endpoint = process.env.BYBIT_TRADE_BASE_URL || process.env.BYBIT_BASE_URL || "https://api.bybit.eu";
let alternatives = [];
if (configured.endsWith("USDT")) {
const base = configured.slice(0, -4);
const probes = [`${base}USDC`, `${base}EUR`, `${base}PLN`];
for (const s of probes) {
try {
await __debugGetSpecs(CONFIG.CATEGORY, s);
alternatives.push(s);
} catch {}
}
}
const altHint = alternatives.length
? ` Available on this endpoint/category: ${alternatives.join(", ")}.`
: "";
throw new Error(
`FATAL_NO_RETRY: market ${configured} unavailable on ${endpoint} for category=${CONFIG.CATEGORY}. ` +
`Set BOT_MARKET to a supported symbol for this endpoint.${altHint}${typoHint}`
);
}
throw e;
}
const out = parseSymbol(configured);
return { ...out, pair: `${out.base}/${out.quote}` };
}
async function startupCheckLiveCredentials(ctx) {
const apiKey = process.env.BYBIT_API_KEY || process.env.BYBIT_KEY || "";
const apiSecret = process.env.BYBIT_API_SECRET || process.env.BYBIT_SECRET || "";
if (!apiKey || !apiSecret) {
throw new Error("FATAL_NO_RETRY: Missing API credentials (BYBIT_API_KEY/BYBIT_API_SECRET or BYBIT_KEY/BYBIT_SECRET)");
}
try {
const [feeRate, coins] = await Promise.all([
getFeeRate(CONFIG.CATEGORY, ctx.symbol),
getWalletBalances(),
]);
return { feeRate, coins };
} catch (err) {
if (isPermissionDeniedError(err)) {
throw new Error(
`FATAL_NO_RETRY: API key lacks required permissions (${err?.message || err}). ` +
`Enable Wallet read + Spot trading permissions for this key.`
);
}
if (isInvalidApiKeyError(err)) {
throw new Error(`FATAL_NO_RETRY: API keys invalid for Bybit account/auth (${err?.message || err})`);
}
throw err;
}
}
function applyWalletCoinsToState(state, base, quote, coins) {
state.balances.base = pickCoinBalance(coins, base);
state.balances.quote = pickCoinBalance(coins, quote);
state.available.base = pickCoinAvailable(coins, base);
state.available.quote = pickCoinAvailable(coins, quote);
}
async function doRegrid(state, ctx, price) {
await cancelAllUnified(state, ctx);
const liveFunds = IS_LIVE ? deriveLiveFunds(state) : null;
const freeBase = IS_PAPER ? Number(state.balances.base || 0) : liveFunds.free.base;
const freeQuote = IS_PAPER ? Number(state.balances.quote || 0) : liveFunds.free.quote;
const perOrder = Number(CONFIG.ORDER_QUOTE_VALUE || 0);
const sellQuoteEq = freeBase * Number(price || 0);
const grid = buildGrid({
midPrice: price,
clustersZoned: [],
balances: { base: freeBase, quote: freeQuote },
feeRate: Number(state.fee?.effective || 0),
strategyPolicy: {
allowNewBuys: true,
allowNewSells: true,
spacingMultBuy: 1,
spacingMultSell: 1,
orderCountMultBuy: 1,
orderCountMultSell: 1,
capitalUsagePct: 1,
},
});
const orders = Array.isArray(grid.orders) ? grid.orders : [];
const buyReq = orders.filter((o) => o.side === "Buy").length;
const sellReq = orders.filter((o) => o.side === "Sell").length;
let placed = 0;
for (const o of orders) {
const out = await placeOneExact(state, ctx, {
side: o.side,
price: Number(o.price),
qty: Number(o.qty),
info: "REGRID",
});
if (out) placed += 1;
}
if (IS_LIVE) await refreshOpenOrders(state, ctx);
console.log(`🔁 regrid completed | requested=${orders.length} placed=${placed}`);
if (sellReq === 0) {
console.log(
`ℹ regrid info: no SELL levels generated | free ${ctx.base}=${fmt8(freeBase)} (~${fmt(sellQuoteEq, 2)} ${ctx.quote}) | ` +
`orderQuote=${fmt(perOrder, 2)} ${ctx.quote}`
);
}
if (buyReq === 0) {
console.log(
`ℹ regrid info: no BUY levels generated | free ${ctx.quote}=${fmt8(freeQuote)} | ` +
`orderQuote=${fmt(perOrder, 2)} ${ctx.quote}`
);
}
}
async function pollLiveFills(state, ctx) {
const sinceMs = Number(state.liveExecCursorMs || Date.now() - 2 * 60 * 1000);
const rows = await getExecutions(CONFIG.CATEGORY, ctx.symbol, sinceMs);
if (!Array.isArray(rows) || rows.length === 0) return [];
let maxExecTime = sinceMs;
const fills = [];
for (const r of rows) {
const execId = String(r.execId || "");
if (!execId || state.liveSeenExecIds.has(execId)) continue;
state.liveSeenExecIds.add(execId);
const execTime = Number(r.execTime || 0);
if (Number.isFinite(execTime) && execTime > maxExecTime) maxExecTime = execTime;
fills.push({
orderId: String(r.orderId || ""),
orderLinkId: String(r.orderLinkId || ""),
side: String(r.side || ""),
fillPrice: Number(r.execPrice || r.price || 0),
qty: Number(r.execQty || r.qty || 0),
notionalQuote: Number(r.execValue || 0),
fee: Math.abs(Number(r.execFee || 0)),
feeCurrency: String(r.feeCurrency || r.feeCoin || ""),
execTime,
execId,
});
}