feat(maker-points): enhance configuration for maker points bands

Added new configuration options for maker points bands, including target distances and maximum distance limits. Updated the logic to handle these configurations, ensuring backward compatibility with existing defaults. Enhanced documentation and tests to cover the new features and ensure correct functionality across the system.
This commit is contained in:
discountry
2026-08-18 15:16:49 +08:00
parent f3a96886ac
commit 85954461f3
16 changed files with 1085 additions and 306 deletions
+50 -3
View File
@@ -5,6 +5,7 @@
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
import { language, type Language } from "./i18n";
import { DEFAULT_BAND_BPS, MAKER_POINTS_ZERO_BPS } from "./strategy/maker-points-logic";
export interface StandxTokenConfig {
expiryTimestamp: number | null;
@@ -226,7 +227,20 @@ export interface MakerPointsConfig {
band10To30Amount: number;
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
band30To100Amount: number;
/** 0-10 bps 档位目标距离(距 mark price 的 bps),默认 9 */
band0To10Bps: number;
/** 10-30 bps 档位目标距离(距 mark price 的 bps),默认 29 */
band10To30Bps: number;
/** 30-100 bps 档位目标距离(距 mark price 的 bps),默认 40 */
band30To100Bps: number;
/** 距 mark price 的最大允许距离(bps)。100 bps 处倍率归零,默认 95 留安全边际 */
maxDistanceBps: number;
/** 近档最小重挂阈值(bps),默认 3 */
minRepriceBps: number;
/** 远档重挂阈值 = max(minRepriceBps, 目标距离 × 该比例),默认 0.15 */
bandRepriceRatio: number;
/** 成交后立即止损的触发价偏移(bps),默认 2;随标的价格自动缩放 */
slOffsetBps: number;
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
enableBinanceDepthCancel: boolean;
/** Binance 深度监控窗口(bps),默认 3 */
@@ -239,6 +253,33 @@ export interface MakerPointsConfig {
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
const makerPointsBands = {
band0To10: {
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
bps: parseNumber(process.env.MAKER_POINTS_BAND_0_10_BPS, DEFAULT_BAND_BPS["0-10"]),
},
band10To30: {
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
bps: parseNumber(process.env.MAKER_POINTS_BAND_10_30_BPS, DEFAULT_BAND_BPS["10-30"]),
},
band30To100: {
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
bps: parseNumber(process.env.MAKER_POINTS_BAND_30_100_BPS, DEFAULT_BAND_BPS["30-100"]),
},
};
/**
* 最大挂单距离不能小于任何启用档位的目标距离 —— 否则夹回会把挂单推向盘口,
* 正好是最容易被吃的方向。上限锁在 100 bps,那里倍率归零。
*/
function resolveMaxDistanceBps(): number {
const configured = parseNumber(process.env.MAKER_POINTS_MAX_DISTANCE_BPS, 95);
const widest = Object.values(makerPointsBands)
.filter((band) => band.enabled)
.reduce((max, band) => Math.max(max, band.bps), 1);
return Math.min(MAKER_POINTS_ZERO_BPS, Math.max(configured, widest));
}
export const makerPointsConfig: MakerPointsConfig = {
symbol: resolveSymbolFromEnv("standx"),
perOrderAmount: defaultMakerPointsAmount,
@@ -252,13 +293,19 @@ export const makerPointsConfig: MakerPointsConfig = {
),
priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001),
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
enableBand0To10: makerPointsBands.band0To10.enabled,
enableBand10To30: makerPointsBands.band10To30.enabled,
enableBand30To100: makerPointsBands.band30To100.enabled,
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
band0To10Bps: makerPointsBands.band0To10.bps,
band10To30Bps: makerPointsBands.band10To30.bps,
band30To100Bps: makerPointsBands.band30To100.bps,
maxDistanceBps: resolveMaxDistanceBps(),
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
bandRepriceRatio: parseNumber(process.env.MAKER_POINTS_BAND_REPRICE_RATIO, 0.15),
slOffsetBps: parseNumber(process.env.MAKER_POINTS_SL_OFFSET_BPS, 2),
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
binanceDepthWindowBps: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS, 3),
binanceDepthImbalanceRatio: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO, 9),
+7 -2
View File
@@ -243,6 +243,10 @@ const translations: Record<string, TranslationEntry> = {
zh: "交易所: {exchange} 交易对: {symbol} 买一价: {bid} 卖一价: {ask} 点差: {spread}",
en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}",
},
"makerPoints.markLine": {
zh: "计分基准 Mark: {mark} 100bps 外倍率归零,超过 {maxDistance}bps 不再挂单",
en: "Scoring anchor (mark): {mark} | zero multiplier beyond 100bps; quotes capped at {maxDistance}bps",
},
"makerPoints.quoteLine": {
zh: "挂单模式: {mode} BUY {buy} SELL {sell}",
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
@@ -252,9 +256,10 @@ const translations: Record<string, TranslationEntry> = {
en: "Binance depth (±{windowBps}bps): bid {buy} | ask {sell} | Status: {status}",
},
"makerPoints.bandDepthLine": {
zh: "StandX 档位 {band}bps 深度: 买 {buy} {sell}",
en: "StandX band {band}bps depth: buy {buy} | sell {sell}",
zh: "档位 {band} 目标 {target}bps 买 {buyDist} ×{buyMult} 深度 {buy} 卖 {sellDist} ×{sellMult} 深度 {sell}",
en: "Band {band} target {target}bps | buy {buyDist} ×{buyMult} depth {buy} | sell {sellDist} ×{sellMult} depth {sell}",
},
"makerPoints.bandDisabled": { zh: "(已关闭)", en: " (off)" },
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
+319 -220
View File
@@ -35,7 +35,16 @@ import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
import { buildBpsTargets } from "./maker-points-logic";
import {
bandRepriceToleranceBps,
buildBandTargets,
makerPointsMultiplier,
resolveSafeQuotePrice,
shouldKeepQuote,
signedDistanceBps,
type BandTarget,
type MakerPointsBand,
} from "./maker-points-logic";
import { t } from "../i18n";
import { IsolatedMarginGuard } from "./common/isolated-margin-guard";
import { TokenExpiryGuard } from "./common/token-expiry-guard";
@@ -52,11 +61,28 @@ interface DesiredOrder {
reduceOnly: boolean;
}
export interface BandStatus {
band: MakerPointsBand;
/** 该档位配置的目标距离(bps,距 mark price)。 */
bps: number;
enabled: boolean;
/** 盘口一档到目标价之间的挂单量,用于判断被吃穿的风险。 */
buyDepth: number | null;
sellDepth: number | null;
/** 实际在场挂单距 mark 的距离;无挂单时为 null。 */
buyDistanceBps: number | null;
sellDistanceBps: number | null;
/** 上述实际距离对应的 Maker Points 倍率。 */
buyMultiplier: number | null;
sellMultiplier: number | null;
}
export interface MakerPointsSnapshot {
ready: boolean;
symbol: string;
topBid: number | null;
topAsk: number | null;
markPrice: number | null;
spread: number | null;
priceDecimals: number;
position: PositionSnapshot;
@@ -75,13 +101,11 @@ export interface MakerPointsSnapshot {
binance: boolean;
};
binanceDepth: BinanceDepthSnapshot | null;
bandDepths: Array<{
band: "0-10" | "10-30" | "30-100";
bps: number;
buyDepth: number | null;
sellDepth: number | null;
enabled: boolean;
}>;
/** 配置的最大挂单距离(bps),用于仪表盘提示与 100bps 悬崖的安全边际。 */
maxDistanceBps: number;
bandDepths: BandStatus[];
/** 每个在场挂单已在盘口停留的毫秒数;Maker Points 要求超过 3 秒才计分。 */
orderRestingMs: Record<string, number>;
quoteStatus: {
closeOnly: boolean;
skipBuy: boolean;
@@ -134,10 +158,10 @@ export class MakerPointsEngine {
private lastCloseOnly = false;
private lastSkipBuy = false;
private lastSkipSell = false;
private lastQuoteBid1: number | null = null;
private lastQuoteAsk1: number | null = null;
// 跟踪各档位深度是否足够的状态 (按 bps 值索引)
private lastDepthOkStatus: Record<number, { buy: boolean; sell: boolean }> = {};
/** 最近一轮实际下发的报价距 mark 的距离,用于仪表盘展示倍率。 */
private lastQuoteDistanceBps: Partial<Record<MakerPointsBand, { buy: number | null; sell: number | null }>> = {};
private readinessLogged = {
account: false,
@@ -549,8 +573,7 @@ export class MakerPointsEngine {
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
// 重置 reprice 基准,强制下一次重新计算
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
this.lastQuoteDistanceBps = {};
this.desiredOrders = [];
this.lastDesiredSummary = null;
@@ -705,39 +728,18 @@ export class MakerPointsEngine {
this.lastSkipSell = skipSell;
}
const closeOnlyChanged = closeOnly !== prevCloseOnly;
const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell;
const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk);
const depthStatusChanged = this.checkDepthStatusChanged(depth, topBid, topAsk);
const shouldRecompute =
closeOnly ||
repriceNeeded ||
closeOnlyChanged ||
skipChanged ||
depthStatusChanged ||
this.desiredOrders.length === 0;
const desired = shouldRecompute
? closeOnly
? this.buildCloseOnlyOrders(position, topBid, topAsk)
: this.buildDesiredOrders({
bid1: topBid,
ask1: topAsk,
skipBuy,
skipSell,
depth,
})
: this.desiredOrders;
if (shouldRecompute) {
if (closeOnly) {
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
} else {
this.lastQuoteBid1 = topBid;
this.lastQuoteAsk1 = topAsk;
}
}
// 每轮都重算:报价是否真的变动由各档位的 sticky 判定决定,
// 价格没漂出档位容差时会复用现有挂单价,makeOrderPlan 也就不会撤单。
const desired = closeOnly
? this.buildCloseOnlyOrders(position, topBid, topAsk)
: this.buildDesiredOrders({
bid1: topBid,
ask1: topAsk,
anchor: this.getQuoteAnchor(depth),
skipBuy,
skipSell,
depth,
});
this.desiredOrders = desired;
this.logDesiredOrders(desired);
@@ -759,143 +761,173 @@ export class MakerPointsEngine {
}
}
/** 当前启用的档位及其目标距离,按距离升序。 */
private bandTargets(): BandTarget[] {
return buildBandTargets({
band0To10: this.config.enableBand0To10,
band10To30: this.config.enableBand10To30,
band30To100: this.config.enableBand30To100,
band0To10Bps: this.config.band0To10Bps,
band10To30Bps: this.config.band10To30Bps,
band30To100Bps: this.config.band30To100Bps,
});
}
private amountForBand(band: MakerPointsBand): number {
if (band === "0-10") return Number(this.config.band0To10Amount);
if (band === "10-30") return Number(this.config.band10To30Amount);
return Number(this.config.band30To100Amount);
}
private toleranceFor(targetBps: number): number {
return bandRepriceToleranceBps(targetBps, this.config.minRepriceBps, this.config.bandRepriceRatio);
}
/**
* 距离计算的基准价:活动按 mark price 计分,所以优先用交易所 mark price,
* 拿不到时退回盘口中值。
*/
private getQuoteAnchor(depth: Depth | null): number | null {
const mark = Number(this.tickerSnapshot?.markPrice);
if (Number.isFinite(mark) && mark > 0) return mark;
const { topBid, topAsk } = getTopPrices(depth ?? this.depthSnapshot);
if (topBid == null || topAsk == null) return null;
return (topBid + topAsk) / 2;
}
/** 可以被 sticky 复用的在场开仓挂单。 */
private activeEntryOrders(): Order[] {
return this.openOrders.filter(
(order) =>
order.symbol === this.config.symbol &&
!order.reduceOnly &&
isOrderActiveStatus(order.status) &&
!this.pendingCancelOrders.has(String(order.orderId))
);
}
/**
* 在现有挂单中找出还能留在原地的那一张:距离仍在本档容差内、数量一致、
* 且没有被其它档位认领。找到就复用它的价格,这一轮该档位不撤不挂。
*/
private pickStickyPrice(params: {
side: "BUY" | "SELL";
targetBps: number;
anchor: number;
amount: number;
pool: Order[];
claimed: Set<string>;
}): number | null {
const { side, targetBps, anchor, amount, pool, claimed } = params;
const tolerance = this.toleranceFor(targetBps);
const qtyTolerance = Math.max(this.precision.qtyStep, EPS);
let best: { id: string; price: number; delta: number } | null = null;
for (const order of pool) {
if (order.side !== side) continue;
const id = String(order.orderId);
if (claimed.has(id)) continue;
const price = Number(order.price);
if (!Number.isFinite(price) || price <= 0) continue;
const origQty = Number(order.origQty);
if (Number.isFinite(origQty) && Math.abs(origQty - amount) > qtyTolerance) continue;
const keep = shouldKeepQuote({
side,
existingPrice: price,
anchor,
targetBps,
toleranceBps: tolerance,
maxDistanceBps: this.config.maxDistanceBps,
});
if (!keep) continue;
const delta = Math.abs(signedDistanceBps(side, price, anchor) - targetBps);
if (!best || delta < best.delta) {
best = { id, price, delta };
}
}
if (!best) return null;
claimed.add(best.id);
return best.price;
}
private buildDesiredOrders(params: {
bid1: number;
ask1: number;
anchor: number | null;
skipBuy: boolean;
skipSell: boolean;
depth: Depth | null;
}): DesiredOrder[] {
const { bid1, ask1, skipBuy, skipSell, depth } = params;
const targets = buildBpsTargets({
band0To10: this.config.enableBand0To10,
band10To30: this.config.enableBand10To30,
band30To100: this.config.enableBand30To100,
}).sort((a, b) => b - a);
const { bid1, ask1, anchor, skipBuy, skipSell, depth } = params;
// 远档先算,让它优先认领距离最匹配的在场挂单
const targets = this.bandTargets().sort((a, b) => b.bps - a.bps);
if (!targets.length) return [];
const priceDecimals = this.getPriceDecimals();
const desired: DesiredOrder[] = [];
const minDepth = this.config.filterMinDepth;
const desired: DesiredOrder[] = [];
const pool = this.activeEntryOrders();
const claimed = new Set<string>();
const distances: Partial<Record<MakerPointsBand, { buy: number | null; sell: number | null }>> = {};
const getAmountForBps = (bps: number): number => {
if (bps <= 10) return Number(this.config.band0To10Amount);
if (bps <= 30) return Number(this.config.band10To30Amount);
return Number(this.config.band30To100Amount);
};
for (const bps of targets) {
const amount = getAmountForBps(bps);
for (const target of targets) {
const amount = this.amountForBand(target.band);
const record: { buy: number | null; sell: number | null } = { buy: null, sell: null };
distances[target.band] = record;
if (!Number.isFinite(amount) || amount <= 0) continue;
// 所有档位都检查深度
const shouldCheckDepth = minDepth > 0;
for (const side of ["BUY", "SELL"] as const) {
if (side === "BUY" ? skipBuy : skipSell) continue;
if (!skipBuy) {
const targetPrice = this.normalizeDepthTargetPrice(bid1 * (1 - bps / 10000), priceDecimals);
if (targetPrice != null) {
if (shouldCheckDepth) {
const depthQty = getDepthBetweenPrices(depth, "BUY", targetPrice);
if (depthQty < minDepth) {
this.logThinDepthSkip("BUY", bps, depthQty, minDepth);
} else {
this.resetThinDepthSkip("BUY", bps);
desired.push({
side: "BUY",
price: formatPriceToString(targetPrice, priceDecimals),
amount,
reduceOnly: false,
});
}
} else {
desired.push({
side: "BUY",
price: formatPriceToString(targetPrice, priceDecimals),
amount,
reduceOnly: false,
});
const raw = resolveSafeQuotePrice({
side,
targetBps: target.bps,
markPrice: anchor,
bookPrice: side === "BUY" ? bid1 : ask1,
maxDistanceBps: this.config.maxDistanceBps,
});
const ideal = raw == null ? null : this.normalizeDepthTargetPrice(raw, priceDecimals);
if (ideal == null) continue;
// 深度保护先于价格复用:目标价前方挂单太薄就整档不挂
if (minDepth > 0) {
const depthQty = getDepthBetweenPrices(depth, side, ideal);
if (depthQty < minDepth) {
this.logThinDepthSkip(side, target.bps, depthQty, minDepth);
continue;
}
this.resetThinDepthSkip(side, target.bps);
}
}
if (!skipSell) {
const targetPrice = this.normalizeDepthTargetPrice(ask1 * (1 + bps / 10000), priceDecimals);
if (targetPrice != null) {
if (shouldCheckDepth) {
const depthQty = getDepthBetweenPrices(depth, "SELL", targetPrice);
if (depthQty < minDepth) {
this.logThinDepthSkip("SELL", bps, depthQty, minDepth);
} else {
this.resetThinDepthSkip("SELL", bps);
desired.push({
side: "SELL",
price: formatPriceToString(targetPrice, priceDecimals),
amount,
reduceOnly: false,
});
}
} else {
desired.push({
side: "SELL",
price: formatPriceToString(targetPrice, priceDecimals),
amount,
reduceOnly: false,
});
}
const sticky =
anchor == null
? null
: this.pickStickyPrice({ side, targetBps: target.bps, anchor, amount, pool, claimed });
const price = sticky ?? ideal;
if (anchor != null) {
const distance = signedDistanceBps(side, price, anchor);
record[side === "BUY" ? "buy" : "sell"] = Number.isFinite(distance) ? distance : null;
}
desired.push({
side,
price: formatPriceToString(price, priceDecimals),
amount,
reduceOnly: false,
});
}
}
this.lastQuoteDistanceBps = distances;
return desired;
}
/**
* 检查各档位的深度状态是否发生变化
* 当深度从足够变为不足,或从不足变为足够时,需要触发重新计算
*/
private checkDepthStatusChanged(
depth: Depth | null,
bid1: number,
ask1: number
): boolean {
const minDepth = this.config.filterMinDepth;
if (minDepth <= 0) return false;
const priceDecimals = this.getPriceDecimals();
// 获取启用的所有档位
const targets = buildBpsTargets({
band0To10: this.config.enableBand0To10,
band10To30: this.config.enableBand10To30,
band30To100: this.config.enableBand30To100,
});
let changed = false;
for (const bps of targets) {
const buyTargetPrice = this.normalizeDepthTargetPrice(bid1 * (1 - bps / 10000), priceDecimals);
const sellTargetPrice = this.normalizeDepthTargetPrice(ask1 * (1 + bps / 10000), priceDecimals);
const buyDepthQty = getDepthBetweenPrices(depth, "BUY", buyTargetPrice ?? 0);
const sellDepthQty = getDepthBetweenPrices(depth, "SELL", sellTargetPrice ?? 0);
const currentBuyOk = buyDepthQty >= minDepth;
const currentSellOk = sellDepthQty >= minDepth;
const lastStatus = this.lastDepthOkStatus[bps];
if (lastStatus) {
if (lastStatus.buy !== currentBuyOk || lastStatus.sell !== currentSellOk) {
changed = true;
}
}
this.lastDepthOkStatus[bps] = { buy: currentBuyOk, sell: currentSellOk };
}
return changed;
}
/**
* 当深度从“满足阈值”切换到“不满足阈值”时,立即触发一次主循环,优先撤销不再安全的挂单。
* 深度从“满足阈值”切换到“不满足阈值”时立即触发一次主循环,抢在被吃穿前撤单。
* 同时维护 lastDepthOkStatus,供下一次比较使用。
*/
private shouldTriggerImmediateDepthProtection(depth: Depth | null): boolean {
if (!depth) return false;
@@ -907,47 +939,78 @@ export class MakerPointsEngine {
const { topBid, topAsk } = getTopPrices(depth);
if (topBid == null || topAsk == null) return false;
const targets = buildBpsTargets({
band0To10: this.config.enableBand0To10,
band10To30: this.config.enableBand10To30,
band30To100: this.config.enableBand30To100,
});
const anchor = this.getQuoteAnchor(depth);
const priceDecimals = this.getPriceDecimals();
let degraded = false;
for (const bps of targets) {
const lastStatus = this.lastDepthOkStatus[bps];
if (!lastStatus) continue;
for (const target of this.bandTargets()) {
const buyPrice = this.normalizeSafeQuote("BUY", target.bps, anchor, topBid, priceDecimals);
const sellPrice = this.normalizeSafeQuote("SELL", target.bps, anchor, topAsk, priceDecimals);
const currentBuyOk = getDepthBetweenPrices(depth, "BUY", buyPrice ?? 0) >= minDepth;
const currentSellOk = getDepthBetweenPrices(depth, "SELL", sellPrice ?? 0) >= minDepth;
const buyTargetPrice = this.normalizeDepthTargetPrice(topBid * (1 - bps / 10000), priceDecimals);
const sellTargetPrice = this.normalizeDepthTargetPrice(topAsk * (1 + bps / 10000), priceDecimals);
const buyDepthQty = getDepthBetweenPrices(depth, "BUY", buyTargetPrice ?? 0);
const sellDepthQty = getDepthBetweenPrices(depth, "SELL", sellTargetPrice ?? 0);
const currentBuyOk = buyDepthQty >= minDepth;
const currentSellOk = sellDepthQty >= minDepth;
if (lastStatus.buy && !currentBuyOk) return true;
if (lastStatus.sell && !currentSellOk) return true;
const lastStatus = this.lastDepthOkStatus[target.bps];
if (lastStatus && ((lastStatus.buy && !currentBuyOk) || (lastStatus.sell && !currentSellOk))) {
degraded = true;
}
this.lastDepthOkStatus[target.bps] = { buy: currentBuyOk, sell: currentSellOk };
}
return false;
return degraded;
}
private normalizeSafeQuote(
side: "BUY" | "SELL",
targetBps: number,
anchor: number | null,
bookPrice: number,
priceDecimals: number
): number | null {
const raw = resolveSafeQuotePrice({
side,
targetBps,
markPrice: anchor,
bookPrice,
maxDistanceBps: this.config.maxDistanceBps,
});
return raw == null ? null : this.normalizeDepthTargetPrice(raw, priceDecimals);
}
/**
* 当盘口相对上次报价偏移超过 minRepriceBps 时,立即触发一次主循环,优先撤销旧报价。
* 任一在场挂单已经漂出所有启用档位的容差(或穿过 mark、掉出积分范围)时,
* 立即触发一次主循环,不等 500ms 定时器。
*/
private shouldTriggerImmediateReprice(depth: Depth | null): boolean {
if (!depth) return false;
if (this.defenseMode || this.reconnectResetPending || this.stopLossProcessing) return false;
const hasActiveEntryOrders = this.openOrders.some(
(order) => order.symbol === this.config.symbol && !order.reduceOnly && isOrderActiveStatus(order.status)
);
if (!hasActiveEntryOrders) return false;
const pool = this.activeEntryOrders();
if (!pool.length) return false;
const { topBid, topAsk } = getTopPrices(depth);
if (topBid == null || topAsk == null) return false;
const anchor = this.getQuoteAnchor(depth);
if (anchor == null) return false;
return this.shouldReprice(topBid, topAsk);
const targets = this.bandTargets();
if (!targets.length) return true;
for (const order of pool) {
const price = Number(order.price);
if (!Number.isFinite(price) || price <= 0) return true;
const side = order.side === "BUY" ? "BUY" : "SELL";
const keepable = targets.some((target) =>
shouldKeepQuote({
side,
existingPrice: price,
anchor,
targetBps: target.bps,
toleranceBps: this.toleranceFor(target.bps),
maxDistanceBps: this.config.maxDistanceBps,
})
);
if (!keepable) return true;
}
return false;
}
private buildCloseOnlyOrders(
@@ -978,19 +1041,6 @@ export class MakerPointsEngine {
];
}
private shouldReprice(bid1: number, ask1: number): boolean {
const threshold = Number(this.config.minRepriceBps);
if (!Number.isFinite(threshold) || threshold <= 0) return true;
if (!Number.isFinite(bid1) || !Number.isFinite(ask1)) return false;
if (!Number.isFinite(this.lastQuoteBid1 ?? NaN) || !Number.isFinite(this.lastQuoteAsk1 ?? NaN)) {
return true;
}
if ((this.lastQuoteBid1 ?? 0) <= 0 || (this.lastQuoteAsk1 ?? 0) <= 0) return true;
const bidMove = Math.abs(bid1 - (this.lastQuoteBid1 ?? bid1)) / (this.lastQuoteBid1 ?? bid1) * 10000;
const askMove = Math.abs(ask1 - (this.lastQuoteAsk1 ?? ask1)) / (this.lastQuoteAsk1 ?? ask1) * 10000;
return bidMove >= threshold || askMove >= threshold;
}
private async ensureStartupOrderReset(): Promise<boolean> {
if (this.initialOrderResetDone) return true;
if (!this.initialOrderSnapshotReady) return false;
@@ -1077,12 +1127,7 @@ export class MakerPointsEngine {
if (target.amount < EPS) continue;
try {
// reduce-only 订单不能设置 tp/sl,仅开仓单设置止损
const priceNum = Number(target.price);
const slPrice = target.reduceOnly
? undefined
: target.side === "BUY"
? priceNum - 1
: priceNum + 1;
const slPrice = target.reduceOnly ? undefined : this.computeStopLossTrigger(target.side, Number(target.price));
await placeOrder(this.orderContext, {
openOrders: this.openOrders,
side: target.side,
@@ -1329,6 +1374,21 @@ export class MakerPointsEngine {
}
}
/**
* 开仓单附带的止损触发价:一旦挂单被吃就立刻市价止血。
* 按 bps 计算而非固定金额,换标的时不会退化成几百 bps 或落到 tick 之内被交易所拒单。
*/
private computeStopLossTrigger(side: "BUY" | "SELL", price: number): number | undefined {
if (!Number.isFinite(price) || price <= 0) return undefined;
const bps = Number(this.config.slOffsetBps);
if (!Number.isFinite(bps) || bps <= 0) return undefined;
const tick = Math.max(this.precision.priceTick, 1e-9);
const offset = Math.max((price * bps) / 10000, tick * 2);
const trigger = side === "BUY" ? price - offset : price + offset;
if (!Number.isFinite(trigger) || trigger <= 0) return undefined;
return Number(formatPriceToString(trigger, this.getPriceDecimals()));
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.precision.priceTick);
const raw = Math.log10(1 / tick);
@@ -1359,13 +1419,24 @@ export class MakerPointsEngine {
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
const pnl = computePositionPnl(position, topBid, topAsk);
const bandDepths = this.computeBandDepths(topBid, topAsk);
const anchor = this.getQuoteAnchor(this.depthSnapshot);
const bandDepths = this.computeBandDepths(topBid, topAsk, anchor);
const markRaw = Number(this.tickerSnapshot?.markPrice);
const now = Date.now();
const orderRestingMs: Record<string, number> = {};
for (const order of this.openOrders) {
const placed = Number(order.time);
if (Number.isFinite(placed) && placed > 0) {
orderRestingMs[String(order.orderId)] = Math.max(0, now - placed);
}
}
return {
ready: this.isReady(),
symbol: this.config.symbol,
topBid,
topAsk,
markPrice: Number.isFinite(markRaw) && markRaw > 0 ? markRaw : null,
spread,
priceDecimals: this.getPriceDecimals(),
position,
@@ -1378,7 +1449,9 @@ export class MakerPointsEngine {
lastUpdated: Date.now(),
feedStatus: { ...this.feedStatus },
binanceDepth: this.binanceDepth.getSnapshot(),
maxDistanceBps: this.config.maxDistanceBps,
bandDepths,
orderRestingMs,
quoteStatus: {
closeOnly: this.lastCloseOnly,
skipBuy: this.lastSkipBuy,
@@ -1387,24 +1460,51 @@ export class MakerPointsEngine {
};
}
private computeBandDepths(topBid: number | null, topAsk: number | null): MakerPointsSnapshot["bandDepths"] {
const bands: MakerPointsSnapshot["bandDepths"] = [
{ band: "0-10", bps: 9, buyDepth: null, sellDepth: null, enabled: this.config.enableBand0To10 },
{ band: "10-30", bps: 29, buyDepth: null, sellDepth: null, enabled: this.config.enableBand10To30 },
{ band: "30-100", bps: 99, buyDepth: null, sellDepth: null, enabled: this.config.enableBand30To100 },
];
if (!this.depthSnapshot || topBid == null || topAsk == null) {
return bands;
}
private computeBandDepths(
topBid: number | null,
topAsk: number | null,
anchor: number | null
): BandStatus[] {
const enabled: Record<MakerPointsBand, boolean> = {
"0-10": this.config.enableBand0To10,
"10-30": this.config.enableBand10To30,
"30-100": this.config.enableBand30To100,
};
// 展开全部三档(含未启用的),仪表盘要能看到被关掉的档位
const all = buildBandTargets({
band0To10: true,
band10To30: true,
band30To100: true,
band0To10Bps: this.config.band0To10Bps,
band10To30Bps: this.config.band10To30Bps,
band30To100Bps: this.config.band30To100Bps,
});
const priceDecimals = this.getPriceDecimals();
return bands.map((band) => {
const buyTargetPrice = this.normalizeDepthTargetPrice(topBid * (1 - band.bps / 10000), priceDecimals);
const sellTargetPrice = this.normalizeDepthTargetPrice(topAsk * (1 + band.bps / 10000), priceDecimals);
const buyDepth = getDepthBetweenPrices(this.depthSnapshot, "BUY", buyTargetPrice ?? 0);
const sellDepth = getDepthBetweenPrices(this.depthSnapshot, "SELL", sellTargetPrice ?? 0);
return { ...band, buyDepth, sellDepth };
return all.map(({ band, bps }) => {
const quoted = this.lastQuoteDistanceBps[band];
const buyDistanceBps = quoted?.buy ?? null;
const sellDistanceBps = quoted?.sell ?? null;
const base: BandStatus = {
band,
bps,
enabled: enabled[band],
buyDepth: null,
sellDepth: null,
buyDistanceBps,
sellDistanceBps,
buyMultiplier: buyDistanceBps == null ? null : makerPointsMultiplier(buyDistanceBps),
sellMultiplier: sellDistanceBps == null ? null : makerPointsMultiplier(sellDistanceBps),
};
if (!this.depthSnapshot || topBid == null || topAsk == null) return base;
const buyPrice = this.normalizeSafeQuote("BUY", bps, anchor, topBid, priceDecimals);
const sellPrice = this.normalizeSafeQuote("SELL", bps, anchor, topAsk, priceDecimals);
return {
...base,
buyDepth: getDepthBetweenPrices(this.depthSnapshot, "BUY", buyPrice ?? 0),
sellDepth: getDepthBetweenPrices(this.depthSnapshot, "SELL", sellPrice ?? 0),
};
});
}
@@ -1718,8 +1818,7 @@ export class MakerPointsEngine {
// 重置本地状态,强制下一轮重新计算挂单
this.desiredOrders = [];
this.lastDesiredSummary = null;
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
this.lastQuoteDistanceBps = {};
}
/**
+121 -4
View File
@@ -1,14 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildBpsTargets } from "./maker-points-logic";
import {
bandRepriceToleranceBps,
buildBandTargets,
buildBpsTargets,
makerPointsMultiplier,
resolveSafeQuotePrice,
shouldKeepQuote,
signedDistanceBps,
} from "./maker-points-logic";
describe("maker points target builder", () => {
it("builds fixed bps targets per enabled band", () => {
it("uses the default bps per enabled band", () => {
const targets = buildBpsTargets({
band0To10: true,
band10To30: true,
band30To100: true,
});
expect(targets).toEqual([9, 29, 99]);
expect(targets).toEqual([9, 29, 40]);
});
it("skips disabled bands", () => {
@@ -17,6 +25,115 @@ describe("maker points target builder", () => {
band10To30: false,
band30To100: true,
});
expect(targets).toEqual([9, 99]);
expect(targets).toEqual([9, 40]);
});
it("lets an explicit bps override the band default", () => {
const targets = buildBandTargets({
band0To10: true,
band10To30: true,
band30To100: true,
band0To10Bps: 5,
band30To100Bps: 60,
});
expect(targets).toEqual([
{ band: "0-10", bps: 5 },
{ band: "10-30", bps: 29 },
{ band: "30-100", bps: 60 },
]);
});
it("caps a configured bps at the zero-points cliff", () => {
const targets = buildBpsTargets({
band0To10: false,
band10To30: false,
band30To100: true,
band30To100Bps: 250,
});
expect(targets).toEqual([100]);
});
});
describe("maker points multiplier curve", () => {
// 活动公布的样例点,用来锁住三段折线的系数
it.each([
[2, 0.88],
[5, 0.7],
[10, 0.4],
[20, 0.2625],
[50, 0.0893],
])("matches the published example at %i bps", (distance, expected) => {
expect(makerPointsMultiplier(distance)).toBeCloseTo(expected, 4);
});
it("returns zero at and beyond the 100 bps cliff", () => {
expect(makerPointsMultiplier(100)).toBe(0);
expect(makerPointsMultiplier(101)).toBe(0);
});
it("ranks 40 bps far above the old 99 bps edge quote", () => {
expect(makerPointsMultiplier(40)).toBeCloseTo(0.1071, 4);
expect(makerPointsMultiplier(99)).toBeCloseTo(0.0018, 4);
});
});
describe("safe quote price", () => {
const base = { targetBps: 40, maxDistanceBps: 95 };
it("picks the lower of mark/book for a buy", () => {
// mark 低于 bid1 时以 mark 为基准更远离盘口
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: 90_000, bookPrice: 90_020 });
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
});
it("picks the higher of mark/book for a sell", () => {
const price = resolveSafeQuotePrice({ ...base, side: "SELL", markPrice: 90_050, bookPrice: 90_020 });
expect(price).toBeCloseTo(90_050 * (1 + 0.004), 6);
});
it("falls back to the book when mark is unavailable", () => {
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: null, bookPrice: 90_000 });
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
});
it("clamps a safer-but-worthless price back inside the cliff", () => {
// bid1 已经砸到 mark 下方,照盘口算出的买价会被推过 100 bps 变成零积分
const price = resolveSafeQuotePrice({
side: "BUY",
targetBps: 90,
maxDistanceBps: 95,
markPrice: 90_500,
bookPrice: 90_000,
});
expect(signedDistanceBps("BUY", price!, 90_500)).toBeCloseTo(95, 6);
});
});
describe("band reprice tolerance", () => {
it("keeps the floor for near bands and scales up for far bands", () => {
expect(bandRepriceToleranceBps(9, 3, 0.15)).toBeCloseTo(3, 6);
expect(bandRepriceToleranceBps(40, 3, 0.15)).toBeCloseTo(6, 6);
});
});
describe("sticky quote decision", () => {
const base = { side: "BUY" as const, anchor: 90_000, targetBps: 40, toleranceBps: 6, maxDistanceBps: 95 };
it("keeps a quote that drifted inside the tolerance", () => {
// 89_650 距 mark 38.9 bps,仍在 40±6 内
expect(shouldKeepQuote({ ...base, existingPrice: 89_650 })).toBe(true);
});
it("drops a quote that drifted outside the tolerance", () => {
// 89_500 距 mark 55.6 bps
expect(shouldKeepQuote({ ...base, existingPrice: 89_500 })).toBe(false);
});
it("drops a quote that crossed to the wrong side of mark", () => {
expect(shouldKeepQuote({ ...base, existingPrice: 90_100 })).toBe(false);
});
it("drops a quote that fell out of the scoring range", () => {
expect(shouldKeepQuote({ ...base, targetBps: 90, toleranceBps: 20, existingPrice: 89_100 })).toBe(false);
});
});
+160 -5
View File
@@ -1,13 +1,168 @@
export type MakerPointsBand = "0-10" | "10-30" | "30-100";
/**
* StandX 的 Maker Points 在距 mark price 100 bps 处倍率归零。
* 越过这条线的挂单不产生任何积分,只消耗保证金和下单配额。
*/
export const MAKER_POINTS_ZERO_BPS = 100;
/** 布尔开关全开时各档位的默认目标距离(bps)。 */
export const DEFAULT_BAND_BPS: Record<MakerPointsBand, number> = {
"0-10": 9,
"10-30": 29,
// 活动改为线性梯度后贴边(99 bps)倍率仅 0.18%40 bps 仍有 10.7%
"30-100": 40,
};
export interface MakerPointsBandConfig {
band0To10: boolean;
band10To30: boolean;
band30To100: boolean;
/** 各档位目标距离(bps);省略时回落到 DEFAULT_BAND_BPS。 */
band0To10Bps?: number;
band10To30Bps?: number;
band30To100Bps?: number;
}
export interface BandTarget {
band: MakerPointsBand;
bps: number;
}
const BAND_ORDER: MakerPointsBand[] = ["0-10", "10-30", "30-100"];
function resolveBandBps(band: MakerPointsBand, configured: number | undefined): number {
if (Number.isFinite(configured) && (configured as number) > 0) {
return Math.min(configured as number, MAKER_POINTS_ZERO_BPS);
}
return DEFAULT_BAND_BPS[band];
}
/**
* 展开启用的档位及其目标距离,按距离升序返回。
* 布尔开关继续决定档位是否启用,bps 数值可单独覆盖默认值。
*/
export function buildBandTargets(config: MakerPointsBandConfig): BandTarget[] {
const enabled: Record<MakerPointsBand, boolean> = {
"0-10": config.band0To10,
"10-30": config.band10To30,
"30-100": config.band30To100,
};
const configured: Record<MakerPointsBand, number | undefined> = {
"0-10": config.band0To10Bps,
"10-30": config.band10To30Bps,
"30-100": config.band30To100Bps,
};
return BAND_ORDER.filter((band) => enabled[band])
.map((band) => ({ band, bps: resolveBandBps(band, configured[band]) }))
.sort((a, b) => a.bps - b.bps);
}
export function buildBpsTargets(config: MakerPointsBandConfig): number[] {
const targets: number[] = [];
if (config.band0To10) targets.push(9);
if (config.band10To30) targets.push(29);
if (config.band30To100) targets.push(99);
return targets.sort((a, b) => a - b);
return buildBandTargets(config).map((target) => target.bps);
}
/**
* Maker Points 的线性梯度倍率,三段折线:
* 010 bps: 100% → 40%
* 1030 bps: 40% → 12.5%
* 30100 bps: 12.5% → 0%
* 系数由活动公布的样例点(2/5/10/20/50 bps)反解得到。
*/
export function makerPointsMultiplier(distanceBps: number): number {
if (!Number.isFinite(distanceBps) || distanceBps < 0) return 0;
if (distanceBps >= MAKER_POINTS_ZERO_BPS) return 0;
if (distanceBps <= 10) return 1 - 0.06 * distanceBps;
if (distanceBps <= 30) return 0.4 - 0.01375 * (distanceBps - 10);
return (0.125 * (MAKER_POINTS_ZERO_BPS - distanceBps)) / 70;
}
/**
* 挂单价相对参考价的带符号距离(bps)。
* 正数表示朝“更不容易成交”的方向偏离:BUY 在参考价下方,SELL 在参考价上方。
* 负数说明挂单已经穿过参考价,随时可能被吃。
*/
export function signedDistanceBps(side: "BUY" | "SELL", price: number, anchor: number): number {
if (!Number.isFinite(price) || !Number.isFinite(anchor) || anchor <= 0) return Number.NaN;
const raw = side === "BUY" ? anchor - price : price - anchor;
return (raw / anchor) * 10000;
}
export interface SafeQuoteInput {
side: "BUY" | "SELL";
/** 目标距离(bps)。 */
targetBps: number;
/** 交易所 mark price;不可用时传 null。 */
markPrice: number | null;
/** 盘口一档:BUY 用 bid1SELL 用 ask1。 */
bookPrice: number;
/** 距 mark 的最大允许距离(bps),超出即失去积分资格。 */
maxDistanceBps: number;
}
/**
* 同时以 mark price 和盘口一档为基准算价,取对“不成交”更安全的一侧:
* BUY 取更低价、SELL 取更高价。
*
* 随后按 maxDistanceBps 夹回 —— 否则在 mark 远离盘口时,为了安全选出的价格
* 可能被推过 100 bps 悬崖,挂单虽然更安全却一分不得。
*/
export function resolveSafeQuotePrice(input: SafeQuoteInput): number | null {
const { side, targetBps, markPrice, bookPrice, maxDistanceBps } = input;
if (!Number.isFinite(bookPrice) || bookPrice <= 0) return null;
if (!Number.isFinite(targetBps) || targetBps < 0) return null;
const mark = Number.isFinite(markPrice ?? Number.NaN) && (markPrice ?? 0) > 0 ? (markPrice as number) : null;
const factor = side === "BUY" ? 1 - targetBps / 10000 : 1 + targetBps / 10000;
const fromBook = bookPrice * factor;
const candidate =
mark == null
? fromBook
: side === "BUY"
? Math.min(fromBook, mark * factor)
: Math.max(fromBook, mark * factor);
// 悬崖以 mark 为准;拿不到 mark 时只能用盘口近似
const anchor = mark ?? bookPrice;
const cap = Math.max(0, Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS));
const limit = side === "BUY" ? anchor * (1 - cap / 10000) : anchor * (1 + cap / 10000);
const clamped = side === "BUY" ? Math.max(candidate, limit) : Math.min(candidate, limit);
return Number.isFinite(clamped) && clamped > 0 ? clamped : null;
}
/**
* 该档位允许的距离漂移(bps)。远档天然容忍更大的漂移,因为同样的盘口移动
* 对远档的倍率影响小得多,没必要跟着近档一起撤挂。
*/
export function bandRepriceToleranceBps(targetBps: number, minRepriceBps: number, ratio: number): number {
const floor = Number.isFinite(minRepriceBps) && minRepriceBps > 0 ? minRepriceBps : 0;
const scaled = Number.isFinite(ratio) && ratio > 0 ? targetBps * ratio : 0;
return Math.max(floor, scaled);
}
export interface KeepQuoteInput {
side: "BUY" | "SELL";
/** 当前已挂在盘口上的价格。 */
existingPrice: number;
/** 参考价:优先 mark price。 */
anchor: number;
targetBps: number;
toleranceBps: number;
maxDistanceBps: number;
}
/**
* 判断现有挂单是否还能原地不动。保持不动意味着这一轮不撤不挂,
* 订单得以在盘口连续停留,跨过 Maker Points 的 3 秒计分门槛。
*/
export function shouldKeepQuote(input: KeepQuoteInput): boolean {
const { side, existingPrice, anchor, targetBps, toleranceBps, maxDistanceBps } = input;
const distance = signedDistanceBps(side, existingPrice, anchor);
if (!Number.isFinite(distance)) return false;
// 已经穿到参考价另一侧,随时可能成交,必须立即重挂
if (distance <= 0) return false;
// 已经掉出积分范围,留着也不得分
if (distance >= Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS)) return false;
return Math.abs(distance - targetBps) <= toleranceBps;
}
+24
View File
@@ -43,12 +43,20 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
);
// Maker Points 只对停留超过 3 秒的挂单计分,所以存活时长要直接可见
const formatResting = (orderId: string | number) => {
const ms = snapshot.orderRestingMs[String(orderId)];
if (ms == null) return "-";
const seconds = ms / 1000;
return `${seconds < 3 ? "!" : ""}${formatNumber(seconds, 1)}s`;
};
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
id: order.orderId,
side: order.side,
price: order.price,
qty: order.origQty,
filled: order.executedQty,
resting: formatResting(order.orderId),
reduceOnly: order.reduceOnly ? "yes" : "no",
status: order.status,
}));
@@ -58,6 +66,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
{ key: "resting", header: "Rest", align: "right", minWidth: 6 },
{ key: "reduceOnly", header: "RO", minWidth: 4 },
{ key: "status", header: "Status", minWidth: 10 },
];
@@ -96,6 +105,9 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
: t("offset.imbalance.balanced");
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
const formatDistance = (value: number | null) => (value == null ? "-" : `${formatNumber(value, 1)}bps`);
const formatMultiplier = (value: number | null) =>
value == null ? "-" : `${formatNumber(value * 100, 2)}%`;
return (
<Box flexDirection="column" paddingX={1}>
@@ -110,6 +122,12 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
spread: spreadDisplay,
})}
</Text>
<Text color={snapshot.markPrice == null ? "yellow" : undefined}>
{t("makerPoints.markLine", {
mark: snapshot.markPrice == null ? "-" : formatNumber(snapshot.markPrice, priceDigits),
maxDistance: snapshot.maxDistanceBps,
})}
</Text>
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
<Text>
{t("makerPoints.quoteLine", {
@@ -130,9 +148,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
{t("makerPoints.bandDepthLine", {
band: band.band,
target: formatNumber(band.bps, 1),
buyDist: formatDistance(band.buyDistanceBps),
buyMult: formatMultiplier(band.buyMultiplier),
buy: formatDepth(band.buyDepth),
sellDist: formatDistance(band.sellDistanceBps),
sellMult: formatMultiplier(band.sellMultiplier),
sell: formatDepth(band.sellDepth),
})}
{band.enabled ? "" : t("makerPoints.bandDisabled")}
</Text>
))}
<Text>