mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
Compare commits
3
Commits
fe7b8eb6f3
...
1d88ddefb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d88ddefb5 | ||
|
|
683352f737 | ||
|
|
a629bc940c |
+35
-7
@@ -105,23 +105,51 @@ export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId |
|
|||||||
: resolveExchangeId();
|
: resolveExchangeId();
|
||||||
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
|
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = process.env[key];
|
const value = normalizeEnvValue(process.env[key]);
|
||||||
if (value && value.trim()) {
|
if (value) {
|
||||||
return value.trim();
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fallback;
|
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 {
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
if (!value) return fallback;
|
const normalized = normalizeEnvValue(value);
|
||||||
const next = Number(value);
|
if (!normalized) return fallback;
|
||||||
|
const next = Number(normalized);
|
||||||
return Number.isFinite(next) ? next : fallback;
|
return Number.isFinite(next) ? next : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
if (!value) return fallback;
|
const normalized = normalizeEnvValue(value)?.toLowerCase();
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (!normalized) return fallback;
|
if (!normalized) return fallback;
|
||||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
||||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ export interface FundingRateListener {
|
|||||||
(snapshot: FundingRateSnapshot): void;
|
(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 {
|
export interface ExchangePrecision {
|
||||||
priceTick: number;
|
priceTick: number;
|
||||||
qtyStep: number;
|
qtyStep: number;
|
||||||
@@ -69,6 +80,10 @@ export interface ExchangeAdapter {
|
|||||||
// 连接保护相关方法(可选,仅 StandX 支持)
|
// 连接保护相关方法(可选,仅 StandX 支持)
|
||||||
onConnectionEvent?(listener: ConnectionEventListener): void;
|
onConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
offConnectionEvent?(listener: ConnectionEventListener): void;
|
offConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
onRestHealthEvent?(listener: RestHealthListener): void;
|
||||||
|
offRestHealthEvent?(listener: RestHealthListener): void;
|
||||||
queryOpenOrders?(): Promise<AsterOrder[]>;
|
queryOpenOrders?(): Promise<AsterOrder[]>;
|
||||||
|
queryAccountSnapshot?(): Promise<AsterAccountSnapshot | null>;
|
||||||
|
changeMarginMode?(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void>;
|
||||||
forceCancelAllOrders?(): Promise<boolean>;
|
forceCancelAllOrders?(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
FundingRateListener,
|
FundingRateListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
OrderListener,
|
OrderListener,
|
||||||
|
RestHealthListener,
|
||||||
TickerListener,
|
TickerListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import type { AsterOrder, CreateOrderParams } from "../types";
|
import type { AsterOrder, CreateOrderParams } from "../types";
|
||||||
@@ -138,6 +139,14 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
|
|||||||
this.gateway.offConnectionEvent(listener);
|
this.gateway.offConnectionEvent(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.gateway.onRestHealthEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.gateway.offRestHealthEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前真实的挂单状态(通过 HTTP API)
|
* 查询当前真实的挂单状态(通过 HTTP API)
|
||||||
* 用于验证实际挂单情况,防止取消请求丢失
|
* 用于验证实际挂单情况,防止取消请求丢失
|
||||||
@@ -147,6 +156,16 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
|
|||||||
return this.gateway.queryOpenOrders(this.symbol);
|
return this.gateway.queryOpenOrders(this.symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot() {
|
||||||
|
await this.ensureInitialized("queryAccountSnapshot");
|
||||||
|
return this.gateway.queryAccountSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
|
||||||
|
await this.ensureInitialized("changeMarginMode");
|
||||||
|
await this.gateway.changeMarginMode(params.symbol, params.marginMode);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制取消所有挂单
|
* 强制取消所有挂单
|
||||||
* 会查询当前挂单然后取消,并验证取消成功
|
* 会查询当前挂单然后取消,并验证取消成功
|
||||||
|
|||||||
+118
-21
@@ -8,6 +8,9 @@ import type {
|
|||||||
FundingRateListener,
|
FundingRateListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
OrderListener,
|
OrderListener,
|
||||||
|
RestHealthInfo,
|
||||||
|
RestHealthListener,
|
||||||
|
RestHealthState,
|
||||||
TickerListener,
|
TickerListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import type {
|
import type {
|
||||||
@@ -62,6 +65,7 @@ const WS_HEARTBEAT_CHECK_INTERVAL = 30_000;
|
|||||||
const WS_DATA_STALE_THRESHOLD = 3000;
|
const WS_DATA_STALE_THRESHOLD = 3000;
|
||||||
// REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔
|
// REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔
|
||||||
const REST_POLL_INTERVAL = 2000;
|
const REST_POLL_INTERVAL = 2000;
|
||||||
|
const REST_ERROR_DEFENSE_THRESHOLD = 3;
|
||||||
|
|
||||||
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
|
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
|
||||||
|
|
||||||
@@ -420,6 +424,10 @@ export class StandxGateway {
|
|||||||
private readonly virtualStops = new Map<string, VirtualStop>();
|
private readonly virtualStops = new Map<string, VirtualStop>();
|
||||||
|
|
||||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
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 fundingState = new Map<string, FundingState>();
|
||||||
|
|
||||||
private marketWs: WebSocket | null = null;
|
private marketWs: WebSocket | null = null;
|
||||||
@@ -558,6 +566,14 @@ export class StandxGateway {
|
|||||||
this.connectionListeners.add(listener);
|
this.connectionListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.restHealthListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.restHealthListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
offConnectionEvent(listener: ConnectionEventListener): void {
|
offConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
this.connectionListeners.delete(listener);
|
this.connectionListeners.delete(listener);
|
||||||
}
|
}
|
||||||
@@ -1382,7 +1398,7 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitAccountSnapshot(): void {
|
private emitAccountSnapshot(updateTime?: number): void {
|
||||||
const positions = Array.from(this.positions.values());
|
const positions = Array.from(this.positions.values());
|
||||||
const assets = Array.from(this.balances.values());
|
const assets = Array.from(this.balances.values());
|
||||||
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
|
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
|
||||||
@@ -1394,7 +1410,7 @@ export class StandxGateway {
|
|||||||
canTrade: true,
|
canTrade: true,
|
||||||
canDeposit: true,
|
canDeposit: true,
|
||||||
canWithdraw: true,
|
canWithdraw: true,
|
||||||
updateTime: Date.now(),
|
updateTime: typeof updateTime === "number" && Number.isFinite(updateTime) && updateTime > 0 ? updateTime : Date.now(),
|
||||||
totalWalletBalance: String(totalWalletBalance || 0),
|
totalWalletBalance: String(totalWalletBalance || 0),
|
||||||
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
|
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
|
||||||
positions,
|
positions,
|
||||||
@@ -1411,16 +1427,19 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshAccountSnapshot(): Promise<void> {
|
private async refreshAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
try {
|
try {
|
||||||
const [balance, positions] = await Promise.all([
|
const [balance, positions] = await Promise.all([
|
||||||
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
|
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
|
||||||
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
|
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
|
||||||
]);
|
]);
|
||||||
|
let restSnapshotTime = 0;
|
||||||
if (Array.isArray(positions)) {
|
if (Array.isArray(positions)) {
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const mapped = this.mapPosition(position);
|
const mapped = this.mapPosition(position);
|
||||||
this.positions.set(mapped.symbol, mapped);
|
this.positions.set(mapped.symbol, mapped);
|
||||||
|
const positionTime = toTimestamp(position.time ?? position.updated_at);
|
||||||
|
restSnapshotTime = Math.max(restSnapshotTime, positionTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (balance) {
|
if (balance) {
|
||||||
@@ -1429,14 +1448,44 @@ export class StandxGateway {
|
|||||||
asset: token,
|
asset: token,
|
||||||
walletBalance: String(balance.balance ?? "0"),
|
walletBalance: String(balance.balance ?? "0"),
|
||||||
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
|
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
|
||||||
updateTime: Date.now(),
|
updateTime: restSnapshotTime > 0 ? restSnapshotTime : Date.now(),
|
||||||
unrealizedProfit: String(balance.upnl ?? "0"),
|
unrealizedProfit: String(balance.upnl ?? "0"),
|
||||||
};
|
};
|
||||||
this.balances.set(token, asset);
|
this.balances.set(token, asset);
|
||||||
}
|
}
|
||||||
this.emitAccountSnapshot();
|
this.emitAccountSnapshot(restSnapshotTime > 0 ? restSnapshotTime : undefined);
|
||||||
|
return this.accountSnapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("accountSnapshot", error);
|
this.logger("accountSnapshot", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return await this.refreshAccountSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise<void> {
|
||||||
|
if (!this.signer.hasKey()) {
|
||||||
|
throw new Error("StandX change_margin_mode requires STANDX_REQUEST_PRIVATE_KEY for signed requests");
|
||||||
|
}
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
||||||
|
"/api/change_margin_mode",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
symbol: normalized,
|
||||||
|
margin_mode: marginMode,
|
||||||
|
},
|
||||||
|
signed: true,
|
||||||
|
extraHeaders: {
|
||||||
|
"x-session-id": this.sessionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response && typeof response.code === "number" && response.code !== 0) {
|
||||||
|
throw new Error(response.message ?? "StandX change margin mode rejected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1633,7 +1682,7 @@ export class StandxGateway {
|
|||||||
entryPrice: String(data.entry_price ?? "0"),
|
entryPrice: String(data.entry_price ?? "0"),
|
||||||
unrealizedProfit: String(data.upnl ?? "0"),
|
unrealizedProfit: String(data.upnl ?? "0"),
|
||||||
positionSide: "BOTH",
|
positionSide: "BOTH",
|
||||||
updateTime: toTimestamp(data.updated_at),
|
updateTime: toTimestamp(data.time ?? data.updated_at),
|
||||||
leverage: data.leverage ? String(data.leverage) : undefined,
|
leverage: data.leverage ? String(data.leverage) : undefined,
|
||||||
marginType: data.margin_mode,
|
marginType: data.margin_mode,
|
||||||
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
|
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
|
||||||
@@ -1708,22 +1757,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 {
|
try {
|
||||||
return JSON.parse(text) as T;
|
const response = await fetch(url.toString(), {
|
||||||
} catch {
|
method: options.method,
|
||||||
return text as unknown as T;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface StandxPosition {
|
|||||||
leverage?: string;
|
leverage?: string;
|
||||||
liq_price?: string;
|
liq_price?: string;
|
||||||
margin_mode?: string;
|
margin_mode?: string;
|
||||||
|
time?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ function formatNotificationMessage(notification: TradeNotification, accountLabel
|
|||||||
lines.push(``);
|
lines.push(``);
|
||||||
for (const [key, value] of Object.entries(notification.details)) {
|
for (const [key, value] of Object.entries(notification.details)) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
lines.push(`• ${key}: ${value}`);
|
if (Array.isArray(value)) {
|
||||||
|
lines.push(`• ${key}: ${value.join(", ") || "[]"}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`• ${key}: ${value}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface TradeNotification {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
accountLabel?: string;
|
accountLabel?: string;
|
||||||
details?: Record<string, string | number | boolean | null>;
|
details?: Record<string, string | number | boolean | null | string[]>;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { formatPriceToString } from "../utils/math";
|
|||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { extractMessage, isInsufficientBalanceError, isPrecisionError, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isInsufficientBalanceError, isPrecisionError, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
||||||
import { isOrderActiveStatus } from "../utils/order-status";
|
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 type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl, computeStopLossPnl } from "../utils/pnl";
|
||||||
import { getDepthBetweenPrices, getMidOrLast, getTopPrices } from "../utils/price";
|
import { getDepthBetweenPrices, getMidOrLast, getTopPrices } from "../utils/price";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -95,6 +95,11 @@ const STOP_LOSS_CHECK_INTERVAL_MS = 250; // 止损检查最大间隔
|
|||||||
const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔
|
const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔
|
||||||
const DATA_STALE_THRESHOLD_MS = 5_000; // 数据过时阈值(5秒)
|
const DATA_STALE_THRESHOLD_MS = 5_000; // 数据过时阈值(5秒)
|
||||||
const DEFENSE_MODE_CHECK_INTERVAL_MS = 1000; // 防御模式检查间隔
|
const DEFENSE_MODE_CHECK_INTERVAL_MS = 1000; // 防御模式检查间隔
|
||||||
|
const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000; // 账户数据长期无更新阈值(会先尝试通过 REST 补拉验证,不直接进入防御模式)
|
||||||
|
const STANDX_REST_ERROR_DEFENSE_THRESHOLD = 3;
|
||||||
|
const STANDX_MARGIN_MODE_CHECK_INTERVAL_MS = 500;
|
||||||
|
const STANDX_MARGIN_MODE_MAX_ATTEMPTS = 10;
|
||||||
|
const ACCOUNT_STALE_REST_PROBE_MIN_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
export class MakerPointsEngine {
|
export class MakerPointsEngine {
|
||||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
@@ -173,6 +178,9 @@ export class MakerPointsEngine {
|
|||||||
private lastStandxDepthTime = 0;
|
private lastStandxDepthTime = 0;
|
||||||
private lastStandxAccountTime = 0;
|
private lastStandxAccountTime = 0;
|
||||||
private lastBinanceDepthTime = 0;
|
private lastBinanceDepthTime = 0;
|
||||||
|
private accountStaleRestProbeInFlight: Promise<void> | null = null;
|
||||||
|
private accountStaleRestProbeLastAttempt = 0;
|
||||||
|
private accountStaleRestProbeConsecutiveFailures = 0;
|
||||||
// 防御模式状态
|
// 防御模式状态
|
||||||
private defenseMode = false;
|
private defenseMode = false;
|
||||||
private defenseModeNotified = false;
|
private defenseModeNotified = false;
|
||||||
@@ -180,6 +188,10 @@ export class MakerPointsEngine {
|
|||||||
// 防御模式下的 REST 轮询定时器
|
// 防御模式下的 REST 轮询定时器
|
||||||
private defenseRestPollTimer: ReturnType<typeof setTimeout> | null = null;
|
private defenseRestPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private defenseRestPollActive = false;
|
private defenseRestPollActive = false;
|
||||||
|
private standxRestConsecutiveErrors = 0;
|
||||||
|
private standxRestUnhealthy = false;
|
||||||
|
private standxRestLastError: string | null = null;
|
||||||
|
private marginModeEnsuring: Promise<boolean> | null = null;
|
||||||
|
|
||||||
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
@@ -276,21 +288,12 @@ export class MakerPointsEngine {
|
|||||||
|
|
||||||
private bootstrap(): void {
|
private bootstrap(): void {
|
||||||
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
||||||
|
this.setupRestHealthProtection();
|
||||||
|
|
||||||
safeSubscribe<AsterAccountSnapshot>(
|
safeSubscribe<AsterAccountSnapshot>(
|
||||||
this.exchange.watchAccount.bind(this.exchange),
|
this.exchange.watchAccount.bind(this.exchange),
|
||||||
(snapshot) => {
|
(snapshot) => {
|
||||||
this.accountSnapshot = snapshot;
|
this.applyAccountSnapshot(snapshot);
|
||||||
this.lastStandxAccountTime = Date.now();
|
|
||||||
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
|
|
||||||
if (Number.isFinite(totalUnrealized)) {
|
|
||||||
this.accountUnrealized = totalUnrealized;
|
|
||||||
}
|
|
||||||
const position = getPosition(snapshot, this.config.symbol);
|
|
||||||
this.sessionVolume.update(position, this.getReferencePrice());
|
|
||||||
this.detectPositionChange(position);
|
|
||||||
this.feedStatus.account = true;
|
|
||||||
this.emitUpdate();
|
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
@@ -361,6 +364,51 @@ export class MakerPointsEngine {
|
|||||||
this.setupConnectionProtection();
|
this.setupConnectionProtection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyAccountSnapshot(snapshot: AsterAccountSnapshot): void {
|
||||||
|
this.accountSnapshot = snapshot;
|
||||||
|
// StandX: WS 推送使用本地接收时间戳;REST 快照使用响应里的 time 字段映射到 snapshot.updateTime
|
||||||
|
this.lastStandxAccountTime =
|
||||||
|
this.exchange.id === "standx" && Number.isFinite(snapshot.updateTime) && snapshot.updateTime > 0
|
||||||
|
? snapshot.updateTime
|
||||||
|
: Date.now();
|
||||||
|
this.accountStaleRestProbeConsecutiveFailures = 0;
|
||||||
|
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
|
||||||
|
if (Number.isFinite(totalUnrealized)) {
|
||||||
|
this.accountUnrealized = totalUnrealized;
|
||||||
|
}
|
||||||
|
const position = getPosition(snapshot, this.config.symbol);
|
||||||
|
this.sessionVolume.update(position, this.getReferencePrice());
|
||||||
|
this.detectPositionChange(position);
|
||||||
|
this.feedStatus.account = true;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeProbeStandxAccountSnapshot(now: number): void {
|
||||||
|
if (this.exchange.id !== "standx") return;
|
||||||
|
if (!this.exchange.queryAccountSnapshot) return;
|
||||||
|
if (this.defenseMode) return;
|
||||||
|
if (this.accountStaleRestProbeInFlight) return;
|
||||||
|
if (this.accountStaleRestProbeLastAttempt > 0 && now - this.accountStaleRestProbeLastAttempt < ACCOUNT_STALE_REST_PROBE_MIN_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.accountStaleRestProbeLastAttempt = now;
|
||||||
|
|
||||||
|
this.accountStaleRestProbeInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const next = await this.exchange.queryAccountSnapshot?.();
|
||||||
|
if (next) {
|
||||||
|
this.applyAccountSnapshot(next);
|
||||||
|
} else {
|
||||||
|
this.accountStaleRestProbeConsecutiveFailures += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.accountStaleRestProbeConsecutiveFailures += 1;
|
||||||
|
} finally {
|
||||||
|
this.accountStaleRestProbeInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置连接保护机制
|
* 设置连接保护机制
|
||||||
* 监听断连/重连事件,实现保护逻辑
|
* 监听断连/重连事件,实现保护逻辑
|
||||||
@@ -377,6 +425,38 @@ 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,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: this.getStandxMarginMode(this.accountSnapshot),
|
||||||
|
standxDepthAge: 0,
|
||||||
|
binanceAge: 0,
|
||||||
|
standxAccountAge: 0,
|
||||||
|
accountIssues: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (state === "healthy") {
|
||||||
|
this.standxRestUnhealthy = false;
|
||||||
|
this.standxRestConsecutiveErrors = 0;
|
||||||
|
this.standxRestLastError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理断连事件
|
* 处理断连事件
|
||||||
*/
|
*/
|
||||||
@@ -498,6 +578,49 @@ export class MakerPointsEngine {
|
|||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await this.ensureStandxIsolatedMarginMode())) {
|
||||||
|
const current = this.getStandxMarginMode(this.accountSnapshot);
|
||||||
|
this.enterDefenseMode({
|
||||||
|
standxDepthStale: false,
|
||||||
|
binanceStale: false,
|
||||||
|
standxAccountStale: false,
|
||||||
|
accountInvalid: false,
|
||||||
|
standxRestUnhealthy: false,
|
||||||
|
standxRestConsecutiveErrors: this.standxRestConsecutiveErrors,
|
||||||
|
standxRestLastError: this.standxRestLastError,
|
||||||
|
marginModeNotIsolated: true,
|
||||||
|
marginMode: current,
|
||||||
|
standxDepthAge: 0,
|
||||||
|
binanceAge: 0,
|
||||||
|
standxAccountAge: 0,
|
||||||
|
accountIssues: [],
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: this.getStandxMarginMode(this.accountSnapshot),
|
||||||
|
standxDepthAge: 0,
|
||||||
|
binanceAge: 0,
|
||||||
|
standxAccountAge: 0,
|
||||||
|
accountIssues: accountHealth.issues,
|
||||||
|
});
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.resetReadinessFlags();
|
this.resetReadinessFlags();
|
||||||
if (!(await this.ensureStartupOrderReset())) {
|
if (!(await this.ensureStartupOrderReset())) {
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
@@ -979,15 +1102,7 @@ export class MakerPointsEngine {
|
|||||||
*/
|
*/
|
||||||
private computeRealtimePnl(position: PositionSnapshot): number | null {
|
private computeRealtimePnl(position: PositionSnapshot): number | null {
|
||||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
// 使用实时深度计算 PnL
|
return computeStopLossPnl(position, topBid, topAsk);
|
||||||
if (topBid != null && topAsk != null) {
|
|
||||||
return computePositionPnl(position, topBid, topAsk);
|
|
||||||
}
|
|
||||||
// 回退到账户推送的数据
|
|
||||||
if (Number.isFinite(position.unrealizedProfit)) {
|
|
||||||
return position.unrealizedProfit;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkStopLoss(): Promise<void> {
|
private async checkStopLoss(): Promise<void> {
|
||||||
@@ -1533,24 +1648,54 @@ export class MakerPointsEngine {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查数据是否过时,进入或退出防御模式
|
* 检查数据是否过时,进入或退出防御模式
|
||||||
* 仅检查深度数据(持续推送),账户数据只在变化时推送,不应作为过时判断依据
|
* StandX 账户数据在 WS 推送异常时会通过 REST 补拉;长期无更新通常意味着 WS/REST 均异常,应进入防御模式
|
||||||
*/
|
*/
|
||||||
private checkDataStaleAndDefense(): void {
|
private checkDataStaleAndDefense(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const standxDepthStale = this.lastStandxDepthTime > 0 && (now - this.lastStandxDepthTime) > DATA_STALE_THRESHOLD_MS;
|
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 binanceStale = this.lastBinanceDepthTime > 0 && (now - this.lastBinanceDepthTime) > DATA_STALE_THRESHOLD_MS;
|
||||||
|
|
||||||
const shouldDefend = standxDepthStale || binanceStale;
|
const standxAccountAge = this.lastStandxAccountTime > 0 ? now - this.lastStandxAccountTime : 0;
|
||||||
|
const standxAccountStaleByAge = this.lastStandxAccountTime > 0 && standxAccountAge > ACCOUNT_DATA_STALE_THRESHOLD_MS;
|
||||||
|
if (standxAccountStaleByAge) {
|
||||||
|
this.maybeProbeStandxAccountSnapshot(now);
|
||||||
|
}
|
||||||
|
const standxAccountStale =
|
||||||
|
standxAccountStaleByAge &&
|
||||||
|
// WS 推送间隔可能较长,先给 REST 补拉一次机会;只有补拉失败后才进入防御模式
|
||||||
|
this.accountStaleRestProbeConsecutiveFailures > 0 &&
|
||||||
|
this.accountStaleRestProbeInFlight == null;
|
||||||
|
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 marginMode = this.getStandxMarginMode(this.accountSnapshot);
|
||||||
|
const marginModeNotIsolated = this.exchange.id === "standx" && marginMode != null && marginMode !== "isolated";
|
||||||
|
|
||||||
|
const shouldDefend =
|
||||||
|
standxDepthStale ||
|
||||||
|
binanceStale ||
|
||||||
|
standxAccountStale ||
|
||||||
|
accountInvalid ||
|
||||||
|
standxRestUnhealthy ||
|
||||||
|
marginModeNotIsolated;
|
||||||
|
|
||||||
if (shouldDefend && !this.defenseMode) {
|
if (shouldDefend && !this.defenseMode) {
|
||||||
// 进入防御模式
|
// 进入防御模式
|
||||||
this.enterDefenseMode({
|
this.enterDefenseMode({
|
||||||
standxDepthStale,
|
standxDepthStale,
|
||||||
binanceStale,
|
binanceStale,
|
||||||
standxDepthAge: now - this.lastStandxDepthTime,
|
standxAccountStale,
|
||||||
binanceAge: now - this.lastBinanceDepthTime,
|
accountInvalid,
|
||||||
|
standxRestUnhealthy,
|
||||||
|
standxRestConsecutiveErrors: this.standxRestConsecutiveErrors,
|
||||||
|
standxRestLastError: this.standxRestLastError,
|
||||||
|
marginModeNotIsolated,
|
||||||
|
marginMode,
|
||||||
|
standxDepthAge: this.lastStandxDepthTime > 0 ? now - this.lastStandxDepthTime : 0,
|
||||||
|
binanceAge: this.lastBinanceDepthTime > 0 ? now - this.lastBinanceDepthTime : 0,
|
||||||
|
standxAccountAge,
|
||||||
|
accountIssues: accountInvalid ? accountHealth.issues : [],
|
||||||
});
|
});
|
||||||
} else if (!shouldDefend && this.defenseMode) {
|
} else if (!shouldDefend && this.defenseMode) {
|
||||||
// 退出防御模式
|
// 退出防御模式
|
||||||
@@ -1565,8 +1710,17 @@ export class MakerPointsEngine {
|
|||||||
private enterDefenseMode(staleInfo: {
|
private enterDefenseMode(staleInfo: {
|
||||||
standxDepthStale: boolean;
|
standxDepthStale: boolean;
|
||||||
binanceStale: boolean;
|
binanceStale: boolean;
|
||||||
|
standxAccountStale: boolean;
|
||||||
|
accountInvalid: boolean;
|
||||||
|
standxRestUnhealthy: boolean;
|
||||||
|
standxRestConsecutiveErrors: number;
|
||||||
|
standxRestLastError: string | null;
|
||||||
|
marginModeNotIsolated: boolean;
|
||||||
|
marginMode: string | null;
|
||||||
standxDepthAge: number;
|
standxDepthAge: number;
|
||||||
binanceAge: number;
|
binanceAge: number;
|
||||||
|
standxAccountAge: number;
|
||||||
|
accountIssues: string[];
|
||||||
}): void {
|
}): void {
|
||||||
this.defenseMode = true;
|
this.defenseMode = true;
|
||||||
|
|
||||||
@@ -1575,6 +1729,18 @@ export class MakerPointsEngine {
|
|||||||
if (staleInfo.standxDepthStale) {
|
if (staleInfo.standxDepthStale) {
|
||||||
staleItems.push(`StandX深度(${Math.round(staleInfo.standxDepthAge / 1000)}s)`);
|
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.marginModeNotIsolated) {
|
||||||
|
staleItems.push(`保证金模式(${staleInfo.marginMode ?? "unknown"})`);
|
||||||
|
}
|
||||||
if (staleInfo.binanceStale) {
|
if (staleInfo.binanceStale) {
|
||||||
staleItems.push(`Binance深度(${Math.round(staleInfo.binanceAge / 1000)}s)`);
|
staleItems.push(`Binance深度(${Math.round(staleInfo.binanceAge / 1000)}s)`);
|
||||||
}
|
}
|
||||||
@@ -1675,13 +1841,50 @@ export class MakerPointsEngine {
|
|||||||
if (!this.defenseRestPollActive || !this.defenseMode) return;
|
if (!this.defenseRestPollActive || !this.defenseMode) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果有查询挂单的方法,定期检查并取消
|
if (this.exchange.queryAccountSnapshot) {
|
||||||
|
const nextAccount = await this.exchange.queryAccountSnapshot();
|
||||||
|
if (nextAccount) {
|
||||||
|
this.applyAccountSnapshot(nextAccount);
|
||||||
|
const health = validateAccountSnapshotForSymbol(nextAccount, this.config.symbol);
|
||||||
|
if (!health.ok) {
|
||||||
|
this.tradeLog.push("warn", `防御模式: 仓位数据仍异常: ${health.issues.join(",")}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.tradeLog.push("warn", "防御模式: REST 获取账户快照为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防御模式下也尝试修复保证金模式(StandX)
|
||||||
|
if (this.exchange.id === "standx") {
|
||||||
|
await this.ensureStandxIsolatedMarginMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防御模式下持续通过 REST 刷新挂单,并尽力撤销所有挂单(避免本地状态/WS 丢失导致遗留挂单)
|
||||||
if (this.exchange.queryOpenOrders) {
|
if (this.exchange.queryOpenOrders) {
|
||||||
const realOrders = await this.exchange.queryOpenOrders();
|
try {
|
||||||
if (realOrders.length > 0) {
|
const realOrders = await this.exchange.queryOpenOrders();
|
||||||
this.tradeLog.push("warn", `防御模式: 发现 ${realOrders.length} 个挂单,执行取消`);
|
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();
|
await this.defenseCancelAllOrders();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await this.defenseCancelAllOrders();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查止损条件(使用当前账户快照中的数据)
|
// 检查止损条件(使用当前账户快照中的数据)
|
||||||
@@ -1699,6 +1902,55 @@ export class MakerPointsEngine {
|
|||||||
void poll();
|
void poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getStandxMarginMode(snapshot: AsterAccountSnapshot | null): string | null {
|
||||||
|
if (this.exchange.id !== "standx") return null;
|
||||||
|
const positions = snapshot?.positions ?? [];
|
||||||
|
const match = positions.find((pos) => pos.symbol === this.config.symbol);
|
||||||
|
const raw = (match as any)?.marginType ?? (match as any)?.margin_mode;
|
||||||
|
const mode = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||||
|
return mode ? mode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureStandxIsolatedMarginMode(): Promise<boolean> {
|
||||||
|
if (this.exchange.id !== "standx") return true;
|
||||||
|
const currentMode = this.getStandxMarginMode(this.accountSnapshot);
|
||||||
|
if (currentMode === "isolated") return true;
|
||||||
|
const change = this.exchange.changeMarginMode?.bind(this.exchange);
|
||||||
|
const queryAccount = this.exchange.queryAccountSnapshot?.bind(this.exchange);
|
||||||
|
if (!change || !queryAccount) return false;
|
||||||
|
|
||||||
|
if (this.marginModeEnsuring) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.marginModeEnsuring = (async () => {
|
||||||
|
try {
|
||||||
|
await change({ symbol: this.config.symbol, marginMode: "isolated" });
|
||||||
|
for (let attempt = 0; attempt < STANDX_MARGIN_MODE_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const next = await queryAccount();
|
||||||
|
if (next) {
|
||||||
|
this.applyAccountSnapshot(next);
|
||||||
|
}
|
||||||
|
const mode = this.getStandxMarginMode(this.accountSnapshot);
|
||||||
|
if (mode === "isolated") {
|
||||||
|
this.tradeLog.push("info", "已切换为逐仓模式 (isolated),恢复策略运行");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await this.sleep(STANDX_MARGIN_MODE_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
this.tradeLog.push("warn", `逐仓模式切换未确认,当前模式: ${this.getStandxMarginMode(this.accountSnapshot) ?? "unknown"}`);
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
this.tradeLog.push("error", `切换逐仓模式失败: ${extractMessage(error)}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.marginModeEnsuring = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return await this.marginModeEnsuring;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止防御模式下的 REST 轮询
|
* 停止防御模式下的 REST 轮询
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,4 +13,24 @@ export function computePositionPnl(
|
|||||||
: (position.entryPrice - (priceForPnl as number)) * absAmt;
|
: (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export function getSMA(values: AsterKline[], length: number): number | null {
|
||||||
if (!Array.isArray(values) || values.length < length) return null;
|
if (!Array.isArray(values) || values.length < length) return null;
|
||||||
const window = values.slice(-length);
|
const window = values.slice(-length);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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";
|
||||||
|
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> {}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return this.accountSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MakerPointsEngine defense-mode account staleness", () => {
|
||||||
|
it("does not enter defense mode for ~21s StandX account gap (REST probe succeeds)", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
|
||||||
|
const adapter = new StubAdapter();
|
||||||
|
adapter.accountSnapshot = {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: "0",
|
||||||
|
positions: [],
|
||||||
|
assets: [],
|
||||||
|
marketType: "perp",
|
||||||
|
};
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
(engine as any).lastStandxDepthTime = now;
|
||||||
|
(engine as any).lastBinanceDepthTime = now;
|
||||||
|
(engine as any).lastStandxAccountTime = now - 21_000;
|
||||||
|
|
||||||
|
(engine as any).checkDataStaleAndDefense();
|
||||||
|
expect((engine as any).defenseMode).toBe(false);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters defense mode if StandX account REST probe fails", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
|
||||||
|
const adapter = new StubAdapter();
|
||||||
|
adapter.accountSnapshot = null;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
(engine as any).lastStandxDepthTime = now;
|
||||||
|
(engine as any).lastBinanceDepthTime = now;
|
||||||
|
(engine as any).lastStandxAccountTime = now - 121_000;
|
||||||
|
|
||||||
|
(engine as any).checkDataStaleAndDefense();
|
||||||
|
expect((engine as any).defenseMode).toBe(false);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
(engine as any).checkDataStaleAndDefense();
|
||||||
|
expect((engine as any).defenseMode).toBe(true);
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: "isolated",
|
||||||
|
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,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: "isolated",
|
||||||
|
standxDepthAge: 6000,
|
||||||
|
binanceAge: 0,
|
||||||
|
standxAccountAge: 0,
|
||||||
|
accountIssues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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 StandxStubAdapter implements ExchangeAdapter {
|
||||||
|
id = "standx";
|
||||||
|
marginMode: "cross" | "isolated" = "cross";
|
||||||
|
changeCalls: Array<{ symbol: string; marginMode: "isolated" | "cross" }> = [];
|
||||||
|
|
||||||
|
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> {}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: "0",
|
||||||
|
marketType: "perp",
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "0",
|
||||||
|
entryPrice: "0",
|
||||||
|
unrealizedProfit: "0",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
marginType: this.marginMode,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
|
||||||
|
this.changeCalls.push(params);
|
||||||
|
this.marginMode = params.marginMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MakerPointsEngine StandX isolated margin guard", () => {
|
||||||
|
it("switches to isolated before placing orders", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const adapter = new StandxStubAdapter();
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed engine state to pass readiness checks without WS.
|
||||||
|
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
||||||
|
(engine as any).initialOrderSnapshotReady = true;
|
||||||
|
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
|
||||||
|
(engine as any).depthSnapshot = {
|
||||||
|
lastUpdateId: 1,
|
||||||
|
bids: [["100", "1"]],
|
||||||
|
asks: [["101", "1"]],
|
||||||
|
eventTime: Date.now(),
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
} as AsterDepth;
|
||||||
|
(engine as any).tickerSnapshot = {
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
lastPrice: "100",
|
||||||
|
openPrice: "0",
|
||||||
|
highPrice: "0",
|
||||||
|
lowPrice: "0",
|
||||||
|
volume: "0",
|
||||||
|
quoteVolume: "0",
|
||||||
|
eventTime: Date.now(),
|
||||||
|
} as AsterTicker;
|
||||||
|
|
||||||
|
const syncSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(engine as any).syncOrders = syncSpy;
|
||||||
|
|
||||||
|
// First tick should force margin mode to isolated and then proceed to sync orders.
|
||||||
|
await (engine as any).tick();
|
||||||
|
|
||||||
|
expect(adapter.changeCalls).toEqual([{ symbol: "BTC-USD", marginMode: "isolated" }]);
|
||||||
|
expect(syncSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters defense mode if it cannot switch to isolated", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const adapter = new StandxStubAdapter();
|
||||||
|
adapter.changeMarginMode = vi.fn(async () => {
|
||||||
|
throw new Error("change failed");
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
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).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
||||||
|
(engine as any).initialOrderSnapshotReady = true;
|
||||||
|
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
|
||||||
|
(engine as any).depthSnapshot = {
|
||||||
|
lastUpdateId: 1,
|
||||||
|
bids: [["100", "1"]],
|
||||||
|
asks: [["101", "1"]],
|
||||||
|
eventTime: Date.now(),
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
} as AsterDepth;
|
||||||
|
(engine as any).tickerSnapshot = {
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
lastPrice: "100",
|
||||||
|
openPrice: "0",
|
||||||
|
highPrice: "0",
|
||||||
|
lowPrice: "0",
|
||||||
|
volume: "0",
|
||||||
|
quoteVolume: "0",
|
||||||
|
eventTime: Date.now(),
|
||||||
|
} as AsterTicker;
|
||||||
|
|
||||||
|
const syncSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(engine as any).syncOrders = syncSpy;
|
||||||
|
|
||||||
|
await (engine as any).tick();
|
||||||
|
|
||||||
|
expect(syncSpy).not.toHaveBeenCalled();
|
||||||
|
expect((engine as any).defenseMode).toBe(true);
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user