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