@debate300/bithumb
Version:
A real-time cryptocurrency price tracker for Bithumb.
455 lines (454 loc) • 20.6 kB
JavaScript
;
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);
});