UNPKG

@debate300/bithumb

Version:

A real-time cryptocurrency price tracker for Bithumb.

455 lines (454 loc) 20.6 kB
#!/usr/bin/env node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const ws_1 = __importDefault(require("ws")); const chalk_1 = __importDefault(require("chalk")); const cli_table3_1 = __importDefault(require("cli-table3")); const fs = __importStar(require("fs")); const os = __importStar(require("os")); const path = __importStar(require("path")); const axios_1 = __importDefault(require("axios")); const node_notifier_1 = __importDefault(require("node-notifier")); const child_process_1 = require("child_process"); // Function to ensure config file exists function ensureConfigFile() { const homeDir = os.homedir(); const configDir = path.join(homeDir, ".debate300"); const configFilePath = path.join(configDir, "config.json"); // 1. Check if ~/.debate300 directory exists, if not create it. if (!fs.existsSync(configDir)) { try { fs.mkdirSync(configDir, { recursive: true }); } catch (error) { console.error(chalk_1.default.red(`Error creating config directory at ${configDir}:`), error); process.exit(1); } } // 2. Check if config.json exists in the directory. if (!fs.existsSync(configFilePath)) { // If not, create it with default content from config.json-top30 try { const defaultConfigPath = path.join(__dirname, "..", "config.json-top30"); const defaultConfigContent = fs.readFileSync(defaultConfigPath, "utf8"); fs.writeFileSync(configFilePath, defaultConfigContent, "utf8"); console.log(chalk_1.default.green(`Default config file created at ${configFilePath}`)); } catch (error) { console.error(chalk_1.default.red(`Error creating default config file:`), error); process.exit(1); } } } // Ensure the config file is in place before doing anything else. ensureConfigFile(); // 커맨드 라인 인수 처리 const args = process.argv.slice(2); let sortBy = "rate"; // 기본 정렬: 변동률 let displayLimit = 30; // 기본 표시 갯수 const sortByArgIndex = args.indexOf("--sort-by"); if (sortByArgIndex > -1 && args[sortByArgIndex + 1]) { const sortArg = args[sortByArgIndex + 1]; // 허용된 정렬 옵션인지 확인 if (["name", "rate"].includes(sortArg)) { sortBy = sortArg; } else { console.log(chalk_1.default.yellow(`Warning: Invalid sort option '${sortArg}'. Defaulting to 'rate'.`)); } } const limitArgIndex = args.indexOf("--limit"); if (limitArgIndex > -1 && args[limitArgIndex + 1]) { const limitArg = parseInt(args[limitArgIndex + 1], 10); if (!isNaN(limitArg) && limitArg > 0) { displayLimit = limitArg; } else { console.log(chalk_1.default.yellow(`Warning: Invalid limit option '${args[limitArgIndex + 1]}'. Using default of ${displayLimit}.`)); } } // Define icon map let iconMap = {}; let marketInfo = {}; let appConfig; function loadConfig() { const currentDirConfigPath = path.join(process.cwd(), "config.json"); const homeDirConfigPath = path.join(os.homedir(), ".debate300", "config.json"); let configContent; let configPathUsed; if (fs.existsSync(currentDirConfigPath)) { configContent = fs.readFileSync(currentDirConfigPath, "utf8"); configPathUsed = currentDirConfigPath; } else if (fs.existsSync(homeDirConfigPath)) { configContent = fs.readFileSync(homeDirConfigPath, "utf8"); configPathUsed = homeDirConfigPath; } else { console.error(chalk_1.default.red("오류: 'config.json' 파일을 찾을 수 없습니다.")); console.error(chalk_1.default.yellow("다음 위치에서 파일을 확인했습니다:")); console.error(chalk_1.default.yellow(` - 현재 디렉토리: ${currentDirConfigPath}`)); console.error(chalk_1.default.yellow(` - 홈 디렉토리: ${homeDirConfigPath}`)); console.error(chalk_1.default.yellow("debate300을 실행하려면 위 경로 중 한 곳에 'config.json' 파일을 생성해야 합니다.")); process.exit(1); } try { return JSON.parse(configContent); } catch (error) { console.error(chalk_1.default.red(`오류: '${configPathUsed}' 파일의 형식이 올바르지 않습니다. JSON 파싱 오류:`), error); process.exit(1); } } appConfig = loadConfig(); // Populate iconMap after appConfig is loaded appConfig.coins.forEach((coin) => { iconMap[`${coin.symbol}_${coin.unit_currency}`] = coin.icon; }); // 구독할 코인 목록 (예: BTC_KRW, ETH_KRW) const symbols = appConfig.coins.map((coin) => `${coin.symbol}_${coin.unit_currency}`); // Function to fetch market names from Bithumb API async function fetchMarketInfo() { try { const response = await axios_1.default.get("https://api.bithumb.com/v1/market/all?isDetails=false"); if (response.status === 200) { const markets = response.data; markets.forEach((market) => { if (market.market.startsWith("KRW-")) { const symbol = `${market.market.replace("KRW-", "")}_KRW`; marketInfo[symbol] = { market: market.market, korean_name: market.korean_name, english_name: market.english_name, }; } }); } } catch (error) { // console.error(chalk.red("한글 코인 이름 로딩 오류:"), error); } } // Bithumb WebSocket URL const wsUri = "wss://pubwss.bithumb.com/pub/ws"; // 실시간 시세 데이터를 저장할 객체 const realTimeData = {}; let redrawTimeout = null; const lastNotificationLevels = {}; // 콘솔을 지우고 테이블을 다시 그리는 함수 function redrawTable() { // 테이블 생성 const table = new cli_table3_1.default({ head: [ chalk_1.default.magentaBright("코인"), chalk_1.default.magentaBright("현재가"), chalk_1.default.magentaBright("체결강도"), // volumePower chalk_1.default.magentaBright("수익률"), // Profit/Loss Rate chalk_1.default.magentaBright("전일대비"), chalk_1.default.magentaBright("전일대비금액"), chalk_1.default.magentaBright("전일종가"), chalk_1.default.magentaBright("고가"), // High Price chalk_1.default.magentaBright("저가"), // Low Price ], colWidths: [22, 15, 10, 10, 15, 18, 15, 20, 20], }); // 저장된 실시간 데이터로 테이블 채우기 // --sort-by 인수에 따라 정렬. 기본은 변동률순. const sortedSymbols = Object.keys(realTimeData).sort((a, b) => { if (sortBy === "name") { return a.localeCompare(b); // 이름순 } // 기본 정렬: 변동률 기준 내림차순 const rateA = parseFloat(realTimeData[a].chgRate); const rateB = parseFloat(realTimeData[b].chgRate); return rateB - rateA; }); const displaySymbols = sortedSymbols.length > displayLimit ? sortedSymbols.slice(0, displayLimit) : sortedSymbols; for (const symbol of displaySymbols) { // Iterate over sorted symbols const data = realTimeData[symbol]; // console.log(data); // Removed for cleaner output after debugging const price = parseFloat(data.closePrice).toLocaleString("ko-KR"); const prevPrice = parseFloat(data.lastClosePrice || data.prevClosePrice); // Use lastClosePrice for comparison, fallback to prevClosePrice if not available const changeRate = parseFloat(data.chgRate); const changeAmount = parseFloat(data.chgAmt); let priceColor = chalk_1.default.white; // Default color for price const currentClosePrice = parseFloat(data.closePrice); //console.log("=============>", currentClosePrice, prevPrice); if (currentClosePrice > prevPrice) { priceColor = chalk_1.default.redBright; // Price increased } else if (currentClosePrice < prevPrice) { priceColor = chalk_1.default.cyanBright; // Price decreased } let rateColor = chalk_1.default.white; if (changeRate > 0) { rateColor = chalk_1.default.green; // 상승 } else if (changeRate < 0) { rateColor = chalk_1.default.red; // 하락 } const icon = iconMap[symbol] || " "; // Get icon, default to space if not found const koreanName = marketInfo[symbol]?.korean_name; const displayName = koreanName ? `${symbol.replace("_KRW", "")} ${koreanName}` : symbol; const coinConfig = appConfig.coins.find((c) => `${c.symbol}_${c.unit_currency}` === symbol); let profitLossRate; let profitLossColor = chalk_1.default.white; if (coinConfig && coinConfig.averagePurchasePrice > 0) { const currentPrice = parseFloat(data.closePrice); const avgPrice = coinConfig.averagePurchasePrice; const rate = ((currentPrice - avgPrice) / avgPrice) * 100; profitLossRate = `${rate.toFixed(2)}%`; if (rate > 0) { profitLossColor = chalk_1.default.green; } else if (rate < 0) { profitLossColor = chalk_1.default.red; } } else { // If averagePurchasePrice is 0 or undefined, show change rate const changeRateValue = parseFloat(data.chgRate); profitLossRate = `-`; if (changeRateValue > 0) { profitLossColor = chalk_1.default.green; } else if (changeRateValue < 0) { profitLossColor = chalk_1.default.red; } } const prevClosePriceNum = parseFloat(data.prevClosePrice); const highPriceNum = parseFloat(data.highPrice); const lowPriceNum = parseFloat(data.lowPrice); const highPricePercent = prevClosePriceNum > 0 ? ((highPriceNum - prevClosePriceNum) / prevClosePriceNum) * 100 : 0; const lowPricePercent = prevClosePriceNum > 0 ? ((lowPriceNum - prevClosePriceNum) / prevClosePriceNum) * 100 : 0; const highPriceDisplay = `${highPricePercent >= 0 ? chalk_1.default.green(`+${highPricePercent.toFixed(2)}%`) : chalk_1.default.red(`${highPricePercent.toFixed(2)}%`)} (${highPriceNum.toLocaleString("ko-KR")})`; const lowPriceDisplay = `${lowPricePercent >= 0 ? chalk_1.default.green(`+${lowPricePercent.toFixed(2)}%`) : chalk_1.default.red(`${lowPricePercent.toFixed(2)}%`)} (${lowPriceNum.toLocaleString("ko-KR")})`; table.push([ chalk_1.default.yellow(`${icon} ${displayName}`), priceColor(`${price} KRW`), parseFloat(data.volumePower).toFixed(2), profitLossColor(profitLossRate), // Profit/Loss Rate rateColor(`${changeRate.toFixed(2)}%`), rateColor(`${changeAmount.toLocaleString("ko-KR")} KRW`), prevClosePriceNum.toLocaleString("ko-KR"), highPriceDisplay, lowPriceDisplay, ]); } // Calculate overall market sentiment let totalWeightedChange = 0; let totalVolume = 0; for (const symbol of Object.keys(realTimeData)) { const data = realTimeData[symbol]; const chgRate = parseFloat(data.chgRate); const tradeValue = parseFloat(data.value); // Using trade value as weight if (!isNaN(chgRate) && !isNaN(tradeValue) && tradeValue > 0) { totalWeightedChange += chgRate * tradeValue; totalVolume += tradeValue; // totalVolume 대신 totalValue로 변경 } } let marketSentiment = ""; let sentimentColor = chalk_1.default.white; if (totalVolume > 0) { const averageChange = totalWeightedChange / totalVolume; if (averageChange > 0.5) { // Threshold for significant upward trend marketSentiment = "전체 시장: 강한 상승세 🚀"; sentimentColor = chalk_1.default.green; } else if (averageChange > 0) { marketSentiment = "전체 시장: 상승세 📈"; sentimentColor = chalk_1.default.green; } else if (averageChange < -0.5) { // Threshold for significant downward trend marketSentiment = "전체 시장: 강한 하락세 🙇"; sentimentColor = chalk_1.default.red; } else if (averageChange < 0) { marketSentiment = "전체 시장: 하락세 📉"; sentimentColor = chalk_1.default.red; } else { marketSentiment = "전체 시장: 보합세 ↔️"; sentimentColor = chalk_1.default.white; } const volumePowers = Object.values(realTimeData) .map((data) => parseFloat(data.volumePower)) .filter((vp) => !isNaN(vp)); const averageVolumePower = volumePowers.length > 0 ? volumePowers.reduce((sum, vp) => sum + vp, 0) / volumePowers.length : 0; marketSentiment += ` | 체결강도: ${averageVolumePower.toFixed(2)}`; } else { marketSentiment = "전체 시장: 데이터 부족"; sentimentColor = chalk_1.default.gray; } // 화면 출력을 위한 버퍼 생성 const output = []; output.push(chalk_1.default.bold("Bithumb 실시간 시세 (Ctrl+C to exit) - Debate300.com")); output.push(sentimentColor(marketSentiment)); // Display market sentiment output.push(table.toString()); if (sortedSymbols.length > displayLimit) { output.push(chalk_1.default.yellow(`참고: 시세 표시가 ${displayLimit}개로 제한되었습니다. (총 ${sortedSymbols.length}개)`)); } // 콘솔을 지우고 한 번에 출력하여 깜빡임 최소화 process.stdout.write("\x1B[H\x1B[J" + output.join("\n")); } function sendNotification(title, message) { if (os.platform() === 'darwin') { const escapedTitle = title.replace(/"/g, '"'); const escapedMessage = message.replace(/"/g, '"'); const command = `osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}" sound name "Ping"'`; (0, child_process_1.exec)(command, (error) => { if (error) { console.error(`[Notification Error] Failed to execute osascript. Please ensure you are on macOS and that your terminal has notification permissions.`); console.error(`[Notification Error] Details: ${error.message}`); } }); } else { // Fallback to node-notifier for other platforms node_notifier_1.default.notify({ title: title, message: message, sound: true, wait: false }); } } function connect() { const ws = new ws_1.default(wsUri); ws.on("open", () => { console.log(chalk_1.default.green("Bithumb WebSocket에 연결되었습니다.")); // 구독 메시지 전송 const subscribeMsg = { type: "ticker", symbols: symbols, tickTypes: ["MID"], // 자정 기준 변동률 }; ws.send(JSON.stringify(subscribeMsg)); }); ws.on("message", (data) => { const message = JSON.parse(data.toString()); if (message.type === "ticker" && message.content) { const content = message.content; // Store the current closePrice as lastClosePrice for the next update if (realTimeData[content.symbol]) { content.lastClosePrice = realTimeData[content.symbol].closePrice; } else { // For the first message, set lastClosePrice to current closePrice content.lastClosePrice = content.closePrice; } // 실시간 데이터 업데이트 realTimeData[content.symbol] = content; // Notification logic const changeRate = parseFloat(content.chgRate); const symbol = content.symbol; if (!lastNotificationLevels[symbol]) { lastNotificationLevels[symbol] = { positive: 0, negative: 0 }; } const currentLevel = Math.floor(Math.abs(changeRate) / 5); if (changeRate > 0) { if (currentLevel > lastNotificationLevels[symbol].positive) { const koreanName = marketInfo[symbol]?.korean_name || symbol; const price = parseFloat(content.closePrice).toLocaleString("ko-KR"); const notificationLevel = currentLevel * 5; const title = `코인 가격 상승 알림`; const message = `${koreanName}이(가) ${notificationLevel}% 이상 상승했습니다. 현재가: ${price} KRW (${changeRate.toFixed(2)}%)`; sendNotification(title, message); lastNotificationLevels[symbol].positive = currentLevel; lastNotificationLevels[symbol].negative = 0; // Reset negative level on positive change } } else if (changeRate < 0) { if (currentLevel > lastNotificationLevels[symbol].negative) { const koreanName = marketInfo[symbol]?.korean_name || symbol; const price = parseFloat(content.closePrice).toLocaleString("ko-KR"); const notificationLevel = currentLevel * 5; const title = `코인 가격 하락 알림`; const message = `${koreanName}이(가) ${notificationLevel}% 이상 하락했습니다. 현재가: ${price} KRW (${changeRate.toFixed(2)}%)`; sendNotification(title, message); lastNotificationLevels[symbol].negative = currentLevel; lastNotificationLevels[symbol].positive = 0; // Reset positive level on negative change } } // 깜빡임 감소를 위해 redrawTable 호출을 디바운스합니다. if (!redrawTimeout) { redrawTimeout = setTimeout(() => { redrawTable(); redrawTimeout = null; }, 100); // 100ms 간격으로 다시 그립니다. } } }); ws.on("error", (error) => { console.error(chalk_1.default.red("WebSocket 오류 발생:"), error); }); ws.on("close", () => { console.log(chalk_1.default.yellow("WebSocket 연결이 종료되었습니다. 5초 후 재연결합니다.")); setTimeout(connect, 5000); }); } async function start() { await fetchMarketInfo(); connect(); } // 프로그램 시작 start(); // Graceful shutdown process.on("SIGINT", () => { console.log(chalk_1.default.blue("\n프로그램을 종료합니다.")); process.exit(0); });