feat: add minBaseAmount and minQuoteAmount to ExchangePrecision and LighterGateway for improved trading logic

This commit is contained in:
discountry
2025-12-07 21:52:35 +08:00
parent 180f47b6e0
commit 2cb6c4fef1
3 changed files with 102 additions and 21 deletions
+2
View File
@@ -33,6 +33,8 @@ export interface ExchangePrecision {
priceDecimals?: number; priceDecimals?: number;
sizeDecimals?: number; sizeDecimals?: number;
marketId?: number; marketId?: number;
minBaseAmount?: number;
minQuoteAmount?: number;
} }
export interface ExchangeAdapter { export interface ExchangeAdapter {
+4
View File
@@ -1722,6 +1722,8 @@ export class LighterGateway {
priceDecimals: number; priceDecimals: number;
sizeDecimals: number; sizeDecimals: number;
marketId: number | null; marketId: number | null;
minBaseAmount: number | null;
minQuoteAmount: number | null;
}> { }> {
await this.loadMetadata(); await this.loadMetadata();
if (this.priceDecimals == null || this.sizeDecimals == null) { if (this.priceDecimals == null || this.sizeDecimals == null) {
@@ -1735,6 +1737,8 @@ export class LighterGateway {
priceDecimals: this.priceDecimals, priceDecimals: this.priceDecimals,
sizeDecimals: this.sizeDecimals, sizeDecimals: this.sizeDecimals,
marketId: this.marketId ?? null, marketId: this.marketId ?? null,
minBaseAmount: this.minBaseAmount ?? null,
minQuoteAmount: this.minQuoteAmount ?? null,
}; };
} }
+96 -21
View File
@@ -70,6 +70,8 @@ export class OffsetMakerEngine {
private readonly sessionVolume = new SessionVolumeTracker(); private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1; private priceTick: number = 0.1;
private qtyStep: number = 0.001; private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null; private precisionSync: Promise<void> | null = null;
private marketType: "perp" | "spot" = "perp"; private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null; private baseAsset: string | null = null;
@@ -343,12 +345,35 @@ export class OffsetMakerEngine {
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk); const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null; const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null; const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
const absPosition = Math.abs(position.positionAmt); const rawAbsPosition = Math.abs(position.positionAmt);
const minSell =
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
? this.minBaseAmount!
: Math.max(this.config.tradeAmount, this.qtyStep);
let absPosition = rawAbsPosition;
const tinySpotPosition =
isSpotMarket &&
minSell > 0 &&
rawAbsPosition > EPS &&
rawAbsPosition + EPS < minSell;
if (tinySpotPosition) {
absPosition = 0; // treat as flat to allow buys to accumulate until reaching minimum sell size
}
const desired: DesiredOrder[] = []; const desired: DesiredOrder[] = [];
const canEnter = !this.rateLimit.shouldBlockEntries(); const canEnter = !this.rateLimit.shouldBlockEntries();
if (absPosition < EPS && isSpotMarket) { if (absPosition < EPS && isSpotMarket) {
this.entryPricePendingLogged = false; this.entryPricePendingLogged = false;
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
const maxBase = Math.max(baseAvail, baseWallet);
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
// 无法卖出,跳过卖单,允许买单累计
this.lastSellPriceViable = false;
if (!skipSellSide) {
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
}
}
if (!skipBuySide && canEnter) { if (!skipBuySide && canEnter) {
const buyAmount = this.computeSpotOrderSize({ const buyAmount = this.computeSpotOrderSize({
side: "BUY", side: "BUY",
@@ -369,24 +394,35 @@ export class OffsetMakerEngine {
} }
} }
if (!skipSellSide && canEnter) { if (!skipSellSide && canEnter) {
const desiredSellAmount = const baseAvail = balancesForSpot?.baseAvailable ?? 0;
isSpotMarket && balancesForSpot ? balancesForSpot.baseAvailable : this.config.tradeAmount; const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
const sellAmount = this.computeSpotOrderSize({ const maxBase = Math.max(baseAvail, baseWallet);
side: "SELL", if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
desiredAmount: desiredSellAmount, // 持仓低于最小卖单量,跳过卖单,等待累积
price: askPrice != null ? Number(askPrice) : null, if (this.lastSellPriceViable) {
balances: balancesForSpot, this.lastSellPriceViable = false;
}); this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
if (askPrice != null && sellAmount >= EPS) { }
this.lastSellPriceViable = true; } else {
desired.push({ side: "SELL", price: askPrice, amount: sellAmount, reduceOnly: false }); const desiredSellAmount =
} else if (this.lastSellPriceViable) { isSpotMarket && balancesForSpot ? balancesForSpot.baseAvailable : this.config.tradeAmount;
this.lastSellPriceViable = false; const sellAmount = this.computeSpotOrderSize({
const reason = side: "SELL",
sellAmount < EPS && isSpotMarket desiredAmount: desiredSellAmount,
? "现货可用基础资产不足,跳过卖单" price: askPrice != null ? Number(askPrice) : null,
: "跳过卖单:价差不足以构造maker价格"; balances: balancesForSpot,
this.tradeLog.push("info", reason); });
if (askPrice != null && sellAmount >= EPS) {
this.lastSellPriceViable = true;
desired.push({ side: "SELL", price: askPrice, amount: sellAmount, reduceOnly: false });
} else if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
const reason =
sellAmount < EPS && isSpotMarket
? "现货可用基础资产不足,跳过卖单"
: "跳过卖单:价差不足以构造maker价格";
this.tradeLog.push("info", reason);
}
} }
} }
} else if (absPosition < EPS) { } else if (absPosition < EPS) {
@@ -395,20 +431,37 @@ export class OffsetMakerEngine {
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false }); desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
} }
if (!skipSellSide && canEnter) { if (!skipSellSide && canEnter) {
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
}
}
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false }); desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
} }
} else { } else {
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
const closePrice = closeSide === "SELL" ? closeAskPrice : closeBidPrice; const closePrice = closeSide === "SELL" ? closeAskPrice : closeBidPrice;
if (isSpotMarket && minSell > 0 && rawAbsPosition + EPS < minSell) {
// 持仓未达最小卖出量,等待累积,不下单
this.lastSellPriceViable = false;
this.lastBuyPriceViable = false;
this.desiredOrders = [];
this.sessionVolume.update(position, this.getReferencePrice());
this.emitUpdate();
return;
}
const closeQty = const closeQty =
isSpotMarket && balancesForSpot isSpotMarket && balancesForSpot
? this.computeSpotOrderSize({ ? this.computeSpotOrderSize({
side: "SELL", side: "SELL",
desiredAmount: absPosition, desiredAmount: rawAbsPosition,
price: closePrice != null ? Number(closePrice) : null, price: closePrice != null ? Number(closePrice) : null,
balances: balancesForSpot, balances: balancesForSpot,
}) })
: absPosition; : rawAbsPosition;
if (closePrice != null && closeQty >= EPS) { if (closePrice != null && closeQty >= EPS) {
desired.push({ side: closeSide, price: closePrice, amount: closeQty, reduceOnly: false }); desired.push({ side: closeSide, price: closePrice, amount: closeQty, reduceOnly: false });
} }
@@ -631,6 +684,19 @@ export class OffsetMakerEngine {
for (const target of toPlace) { for (const target of toPlace) {
if (!target) continue; if (!target) continue;
if (target.amount < EPS) continue; if (target.amount < EPS) continue;
if (
this.marketType === "spot" &&
this.minBaseAmount != null &&
target.side === "SELL" &&
target.amount + EPS < this.minBaseAmount
) {
// Skip placing sells that would be bumped by venue minimums
if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
}
continue;
}
try { try {
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly; const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
await placeOrder( await placeOrder(
@@ -813,6 +879,12 @@ export class OffsetMakerEngine {
updated = true; updated = true;
} }
} }
if (Number.isFinite(precision.minBaseAmount)) {
this.minBaseAmount = precision.minBaseAmount!;
}
if (Number.isFinite(precision.minQuoteAmount)) {
this.minQuoteAmount = precision.minQuoteAmount!;
}
if (updated) { if (updated) {
this.tradeLog.push( this.tradeLog.push(
"info", "info",
@@ -936,6 +1008,9 @@ export class OffsetMakerEngine {
if (!params.balances) return desired; if (!params.balances) return desired;
if (params.side === "SELL") { if (params.side === "SELL") {
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0); const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
return 0; // below venue min trade size; skip sell until enough balance
}
return this.roundToStep(Math.max(0, Math.min(desired, cap))); return this.roundToStep(Math.max(0, Math.min(desired, cap)));
} }
const price = Number(params.price); const price = Number(params.price);