mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 16:58:08 +00:00
init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user