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
+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 },
]);
});
});