mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
add presist
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
data/*
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ import type {
|
|||||||
} from "../exchanges/types";
|
} from "../exchanges/types";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { toPrice1Decimal } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||||
|
import { loadState, saveState } from "../utils/persistence";
|
||||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
OrderLockMap,
|
|
||||||
OrderPendingMap,
|
|
||||||
OrderTimerMap,
|
|
||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "./order-coordinator";
|
} from "./order-coordinator";
|
||||||
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -57,6 +56,10 @@ export class MakerEngine {
|
|||||||
|
|
||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
||||||
|
private readonly stateFile: string;
|
||||||
|
private savedStateApplied = false;
|
||||||
|
private lastPersistedAt = 0;
|
||||||
|
private savedOpenOrders: AsterOrder[] = [];
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processing = false;
|
private processing = false;
|
||||||
@@ -65,7 +68,9 @@ export class MakerEngine {
|
|||||||
|
|
||||||
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
|
this.stateFile = `maker-${this.config.symbol}.json`;
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
|
void this.restoreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -113,7 +118,10 @@ export class MakerEngine {
|
|||||||
|
|
||||||
this.exchange.watchOrders((orders) => {
|
this.exchange.watchOrders((orders) => {
|
||||||
this.syncLocksWithOrders(orders);
|
this.syncLocksWithOrders(orders);
|
||||||
this.openOrders = Array.isArray(orders) ? orders.filter((order) => order.type !== "MARKET") : [];
|
this.openOrders = Array.isArray(orders)
|
||||||
|
? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol)
|
||||||
|
: [];
|
||||||
|
this.reconcileSavedOpenOrders();
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,6 +238,7 @@ export class MakerEngine {
|
|||||||
|
|
||||||
for (const index of unmatched) {
|
for (const index of unmatched) {
|
||||||
const target = targets[index];
|
const target = targets[index];
|
||||||
|
if (!target) continue;
|
||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
try {
|
try {
|
||||||
await placeOrder(
|
await placeOrder(
|
||||||
@@ -297,8 +306,10 @@ export class MakerEngine {
|
|||||||
private emitUpdate(): void {
|
private emitUpdate(): void {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
const handlers = this.listeners.get("update");
|
const handlers = this.listeners.get("update");
|
||||||
if (!handlers) return;
|
if (handlers) {
|
||||||
handlers.forEach((handler) => handler(snapshot));
|
handlers.forEach((handler) => handler(snapshot));
|
||||||
|
}
|
||||||
|
void this.persistSnapshot(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSnapshot(): MakerEngineSnapshot {
|
private buildSnapshot(): MakerEngineSnapshot {
|
||||||
@@ -330,4 +341,42 @@ export class MakerEngine {
|
|||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async restoreState(): Promise<void> {
|
||||||
|
if (this.savedStateApplied) return;
|
||||||
|
this.savedStateApplied = true;
|
||||||
|
const state = await loadState<{
|
||||||
|
tradeLog?: TradeLogEntry[];
|
||||||
|
accountUnrealized?: number;
|
||||||
|
openOrders?: AsterOrder[];
|
||||||
|
}>(this.stateFile);
|
||||||
|
if (!state) return;
|
||||||
|
if (Array.isArray(state.tradeLog)) this.tradeLog.replace(state.tradeLog);
|
||||||
|
if (typeof state.accountUnrealized === "number") this.accountUnrealized = state.accountUnrealized;
|
||||||
|
if (Array.isArray(state.openOrders)) this.savedOpenOrders = state.openOrders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconcileSavedOpenOrders(): void {
|
||||||
|
if (!this.savedOpenOrders.length) return;
|
||||||
|
const currentIds = new Set(this.openOrders.map((order) => order.orderId));
|
||||||
|
const missing = this.savedOpenOrders.filter((order) => !currentIds.has(order.orderId));
|
||||||
|
if (missing.length) {
|
||||||
|
this.tradeLog.push("order", `检测到 ${missing.length} 个历史挂单与当前状态不一致,将重新同步`);
|
||||||
|
}
|
||||||
|
this.savedOpenOrders = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistSnapshot(snapshot?: MakerEngineSnapshot): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastPersistedAt < 1000) return;
|
||||||
|
this.lastPersistedAt = now;
|
||||||
|
const current = snapshot ?? this.buildSnapshot();
|
||||||
|
await saveState(this.stateFile, {
|
||||||
|
tradeLog: current.tradeLog,
|
||||||
|
accountUnrealized: current.accountUnrealized,
|
||||||
|
openOrders: current.openOrders.filter((order) => order.symbol === this.config.symbol),
|
||||||
|
position: current.position,
|
||||||
|
timestamp: current.lastUpdated,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,15 @@ import {
|
|||||||
} from "../utils/strategy";
|
} from "../utils/strategy";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
OrderLockMap,
|
|
||||||
OrderPendingMap,
|
|
||||||
OrderTimerMap,
|
|
||||||
placeMarketOrder,
|
placeMarketOrder,
|
||||||
placeStopLossOrder,
|
placeStopLossOrder,
|
||||||
placeTrailingStopOrder,
|
placeTrailingStopOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "./order-coordinator";
|
} from "./order-coordinator";
|
||||||
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { toPrice1Decimal } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||||
|
import { loadState, saveState } from "../utils/persistence";
|
||||||
|
|
||||||
export interface TrendEngineSnapshot {
|
export interface TrendEngineSnapshot {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -75,12 +74,18 @@ export class TrendEngine {
|
|||||||
private totalProfit = 0;
|
private totalProfit = 0;
|
||||||
private totalTrades = 0;
|
private totalTrades = 0;
|
||||||
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
||||||
|
private savedStateApplied = false;
|
||||||
|
private readonly stateFile: string;
|
||||||
|
private lastPersistedAt = 0;
|
||||||
|
private savedOpenOrders: AsterOrder[] = [];
|
||||||
|
|
||||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
|
this.stateFile = `trend-${this.config.symbol}.json`;
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
|
void this.restoreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -124,8 +129,9 @@ export class TrendEngine {
|
|||||||
this.exchange.watchOrders((orders) => {
|
this.exchange.watchOrders((orders) => {
|
||||||
this.synchronizeLocks(orders);
|
this.synchronizeLocks(orders);
|
||||||
this.openOrders = Array.isArray(orders)
|
this.openOrders = Array.isArray(orders)
|
||||||
? orders.filter((order) => order.type !== "MARKET")
|
? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol)
|
||||||
: [];
|
: [];
|
||||||
|
this.reconcileSavedOpenOrders();
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||||
@@ -392,8 +398,10 @@ export class TrendEngine {
|
|||||||
private emitUpdate(): void {
|
private emitUpdate(): void {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
const handlers = this.listeners.get("update");
|
const handlers = this.listeners.get("update");
|
||||||
if (!handlers) return;
|
if (handlers) {
|
||||||
handlers.forEach((handler) => handler(snapshot));
|
handlers.forEach((handler) => handler(snapshot));
|
||||||
|
}
|
||||||
|
void this.persistSnapshot(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSnapshot(): TrendEngineSnapshot {
|
private buildSnapshot(): TrendEngineSnapshot {
|
||||||
@@ -431,4 +439,50 @@ export class TrendEngine {
|
|||||||
lastOpenSignal: this.lastOpenPlan,
|
lastOpenSignal: this.lastOpenPlan,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async restoreState(): Promise<void> {
|
||||||
|
if (this.savedStateApplied) return;
|
||||||
|
this.savedStateApplied = true;
|
||||||
|
const state = await loadState<{
|
||||||
|
totalProfit?: number;
|
||||||
|
totalTrades?: number;
|
||||||
|
lastOpenPlan?: OpenOrderPlan;
|
||||||
|
tradeLog?: TradeLogEntry[];
|
||||||
|
openOrders?: AsterOrder[];
|
||||||
|
}>(this.stateFile);
|
||||||
|
if (!state) return;
|
||||||
|
if (typeof state.totalProfit === "number") this.totalProfit = state.totalProfit;
|
||||||
|
if (typeof state.totalTrades === "number") this.totalTrades = state.totalTrades;
|
||||||
|
if (state.lastOpenPlan) this.lastOpenPlan = state.lastOpenPlan;
|
||||||
|
if (Array.isArray(state.tradeLog)) this.tradeLog.replace(state.tradeLog);
|
||||||
|
if (Array.isArray(state.openOrders)) this.savedOpenOrders = state.openOrders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconcileSavedOpenOrders(): void {
|
||||||
|
if (!this.savedOpenOrders.length) return;
|
||||||
|
const currentIds = new Set(this.openOrders.map((order) => order.orderId));
|
||||||
|
const missing = this.savedOpenOrders.filter((order) => !currentIds.has(order.orderId));
|
||||||
|
if (missing.length) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"order",
|
||||||
|
`检测到 ${missing.length} 个历史挂单与当前状态不符,将按策略逻辑重新挂单`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.savedOpenOrders = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistSnapshot(snapshot: TrendEngineSnapshot): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastPersistedAt < 1000) return;
|
||||||
|
this.lastPersistedAt = now;
|
||||||
|
await saveState(this.stateFile, {
|
||||||
|
totalProfit: this.totalProfit,
|
||||||
|
totalTrades: this.totalTrades,
|
||||||
|
lastOpenPlan: this.lastOpenPlan,
|
||||||
|
tradeLog: snapshot.tradeLog,
|
||||||
|
openOrders: snapshot.openOrders.filter((order) => order.symbol === this.config.symbol),
|
||||||
|
position: snapshot.position,
|
||||||
|
timestamp: snapshot.lastUpdated,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-62
@@ -7,6 +7,7 @@ import type {
|
|||||||
AsterOrder,
|
AsterOrder,
|
||||||
AsterTicker,
|
AsterTicker,
|
||||||
CreateOrderParams,
|
CreateOrderParams,
|
||||||
|
PositionSide,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const REST_BASE = "https://fapi.asterdex.com";
|
const REST_BASE = "https://fapi.asterdex.com";
|
||||||
@@ -93,18 +94,18 @@ function fromRestKline(entry: any[], interval: string, symbol: string): AsterKli
|
|||||||
symbol,
|
symbol,
|
||||||
interval,
|
interval,
|
||||||
openTime: entry[0],
|
openTime: entry[0],
|
||||||
open: entry[1],
|
open: String(entry[1]),
|
||||||
high: entry[2],
|
high: String(entry[2]),
|
||||||
low: entry[3],
|
low: String(entry[3]),
|
||||||
close: entry[4],
|
close: String(entry[4]),
|
||||||
volume: entry[5],
|
volume: String(entry[5]),
|
||||||
closeTime: entry[6],
|
closeTime: entry[6],
|
||||||
quoteAssetVolume: entry[7],
|
quoteAssetVolume: String(entry[7]),
|
||||||
numberOfTrades: entry[8],
|
numberOfTrades: Number(entry[8] ?? 0),
|
||||||
takerBuyBaseAssetVolume: entry[9],
|
takerBuyBaseAssetVolume: String(entry[9] ?? "0"),
|
||||||
takerBuyQuoteAssetVolume: entry[10],
|
takerBuyQuoteAssetVolume: String(entry[10] ?? "0"),
|
||||||
isClosed: Boolean(entry[11]),
|
isClosed: Boolean(entry[11]),
|
||||||
} as AsterKline;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOrderFromRest(raw: any): AsterOrder {
|
function toOrderFromRest(raw: any): AsterOrder {
|
||||||
@@ -243,13 +244,22 @@ export class AsterRestClient {
|
|||||||
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
|
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
|
||||||
const upper = symbol.toUpperCase();
|
const upper = symbol.toUpperCase();
|
||||||
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
|
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
|
||||||
const response = await fetch(url);
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`[AsterRestClient] 获取K线失败 ${String(error)}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status} ${text}`);
|
throw new Error(`HTTP ${response.status} ${text}`);
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as any[];
|
try {
|
||||||
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
const payload = JSON.parse(text) as any[];
|
||||||
|
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`[AsterRestClient] 无法解析K线响应: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getListenKey(): Promise<string> {
|
async getListenKey(): Promise<string> {
|
||||||
@@ -278,16 +288,26 @@ export class AsterRestClient {
|
|||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const response = await fetch(url, init);
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, init);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`[AsterRestClient] 请求失败 ${String(error)}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status} ${text}`);
|
throw new Error(`HTTP ${response.status} ${text}`);
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`[AsterRestClient] 无法解析响应: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private serialize(params: Record<string, unknown>): string {
|
private serialize(params: Record<string, unknown>): string {
|
||||||
return Object.keys(params)
|
return Object.keys(params)
|
||||||
|
.filter((key) => params[key] !== undefined && params[key] !== null)
|
||||||
.sort()
|
.sort()
|
||||||
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
|
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
|
||||||
.join("&");
|
.join("&");
|
||||||
@@ -374,7 +394,17 @@ export class AsterPublicStreams {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
const payload = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
let payload: any;
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AsterPublicStreams] 无法解析消息", error, event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload = event.data;
|
||||||
|
}
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (payload.result !== undefined) return; // subscription ack
|
if (payload.result !== undefined) return; // subscription ack
|
||||||
const data = payload.data ?? payload;
|
const data = payload.data ?? payload;
|
||||||
@@ -456,6 +486,7 @@ export class AsterUserStream {
|
|||||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly accountEvent = new SimpleEvent<{ eventTime: number; payload: AccountUpdatePayload }>();
|
private readonly accountEvent = new SimpleEvent<{ eventTime: number; payload: AccountUpdatePayload }>();
|
||||||
private readonly orderEvent = new SimpleEvent<{ eventTime: number; payload: OrderUpdatePayload }>();
|
private readonly orderEvent = new SimpleEvent<{ eventTime: number; payload: OrderUpdatePayload }>();
|
||||||
|
private readonly connectEvent = new SimpleEvent<void>();
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
constructor(rest: AsterRestClient) {
|
constructor(rest: AsterRestClient) {
|
||||||
@@ -470,6 +501,10 @@ export class AsterUserStream {
|
|||||||
this.orderEvent.add(listener);
|
this.orderEvent.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnect(listener: () => void): void {
|
||||||
|
this.connectEvent.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (this.isRunning) return;
|
if (this.isRunning) return;
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
@@ -517,10 +552,20 @@ export class AsterUserStream {
|
|||||||
const url = `${WS_LISTEN_KEY_URL}${this.listenKey}`;
|
const url = `${WS_LISTEN_KEY_URL}${this.listenKey}`;
|
||||||
this.ws = new WebSocket(url);
|
this.ws = new WebSocket(url);
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
// no-op
|
this.connectEvent.emit();
|
||||||
};
|
};
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
const payload = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
let payload: any;
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AsterUserStream] 无法解析消息", error, event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload = event.data;
|
||||||
|
}
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (payload === "ping") {
|
if (payload === "ping") {
|
||||||
this.ws?.send("pong");
|
this.ws?.send("pong");
|
||||||
@@ -572,30 +617,20 @@ function updateAccountSnapshot(snapshot: AsterAccountSnapshot | null, event: { e
|
|||||||
const balances = event.payload.B ?? [];
|
const balances = event.payload.B ?? [];
|
||||||
for (const balance of balances) {
|
for (const balance of balances) {
|
||||||
const asset = balance.a;
|
const asset = balance.a;
|
||||||
let existing = next.assets.find((item) => item.asset === asset);
|
let assetEntry = next.assets.find((item) => item.asset === asset);
|
||||||
if (!existing) {
|
if (!assetEntry) {
|
||||||
existing = {
|
assetEntry = {
|
||||||
asset,
|
asset,
|
||||||
walletBalance: "0",
|
walletBalance: "0",
|
||||||
unrealizedProfit: "0",
|
|
||||||
marginBalance: "0",
|
|
||||||
maintMargin: "0",
|
|
||||||
initialMargin: "0",
|
|
||||||
positionInitialMargin: "0",
|
|
||||||
openOrderInitialMargin: "0",
|
|
||||||
crossWalletBalance: "0",
|
|
||||||
crossUnPnl: "0",
|
|
||||||
availableBalance: "0",
|
availableBalance: "0",
|
||||||
maxWithdrawAmount: "0",
|
|
||||||
marginAvailable: true,
|
|
||||||
updateTime: event.eventTime,
|
updateTime: event.eventTime,
|
||||||
} as any;
|
};
|
||||||
next.assets.push(existing);
|
next.assets.push(assetEntry);
|
||||||
}
|
}
|
||||||
if (balance.wb !== undefined) existing.walletBalance = balance.wb;
|
if (balance.wb !== undefined) assetEntry.walletBalance = balance.wb;
|
||||||
if (balance.cw !== undefined) existing.crossWalletBalance = balance.cw;
|
if (balance.cw !== undefined) assetEntry.crossWalletBalance = balance.cw;
|
||||||
if (balance.bc !== undefined) existing.availableBalance = balance.bc;
|
if (balance.bc !== undefined) assetEntry.availableBalance = balance.bc;
|
||||||
existing.updateTime = event.eventTime;
|
assetEntry.updateTime = event.eventTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const positions = event.payload.P ?? [];
|
const positions = event.payload.P ?? [];
|
||||||
@@ -604,29 +639,22 @@ function updateAccountSnapshot(snapshot: AsterAccountSnapshot | null, event: { e
|
|||||||
|
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const symbol = position.s;
|
const symbol = position.s;
|
||||||
let existing = next.positions.find((item) => item.symbol === symbol && item.positionSide === position.ps);
|
let positionEntry = next.positions.find((item) => item.symbol === symbol && item.positionSide === (position.ps as PositionSide));
|
||||||
if (!existing) {
|
if (!positionEntry) {
|
||||||
existing = {
|
positionEntry = {
|
||||||
symbol,
|
symbol,
|
||||||
positionAmt: "0",
|
positionAmt: "0",
|
||||||
entryPrice: "0",
|
entryPrice: "0",
|
||||||
unrealizedProfit: "0",
|
unrealizedProfit: "0",
|
||||||
positionSide: position.ps,
|
positionSide: position.ps as PositionSide,
|
||||||
updateTime: event.eventTime,
|
updateTime: event.eventTime,
|
||||||
initialMargin: "0",
|
};
|
||||||
maintMargin: "0",
|
next.positions.push(positionEntry);
|
||||||
positionInitialMargin: "0",
|
|
||||||
openOrderInitialMargin: "0",
|
|
||||||
leverage: "",
|
|
||||||
isolated: position.mt === "isolated",
|
|
||||||
maxNotional: "0",
|
|
||||||
} as any;
|
|
||||||
next.positions.push(existing);
|
|
||||||
}
|
}
|
||||||
existing.positionAmt = position.pa ?? existing.positionAmt;
|
positionEntry.positionAmt = position.pa ?? positionEntry.positionAmt;
|
||||||
existing.entryPrice = position.ep ?? existing.entryPrice;
|
positionEntry.entryPrice = position.ep ?? positionEntry.entryPrice;
|
||||||
existing.unrealizedProfit = position.up ?? existing.unrealizedProfit;
|
positionEntry.unrealizedProfit = position.up ?? positionEntry.unrealizedProfit;
|
||||||
existing.updateTime = event.eventTime;
|
positionEntry.updateTime = event.eventTime;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
@@ -675,20 +703,18 @@ export class AsterGateway {
|
|||||||
mergeOrderSnapshot(this.openOrders, order);
|
mergeOrderSnapshot(this.openOrders, order);
|
||||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||||
});
|
});
|
||||||
|
this.userStream.onConnect(() => {
|
||||||
|
void this.refreshSnapshots();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureInitialized(symbol: string): Promise<void> {
|
async ensureInitialized(symbol: string): Promise<void> {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
if (this.initializing) return this.initializing;
|
if (this.initializing) return this.initializing;
|
||||||
this.initializing = (async () => {
|
this.initializing = (async () => {
|
||||||
this.accountSnapshot = await this.rest.getAccount();
|
await this.refreshSnapshots();
|
||||||
const orders = await this.rest.getOpenOrders();
|
|
||||||
this.openOrders.clear();
|
|
||||||
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
await this.userStream.start();
|
await this.userStream.start();
|
||||||
this.accountEvent.emit(this.accountSnapshot!);
|
|
||||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
this.initializing = null;
|
this.initializing = null;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -811,6 +837,24 @@ export class AsterGateway {
|
|||||||
this.klineRefreshTimers.set(key, timer);
|
this.klineRefreshTimers.set(key, timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshSnapshots(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const account = await this.rest.getAccount();
|
||||||
|
this.accountSnapshot = account;
|
||||||
|
this.accountEvent.emit(account);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AsterGateway] 刷新账户信息失败", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const orders = await this.rest.getOpenOrders();
|
||||||
|
this.openOrders.clear();
|
||||||
|
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
||||||
|
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AsterGateway] 刷新挂单失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAccountSnapshot(): AsterAccountSnapshot | null {
|
getAccountSnapshot(): AsterAccountSnapshot | null {
|
||||||
return this.accountSnapshot;
|
return this.accountSnapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface AsterAccountPosition {
|
|||||||
unrealizedProfit: string;
|
unrealizedProfit: string;
|
||||||
positionSide: PositionSide;
|
positionSide: PositionSide;
|
||||||
updateTime: number;
|
updateTime: number;
|
||||||
|
initialMargin?: string;
|
||||||
|
maintMargin?: string;
|
||||||
|
positionInitialMargin?: string;
|
||||||
|
openOrderInitialMargin?: string;
|
||||||
|
leverage?: string;
|
||||||
|
isolated?: boolean;
|
||||||
|
maxNotional?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountAsset {
|
export interface AsterAccountAsset {
|
||||||
@@ -37,6 +44,16 @@ export interface AsterAccountAsset {
|
|||||||
walletBalance: string;
|
walletBalance: string;
|
||||||
availableBalance: string;
|
availableBalance: string;
|
||||||
updateTime: number;
|
updateTime: number;
|
||||||
|
unrealizedProfit?: string;
|
||||||
|
marginBalance?: string;
|
||||||
|
maintMargin?: string;
|
||||||
|
initialMargin?: string;
|
||||||
|
positionInitialMargin?: string;
|
||||||
|
openOrderInitialMargin?: string;
|
||||||
|
crossWalletBalance?: string;
|
||||||
|
crossUnPnl?: string;
|
||||||
|
maxWithdrawAmount?: string;
|
||||||
|
marginAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountSnapshot {
|
export interface AsterAccountSnapshot {
|
||||||
@@ -46,6 +63,15 @@ export interface AsterAccountSnapshot {
|
|||||||
updateTime: number;
|
updateTime: number;
|
||||||
totalWalletBalance: string;
|
totalWalletBalance: string;
|
||||||
totalUnrealizedProfit: string;
|
totalUnrealizedProfit: string;
|
||||||
|
totalMarginBalance?: string;
|
||||||
|
totalInitialMargin?: string;
|
||||||
|
totalMaintMargin?: string;
|
||||||
|
totalPositionInitialMargin?: string;
|
||||||
|
totalOpenOrderInitialMargin?: string;
|
||||||
|
totalCrossWalletBalance?: string;
|
||||||
|
totalCrossUnPnl?: string;
|
||||||
|
availableBalance?: string;
|
||||||
|
maxWithdrawAmount?: string;
|
||||||
positions: AsterAccountPosition[];
|
positions: AsterAccountPosition[];
|
||||||
assets: AsterAccountAsset[];
|
assets: AsterAccountAsset[];
|
||||||
}
|
}
|
||||||
@@ -60,6 +86,9 @@ export interface AsterDepth {
|
|||||||
bids: AsterDepthLevel[];
|
bids: AsterDepthLevel[];
|
||||||
asks: AsterDepthLevel[];
|
asks: AsterDepthLevel[];
|
||||||
eventTime?: number;
|
eventTime?: number;
|
||||||
|
eventType?: string;
|
||||||
|
tradeTime?: number;
|
||||||
|
symbol?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterTicker {
|
export interface AsterTicker {
|
||||||
@@ -71,6 +100,16 @@ export interface AsterTicker {
|
|||||||
volume: string;
|
volume: string;
|
||||||
quoteVolume: string;
|
quoteVolume: string;
|
||||||
eventTime?: number;
|
eventTime?: number;
|
||||||
|
eventType?: string;
|
||||||
|
priceChange?: string;
|
||||||
|
priceChangePercent?: string;
|
||||||
|
weightedAvgPrice?: string;
|
||||||
|
lastQty?: string;
|
||||||
|
openTime?: number;
|
||||||
|
closeTime?: number;
|
||||||
|
firstId?: number;
|
||||||
|
lastId?: number;
|
||||||
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterKline {
|
export interface AsterKline {
|
||||||
@@ -110,4 +149,12 @@ export interface AsterOrder {
|
|||||||
reduceOnly: boolean;
|
reduceOnly: boolean;
|
||||||
closePosition: boolean;
|
closePosition: boolean;
|
||||||
workingType?: string;
|
workingType?: string;
|
||||||
|
avgPrice?: string;
|
||||||
|
cumQuote?: string;
|
||||||
|
origType?: string;
|
||||||
|
positionSide?: PositionSide;
|
||||||
|
timeInForce?: TimeInForce;
|
||||||
|
activatePrice?: string;
|
||||||
|
priceRate?: string;
|
||||||
|
priceProtect?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export interface TradeLogEntry {
|
|||||||
detail: string;
|
detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTradeLog(maxEntries: number) {
|
export function createTradeLog(maxEntries: number, seed: TradeLogEntry[] = []) {
|
||||||
const entries: TradeLogEntry[] = [];
|
const entries: TradeLogEntry[] = seed.slice(-maxEntries);
|
||||||
function push(type: string, detail: string) {
|
function push(type: string, detail: string) {
|
||||||
entries.push({ time: new Date().toLocaleString(), type, detail });
|
entries.push({ time: new Date().toLocaleString(), type, detail });
|
||||||
if (entries.length > maxEntries) {
|
if (entries.length > maxEntries) {
|
||||||
@@ -15,5 +15,8 @@ export function createTradeLog(maxEntries: number) {
|
|||||||
function all() {
|
function all() {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
return { push, all };
|
function replace(next: TradeLogEntry[]) {
|
||||||
|
entries.splice(0, entries.length, ...next.slice(-maxEntries));
|
||||||
|
}
|
||||||
|
return { push, all, replace };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export function DataTable<Row extends Record<string, unknown>>({ columns, rows }
|
|||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text>
|
<Text>
|
||||||
{columns
|
{columns
|
||||||
.map((col, index) => pad(col.header, widths[index], col.align ?? "left"))
|
.map((col, index) => {
|
||||||
|
const width = widths[index] ?? col.header.length;
|
||||||
|
return pad(col.header, width, col.align ?? "left");
|
||||||
|
})
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
</Text>
|
</Text>
|
||||||
{rows.map((row, rowIndex) => (
|
{rows.map((row, rowIndex) => (
|
||||||
@@ -54,7 +57,8 @@ export function DataTable<Row extends Record<string, unknown>>({ columns, rows }
|
|||||||
.map((col, index) => {
|
.map((col, index) => {
|
||||||
const align = col.align ?? "left";
|
const align = col.align ?? "left";
|
||||||
const cell = formatCell(row[col.key]);
|
const cell = formatCell(row[col.key]);
|
||||||
return pad(cell, widths[index], align);
|
const width = widths[index] ?? Math.max(col.header.length, cell.length);
|
||||||
|
return pad(cell, width, align);
|
||||||
})
|
})
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const DATA_DIR = path.resolve(process.cwd(), "data");
|
||||||
|
let dataDirReady = false;
|
||||||
|
|
||||||
|
async function ensureDataDir(): Promise<void> {
|
||||||
|
if (dataDirReady) return;
|
||||||
|
await fs.mkdir(DATA_DIR, { recursive: true }).catch(() => undefined);
|
||||||
|
dataDirReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadState<T>(fileName: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
await ensureDataDir();
|
||||||
|
const filePath = path.join(DATA_DIR, fileName);
|
||||||
|
const data = await fs.readFile(filePath, "utf8");
|
||||||
|
return JSON.parse(data) as T;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === "ENOENT") return null;
|
||||||
|
console.error(`[state] load ${fileName} failed`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveState(fileName: string, state: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDataDir();
|
||||||
|
const filePath = path.join(DATA_DIR, fileName);
|
||||||
|
const tempPath = `${filePath}.tmp`;
|
||||||
|
const payload = JSON.stringify(state, null, 2);
|
||||||
|
await fs.writeFile(tempPath, payload, "utf8");
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[state] save ${fileName} failed`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AsterAccountSnapshot, AsterKline } from "../exchanges/types";
|
import type { AsterAccountSnapshot, AsterKline } from "../exchanges/types";
|
||||||
|
|
||||||
export interface PositionSnapshot {
|
export interface PositionSnapshot {
|
||||||
positionAmt: number;
|
positionAmt: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user