From 7eddc3b88ca1074f19615c46ba3f8c93392adc0a Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 24 Sep 2025 04:33:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20MakerEngine=E3=80=81Offset?= =?UTF-8?q?MakerEngine=20=E5=92=8C=20TrendEngine=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=AE=A2=E5=8D=95=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=92=A4=E5=8D=95=E5=92=8C=E6=AD=A2?= =?UTF-8?q?=E6=8D=9F=E5=88=A4=E6=96=AD=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=AE=A1=E5=88=92=E7=94=9F=E6=88=90=E5=92=8C=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=92=A4=E5=8D=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E4=BB=A3=E7=A0=81=E5=8F=AF=E8=AF=BB=E6=80=A7=E5=92=8C?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/lib/order-plan.ts | 47 ++++++++++ src/core/lib/orders.ts | 22 +++++ src/core/maker-engine.ts | 130 ++++++++++------------------ src/core/offset-maker-engine.ts | 149 +++++++++++--------------------- src/core/trend-engine.ts | 20 +---- src/utils/depth.ts | 42 +++++++++ src/utils/pnl.ts | 16 ++++ src/utils/price.ts | 19 ++++ src/utils/risk.ts | 26 ++++++ 9 files changed, 272 insertions(+), 199 deletions(-) create mode 100644 src/core/lib/order-plan.ts create mode 100644 src/core/lib/orders.ts create mode 100644 src/utils/depth.ts create mode 100644 src/utils/pnl.ts create mode 100644 src/utils/price.ts create mode 100644 src/utils/risk.ts diff --git a/src/core/lib/order-plan.ts b/src/core/lib/order-plan.ts new file mode 100644 index 0000000..5bc8869 --- /dev/null +++ b/src/core/lib/order-plan.ts @@ -0,0 +1,47 @@ +import type { AsterOrder } from "../../exchanges/types"; + +export interface OrderTarget { + side: "BUY" | "SELL"; + price: number; + amount: number; + reduceOnly: boolean; +} + +export function makeOrderPlan( + openOrders: AsterOrder[], + targets: OrderTarget[], + tolerance: number +): { toCancel: AsterOrder[]; toPlace: OrderTarget[] } { + const unmatched = new Set(targets.map((_, idx) => idx)); + const toCancel: AsterOrder[] = []; + + for (const order of openOrders) { + const price = Number(order.price); + if (!Number.isFinite(price)) { + toCancel.push(order); + continue; + } + const reduceOnly = order.reduceOnly === true; + const matchedIndex = targets.findIndex((target, index) => { + return ( + unmatched.has(index) && + target.side === order.side && + target.reduceOnly === reduceOnly && + Math.abs(price - target.price) <= tolerance + ); + }); + if (matchedIndex >= 0) { + unmatched.delete(matchedIndex); + } else { + toCancel.push(order); + } + } + + const toPlace = [...unmatched] + .map((idx) => targets[idx]) + .filter((t): t is OrderTarget => t !== undefined && t.amount > 1e-5); + + return { toCancel, toPlace }; +} + + diff --git a/src/core/lib/orders.ts b/src/core/lib/orders.ts new file mode 100644 index 0000000..5b3c7db --- /dev/null +++ b/src/core/lib/orders.ts @@ -0,0 +1,22 @@ +import type { ExchangeAdapter } from "../../exchanges/adapter"; +import type { AsterOrder } from "../../exchanges/types"; +import { isUnknownOrderError } from "../../utils/errors"; + +export async function safeCancelOrder( + exchange: ExchangeAdapter, + symbol: string, + order: AsterOrder, + onResolved: (orderId: number) => void, + onUnknown: () => void, + onError: (err: unknown) => void +): Promise { + try { + await exchange.cancelOrder({ symbol, orderId: order.orderId }); + onResolved(order.orderId); + } catch (error) { + if (isUnknownOrderError(error)) onUnknown(); + else onError(error); + } +} + + diff --git a/src/core/maker-engine.ts b/src/core/maker-engine.ts index 8a33117..c1c5e6c 100644 --- a/src/core/maker-engine.ts +++ b/src/core/maker-engine.ts @@ -10,12 +10,17 @@ import { toPrice1Decimal } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../state/trade-log"; import { isUnknownOrderError } from "../utils/errors"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; +import { computePositionPnl } from "../utils/pnl"; +import { getTopPrices, getMidOrLast } from "../utils/price"; +import { shouldStopLoss } from "../utils/risk"; import { marketClose, placeOrder, unlockOperating, } from "./order-coordinator"; import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator"; +import { makeOrderPlan } from "./lib/order-plan"; +import { safeCancelOrder } from "./lib/orders"; interface DesiredOrder { side: "BUY" | "SELL"; @@ -221,17 +226,14 @@ export class MakerEngine { } const depth = this.depthSnapshot!; - const bidLevel = depth.bids?.[0]; - const askLevel = depth.asks?.[0]; - const topBid = bidLevel ? Number(bidLevel[0]) : undefined; - const topAsk = askLevel ? Number(askLevel[0]) : undefined; - if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) { + const { topBid, topAsk } = getTopPrices(depth); + if (topBid == null || topAsk == null) { this.emitUpdate(); return; } - const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset); - const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset); + const bidPrice = toPrice1Decimal(topBid - this.config.bidOffset); + const askPrice = toPrice1Decimal(topAsk + this.config.askOffset); const position = getPosition(this.accountSnapshot, this.config.symbol); const absPosition = Math.abs(position.positionAmt); const desired: DesiredOrder[] = []; @@ -290,52 +292,36 @@ export class MakerEngine { private async syncOrders(targets: DesiredOrder[]): Promise { const tolerance = this.config.priceChaseThreshold; - const unmatched = new Set(targets.map((_, idx) => idx)); - const toCancel: AsterOrder[] = []; - - for (const order of this.openOrders) { - const price = Number(order.price); - if (!Number.isFinite(price)) { - toCancel.push(order); - continue; - } - const reduceOnly = order.reduceOnly === true; - if (this.pendingCancelOrders.has(order.orderId)) { - continue; - } - const matchedIndex = targets.findIndex((target, index) => { - if (!unmatched.has(index)) return false; - if (target.side !== order.side) return false; - if (target.reduceOnly !== reduceOnly) return false; - return Math.abs(price - target.price) <= tolerance; - }); - if (matchedIndex >= 0) { - unmatched.delete(matchedIndex); - continue; - } - toCancel.push(order); - } + const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(o.orderId)); + const { toCancel, toPlace } = makeOrderPlan(availableOrders, targets, tolerance); for (const order of toCancel) { + if (this.pendingCancelOrders.has(order.orderId)) continue; this.pendingCancelOrders.add(order.orderId); - try { - await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId }); - this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`); - } catch (error) { - if (isUnknownOrderError(error)) { + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + this.tradeLog.push( + "order", + `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}` + ); + }, + () => { this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); this.pendingCancelOrders.delete(order.orderId); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); - } else { + }, + (error) => { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(order.orderId); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } - } + ); } - for (const index of unmatched) { - const target = targets[index]; + for (const target of toPlace) { if (!target) continue; if (target.amount < EPS) continue; try { @@ -376,20 +362,10 @@ export class MakerEngine { } this.entryPricePendingLogged = false; - const pnl = position.positionAmt > 0 - ? (bidPrice - position.entryPrice) * absPosition - : (position.entryPrice - askPrice) * absPosition; - const unrealized = Number.isFinite(position.unrealizedProfit) - ? position.unrealizedProfit - : null; - const derivedLoss = pnl < -this.config.lossLimit; - const snapshotLoss = Boolean( - unrealized != null && - unrealized < -this.config.lossLimit && - pnl <= 0 - ); + const pnl = computePositionPnl(position, bidPrice, askPrice); + const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit); - if (derivedLoss || snapshotLoss) { + if (triggerStop) { // 价格操纵保护:只有平仓方向价格与标记价格在阈值内才允许市价平仓 const closeSideIsSell = position.positionAmt > 0; const closeSidePrice = closeSideIsSell ? bidPrice : askPrice; @@ -430,20 +406,24 @@ export class MakerEngine { for (const order of this.openOrders) { if (this.pendingCancelOrders.has(order.orderId)) continue; this.pendingCancelOrders.add(order.orderId); - try { - await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId }); - } catch (error) { - if (isUnknownOrderError(error)) { + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + // 成功撤销不记录日志,保持现有行为 + }, + () => { this.tradeLog.push("order", "订单已不存在,撤销跳过"); this.pendingCancelOrders.delete(order.orderId); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); - } else { + }, + (error) => { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(order.orderId); - // 与偏移做市保持一致:移除本地缓存中的异常订单,等待订单流推送重建 this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } - } + ); } } @@ -467,23 +447,15 @@ export class MakerEngine { private buildSnapshot(): MakerEngineSnapshot { const position = getPosition(this.accountSnapshot, this.config.symbol); - const bid = this.depthSnapshot?.bids?.[0]?.[0]; - const ask = this.depthSnapshot?.asks?.[0]?.[0]; - const bidNum = Number(bid); - const askNum = Number(ask); - const spread = Number.isFinite(bidNum) && Number.isFinite(askNum) ? askNum - bidNum : null; - const priceForPnl = position.positionAmt > 0 ? bidNum : askNum; - const pnl = Number.isFinite(priceForPnl) - ? (position.positionAmt > 0 - ? (priceForPnl! - position.entryPrice) * Math.abs(position.positionAmt) - : (position.entryPrice - priceForPnl!) * Math.abs(position.positionAmt)) - : 0; + const { topBid, topAsk } = getTopPrices(this.depthSnapshot); + const spread = topBid != null && topAsk != null ? topAsk - topBid : null; + const pnl = computePositionPnl(position, topBid, topAsk); return { ready: this.isReady(), symbol: this.config.symbol, - topBid: Number.isFinite(bidNum) ? bidNum : null, - topAsk: Number.isFinite(askNum) ? askNum : null, + topBid: topBid, + topAsk: topAsk, spread, position, pnl, @@ -515,14 +487,6 @@ export class MakerEngine { } private getReferencePrice(): number | null { - const bid = Number(this.depthSnapshot?.bids?.[0]?.[0]); - const ask = Number(this.depthSnapshot?.asks?.[0]?.[0]); - if (Number.isFinite(bid) && Number.isFinite(ask)) return (bid + ask) / 2; - if (this.tickerSnapshot) { - const last = Number(this.tickerSnapshot.lastPrice); - if (Number.isFinite(last)) return last; - } - return null; + return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } - } diff --git a/src/core/offset-maker-engine.ts b/src/core/offset-maker-engine.ts index add9d5c..f0dc6f1 100644 --- a/src/core/offset-maker-engine.ts +++ b/src/core/offset-maker-engine.ts @@ -10,6 +10,10 @@ import { toPrice1Decimal } from "../utils/math"; import { createTradeLog } from "../state/trade-log"; import { isUnknownOrderError } from "../utils/errors"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; +import { computeDepthStats } from "../utils/depth"; +import { computePositionPnl } from "../utils/pnl"; +import { getTopPrices, getMidOrLast } from "../utils/price"; +import { shouldStopLoss } from "../utils/risk"; import { marketClose, placeOrder, @@ -17,6 +21,8 @@ import { } from "./order-coordinator"; import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator"; import type { MakerEngineSnapshot } from "./maker-engine"; +import { makeOrderPlan } from "./lib/order-plan"; +import { safeCancelOrder } from "./lib/orders"; interface DesiredOrder { side: "BUY" | "SELL"; @@ -219,11 +225,8 @@ export class OffsetMakerEngine { } const depth = this.depthSnapshot!; - const bidLevel = depth.bids?.[0]; - const askLevel = depth.asks?.[0]; - const topBid = bidLevel ? Number(bidLevel[0]) : undefined; - const topAsk = askLevel ? Number(askLevel[0]) : undefined; - if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) { + const { topBid, topAsk } = getTopPrices(depth); + if (topBid == null || topAsk == null) { this.emitUpdate(); return; } @@ -310,27 +313,8 @@ export class OffsetMakerEngine { skipSellSide: boolean; imbalance: "balanced" | "buy_dominant" | "sell_dominant"; } { - const topBids = (depth.bids ?? []).slice(0, 10); - const topAsks = (depth.asks ?? []).slice(0, 10); - const buySum = topBids.reduce((total, [price, qty]) => { - const size = Number(qty); - return Number.isFinite(size) ? total + size : total; - }, 0); - const sellSum = topAsks.reduce((total, [price, qty]) => { - const size = Number(qty); - return Number.isFinite(size) ? total + size : total; - }, 0); - - const skipSellSide = sellSum === 0 || sellSum * 3 < buySum; - const skipBuySide = buySum === 0 || buySum * 3 < sellSum; - let imbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced"; - if (buySum > sellSum * 3) { - imbalance = "buy_dominant"; - } else if (sellSum > buySum * 3) { - imbalance = "sell_dominant"; - } - - return { buySum, sellSum, skipBuySide, skipSellSide, imbalance }; + // Keep existing behavior: 10 levels, ratio threshold 3x + return computeDepthStats(depth, 10, 3); } private async handleImbalanceExit( @@ -384,53 +368,38 @@ export class OffsetMakerEngine { private async syncOrders(targets: DesiredOrder[]): Promise { const tolerance = this.config.priceChaseThreshold; - const unmatched = new Set(targets.map((_, idx) => idx)); - const toCancel: AsterOrder[] = []; - - for (const order of this.openOrders) { - const price = Number(order.price); - if (!Number.isFinite(price)) { - toCancel.push(order); - continue; - } - const reduceOnly = order.reduceOnly === true; - if (this.pendingCancelOrders.has(order.orderId)) { - continue; - } - const matchedIndex = targets.findIndex((target, index) => { - if (!unmatched.has(index)) return false; - if (target.side !== order.side) return false; - if (target.reduceOnly !== reduceOnly) return false; - return Math.abs(price - target.price) <= tolerance; - }); - if (matchedIndex >= 0) { - unmatched.delete(matchedIndex); - continue; - } - toCancel.push(order); - } + const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(o.orderId)); + const { toCancel, toPlace } = makeOrderPlan(availableOrders, targets, tolerance); for (const order of toCancel) { + if (this.pendingCancelOrders.has(order.orderId)) continue; this.pendingCancelOrders.add(order.orderId); - try { - await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId }); - this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`); - } catch (error) { - if (isUnknownOrderError(error)) { + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + this.tradeLog.push( + "order", + `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}` + ); + // 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建 + }, + () => { this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); this.pendingCancelOrders.delete(order.orderId); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); - } else { + }, + (error) => { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(order.orderId); // 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建 this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } - } + ); } - for (const index of unmatched) { - const target = targets[index]; + for (const target of toPlace) { if (!target) continue; if (target.amount < EPS) continue; try { @@ -471,20 +440,10 @@ export class OffsetMakerEngine { } this.entryPricePendingLogged = false; - const pnl = position.positionAmt > 0 - ? (bidPrice - position.entryPrice) * absPosition - : (position.entryPrice - askPrice) * absPosition; - const unrealized = Number.isFinite(position.unrealizedProfit) - ? position.unrealizedProfit - : null; - const derivedLoss = pnl < -this.config.lossLimit; - const snapshotLoss = Boolean( - unrealized != null && - unrealized < -this.config.lossLimit && - pnl <= 0 - ); + const pnl = computePositionPnl(position, bidPrice, askPrice); + const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit); - if (derivedLoss || snapshotLoss) { + if (triggerStop) { this.tradeLog.push( "stop", `触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT` @@ -522,20 +481,25 @@ export class OffsetMakerEngine { for (const order of this.openOrders) { if (this.pendingCancelOrders.has(order.orderId)) continue; this.pendingCancelOrders.add(order.orderId); - try { - await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId }); - } catch (error) { - if (isUnknownOrderError(error)) { + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + // 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders + }, + () => { this.tradeLog.push("order", "订单已不存在,撤销跳过"); this.pendingCancelOrders.delete(order.orderId); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); - } else { + }, + (error) => { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(order.orderId); // 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建 this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } - } + ); } } @@ -559,23 +523,15 @@ export class OffsetMakerEngine { private buildSnapshot(): OffsetMakerEngineSnapshot { const position = getPosition(this.accountSnapshot, this.config.symbol); - const bid = this.depthSnapshot?.bids?.[0]?.[0]; - const ask = this.depthSnapshot?.asks?.[0]?.[0]; - const bidNum = Number(bid); - const askNum = Number(ask); - const spread = Number.isFinite(bidNum) && Number.isFinite(askNum) ? askNum - bidNum : null; - const priceForPnl = position.positionAmt > 0 ? bidNum : askNum; - const pnl = Number.isFinite(priceForPnl) - ? (position.positionAmt > 0 - ? (priceForPnl! - position.entryPrice) * Math.abs(position.positionAmt) - : (position.entryPrice - priceForPnl!) * Math.abs(position.positionAmt)) - : 0; + const { topBid, topAsk } = getTopPrices(this.depthSnapshot); + const spread = topBid != null && topAsk != null ? topAsk - topBid : null; + const pnl = computePositionPnl(position, topBid, topAsk); return { ready: this.isReady(), symbol: this.config.symbol, - topBid: Number.isFinite(bidNum) ? bidNum : null, - topAsk: Number.isFinite(askNum) ? askNum : null, + topBid: topBid, + topAsk: topAsk, spread, position, pnl, @@ -612,13 +568,6 @@ export class OffsetMakerEngine { } private getReferencePrice(): number | null { - const bid = Number(this.depthSnapshot?.bids?.[0]?.[0]); - const ask = Number(this.depthSnapshot?.asks?.[0]?.[0]); - if (Number.isFinite(bid) && Number.isFinite(ask)) return (bid + ask) / 2; - if (this.tickerSnapshot) { - const last = Number(this.tickerSnapshot.lastPrice); - if (Number.isFinite(last)) return last; - } - return null; + return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } } diff --git a/src/core/trend-engine.ts b/src/core/trend-engine.ts index 5104f25..e422611 100644 --- a/src/core/trend-engine.ts +++ b/src/core/trend-engine.ts @@ -14,6 +14,8 @@ import { getSMA, type PositionSnapshot, } from "../utils/strategy"; +import { computePositionPnl } from "../utils/pnl"; +import { getTopPrices, getMidOrLast } from "../utils/price"; import { marketClose, placeMarketOrder, @@ -601,11 +603,7 @@ export class TrendEngine { : price < sma30 ? "做空" : "无信号"; - const pnl = price != null && position - ? (position.positionAmt > 0 - ? (price - position.entryPrice) * Math.abs(position.positionAmt) - : (position.entryPrice - price) * Math.abs(position.positionAmt)) - : 0; + const pnl = price != null ? computePositionPnl(position, price, price) : 0; return { ready: this.isReady(), symbol: this.config.symbol, @@ -646,17 +644,7 @@ export class TrendEngine { } private getReferencePrice(): number | null { - if (this.tickerSnapshot) { - const last = Number(this.tickerSnapshot.lastPrice); - if (Number.isFinite(last)) return last; - } - if (this.depthSnapshot) { - const bid = Number(this.depthSnapshot.bids?.[0]?.[0]); - const ask = Number(this.depthSnapshot.asks?.[0]?.[0]); - if (Number.isFinite(bid) && Number.isFinite(ask)) return (bid + ask) / 2; - } - if (this.lastPrice != null && Number.isFinite(this.lastPrice)) return this.lastPrice; - return null; + return getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ?? (this.lastPrice != null && Number.isFinite(this.lastPrice) ? this.lastPrice : null); } } diff --git a/src/utils/depth.ts b/src/utils/depth.ts new file mode 100644 index 0000000..52de475 --- /dev/null +++ b/src/utils/depth.ts @@ -0,0 +1,42 @@ +import type { AsterDepth } from "../exchanges/types"; + +export type DepthImbalance = "balanced" | "buy_dominant" | "sell_dominant"; + +export function computeDepthStats( + depth: AsterDepth, + levels = 10, + ratio = 3 +): { + buySum: number; + sellSum: number; + skipBuySide: boolean; + skipSellSide: boolean; + imbalance: DepthImbalance; +} { + const topBids = (depth.bids ?? []).slice(0, levels); + const topAsks = (depth.asks ?? []).slice(0, levels); + + const buySum = topBids.reduce((total, level) => { + const qty = Number(level?.[1]); + return Number.isFinite(qty) ? total + qty : total; + }, 0); + + const sellSum = topAsks.reduce((total, level) => { + const qty = Number(level?.[1]); + return Number.isFinite(qty) ? total + qty : total; + }, 0); + + const skipSellSide = sellSum === 0 || sellSum * ratio < buySum; + const skipBuySide = buySum === 0 || buySum * ratio < sellSum; + + let imbalance: DepthImbalance = "balanced"; + if (buySum > sellSum * ratio) { + imbalance = "buy_dominant"; + } else if (sellSum > buySum * ratio) { + imbalance = "sell_dominant"; + } + + return { buySum, sellSum, skipBuySide, skipSellSide, imbalance }; +} + + diff --git a/src/utils/pnl.ts b/src/utils/pnl.ts new file mode 100644 index 0000000..245dc63 --- /dev/null +++ b/src/utils/pnl.ts @@ -0,0 +1,16 @@ +import type { PositionSnapshot } from "./strategy"; + +export function computePositionPnl( + position: PositionSnapshot, + bestBid?: number | null, + bestAsk?: number | null +): number { + const priceForPnl = position.positionAmt > 0 ? bestBid : bestAsk; + if (!Number.isFinite(priceForPnl as number)) return 0; + const absAmt = Math.abs(position.positionAmt); + return position.positionAmt > 0 + ? ((priceForPnl as number) - position.entryPrice) * absAmt + : (position.entryPrice - (priceForPnl as number)) * absAmt; +} + + diff --git a/src/utils/price.ts b/src/utils/price.ts new file mode 100644 index 0000000..fb5e6ba --- /dev/null +++ b/src/utils/price.ts @@ -0,0 +1,19 @@ +import type { AsterDepth, AsterTicker } from "../exchanges/types"; + +export function getTopPrices(depth?: AsterDepth | null): { topBid: number | null; topAsk: number | null } { + const bid = Number(depth?.bids?.[0]?.[0]); + const ask = Number(depth?.asks?.[0]?.[0]); + return { + topBid: Number.isFinite(bid) ? bid : null, + topAsk: Number.isFinite(ask) ? ask : null, + }; +} + +export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null { + const { topBid, topAsk } = getTopPrices(depth); + if (topBid != null && topAsk != null) return (topBid + topAsk) / 2; + const last = Number(ticker?.lastPrice); + return Number.isFinite(last) ? last : null; +} + + diff --git a/src/utils/risk.ts b/src/utils/risk.ts new file mode 100644 index 0000000..0fa5c2f --- /dev/null +++ b/src/utils/risk.ts @@ -0,0 +1,26 @@ +import type { PositionSnapshot } from "./strategy"; + +export function shouldStopLoss( + position: PositionSnapshot, + bestBid: number, + bestAsk: number, + lossLimit: number +): boolean { + const absPosition = Math.abs(position.positionAmt); + if (absPosition < 1e-5) return false; + + const pnl = position.positionAmt > 0 + ? (bestBid - position.entryPrice) * absPosition + : (position.entryPrice - bestAsk) * absPosition; + + const unrealized = Number.isFinite(position.unrealizedProfit) + ? (position.unrealizedProfit as number) + : null; + + const derivedLoss = pnl < -lossLimit; + const snapshotLoss = Boolean(unrealized != null && unrealized < -lossLimit && pnl <= 0); + + return derivedLoss || snapshotLoss; +} + +