From a629bc940c99d056de91e326160d794f585a6043 Mon Sep 17 00:00:00 2001 From: discountry Date: Sat, 24 Jan 2026 22:46:14 +0800 Subject: [PATCH] Enhance environment variable parsing and account snapshot validation - Introduced `normalizeEnvValue` function to improve handling of environment variable values, including trimming, unquoting, and stripping inline comments. - Updated `resolveSymbolFromEnv` and parsing functions to utilize the new normalization logic. - Added `validateAccountSnapshotForSymbol` function to validate account snapshots, ensuring numeric fields are correctly formatted and flagging any issues. - Implemented tests for environment variable parsing and account snapshot validation to ensure robustness and correctness. --- src/config.ts | 42 +++++- src/exchanges/adapter.ts | 14 ++ src/exchanges/standx/adapter.ts | 14 ++ src/exchanges/standx/gateway.ts | 102 ++++++++++++--- src/notifications/telegram.ts | 6 +- src/notifications/types.ts | 2 +- src/strategy/maker-points-engine.ts | 152 +++++++++++++++++++--- src/utils/pnl.ts | 20 +++ src/utils/strategy.ts | 41 ++++++ tests/account-snapshot-validate.test.ts | 87 +++++++++++++ tests/config-inline-comments.test.ts | 45 +++++++ tests/maker-points-defense-rest.test.ts | 165 ++++++++++++++++++++++++ tests/pnl-stoploss.test.ts | 39 ++++++ tests/standx-rest-health.test.ts | 55 ++++++++ 14 files changed, 738 insertions(+), 46 deletions(-) create mode 100644 tests/account-snapshot-validate.test.ts create mode 100644 tests/config-inline-comments.test.ts create mode 100644 tests/maker-points-defense-rest.test.ts create mode 100644 tests/pnl-stoploss.test.ts create mode 100644 tests/standx-rest-health.test.ts diff --git a/src/config.ts b/src/config.ts index 1c3d679..a48f709 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,23 +105,51 @@ export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | : resolveExchangeId(); const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId]; for (const key of envKeys) { - const value = process.env[key]; - if (value && value.trim()) { - return value.trim(); + const value = normalizeEnvValue(process.env[key]); + if (value) { + return value; } } return fallback; } +function normalizeEnvValue(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const quote = trimmed[0]; + if ((quote === "'" || quote === "\"") && trimmed.endsWith(quote)) { + const unquoted = trimmed.slice(1, -1).trim(); + return unquoted ? unquoted : undefined; + } + + // Allow shell-style inline comments: KEY=value # comment + const commentIndexHash = trimmed.search(/\s#/); + const commentIndexSemi = trimmed.search(/\s;/); + const commentIndex = + commentIndexHash === -1 + ? commentIndexSemi + : commentIndexSemi === -1 + ? commentIndexHash + : Math.min(commentIndexHash, commentIndexSemi); + if (commentIndex !== -1) { + const withoutComment = trimmed.slice(0, commentIndex).trim(); + return withoutComment ? withoutComment : undefined; + } + + return trimmed; +} + function parseNumber(value: string | undefined, fallback: number): number { - if (!value) return fallback; - const next = Number(value); + const normalized = normalizeEnvValue(value); + if (!normalized) return fallback; + const next = Number(normalized); return Number.isFinite(next) ? next : fallback; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { - if (!value) return fallback; - const normalized = value.trim().toLowerCase(); + const normalized = normalizeEnvValue(value)?.toLowerCase(); if (!normalized) return fallback; if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true; if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false; diff --git a/src/exchanges/adapter.ts b/src/exchanges/adapter.ts index d88640a..08b0b40 100644 --- a/src/exchanges/adapter.ts +++ b/src/exchanges/adapter.ts @@ -37,6 +37,17 @@ export interface FundingRateListener { (snapshot: FundingRateSnapshot): void; } +export type RestHealthState = "healthy" | "unhealthy"; +export interface RestHealthInfo { + consecutiveErrors: number; + method?: string; + path?: string; + error?: string; +} +export interface RestHealthListener { + (state: RestHealthState, info: RestHealthInfo): void; +} + export interface ExchangePrecision { priceTick: number; qtyStep: number; @@ -69,6 +80,9 @@ export interface ExchangeAdapter { // 连接保护相关方法(可选,仅 StandX 支持) onConnectionEvent?(listener: ConnectionEventListener): void; offConnectionEvent?(listener: ConnectionEventListener): void; + onRestHealthEvent?(listener: RestHealthListener): void; + offRestHealthEvent?(listener: RestHealthListener): void; queryOpenOrders?(): Promise; + queryAccountSnapshot?(): Promise; forceCancelAllOrders?(): Promise; } diff --git a/src/exchanges/standx/adapter.ts b/src/exchanges/standx/adapter.ts index 450d599..9b1109d 100644 --- a/src/exchanges/standx/adapter.ts +++ b/src/exchanges/standx/adapter.ts @@ -7,6 +7,7 @@ import type { FundingRateListener, KlineListener, OrderListener, + RestHealthListener, TickerListener, } from "../adapter"; import type { AsterOrder, CreateOrderParams } from "../types"; @@ -138,6 +139,14 @@ export class StandxExchangeAdapter implements ExchangeAdapter { this.gateway.offConnectionEvent(listener); } + onRestHealthEvent(listener: RestHealthListener): void { + this.gateway.onRestHealthEvent(listener); + } + + offRestHealthEvent(listener: RestHealthListener): void { + this.gateway.offRestHealthEvent(listener); + } + /** * 查询当前真实的挂单状态(通过 HTTP API) * 用于验证实际挂单情况,防止取消请求丢失 @@ -147,6 +156,11 @@ export class StandxExchangeAdapter implements ExchangeAdapter { return this.gateway.queryOpenOrders(this.symbol); } + async queryAccountSnapshot() { + await this.ensureInitialized("queryAccountSnapshot"); + return this.gateway.queryAccountSnapshot(); + } + /** * 强制取消所有挂单 * 会查询当前挂单然后取消,并验证取消成功 diff --git a/src/exchanges/standx/gateway.ts b/src/exchanges/standx/gateway.ts index a4e2cc8..91bfdac 100644 --- a/src/exchanges/standx/gateway.ts +++ b/src/exchanges/standx/gateway.ts @@ -8,6 +8,9 @@ import type { FundingRateListener, KlineListener, OrderListener, + RestHealthInfo, + RestHealthListener, + RestHealthState, TickerListener, } from "../adapter"; import type { @@ -62,6 +65,7 @@ const WS_HEARTBEAT_CHECK_INTERVAL = 30_000; const WS_DATA_STALE_THRESHOLD = 3000; // REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔 const REST_POLL_INTERVAL = 2000; +const REST_ERROR_DEFENSE_THRESHOLD = 3; const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"]; @@ -420,6 +424,10 @@ export class StandxGateway { private readonly virtualStops = new Map(); private accountSnapshot: AsterAccountSnapshot | null = null; + private readonly restHealthListeners = new Set(); + private restConsecutiveErrors = 0; + private restUnhealthy = false; + private restLastError: string | null = null; private fundingState = new Map(); private marketWs: WebSocket | null = null; @@ -558,6 +566,14 @@ export class StandxGateway { this.connectionListeners.add(listener); } + onRestHealthEvent(listener: RestHealthListener): void { + this.restHealthListeners.add(listener); + } + + offRestHealthEvent(listener: RestHealthListener): void { + this.restHealthListeners.delete(listener); + } + offConnectionEvent(listener: ConnectionEventListener): void { this.connectionListeners.delete(listener); } @@ -1411,7 +1427,7 @@ export class StandxGateway { } } - private async refreshAccountSnapshot(): Promise { + private async refreshAccountSnapshot(): Promise { try { const [balance, positions] = await Promise.all([ this.requestJson("/api/query_balance", { method: "GET" }), @@ -1435,11 +1451,17 @@ export class StandxGateway { this.balances.set(token, asset); } this.emitAccountSnapshot(); + return this.accountSnapshot; } catch (error) { this.logger("accountSnapshot", error); + return null; } } + async queryAccountSnapshot(): Promise { + return await this.refreshAccountSnapshot(); + } + private async refreshOpenOrders(symbol: string): Promise { try { const ordersPayload = await this.requestJson("/api/query_open_orders", { @@ -1708,22 +1730,70 @@ export class StandxGateway { } } } - const response = await fetch(url.toString(), { - method: options.method, - headers, - body: options.method === "GET" ? undefined : body, - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`); - } - if (!text) { - return {} as T; - } try { - return JSON.parse(text) as T; - } catch { - return text as unknown as T; + const response = await fetch(url.toString(), { + method: options.method, + headers, + body: options.method === "GET" ? undefined : body, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`); + } + if (!text) { + this.recordRestSuccess(); + return {} as T; + } + try { + const parsed = JSON.parse(text) as T; + this.recordRestSuccess(); + return parsed; + } catch { + this.recordRestSuccess(); + return text as unknown as T; + } + } catch (error) { + this.recordRestError({ + consecutiveErrors: this.restConsecutiveErrors + 1, + method: options.method, + path, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private recordRestSuccess(): void { + if (this.restConsecutiveErrors === 0 && !this.restUnhealthy) return; + this.restConsecutiveErrors = 0; + this.restLastError = null; + if (!this.restUnhealthy) return; + this.restUnhealthy = false; + this.emitRestHealth("healthy", { consecutiveErrors: 0 }); + } + + private recordRestError(info: RestHealthInfo): void { + this.restConsecutiveErrors = Math.max(0, Number(info.consecutiveErrors) || 0); + this.restLastError = info.error ?? this.restLastError; + + if (!this.restUnhealthy && this.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD) { + this.restUnhealthy = true; + this.emitRestHealth("unhealthy", { + consecutiveErrors: this.restConsecutiveErrors, + method: info.method, + path: info.path, + error: info.error ?? this.restLastError ?? undefined, + }); + } + } + + private emitRestHealth(state: RestHealthState, info: RestHealthInfo): void { + for (const listener of this.restHealthListeners) { + try { + listener(state, info); + } catch (error) { + this.logger("restHealthListener", error); + } } } diff --git a/src/notifications/telegram.ts b/src/notifications/telegram.ts index 41aa54f..e783ace 100644 --- a/src/notifications/telegram.ts +++ b/src/notifications/telegram.ts @@ -41,7 +41,11 @@ function formatNotificationMessage(notification: TradeNotification, accountLabel lines.push(``); for (const [key, value] of Object.entries(notification.details)) { if (value != null) { - lines.push(`• ${key}: ${value}`); + if (Array.isArray(value)) { + lines.push(`• ${key}: ${value.join(", ") || "[]"}`); + } else { + lines.push(`• ${key}: ${value}`); + } } } } diff --git a/src/notifications/types.ts b/src/notifications/types.ts index 723b7c8..1625dc2 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -7,7 +7,7 @@ export interface TradeNotification { title: string; message: string; accountLabel?: string; - details?: Record; + details?: Record; timestamp?: number; } diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 7ebbc29..22757cc 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -10,9 +10,9 @@ import { formatPriceToString } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { extractMessage, isInsufficientBalanceError, isPrecisionError, isRateLimitError, isUnknownOrderError } from "../utils/errors"; import { isOrderActiveStatus } from "../utils/order-status"; -import { getPosition, parseSymbolParts } from "../utils/strategy"; +import { getPosition, parseSymbolParts, validateAccountSnapshotForSymbol } from "../utils/strategy"; import type { PositionSnapshot } from "../utils/strategy"; -import { computePositionPnl } from "../utils/pnl"; +import { computePositionPnl, computeStopLossPnl } from "../utils/pnl"; import { getDepthBetweenPrices, getMidOrLast, getTopPrices } from "../utils/price"; import { marketClose, @@ -95,6 +95,8 @@ const STOP_LOSS_CHECK_INTERVAL_MS = 250; // 止损检查最大间隔 const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔 const DATA_STALE_THRESHOLD_MS = 5_000; // 数据过时阈值(5秒) const DEFENSE_MODE_CHECK_INTERVAL_MS = 1000; // 防御模式检查间隔 +const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000; // 账户数据长期无更新阈值(StandX 有 REST 兜底,长期无更新通常意味着异常) +const STANDX_REST_ERROR_DEFENSE_THRESHOLD = 3; export class MakerPointsEngine { private accountSnapshot: AsterAccountSnapshot | null = null; @@ -180,6 +182,9 @@ export class MakerPointsEngine { // 防御模式下的 REST 轮询定时器 private defenseRestPollTimer: ReturnType | null = null; private defenseRestPollActive = false; + private standxRestConsecutiveErrors = 0; + private standxRestUnhealthy = false; + private standxRestLastError: string | null = null; constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); @@ -276,6 +281,7 @@ export class MakerPointsEngine { private bootstrap(): void { const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); + this.setupRestHealthProtection(); safeSubscribe( this.exchange.watchAccount.bind(this.exchange), @@ -377,6 +383,36 @@ export class MakerPointsEngine { }); } + private setupRestHealthProtection(): void { + if (!this.exchange.onRestHealthEvent) return; + this.exchange.onRestHealthEvent((state, info) => { + if (state === "unhealthy") { + this.standxRestUnhealthy = true; + this.standxRestConsecutiveErrors = Math.max(this.standxRestConsecutiveErrors, info.consecutiveErrors); + this.standxRestLastError = info.error ?? this.standxRestLastError; + if (!this.defenseMode && this.standxRestConsecutiveErrors >= STANDX_REST_ERROR_DEFENSE_THRESHOLD) { + this.enterDefenseMode({ + standxDepthStale: false, + binanceStale: false, + standxAccountStale: false, + accountInvalid: false, + standxRestUnhealthy: true, + standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, + standxRestLastError: this.standxRestLastError, + standxDepthAge: 0, + binanceAge: 0, + standxAccountAge: 0, + accountIssues: [], + }); + } + } else if (state === "healthy") { + this.standxRestUnhealthy = false; + this.standxRestConsecutiveErrors = 0; + this.standxRestLastError = null; + } + }); + } + /** * 处理断连事件 */ @@ -498,6 +534,26 @@ export class MakerPointsEngine { this.emitUpdate(); return; } + + const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol); + if (!accountHealth.ok && !this.defenseMode) { + this.enterDefenseMode({ + standxDepthStale: false, + binanceStale: false, + standxAccountStale: false, + accountInvalid: true, + standxRestUnhealthy: false, + standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, + standxRestLastError: this.standxRestLastError, + standxDepthAge: 0, + binanceAge: 0, + standxAccountAge: 0, + accountIssues: accountHealth.issues, + }); + this.emitUpdate(); + return; + } + this.resetReadinessFlags(); if (!(await this.ensureStartupOrderReset())) { this.emitUpdate(); @@ -979,15 +1035,7 @@ export class MakerPointsEngine { */ private computeRealtimePnl(position: PositionSnapshot): number | null { const { topBid, topAsk } = getTopPrices(this.depthSnapshot); - // 使用实时深度计算 PnL - if (topBid != null && topAsk != null) { - return computePositionPnl(position, topBid, topAsk); - } - // 回退到账户推送的数据 - if (Number.isFinite(position.unrealizedProfit)) { - return position.unrealizedProfit; - } - return null; + return computeStopLossPnl(position, topBid, topAsk); } private async checkStopLoss(): Promise { @@ -1533,24 +1581,36 @@ export class MakerPointsEngine { /** * 检查数据是否过时,进入或退出防御模式 - * 仅检查深度数据(持续推送),账户数据只在变化时推送,不应作为过时判断依据 + * StandX 账户数据在 WS 推送异常时会通过 REST 补拉;长期无更新通常意味着 WS/REST 均异常,应进入防御模式 */ private checkDataStaleAndDefense(): void { const now = Date.now(); const standxDepthStale = this.lastStandxDepthTime > 0 && (now - this.lastStandxDepthTime) > DATA_STALE_THRESHOLD_MS; - // 账户数据只在有变化时推送,不作为过时判断依据 - // const standxAccountStale = this.lastStandxAccountTime > 0 && (now - this.lastStandxAccountTime) > DATA_STALE_THRESHOLD_MS; const binanceStale = this.lastBinanceDepthTime > 0 && (now - this.lastBinanceDepthTime) > DATA_STALE_THRESHOLD_MS; - const shouldDefend = standxDepthStale || binanceStale; + const standxAccountStale = + this.lastStandxAccountTime > 0 && (now - this.lastStandxAccountTime) > ACCOUNT_DATA_STALE_THRESHOLD_MS; + const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol); + const accountInvalid = this.accountSnapshot != null && !accountHealth.ok; + const standxRestUnhealthy = + this.standxRestUnhealthy && this.standxRestConsecutiveErrors >= STANDX_REST_ERROR_DEFENSE_THRESHOLD; + + const shouldDefend = standxDepthStale || binanceStale || standxAccountStale || accountInvalid || standxRestUnhealthy; if (shouldDefend && !this.defenseMode) { // 进入防御模式 this.enterDefenseMode({ standxDepthStale, binanceStale, - standxDepthAge: now - this.lastStandxDepthTime, - binanceAge: now - this.lastBinanceDepthTime, + standxAccountStale, + accountInvalid, + standxRestUnhealthy, + standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, + standxRestLastError: this.standxRestLastError, + standxDepthAge: this.lastStandxDepthTime > 0 ? now - this.lastStandxDepthTime : 0, + binanceAge: this.lastBinanceDepthTime > 0 ? now - this.lastBinanceDepthTime : 0, + standxAccountAge: this.lastStandxAccountTime > 0 ? now - this.lastStandxAccountTime : 0, + accountIssues: accountInvalid ? accountHealth.issues : [], }); } else if (!shouldDefend && this.defenseMode) { // 退出防御模式 @@ -1565,8 +1625,15 @@ export class MakerPointsEngine { private enterDefenseMode(staleInfo: { standxDepthStale: boolean; binanceStale: boolean; + standxAccountStale: boolean; + accountInvalid: boolean; + standxRestUnhealthy: boolean; + standxRestConsecutiveErrors: number; + standxRestLastError: string | null; standxDepthAge: number; binanceAge: number; + standxAccountAge: number; + accountIssues: string[]; }): void { this.defenseMode = true; @@ -1575,6 +1642,15 @@ export class MakerPointsEngine { if (staleInfo.standxDepthStale) { staleItems.push(`StandX深度(${Math.round(staleInfo.standxDepthAge / 1000)}s)`); } + if (staleInfo.standxAccountStale) { + staleItems.push(`StandX账户(${Math.round(staleInfo.standxAccountAge / 1000)}s)`); + } + if (staleInfo.accountInvalid) { + staleItems.push(`StandX仓位数据异常(${staleInfo.accountIssues.join(",") || "unknown"})`); + } + if (staleInfo.standxRestUnhealthy) { + staleItems.push(`StandX REST错误(${staleInfo.standxRestConsecutiveErrors}次)`); + } if (staleInfo.binanceStale) { staleItems.push(`Binance深度(${Math.round(staleInfo.binanceAge / 1000)}s)`); } @@ -1675,13 +1751,47 @@ export class MakerPointsEngine { if (!this.defenseRestPollActive || !this.defenseMode) return; try { - // 如果有查询挂单的方法,定期检查并取消 + if (this.exchange.queryAccountSnapshot) { + const nextAccount = await this.exchange.queryAccountSnapshot(); + if (nextAccount) { + this.accountSnapshot = nextAccount; + this.lastStandxAccountTime = Date.now(); + this.feedStatus.account = true; + const health = validateAccountSnapshotForSymbol(nextAccount, this.config.symbol); + if (!health.ok) { + this.tradeLog.push("warn", `防御模式: 仓位数据仍异常: ${health.issues.join(",")}`); + } + } else { + this.tradeLog.push("warn", "防御模式: REST 获取账户快照为空"); + } + } + + // 防御模式下持续通过 REST 刷新挂单,并尽力撤销所有挂单(避免本地状态/WS 丢失导致遗留挂单) if (this.exchange.queryOpenOrders) { - const realOrders = await this.exchange.queryOpenOrders(); - if (realOrders.length > 0) { - this.tradeLog.push("warn", `防御模式: 发现 ${realOrders.length} 个挂单,执行取消`); + try { + const realOrders = await this.exchange.queryOpenOrders(); + this.openOrders = Array.isArray(realOrders) + ? realOrders.filter( + (order) => + order.type !== "MARKET" && + order.symbol === this.config.symbol && + isOrderActiveStatus(order.status) + ) + : []; + this.pendingCancelOrders.clear(); + this.feedStatus.orders = true; + + if (realOrders.length > 0) { + this.tradeLog.push("warn", `防御模式: 发现 ${realOrders.length} 个挂单,执行取消`); + await this.defenseCancelAllOrders(); + } + } catch (error) { + this.tradeLog.push("error", `防御模式查询挂单失败: ${extractMessage(error)}`); + // 查询失败时仍然尝试撤销所有挂单(宁可多撤,也不遗留) await this.defenseCancelAllOrders(); } + } else { + await this.defenseCancelAllOrders(); } // 检查止损条件(使用当前账户快照中的数据) diff --git a/src/utils/pnl.ts b/src/utils/pnl.ts index 245dc63..6d67544 100644 --- a/src/utils/pnl.ts +++ b/src/utils/pnl.ts @@ -13,4 +13,24 @@ export function computePositionPnl( : (position.entryPrice - (priceForPnl as number)) * absAmt; } +export function computeStopLossPnl( + position: PositionSnapshot, + bestBid?: number | null, + bestAsk?: number | null +): number | null { + const absAmt = Math.abs(position.positionAmt); + if (!Number.isFinite(absAmt) || absAmt <= 0) return 0; + + // If entry price is missing, prefer the exchange-provided unrealized PnL. + if (!Number.isFinite(position.entryPrice) || position.entryPrice <= 0) { + return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null; + } + + const priceForPnl = position.positionAmt > 0 ? bestBid : bestAsk; + if (!Number.isFinite(priceForPnl as number) || (priceForPnl as number) <= 0) { + return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null; + } + + return computePositionPnl(position, bestBid, bestAsk); +} diff --git a/src/utils/strategy.ts b/src/utils/strategy.ts index 2cbe492..21bde5f 100644 --- a/src/utils/strategy.ts +++ b/src/utils/strategy.ts @@ -47,6 +47,47 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin }; } +export function validateAccountSnapshotForSymbol( + snapshot: AsterAccountSnapshot | null, + symbol: string +): { ok: true } | { ok: false; issues: string[] } { + if (!snapshot) return { ok: true }; + const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? []; + if (positions.length === 0) return { ok: true }; + + const NON_ZERO_EPS = 1e-8; + const issues: string[] = []; + + for (const position of positions) { + const amt = Number(position.positionAmt); + if (!Number.isFinite(amt)) { + issues.push("invalid_positionAmt"); + continue; + } + if (Math.abs(amt) <= NON_ZERO_EPS) { + continue; + } + + const entryPrice = Number(position.entryPrice); + if (!Number.isFinite(entryPrice) || entryPrice <= 0) { + issues.push("invalid_entryPrice"); + } + + const unrealizedProfit = Number(position.unrealizedProfit); + if (!Number.isFinite(unrealizedProfit)) { + issues.push("invalid_unrealizedProfit"); + } + + const rawMark = Number(position.markPrice); + if (position.markPrice != null && position.markPrice !== "" && (!Number.isFinite(rawMark) || rawMark <= 0)) { + issues.push("invalid_markPrice"); + } + } + + if (issues.length === 0) return { ok: true }; + return { ok: false, issues: Array.from(new Set(issues)) }; +} + export function getSMA(values: AsterKline[], length: number): number | null { if (!Array.isArray(values) || values.length < length) return null; const window = values.slice(-length); diff --git a/tests/account-snapshot-validate.test.ts b/tests/account-snapshot-validate.test.ts new file mode 100644 index 0000000..73a154f --- /dev/null +++ b/tests/account-snapshot-validate.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { AsterAccountSnapshot } from "../src/exchanges/types"; +import { validateAccountSnapshotForSymbol } from "../src/utils/strategy"; + +function baseSnapshot(positions: AsterAccountSnapshot["positions"]): AsterAccountSnapshot { + return { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: Date.now(), + totalWalletBalance: "0", + totalUnrealizedProfit: "0", + positions, + assets: [], + marketType: "perp", + }; +} + +describe("validateAccountSnapshotForSymbol", () => { + it("accepts empty positions", () => { + const result = validateAccountSnapshotForSymbol(baseSnapshot([]), "BTC-USD"); + expect(result.ok).toBe(true); + }); + + it("accepts zero-sized positions even if entry price is zero", () => { + const result = validateAccountSnapshotForSymbol( + baseSnapshot([ + { + symbol: "BTC-USD", + positionAmt: "0", + entryPrice: "0", + unrealizedProfit: "0", + positionSide: "BOTH", + updateTime: Date.now(), + markPrice: "0", + }, + ]), + "BTC-USD" + ); + expect(result.ok).toBe(true); + }); + + it("flags invalid numeric fields for non-zero positions", () => { + const result = validateAccountSnapshotForSymbol( + baseSnapshot([ + { + symbol: "BTC-USD", + positionAmt: "1", + entryPrice: "NaN", + unrealizedProfit: "oops", + positionSide: "BOTH", + updateTime: Date.now(), + markPrice: "-1", + }, + ]), + "BTC-USD" + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toEqual( + expect.arrayContaining(["invalid_entryPrice", "invalid_unrealizedProfit", "invalid_markPrice"]) + ); + } + }); + + it("flags invalid positionAmt", () => { + const result = validateAccountSnapshotForSymbol( + baseSnapshot([ + { + symbol: "BTC-USD", + positionAmt: "abc", + entryPrice: "100", + unrealizedProfit: "0", + positionSide: "BOTH", + updateTime: Date.now(), + markPrice: "101", + }, + ]), + "BTC-USD" + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toContain("invalid_positionAmt"); + } + }); +}); + diff --git a/tests/config-inline-comments.test.ts b/tests/config-inline-comments.test.ts new file mode 100644 index 0000000..1b0a39c --- /dev/null +++ b/tests/config-inline-comments.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +async function loadConfig() { + vi.resetModules(); + return await import("../src/config"); +} + +describe("config env parsing", () => { + it("strips shell-style inline comments from symbol values", async () => { + process.env.EXCHANGE = "standx"; + process.env.STANDX_SYMBOL = "BTC-USD # comment"; + + const { resolveSymbolFromEnv } = await loadConfig(); + expect(resolveSymbolFromEnv()).toBe("BTC-USD"); + }); + + it("parses numeric maker-points env values with inline comments", async () => { + process.env.EXCHANGE = "standx"; + process.env.MAKER_POINTS_STOP_LOSS_USD = "1 # comment"; + process.env.MAKER_POINTS_CLOSE_THRESHOLD = "2 ; comment"; + + const { makerPointsConfig } = await loadConfig(); + expect(makerPointsConfig.stopLossUsd).toBe(1); + expect(makerPointsConfig.closeThreshold).toBe(2); + }); + + it("parses boolean maker-points env values with inline comments", async () => { + process.env.EXCHANGE = "standx"; + process.env.MAKER_POINTS_BAND_10_30 = "false # comment"; + + const { makerPointsConfig } = await loadConfig(); + expect(makerPointsConfig.enableBand10To30).toBe(false); + }); +}); + diff --git a/tests/maker-points-defense-rest.test.ts b/tests/maker-points-defense-rest.test.ts new file mode 100644 index 0000000..8c825b1 --- /dev/null +++ b/tests/maker-points-defense-rest.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types"; +import { MakerPointsEngine } from "../src/strategy/maker-points-engine"; + +class StubAdapter implements ExchangeAdapter { + id = "standx"; + cancelAllCount = 0; + openOrders: AsterOrder[] | Error = []; + accountSnapshot: AsterAccountSnapshot | null = null; + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {} + watchOrders(_cb: (orders: AsterOrder[]) => void): void {} + watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {} + watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {} + watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {} + + async createOrder(): Promise { + throw new Error("not implemented"); + } + + async cancelOrder(): Promise {} + async cancelOrders(): Promise {} + + async cancelAllOrders(): Promise { + this.cancelAllCount += 1; + this.openOrders = []; + } + + async queryOpenOrders(): Promise { + if (this.openOrders instanceof Error) throw this.openOrders; + return this.openOrders; + } + + async queryAccountSnapshot(): Promise { + return this.accountSnapshot; + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("MakerPointsEngine defense-mode REST polling", () => { + it("keeps trying to fetch open orders and cancel all when open orders exist", async () => { + vi.useFakeTimers(); + const adapter = new StubAdapter(); + adapter.openOrders = [ + { + orderId: "1", + clientOrderId: "c1", + symbol: "BTC-USD", + side: "BUY", + type: "LIMIT", + status: "NEW", + price: "100", + origQty: "1", + executedQty: "0", + time: Date.now(), + updateTime: Date.now(), + reduceOnly: "false", + closePosition: "false", + }, + ]; + + const engine = new MakerPointsEngine( + { + symbol: "BTC-USD", + perOrderAmount: 0.01, + closeThreshold: 0, + stopLossUsd: 1, + refreshIntervalMs: 500, + maxLogEntries: 10, + maxCloseSlippagePct: 0.05, + priceTick: 0.1, + qtyStep: 0.001, + enableBand0To10: true, + enableBand10To30: false, + enableBand30To100: false, + band0To10Amount: 0.01, + band10To30Amount: 0.01, + band30To100Amount: 0.01, + minRepriceBps: 3, + enableBinanceDepthCancel: false, + filterMinDepth: 0, + }, + adapter + ); + + (engine as any).enterDefenseMode({ + standxDepthStale: true, + binanceStale: false, + standxAccountStale: false, + accountInvalid: false, + standxRestUnhealthy: false, + standxRestConsecutiveErrors: 0, + standxRestLastError: null, + standxDepthAge: 6000, + binanceAge: 0, + standxAccountAge: 0, + accountIssues: [], + }); + + await vi.waitFor(() => { + expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1); + }); + + engine.stop(); + }); + + it("attempts cancel-all even if open-order query fails", async () => { + vi.useFakeTimers(); + const adapter = new StubAdapter(); + adapter.openOrders = new Error("boom"); + + const engine = new MakerPointsEngine( + { + symbol: "BTC-USD", + perOrderAmount: 0.01, + closeThreshold: 0, + stopLossUsd: 1, + refreshIntervalMs: 500, + maxLogEntries: 10, + maxCloseSlippagePct: 0.05, + priceTick: 0.1, + qtyStep: 0.001, + enableBand0To10: true, + enableBand10To30: false, + enableBand30To100: false, + band0To10Amount: 0.01, + band10To30Amount: 0.01, + band30To100Amount: 0.01, + minRepriceBps: 3, + enableBinanceDepthCancel: false, + filterMinDepth: 0, + }, + adapter + ); + + (engine as any).enterDefenseMode({ + standxDepthStale: true, + binanceStale: false, + standxAccountStale: false, + accountInvalid: false, + standxRestUnhealthy: false, + standxRestConsecutiveErrors: 0, + standxRestLastError: null, + standxDepthAge: 6000, + binanceAge: 0, + standxAccountAge: 0, + accountIssues: [], + }); + + await vi.waitFor(() => { + expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1); + }); + + engine.stop(); + }); +}); + diff --git a/tests/pnl-stoploss.test.ts b/tests/pnl-stoploss.test.ts new file mode 100644 index 0000000..ef8ead9 --- /dev/null +++ b/tests/pnl-stoploss.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { computeStopLossPnl } from "../src/utils/pnl"; + +describe("computeStopLossPnl", () => { + it("falls back to exchange-provided unrealized PnL when entryPrice is missing", () => { + const pnl = computeStopLossPnl( + { positionAmt: 1, entryPrice: 0, unrealizedProfit: -2000, markPrice: null }, + 40000, + 40010 + ); + expect(pnl).toBe(-2000); + }); + + it("uses best bid/ask when entryPrice is available", () => { + const longPnl = computeStopLossPnl( + { positionAmt: 1, entryPrice: 100, unrealizedProfit: -5, markPrice: null }, + 90, + 91 + ); + expect(longPnl).toBe(-10); + + const shortPnl = computeStopLossPnl( + { positionAmt: -2, entryPrice: 100, unrealizedProfit: -5, markPrice: null }, + 95, + 105 + ); + expect(shortPnl).toBe(-10); + }); + + it("falls back to exchange-provided unrealized PnL when best prices are unavailable", () => { + const pnl = computeStopLossPnl( + { positionAmt: 1, entryPrice: 100, unrealizedProfit: -123, markPrice: null }, + null, + null + ); + expect(pnl).toBe(-123); + }); +}); + diff --git a/tests/standx-rest-health.test.ts b/tests/standx-rest-health.test.ts new file mode 100644 index 0000000..d0c7745 --- /dev/null +++ b/tests/standx-rest-health.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StandxGateway } from "../src/exchanges/standx/gateway"; + +const ORIGINAL_FETCH = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + vi.restoreAllMocks(); +}); + +describe("StandxGateway REST health", () => { + it("emits unhealthy after 3 consecutive REST failures, then healthy after a success", async () => { + const gateway = new StandxGateway({ + token: "test-token", + symbol: "BTC-USD", + baseUrl: "https://example.com", + wsUrl: "wss://example.com/ws", + logger: () => {}, + }); + + const events: Array<{ state: string; consecutiveErrors: number }> = []; + gateway.onRestHealthEvent((state, info) => { + events.push({ state, consecutiveErrors: info.consecutiveErrors }); + }); + + globalThis.fetch = vi.fn(async () => { + return { + ok: false, + status: 500, + text: async () => "server error", + } as any; + }) as any; + + await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow(); + await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow(); + await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow(); + + expect(events).toEqual([{ state: "unhealthy", consecutiveErrors: 3 }]); + + globalThis.fetch = vi.fn(async () => { + return { + ok: true, + status: 200, + text: async () => "[]", + } as any; + }) as any; + + await expect(gateway.queryOpenOrders("BTC-USD")).resolves.toEqual([]); + expect(events).toEqual([ + { state: "unhealthy", consecutiveErrors: 3 }, + { state: "healthy", consecutiveErrors: 0 }, + ]); + }); +}); +