add presist

This commit is contained in:
discountry
2025-09-23 02:01:55 +08:00
parent 667ede7ca8
commit 1386455870
9 changed files with 319 additions and 80 deletions
+1
View File
@@ -1,5 +1,6 @@
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
data/*
# output # output
out out
+54 -5
View File
@@ -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,9 +306,11 @@ 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 {
const position = getPosition(this.accountSnapshot, this.config.symbol); const position = getPosition(this.accountSnapshot, this.config.symbol);
@@ -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,
});
}
} }
+59 -5
View File
@@ -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,9 +398,11 @@ 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 {
const position = getPosition(this.accountSnapshot, this.config.symbol); const position = getPosition(this.accountSnapshot, this.config.symbol);
@@ -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,
});
}
} }
+105 -61
View File
@@ -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;
if (!response.ok) { try {
response = await fetch(url);
} catch (error) {
throw new Error(`[AsterRestClient] 获取K线失败 ${String(error)}`);
}
const text = await response.text(); const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${text}`); throw new Error(`HTTP ${response.status} ${text}`);
} }
const payload = (await response.json()) as any[]; try {
const payload = JSON.parse(text) as any[];
return payload.map((entry) => fromRestKline(entry, interval, upper)); 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;
if (!response.ok) { try {
response = await fetch(url, init);
} catch (error) {
throw new Error(`[AsterRestClient] 请求失败 ${String(error)}`);
}
const text = await response.text(); const text = await response.text();
if (!response.ok) {
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;
} }
+47
View File
@@ -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;
} }
+6 -3
View File
@@ -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 };
} }
+6 -2
View File
@@ -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>
+37
View File
@@ -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 -1
View File
@@ -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;