This commit is contained in:
discountry
2025-09-23 01:26:49 +08:00
commit 667ede7ca8
26 changed files with 7836 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
export interface TradingConfig {
symbol: string;
tradeAmount: number;
lossLimit: number;
trailingProfit: number;
trailingCallbackRate: number;
profitLockTriggerUsd: number;
profitLockOffsetUsd: number;
pollIntervalMs: number;
maxLogEntries: number;
klineInterval: string;
}
function parseNumber(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const next = Number(value);
return Number.isFinite(next) ? next : fallback;
}
export const tradingConfig: TradingConfig = {
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
lossLimit: parseNumber(process.env.LOSS_LIMIT, 0.03),
trailingProfit: parseNumber(process.env.TRAILING_PROFIT, 0.2),
trailingCallbackRate: parseNumber(process.env.TRAILING_CALLBACK_RATE, 0.2),
profitLockTriggerUsd: parseNumber(process.env.PROFIT_LOCK_TRIGGER_USD, 0.1),
profitLockOffsetUsd: parseNumber(process.env.PROFIT_LOCK_OFFSET_USD, 0.05),
pollIntervalMs: parseNumber(process.env.POLL_INTERVAL_MS, 500),
maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200),
klineInterval: process.env.KLINE_INTERVAL ?? "1m",
};
export interface MakerConfig {
symbol: string;
tradeAmount: number;
lossLimit: number;
priceChaseThreshold: number;
bidOffset: number;
askOffset: number;
refreshIntervalMs: number;
maxLogEntries: number;
}
export const makerConfig: MakerConfig = {
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
lossLimit: parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.5)),
priceChaseThreshold: parseNumber(process.env.MAKER_PRICE_CHASE, 0.5),
bidOffset: parseNumber(process.env.MAKER_BID_OFFSET, 0),
askOffset: parseNumber(process.env.MAKER_ASK_OFFSET, 0),
refreshIntervalMs: parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 1500),
maxLogEntries: parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200),
};
+333
View File
@@ -0,0 +1,333 @@
import type { MakerConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type {
AsterAccountSnapshot,
AsterDepth,
AsterOrder,
AsterTicker,
} from "../exchanges/types";
import { toPrice1Decimal } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
import { getPosition, type PositionSnapshot } from "../utils/strategy";
import {
marketClose,
OrderLockMap,
OrderPendingMap,
OrderTimerMap,
placeOrder,
unlockOperating,
} from "./order-coordinator";
interface DesiredOrder {
side: "BUY" | "SELL";
price: number;
amount: number;
reduceOnly: boolean;
}
export interface MakerEngineSnapshot {
ready: boolean;
symbol: string;
topBid: number | null;
topAsk: number | null;
spread: number | null;
position: PositionSnapshot;
pnl: number;
accountUnrealized: number;
openOrders: AsterOrder[];
desiredOrders: DesiredOrder[];
tradeLog: TradeLogEntry[];
lastUpdated: number | null;
}
type MakerEvent = "update";
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
const EPS = 1e-5;
export class MakerEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
private depthSnapshot: AsterDepth | null = null;
private tickerSnapshot: AsterTicker | null = null;
private openOrders: AsterOrder[] = [];
private readonly locks: OrderLockMap = {};
private readonly timers: OrderTimerMap = {};
private readonly pending: OrderPendingMap = {};
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
private timer: ReturnType<typeof setInterval> | null = null;
private processing = false;
private desiredOrders: DesiredOrder[] = [];
private accountUnrealized = 0;
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.config.refreshIntervalMs);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
on(event: MakerEvent, handler: MakerListener): void {
const handlers = this.listeners.get(event) ?? new Set<MakerListener>();
handlers.add(handler);
this.listeners.set(event, handlers);
}
off(event: MakerEvent, handler: MakerListener): void {
const handlers = this.listeners.get(event);
if (!handlers) return;
handlers.delete(handler);
if (handlers.size === 0) {
this.listeners.delete(event);
}
}
getSnapshot(): MakerEngineSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
this.exchange.watchAccount((snapshot) => {
this.accountSnapshot = snapshot;
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
if (Number.isFinite(totalUnrealized)) {
this.accountUnrealized = totalUnrealized;
}
this.emitUpdate();
});
this.exchange.watchOrders((orders) => {
this.syncLocksWithOrders(orders);
this.openOrders = Array.isArray(orders) ? orders.filter((order) => order.type !== "MARKET") : [];
this.emitUpdate();
});
this.exchange.watchDepth(this.config.symbol, (depth) => {
this.depthSnapshot = depth;
this.emitUpdate();
});
this.exchange.watchTicker(this.config.symbol, (ticker) => {
this.tickerSnapshot = ticker;
this.emitUpdate();
});
// Maker strategy does not consume klines, but subscribe to keep parity with other modules
this.exchange.watchKlines(this.config.symbol, "1m", () => {
/* no-op */
});
}
private syncLocksWithOrders(orders: AsterOrder[]): void {
Object.keys(this.pending).forEach((type) => {
const pendingId = this.pending[type];
if (!pendingId) return;
const match = orders.find((order) => String(order.orderId) === pendingId);
if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) {
unlockOperating(this.locks, this.timers, this.pending, type);
}
});
}
private isReady(): boolean {
return Boolean(this.accountSnapshot && this.depthSnapshot);
}
private async tick(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
if (!this.isReady()) {
this.emitUpdate();
return;
}
const depth = this.depthSnapshot!;
const bidLevel = depth.bids?.[0];
const askLevel = depth.asks?.[0];
const topBid = bidLevel ? Number(bidLevel[0]) : undefined;
const topAsk = askLevel ? Number(askLevel[0]) : undefined;
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
this.emitUpdate();
return;
}
const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset);
const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset);
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
const desired: DesiredOrder[] = [];
if (absPosition < EPS) {
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
} else {
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
const closePrice = closeSide === "SELL" ? askPrice : bidPrice;
desired.push({ side: closeSide, price: closePrice, amount: absPosition, reduceOnly: true });
}
this.desiredOrders = desired;
await this.syncOrders(desired);
await this.checkRisk(position, bidPrice, askPrice);
this.emitUpdate();
} catch (error) {
this.tradeLog.push("error", `做市循环异常: ${String(error)}`);
this.emitUpdate();
} finally {
this.processing = false;
}
}
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
const tolerance = this.config.priceChaseThreshold;
const unmatched = new Set(targets.map((_, idx) => idx));
const toCancel: AsterOrder[] = [];
for (const order of this.openOrders) {
const price = Number(order.price);
if (!Number.isFinite(price)) {
toCancel.push(order);
continue;
}
const reduceOnly = order.reduceOnly === true;
const matchedIndex = targets.findIndex((target, index) => {
if (!unmatched.has(index)) return false;
if (target.side !== order.side) return false;
if (target.reduceOnly !== reduceOnly) return false;
return Math.abs(price - target.price) <= tolerance;
});
if (matchedIndex >= 0) {
unmatched.delete(matchedIndex);
continue;
}
toCancel.push(order);
}
for (const order of toCancel) {
try {
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`);
} catch (error) {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
}
}
for (const index of unmatched) {
const target = targets[index];
if (target.amount < EPS) continue;
try {
await placeOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
target.price,
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly
);
} catch (error) {
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
}
}
}
private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise<void> {
const absPosition = Math.abs(position.positionAmt);
if (absPosition < EPS) return;
const pnl = position.positionAmt > 0
? (bidPrice - position.entryPrice) * absPosition
: (position.entryPrice - askPrice) * absPosition;
if (pnl < -this.config.lossLimit || position.unrealizedProfit < -this.config.lossLimit) {
this.tradeLog.push(
"stop",
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
);
try {
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail)
);
} catch (error) {
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
}
}
}
private async flushOrders(): Promise<void> {
if (!this.openOrders.length) return;
for (const order of this.openOrders) {
try {
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
} catch (error) {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
}
}
}
private emitUpdate(): void {
const snapshot = this.buildSnapshot();
const handlers = this.listeners.get("update");
if (!handlers) return;
handlers.forEach((handler) => handler(snapshot));
}
private buildSnapshot(): MakerEngineSnapshot {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const bid = this.depthSnapshot?.bids?.[0]?.[0];
const ask = this.depthSnapshot?.asks?.[0]?.[0];
const bidNum = Number(bid);
const askNum = Number(ask);
const spread = Number.isFinite(bidNum) && Number.isFinite(askNum) ? askNum - bidNum : null;
const priceForPnl = position.positionAmt > 0 ? bidNum : askNum;
const pnl = Number.isFinite(priceForPnl)
? (position.positionAmt > 0
? (priceForPnl! - position.entryPrice) * Math.abs(position.positionAmt)
: (position.entryPrice - priceForPnl!) * Math.abs(position.positionAmt))
: 0;
return {
ready: this.isReady(),
symbol: this.config.symbol,
topBid: Number.isFinite(bidNum) ? bidNum : null,
topAsk: Number.isFinite(askNum) ? askNum : null,
spread,
position,
pnl,
accountUnrealized: this.accountUnrealized,
openOrders: this.openOrders,
desiredOrders: this.desiredOrders,
tradeLog: this.tradeLog.all(),
lastUpdated: Date.now(),
};
}
}
+268
View File
@@ -0,0 +1,268 @@
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
export type OrderLockMap = Record<string, boolean>;
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
export type OrderPendingMap = Record<string, string | null>;
export type LogHandler = (type: string, detail: string) => void;
export function isOperating(locks: OrderLockMap, type: string): boolean {
return Boolean(locks[type]);
}
export function lockOperating(
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
type: string,
log: LogHandler,
timeout = 3000
): void {
locks[type] = true;
if (timers[type]) {
clearTimeout(timers[type]!);
}
timers[type] = setTimeout(() => {
locks[type] = false;
pendings[type] = null;
log("error", `${type} 操作超时自动解锁`);
}, timeout);
}
export function unlockOperating(
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
type: string
): void {
locks[type] = false;
pendings[type] = null;
if (timers[type]) {
clearTimeout(timers[type]!);
}
timers[type] = null;
}
export async function deduplicateOrders(
adapter: ExchangeAdapter,
symbol: string,
openOrders: AsterOrder[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
type: string,
side: string,
log: LogHandler
): Promise<void> {
const sameTypeOrders = openOrders.filter((o) => o.type === type && o.side === side);
if (sameTypeOrders.length <= 1) return;
sameTypeOrders.sort((a, b) => {
const ta = b.updateTime || b.time || 0;
const tb = a.updateTime || a.time || 0;
return ta - tb;
});
const toCancel = sameTypeOrders.slice(1);
const orderIdList = toCancel.map((o) => o.orderId);
if (!orderIdList.length) return;
try {
lockOperating(locks, timers, pendings, type, log);
await adapter.cancelOrders({ symbol, orderIdList });
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
} catch (err) {
log("error", `去重撤单失败: ${String(err)}`);
} finally {
unlockOperating(locks, timers, pendings, type);
}
}
export async function placeOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: AsterOrder[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
price: number,
amount: number,
log: LogHandler,
reduceOnly = false
): Promise<AsterOrder | undefined> {
const type = "LIMIT";
if (isOperating(locks, type)) return;
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: toQty3Decimal(amount),
price: toPrice1Decimal(price),
timeInForce: "GTX",
};
if (reduceOnly) params.reduceOnly = "true";
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
throw err;
}
}
export async function placeMarketOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: AsterOrder[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
amount: number,
log: LogHandler,
reduceOnly = false
): Promise<AsterOrder | undefined> {
const type = "MARKET";
if (isOperating(locks, type)) return;
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: toQty3Decimal(amount),
};
if (reduceOnly) params.reduceOnly = "true";
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
throw err;
}
}
export async function placeStopLossOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: AsterOrder[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
stopPrice: number,
quantity: number,
lastPrice: number | null,
log: LogHandler
): Promise<AsterOrder | undefined> {
const type = "STOP_MARKET";
if (isOperating(locks, type)) return;
if (lastPrice != null) {
if (side === "SELL" && stopPrice >= lastPrice) {
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
return;
}
if (side === "BUY" && stopPrice <= lastPrice) {
log("error", `止损价 ${stopPrice} 低于或等于当前价 ${lastPrice},取消挂单`);
return;
}
}
const params: CreateOrderParams = {
symbol,
side,
type,
stopPrice: toPrice1Decimal(stopPrice),
closePosition: "true",
timeInForce: "GTC",
quantity: toQty3Decimal(quantity),
};
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
throw err;
}
}
export async function placeTrailingStopOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: AsterOrder[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
activationPrice: number,
quantity: number,
callbackRate: number,
log: LogHandler
): Promise<AsterOrder | undefined> {
const type = "TRAILING_STOP_MARKET";
if (isOperating(locks, type)) return;
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: toQty3Decimal(quantity),
reduceOnly: "true",
activationPrice: toPrice1Decimal(activationPrice),
callbackRate,
timeInForce: "GTC",
};
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log(
"order",
`挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}`
);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
throw err;
}
}
export async function marketClose(
adapter: ExchangeAdapter,
symbol: string,
openOrders: AsterOrder[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
quantity: number,
log: LogHandler
): Promise<void> {
const type = "MARKET";
if (isOperating(locks, type)) return;
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: toQty3Decimal(quantity),
reduceOnly: "true",
};
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("close", `市价平仓: ${side}`);
} catch (err) {
unlockOperating(locks, timers, pendings, type);
throw err;
}
}
+434
View File
@@ -0,0 +1,434 @@
import type { TradingConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type {
AsterAccountSnapshot,
AsterOrder,
AsterTicker,
AsterDepth,
AsterKline,
} from "../exchanges/types";
import {
calcStopLossPrice,
calcTrailingActivationPrice,
getPosition,
getSMA,
type PositionSnapshot,
} from "../utils/strategy";
import {
marketClose,
OrderLockMap,
OrderPendingMap,
OrderTimerMap,
placeMarketOrder,
placeStopLossOrder,
placeTrailingStopOrder,
unlockOperating,
} from "./order-coordinator";
import { toPrice1Decimal } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
export interface TrendEngineSnapshot {
ready: boolean;
symbol: string;
lastPrice: number | null;
sma30: number | null;
trend: "做多" | "做空" | "无信号";
position: PositionSnapshot;
pnl: number;
unrealized: number;
totalProfit: number;
totalTrades: number;
tradeLog: TradeLogEntry[];
openOrders: AsterOrder[];
depth: AsterDepth | null;
ticker: AsterTicker | null;
lastUpdated: number | null;
lastOpenSignal: OpenOrderPlan;
}
export interface OpenOrderPlan {
side: "BUY" | "SELL" | null;
price: number | null;
}
type TrendEngineEvent = "update";
type TrendEngineListener = (snapshot: TrendEngineSnapshot) => void;
export class TrendEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
private openOrders: AsterOrder[] = [];
private depthSnapshot: AsterDepth | null = null;
private tickerSnapshot: AsterTicker | null = null;
private klineSnapshot: AsterKline[] = [];
private readonly locks: OrderLockMap = {};
private readonly timers: OrderTimerMap = {};
private readonly pending: OrderPendingMap = {};
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private timer: ReturnType<typeof setInterval> | null = null;
private processing = false;
private lastPrice: number | null = null;
private lastSma30: number | null = null;
private totalProfit = 0;
private totalTrades = 0;
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.config.pollIntervalMs);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
const handlers = this.listeners.get(event) ?? new Set<TrendEngineListener>();
handlers.add(handler);
this.listeners.set(event, handlers);
}
off(event: TrendEngineEvent, handler: TrendEngineListener): void {
const handlers = this.listeners.get(event);
if (!handlers) return;
handlers.delete(handler);
if (handlers.size === 0) {
this.listeners.delete(event);
}
}
getSnapshot(): TrendEngineSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
this.exchange.watchAccount((snapshot) => {
this.accountSnapshot = snapshot;
this.emitUpdate();
});
this.exchange.watchOrders((orders) => {
this.synchronizeLocks(orders);
this.openOrders = Array.isArray(orders)
? orders.filter((order) => order.type !== "MARKET")
: [];
this.emitUpdate();
});
this.exchange.watchDepth(this.config.symbol, (depth) => {
this.depthSnapshot = depth;
this.emitUpdate();
});
this.exchange.watchTicker(this.config.symbol, (ticker) => {
this.tickerSnapshot = ticker;
this.emitUpdate();
});
this.exchange.watchKlines(this.config.symbol, this.config.klineInterval, (klines) => {
this.klineSnapshot = klines;
this.emitUpdate();
});
}
private synchronizeLocks(orders: AsterOrder[]): void {
Object.keys(this.pending).forEach((type) => {
const pendingId = this.pending[type];
if (!pendingId) return;
const match = orders.find((order) => String(order.orderId) === pendingId);
if (!match || (match.status && match.status !== "NEW")) {
unlockOperating(this.locks, this.timers, this.pending, type);
}
});
}
private isReady(): boolean {
return Boolean(
this.accountSnapshot &&
this.tickerSnapshot &&
this.depthSnapshot &&
this.klineSnapshot.length >= 30
);
}
private async tick(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
if (!this.isReady()) {
this.emitUpdate();
return;
}
const sma30 = getSMA(this.klineSnapshot, 30);
if (sma30 == null) {
return;
}
const ticker = this.tickerSnapshot!;
const price = Number(ticker.lastPrice);
const position = getPosition(this.accountSnapshot, this.config.symbol);
if (Math.abs(position.positionAmt) < 1e-5) {
await this.handleOpenPosition(price, sma30);
} else {
const result = await this.handlePositionManagement(position, price);
if (result.closed) {
this.totalTrades += 1;
this.totalProfit += result.pnl;
}
}
this.lastSma30 = sma30;
this.lastPrice = price;
this.emitUpdate();
} catch (error) {
this.tradeLog.push("error", `策略循环异常: ${String(error)}`);
this.emitUpdate();
} finally {
this.processing = false;
}
}
private async handleOpenPosition(currentPrice: number, currentSma: number): Promise<void> {
if (this.lastPrice == null) {
this.lastPrice = currentPrice;
return;
}
if (this.openOrders.length > 0) {
try {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
} catch (err) {
this.tradeLog.push("error", `撤销挂单失败: ${String(err)}`);
}
}
if (this.lastPrice > currentSma && currentPrice < currentSma) {
await this.submitMarketOrder("SELL", currentPrice, "下穿SMA30,市价开空");
} else if (this.lastPrice < currentSma && currentPrice > currentSma) {
await this.submitMarketOrder("BUY", currentPrice, "上穿SMA30,市价开多");
}
}
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
try {
await placeMarketOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
this.config.tradeAmount,
(type, detail) => this.tradeLog.push(type, detail)
);
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
this.lastOpenPlan = { side, price };
} catch (err) {
this.tradeLog.push("error", `市价下单失败: ${String(err)}`);
}
}
private async handlePositionManagement(
position: PositionSnapshot,
price: number
): Promise<{ closed: boolean; pnl: number }> {
const direction = position.positionAmt > 0 ? "long" : "short";
const pnl =
(direction === "long"
? price - position.entryPrice
: position.entryPrice - price) * Math.abs(position.positionAmt);
const stopSide = direction === "long" ? "SELL" : "BUY";
const stopPrice = calcStopLossPrice(
position.entryPrice,
Math.abs(position.positionAmt),
direction,
this.config.lossLimit
);
const activationPrice = calcTrailingActivationPrice(
position.entryPrice,
Math.abs(position.positionAmt),
direction,
this.config.trailingProfit
);
const currentStop = this.openOrders.find(
(o) => o.type === "STOP_MARKET" && o.side === stopSide
);
const currentTrailing = this.openOrders.find(
(o) => o.type === "TRAILING_STOP_MARKET" && o.side === stopSide
);
const profitLockStopPrice = direction === "long"
? toPrice1Decimal(
position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
)
: toPrice1Decimal(
position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
);
if (pnl > this.config.profitLockTriggerUsd || position.unrealizedProfit > this.config.profitLockTriggerUsd) {
if (!currentStop) {
await this.tryPlaceStopLoss(stopSide, profitLockStopPrice, price);
} else {
const existingPrice = Number(currentStop.stopPrice);
if (Math.abs(existingPrice - profitLockStopPrice) > 0.01) {
await this.tryReplaceStop(stopSide, currentStop, profitLockStopPrice, price);
}
}
}
if (!currentStop) {
await this.tryPlaceStopLoss(stopSide, toPrice1Decimal(stopPrice), price);
}
if (!currentTrailing) {
await this.tryPlaceTrailingStop(
stopSide,
toPrice1Decimal(activationPrice),
Math.abs(position.positionAmt)
);
}
if (pnl < -this.config.lossLimit || position.unrealizedProfit < -this.config.lossLimit) {
try {
if (this.openOrders.length > 0) {
const orderIdList = this.openOrders.map((order) => order.orderId);
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
}
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
direction === "long" ? "SELL" : "BUY",
this.config.tradeAmount,
(type, detail) => this.tradeLog.push(type, detail)
);
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
} catch (err) {
this.tradeLog.push("error", `止损平仓失败: ${String(err)}`);
}
return { closed: true, pnl };
}
return { closed: false, pnl };
}
private async tryPlaceStopLoss(
side: "BUY" | "SELL",
stopPrice: number,
lastPrice: number
): Promise<void> {
try {
await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
stopPrice,
this.config.tradeAmount,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail)
);
} catch (err) {
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
}
}
private async tryReplaceStop(
side: "BUY" | "SELL",
currentOrder: AsterOrder,
nextStopPrice: number,
lastPrice: number
): Promise<void> {
try {
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
} catch (err) {
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
}
await this.tryPlaceStopLoss(side, nextStopPrice, lastPrice);
this.tradeLog.push("stop", `移动止损到 ${nextStopPrice}`);
}
private async tryPlaceTrailingStop(
side: "BUY" | "SELL",
activationPrice: number,
quantity: number
): Promise<void> {
try {
await placeTrailingStopOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
activationPrice,
quantity,
this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail)
);
} catch (err) {
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
}
}
private emitUpdate(): void {
const snapshot = this.buildSnapshot();
const handlers = this.listeners.get("update");
if (!handlers) return;
handlers.forEach((handler) => handler(snapshot));
}
private buildSnapshot(): TrendEngineSnapshot {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
const sma30 = this.lastSma30;
const trend = price == null || sma30 == null
? "无信号"
: price > sma30
? "做多"
: price < sma30
? "做空"
: "无信号";
const pnl = price != null && position
? (position.positionAmt > 0
? (price - position.entryPrice) * Math.abs(position.positionAmt)
: (position.entryPrice - price) * Math.abs(position.positionAmt))
: 0;
return {
ready: this.isReady(),
symbol: this.config.symbol,
lastPrice: price,
sma30,
trend,
position,
pnl,
unrealized: position.unrealizedProfit,
totalProfit: this.totalProfit,
totalTrades: this.totalTrades,
tradeLog: this.tradeLog.all(),
openOrders: this.openOrders,
depth: this.depthSnapshot,
ticker: this.tickerSnapshot,
lastUpdated: Date.now(),
lastOpenSignal: this.lastOpenPlan,
};
}
}
+41
View File
@@ -0,0 +1,41 @@
import type {
AsterAccountSnapshot,
AsterOrder,
AsterDepth,
AsterTicker,
AsterKline,
CreateOrderParams,
} from "./types";
export interface AccountListener {
(snapshot: AsterAccountSnapshot): void;
}
export interface OrderListener {
(orders: AsterOrder[]): void;
}
export interface DepthListener {
(depth: AsterDepth): void;
}
export interface TickerListener {
(ticker: AsterTicker): void;
}
export interface KlineListener {
(klines: AsterKline[]): void;
}
export interface ExchangeAdapter {
readonly id: string;
watchAccount(cb: AccountListener): void;
watchOrders(cb: OrderListener): void;
watchDepth(symbol: string, cb: DepthListener): void;
watchTicker(symbol: string, cb: TickerListener): void;
watchKlines(symbol: string, interval: string, cb: KlineListener): void;
createOrder(params: CreateOrderParams): Promise<AsterOrder>;
cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>;
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
cancelAllOrders(params: { symbol: string }): Promise<void>;
}
+90
View File
@@ -0,0 +1,90 @@
import type {
AccountListener,
DepthListener,
ExchangeAdapter,
KlineListener,
OrderListener,
TickerListener,
} from "./adapter";
import type { AsterOrder, CreateOrderParams, AsterDepth, AsterTicker, AsterKline } from "./types";
import { AsterGateway } from "./aster/client";
export interface AsterCredentials {
apiKey?: string;
apiSecret?: string;
symbol?: string;
}
export class AsterExchangeAdapter implements ExchangeAdapter {
readonly id = "aster";
private readonly gateway: AsterGateway;
private readonly symbol: string;
private initPromise: Promise<void> | null = null;
constructor(credentials: AsterCredentials = {}) {
this.gateway = new AsterGateway({ apiKey: credentials.apiKey, apiSecret: credentials.apiSecret });
this.symbol = (credentials.symbol ?? process.env.TRADE_SYMBOL ?? "BTCUSDT").toUpperCase();
}
private ensureInitialized(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.gateway.ensureInitialized(this.symbol);
}
return this.initPromise;
}
watchAccount(cb: AccountListener): void {
void this.ensureInitialized();
this.gateway.onAccount((snapshot) => {
cb(snapshot);
});
}
watchOrders(cb: OrderListener): void {
void this.ensureInitialized();
this.gateway.onOrders((orders) => {
cb(orders);
});
}
watchDepth(symbol: string, cb: DepthListener): void {
void this.ensureInitialized();
this.gateway.onDepth(symbol, (depth: AsterDepth) => {
cb(depth);
});
}
watchTicker(symbol: string, cb: TickerListener): void {
void this.ensureInitialized();
this.gateway.onTicker(symbol, (ticker: AsterTicker) => {
cb(ticker);
});
}
watchKlines(symbol: string, interval: string, cb: KlineListener): void {
void this.ensureInitialized();
this.gateway.onKlines(symbol, interval, (klines: AsterKline[]) => {
cb(klines);
});
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized();
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized();
await this.gateway.cancelOrder({ symbol: params.symbol, orderId: Number(params.orderId) });
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized();
await this.gateway.cancelOrders({ symbol: params.symbol, orderIdList: params.orderIdList });
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.ensureInitialized();
await this.gateway.cancelAllOrders(params);
}
}
+850
View File
@@ -0,0 +1,850 @@
import crypto from "crypto";
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
import type {
AsterAccountSnapshot,
AsterDepth,
AsterKline,
AsterOrder,
AsterTicker,
CreateOrderParams,
} from "../types";
const REST_BASE = "https://fapi.asterdex.com";
const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/";
const FINAL_ORDER_STATUSES = new Set(["FILLED", "CANCELED", "REJECTED", "EXPIRED"]);
const DEFAULT_DEPTH_LEVEL = 20;
const DEFAULT_DEPTH_SPEED = "100ms";
const DEFAULT_KLINE_LIMIT = 120;
const KLINE_REFRESH_INTERVAL_MS = 60_000;
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
const RECONNECT_DELAY_MS = 2000;
function requireEnv(value: string | undefined, key: string): string {
if (!value) {
throw new Error(`Missing required environment variable ${key}`);
}
return value;
}
function toDepth(streamSymbol: string, data: any): AsterDepth {
return {
eventType: data.e,
eventTime: data.E,
tradeTime: data.T,
symbol: streamSymbol,
lastUpdateId: data.u,
bids: (data.b ?? []).map(([price, qty]: [string, string]) => [price, qty]),
asks: (data.a ?? []).map(([price, qty]: [string, string]) => [price, qty]),
};
}
function toTicker(data: any): AsterTicker {
return {
eventType: data.e,
eventTime: data.E,
symbol: data.s,
lastPrice: data.c,
openPrice: data.o,
highPrice: data.h,
lowPrice: data.l,
volume: data.q ?? data.v ?? "0",
quoteVolume: data.Q ?? data.V ?? "0",
priceChange: data.p,
priceChangePercent: data.P,
weightedAvgPrice: data.w,
lastQty: data.l ?? data.L,
openTime: data.O,
closeTime: data.C,
firstId: data.F,
lastId: data.L,
count: data.n,
};
}
function toKline(data: any): AsterKline {
return {
eventType: data.e,
eventTime: data.E,
symbol: data.s,
interval: data.k.i,
openTime: data.k.t,
closeTime: data.k.T,
firstTradeId: data.k.f,
lastTradeId: data.k.L,
open: data.k.o,
high: data.k.h,
low: data.k.l,
close: data.k.c,
volume: data.k.v,
numberOfTrades: data.k.n,
quoteAssetVolume: data.k.q,
takerBuyBaseAssetVolume: data.k.V,
takerBuyQuoteAssetVolume: data.k.Q,
isClosed: Boolean(data.k.x),
};
}
function fromRestKline(entry: any[], interval: string, symbol: string): AsterKline {
return {
eventType: undefined,
eventTime: undefined,
symbol,
interval,
openTime: entry[0],
open: entry[1],
high: entry[2],
low: entry[3],
close: entry[4],
volume: entry[5],
closeTime: entry[6],
quoteAssetVolume: entry[7],
numberOfTrades: entry[8],
takerBuyBaseAssetVolume: entry[9],
takerBuyQuoteAssetVolume: entry[10],
isClosed: Boolean(entry[11]),
} as AsterKline;
}
function toOrderFromRest(raw: any): AsterOrder {
return {
avgPrice: raw.avgPrice ?? "0",
clientOrderId: raw.clientOrderId ?? "",
cumQuote: raw.cumQuote ?? "0",
executedQty: raw.executedQty ?? "0",
orderId: raw.orderId,
origQty: raw.origQty ?? raw.quantity ?? "0",
origType: raw.origType ?? raw.type ?? "",
price: raw.price ?? "0",
reduceOnly: Boolean(raw.reduceOnly),
side: raw.side ?? "",
positionSide: raw.positionSide ?? "BOTH",
status: raw.status ?? "NEW",
stopPrice: raw.stopPrice ?? raw.triggerPrice ?? "0",
closePosition: Boolean(raw.closePosition),
symbol: raw.symbol ?? "",
time: raw.time ?? raw.updateTime ?? Date.now(),
timeInForce: raw.timeInForce ?? "GTC",
type: raw.type ?? "LIMIT",
activatePrice: raw.activatePrice,
priceRate: raw.priceRate,
updateTime: raw.updateTime ?? Date.now(),
workingType: raw.workingType ?? "CONTRACT_PRICE",
priceProtect: Boolean(raw.priceProtect),
};
}
function toOrderFromEvent(event: any): AsterOrder {
return {
avgPrice: event.ap ?? "0",
clientOrderId: event.c ?? "",
cumQuote: event.z ?? "0",
executedQty: event.z ?? "0",
orderId: event.i,
origQty: event.q ?? "0",
origType: event.ot ?? event.o ?? "",
price: event.p ?? "0",
reduceOnly: Boolean(event.R),
side: event.S,
positionSide: event.ps ?? "BOTH",
status: event.X,
stopPrice: event.sp ?? "0",
closePosition: Boolean(event.cp),
symbol: event.s,
time: event.T ?? Date.now(),
timeInForce: event.f ?? "GTC",
type: event.o ?? "LIMIT",
activatePrice: event.AP,
priceRate: event.cr,
updateTime: event.T ?? Date.now(),
workingType: event.wt ?? "CONTRACT_PRICE",
priceProtect: Boolean(event.PP),
};
}
function deepCloneAccount(snapshot: AsterAccountSnapshot | null): AsterAccountSnapshot | null {
return snapshot ? JSON.parse(JSON.stringify(snapshot)) : null;
}
class SimpleEvent<T> {
private readonly listeners = new Set<(payload: T) => void>();
add(listener: (payload: T) => void): void {
this.listeners.add(listener);
}
remove(listener: (payload: T) => void): void {
this.listeners.delete(listener);
}
emit(payload: T): void {
for (const listener of Array.from(this.listeners)) {
try {
listener(payload);
} catch (error) {
console.error("[SimpleEvent] listener failure", error);
}
}
}
listenerCount(): number {
return this.listeners.size;
}
}
export interface ListenKeyResponse {
listenKey: string;
}
export class AsterRestClient {
private readonly apiKey: string;
private readonly apiSecret: string;
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
this.apiKey = requireEnv(options.apiKey ?? process.env.ASTER_API_KEY, "ASTER_API_KEY");
this.apiSecret = requireEnv(options.apiSecret ?? process.env.ASTER_API_SECRET, "ASTER_API_SECRET");
}
async getAccount(): Promise<AsterAccountSnapshot> {
return this.signedRequest<AsterAccountSnapshot>({ path: "/fapi/v2/account", method: "GET", params: {} });
}
async getOpenOrders(symbol?: string): Promise<AsterOrder[]> {
const params: Record<string, unknown> = {};
if (symbol) params.symbol = symbol;
const raw = await this.signedRequest<any[]>({ path: "/fapi/v1/openOrders", method: "GET", params });
return raw.map(toOrderFromRest);
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const payload: Record<string, unknown> = { ...params };
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
return toOrderFromRest(response);
}
async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<AsterOrder> {
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "DELETE", params });
return toOrderFromRest(response);
}
async cancelOrders(params: { symbol: string; orderIdList?: Array<number | string>; origClientOrderIdList?: string[] }): Promise<AsterOrder[]> {
const payload: Record<string, unknown> = { symbol: params.symbol };
if (params.orderIdList) payload.orderIdList = JSON.stringify(params.orderIdList.map((id) => Number(id)));
if (params.origClientOrderIdList) payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList);
const response = await this.signedRequest<any[]>({ path: "/fapi/v1/batchOrders", method: "DELETE", params: payload });
return response.map(toOrderFromRest);
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.signedRequest({ path: "/fapi/v1/allOpenOrders", method: "DELETE", params });
}
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
const upper = symbol.toUpperCase();
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
const response = await fetch(url);
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status} ${text}`);
}
const payload = (await response.json()) as any[];
return payload.map((entry) => fromRestKline(entry, interval, upper));
}
async getListenKey(): Promise<string> {
const response = await this.signedRequest<ListenKeyResponse>({ path: "/fapi/v1/listenKey", method: "POST", params: {} });
return response.listenKey;
}
async keepAliveListenKey(listenKey: string): Promise<void> {
await this.signedRequest({ path: "/fapi/v1/listenKey", method: "PUT", params: { listenKey } });
}
async closeListenKey(listenKey: string): Promise<void> {
await this.signedRequest({ path: "/fapi/v1/listenKey", method: "DELETE", params: { listenKey } });
}
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
const timestamp = Date.now();
const payload = { ...params, timestamp, recvWindow: 5000 };
const query = this.serialize(payload);
const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex");
const url = `${REST_BASE}${path}?${query}&signature=${signature}`;
const init: RequestInit = {
method,
headers: {
"X-MBX-APIKEY": this.apiKey,
"Content-Type": "application/x-www-form-urlencoded",
},
};
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status} ${text}`);
}
return (await response.json()) as T;
}
private serialize(params: Record<string, unknown>): string {
return Object.keys(params)
.sort()
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
.join("&");
}
}
type DepthHandler = (depth: AsterDepth) => void;
type TickerHandler = (ticker: AsterTicker) => void;
type KlineHandler = (kline: AsterKline) => void;
type StreamKind = "depth" | "ticker" | "kline";
interface StreamState {
stream: string;
kind: StreamKind;
symbol: string;
interval?: string;
}
export class AsterPublicStreams {
private ws: WebSocket | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly streams = new Map<string, StreamState>();
private readonly depthHandlers = new Map<string, Set<DepthHandler>>();
private readonly tickerHandlers = new Map<string, Set<TickerHandler>>();
private readonly klineHandlers = new Map<string, Set<KlineHandler>>();
private nextRequestId = 1;
subscribeDepth(symbol: string, handler: DepthHandler): void {
const upper = symbol.toUpperCase();
const stream = `${upper.toLowerCase()}@depth${DEFAULT_DEPTH_LEVEL}@${DEFAULT_DEPTH_SPEED}`;
this.addHandler(this.depthHandlers, upper, handler);
this.registerStream(stream, { stream, kind: "depth", symbol: upper });
}
subscribeTicker(symbol: string, handler: TickerHandler): void {
const upper = symbol.toUpperCase();
const stream = `${upper.toLowerCase()}@miniTicker`;
this.addHandler(this.tickerHandlers, upper, handler);
this.registerStream(stream, { stream, kind: "ticker", symbol: upper });
}
subscribeKline(symbol: string, interval: string, handler: KlineHandler): void {
const upper = symbol.toUpperCase();
const stream = `${upper.toLowerCase()}@kline_${interval}`;
this.addHandler(this.klineHandlers, `${upper}:${interval}`, handler);
this.registerStream(stream, { stream, kind: "kline", symbol: upper, interval });
}
private addHandler<T>(map: Map<string, Set<T>>, key: string, handler: T): void {
let set = map.get(key);
if (!set) {
set = new Set();
map.set(key, set);
}
set.add(handler);
this.ensureConnection();
}
private registerStream(stream: string, state: StreamState): void {
if (!this.streams.has(stream)) {
this.streams.set(stream, state);
this.send({ method: "SUBSCRIBE", params: [stream], id: this.nextRequestId++ });
}
}
private ensureConnection(): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.connect();
}
private connect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.ws = new WebSocket(WS_PUBLIC_URL);
this.ws.onopen = () => {
const streams = Array.from(this.streams.keys());
if (streams.length) {
this.send({ method: "SUBSCRIBE", params: streams, id: this.nextRequestId++ });
}
};
this.ws.onmessage = (event) => {
const payload = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
if (!payload) return;
if (payload.result !== undefined) return; // subscription ack
const data = payload.data ?? payload;
if (!data.e) return;
switch (data.e) {
case "depthUpdate":
this.dispatchDepth(data);
break;
case "24hrMiniTicker":
this.dispatchTicker(data);
break;
case "kline":
this.dispatchKline(data);
break;
default:
break;
}
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
private scheduleReconnect(): void {
if (this.reconnectTimeout) return;
this.reconnectTimeout = setTimeout(() => {
this.connect();
}, RECONNECT_DELAY_MS);
}
private send(message: Record<string, unknown>): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
private dispatchDepth(data: any): void {
const symbol = String(data.s ?? "").toUpperCase();
const handlers = this.depthHandlers.get(symbol);
if (!handlers || !handlers.size) return;
const depth = toDepth(symbol, data);
handlers.forEach((handler) => handler(depth));
}
private dispatchTicker(data: any): void {
const symbol = String(data.s ?? "").toUpperCase();
const handlers = this.tickerHandlers.get(symbol);
if (!handlers || !handlers.size) return;
const ticker = toTicker(data);
handlers.forEach((handler) => handler(ticker));
}
private dispatchKline(data: any): void {
const symbol = String(data.s ?? "").toUpperCase();
const interval = data.k?.i ?? "";
const key = `${symbol}:${interval}`;
const handlers = this.klineHandlers.get(key);
if (!handlers || !handlers.size) return;
const kline = toKline(data);
handlers.forEach((handler) => handler(kline));
}
}
interface AccountUpdatePayload {
B: Array<{ a: string; wb: string; cw: string; bc: string; wbBalance?: string; } & Record<string, string>>;
P: Array<{ s: string; pa: string; ep: string; cr: string; up: string; mt: string; iw?: string; ps: string; pc?: string; } & Record<string, string>>;
}
interface OrderUpdatePayload extends Record<string, any> {}
export class AsterUserStream {
private readonly rest: AsterRestClient;
private listenKey: string | null = null;
private ws: WebSocket | null = null;
private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly accountEvent = new SimpleEvent<{ eventTime: number; payload: AccountUpdatePayload }>();
private readonly orderEvent = new SimpleEvent<{ eventTime: number; payload: OrderUpdatePayload }>();
private isRunning = false;
constructor(rest: AsterRestClient) {
this.rest = rest;
}
onAccount(listener: (payload: { eventTime: number; payload: AccountUpdatePayload }) => void): void {
this.accountEvent.add(listener);
}
onOrder(listener: (payload: { eventTime: number; payload: OrderUpdatePayload }) => void): void {
this.orderEvent.add(listener);
}
async start(): Promise<void> {
if (this.isRunning) return;
this.isRunning = true;
await this.ensureListenKey();
this.openSocket();
this.scheduleKeepAlive();
}
stop(): void {
this.isRunning = false;
if (this.keepAliveTimer) {
clearInterval(this.keepAliveTimer);
this.keepAliveTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
if (this.listenKey) {
void this.rest.closeListenKey(this.listenKey).catch(() => undefined);
this.listenKey = null;
}
}
private async ensureListenKey(): Promise<void> {
if (this.listenKey) return;
this.listenKey = await this.rest.getListenKey();
}
private scheduleKeepAlive(): void {
if (this.keepAliveTimer) return;
this.keepAliveTimer = setInterval(() => {
if (!this.listenKey) return;
void this.rest.keepAliveListenKey(this.listenKey).catch((error) => {
console.error("[AsterUserStream] keepAlive error", error);
});
}, LISTEN_KEY_KEEPALIVE_MS / 2);
}
private openSocket(): void {
if (!this.listenKey) return;
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
const url = `${WS_LISTEN_KEY_URL}${this.listenKey}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
// no-op
};
this.ws.onmessage = (event) => {
const payload = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
if (!payload) return;
if (payload === "ping") {
this.ws?.send("pong");
return;
}
switch (payload.e) {
case "ACCOUNT_UPDATE":
this.accountEvent.emit({ eventTime: payload.E, payload: payload.a });
break;
case "ORDER_TRADE_UPDATE":
this.orderEvent.emit({ eventTime: payload.E, payload: payload.o });
break;
case "listenKeyExpired":
this.handleListenKeyExpired();
break;
default:
break;
}
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
private async handleListenKeyExpired(): Promise<void> {
this.listenKey = null;
await this.ensureListenKey();
this.openSocket();
}
private scheduleReconnect(): void {
if (!this.isRunning) return;
if (this.reconnectTimeout) return;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.openSocket();
}, RECONNECT_DELAY_MS);
}
}
function updateAccountSnapshot(snapshot: AsterAccountSnapshot | null, event: { eventTime: number; payload: AccountUpdatePayload }): AsterAccountSnapshot | null {
if (!snapshot) return snapshot;
const next = deepCloneAccount(snapshot);
if (!next) return snapshot;
next.updateTime = event.eventTime;
const balances = event.payload.B ?? [];
for (const balance of balances) {
const asset = balance.a;
let existing = next.assets.find((item) => item.asset === asset);
if (!existing) {
existing = {
asset,
walletBalance: "0",
unrealizedProfit: "0",
marginBalance: "0",
maintMargin: "0",
initialMargin: "0",
positionInitialMargin: "0",
openOrderInitialMargin: "0",
crossWalletBalance: "0",
crossUnPnl: "0",
availableBalance: "0",
maxWithdrawAmount: "0",
marginAvailable: true,
updateTime: event.eventTime,
} as any;
next.assets.push(existing);
}
if (balance.wb !== undefined) existing.walletBalance = balance.wb;
if (balance.cw !== undefined) existing.crossWalletBalance = balance.cw;
if (balance.bc !== undefined) existing.availableBalance = balance.bc;
existing.updateTime = event.eventTime;
}
const positions = event.payload.P ?? [];
const unrealizedTotals = positions.reduce((acc, item) => acc + parseFloat(item.up ?? "0"), 0);
next.totalUnrealizedProfit = unrealizedTotals.toFixed(8);
for (const position of positions) {
const symbol = position.s;
let existing = next.positions.find((item) => item.symbol === symbol && item.positionSide === position.ps);
if (!existing) {
existing = {
symbol,
positionAmt: "0",
entryPrice: "0",
unrealizedProfit: "0",
positionSide: position.ps,
updateTime: event.eventTime,
initialMargin: "0",
maintMargin: "0",
positionInitialMargin: "0",
openOrderInitialMargin: "0",
leverage: "",
isolated: position.mt === "isolated",
maxNotional: "0",
} as any;
next.positions.push(existing);
}
existing.positionAmt = position.pa ?? existing.positionAmt;
existing.entryPrice = position.ep ?? existing.entryPrice;
existing.unrealizedProfit = position.up ?? existing.unrealizedProfit;
existing.updateTime = event.eventTime;
}
return next;
}
function mergeOrderSnapshot(map: Map<number, AsterOrder>, order: AsterOrder): void {
if (FINAL_ORDER_STATUSES.has(order.status)) {
map.delete(order.orderId);
} else {
map.set(order.orderId, order);
}
}
export class AsterGateway {
private readonly rest: AsterRestClient;
private readonly publicStreams: AsterPublicStreams;
private readonly userStream: AsterUserStream;
private accountSnapshot: AsterAccountSnapshot | null = null;
private readonly openOrders = new Map<number, AsterOrder>();
private readonly accountEvent = new SimpleEvent<AsterAccountSnapshot>();
private readonly ordersEvent = new SimpleEvent<AsterOrder[]>();
private readonly depthEvents = new Map<string, SimpleEvent<AsterDepth>>();
private readonly tickerEvents = new Map<string, SimpleEvent<AsterTicker>>();
private readonly klineEvents = new Map<string, SimpleEvent<AsterKline[]>>();
private readonly klineStores = new Map<string, AsterKline[]>();
private readonly klineRefreshTimers = new Map<string, ReturnType<typeof setInterval>>();
private readonly klineInitialFetches = new Map<string, Promise<void>>();
private initialized = false;
private initializing: Promise<void> | null = null;
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
this.rest = new AsterRestClient(options);
this.publicStreams = new AsterPublicStreams();
this.userStream = new AsterUserStream(this.rest);
this.userStream.onAccount((event) => {
const updated = updateAccountSnapshot(this.accountSnapshot, event);
if (updated) {
this.accountSnapshot = updated;
this.accountEvent.emit(updated);
}
});
this.userStream.onOrder((event) => {
const order = toOrderFromEvent(event.payload);
mergeOrderSnapshot(this.openOrders, order);
this.ordersEvent.emit(Array.from(this.openOrders.values()));
});
}
async ensureInitialized(symbol: string): Promise<void> {
if (this.initialized) return;
if (this.initializing) return this.initializing;
this.initializing = (async () => {
this.accountSnapshot = await this.rest.getAccount();
const orders = await this.rest.getOpenOrders();
this.openOrders.clear();
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
this.initialized = true;
await this.userStream.start();
this.accountEvent.emit(this.accountSnapshot!);
this.ordersEvent.emit(Array.from(this.openOrders.values()));
})().catch((error) => {
this.initializing = null;
throw error;
});
return this.initializing;
}
onAccount(listener: (snapshot: AsterAccountSnapshot) => void): void {
this.accountEvent.add(listener);
if (this.accountSnapshot) listener(this.accountSnapshot);
}
onOrders(listener: (orders: AsterOrder[]) => void): void {
this.ordersEvent.add(listener);
listener(Array.from(this.openOrders.values()));
}
onDepth(symbol: string, listener: (depth: AsterDepth) => void): void {
const upper = symbol.toUpperCase();
let event = this.depthEvents.get(upper);
if (!event) {
event = new SimpleEvent<AsterDepth>();
this.depthEvents.set(upper, event);
this.publicStreams.subscribeDepth(upper, (depth) => {
event?.emit(depth);
});
}
event.add(listener);
}
onTicker(symbol: string, listener: (ticker: AsterTicker) => void): void {
const upper = symbol.toUpperCase();
let event = this.tickerEvents.get(upper);
if (!event) {
event = new SimpleEvent<AsterTicker>();
this.tickerEvents.set(upper, event);
this.publicStreams.subscribeTicker(upper, (ticker) => {
event?.emit(ticker);
});
}
event.add(listener);
}
onKlines(symbol: string, interval: string, listener: (klines: AsterKline[]) => void): void {
const upper = symbol.toUpperCase();
const key = `${upper}:${interval}`;
let event = this.klineEvents.get(key);
if (!event) {
event = new SimpleEvent<AsterKline[]>();
this.klineEvents.set(key, event);
this.publicStreams.subscribeKline(symbol, interval, (kline) => {
const storeKey = `${upper}:${interval}`;
let store = this.klineStores.get(storeKey);
if (!store) {
store = [];
this.klineStores.set(storeKey, store);
}
const index = store.findIndex((item) => item.openTime === kline.openTime);
if (index >= 0) {
store[index] = kline;
} else {
store.push(kline);
store.sort((a, b) => a.openTime - b.openTime);
if (store.length > DEFAULT_KLINE_LIMIT) {
store.shift();
}
}
event?.emit([...store]);
});
void this.ensureKlineSeed(upper, interval);
}
event.add(listener);
const existing = this.klineStores.get(key);
if (existing && existing.length) {
listener([...existing]);
} else {
void this.ensureKlineSeed(upper, interval);
}
}
private ensureKlineSeed(symbol: string, interval: string): Promise<void> {
const key = `${symbol}:${interval}`;
const existing = this.klineInitialFetches.get(key);
if (existing) return existing;
const task = (async () => {
try {
const klines = await this.rest.getKlines(symbol, interval, DEFAULT_KLINE_LIMIT);
klines.sort((a, b) => a.openTime - b.openTime);
this.klineStores.set(key, klines);
const event = this.klineEvents.get(key);
if (event) {
event.emit([...klines]);
}
} catch (error) {
console.error("[AsterGateway] seed klines failed", error);
} finally {
this.startKlineRefresh(symbol, interval);
}
})();
this.klineInitialFetches.set(key, task);
return task;
}
private startKlineRefresh(symbol: string, interval: string): void {
const key = `${symbol}:${interval}`;
if (this.klineRefreshTimers.has(key)) return;
const timer = setInterval(async () => {
try {
const klines = await this.rest.getKlines(symbol, interval, DEFAULT_KLINE_LIMIT);
klines.sort((a, b) => a.openTime - b.openTime);
this.klineStores.set(key, klines);
const event = this.klineEvents.get(key);
if (event) {
event.emit([...klines]);
}
} catch (error) {
console.error("[AsterGateway] refresh klines failed", error);
}
}, KLINE_REFRESH_INTERVAL_MS);
this.klineRefreshTimers.set(key, timer);
}
getAccountSnapshot(): AsterAccountSnapshot | null {
return this.accountSnapshot;
}
getOpenOrdersSnapshot(): AsterOrder[] {
return Array.from(this.openOrders.values());
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const order = await this.rest.createOrder(params);
mergeOrderSnapshot(this.openOrders, order);
this.ordersEvent.emit(Array.from(this.openOrders.values()));
return order;
}
async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<void> {
const result = await this.rest.cancelOrder(params);
mergeOrderSnapshot(this.openOrders, result);
this.ordersEvent.emit(Array.from(this.openOrders.values()));
}
async cancelOrders(params: { symbol: string; orderIdList?: Array<number | string>; origClientOrderIdList?: string[] }): Promise<void> {
const results = await this.rest.cancelOrders(params);
results.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
this.ordersEvent.emit(Array.from(this.openOrders.values()));
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.rest.cancelAllOrders(params);
for (const order of Array.from(this.openOrders.values())) {
if (order.symbol === params.symbol) {
this.openOrders.delete(order.orderId);
}
}
this.ordersEvent.emit(Array.from(this.openOrders.values()));
}
}
+113
View File
@@ -0,0 +1,113 @@
export type StringBoolean = "true" | "false";
export type OrderSide = "BUY" | "SELL";
export type OrderType =
| "LIMIT"
| "MARKET"
| "STOP_MARKET"
| "TRAILING_STOP_MARKET";
export type PositionSide = "BOTH" | "LONG" | "SHORT";
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
export interface CreateOrderParams {
symbol: string;
side: OrderSide;
type: OrderType;
quantity?: number;
price?: number;
stopPrice?: number;
activationPrice?: number;
callbackRate?: number;
timeInForce?: TimeInForce;
reduceOnly?: StringBoolean;
closePosition?: StringBoolean;
}
export interface AsterAccountPosition {
symbol: string;
positionAmt: string;
entryPrice: string;
unrealizedProfit: string;
positionSide: PositionSide;
updateTime: number;
}
export interface AsterAccountAsset {
asset: string;
walletBalance: string;
availableBalance: string;
updateTime: number;
}
export interface AsterAccountSnapshot {
canTrade: boolean;
canDeposit: boolean;
canWithdraw: boolean;
updateTime: number;
totalWalletBalance: string;
totalUnrealizedProfit: string;
positions: AsterAccountPosition[];
assets: AsterAccountAsset[];
}
export interface AsterDepthLevel extends Array<string> {
0: string; // price
1: string; // quantity
}
export interface AsterDepth {
lastUpdateId: number;
bids: AsterDepthLevel[];
asks: AsterDepthLevel[];
eventTime?: number;
}
export interface AsterTicker {
symbol: string;
lastPrice: string;
openPrice: string;
highPrice: string;
lowPrice: string;
volume: string;
quoteVolume: string;
eventTime?: number;
}
export interface AsterKline {
eventType?: string;
eventTime?: number;
symbol?: string;
interval?: string;
openTime: number;
open: string;
high: string;
low: string;
close: string;
volume: string;
closeTime: number;
firstTradeId?: number;
lastTradeId?: number;
quoteAssetVolume?: string;
numberOfTrades: number;
takerBuyBaseAssetVolume?: string;
takerBuyQuoteAssetVolume?: string;
isClosed?: boolean;
}
export interface AsterOrder {
orderId: number;
clientOrderId: string;
symbol: string;
side: OrderSide;
type: OrderType;
status: string;
price: string;
origQty: string;
executedQty: string;
stopPrice: string;
time: number;
updateTime: number;
reduceOnly: boolean;
closePosition: boolean;
workingType?: string;
}
+6
View File
@@ -0,0 +1,6 @@
import "dotenv/config";
import React from "react";
import { render } from "ink";
import { App } from "./ui/App";
render(<App />);
+19
View File
@@ -0,0 +1,19 @@
export interface TradeLogEntry {
time: string;
type: string;
detail: string;
}
export function createTradeLog(maxEntries: number) {
const entries: TradeLogEntry[] = [];
function push(type: string, detail: string) {
entries.push({ time: new Date().toLocaleString(), type, detail });
if (entries.length > maxEntries) {
entries.shift();
}
}
function all() {
return entries;
}
return { push, all };
}
+75
View File
@@ -0,0 +1,75 @@
import React, { useState } from "react";
import { Box, Text, useInput } from "ink";
import { TrendApp } from "./TrendApp";
import { MakerApp } from "./MakerApp";
interface StrategyOption {
id: "trend" | "maker";
label: string;
description: string;
component: React.ComponentType<{ onExit: () => void }>;
}
const STRATEGIES: StrategyOption[] = [
{
id: "trend",
label: "趋势跟随策略 (SMA30)",
description: "监控均线信号,自动进出场并维护止损/止盈",
component: TrendApp,
},
{
id: "maker",
label: "做市刷单策略",
description: "双边挂单提供流动性,自动追价与风控止损",
component: MakerApp,
},
];
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function App() {
const [cursor, setCursor] = useState(0);
const [selected, setSelected] = useState<StrategyOption | null>(null);
useInput(
(input, key) => {
if (selected) return;
if (key.upArrow) {
setCursor((prev) => (prev - 1 + STRATEGIES.length) % STRATEGIES.length);
} else if (key.downArrow) {
setCursor((prev) => (prev + 1) % STRATEGIES.length);
} else if (key.return) {
const strategy = STRATEGIES[cursor];
if (strategy) {
setSelected(strategy);
}
}
},
{ isActive: inputSupported && !selected }
);
if (selected) {
const Selected = selected.component;
return <Selected onExit={() => setSelected(null)} />;
}
return (
<Box flexDirection="column" paddingX={1} paddingY={1}>
<Text color="cyanBright"></Text>
<Text color="gray">使 / Ctrl+C 退</Text>
<Box flexDirection="column" marginTop={1}>
{STRATEGIES.map((strategy, index) => {
const active = index === cursor;
return (
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
<Text color={active ? "greenBright" : undefined}>
{active ? "➤" : " "} {strategy.label}
</Text>
<Text color="gray"> {strategy.description}</Text>
</Box>
);
})}
</Box>
</Box>
);
}
+177
View File
@@ -0,0 +1,177 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerConfig } from "../config";
import { AsterExchangeAdapter } from "../exchanges/aster-adapter";
import { MakerEngine, type MakerEngineSnapshot } from "../core/maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
interface MakerAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function MakerApp({ onExit }: MakerAppProps) {
const [snapshot, setSnapshot] = useState<MakerEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<MakerEngine | null>(null);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
return;
}
try {
const adapter = new AsterExchangeAdapter({
apiKey,
apiSecret,
symbol: makerConfig.symbol,
});
const engine = new MakerEngine(makerConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: MakerEngineSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, []);
if (error) {
return (
<Box flexDirection="column" padding={1}>
<Text color="red">: {error.message}</Text>
<Text color="gray"></Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text></Text>
</Box>
);
}
const topBid = snapshot.topBid;
const topAsk = snapshot.topAsk;
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-";
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
const openOrderRows = snapshot.openOrders.map((order) => ({
id: order.orderId,
side: order.side,
price: order.price,
qty: order.origQty,
filled: order.executedQty,
reduceOnly: order.reduceOnly ? "yes" : "no",
status: order.status,
}));
const openOrderColumns: TableColumn[] = [
{ key: "id", header: "ID", align: "right", minWidth: 6 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
{ key: "reduceOnly", header: "RO", minWidth: 4 },
{ key: "status", header: "Status", minWidth: 10 },
];
const desiredRows = snapshot.desiredOrders.map((order, index) => ({
index: index + 1,
side: order.side,
price: order.price,
amount: order.amount,
reduceOnly: order.reduceOnly ? "yes" : "no",
}));
const desiredColumns: TableColumn[] = [
{ key: "index", header: "#", align: "right", minWidth: 2 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
{ key: "reduceOnly", header: "RO", minWidth: 4 },
];
const lastLogs = snapshot.tradeLog.slice(-10);
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Maker Strategy Dashboard</Text>
<Text>
: {snapshot.symbol} : {formatNumber(topBid, 2)} : {formatNumber(topAsk, 2)} : {spreadDisplay}
</Text>
<Text color="gray">: {snapshot.ready ? "实时运行" : "等待市场数据"} Esc </Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright"></Text>
{hasPosition ? (
<>
<Text>
: {snapshot.position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} : {formatNumber(snapshot.position.entryPrice, 2)}
</Text>
<Text>
: {formatNumber(snapshot.pnl, 4)} USDT : {formatNumber(snapshot.accountUnrealized, 4)} USDT
</Text>
</>
) : (
<Text color="gray"></Text>
)}
</Box>
<Box flexDirection="column">
<Text color="greenBright"></Text>
{desiredRows.length > 0 ? (
<DataTable columns={desiredColumns} rows={desiredRows} />
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow"></Text>
{openOrderRows.length > 0 ? (
<DataTable columns={openOrderColumns} rows={openOrderRows} />
) : (
<Text color="gray"></Text>
)}
</Box>
<Box flexDirection="column">
<Text color="yellow"></Text>
{lastLogs.length > 0 ? (
lastLogs.map((item, index) => (
<Text key={`${item.time}-${index}`}>
[{item.time}] [{item.type}] {item.detail}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
);
}
+164
View File
@@ -0,0 +1,164 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { tradingConfig } from "../config";
import { AsterExchangeAdapter } from "../exchanges/aster-adapter";
import { TrendEngine, type TrendEngineSnapshot } from "../core/trend-engine";
import { formatNumber } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
const READY_MESSAGE = "正在等待交易所推送数据…";
interface TrendAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function TrendApp({ onExit }: TrendAppProps) {
const [snapshot, setSnapshot] = useState<TrendEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<TrendEngine | null>(null);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
const apiKey = process.env.ASTER_API_KEY;
const apiSecret = process.env.ASTER_API_SECRET;
if (!apiKey || !apiSecret) {
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
return;
}
try {
const adapter = new AsterExchangeAdapter({
apiKey,
apiSecret,
symbol: tradingConfig.symbol,
});
const engine = new TrendEngine(tradingConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: TrendEngineSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, []);
if (error) {
return (
<Box flexDirection="column" padding={1}>
<Text color="red">: {error.message}</Text>
<Text color="gray"></Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text></Text>
</Box>
);
}
const { position, tradeLog, openOrders, trend, ready, lastPrice, sma30 } = snapshot;
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
const lastLogs = tradeLog.slice(-10);
const orderRows = openOrders.slice(0, 8).map((order) => ({
id: order.orderId,
side: order.side,
type: order.type,
price: order.price,
qty: order.origQty,
filled: order.executedQty,
status: order.status,
}));
const orderColumns: TableColumn[] = [
{ key: "id", header: "ID", align: "right", minWidth: 6 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "type", header: "Type", minWidth: 10 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
{ key: "status", header: "Status", minWidth: 10 },
];
return (
<Box flexDirection="column" paddingX={1} paddingY={0}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Trend Strategy Dashboard</Text>
<Text>
: {snapshot.symbol} : {formatNumber(lastPrice, 2)} SMA30: {formatNumber(sma30, 2)} : {trend}
</Text>
<Text color="gray">: {ready ? "实时运行" : READY_MESSAGE} Esc </Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright"></Text>
{hasPosition ? (
<>
<Text>
: {position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(position.positionAmt), 4)} : {formatNumber(position.entryPrice, 2)}
</Text>
<Text>
: {formatNumber(snapshot.pnl, 4)} USDT : {formatNumber(snapshot.unrealized, 4)} USDT
</Text>
</>
) : (
<Text color="gray"></Text>
)}
</Box>
<Box flexDirection="column">
<Text color="greenBright"></Text>
<Text>
: {snapshot.totalTrades} : {formatNumber(snapshot.totalProfit, 4)} USDT
</Text>
{snapshot.lastOpenSignal.side ? (
<Text color="gray">
: {snapshot.lastOpenSignal.side} @ {formatNumber(snapshot.lastOpenSignal.price, 2)}
</Text>
) : null}
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow"></Text>
{orderRows.length > 0 ? (
<DataTable columns={orderColumns} rows={orderRows} />
) : (
<Text color="gray"></Text>
)}
</Box>
<Box flexDirection="column">
<Text color="yellow"></Text>
{lastLogs.length > 0 ? (
lastLogs.map((item, index) => (
<Text key={`${item.time}-${index}`}>
[{item.time}] [{item.type}] {item.detail}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
);
}
+64
View File
@@ -0,0 +1,64 @@
import React from "react";
import { Box, Text } from "ink";
type Align = "left" | "right";
export interface TableColumn {
key: string;
header: string;
align?: Align;
minWidth?: number;
}
export interface DataTableProps<Row extends Record<string, unknown>> {
columns: TableColumn[];
rows: Row[];
}
function formatCell(value: unknown): string {
if (value == null) return "";
if (typeof value === "number") {
if (Number.isInteger(value)) return value.toString();
return value.toFixed(4).replace(/\.0+$/, ".0");
}
return String(value);
}
function pad(text: string, width: number, align: Align): string {
if (text.length >= width) return text;
const padding = " ".repeat(width - text.length);
return align === "right" ? padding + text : text + padding;
}
export function DataTable<Row extends Record<string, unknown>>({ columns, rows }: DataTableProps<Row>) {
const widths = columns.map((col) => {
const headerLength = col.header.length;
const minWidth = col.minWidth ?? 0;
const contentLength = rows.reduce((max, row) => {
const cell = formatCell(row[col.key]);
return Math.max(max, cell.length);
}, 0);
return Math.max(headerLength, contentLength, minWidth);
});
return (
<Box flexDirection="column">
<Text>
{columns
.map((col, index) => pad(col.header, widths[index], col.align ?? "left"))
.join(" ")}
</Text>
{rows.map((row, rowIndex) => (
<Text key={rowIndex}>
{columns
.map((col, index) => {
const align = col.align ?? "left";
const cell = formatCell(row[col.key]);
return pad(cell, widths[index], align);
})
.join(" ")}
</Text>
))}
</Box>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function formatNumber(value: number | null | undefined, digits = 4, fallback = "-"): string {
if (value == null || Number.isNaN(value)) return fallback;
return Number(value).toFixed(digits);
}
export function formatTrendLabel(trend: "做多" | "做空" | "无信号"): string {
return trend;
}
+11
View File
@@ -0,0 +1,11 @@
export function toPrice1Decimal(price: number): number {
return Math.floor(price * 10) / 10;
}
export function toQty3Decimal(qty: number): number {
return Math.floor(qty * 1000) / 1000;
}
export function isNearlyZero(value: number, epsilon = 1e-5): boolean {
return Math.abs(value) < epsilon;
}
+43
View File
@@ -0,0 +1,43 @@
import { AsterAccountSnapshot, AsterKline } from "../exchanges/types";
export interface PositionSnapshot {
positionAmt: number;
entryPrice: number;
unrealizedProfit: number;
}
export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: string): PositionSnapshot {
if (!snapshot) {
return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 };
}
const pos = snapshot.positions?.find((p) => p.symbol === symbol);
if (!pos) {
return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 };
}
return {
positionAmt: Number(pos.positionAmt),
entryPrice: Number(pos.entryPrice),
unrealizedProfit: Number(pos.unrealizedProfit),
};
}
export function getSMA(values: AsterKline[], length: number): number | null {
if (!values || values.length < length) return null;
const closes = values.slice(-length).map((k) => Number(k.close));
const sum = closes.reduce((acc, current) => acc + current, 0);
return sum / closes.length;
}
export function calcStopLossPrice(entryPrice: number, qty: number, side: "long" | "short", loss: number): number {
if (side === "long") {
return entryPrice - loss / qty;
}
return entryPrice + loss / Math.abs(qty);
}
export function calcTrailingActivationPrice(entryPrice: number, qty: number, side: "long" | "short", profit: number): number {
if (side === "long") {
return entryPrice + profit / qty;
}
return entryPrice - profit / Math.abs(qty);
}