/******************************************************************** * trade.js — Bitget AutoTrade Server (COMMONJS) * -------------------------------------------------------------- * [MOD] productType/marginMode 패스스루 유지 * [MOD] quantity 별칭 지원 (quantity || size || quote → Bitget size) 유지 * [MOD] Bitget v2 서명(ACCESS-SIGN) 유지 * [FIX] reduceOnly를 Bitget 요청 본문에서 **항상 제외** ← 문제 원인 해결 * [MOD] mix/spot 라우트 await/에러 처리 보강 유지 ********************************************************************/ require("dotenv").config(); /* [MOD] 환경변수 지원 */ const fs = require("fs"); const path = require("path"); const express = require("express"); const cors = require("cors"); const crypto = require("crypto"); const fetch = global.fetch || require("node-fetch"); const { PolicyGate } = require("./gate"); /* ======================= Config Loader ======================= */ const CONFIG_PATH = path.join(__dirname, "config.json"); function loadConfig() { const base = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); if (process.env.TRADE_PORT) base.trade_port = Number(process.env.TRADE_PORT); if (process.env.DRY_RUN) base.dry_run = String(process.env.DRY_RUN).toLowerCase() === "true"; if (process.env.KILL_SWITCH) base.kill_switch = String(process.env.KILL_SWITCH).toLowerCase() === "true"; if (process.env.WHITELIST) base.whitelist = process.env.WHITELIST.split(",").map((s) => s.trim()); if (process.env.API_KEY) base.api_key = process.env.API_KEY; if (process.env.API_SECRET) base.api_secret = process.env.API_SECRET; if (process.env.PASSPHRASE) base.passphrase = process.env.PASSPHRASE; return base; } let config = loadConfig(); /* ======================= App Bootstrap ======================= */ const app = express(); app.use(express.json()); app.use( cors({ origin: (origin, cb) => { const wl = config?.whitelist || []; if (!origin || wl.includes("*") || wl.includes(origin)) return cb(null, true); return cb(new Error("FORBIDDEN_ORIGIN"), false); }, }) ); /* [MOD] OpsDeck: SSE client registry */ const sseClients = new Set(); /* [MOD] OpsDeck: Server-Sent Events stream */ app.get("/events", (req, res) => { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.flushHeaders?.(); const client = { id: Date.now(), res }; sseClients.add(client); // initial message res.write( `event: connected\ndata: ${JSON.stringify({ ok: true, at: Date.now() })}\n\n` ); req.on("close", () => { sseClients.delete(client); }); }); /* [MOD] OpsDeck: log rebroadcast */ app.post("/log", (req, res) => { const { level = "info", message = "", meta = {} } = req.body || {}; const payload = { t: Date.now(), level, message, meta }; for (const c of sseClients) { try { c.res.write(`event: log\ndata: ${JSON.stringify(payload)}\n\n`); } catch {} } res.json({ ok: true }); }); /* [MOD] OpsDeck: heartbeat every 5s */ setInterval(() => { const hb = { ts: Date.now(), uptime_s: (process.uptime() || 0).toFixed(1), clients: sseClients.size, }; for (const c of sseClients) { try { c.res.write(`event: heartbeat\ndata: ${JSON.stringify(hb)}\n\n`); } catch {} } }, 5000); const gate = new PolicyGate(config); let lastActivityAt = Date.now(); /* ======================= Bitget Sign v2 ======================= */ function signV2({ timestamp, method, pathOnly, bodyStr, secret }) { const prehash = String(timestamp) + method.toUpperCase() + pathOnly + (bodyStr || ""); return crypto.createHmac("sha256", secret).update(prehash).digest("base64"); } async function bitgetPrivateCall({ method = "POST", pathOnly, bodyObj }) { const baseUrl = "https://api.bitget.com"; const bodyStr = bodyObj ? JSON.stringify(bodyObj) : ""; const ts = Date.now().toString(); const sign = signV2({ timestamp: ts, method, pathOnly, bodyStr, secret: config.api_secret, }); const headers = { "Content-Type": "application/json", "ACCESS-KEY": config.api_key, "ACCESS-SIGN": sign, "ACCESS-TIMESTAMP": ts, "ACCESS-PASSPHRASE": config.passphrase, }; const url = baseUrl + pathOnly; const resp = await fetch(url, { method, headers, body: method === "GET" ? undefined : bodyStr, }); const txt = await resp.text(); let json = null; try { json = JSON.parse(txt); } catch { // not json } if (!resp.ok) { const err = new Error( `Bitget ${resp.status} ${resp.statusText} ${json?.msg || ""}`.trim() ); err.status = resp.status; err.payload = json || txt; throw err; } return json || { raw: txt }; } /* ======================= Policy helpers ======================= */ function isCloseOnly(order) { return !!( order?.reduceOnly || order?.closeOnly === true || order?.intent === "close" ); } async function checkPolicy(order) { const policy = await gate.canExecute(order || {}); if (!policy?.allow) throw Object.assign(new Error(policy?.reason_code || "DENY"), { policy }); return policy; } /* ======================= Health ======================= */ app.get("/health", (req, res) => { const uptime = (process.uptime() || 0).toFixed(1); const snapshotAge = Date.now() - (gate.lastSnapshotAt || Date.now()); res.json({ ok: true, type: "trade", uptime_s: uptime, safeMode: gate.safeMode, lastSnapshotAge_ms: snapshotAge, lastActivityAt, }); }); /* ======================= MIX: Market Order ======================= */ app.post("/mix/market-order", async (req, res) => { try { const { symbol, side, /* [MOD] Bitget 필드 패스스루 */ productType /* e.g., "USDT-FUTURES" */, marginMode /* "crossed" | "isolated" */, /* [MOD] 수량 별칭 지원 */ quantity /* 우선순위 1 */, size /* 우선순위 2 */, quote /* 우선순위 3 (기존 호환) */, /* 기존 플래그 */ intent /* "open" | "close" */, reduceOnly /* boolean - [FIX] 본문 전송에서 항상 제외 */, dryRun, } = req.body || {}; if (!symbol || !side) { return res.status(400).json({ ok: false, error: "symbol/side required" }); } let normalizedSize = quantity ?? size ?? quote; if (!normalizedSize) { return res .status(400) .json({ ok: false, error: "size/quantity/quote required" }); } const order = { symbol: String(symbol || "").toUpperCase(), side: String(side || "").toUpperCase(), // buy/sell notional: Number(quote || 0), reduceOnly: !!reduceOnly, intent, }; // policy const policy = await checkPolicy(order); lastActivityAt = Date.now(); if (config?.kill_switch && !isCloseOnly(order)) { return res.status(403).json({ ok: false, error: "KILL_SWITCH" }); } if (config?.dry_run || dryRun) { // echo return res.json({ ok: true, dryRun: true, policy, payload: { path: "/api/v2/mix/order/place-order", body: { symbol: order.symbol, side: order.side, marginMode: marginMode || "isolated", productType: productType || "USDT-FUTURES", size: Number(normalizedSize), orderType: "market", }, }, }); } const bodyObj = { symbol: order.symbol, side: order.side, marginMode: marginMode || "isolated", productType: productType || "USDT-FUTURES", size: Number(normalizedSize), orderType: "market", }; const apiResp = await bitgetPrivateCall({ method: "POST", pathOnly: "/api/v2/mix/order/place-order", bodyObj, }); res.json({ ok: true, dryRun: false, result: apiResp }); } catch (err) { console.error("[mix/market-order] error:", err); res.status(err.status || 500).json({ ok: false, error: err.message, payload: err.payload, policy: err.policy, }); } }); /* ======================= SPOT: Market Order ======================= */ app.post("/spot/market-order", async (req, res) => { try { const { symbol, side, /* [MOD] 수량 별칭 지원 */ quantity /* 우선순위 1 */, size /* 우선순위 2 */, quote /* 우선순위 3 */, intent, reduceOnly, // [FIX] 본문에서 제외 dryRun, } = req.body || {}; if (!symbol || !side) { return res.status(400).json({ ok: false, error: "symbol/side required" }); } let normalizedSize = quantity ?? size ?? quote; if (!normalizedSize) { return res .status(400) .json({ ok: false, error: "size/quantity/quote required" }); } const order = { symbol: String(symbol || "").toUpperCase(), side: String(side || "").toUpperCase(), notional: Number(quote || 0), reduceOnly: !!reduceOnly, intent, }; const policy = await checkPolicy(order); lastActivityAt = Date.now(); if (config?.dry_run || dryRun) { return res.json({ ok: true, dryRun: true, policy, payload: { path: "/api/v2/spot/order/place-order", body: { symbol: order.symbol, side: order.side, size: Number(normalizedSize), orderType: "market", }, }, }); } const bodyObj = { symbol, side, size: Number(normalizedSize), orderType: "market", }; const apiResp = await bitgetPrivateCall({ method: "POST", pathOnly: "/api/v2/spot/order/place-order", bodyObj, }); res.json({ ok: true, dryRun: false, result: apiResp }); } catch (err) { console.error("[spot/market-order] error:", err); res .status(err.status || 500) .json({ ok: false, error: err.message, payload: err.payload }); } }); /* ======================= Listen ======================= */ const PORT = config?.trade_port || 8789; app.listen(PORT, () => console.log(`[trade] Trade server running on :${PORT}`));