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.
This commit is contained in:
discountry
2026-01-24 22:46:14 +08:00
parent fe7b8eb6f3
commit a629bc940c
14 changed files with 738 additions and 46 deletions
+35 -7
View File
@@ -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;
+14
View File
@@ -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<AsterOrder[]>;
queryAccountSnapshot?(): Promise<AsterAccountSnapshot | null>;
forceCancelAllOrders?(): Promise<boolean>;
}
+14
View File
@@ -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();
}
/**
* 强制取消所有挂单
* 会查询当前挂单然后取消,并验证取消成功
+86 -16
View File
@@ -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<string, VirtualStop>();
private accountSnapshot: AsterAccountSnapshot | null = null;
private readonly restHealthListeners = new Set<RestHealthListener>();
private restConsecutiveErrors = 0;
private restUnhealthy = false;
private restLastError: string | null = null;
private fundingState = new Map<string, FundingState>();
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<void> {
private async refreshAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
try {
const [balance, positions] = await Promise.all([
this.requestJson<StandxBalanceSnapshot>("/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<AsterAccountSnapshot | null> {
return await this.refreshAccountSnapshot();
}
private async refreshOpenOrders(symbol: string): Promise<void> {
try {
const ordersPayload = await this.requestJson<unknown>("/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);
}
}
}
+5 -1
View File
@@ -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}`);
}
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ export interface TradeNotification {
title: string;
message: string;
accountLabel?: string;
details?: Record<string, string | number | boolean | null>;
details?: Record<string, string | number | boolean | null | string[]>;
timestamp?: number;
}
+131 -21
View File
@@ -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<typeof setTimeout> | 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<AsterAccountSnapshot>(
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<void> {
@@ -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();
}
// 检查止损条件(使用当前账户快照中的数据)
+20
View File
@@ -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);
}
+41
View File
@@ -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);
+87
View File
@@ -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");
}
});
});
+45
View File
@@ -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);
});
});
+165
View File
@@ -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<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {
this.cancelAllCount += 1;
this.openOrders = [];
}
async queryOpenOrders(): Promise<AsterOrder[]> {
if (this.openOrders instanceof Error) throw this.openOrders;
return this.openOrders;
}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
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();
});
});
+39
View File
@@ -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);
});
});
+55
View File
@@ -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 },
]);
});
});