From 5c1283fac2aaf3da170ae3eaf97ee7a661756fac Mon Sep 17 00:00:00 2001 From: discountry Date: Sun, 28 Sep 2025 01:26:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A1=8C=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90=E5=92=8C=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=90=AF=E5=8A=A8=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=9D=99=E9=BB=98=E6=A8=A1=E5=BC=8F=E5=92=8C=E5=B8=AE?= =?UTF-8?q?=E5=8A=A9=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 8 +- src/cli/args.ts | 62 +++++++++++++ src/cli/strategy-runner.ts | 186 +++++++++++++++++++++++++++++++++++++ src/index.tsx | 19 +++- 4 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 src/cli/args.ts create mode 100644 src/cli/strategy-runner.ts diff --git a/package.json b/package.json index 659f782..eef18f8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,13 @@ "dev": "bun run index.ts", "start": "bun run index.ts", "test": "bun x vitest run", - "test:watch": "bun x vitest" + "test:watch": "bun x vitest", + "start:trend:silent": "bun run index.ts --strategy trend --silent", + "start:maker:silent": "bun run index.ts --strategy maker --silent", + "start:offset:silent": "bun run index.ts --strategy offset-maker --silent", + "pm2:start:trend": "pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent", + "pm2:start:maker": "pm2 start bun --name ritmex-maker --cwd . --restart-delay 5000 -- run index.ts --strategy maker --silent", + "pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..17347af --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,62 @@ +export type StrategyId = "trend" | "maker" | "offset-maker"; + +export interface CliOptions { + strategy?: StrategyId; + silent: boolean; + help: boolean; +} + +const STRATEGY_VALUES = new Set(["trend", "maker", "offset-maker"]); + +export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions { + const options: CliOptions = { silent: false, help: false }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg) continue; + + if (arg === "--silent" || arg === "-q" || arg === "--quiet") { + options.silent = true; + continue; + } + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg.startsWith("--strategy=")) { + const value = arg.split("=", 2)[1] ?? ""; + assignStrategy(options, value); + continue; + } + if (arg === "--strategy" || arg === "-s") { + const value = argv[i + 1]; + if (value) { + assignStrategy(options, value); + i += 1; + } + continue; + } + } + + return options; +} + +function assignStrategy(options: CliOptions, raw: string): void { + const normalized = raw.trim().toLowerCase(); + if (!normalized) return; + if (STRATEGY_VALUES.has(normalized as StrategyId)) { + options.strategy = normalized as StrategyId; + } else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") { + options.strategy = "offset-maker"; + } +} + +export function printCliHelp(): void { + // eslint-disable-next-line no-console + console.log(`Usage: bun run index.ts [--strategy ] [--silent]\n\n` + + `Options:\n` + + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + + ` Aliases: offset, offset-maker for the offset maker engine.\n` + + ` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` + + ` --help, -h Show this help message.\n`); +} diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts new file mode 100644 index 0000000..9174d6f --- /dev/null +++ b/src/cli/strategy-runner.ts @@ -0,0 +1,186 @@ +import { makerConfig, tradingConfig } from "../config"; +import { + createExchangeAdapter, + getExchangeDisplayName, + resolveExchangeId, +} from "../exchanges/create-adapter"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import { + MakerEngine, + type MakerEngineSnapshot, +} from "../strategy/maker-engine"; +import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; +import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; +import { extractMessage } from "../utils/errors"; +import type { StrategyId } from "./args"; + +interface RunnerOptions { + silent?: boolean; +} + +type StrategyRunner = (options: RunnerOptions) => Promise; + +export const STRATEGY_LABELS: Record = { + trend: "Trend Following", + maker: "Maker", + "offset-maker": "Offset Maker", +}; + +export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise { + const runner = STRATEGY_FACTORIES[strategyId]; + if (!runner) { + throw new Error(`Unsupported strategy: ${strategyId}`); + } + await runner(options); +} + +const STRATEGY_FACTORIES: Record = { + trend: async (opts) => { + const config = tradingConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new TrendEngine(config, adapter); + await runEngine({ + engine, + strategy: "trend", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, + maker: async (opts) => { + const config = makerConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new MakerEngine(config, adapter); + await runEngine({ + engine, + strategy: "maker", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, + "offset-maker": async (opts) => { + const config = makerConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new OffsetMakerEngine(config, adapter); + await runEngine({ + engine, + strategy: "offset-maker", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, +}; + +interface EngineHarness { + engine: { start(): void; stop(): void }; + strategy: StrategyId; + silent?: boolean; + getSnapshot: () => TSnapshot; + onUpdate: (handler: (snapshot: TSnapshot) => void) => void; + offUpdate: (handler: (snapshot: TSnapshot) => void) => void; +} + +async function runEngine( + harness: EngineHarness +): Promise { + const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness; + const exchangeId = resolveExchangeId(); + const exchangeName = getExchangeDisplayName(exchangeId); + const label = STRATEGY_LABELS[strategy]; + + const initial = getSnapshot(); + let lastLogKey: string | undefined; + if (Array.isArray(initial.tradeLog) && initial.tradeLog.length > 0) { + const lastEntry = initial.tradeLog[initial.tradeLog.length - 1]!; + lastLogKey = createLogKey(lastEntry); + } + let readyLogged = initial.ready === true; + + const emitter = (snapshot: TSnapshot) => { + if (!Array.isArray(snapshot.tradeLog)) return; + if (!readyLogged && snapshot.ready) { + readyLogged = true; + console.info(`[${label}] Strategy ready. Listening for market data…`); + } + const pending = diffTradeLog(snapshot.tradeLog, lastLogKey); + if (!pending.length) return; + for (const entry of pending) { + console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`); + } + const lastEntry = pending[pending.length - 1]!; + if (lastEntry) { + lastLogKey = createLogKey(lastEntry); + } + }; + + onUpdate(emitter); + engine.start(); + + console.info(`[${label}] Starting on ${exchangeName}. Mode: ${silent ? "silent" : "interactive"}. Press Ctrl+C to exit.`); + + const shutdown = (signal: NodeJS.Signals) => { + try { + console.info(`[${label}] Received ${signal}. Shutting down…`); + engine.stop(); + offUpdate(emitter); + } catch (error) { + console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`); + } + }; + + await new Promise((resolve) => { + const wrapper = (signal: NodeJS.Signals) => { + shutdown(signal); + process.off("SIGINT", wrapper); + process.off("SIGTERM", wrapper); + resolve(); + }; + + process.on("SIGINT", wrapper); + process.on("SIGTERM", wrapper); + }); +} + +function createAdapterOrThrow(symbol: string): ExchangeAdapter { + const exchangeId = resolveExchangeId(); + if (exchangeId === "aster") { + const apiKey = process.env.ASTER_API_KEY; + const apiSecret = process.env.ASTER_API_SECRET; + if (!apiKey || !apiSecret) { + throw new Error("Missing ASTER_API_KEY or ASTER_API_SECRET environment variables"); + } + return createExchangeAdapter({ + exchange: exchangeId, + symbol, + aster: { apiKey, apiSecret }, + }); + } + + return createExchangeAdapter({ + exchange: exchangeId, + symbol, + grvt: { symbol }, + }); +} + +type TradeLogEntry = { time: string; type: string; detail: string }; + +function diffTradeLog(tradeLog: TradeLogEntry[], lastKey: string | undefined): TradeLogEntry[] { + if (!tradeLog.length) return []; + if (!lastKey) return tradeLog; + const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey); + if (lastIndex === -1) { + return tradeLog; + } + if (lastIndex === tradeLog.length - 1) return []; + return tradeLog.slice(lastIndex + 1); +} + +function createLogKey(entry: TradeLogEntry): string { + return `${entry.time}|${entry.type}|${entry.detail}`; +} diff --git a/src/index.tsx b/src/index.tsx index a8c52f6..84f3812 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,24 @@ import React from "react"; import { render } from "ink"; import { App } from "./ui/App"; import { setupGlobalErrorHandlers } from "./runtime-errors"; +import { parseCliArgs, printCliHelp } from "./cli/args"; +import { startStrategy } from "./cli/strategy-runner"; setupGlobalErrorHandlers(); +const options = parseCliArgs(); -render(); +if (options.help) { + printCliHelp(); + process.exit(0); +} + +if (options.strategy) { + startStrategy(options.strategy, { silent: options.silent }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[Strategy] Failed to start: ${message}`); + process.exit(1); + }); +} else { + render(); +}