From 083592e06d91f1fe46f528619bf1e39549ccc96b Mon Sep 17 00:00:00 2001 From: tae_98 Date: Tue, 9 Jun 2026 01:04:22 +0900 Subject: [PATCH 1/8] fix : close command fails to execute on Hyperliquid --- package-lock.json | 4 ++-- src/commands/perps.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e1048d8..03ff2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "minara", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "minara", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.0.0", diff --git a/src/commands/perps.ts b/src/commands/perps.ts index 48a9076..11cf425 100644 --- a/src/commands/perps.ts +++ b/src/commands/perps.ts @@ -941,7 +941,7 @@ const closeCmd = new Command('close') p: limitPx, s: sz, r: true, - t: { trigger: { triggerPx: String(marketPx), tpsl: 'tp', isMarket: true } }, + t: { limit: { tif: 'Ioc' } }, }; try { @@ -1052,7 +1052,7 @@ const closeCmd = new Command('close') p: limitPx, s: sz, r: true, - t: { trigger: { triggerPx: String(marketPx), tpsl: 'tp', isMarket: true } }, + t: { limit: { tif: 'Ioc' } }, }; const sideLabel = isLong ? 'LONG' : 'SHORT'; From ca087f5eb53fdbae608fb83dc6867c04796b365c Mon Sep 17 00:00:00 2001 From: tae_98 Date: Tue, 9 Jun 2026 01:20:08 +0900 Subject: [PATCH 2/8] fix(hyperliquid): change perps open market order from trigger to limit IOC --- src/commands/perps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/perps.ts b/src/commands/perps.ts index 11cf425..d74776c 100644 --- a/src/commands/perps.ts +++ b/src/commands/perps.ts @@ -753,7 +753,7 @@ const orderCmd = new Command('order') r: reduceOnly, t: orderType === 'limit' ? { limit: { tif: 'Gtc' } } - : { trigger: { triggerPx: String(marketPx ?? limitPx), tpsl: opts.tpsl ?? 'tp', isMarket: true } }, + : { limit: { tif: 'Ioc' } }, }; const priceLabel = orderType === 'market' ? `Market (~$${marketPx ?? limitPx})` : `$${limitPx}`; From 7e963c072bfa412e0145c5e72a949332d532e94c Mon Sep 17 00:00:00 2001 From: tae_98 Date: Tue, 9 Jun 2026 01:29:29 +0900 Subject: [PATCH 3/8] fix: hide raw_data from order result output --- src/formatters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/formatters.ts b/src/formatters.ts index 446377c..406c473 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -55,7 +55,7 @@ export function isRawJson(): boolean { return _rawJson; } * Keys matching this pattern are hidden from printKV / auto-detected table * columns. They are still included in --json raw output. */ -const HIDDEN_KEYS = /^_?id$|^_id$|^__v$/i; +const HIDDEN_KEYS = /^_?id$|^_id$|^__v$|^raw_data$/i; // ─── Per-context key exclusions (non-regex, for specific commands) ─────── From 725e1a27f36fe374bd6fbb3d404fc65c84007243 Mon Sep 17 00:00:00 2001 From: tae_98 Date: Tue, 9 Jun 2026 01:36:26 +0900 Subject: [PATCH 4/8] fix2 : hide raw_data from order result output --- src/formatters.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/formatters.ts b/src/formatters.ts index 406c473..84a8030 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -137,12 +137,12 @@ export function formatValue(value: unknown, key?: string): string { if (typeof value === 'object') { // Shallow nested — show as inline key=value const entries = Object.entries(value as Record).filter( - ([, v]) => v !== null && v !== undefined, + ([k, v]) => v !== null && v !== undefined && v !== '' && !HIDDEN_KEYS.test(k), ); if (entries.length <= 3) { - return entries.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(' '); + return entries.map(([k, v]) => `${k}=${typeof v === 'string' ? v : formatValue(v, k)}`).join(' '); } - return chalk.dim(JSON.stringify(value)); + return entries.map(([k, v]) => `${k}=${typeof v === 'string' ? v : formatValue(v, k)}`).join(' '); } return String(value); @@ -301,9 +301,18 @@ export function printTxResult(data: unknown): void { return; } - const obj = data as Record; console.log(''); - printKV(obj); + if (Array.isArray(data)) { + for (const item of data) { + if (typeof item === 'object' && item !== null) { + printKV(item as Record); + } else { + console.log(chalk.dim(` ${item}`)); + } + } + } else { + printKV(data as Record); + } } // ═══════════════════════════════════════════════════════════════════════════ From a1be34f08431cdc99a229c55f0b991b9b5253127 Mon Sep 17 00:00:00 2001 From: tae_98 Date: Wed, 24 Jun 2026 12:50:02 +0900 Subject: [PATCH 5/8] feat(perps): add mirror/hedge command for auto opposite positions Monitor source wallet(s) and automatically open/close opposite positions on a target wallet. Supports multi-source monitoring, USD value matching, and fixed leverage (40x default). --- src/commands/perps.ts | 286 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 285 insertions(+), 1 deletion(-) diff --git a/src/commands/perps.ts b/src/commands/perps.ts index d74776c..c1c44ec 100644 --- a/src/commands/perps.ts +++ b/src/commands/perps.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { input, select, confirm, number as numberPrompt } from '@inquirer/prompts'; +import { input, select, confirm, number as numberPrompt, checkbox } from '@inquirer/prompts'; import chalk from 'chalk'; import * as perpsApi from '../api/perps.js'; import { requireAuth } from '../config.js'; @@ -1076,6 +1076,288 @@ const closeCmd = new Command('close') printTxResult(orderRes.data); })); +// ─── mirror (hedge) ────────────────────────────────────────────────────── + +interface MirrorOpts { + source?: string; + interval?: string; + dryRun?: boolean; + yes?: boolean; + wallet?: string; + leverage?: string; +} + +const mirrorCmd = new Command('mirror') + .description('Monitor a wallet and auto-open opposite positions (hedge)') + .option('--source ', 'Source wallet(s) to monitor, comma-separated (0x… or wallet name)') + .option(WALLET_OPT[0], WALLET_OPT[1]) + .option('-i, --interval ', 'Polling interval in seconds', '10') + .option('--leverage ', 'Target wallet leverage (fixed)', '40') + .option('--dry-run', 'Simulate without placing real orders') + .option('-y, --yes', 'Skip confirmation') + .action(wrapAction(async (opts: MirrorOpts) => { + const creds = requireAuth(); + + // Resolve target wallet + const target = await resolveWallet(creds.accessToken, opts.wallet, 'Select target (hedge) wallet:'); + if (!target) return; + const { wallet, walletId } = target; + + // Resolve source wallets (supports comma-separated) + interface SourceWallet { + key: string; // unique identifier for dedup + address?: string; // for external wallets (Hyperliquid public API) + subAccountId?: string; // for Minara sub-accounts (Minara API) + label: string; + } + const sources: SourceWallet[] = []; + const wallets = await fetchSubAccounts(creds.accessToken); + + const resolveSourceInput = (inp: string): SourceWallet | null => { + const trimmed = inp.trim(); + // External address + if (trimmed.startsWith('0x') && trimmed.length >= 42) { + return { key: trimmed, address: trimmed, label: `${trimmed.slice(0, 8)}…${trimmed.slice(-4)}` }; + } + // Minara sub-account by name or ID + const match = wallets.find((w) => + (w.name ?? '').toUpperCase() === trimmed.toUpperCase() + || getSubAccountId(w) === trimmed, + ); + if (!match) return null; + const sid = getSubAccountId(match); + return { + key: sid || match.name || trimmed, + address: match.address, + subAccountId: sid || undefined, + label: match.name ?? sid ?? trimmed, + }; + }; + + if (opts.source) { + const inputs = opts.source.split(',').map((s) => s.trim()).filter(Boolean); + for (const inp of inputs) { + const src = resolveSourceInput(inp); + if (!src) { + warn(`Could not resolve source "${inp}".`); + return; + } + sources.push(src); + } + } else { + if (wallets.length > 0) { + const selected = await checkbox({ + message: 'Select source wallet(s) to monitor:', + choices: wallets.map((w) => ({ + name: `${getSubAccountLabel(w)}`, + value: getSubAccountId(w), + })), + }); + for (const sid of selected) { + const w = wallets.find((w) => getSubAccountId(w) === sid); + if (w) sources.push({ + key: sid, + address: w.address, + subAccountId: sid, + label: w.name ?? sid, + }); + } + if (sources.length === 0) { + const manualAddr = await input({ + message: 'Source wallet address (0x…):', + validate: (v) => v.startsWith('0x') && v.length >= 42 ? true : 'Enter a valid EVM address', + }); + sources.push({ key: manualAddr, address: manualAddr, label: `${manualAddr.slice(0, 8)}…${manualAddr.slice(-4)}` }); + } + } else { + const manualAddr = await input({ + message: 'Source wallet address (0x…):', + validate: (v) => v.startsWith('0x') && v.length >= 42 ? true : 'Enter a valid EVM address', + }); + sources.push({ key: manualAddr, address: manualAddr, label: `${manualAddr.slice(0, 8)}…${manualAddr.slice(-4)}` }); + } + } + + if (sources.length === 0) { + warn('No source wallets selected.'); + return; + } + + const intervalSec = Math.max(3, parseInt(opts.interval ?? '10', 10) || 10); + const targetLeverage = Math.max(1, parseInt(opts.leverage ?? '40', 10) || 40); + const leverageSet = new Set(); + + // Summary + console.log(''); + console.log(chalk.bold('Mirror / Hedge Setup:')); + console.log(` Sources (monitor):`); + for (const s of sources) { + const detail = s.address ? chalk.dim(s.address) : chalk.dim('(sub-account)'); + console.log(` ${chalk.yellow(s.label)} ${detail}`); + } + console.log(` Target (hedge) : ${getSubAccountLabel(wallet)}`); + console.log(` Interval : ${intervalSec}s`); + console.log(` Leverage : ${chalk.cyan(`${targetLeverage}x (cross)`)}`); + console.log(` Size matching : ${chalk.dim('USD value')}`); + console.log(` Mode : ${opts.dryRun ? chalk.yellow('DRY RUN') : chalk.red('LIVE')}`); + console.log(''); + + if (!opts.yes) { + const ok = await confirm({ message: 'Start mirroring?', default: false }); + if (!ok) return; + } + if (!opts.dryRun) await requireTouchId(); + + // ── Helper: fetch fills from a source (sub-account API or public API) ── + const fetchSourceFills = async (source: SourceWallet): Promise[]> => { + const startTime = Date.now() - 24 * 60 * 60 * 1000; + if (source.subAccountId) { + const res = await perpsApi.getSubAccountFills(creds.accessToken, source.subAccountId, startTime); + return res.success && Array.isArray(res.data) ? res.data as Record[] : []; + } + if (source.address) { + return await perpsApi.getUserFills(source.address, 1) as unknown as Record[]; + } + return []; + }; + + // ── Baseline ── + const initSpin = spinner('Establishing baseline…'); + const initialResults = await Promise.all(sources.map((s) => fetchSourceFills(s))); + initSpin.stop(); + + const processed = new Set(); + let totalBaseline = 0; + for (let i = 0; i < initialResults.length; i++) { + const key = sources[i].key; + for (const f of initialResults[i]) { + processed.add(`${key}:${f.tid}`); + totalBaseline++; + } + } + + success(`Baseline set — ${totalBaseline} existing fills across ${sources.length} source(s). Watching for new trades…`); + console.log(chalk.dim(' Press Ctrl+C to stop.\n')); + + // ── Monitoring loop ── + let running = true; + let mirrored = 0; + const shutdown = () => { running = false; }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + while (running) { + try { + // Fetch from all sources in parallel + const results = await Promise.all( + sources.map(async (source) => { + const fills = await fetchSourceFills(source); + return fills + .filter((f) => !processed.has(`${source.key}:${f.tid}`)) + .map((f) => ({ fill: f, sourceKey: source.key })); + }), + ); + const newEntries = results.flat().sort((a, b) => Number(a.fill.time) - Number(b.fill.time)); + + for (const { fill, sourceKey } of newEntries) { + processed.add(`${sourceKey}:${fill.tid}`); + + const dir = String(fill.dir ?? ''); + const coin = String(fill.coin ?? ''); + const sz = String(fill.sz ?? '0'); + const px = Number(fill.px ?? 0); + const fillTime = Number(fill.time ?? 0); + + // Only mirror opens and closes + const isOpen = dir.startsWith('Open'); + const isClose = dir.startsWith('Close'); + if (!isOpen && !isClose) continue; + + const isLong = dir.endsWith('Long'); + const isBuy = isOpen !== isLong; + const reduceOnly = isClose; + const action = isOpen ? 'OPEN' : 'CLOSE'; + const sideLabel = isLong ? chalk.green('LONG') : chalk.red('SHORT'); + const targetSide = isBuy ? chalk.green('LONG') : chalk.red('SHORT'); + + const sourceLabel = sources.find((s) => s.key === sourceKey)?.label ?? sourceKey; + const ts = new Date(fillTime).toLocaleTimeString('en-US', { hour12: false }); + const sourceValueUsd = px * Number(sz); + console.log(`\n ${chalk.dim(ts)} [${chalk.cyan(sourceLabel)}] ${action}: ${chalk.bold(coin)} ${sideLabel} ${sz} @ $${px.toLocaleString()} (${chalk.dim(`$${sourceValueUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)})`); + + // Get mark price for slippage + size calculation + const assets = await perpsApi.getAssetMeta(); + const meta = assets.find((a) => a.name.toUpperCase() === coin.toUpperCase()); + const markPx = meta?.markPx ?? px; + + // Calculate target size to match USD position value + const szDecimals = meta?.szDecimals ?? 4; + const targetSz = (sourceValueUsd / markPx).toFixed(szDecimals); + + if (opts.dryRun) { + console.log(` → ${chalk.yellow('[DRY RUN]')} Would ${isOpen ? 'open' : 'close'} ${targetSide} ${targetSz} ${coin} (~$${sourceValueUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`); + continue; + } + + // Set leverage for this asset (once per asset) + if (!leverageSet.has(coin)) { + try { + await perpsApi.updateLeverage(creds.accessToken, { + symbol: coin, + isCross: true, + leverage: targetLeverage, + subAccountId: walletId, + }); + leverageSet.add(coin); + } catch (e) { + warn(` Could not set leverage for ${coin}: ${String(e).slice(0, 80)}`); + } + } + + const limitPx = (isBuy ? markPx * 1.01 : markPx * 0.99).toPrecision(5); + + const order: PerpsOrder = { + a: coin, + b: isBuy, + p: limitPx, + s: targetSz, + r: reduceOnly, + t: { limit: { tif: 'Ioc' } }, + }; + + const verb = isOpen ? 'Opening' : 'Closing'; + const spin = spinner(`${verb} ${targetSide} ${targetSz} ${coin}…`); + try { + const res = await perpsApi.placeOrders(creds.accessToken, { + orders: [order], + grouping: 'na', + subAccountId: walletId, + }); + spin.stop(); + if (res.success) { + mirrored++; + success(` Mirrored → ${verb} ${targetSide} ${targetSz} ${coin} @ ~$${markPx.toLocaleString()}`); + } else { + warn(` Failed: ${res.error?.message ?? 'Unknown error'}`); + } + } catch (e) { + spin.stop(); + warn(` Error: ${String(e).slice(0, 100)}`); + } + } + } catch (e) { + console.log(chalk.dim(` ${new Date().toLocaleTimeString('en-US', { hour12: false })} poll error: ${String(e).slice(0, 80)}`)); + } + + if (running) await new Promise((r) => setTimeout(r, intervalSec * 1000)); + } + + process.off('SIGINT', shutdown); + process.off('SIGTERM', shutdown); + console.log(''); + info(`Mirror stopped. Total mirrored: ${mirrored}`); + })); + // ─── leverage ──────────────────────────────────────────────────────────── interface LeverageOpts { @@ -2322,6 +2604,7 @@ export const perpsCommand = new Command('perps') .addCommand(orderCmd) .addCommand(cancelCmd) .addCommand(closeCmd) + .addCommand(mirrorCmd) .addCommand(leverageCmd) .addCommand(tradesCmd) .addCommand(depositCmd) @@ -2347,6 +2630,7 @@ export const perpsCommand = new Command('perps') { name: 'View positions', value: 'positions' }, { name: 'Place order', value: 'order' }, { name: 'Close position', value: 'close' }, + { name: 'Mirror/Hedge (auto opposite)', value: 'mirror' }, { name: 'Cancel order', value: 'cancel' }, { name: 'Update leverage', value: 'leverage' }, { name: 'View trade history', value: 'trades' }, From 646e504e44588b0dcd5a97a614ea942f3ebd39fd Mon Sep 17 00:00:00 2001 From: tae_98 Date: Fri, 26 Jun 2026 23:13:18 +0900 Subject: [PATCH 6/8] fix : doen't take a long position --- src/api/perps.ts | 46 +++++++- src/commands/perps.ts | 266 +++++++++++++++++++++++++----------------- 2 files changed, 200 insertions(+), 112 deletions(-) diff --git a/src/api/perps.ts b/src/api/perps.ts index 3068766..3a4302c 100644 --- a/src/api/perps.ts +++ b/src/api/perps.ts @@ -205,10 +205,13 @@ export interface HlAssetInfo extends HlAssetMeta { } let _assetInfoCache: HlAssetInfo[] | null = null; +let _assetInfoCacheTime = 0; +const ASSET_CACHE_TTL_MS = 30_000; // 30 seconds — stale prices cause IOC failures -/** Fetch perpetuals universe metadata + live prices from Hyperliquid (cached per session). */ +/** Fetch perpetuals universe metadata + live prices from Hyperliquid (cached for 30s). */ export async function getAssetMeta(): Promise { - if (_assetInfoCache) return _assetInfoCache; + const now = Date.now(); + if (_assetInfoCache && (now - _assetInfoCacheTime) < ASSET_CACHE_TTL_MS) return _assetInfoCache; try { const res = await fetch('https://api.hyperliquid.xyz/info', { method: 'POST', @@ -224,9 +227,10 @@ export async function getAssetMeta(): Promise { ...m, markPx: Number(ctxs?.[i]?.markPx ?? 0), })); + _assetInfoCacheTime = now; return _assetInfoCache; } catch { - return []; + return _assetInfoCache ?? []; } } @@ -322,3 +326,39 @@ export async function getUserLeverage(address: string): Promise { + try { + const res = await fetch('https://api.hyperliquid.xyz/info', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'clearinghouseState', user: address }), + }); + const data = (await res.json()) as { + assetPositions?: { + position: { + coin: string; + szi: string; + entryPx?: string; + unrealizedPnl?: string; + }; + }[]; + }; + return (data.assetPositions ?? []).map((ap) => ({ + coin: ap.position.coin, + szi: parseFloat(ap.position.szi), + entryPx: parseFloat(ap.position.entryPx ?? '0'), + unrealizedPnl: parseFloat(ap.position.unrealizedPnl ?? '0'), + })); + } catch { + return []; + } +} diff --git a/src/commands/perps.ts b/src/commands/perps.ts index c1c44ec..94c1cd2 100644 --- a/src/commands/perps.ts +++ b/src/commands/perps.ts @@ -1208,147 +1208,195 @@ const mirrorCmd = new Command('mirror') } if (!opts.dryRun) await requireTouchId(); - // ── Helper: fetch fills from a source (sub-account API or public API) ── - const fetchSourceFills = async (source: SourceWallet): Promise[]> => { - const startTime = Date.now() - 24 * 60 * 60 * 1000; - if (source.subAccountId) { - const res = await perpsApi.getSubAccountFills(creds.accessToken, source.subAccountId, startTime); - return res.success && Array.isArray(res.data) ? res.data as Record[] : []; - } - if (source.address) { - return await perpsApi.getUserFills(source.address, 1) as unknown as Record[]; + // ── Helper: fetch current positions from a source ── + const fetchSourcePositions = async (source: SourceWallet): Promise> => { + const positions = new Map(); + try { + if (source.subAccountId) { + const res = await perpsApi.getSubAccountSummary(creds.accessToken, source.subAccountId); + if (res.success && res.data) { + const raw = res.data as Record; + // Try Hyperliquid format: assetPositions[].position.{coin, szi} + const rawAssetPositions = Array.isArray(raw.assetPositions) + ? (raw.assetPositions as Record[]) + : null; + // Try Minara flattened format: positions[].{symbol/coin, side, size} + const rawPositions = Array.isArray(raw.positions) + ? (raw.positions as Record[]) + : null; + + if (rawAssetPositions) { + for (const ap of rawAssetPositions) { + const pos = (ap.position && typeof ap.position === 'object' ? ap.position : ap) as Record; + const coin = String(pos.coin ?? ''); + const szi = parseFloat(String(pos.szi ?? 0)); + if (coin && szi !== 0) positions.set(coin, szi); + } + } else if (rawPositions) { + for (const pos of rawPositions) { + const coin = String(pos.symbol ?? pos.coin ?? ''); + const side = String(pos.side ?? '').toLowerCase(); + const size = Math.abs(parseFloat(String(pos.size ?? pos.szi ?? 0))); + const szi = size === 0 ? 0 : (side === 'long' || side === 'buy' ? size : -size); + if (coin && szi !== 0) positions.set(coin, szi); + } + } else { + // Debug: log raw keys to help diagnose format issues + console.log(chalk.dim(` [debug] ${source.label} summary keys: ${Object.keys(raw).join(', ')}`)); + } + } else { + console.log(chalk.dim(` [debug] ${source.label} API returned: success=${res.success}`)); + } + } else if (source.address) { + const userPositions = await perpsApi.getUserPositions(source.address); + for (const p of userPositions) { + if (p.coin && p.szi !== 0) positions.set(p.coin, p.szi); + } + } + } catch (e) { + console.log(chalk.dim(` [debug] ${source.label} fetch error: ${String(e).slice(0, 100)}`)); } - return []; + return positions; }; - // ── Baseline ── + // ── Baseline: record initial positions ── const initSpin = spinner('Establishing baseline…'); - const initialResults = await Promise.all(sources.map((s) => fetchSourceFills(s))); + const lastPositions = new Map>(); + const initialResults = await Promise.all(sources.map(async (s) => { + const pos = await fetchSourcePositions(s); + lastPositions.set(s.key, pos); + return { source: s, positions: pos }; + })); initSpin.stop(); - const processed = new Set(); - let totalBaseline = 0; - for (let i = 0; i < initialResults.length; i++) { - const key = sources[i].key; - for (const f of initialResults[i]) { - processed.add(`${key}:${f.tid}`); - totalBaseline++; - } + let totalPositions = 0; + for (const { source, positions } of initialResults) { + const posStr = positions.size > 0 + ? [...positions.entries()].map(([coin, szi]) => `${coin} ${szi > 0 ? 'LONG' : 'SHORT'} ${Math.abs(szi)}`).join(', ') + : 'no positions'; + console.log(chalk.dim(` ${source.label}: ${posStr}`)); + totalPositions += positions.size; } - success(`Baseline set — ${totalBaseline} existing fills across ${sources.length} source(s). Watching for new trades…`); + success(`Baseline set — ${totalPositions} position(s) across ${sources.length} source(s). Watching for changes…`); console.log(chalk.dim(' Press Ctrl+C to stop.\n')); // ── Monitoring loop ── let running = true; let mirrored = 0; + let pollCount = 0; const shutdown = () => { running = false; }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); while (running) { try { - // Fetch from all sources in parallel - const results = await Promise.all( - sources.map(async (source) => { - const fills = await fetchSourceFills(source); - return fills - .filter((f) => !processed.has(`${source.key}:${f.tid}`)) - .map((f) => ({ fill: f, sourceKey: source.key })); - }), - ); - const newEntries = results.flat().sort((a, b) => Number(a.fill.time) - Number(b.fill.time)); - - for (const { fill, sourceKey } of newEntries) { - processed.add(`${sourceKey}:${fill.tid}`); - - const dir = String(fill.dir ?? ''); - const coin = String(fill.coin ?? ''); - const sz = String(fill.sz ?? '0'); - const px = Number(fill.px ?? 0); - const fillTime = Number(fill.time ?? 0); - - // Only mirror opens and closes - const isOpen = dir.startsWith('Open'); - const isClose = dir.startsWith('Close'); - if (!isOpen && !isClose) continue; - - const isLong = dir.endsWith('Long'); - const isBuy = isOpen !== isLong; - const reduceOnly = isClose; - const action = isOpen ? 'OPEN' : 'CLOSE'; - const sideLabel = isLong ? chalk.green('LONG') : chalk.red('SHORT'); - const targetSide = isBuy ? chalk.green('LONG') : chalk.red('SHORT'); - - const sourceLabel = sources.find((s) => s.key === sourceKey)?.label ?? sourceKey; - const ts = new Date(fillTime).toLocaleTimeString('en-US', { hour12: false }); - const sourceValueUsd = px * Number(sz); - console.log(`\n ${chalk.dim(ts)} [${chalk.cyan(sourceLabel)}] ${action}: ${chalk.bold(coin)} ${sideLabel} ${sz} @ $${px.toLocaleString()} (${chalk.dim(`$${sourceValueUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)})`); - - // Get mark price for slippage + size calculation - const assets = await perpsApi.getAssetMeta(); - const meta = assets.find((a) => a.name.toUpperCase() === coin.toUpperCase()); - const markPx = meta?.markPx ?? px; - - // Calculate target size to match USD position value - const szDecimals = meta?.szDecimals ?? 4; - const targetSz = (sourceValueUsd / markPx).toFixed(szDecimals); - - if (opts.dryRun) { - console.log(` → ${chalk.yellow('[DRY RUN]')} Would ${isOpen ? 'open' : 'close'} ${targetSide} ${targetSz} ${coin} (~$${sourceValueUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`); - continue; - } + for (const source of sources) { + const current = await fetchSourcePositions(source); + const previous = lastPositions.get(source.key) ?? new Map(); + + // Find all coins in either snapshot + const allCoins = new Set([...previous.keys(), ...current.keys()]); + + for (const coin of allCoins) { + const oldSize = previous.get(coin) ?? 0; + const newSize = current.get(coin) ?? 0; + const delta = newSize - oldSize; + if (Math.abs(delta) < 0.0001) continue; // skip negligible changes + + // Source delta > 0 → source increased long (or reduced short) → mirror sells + // Source delta < 0 → source increased short (or reduced long) → mirror buys + const isBuy = delta < 0; + const action = newSize === 0 ? 'CLOSE' : oldSize === 0 ? 'OPEN' : 'ADJUST'; + const sideLabel = delta > 0 ? chalk.green('LONG') : chalk.red('SHORT'); + const targetSide = isBuy ? chalk.green('LONG') : chalk.red('SHORT'); + + // Get mark price + const assets = await perpsApi.getAssetMeta(); + const meta = assets.find((a) => a.name.toUpperCase() === coin.toUpperCase()); + const markPx = meta?.markPx ?? 0; + if (markPx <= 0) { + warn(` Could not fetch price for ${coin}, skipping`); + continue; + } + + const szDecimals = meta?.szDecimals ?? 4; + const deltaSz = Math.abs(delta).toFixed(szDecimals); + const deltaUsd = Math.abs(delta) * markPx; + + const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); + console.log(`\n ${chalk.dim(ts)} [${chalk.cyan(source.label)}] ${action}: ${chalk.bold(coin)} ${sideLabel} ${deltaSz} (${chalk.dim(`$${deltaUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)})`); - // Set leverage for this asset (once per asset) - if (!leverageSet.has(coin)) { + if (opts.dryRun) { + console.log(` → ${chalk.yellow('[DRY RUN]')} Would ${isBuy ? 'buy' : 'sell'} ${deltaSz} ${coin} (~$${deltaUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`); + continue; + } + + // Set leverage for this asset (once per asset) + if (!leverageSet.has(coin)) { + try { + await perpsApi.updateLeverage(creds.accessToken, { + symbol: coin, + isCross: true, + leverage: targetLeverage, + subAccountId: walletId, + }); + leverageSet.add(coin); + } catch (e) { + warn(` Could not set leverage for ${coin}: ${String(e).slice(0, 80)}`); + } + } + + const limitPx = (isBuy ? markPx * 1.02 : markPx * 0.98).toPrecision(5); + + const order: PerpsOrder = { + a: coin, + b: isBuy, + p: limitPx, + s: deltaSz, + r: false, + t: { limit: { tif: 'Ioc' } }, + }; + + const verb = action === 'CLOSE' ? 'Closing' : action === 'OPEN' ? 'Opening' : 'Adjusting'; + const spin = spinner(`${verb} ${targetSide} ${deltaSz} ${coin}…`); try { - await perpsApi.updateLeverage(creds.accessToken, { - symbol: coin, - isCross: true, - leverage: targetLeverage, + const res = await perpsApi.placeOrders(creds.accessToken, { + orders: [order], + grouping: 'na', subAccountId: walletId, }); - leverageSet.add(coin); + spin.stop(); + if (res.success) { + mirrored++; + success(` Mirrored → ${verb} ${targetSide} ${deltaSz} ${coin} @ ~$${markPx.toLocaleString()}`); + } else { + warn(` Failed: ${res.error?.message ?? 'Unknown error'}`); + } } catch (e) { - warn(` Could not set leverage for ${coin}: ${String(e).slice(0, 80)}`); + spin.stop(); + warn(` Error: ${String(e).slice(0, 100)}`); } } - const limitPx = (isBuy ? markPx * 1.01 : markPx * 0.99).toPrecision(5); - - const order: PerpsOrder = { - a: coin, - b: isBuy, - p: limitPx, - s: targetSz, - r: reduceOnly, - t: { limit: { tif: 'Ioc' } }, - }; - - const verb = isOpen ? 'Opening' : 'Closing'; - const spin = spinner(`${verb} ${targetSide} ${targetSz} ${coin}…`); - try { - const res = await perpsApi.placeOrders(creds.accessToken, { - orders: [order], - grouping: 'na', - subAccountId: walletId, - }); - spin.stop(); - if (res.success) { - mirrored++; - success(` Mirrored → ${verb} ${targetSide} ${targetSz} ${coin} @ ~$${markPx.toLocaleString()}`); - } else { - warn(` Failed: ${res.error?.message ?? 'Unknown error'}`); - } - } catch (e) { - spin.stop(); - warn(` Error: ${String(e).slice(0, 100)}`); - } + lastPositions.set(source.key, current); } } catch (e) { console.log(chalk.dim(` ${new Date().toLocaleTimeString('en-US', { hour12: false })} poll error: ${String(e).slice(0, 80)}`)); } + pollCount++; + // Heartbeat every ~1 minute (6 polls at 10s, or adjust for interval) + if (pollCount % Math.max(1, Math.round(60 / intervalSec)) === 0) { + const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); + const summary = sources.map((s) => { + const pos = lastPositions.get(s.key); + const count = pos?.size ?? 0; + return `${s.label}: ${count} pos`; + }).join(', '); + console.log(chalk.dim(` ${ts} [heartbeat] poll #${pollCount}, ${summary}`)); + } + if (running) await new Promise((r) => setTimeout(r, intervalSec * 1000)); } From b21879886142633f51daf812ec271a40cf7e565e Mon Sep 17 00:00:00 2001 From: tae_98 Date: Sat, 27 Jun 2026 00:04:12 +0900 Subject: [PATCH 7/8] fix2 : doen't take a long position --- src/commands/perps.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/perps.ts b/src/commands/perps.ts index 94c1cd2..6732448 100644 --- a/src/commands/perps.ts +++ b/src/commands/perps.ts @@ -1347,7 +1347,9 @@ const mirrorCmd = new Command('mirror') } } - const limitPx = (isBuy ? markPx * 1.02 : markPx * 0.98).toPrecision(5); + // Wide slippage (5%) = effective market order for hedging. + // Entry price doesn't matter for hedges — only position size does. + const limitPx = (isBuy ? markPx * 1.05 : markPx * 0.95).toPrecision(5); const order: PerpsOrder = { a: coin, From b010ef8675b096d4985b9ecbdbbb50b9f1ae2a39 Mon Sep 17 00:00:00 2001 From: tae_98 Date: Wed, 1 Jul 2026 22:56:03 +0900 Subject: [PATCH 8/8] feat: replace mirror delta-tracking with reconciliation loop --- src/commands/perps.ts | 403 ++++++++++++++++++++++++++--------- tests/commands/perps.test.ts | 203 ++++++++++++++++++ 2 files changed, 502 insertions(+), 104 deletions(-) diff --git a/src/commands/perps.ts b/src/commands/perps.ts index 6732448..ae6e1f6 100644 --- a/src/commands/perps.ts +++ b/src/commands/perps.ts @@ -1085,16 +1085,31 @@ interface MirrorOpts { yes?: boolean; wallet?: string; leverage?: string; + /** minimum notional % of current position size to act on (default 1.0) */ + mismatchThreshold?: string; + /** consecutive failures per coin before flatten+halt (default 5) */ + maxRetries?: string; + /** block new orders above this margin ratio 0..1 (default 0.80) */ + marginCeiling?: string; + /** stop-loss % off entry for new hedge opens, 0 = off (default 0) */ + stopLoss?: string; + /** only hedge changes after startup, ignoring pre-existing source positions */ + baselineOnly?: boolean; } const mirrorCmd = new Command('mirror') - .description('Monitor a wallet and auto-open opposite positions (hedge)') + .description('Monitor a wallet and auto-open opposite positions (hedge) via net-exposure reconciliation') .option('--source ', 'Source wallet(s) to monitor, comma-separated (0x… or wallet name)') .option(WALLET_OPT[0], WALLET_OPT[1]) .option('-i, --interval ', 'Polling interval in seconds', '10') .option('--leverage ', 'Target wallet leverage (fixed)', '40') .option('--dry-run', 'Simulate without placing real orders') .option('-y, --yes', 'Skip confirmation') + .option('--mismatch-threshold ', 'Skip corrections under % of position notional (noise filter)', '1.0') + .option('--max-retries ', 'Flatten + halt a coin after consecutive correction failures', '5') + .option('--margin-ceiling ', 'Block new orders when target margin ratio exceeds (0..1)', '0.80') + .option('--stop-loss ', 'Attach SL trigger % off entry on new hedge opens (0 = off)', '0') + .option('--baseline-only', 'Ignore source positions at startup (only hedge subsequent changes)') .action(wrapAction(async (opts: MirrorOpts) => { const creds = requireAuth(); @@ -1187,6 +1202,13 @@ const mirrorCmd = new Command('mirror') const targetLeverage = Math.max(1, parseInt(opts.leverage ?? '40', 10) || 40); const leverageSet = new Set(); + // New safety parameters + const mismatchThresholdPct = Math.max(0, parseFloat(opts.mismatchThreshold ?? '1.0') || 0); + const maxRetries = Math.max(1, parseInt(opts.maxRetries ?? '5', 10) || 5); + const marginCeiling = Math.min(1, Math.max(0, parseFloat(opts.marginCeiling ?? '0.80') || 0)); + const stopLossPct = Math.max(0, parseFloat(opts.stopLoss ?? '0') || 0); + const baselineOnly = !!opts.baselineOnly; + // Summary console.log(''); console.log(chalk.bold('Mirror / Hedge Setup:')); @@ -1198,8 +1220,12 @@ const mirrorCmd = new Command('mirror') console.log(` Target (hedge) : ${getSubAccountLabel(wallet)}`); console.log(` Interval : ${intervalSec}s`); console.log(` Leverage : ${chalk.cyan(`${targetLeverage}x (cross)`)}`); - console.log(` Size matching : ${chalk.dim('USD value')}`); console.log(` Mode : ${opts.dryRun ? chalk.yellow('DRY RUN') : chalk.red('LIVE')}`); + console.log(` Strategy : ${chalk.cyan('net-exposure reconciliation')} (baseline-only=${baselineOnly})`); + console.log(` Mismatch filter : ${chalk.dim(`< ${mismatchThresholdPct}% of position notional`)}`); + console.log(` Max retries : ${chalk.dim(`${maxRetries} per coin → flatten + halt`)}`); + console.log(` Margin ceiling : ${chalk.dim(`${(marginCeiling * 100).toFixed(1)}%`)}`); + console.log(` Stop-loss : ${stopLossPct > 0 ? chalk.yellow(`${stopLossPct}% off entry (new opens)`) : chalk.dim('off')}`); console.log(''); if (!opts.yes) { @@ -1259,144 +1285,310 @@ const mirrorCmd = new Command('mirror') return positions; }; - // ── Baseline: record initial positions ── - const initSpin = spinner('Establishing baseline…'); - const lastPositions = new Map>(); - const initialResults = await Promise.all(sources.map(async (s) => { - const pos = await fetchSourcePositions(s); - lastPositions.set(s.key, pos); - return { source: s, positions: pos }; - })); - initSpin.stop(); + // ── Helper: fetch target (mirror) account positions + margin ratio ── + const fetchTargetState = async (): Promise<{ positions: Map; marginRatio: number }> => { + const positions = new Map(); + let marginRatio = 0; + try { + let raw: Record = {}; + if (walletId) { + const res = await perpsApi.getSubAccountSummary(creds.accessToken, walletId); + if (res.success && res.data) raw = res.data as Record; + } else { + const res = await perpsApi.getAccountSummary(creds.accessToken); + if (res.success && res.data) raw = res.data as Record; + } - let totalPositions = 0; - for (const { source, positions } of initialResults) { - const posStr = positions.size > 0 - ? [...positions.entries()].map(([coin, szi]) => `${coin} ${szi > 0 ? 'LONG' : 'SHORT'} ${Math.abs(szi)}`).join(', ') - : 'no positions'; - console.log(chalk.dim(` ${source.label}: ${posStr}`)); - totalPositions += positions.size; - } + const margin = raw.marginSummary as Record | undefined; + const accountValue = margin + ? parseFloat(String(margin.accountValue ?? 0)) + : Number(raw.equityValue ?? raw.accountValue ?? 0); + const totalMarginUsed = margin + ? parseFloat(String(margin.totalMarginUsed ?? 0)) + : Number(raw.totalMarginUsed ?? 0); + marginRatio = accountValue > 0 ? totalMarginUsed / accountValue : 0; + + const rawAssetPositions = Array.isArray(raw.assetPositions) + ? (raw.assetPositions as Record[]) + : []; + if (rawAssetPositions.length > 0) { + for (const ap of rawAssetPositions) { + const pos = (ap.position && typeof ap.position === 'object' ? ap.position : ap) as Record; + const coin = String(pos.coin ?? ''); + const szi = parseFloat(String(pos.szi ?? 0)); + if (coin && szi !== 0) positions.set(coin, szi); + } + } else if (Array.isArray(raw.positions)) { + for (const pos of raw.positions as Record[]) { + const coin = String(pos.symbol ?? pos.coin ?? ''); + const side = String(pos.side ?? '').toLowerCase(); + const size = Math.abs(parseFloat(String(pos.size ?? pos.szi ?? 0))); + const szi = size === 0 ? 0 : (side === 'long' || side === 'buy' ? size : -size); + if (coin && szi !== 0) positions.set(coin, szi); + } + } + } catch (e) { + console.log(chalk.dim(` [debug] target fetch error: ${String(e).slice(0, 100)}`)); + } + return { positions, marginRatio }; + }; - success(`Baseline set — ${totalPositions} position(s) across ${sources.length} source(s). Watching for changes…`); + // ── Helper: decide reduceOnly per the mixed policy ── + // mismatch that purely shrinks the current target position → reduceOnly + // (opposite sign AND |mismatch| ≤ |current|). Otherwise false. + const isPureReduction = (currentTarget: number, mismatch: number): boolean => { + if (currentTarget === 0) return false; // new open + if (Math.sign(mismatch) === Math.sign(currentTarget)) return false; // expand same direction + if (Math.abs(mismatch) > Math.abs(currentTarget)) return false; // flip + return true; // partial or full close + }; + + // ── Helper: emergency flatten one coin to flat (reduce-only IOC) ── + const flattenCoin = async ( + coin: string, + currentSize: number, + markPx: number, + szDecimals: number, + ): Promise => { + const isBuy = currentSize < 0; // opposite of position direction + const size = Math.abs(currentSize).toFixed(szDecimals); + const limitPx = (isBuy ? markPx * 1.05 : markPx * 0.95).toPrecision(5); + const order: PerpsOrder = { + a: coin, + b: isBuy, + p: limitPx, + s: size, + r: true, + t: { limit: { tif: 'Ioc' } }, + }; + try { + const res = await perpsApi.placeOrders(creds.accessToken, { + orders: [order], + grouping: 'na', + subAccountId: walletId, + }); + return !!res.success; + } catch { + return false; + } + }; + + // ── Baseline (only used when --baseline-only) ── + // When baselineOnly is false (default), the first poll reconciles existing + // source positions immediately (pure reconciliation mode). + const sourceBaseline = new Map>(); + if (baselineOnly) { + const initSpin = spinner('Establishing baseline…'); + for (const s of sources) { + sourceBaseline.set(s.key, await fetchSourcePositions(s)); + } + initSpin.stop(); + for (const s of sources) { + const pos = sourceBaseline.get(s.key) ?? new Map(); + const posStr = pos.size > 0 + ? [...pos.entries()].map(([coin, szi]) => `${coin} ${szi > 0 ? 'LONG' : 'SHORT'} ${Math.abs(szi)}`).join(', ') + : 'no positions'; + console.log(chalk.dim(` ${s.label}: ${posStr}`)); + } + success(`Baseline set — ${sources.length} source(s). Only changes after startup will be hedged.`); + } else { + success(`Pure reconciliation mode — existing source positions will be hedged on first poll.`); + } console.log(chalk.dim(' Press Ctrl+C to stop.\n')); + // ── Reconciliation state ── + const consecutiveFailures = new Map(); + const haltedCoins = new Set(); + // ── Monitoring loop ── let running = true; - let mirrored = 0; + let reconciled = 0; let pollCount = 0; + let lastMarginRatio = 0; const shutdown = () => { running = false; }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); while (running) { try { + // 1. Fetch target state (positions + margin) + const { positions: targetPositions, marginRatio } = await fetchTargetState(); + lastMarginRatio = marginRatio; + + // 2. Aggregate signed source sizes (net of baseline if --baseline-only) + const sourceAggregate = new Map(); for (const source of sources) { - const current = await fetchSourcePositions(source); - const previous = lastPositions.get(source.key) ?? new Map(); - - // Find all coins in either snapshot - const allCoins = new Set([...previous.keys(), ...current.keys()]); - - for (const coin of allCoins) { - const oldSize = previous.get(coin) ?? 0; - const newSize = current.get(coin) ?? 0; - const delta = newSize - oldSize; - if (Math.abs(delta) < 0.0001) continue; // skip negligible changes - - // Source delta > 0 → source increased long (or reduced short) → mirror sells - // Source delta < 0 → source increased short (or reduced long) → mirror buys - const isBuy = delta < 0; - const action = newSize === 0 ? 'CLOSE' : oldSize === 0 ? 'OPEN' : 'ADJUST'; - const sideLabel = delta > 0 ? chalk.green('LONG') : chalk.red('SHORT'); - const targetSide = isBuy ? chalk.green('LONG') : chalk.red('SHORT'); - - // Get mark price - const assets = await perpsApi.getAssetMeta(); - const meta = assets.find((a) => a.name.toUpperCase() === coin.toUpperCase()); - const markPx = meta?.markPx ?? 0; - if (markPx <= 0) { - warn(` Could not fetch price for ${coin}, skipping`); - continue; + const pos = await fetchSourcePositions(source); + const baseline = baselineOnly ? (sourceBaseline.get(source.key) ?? new Map()) : null; + if (baseline) { + // Include coins that exist in baseline but disappeared from current + const allSourceCoins = new Set([...pos.keys(), ...baseline.keys()]); + for (const coin of allSourceCoins) { + const szi = pos.get(coin) ?? 0; + const baseVal = baseline.get(coin) ?? 0; + const net = szi - baseVal; + if (net !== 0) sourceAggregate.set(coin, (sourceAggregate.get(coin) ?? 0) + net); + } + } else { + for (const [coin, szi] of pos) { + if (szi !== 0) sourceAggregate.set(coin, (sourceAggregate.get(coin) ?? 0) + szi); } + } + } + + // 3. Asset metadata (prices + szDecimals) once per poll + const assets = await perpsApi.getAssetMeta(); - const szDecimals = meta?.szDecimals ?? 4; - const deltaSz = Math.abs(delta).toFixed(szDecimals); - const deltaUsd = Math.abs(delta) * markPx; + // 4. Margin ceiling: block new orders for this entire poll + let marginBlocked = marginRatio > marginCeiling; + if (marginBlocked) { + warn(` Margin ratio ${(marginRatio * 100).toFixed(1)}% exceeds ceiling ${(marginCeiling * 100).toFixed(1)}% — new orders blocked this poll (monitoring continues)`); + } - const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); - console.log(`\n ${chalk.dim(ts)} [${chalk.cyan(source.label)}] ${action}: ${chalk.bold(coin)} ${sideLabel} ${deltaSz} (${chalk.dim(`$${deltaUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)})`); + // 5. Reconcile every coin present on either side + const allCoins = new Set([...targetPositions.keys(), ...sourceAggregate.keys()]); + for (const coin of allCoins) { + if (haltedCoins.has(coin)) continue; - if (opts.dryRun) { - console.log(` → ${chalk.yellow('[DRY RUN]')} Would ${isBuy ? 'buy' : 'sell'} ${deltaSz} ${coin} (~$${deltaUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`); - continue; - } + const expected = -(sourceAggregate.get(coin) ?? 0); // desired target position + const actual = targetPositions.get(coin) ?? 0; // current target position + const mismatch = expected - actual; // signed correction - // Set leverage for this asset (once per asset) - if (!leverageSet.has(coin)) { - try { - await perpsApi.updateLeverage(creds.accessToken, { - symbol: coin, - isCross: true, - leverage: targetLeverage, - subAccountId: walletId, - }); - leverageSet.add(coin); - } catch (e) { - warn(` Could not set leverage for ${coin}: ${String(e).slice(0, 80)}`); - } + const meta = assets.find((a) => a.name.toUpperCase() === coin.toUpperCase()); + const markPx = meta?.markPx ?? 0; + if (markPx <= 0) { + warn(` Could not fetch price for ${coin}, skipping`); + continue; + } + + const szDecimals = meta?.szDecimals ?? 4; + const mismatchNotional = Math.abs(mismatch) * markPx; + // Threshold: max(% of current position notional, $5 absolute floor) + const positionNotional = Math.abs(actual) * markPx; + const thresholdNotional = Math.max( + positionNotional * (mismatchThresholdPct / 100), + 5, + ); + if (mismatchNotional < thresholdNotional) continue; // noise filter + + // Retry limit — flatten + halt this coin + const fails = consecutiveFailures.get(coin) ?? 0; + if (fails >= maxRetries) { + warn(` ${coin} exceeded ${maxRetries} consecutive failures — emergency flatten + halt`); + if (!opts.dryRun && actual !== 0) { + const ok = await flattenCoin(coin, actual, markPx, szDecimals); + if (ok) success(` Flattened ${coin} to flat`); + else warn(` Failed to flatten ${coin} — manual intervention required`); + } else if (opts.dryRun && actual !== 0) { + console.log(` ${chalk.yellow('[DRY RUN]')} Would flatten ${coin} (${actual})`); } + haltedCoins.add(coin); + continue; + } - // Wide slippage (5%) = effective market order for hedging. - // Entry price doesn't matter for hedges — only position size does. - const limitPx = (isBuy ? markPx * 1.05 : markPx * 0.95).toPrecision(5); + // Skip new orders when margin ceiling exceeded (but still monitor) + if (marginBlocked) continue; + + const isBuy = mismatch > 0; + const reduceOnly = isPureReduction(actual, mismatch); + const size = Math.abs(mismatch).toFixed(szDecimals); + const limitPx = (isBuy ? markPx * 1.05 : markPx * 0.95).toPrecision(5); + + // Classify action for log readability + const verb = actual === 0 + ? 'OPEN' + : reduceOnly + ? 'REDUCE' + : (Math.sign(mismatch) === Math.sign(actual) ? 'EXPAND' : 'FLIP'); + const sideLabel = isBuy ? chalk.green('BUY') : chalk.red('SELL'); + + const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); + console.log(`\n ${chalk.dim(ts)} ${chalk.bold(coin)} mismatch=${mismatch > 0 ? '+' : ''}${mismatch.toFixed(szDecimals)} → ${verb} ${sideLabel} ${size} (${chalk.dim(`$${mismatchNotional.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)})`); + + if (opts.dryRun) { + const slTag = (stopLossPct > 0 && actual === 0) ? ` +SL ${stopLossPct}%` : ''; + console.log(` ${chalk.yellow('[DRY RUN]')} Would place ${reduceOnly ? 'reduce-only ' : ''}${isBuy ? 'buy' : 'sell'} ${size} ${coin} @ ~$${markPx.toLocaleString()}${slTag}`); + continue; + } - const order: PerpsOrder = { - a: coin, - b: isBuy, - p: limitPx, - s: deltaSz, - r: false, - t: { limit: { tif: 'Ioc' } }, - }; - - const verb = action === 'CLOSE' ? 'Closing' : action === 'OPEN' ? 'Opening' : 'Adjusting'; - const spin = spinner(`${verb} ${targetSide} ${deltaSz} ${coin}…`); + // Set leverage once per coin + if (!leverageSet.has(coin)) { try { - const res = await perpsApi.placeOrders(creds.accessToken, { - orders: [order], - grouping: 'na', + await perpsApi.updateLeverage(creds.accessToken, { + symbol: coin, + isCross: true, + leverage: targetLeverage, subAccountId: walletId, }); - spin.stop(); - if (res.success) { - mirrored++; - success(` Mirrored → ${verb} ${targetSide} ${deltaSz} ${coin} @ ~$${markPx.toLocaleString()}`); - } else { - warn(` Failed: ${res.error?.message ?? 'Unknown error'}`); - } + leverageSet.add(coin); } catch (e) { - spin.stop(); - warn(` Error: ${String(e).slice(0, 100)}`); + warn(` Could not set leverage for ${coin}: ${String(e).slice(0, 80)}`); } } - lastPositions.set(source.key, current); + // Build orders (entry + optional SL trigger) + const orders: PerpsOrder[] = [{ + a: coin, + b: isBuy, + p: limitPx, + s: size, + r: reduceOnly, + t: { limit: { tif: 'Ioc' } }, + }]; + + // SL trigger only on brand-new opens + const isOpeningNew = actual === 0; + if (stopLossPct > 0 && isOpeningNew) { + const triggerPx = (isBuy + ? markPx * (1 - stopLossPct / 100) + : markPx * (1 + stopLossPct / 100) + ).toPrecision(5); + orders.push({ + a: coin, + b: !isBuy, // opposite side — triggers close + p: triggerPx, + s: size, + r: true, + t: { trigger: { triggerPx, tpsl: 'sl', isMarket: true } }, + }); + } + + const grouping: 'na' | 'normalTpsl' = orders.length > 1 ? 'normalTpsl' : 'na'; + const spin = spinner(`${verb} ${isBuy ? 'BUY' : 'SELL'} ${size} ${coin}…`); + try { + const res = await perpsApi.placeOrders(creds.accessToken, { + orders, + grouping, + subAccountId: walletId, + }); + spin.stop(); + if (res.success) { + reconciled++; + consecutiveFailures.set(coin, 0); + const slTag = (stopLossPct > 0 && isOpeningNew) ? ` (SL ${stopLossPct}%)` : ''; + success(` Reconciled ${coin} → ${isBuy ? 'buy' : 'sell'} ${size}${slTag}`); + } else { + consecutiveFailures.set(coin, fails + 1); + warn(` Failed (${fails + 1}/${maxRetries}): ${res.error?.message ?? 'Unknown error'}`); + } + } catch (e) { + spin.stop(); + consecutiveFailures.set(coin, fails + 1); + warn(` Error (${fails + 1}/${maxRetries}): ${String(e).slice(0, 100)}`); + } } } catch (e) { console.log(chalk.dim(` ${new Date().toLocaleTimeString('en-US', { hour12: false })} poll error: ${String(e).slice(0, 80)}`)); } pollCount++; - // Heartbeat every ~1 minute (6 polls at 10s, or adjust for interval) + // Heartbeat every ~60 seconds if (pollCount % Math.max(1, Math.round(60 / intervalSec)) === 0) { const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); - const summary = sources.map((s) => { - const pos = lastPositions.get(s.key); - const count = pos?.size ?? 0; - return `${s.label}: ${count} pos`; - }).join(', '); - console.log(chalk.dim(` ${ts} [heartbeat] poll #${pollCount}, ${summary}`)); + const failSummary = consecutiveFailures.size > 0 + ? [...consecutiveFailures.entries()].map(([c, n]) => `${c}:${n}`).join(' ') + : 'none'; + console.log(chalk.dim(` ${ts} [heartbeat] poll #${pollCount}, margin=${(lastMarginRatio * 100).toFixed(1)}%, reconciled=${reconciled}, halted=${haltedCoins.size}, fails=${failSummary}`)); } if (running) await new Promise((r) => setTimeout(r, intervalSec * 1000)); @@ -1405,7 +1597,10 @@ const mirrorCmd = new Command('mirror') process.off('SIGINT', shutdown); process.off('SIGTERM', shutdown); console.log(''); - info(`Mirror stopped. Total mirrored: ${mirrored}`); + const haltTag = haltedCoins.size > 0 + ? `, halted: ${[...haltedCoins].join(', ')}` + : ''; + info(`Mirror stopped. Reconciled: ${reconciled}${haltTag}`); })); // ─── leverage ──────────────────────────────────────────────────────────── diff --git a/tests/commands/perps.test.ts b/tests/commands/perps.test.ts index 52dc974..48555c9 100644 --- a/tests/commands/perps.test.ts +++ b/tests/commands/perps.test.ts @@ -33,6 +33,7 @@ vi.mock('../../src/api/perps.js', () => ({ getOpenOrders: vi.fn().mockResolvedValue([]), getUserFills: vi.fn().mockResolvedValue([]), getUserLeverage: vi.fn().mockResolvedValue([]), + getUserPositions: vi.fn().mockResolvedValue([]), placeOrders: vi.fn(), cancelOrders: vi.fn(), deposit: vi.fn(), @@ -767,3 +768,205 @@ describe('perps leverage command', () => { logSpy.mockRestore(); }); }); + +// ─── mirror (net-exposure reconciliation) ──────────────────────────────── + +describe('perps mirror command (reconciliation)', () => { + const mockPlaceOrders = vi.mocked(perpsApi.placeOrders); + const mockGetUserPositions = vi.mocked(perpsApi.getUserPositions); + const mockGetAccountSummary = vi.mocked(perpsApi.getAccountSummary); + // External address used as a source (triggers getUserPositions path) + const EXT_ADDR = '0x' + 'a'.repeat(40); + const ASSETS = [ + { name: 'BTC', maxLeverage: 50, szDecimals: 5, markPx: 60000 }, + ]; + + // The default wallet (WALLET_DEFAULT, isDefault:true) is resolved with + // walletId=undefined, which makes fetchTargetState use getAccountSummary + // instead of getSubAccountSummary. Tests that override target state must + // therefore set mockGetAccountSummary, not mockGetSubAccountSummary. + function setTargetState(assetPositions: { position: { coin: string; szi: string; entryPx?: string } }[], totalMarginUsed = '0', accountValue = '1000') { + mockGetAccountSummary.mockResolvedValue({ + success: true, + data: { + marginSummary: { accountValue, totalNtlPos: '0', totalMarginUsed }, + withdrawable: '500', + assetPositions, + }, + } as never); + } + + beforeEach(() => { + vi.clearAllMocks(); + mockRequireAuth.mockReturnValue({ accessToken: 'test-token' }); + mockListSubAccounts.mockResolvedValue({ success: true, data: [WALLET_DEFAULT] as never }); + // Default target: empty, healthy margin + setTargetState([]); + mockGetAssetMeta.mockResolvedValue(ASSETS); + mockGetUserPositions.mockResolvedValue([]); + mockPlaceOrders.mockResolvedValue({ success: true, data: { raw_data: [] } }); + mockUpdateLeverage.mockResolvedValue({ success: true, data: undefined }); + }); + + /** + * Run mirror for a bounded number of polls, then exit via SIGINT. + * `iterations` controls how many full polls complete before SIGINT fires. + * + * Commander v12 subcommands accumulate boolean flag values across parseAsync + * calls on the same instance, so we manually clear `_optionValues` before + * each parse to guarantee a clean option state. + */ + async function runMirror(args: string[], iterations = 1): Promise { + let polls = 0; + mockGetAssetMeta.mockImplementation(async () => { + polls++; + if (polls >= iterations) process.emit('SIGINT', 'SIGINT'); + return ASSETS; + }); + + const cmd = await getCmd('mirror'); + // Wipe Commander's cached option values so this test gets fresh defaults. + (cmd as unknown as { _optionValues: Record })._optionValues = {}; + + const output: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...a) => output.push(a.join(' '))); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation((...a) => output.push(a.join(' '))); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await cmd.parseAsync(args, { from: 'user' }); + return output.join('\n'); + } finally { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errSpy.mockRestore(); + } + } + + it('should exit cleanly when user declines confirmation', async () => { + mockConfirm.mockResolvedValueOnce(false as never); + + const cmd = await getCmd('mirror'); + (cmd as unknown as { _optionValues: Record })._optionValues = {}; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cmd.parseAsync(['-w', 'Main', '--source', EXT_ADDR], { from: 'user' }); + + expect(mockConfirm).toHaveBeenCalled(); + expect(mockPlaceOrders).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + }); + + it('dry-run: should detect source long and propose target short (no orders)', async () => { + mockGetUserPositions.mockResolvedValue([ + { coin: 'BTC', szi: 0.5, entryPx: 60000, unrealizedPnl: 0 }, + ]); + + const full = await runMirror( + ['--dry-run', '-y', '-w', 'Main', '--source', EXT_ADDR, '-i', '3'], + 1, + ); + + expect(full).toContain('BTC'); + expect(full).toContain('mismatch='); + expect(full).toContain('DRY RUN'); + // No leverage set, no orders placed in dry-run + expect(mockUpdateLeverage).not.toHaveBeenCalled(); + expect(mockPlaceOrders).not.toHaveBeenCalled(); + }); + + it('dry-run: should mark REDUCE / reduce-only when closing an existing hedge', async () => { + // Target already has BTC short (existing hedge) + setTargetState( + [{ position: { coin: 'BTC', szi: '-0.5', entryPx: '60000' } }], + '200', + ); + // Source has gone flat (closed long) + mockGetUserPositions.mockResolvedValue([]); + + const full = await runMirror( + ['--dry-run', '-y', '-w', 'Main', '--source', EXT_ADDR, '-i', '3'], + 1, + ); + + expect(full).toContain('REDUCE'); + expect(full).toContain('reduce-only'); + expect(mockPlaceOrders).not.toHaveBeenCalled(); + }); + + it('should block new orders when target margin ratio exceeds ceiling', async () => { + // marginRatio = 900/1000 = 0.9 > default 0.80 + setTargetState([], '900', '1000'); + mockGetUserPositions.mockResolvedValue([ + { coin: 'BTC', szi: 0.5, entryPx: 60000, unrealizedPnl: 0 }, + ]); + + const full = await runMirror( + ['--dry-run', '-y', '-w', 'Main', '--source', EXT_ADDR, '-i', '3', '--margin-ceiling', '0.80'], + 1, + ); + + expect(full).toContain('exceeds ceiling'); + expect(mockPlaceOrders).not.toHaveBeenCalled(); + }); + + it('should flatten + halt a coin after exceeding --max-retries', async () => { + // Target has an existing BTC short that needs to be closed (source flat). + // Mismatch tries to REDUCE the hedge but every order fails — after + // max-retries, the existing target position must be flattened. + setTargetState( + [{ position: { coin: 'BTC', szi: '-0.5', entryPx: '60000' } }], + '200', + ); + mockGetUserPositions.mockResolvedValue([]); + // Every order fails + mockPlaceOrders.mockResolvedValue({ + success: false, error: { code: 500, message: 'Test failure' }, + }); + + // max-retries=2: iter1 (fails=0→1), iter2 (fails=1→2), iter3 (2>=2 → flatten+halt) + const full = await runMirror( + ['-y', '-w', 'Main', '--source', EXT_ADDR, '-i', '3', '--max-retries', '2'], + 3, + ); + + expect(full).toContain('exceeded 2 consecutive failures'); + // 2 REDUCE attempts + 1 flatten attempt = 3 placeOrders calls + expect(mockPlaceOrders.mock.calls.length).toBeGreaterThanOrEqual(3); + // Final flatten call should be reduce-only + const lastCall = mockPlaceOrders.mock.calls.at(-1)?.[1]; + expect(lastCall.orders[0].r).toBe(true); + }, 15000); + + it('baseline-only: should NOT hedge source positions present at startup', async () => { + // Source has BTC position that does NOT change + mockGetUserPositions.mockResolvedValue([ + { coin: 'BTC', szi: 0.5, entryPx: 60000, unrealizedPnl: 0 }, + ]); + + const full = await runMirror( + ['--dry-run', '-y', '-w', 'Main', '--source', EXT_ADDR, '-i', '3', '--baseline-only'], + 1, + ); + + expect(full).toContain('Baseline set'); + // No mismatch to reconcile — baseline = current source positions + expect(full).not.toMatch(/mismatch=/); + expect(mockPlaceOrders).not.toHaveBeenCalled(); + }); + + it('should attach SL trigger on new opens when --stop-loss is set', async () => { + mockGetUserPositions.mockResolvedValue([ + { coin: 'BTC', szi: 0.5, entryPx: 60000, unrealizedPnl: 0 }, + ]); + + const full = await runMirror( + ['--dry-run', '-y', '-w', 'Main', '--source', EXT_ADDR, '-i', '3', '--stop-loss', '5'], + 1, + ); + + // Dry-run log should mention the SL attachment on a brand-new open + expect(full).toMatch(/SL 5%/); + }); +});