From f3ca476a95abeb46ed76c06f7a17e7e4c5e6aec8 Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:47:30 +0900 Subject: [PATCH] feat: scale-in, trailing stop, pnl tracker, backtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `trade scale-in`: place multiple limit orders at different price levels for gradual position building (분할매수) - Add `trade trailing-stop`: client-side trailing stop with --trail pct, --activation price, and --background mode - Add `trade pnl-track`: live terminal dashboard for position PnL - Add `backtest funding-arb`: simulate funding rate arb on historical data - Add `backtest grid`: simulate grid trading on historical klines - Add trailing-stop strategy module and run subcommand for background mode - Bump version to 0.2.2 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/commands/backtest.ts | 367 ++++++++++++++++++++++++++++++++ src/commands/run.ts | 38 ++++ src/commands/trade.ts | 333 +++++++++++++++++++++++++++++ src/index.ts | 2 + src/strategies/trailing-stop.ts | 155 ++++++++++++++ 6 files changed, 896 insertions(+), 1 deletion(-) create mode 100644 src/commands/backtest.ts create mode 100644 src/strategies/trailing-stop.ts diff --git a/package.json b/package.json index 723a9f7..0a68100 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perp-cli", - "version": "0.2.1", + "version": "0.2.2", "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter", "bin": { "perp": "./dist/index.js" diff --git a/src/commands/backtest.ts b/src/commands/backtest.ts new file mode 100644 index 0000000..262d540 --- /dev/null +++ b/src/commands/backtest.ts @@ -0,0 +1,367 @@ +import { Command } from "commander"; +import { printJson, jsonOk, jsonError, makeTable, formatUsd } from "../utils.js"; +import { getHistoricalRates } from "../funding-history.js"; +import chalk from "chalk"; + +export function registerBacktestCommands( + program: Command, + isJson: () => boolean, +) { + const backtest = program.command("backtest").description("Backtest trading strategies on historical data"); + + // ── Funding Arbitrage Backtest ── + + backtest + .command("funding-arb") + .description("Backtest funding rate arbitrage strategy") + .requiredOption("--symbol ", "Symbol to backtest (e.g., BTC)") + .option("--days ", "Number of days to backtest", "30") + .option("--spread-entry ", "Annual spread % to enter (default: 10)", "10") + .option("--spread-close ", "Annual spread % to close (default: 5)", "5") + .option("--exchanges ", "Comma-separated exchange pair (e.g., hyperliquid,pacifica)", "hyperliquid,pacifica") + .option("--size-usd ", "Position size in USD per leg", "1000") + .action(async (opts: { + symbol: string; + days: string; + spreadEntry: string; + spreadClose: string; + exchanges: string; + sizeUsd: string; + }) => { + const sym = opts.symbol.toUpperCase(); + const days = parseInt(opts.days); + const spreadEntry = parseFloat(opts.spreadEntry); + const spreadClose = parseFloat(opts.spreadClose); + const sizeUsd = parseFloat(opts.sizeUsd); + const [exchA, exchB] = opts.exchanges.split(",").map(e => e.trim().toLowerCase()); + + if (!exchA || !exchB) { + if (isJson()) return printJson(jsonError("INVALID_PARAMS", "Need exactly 2 exchanges (e.g., --exchanges hyperliquid,pacifica)")); + console.error(chalk.red("Error: Need exactly 2 exchanges (e.g., --exchanges hyperliquid,pacifica)")); + process.exit(1); + } + + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - days * 24 * 60 * 60 * 1000); + + // Get historical funding data + const ratesA = getHistoricalRates(sym, exchA, startTime, endTime); + const ratesB = getHistoricalRates(sym, exchB, startTime, endTime); + + if (ratesA.length === 0 && ratesB.length === 0) { + if (isJson()) return printJson(jsonError("NO_DATA", `No historical funding data for ${sym}. Run 'perp funding snapshot' first to collect data.`)); + console.log(chalk.yellow(`\n No historical funding data for ${sym} on ${exchA}/${exchB}.`)); + console.log(chalk.yellow(` Run 'perp funding snapshot' periodically to collect data first.\n`)); + return; + } + + // Build time-aligned rate pairs + const rateMap = new Map(); + + for (const r of ratesA) { + // Round timestamp to nearest hour for alignment + const hourKey = new Date(r.ts).toISOString().slice(0, 13); + if (!rateMap.has(hourKey)) rateMap.set(hourKey, {}); + rateMap.get(hourKey)!.a = r.hourlyRate; + } + for (const r of ratesB) { + const hourKey = new Date(r.ts).toISOString().slice(0, 13); + if (!rateMap.has(hourKey)) rateMap.set(hourKey, {}); + rateMap.get(hourKey)!.b = r.hourlyRate; + } + + // Sort by time + const sortedKeys = [...rateMap.keys()].sort(); + + // Simulate + let inPosition = false; + let entrySpread = 0; + let entryTime = ""; + let totalTrades = 0; + let totalFundingCollected = 0; + let totalHoldingHours = 0; + const trades: Array<{ entryTime: string; exitTime: string; holdingHours: number; fundingCollected: number; spread: number }> = []; + + for (const key of sortedKeys) { + const pair = rateMap.get(key)!; + if (pair.a === undefined || pair.b === undefined) continue; + + // Annual spread = |rateA - rateB| * 8760 * 100 + const hourlySpread = Math.abs(pair.a - pair.b); + const annualSpreadPct = hourlySpread * 8760 * 100; + + if (!inPosition && annualSpreadPct >= spreadEntry) { + inPosition = true; + entrySpread = annualSpreadPct; + entryTime = key; + } else if (inPosition && annualSpreadPct < spreadClose) { + // Close position + const holdingHours = (new Date(key).getTime() - new Date(entryTime).getTime()) / (1000 * 60 * 60); + // Funding collected = sum of hourly spreads during holding period + let fundingCollected = 0; + for (const hk of sortedKeys) { + if (hk >= entryTime && hk <= key) { + const p = rateMap.get(hk)!; + if (p.a !== undefined && p.b !== undefined) { + fundingCollected += Math.abs(p.a - p.b) * sizeUsd; + } + } + } + + trades.push({ + entryTime, + exitTime: key, + holdingHours, + fundingCollected, + spread: entrySpread, + }); + totalTrades++; + totalFundingCollected += fundingCollected; + totalHoldingHours += holdingHours; + inPosition = false; + } + } + + // Summary + const avgHoldingHours = totalTrades > 0 ? totalHoldingHours / totalTrades : 0; + // Rough PnL estimate: funding collected minus estimated trading costs (0.1% per trade * 2 legs * 2 trades) + const tradingCosts = totalTrades * 2 * 2 * sizeUsd * 0.001; + const netPnl = totalFundingCollected - tradingCosts; + + const summary = { + symbol: sym, + exchanges: `${exchA} vs ${exchB}`, + period: `${days} days`, + dataPoints: sortedKeys.length, + spreadEntryThreshold: `${spreadEntry}%`, + spreadCloseThreshold: `${spreadClose}%`, + sizeUsd, + totalTrades, + avgHoldingPeriod: `${avgHoldingHours.toFixed(1)}h`, + totalFundingCollected: `$${totalFundingCollected.toFixed(2)}`, + tradingCosts: `$${tradingCosts.toFixed(2)}`, + netPnl: `$${netPnl.toFixed(2)}`, + trades, + }; + + if (isJson()) return printJson(jsonOk(summary)); + + console.log(chalk.cyan.bold(`\n Funding Arb Backtest — ${sym}\n`)); + console.log(` Exchanges: ${exchA} vs ${exchB}`); + console.log(` Period: ${days} days (${sortedKeys.length} data points)`); + console.log(` Entry spread: >= ${spreadEntry}% annualized`); + console.log(` Close spread: < ${spreadClose}% annualized`); + console.log(` Size per leg: $${formatUsd(String(sizeUsd))}`); + console.log(); + console.log(chalk.white.bold(` Results:`)); + console.log(` Total trades: ${totalTrades}`); + console.log(` Avg holding period: ${avgHoldingHours.toFixed(1)}h`); + console.log(` Funding collected: ${chalk.green(`$${totalFundingCollected.toFixed(2)}`)}`); + console.log(` Trading costs: ${chalk.red(`$${tradingCosts.toFixed(2)}`)}`); + const pnlColor = netPnl >= 0 ? chalk.green : chalk.red; + console.log(` Net PnL: ${pnlColor(`$${netPnl.toFixed(2)}`)}`); + + if (trades.length > 0) { + console.log(chalk.white.bold(`\n Trade History:`)); + const rows = trades.map((t, i) => [ + String(i + 1), + t.entryTime.replace("T", " "), + t.exitTime.replace("T", " "), + `${t.holdingHours.toFixed(1)}h`, + `${t.spread.toFixed(1)}%`, + `$${t.fundingCollected.toFixed(2)}`, + ]); + console.log(makeTable(["#", "Entry", "Exit", "Duration", "Spread", "Funding"], rows)); + } + console.log(); + }); + + // ── Grid Backtest ── + + backtest + .command("grid") + .description("Backtest grid trading strategy on historical klines") + .requiredOption("--symbol ", "Symbol to backtest (e.g., ETH)") + .requiredOption("--upper ", "Upper price bound") + .requiredOption("--lower ", "Lower price bound") + .option("--grids ", "Number of grid lines", "10") + .option("--days ", "Number of days to backtest", "7") + .option("--size ", "Size per grid in base currency", "0.1") + .action(async (opts: { + symbol: string; + upper: string; + lower: string; + grids: string; + days: string; + size: string; + }) => { + const sym = opts.symbol.toUpperCase(); + const upperPrice = parseFloat(opts.upper); + const lowerPrice = parseFloat(opts.lower); + const grids = parseInt(opts.grids); + const days = parseInt(opts.days); + const sizePerGrid = parseFloat(opts.size); + + if (upperPrice <= lowerPrice) { + if (isJson()) return printJson(jsonError("INVALID_PARAMS", "Upper price must be greater than lower price")); + console.error(chalk.red("Error: Upper price must be greater than lower price")); + process.exit(1); + } + + if (grids < 2) { + if (isJson()) return printJson(jsonError("INVALID_PARAMS", "Need at least 2 grid lines")); + console.error(chalk.red("Error: Need at least 2 grid lines")); + process.exit(1); + } + + const endTime = Date.now(); + const startTime = endTime - days * 24 * 60 * 60 * 1000; + + // Fetch historical klines from Hyperliquid + if (!isJson()) { + console.log(chalk.gray(`\n Fetching ${days}d of 1h klines for ${sym}...`)); + } + + let klines: Array<{ t: number; o: string; h: string; l: string; c: string }>; + try { + const resp = await fetch("https://api.hyperliquid.xyz/info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "candleSnapshot", + req: { + coin: sym, + interval: "1h", + startTime, + endTime, + }, + }), + }); + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json() as Array<{ t: number; o: string; h: string; l: string; c: string; v: string; n: number }>; + klines = data.map(k => ({ t: k.t, o: k.o, h: k.h, l: k.l, c: k.c })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (isJson()) return printJson(jsonError("FETCH_ERROR", `Failed to fetch klines: ${msg}`)); + console.error(chalk.red(`Error fetching klines: ${msg}`)); + process.exit(1); + } + + if (klines.length === 0) { + if (isJson()) return printJson(jsonError("NO_DATA", `No kline data for ${sym}`)); + console.log(chalk.yellow(`\n No kline data available for ${sym}.\n`)); + return; + } + + // Build grid levels + const step = (upperPrice - lowerPrice) / (grids - 1); + const gridLevels: number[] = []; + for (let i = 0; i < grids; i++) { + gridLevels.push(lowerPrice + step * i); + } + + // Simulate grid fills + // Track which grid levels have pending orders (buy below current, sell above) + let totalTrades = 0; + let totalProfit = 0; + let maxDrawdown = 0; + let currentDrawdown = 0; + let peakProfit = 0; + const filledBuys = new Set(); // grid indices that have been bought + + // Initialize: determine initial grid state based on first kline + const firstPrice = parseFloat(klines[0].c); + for (let i = 0; i < gridLevels.length; i++) { + if (gridLevels[i] < firstPrice) { + // Below current price: place buy orders (unfilled) + } else { + // Above current price: assume we've "bought" these to sell + filledBuys.add(i); + } + } + + for (const kline of klines) { + const low = parseFloat(kline.l); + const high = parseFloat(kline.h); + + // Check buy fills (price dipped to grid level) + for (let i = 0; i < gridLevels.length; i++) { + if (!filledBuys.has(i) && low <= gridLevels[i]) { + filledBuys.add(i); + totalTrades++; + // Bought at grid level + } + } + + // Check sell fills (price rose to grid level) + for (let i = 0; i < gridLevels.length; i++) { + if (filledBuys.has(i) && high >= gridLevels[i] && i > 0) { + // Check if there's a higher grid to sell at + const sellIdx = i; + // Find next unfilled buy below to pair with + for (let j = sellIdx - 1; j >= 0; j--) { + if (filledBuys.has(j)) continue; + // Grid profit = sell level - buy level + break; + } + // Simple model: profit is one step worth + if (filledBuys.has(i)) { + filledBuys.delete(i); + totalTrades++; + totalProfit += step * sizePerGrid; + } + } + } + + // Track drawdown + if (totalProfit > peakProfit) peakProfit = totalProfit; + currentDrawdown = peakProfit - totalProfit; + if (currentDrawdown > maxDrawdown) maxDrawdown = currentDrawdown; + } + + // Calculate some stats + const lastPrice = parseFloat(klines[klines.length - 1].c); + const priceRange = `$${formatUsd(String(Math.min(...klines.map(k => parseFloat(k.l)))))} - $${formatUsd(String(Math.max(...klines.map(k => parseFloat(k.h)))))}`; + const tradingFees = totalTrades * sizePerGrid * lastPrice * 0.00035; // ~0.035% per trade + const netProfit = totalProfit - tradingFees; + + const summary = { + symbol: sym, + period: `${days} days`, + klines: klines.length, + priceRange, + gridRange: `$${formatUsd(String(lowerPrice))} - $${formatUsd(String(upperPrice))}`, + grids, + step: `$${step.toFixed(2)}`, + sizePerGrid, + totalTrades, + grossProfit: `$${totalProfit.toFixed(2)}`, + tradingFees: `$${tradingFees.toFixed(2)}`, + netProfit: `$${netProfit.toFixed(2)}`, + maxDrawdown: `$${maxDrawdown.toFixed(2)}`, + profitPerTrade: totalTrades > 0 ? `$${(netProfit / totalTrades).toFixed(2)}` : "$0.00", + }; + + if (isJson()) return printJson(jsonOk(summary)); + + console.log(chalk.cyan.bold(`\n Grid Backtest — ${sym}\n`)); + console.log(` Period: ${days} days (${klines.length} candles)`); + console.log(` Price range: ${priceRange}`); + console.log(` Grid range: $${formatUsd(String(lowerPrice))} - $${formatUsd(String(upperPrice))}`); + console.log(` Grid lines: ${grids} (step: $${step.toFixed(2)})`); + console.log(` Size per grid: ${sizePerGrid}`); + console.log(); + console.log(chalk.white.bold(` Results:`)); + console.log(` Total trades: ${totalTrades}`); + console.log(` Gross profit: ${chalk.green(`$${totalProfit.toFixed(2)}`)}`); + console.log(` Trading fees: ${chalk.red(`$${tradingFees.toFixed(2)}`)}`); + const pnlColor = netProfit >= 0 ? chalk.green : chalk.red; + console.log(` Net profit: ${pnlColor(`$${netProfit.toFixed(2)}`)}`); + console.log(` Max drawdown: ${chalk.red(`$${maxDrawdown.toFixed(2)}`)}`); + if (totalTrades > 0) { + console.log(` Avg profit/trade: $${(netProfit / totalTrades).toFixed(2)}`); + } + console.log(); + }); +} diff --git a/src/commands/run.ts b/src/commands/run.ts index 0cf8d4b..77324aa 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -6,6 +6,7 @@ import { runTWAP } from "../strategies/twap.js"; import { runFundingArb } from "../strategies/funding-arb.js"; import { runGrid } from "../strategies/grid.js"; import { runDCA } from "../strategies/dca.js"; +import { runTrailingStop } from "../strategies/trailing-stop.js"; import chalk from "chalk"; export function registerRunCommands( @@ -211,6 +212,43 @@ export function registerRunCommands( const result = await runDCA(adapter, params, opts.jobId, log); if (isJson()) printJson(jsonOk(result)); + if (opts.jobId) { + updateJobState(opts.jobId, { status: "done" }); + } + }); + + // ── Trailing Stop ── + + run + .command("trailing-stop ") + .description("Run client-side trailing stop (monitors price, closes position when trail % hit)") + .requiredOption("--trail ", "Trail percentage") + .option("--interval ", "Check interval in seconds", "5") + .option("--activation ", "Only start trailing after price reaches this level") + .option("--job-id ", "Job ID (set automatically for background jobs)") + .action(async (symbol: string, opts: { + trail: string; interval: string; activation?: string; jobId?: string; + }) => { + const sym = symbol.toUpperCase(); + const trailPct = parseFloat(opts.trail); + const intervalSec = parseInt(opts.interval); + const activationPrice = opts.activation ? parseFloat(opts.activation) : undefined; + + const adapter = await getAdapter(); + const log = (msg: string) => { + const ts = new Date().toLocaleTimeString(); + console.log(`${chalk.gray(ts)} ${msg}`); + }; + + const result = await runTrailingStop(adapter, { + symbol: sym, + trailPct, + intervalSec, + activationPrice, + }, opts.jobId, log); + + if (isJson()) printJson(jsonOk(result)); + if (opts.jobId) { updateJobState(opts.jobId, { status: "done" }); } diff --git a/src/commands/trade.ts b/src/commands/trade.ts index 23a6aef..2172976 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -1045,4 +1045,337 @@ export function registerTradeCommands( }); }); + // ── Scale-In (분할매수) ── + + trade + .command("scale-in ") + .description("Place multiple limit orders at different price levels to build a position gradually (분할매수)") + .requiredOption("--levels ", "Comma-separated price:percent pairs (e.g., 65000:30,63000:30,60000:40)") + .option("--size-usd ", "Total USD amount to deploy across all levels") + .option("--size ", "Total base amount (e.g., 0.01 BTC)") + .action(async (symbol: string, side: string, opts: { levels: string; sizeUsd?: string; size?: string }) => { + const sym = symbol.toUpperCase(); + const s = side.toLowerCase(); + if (s !== "buy" && s !== "sell") errorAndExit("Side must be buy or sell"); + if (!opts.sizeUsd && !opts.size) errorAndExit("Must specify --size-usd or --size"); + + // Parse levels: "65000:30,63000:30,60000:40" + const levels = opts.levels.split(",").map(l => { + const [price, pct] = l.trim().split(":"); + if (!price || !pct) errorAndExit(`Invalid level format: ${l}. Use price:percent (e.g., 65000:30)`); + return { price: price.trim(), pct: parseFloat(pct.trim()) }; + }); + + // Validate percentages sum to 100 + const totalPct = levels.reduce((sum, l) => sum + l.pct, 0); + if (Math.abs(totalPct - 100) > 0.01) { + errorAndExit(`Percentages must sum to 100%. Got: ${totalPct}%`); + } + + const adapter = await getAdapter(); + + // Compute sizes for each level + let levelSizes: { price: string; size: string; pct: number }[]; + if (opts.sizeUsd) { + const totalUsd = parseFloat(opts.sizeUsd); + levelSizes = levels.map(l => ({ + price: l.price, + pct: l.pct, + size: (totalUsd * l.pct / 100 / parseFloat(l.price)).toString(), + })); + } else { + const totalBase = parseFloat(opts.size!); + levelSizes = levels.map(l => ({ + price: l.price, + pct: l.pct, + size: (totalBase * l.pct / 100).toString(), + })); + } + + if (dryRunGuard("scale_in", { + exchange: adapter.name, symbol: sym, side: s, + totalSizeUsd: opts.sizeUsd ?? "N/A", + totalSizeBase: opts.size ?? "N/A", + levels: levelSizes.map(l => `${l.price}@${l.pct}% (${l.size})`).join(", "), + })) return; + + // Place limit orders at each level (NOT reduce-only — opening positions) + const results: Array<{ price: string; size: string; pct: number; result: unknown }> = []; + for (const level of levelSizes) { + try { + const result = await adapter.limitOrder(sym, s as "buy" | "sell", level.price, level.size); + results.push({ price: level.price, size: level.size, pct: level.pct, result }); + logExecution({ + type: "limit_order", exchange: adapter.name, symbol: sym, + side: s, size: level.size, price: level.price, + status: "success", dryRun: false, + meta: { action: "scale-in", pct: level.pct }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logExecution({ + type: "limit_order", exchange: adapter.name, symbol: sym, + side: s, size: level.size, price: level.price, + status: "failed", dryRun: false, error: msg, + meta: { action: "scale-in", pct: level.pct }, + }); + if (isJson()) { + results.push({ price: level.price, size: level.size, pct: level.pct, result: { error: msg } }); + } else { + console.log(chalk.red(` Failed: ${level.price} x ${level.size} — ${msg}`)); + } + } + } + + if (isJson()) return printJson(jsonOk({ symbol: sym, side: s, levels: results })); + + console.log(chalk.green(`\n Scale-in orders placed for ${sym} on ${adapter.name}:\n`)); + for (const r of results) { + const status = (r.result as Record)?.error ? chalk.red("FAILED") : chalk.green("OK"); + console.log(` ${status} $${r.price} — ${r.pct}% (${r.size} ${sym})`); + } + console.log(); + }); + + // ── Trailing Stop ── + + trade + .command("trailing-stop ") + .description("Client-side trailing stop that monitors price and closes position when price drops by X% from peak") + .requiredOption("--trail ", "Trail percentage (e.g., 3 = close when price drops 3% from high)") + .option("--interval ", "Check interval in seconds", "5") + .option("--activation ", "Only start trailing after price reaches this level") + .option("--background", "Run in background (tmux)") + .action(async (symbol: string, opts: { + trail: string; interval: string; activation?: string; background?: boolean; + }) => { + const sym = symbol.toUpperCase(); + const trailPct = parseFloat(opts.trail); + const intervalSec = parseInt(opts.interval); + const activationPrice = opts.activation ? parseFloat(opts.activation) : undefined; + + if (isNaN(trailPct) || trailPct <= 0) errorAndExit("Trail percentage must be > 0"); + + const exchange = (await getAdapter()).name; + + // --background → run via tmux + if (opts.background) { + const { startJob } = await import("../jobs.js"); + const cliArgs = [ + `-e`, exchange, sym, + `--trail`, opts.trail, + `--interval`, opts.interval, + ...(opts.activation ? [`--activation`, opts.activation] : []), + ]; + const job = startJob({ + strategy: "trailing-stop", + exchange, + params: { symbol: sym, trail: trailPct, interval: intervalSec, activation: activationPrice }, + cliArgs, + }); + if (isJson()) return printJson(jsonOk(job)); + console.log(chalk.green(`\n Trailing stop started in background.`)); + console.log(` ID: ${chalk.white.bold(job.id)}`); + console.log(` Trail: ${trailPct}%${activationPrice ? ` | Activation: $${activationPrice}` : ""}`); + console.log(` Logs: ${chalk.gray(`perp jobs logs ${job.id}`)}`); + console.log(` Stop: ${chalk.gray(`perp jobs stop ${job.id}`)}\n`); + return; + } + + // Foreground: run trailing stop loop + const adapter = await getAdapter(); + + // Auto-detect position side + const positions = await adapter.getPositions(); + const pos = positions.find(p => symbolMatch(p.symbol, sym)); + if (!pos) errorAndExit(`No open position for ${sym}. Open a position first.`); + + const positionSide = pos.side; // "long" or "short" + const closeSide: "buy" | "sell" = positionSide === "long" ? "sell" : "buy"; + const posSize = pos.size; + + console.log(chalk.cyan(`\n Trailing Stop for ${sym} (${positionSide} ${posSize})`)); + console.log(chalk.cyan(` Trail: ${trailPct}% | Interval: ${intervalSec}s${activationPrice ? ` | Activation: $${activationPrice}` : ""}`)); + console.log(chalk.gray(` Press Ctrl+C to cancel.\n`)); + + let peakPrice = 0; + let activated = !activationPrice; // if no activation price, start immediately + let running = true; + + const cleanup = () => { running = false; }; + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + try { + while (running) { + const markets = await adapter.getMarkets(); + const market = markets.find(m => symbolMatch(m.symbol, sym)); + if (!market) { + console.log(chalk.yellow(` Market data for ${sym} not found, retrying...`)); + await new Promise(r => setTimeout(r, intervalSec * 1000)); + continue; + } + + const currentPrice = parseFloat(market.markPrice); + + // Check activation + if (!activated && activationPrice) { + if (positionSide === "long" && currentPrice >= activationPrice) { + activated = true; + console.log(chalk.green(` Activated at $${currentPrice.toFixed(2)} (>= $${activationPrice})`)); + } else if (positionSide === "short" && currentPrice <= activationPrice) { + activated = true; + console.log(chalk.green(` Activated at $${currentPrice.toFixed(2)} (<= $${activationPrice})`)); + } else { + const ts = new Date().toLocaleTimeString(); + console.log(chalk.gray(` ${ts} | $${currentPrice.toFixed(2)} | Waiting for activation ($${activationPrice})...`)); + await new Promise(r => setTimeout(r, intervalSec * 1000)); + continue; + } + } + + // Update peak + if (positionSide === "long") { + if (currentPrice > peakPrice) peakPrice = currentPrice; + const dropPct = ((peakPrice - currentPrice) / peakPrice) * 100; + const ts = new Date().toLocaleTimeString(); + console.log(chalk.gray(` ${ts} | Price: $${currentPrice.toFixed(2)} | Peak: $${peakPrice.toFixed(2)} | Drop: ${dropPct.toFixed(2)}%`)); + + if (dropPct >= trailPct) { + console.log(chalk.red(`\n TRAILING STOP TRIGGERED! Price dropped ${dropPct.toFixed(2)}% from peak $${peakPrice.toFixed(2)}`)); + console.log(chalk.red(` Closing ${positionSide} ${posSize} ${sym}...\n`)); + const result = await adapter.marketOrder(sym, closeSide, posSize); + logExecution({ + type: "market_order", exchange: adapter.name, symbol: sym, + side: closeSide, size: posSize, status: "success", dryRun: false, + meta: { action: "trailing-stop", trailPct, peakPrice, triggerPrice: currentPrice }, + }); + if (isJson()) return printJson(jsonOk({ triggered: true, peakPrice, triggerPrice: currentPrice, dropPct, result })); + console.log(chalk.green(` Position closed. Peak: $${peakPrice.toFixed(2)}, Exit: $${currentPrice.toFixed(2)}\n`)); + return; + } + } else { + // Short position: track lowest price, trigger when price rises + if (peakPrice === 0 || currentPrice < peakPrice) peakPrice = currentPrice; + const risePct = ((currentPrice - peakPrice) / peakPrice) * 100; + const ts = new Date().toLocaleTimeString(); + console.log(chalk.gray(` ${ts} | Price: $${currentPrice.toFixed(2)} | Trough: $${peakPrice.toFixed(2)} | Rise: ${risePct.toFixed(2)}%`)); + + if (risePct >= trailPct) { + console.log(chalk.red(`\n TRAILING STOP TRIGGERED! Price rose ${risePct.toFixed(2)}% from trough $${peakPrice.toFixed(2)}`)); + console.log(chalk.red(` Closing ${positionSide} ${posSize} ${sym}...\n`)); + const result = await adapter.marketOrder(sym, closeSide, posSize); + logExecution({ + type: "market_order", exchange: adapter.name, symbol: sym, + side: closeSide, size: posSize, status: "success", dryRun: false, + meta: { action: "trailing-stop", trailPct, troughPrice: peakPrice, triggerPrice: currentPrice }, + }); + if (isJson()) return printJson(jsonOk({ triggered: true, troughPrice: peakPrice, triggerPrice: currentPrice, risePct, result })); + console.log(chalk.green(` Position closed. Trough: $${peakPrice.toFixed(2)}, Exit: $${currentPrice.toFixed(2)}\n`)); + return; + } + } + + await new Promise(r => setTimeout(r, intervalSec * 1000)); + } + } finally { + process.removeListener("SIGINT", cleanup); + process.removeListener("SIGTERM", cleanup); + } + + if (isJson()) return printJson(jsonOk({ triggered: false, reason: "cancelled" })); + console.log(chalk.yellow(`\n Trailing stop cancelled.\n`)); + }); + + // ── PnL Tracker ── + + trade + .command("pnl-track") + .description("Live-monitor positions with real-time PnL updates") + .option("--interval ", "Refresh interval in seconds", "3") + .option("--symbol ", "Filter to a specific symbol") + .action(async (opts: { interval: string; symbol?: string }) => { + const intervalSec = parseInt(opts.interval); + const filterSym = opts.symbol?.toUpperCase(); + + const adapter = await getAdapter(); + + console.log(chalk.cyan(`\n PnL Tracker | ${adapter.name} | Interval: ${intervalSec}s`)); + if (filterSym) console.log(chalk.cyan(` Filtering: ${filterSym}`)); + console.log(chalk.gray(` Press Ctrl+C to stop.\n`)); + + let running = true; + const cleanup = () => { running = false; }; + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + try { + while (running) { + const [positions, balance] = await Promise.all([ + adapter.getPositions(), + adapter.getBalance(), + ]); + + let filtered = positions; + if (filterSym) { + filtered = positions.filter(p => symbolMatch(p.symbol, filterSym)); + } + + // Fetch funding payments (recent) for display + let fundingBySymbol: Record = {}; + try { + const payments = await adapter.getFundingPayments(50); + for (const fp of payments) { + const sym = fp.symbol.toUpperCase(); + fundingBySymbol[sym] = (fundingBySymbol[sym] || 0) + parseFloat(fp.payment); + } + } catch { + // funding payments may not be supported on all exchanges + } + + console.clear(); + console.log(chalk.cyan.bold(`\n PnL Tracker — ${adapter.name} | ${new Date().toLocaleTimeString()}\n`)); + console.log(` Equity: $${formatUsd(balance.equity)} | Available: $${formatUsd(balance.available)} | Margin: $${formatUsd(balance.marginUsed)} | uPnL: $${formatUsd(balance.unrealizedPnl)}\n`); + + if (filtered.length === 0) { + console.log(chalk.gray(` No open positions${filterSym ? ` for ${filterSym}` : ""}.`)); + } else { + const { makeTable } = await import("../utils.js"); + const rows = filtered.map(p => { + const entry = parseFloat(p.entryPrice); + const mark = parseFloat(p.markPrice); + const pnl = parseFloat(p.unrealizedPnl); + const notional = parseFloat(p.size) * entry; + const pnlPct = notional > 0 ? (pnl / notional) * 100 : 0; + const funding = fundingBySymbol[p.symbol.toUpperCase()] || 0; + const pnlColor = pnl >= 0 ? chalk.green : chalk.red; + const pctColor = pnlPct >= 0 ? chalk.green : chalk.red; + return [ + chalk.white.bold(p.symbol), + p.side === "long" ? chalk.green("LONG") : chalk.red("SHORT"), + p.size, + `$${formatUsd(p.entryPrice)}`, + `$${formatUsd(p.markPrice)}`, + pnlColor(`${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}`), + pctColor(`${pnlPct >= 0 ? "+" : ""}${pnlPct.toFixed(2)}%`), + funding !== 0 ? `$${funding.toFixed(4)}` : "-", + ]; + }); + console.log(makeTable( + ["Symbol", "Side", "Size", "Entry", "Mark", "PnL", "PnL%", "Funding"], + rows, + )); + } + + console.log(chalk.gray(`\n Refreshing every ${intervalSec}s... Press Ctrl+C to stop.`)); + await new Promise(r => setTimeout(r, intervalSec * 1000)); + } + } finally { + process.removeListener("SIGINT", cleanup); + process.removeListener("SIGTERM", cleanup); + } + + console.log(chalk.yellow(`\n PnL tracker stopped.\n`)); + }); + } diff --git a/src/index.ts b/src/index.ts index 2290112..c2fa6b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { registerSettingsCommands } from "./commands/settings.js"; import { registerDexCommands } from "./commands/dex.js"; import { registerPlanCommands } from "./commands/plan.js"; import { registerFundingCommands } from "./commands/funding.js"; +import { registerBacktestCommands } from "./commands/backtest.js"; import { loadSettings } from "./settings.js"; const program = new Command(); @@ -255,6 +256,7 @@ registerSettingsCommands(program, isJson); registerDexCommands(program, getAdapter, isJson); registerPlanCommands(program, getAdapter, isJson); registerFundingCommands(program, isJson); +registerBacktestCommands(program, isJson); // Agent discovery: perp api-spec — returns full CLI spec as JSON program diff --git a/src/strategies/trailing-stop.ts b/src/strategies/trailing-stop.ts new file mode 100644 index 0000000..b176786 --- /dev/null +++ b/src/strategies/trailing-stop.ts @@ -0,0 +1,155 @@ +import type { ExchangeAdapter } from "../exchanges/interface.js"; +import { updateJobState } from "../jobs.js"; +import { logExecution } from "../execution-log.js"; + +export interface TrailingStopParams { + symbol: string; + trailPct: number; // e.g. 3 = close when price moves 3% from peak/trough + intervalSec?: number; // check interval (default: 5) + activationPrice?: number; // only start trailing after reaching this price +} + +export interface TrailingStopResult { + triggered: boolean; + reason: "triggered" | "cancelled" | "no_position"; + peakPrice?: number; + triggerPrice?: number; + changePct?: number; + positionSide?: string; + runtime: number; // seconds +} + +export async function runTrailingStop( + adapter: ExchangeAdapter, + params: TrailingStopParams, + jobId?: string, + log: (msg: string) => void = console.log, +): Promise { + const { symbol, trailPct } = params; + const intervalMs = (params.intervalSec ?? 5) * 1000; + const activationPrice = params.activationPrice; + const startedAt = Date.now(); + + // Auto-detect position side + const positions = await adapter.getPositions(); + const pos = positions.find(p => { + const c = p.symbol.toUpperCase(); + const t = symbol.toUpperCase(); + return c === t || c === `${t}-PERP` || c.replace(/-PERP$/, "") === t; + }); + + if (!pos) { + log(`[TRAIL] No open position for ${symbol}. Exiting.`); + return { triggered: false, reason: "no_position", runtime: 0 }; + } + + const positionSide = pos.side; + const closeSide: "buy" | "sell" = positionSide === "long" ? "sell" : "buy"; + const posSize = pos.size; + + log(`[TRAIL] ${symbol} ${positionSide} ${posSize} | Trail: ${trailPct}%${activationPrice ? ` | Activation: $${activationPrice}` : ""}`); + + let peakPrice = 0; + let activated = !activationPrice; + let running = true; + + const cleanup = () => { running = false; }; + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + if (jobId) { + updateJobState(jobId, { + status: "running", + result: { symbol, positionSide, trailPct, activationPrice }, + }); + } + + try { + while (running) { + const markets = await adapter.getMarkets(); + const market = markets.find(m => { + const c = m.symbol.toUpperCase(); + const t = symbol.toUpperCase(); + return c === t || c === `${t}-PERP` || c.replace(/-PERP$/, "") === t; + }); + + if (!market) { + log(`[TRAIL] Market data for ${symbol} not found, retrying...`); + await new Promise(r => setTimeout(r, intervalMs)); + continue; + } + + const currentPrice = parseFloat(market.markPrice); + + // Check activation + if (!activated && activationPrice) { + if (positionSide === "long" && currentPrice >= activationPrice) { + activated = true; + log(`[TRAIL] Activated at $${currentPrice.toFixed(2)} (>= $${activationPrice})`); + } else if (positionSide === "short" && currentPrice <= activationPrice) { + activated = true; + log(`[TRAIL] Activated at $${currentPrice.toFixed(2)} (<= $${activationPrice})`); + } else { + log(`[TRAIL] $${currentPrice.toFixed(2)} | Waiting for activation ($${activationPrice})...`); + await new Promise(r => setTimeout(r, intervalMs)); + continue; + } + } + + if (positionSide === "long") { + if (currentPrice > peakPrice) peakPrice = currentPrice; + const dropPct = peakPrice > 0 ? ((peakPrice - currentPrice) / peakPrice) * 100 : 0; + log(`[TRAIL] $${currentPrice.toFixed(2)} | Peak: $${peakPrice.toFixed(2)} | Drop: ${dropPct.toFixed(2)}%`); + + if (dropPct >= trailPct) { + log(`[TRAIL] TRIGGERED! Price dropped ${dropPct.toFixed(2)}% from peak $${peakPrice.toFixed(2)}`); + log(`[TRAIL] Closing ${positionSide} ${posSize} ${symbol}...`); + await adapter.marketOrder(symbol, closeSide, posSize); + logExecution({ + type: "market_order", exchange: adapter.name, symbol, + side: closeSide, size: posSize, status: "success", dryRun: false, + meta: { action: "trailing-stop", trailPct, peakPrice, triggerPrice: currentPrice }, + }); + return { + triggered: true, reason: "triggered", + peakPrice, triggerPrice: currentPrice, changePct: dropPct, + positionSide, runtime: (Date.now() - startedAt) / 1000, + }; + } + } else { + // Short: track trough, trigger on rise + if (peakPrice === 0 || currentPrice < peakPrice) peakPrice = currentPrice; + const risePct = peakPrice > 0 ? ((currentPrice - peakPrice) / peakPrice) * 100 : 0; + log(`[TRAIL] $${currentPrice.toFixed(2)} | Trough: $${peakPrice.toFixed(2)} | Rise: ${risePct.toFixed(2)}%`); + + if (risePct >= trailPct) { + log(`[TRAIL] TRIGGERED! Price rose ${risePct.toFixed(2)}% from trough $${peakPrice.toFixed(2)}`); + log(`[TRAIL] Closing ${positionSide} ${posSize} ${symbol}...`); + await adapter.marketOrder(symbol, closeSide, posSize); + logExecution({ + type: "market_order", exchange: adapter.name, symbol, + side: closeSide, size: posSize, status: "success", dryRun: false, + meta: { action: "trailing-stop", trailPct, troughPrice: peakPrice, triggerPrice: currentPrice }, + }); + return { + triggered: true, reason: "triggered", + peakPrice, triggerPrice: currentPrice, changePct: risePct, + positionSide, runtime: (Date.now() - startedAt) / 1000, + }; + } + } + + await new Promise(r => setTimeout(r, intervalMs)); + } + } finally { + process.removeListener("SIGINT", cleanup); + process.removeListener("SIGTERM", cleanup); + } + + return { + triggered: false, reason: "cancelled", + peakPrice: peakPrice || undefined, + positionSide, + runtime: (Date.now() - startedAt) / 1000, + }; +}