fix(exchanges): restore type-check safety net and repair dead balance guards

tsconfig compiled docs/ (vendored ccxt samples) and @types/react was missing,
so 269 tsc errors buried the real ones. Scoping the project and adding the
missing type packages left 42 genuine errors in src/, which exposed two bugs:

- lighter: assertSpotBalance's buy branch and createOrder's spot-sell guard
  compared the {available, wallet} object against a number, so both guards
  were dead. Introduce SpotAssetBalance with a precomputed 'effective' field
  (Extract Class) and route all three call sites through it.
- offset-maker: the below-min-sell branch logged 'skip sell' but pushed the
  SELL order anyway, and pushed a possibly-null price. Both branches now
  match their working sibling.

Also: widen LighterOrder's is_ask/reduce_only to BooleanFlag (the wire format
flags.ts already parses), extract sellableBase (Extract Function, 3 copies),
delete two scripts importing a module that does not exist, add a typecheck
script. tsc --noEmit: 269 -> 0 errors; 218 tests still pass.
This commit is contained in:
discountry
2026-07-29 20:27:40 +08:00
parent 9f3e50045a
commit 7acb3c3b82
13 changed files with 116 additions and 90 deletions
+48 -27
View File
@@ -122,6 +122,31 @@ interface Pollers {
klines: Map<string, ReturnType<typeof setInterval>>;
}
/**
* Spot balance of a single asset. `effective` is what every balance guard compares
* against: an unknown or unparseable asset collapses to 0 so guards fail closed
* instead of waving an order through.
*/
interface SpotAssetBalance {
available: number | null;
wallet: number | null;
effective: number;
}
function makeSpotAssetBalance(available: number | null, wallet: number | null): SpotAssetBalance {
return {
available,
wallet,
effective: Math.max(
available != null && Number.isFinite(available) ? available : 0,
wallet != null && Number.isFinite(wallet) ? wallet : 0
),
};
}
/** Tolerance that absorbs float drift when comparing a balance against an order size. */
const BALANCE_EPSILON = 1e-9;
const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000;
const DEFAULT_KLINE_POLL_MS = 15000;
@@ -917,7 +942,7 @@ export class LighterGateway {
return 0;
});
return candidates[0];
return candidates[0] ?? null;
}
private scheduleReconnect(): void {
@@ -1756,8 +1781,9 @@ export class LighterGateway {
(matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase();
list.push({
asset: assetSymbol,
walletBalance: asset.balance ?? "0",
availableBalance: available != null && Number.isFinite(available) ? available.toString() : asset.balance ?? "0",
walletBalance: String(asset.balance ?? "0"),
availableBalance:
available != null && Number.isFinite(available) ? available.toString() : String(asset.balance ?? "0"),
updateTime: now,
assetId: Number.isFinite(assetId) ? assetId : undefined,
});
@@ -1796,7 +1822,7 @@ export class LighterGateway {
return (hasBase && hasQuote) || idMatch;
});
if (!matches.length) return null;
return matches[0];
return matches[0] ?? null;
}
async getPrecision(): Promise<{
@@ -1846,8 +1872,7 @@ export class LighterGateway {
return qty;
}
private getAvailableAssetAmount(assetId?: number | null, symbol?: string | null): { available: number | null; wallet: number | null } {
if (!this.assets.size) return null;
private getSpotAssetBalance(assetId?: number | null, symbol?: string | null): SpotAssetBalance {
const normalizedSymbol = symbol ? symbol.toUpperCase() : null;
for (const asset of this.assets.values()) {
const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId;
@@ -1857,29 +1882,23 @@ export class LighterGateway {
const locked = parseNumber(asset.locked_balance ?? 0);
if (balance == null) continue;
const available = locked != null ? balance - locked : balance;
return {
available: Number.isFinite(available) ? available : null,
wallet: Number.isFinite(balance) ? balance : null,
};
return makeSpotAssetBalance(
Number.isFinite(available) ? available : null,
Number.isFinite(balance) ? balance : null
);
}
return { available: null, wallet: null };
return makeSpotAssetBalance(null, null);
}
private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void {
const qty = Number(params.quantity);
if (!Number.isFinite(qty) || qty <= 0) return;
if (params.isAsk) {
const baseAmounts = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
const availableBase = baseAmounts?.available ?? null;
const walletBase = baseAmounts?.wallet ?? null;
const effective = Math.max(
availableBase != null && Number.isFinite(availableBase) ? availableBase : 0,
walletBase != null && Number.isFinite(walletBase) ? walletBase : 0
);
if (effective + 1e-9 < qty) {
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
if (base.effective + BALANCE_EPSILON < qty) {
throw new Error(
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase ?? 0}${
walletBase != null ? ` wallet ${walletBase}` : ""
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${base.available ?? 0}${
base.wallet != null ? ` wallet ${base.wallet}` : ""
}) for spot sell ${qty}`
);
}
@@ -1888,10 +1907,12 @@ export class LighterGateway {
const price = Number(params.price);
if (!Number.isFinite(price) || price <= 0) return;
const requiredQuote = qty * price;
const availableQuote = this.getAvailableAssetAmount(this.quoteAssetId, this.quoteAssetSymbol);
if (availableQuote != null && availableQuote + 1e-9 < requiredQuote) {
const quote = this.getSpotAssetBalance(this.quoteAssetId, this.quoteAssetSymbol);
if (quote.effective + BALANCE_EPSILON < requiredQuote) {
throw new Error(
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${availableQuote}) for spot buy requiring ${requiredQuote}`
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${
quote.available ?? 0
}) for spot buy requiring ${requiredQuote}`
);
}
}
@@ -1932,10 +1953,10 @@ export class LighterGateway {
const isAsk = side === "SELL" ? 1 : 0;
const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null);
if (this.isSpotMarket() && isAsk === 1) {
const availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
if (availableBase != null && availableBase + 1e-9 < enforcedQty) {
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
if (base.effective + BALANCE_EPSILON < enforcedQty) {
throw new Error(
`Spot sell quantity ${enforcedQty} exceeds available base ${availableBase} (min trade size may be higher than balance)`
`Spot sell quantity ${enforcedQty} exceeds available base ${base.effective} (min trade size may be higher than balance)`
);
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): Order
symbol,
side,
type: mapOrderType(order.type),
status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
status: normalizeOrderStatus(String(order.status ?? order.trigger_status ?? "UNKNOWN")),
price: order.price ?? "0",
origQty: order.initial_base_amount ?? "0",
executedQty: computeExecutedQty(order),
+10 -3
View File
@@ -10,6 +10,12 @@ export type LighterOrderType =
type StrOrNum = string | number;
/**
* Lighter reports boolean fields inconsistently across endpoints — `true`, `1`, `"Yes"`.
* `normalizeBooleanFlag` in ./flags is what turns any of these into a real boolean.
*/
type BooleanFlag = boolean | string | number | bigint;
export interface LighterOrder {
order_index: StrOrNum;
client_order_index: StrOrNum;
@@ -23,12 +29,12 @@ export interface LighterOrder {
filled_quote_amount?: string;
price: string;
nonce?: number;
is_ask?: boolean;
is_ask?: BooleanFlag;
side?: LighterSide;
type?: LighterOrderType;
time_in_force?: string;
trigger_price?: string;
reduce_only?: boolean;
reduce_only?: BooleanFlag;
status?: string | number;
trigger_status?: string | number;
trigger_time?: number;
@@ -68,7 +74,8 @@ export interface LighterAccountDetails {
}
export interface LighterAccountAsset {
symbol: string;
/** Optional: the account endpoint omits it for assets it only knows by id. */
symbol?: string;
asset_id?: number;
balance: string | number;
locked_balance?: string | number;
+1 -1
View File
@@ -579,7 +579,7 @@ export class ParadexGateway {
const isClosePosition = (extraParams as any).closePosition === true;
if (isClosePosition) {
const posAbs = this.getCurrentPositionAbs();
if (Number.isFinite(posAbs) && posAbs > 0) {
if (posAbs != null && Number.isFinite(posAbs) && posAbs > 0) {
amount = posAbs;
}
const current = Number(amount);
+34 -16
View File
@@ -38,6 +38,13 @@ interface DesiredOrder {
reduceOnly: boolean;
}
/** Spot wallet view the quoting logic reads; `baseWallet` may lag `baseAvailable` after a fill. */
export interface SpotBalances {
baseAvailable: number;
quoteAvailable: number;
baseWallet: number;
}
export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot {
buyDepthSum10: number;
sellDepthSum10: number;
@@ -269,6 +276,7 @@ export class OffsetMakerEngine {
(klines) => {
if (!Array.isArray(klines) || !klines.length) return;
const latest = klines[klines.length - 1];
if (!latest) return;
this.lastKline = latest;
const open = Number(latest.open);
const close = Number(latest.close);
@@ -340,7 +348,9 @@ export class OffsetMakerEngine {
const position = this.getPositionSnapshot();
const isSpotMarket = this.marketType === "spot";
const spotBalances = isSpotMarket ? this.getSpotBalances() : null;
const balancesForSpot = isSpotMarket ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0 } : spotBalances;
const balancesForSpot = isSpotMarket
? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0, baseWallet: 0 }
: spotBalances;
this.updateLiveCandle();
const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum);
if (handledImbalance) {
@@ -392,9 +402,7 @@ export class OffsetMakerEngine {
if (absPosition < EPS && isSpotMarket) {
this.entryPricePendingLogged = false;
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
const maxBase = Math.max(baseAvail, baseWallet);
const maxBase = this.sellableBase(balancesForSpot);
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
// 无法卖出,跳过卖单,允许买单累计
this.lastSellPriceViable = false;
@@ -429,9 +437,7 @@ export class OffsetMakerEngine {
}
}
if (!skipSellSide && canEnter) {
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
const maxBase = Math.max(baseAvail, baseWallet);
const maxBase = this.sellableBase(balancesForSpot);
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
// 持仓低于最小卖单量,跳过卖单,等待累积
if (this.lastSellPriceViable) {
@@ -468,20 +474,22 @@ export class OffsetMakerEngine {
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
this.lastBuyPriceViable = false;
}
} else {
} else if (bidPrice != null) {
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
}
}
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", "现货持仓低于最小卖单量,跳过卖单");
}
const belowMinSell =
isSpotMarket &&
minSell > 0 &&
this.minBaseAmount != null &&
this.sellableBase(balancesForSpot) + EPS < minSell;
if (belowMinSell) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
} else if (askPrice != null) {
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 {
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
@@ -1012,6 +1020,16 @@ export class OffsetMakerEngine {
return this.spotKlineUp === true || this.isLiveCandleUp();
}
/**
* Base asset the venue will actually let us sell. Wallet balance can exceed the
* available figure right after a fill settles, so the larger of the two wins.
*/
private sellableBase(balances: SpotBalances | null): number {
const available = balances?.baseAvailable ?? 0;
const wallet = balances?.baseWallet ?? available;
return Math.max(available, wallet);
}
private isLiveCandleUp(): boolean {
if (!this.liveCandle) return false;
return this.liveCandle.close > this.liveCandle.open;