@debate300/bithumb-pro
Version:
A real-time cryptocurrency price tracker for Bithumb (Pro).
989 lines (988 loc) • 43.4 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 jwt = __importStar(require("jsonwebtoken"));
const uuid_1 = require("uuid");
const node_notifier_1 = __importDefault(require("node-notifier"));
const child_process_1 = require("child_process");
const readline = __importStar(require("readline"));
const crypto = __importStar(require("crypto"));
const querystring = __importStar(require("querystring"));
let currentView = "market";
// 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();
// 프로그램 시작 시 커서를 숨깁니다.
process.stdout.write("\x1B[?25l");
// 프로그램 종료 시 커서가 다시 보이도록 보장합니다.
process.on("exit", () => {
process.stdout.write("\x1B[?25h");
});
process.on("SIGINT", () => {
process.exit();
});
// 커맨드 라인 인수 처리
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", "my"].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 userPoints = 0;
let krwBalance = 0;
let krwLocked = 0;
let appConfig;
let apiConfig = null;
let fetchUserHoldingsErrorCount = 0;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "> ",
});
function loadConfig() {
const currentDirConfigPath = path.join(process.cwd(), "config.json");
const homeDirConfigPath = path.join(os.homedir(), ".debate300", "config.json");
const homeDirApiKeysPath = path.join(os.homedir(), ".debate300", "api_keys.json");
// Check for api_keys.json and handle it first
if (!fs.existsSync(homeDirApiKeysPath)) {
const defaultApiKeys = {
bithumb_api_key: "YOUR_API_KEY",
bithumb_secret_key: "YOUR_SECRET_KEY",
};
fs.writeFileSync(homeDirApiKeysPath, JSON.stringify(defaultApiKeys, null, 2), "utf8");
console.error(chalk_1.default.red("API 키 파일이 없어 기본 파일을 생성했습니다."));
console.error(chalk_1.default.yellow(`파일 위치: ${homeDirApiKeysPath}`));
console.error(chalk_1.default.yellow("파일을 열어 본인의 빗썸 API 키를 입력해주세요."));
console.error(chalk_1.default.yellow("API 키 발급은 README.md 파일을 참고하세요."));
process.exit(1);
}
const apiConfigContent = fs.readFileSync(homeDirApiKeysPath, "utf8");
apiConfig = JSON.parse(apiConfigContent);
if (!apiConfig ||
apiConfig.bithumb_api_key === "YOUR_API_KEY" ||
apiConfig.bithumb_secret_key === "YOUR_SECRET_KEY") {
console.error(chalk_1.default.red("빗썸 API 키가 설정되지 않았습니다."));
console.error(chalk_1.default.yellow(`파일 위치: ${homeDirApiKeysPath}`));
console.error(chalk_1.default.yellow("파일을 열어 본인의 빗썸 API 키를 입력해주세요."));
console.error(chalk_1.default.yellow("API 키 발급은 README.md 파일을 참고하세요."));
process.exit(1);
}
// Proceed with loading 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 {
// This part should not be reached if ensureConfigFile works correctly
console.error(chalk_1.default.red("오류: 'config.json' 파일을 찾을 수 없습니다."));
process.exit(1);
}
console.log(chalk_1.default.green("API keys loaded successfully. Attempting to fetch user holdings from Bithumb API."));
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 || "KRW")] = coin.icon; // unit_currency 추가
});
// 구독할 코인 목록 (예: BTC, ETH, XRP)
let symbols = appConfig.coins.map((coin) => coin.symbol + "_" + (coin.unit_currency || "KRW")); // unit_currency 추가
// Bithumb API Base URL (for v1 API)
const BITHUMB_API_BASE_URL = "https://api.bithumb.com";
// Function to fetch user holdings from Bithumb API
async function fetchUserHoldings() {
if (!apiConfig) {
// console.log(chalk.yellow("API keys not available. Cannot fetch user holdings."));
return [];
}
const currentApiConfig = apiConfig;
if (!currentApiConfig.bithumb_api_key ||
!currentApiConfig.bithumb_secret_key) {
console.log(chalk_1.default.yellow("Bithumb API key or secret is missing. Cannot fetch user holdings.\n"));
return [];
}
const endpoint = "/v1/accounts"; // 계좌 정보 엔드포인트
const fullUrl = `${BITHUMB_API_BASE_URL}${endpoint}`;
// JWT 토큰 생성
const payload = {
access_key: currentApiConfig.bithumb_api_key,
nonce: (0, uuid_1.v4)(),
timestamp: Date.now(),
};
const jwtToken = jwt.sign(payload, currentApiConfig.bithumb_secret_key);
try {
const response = await axios_1.default.get(fullUrl, {
// GET 요청으로 변경
headers: {
Authorization: `Bearer ${jwtToken}`,
},
});
if (response.status === 200) {
fetchUserHoldingsErrorCount = 0; // 성공 시 에러 카운터 리셋
// status 확인 조건 추가
const data = response.data; // response.data.data 사용
const userHoldings = [];
// 응답 구조에 따라 데이터 처리
data.forEach((item) => {
const currency = item.currency;
const balance = parseFloat(item.balance);
const locked = parseFloat(item.locked);
const avg_buy_price = parseFloat(item.avg_buy_price);
const unit_currency = item.unit_currency || "KRW"; // unit_currency 추가
if (currency === "P") {
userPoints = balance;
}
else if (currency === "KRW") {
krwBalance = balance;
krwLocked = locked;
}
else if (avg_buy_price > 0) {
userHoldings.push({
symbol: currency,
icon: iconMap[currency + "_" + unit_currency] || " ", // unit_currency 추가
averagePurchasePrice: avg_buy_price,
balance: balance,
locked: locked,
unit_currency: unit_currency, // unit_currency 추가
});
}
});
// console.log(chalk.green("Successfully fetched user holdings from Bithumb API."));
return userHoldings;
}
else {
fetchUserHoldingsErrorCount++;
if (fetchUserHoldingsErrorCount === 1 ||
fetchUserHoldingsErrorCount >= 3) {
console.error(chalk_1.default.red(`Bithumb API Error: ${response.data.message}`));
}
return [];
}
}
catch (error) {
// Add : any to error for type checking
fetchUserHoldingsErrorCount++;
if (axios_1.default.isAxiosError(error) &&
error.response &&
error.response.status === 403) {
console.error(chalk_1.default.red("빗썸 API 키에 등록된 IP 주소가 아닙니다. 빗썸 웹사이트에서 IP 주소를 확인하거나 등록해주세요."));
process.exit(1);
}
if (fetchUserHoldingsErrorCount === 1 ||
fetchUserHoldingsErrorCount >= 3) {
console.error(chalk_1.default.red("Error fetching user holdings from Bithumb API:"), error);
}
return [];
}
}
function updateCoinConfiguration(userHoldings) {
if (userHoldings.length <= 0)
return;
const mergedCoins = [];
const apiSymbols = new Set(userHoldings.map((h) => h.symbol + "_" + (h.unit_currency || "KRW")));
userHoldings.forEach((apiCoin) => {
mergedCoins.push(apiCoin);
});
appConfig.coins.forEach((configCoin) => {
if (!apiSymbols.has(configCoin.symbol + "_" + (configCoin.unit_currency || "KRW"))) {
mergedCoins.push(configCoin);
}
else {
const existingCoin = mergedCoins.find((mc) => mc.symbol === configCoin.symbol &&
mc.unit_currency === configCoin.unit_currency);
if (existingCoin) {
existingCoin.icon = configCoin.icon;
}
}
});
appConfig.coins = mergedCoins;
}
// Function to fetch market names from Bithumb API
async function fetchMarketInfo() {
try {
// 유저가 제공한 응답 형식과 일치하는 Upbit API를 사용하여 코인 한글 이름을 가져옵니다.
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,
};
}
});
// console.log(chalk.green("Market names loaded successfully from Upbit API."));
}
}
catch (error) {
// console.error(chalk.red("한글 코인 이름 로딩 오류:"), error);
}
}
// Modify appConfig and symbols based on API data if available
async function initializeAppConfig() {
await fetchMarketInfo();
if (apiConfig) {
if (sortByArgIndex === -1) {
sortBy = "my";
}
const userHoldings = await fetchUserHoldings();
updateCoinConfiguration(userHoldings);
symbols = appConfig.coins.map((coin) => coin.symbol + "_" + (coin.unit_currency || "KRW")); // unit_currency 추가
console.log(chalk_1.default.green("App configuration initialized with user holdings from Bithumb API."));
}
}
function schedulePeriodicUpdates() {
setInterval(async () => {
if (apiConfig) {
// console.log(chalk.cyan("Periodically updating coin information..."));
const userHoldings = await fetchUserHoldings();
updateCoinConfiguration(userHoldings);
// console.log(chalk.cyan("Coin information has been updated."));
}
}, 10000);
}
// Bithumb WebSocket URL
const wsUri = "wss://pubwss.bithumb.com/pub/ws";
let ws = null;
// 실시간 시세 데이터를 저장할 객체
const realTimeData = {};
let redrawTimeout = null;
const RECONNECT_INTERVAL = 5000; // 5 seconds
const lastNotificationLevels = {};
// 콘솔을 지우고 테이블을 다시 그리는 함수
function drawMarketView() {
let totalEvaluationAmount = 0;
let totalProfitLossAmount = 0;
let totalPurchaseAmount = 0;
// 테이블 생성
const table = new cli_table3_1.default({
head: [
chalk_1.default.magentaBright("코인"),
chalk_1.default.magentaBright("현재가"),
chalk_1.default.magentaBright("전일대비"),
chalk_1.default.magentaBright("전일대비금액"),
chalk_1.default.magentaBright("체결강도"),
chalk_1.default.magentaBright("평가손익"),
chalk_1.default.magentaBright("수익률"),
chalk_1.default.magentaBright("보유수량"),
chalk_1.default.magentaBright("평균매수가"),
chalk_1.default.magentaBright("매수금액"),
chalk_1.default.magentaBright("평가금액"),
chalk_1.default.magentaBright("전일종가"),
chalk_1.default.magentaBright("고가"),
chalk_1.default.magentaBright("저가"),
],
colWidths: [22, 18, 10, 15, 10, 15, 10, 12, 15, 18, 18, 12, 18, 18],
});
const allSymbolsSet = new Set([
...appConfig.coins.map((c) => `${c.symbol}_${c.unit_currency || "KRW"}`),
...Object.keys(realTimeData),
]);
const allSymbols = Array.from(allSymbolsSet);
// 저장된 실시간 데이터로 테이블 채우기
// --sort-by 인수에 따라 정렬. 기본은 변동률순.
const sortedSymbols = allSymbols.sort((a, b) => {
const coinAConfig = appConfig.coins.find((c) => `${c.symbol}_${c.unit_currency || "KRW"}` === a);
const coinBConfig = appConfig.coins.find((c) => `${c.symbol}_${c.unit_currency || "KRW"}` === b);
const aIsHolding = !!(coinAConfig &&
((coinAConfig.balance || 0) > 0 || (coinAConfig.locked || 0) > 0));
const bIsHolding = !!(coinBConfig &&
((coinBConfig.balance || 0) > 0 || (coinBConfig.locked || 0) > 0));
if (aIsHolding && !bIsHolding)
return -1;
if (!aIsHolding && bIsHolding)
return 1;
const dataA = realTimeData[a];
const dataB = realTimeData[b];
if (dataA && !dataB)
return -1;
if (!dataA && dataB)
return 1;
if (!dataA && !dataB)
return a.localeCompare(b);
if (sortBy === "name") {
return a.localeCompare(b); // 이름순
}
if (sortBy === "my") {
const balanceA = (coinAConfig?.balance || 0) + (coinAConfig?.locked || 0);
const balanceB = (coinBConfig?.balance || 0) + (coinBConfig?.locked || 0);
const priceA = parseFloat(dataA?.closePrice || "0");
const priceB = parseFloat(dataB?.closePrice || "0");
const valueA = balanceA * priceA;
const valueB = balanceB * priceB;
return valueB - valueA; // 보유금액이 큰 순서로 정렬
}
// 기본 정렬: 변동률 기준 내림차순
const rateA = parseFloat(dataA.chgRate);
const rateB = parseFloat(dataB.chgRate);
return rateB - rateA;
});
const displaySymbols = sortedSymbols.length > displayLimit
? sortedSymbols.slice(0, displayLimit)
: sortedSymbols;
for (const symbol of displaySymbols) {
const data = realTimeData[symbol];
const coinConfig = appConfig.coins.find((c) => c.symbol + "_" + (c.unit_currency || "KRW") === symbol);
const icon = coinConfig?.icon || iconMap[symbol] || " ";
const koreanName = marketInfo[symbol]?.korean_name;
const displayName = koreanName
? `${symbol.replace("_KRW", "")} ${koreanName}`
: symbol;
if (!data) {
const balance = (coinConfig?.balance || 0) + (coinConfig?.locked || 0);
const avgPrice = coinConfig?.averagePurchasePrice || 0;
table.push([
chalk_1.default.yellow(`${icon} ${displayName}`),
chalk_1.default.gray("Loading..."),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
balance > 0 ? `${balance.toLocaleString("ko-KR")}` : "-",
avgPrice > 0 ? avgPrice.toLocaleString("ko-KR") : "-",
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
chalk_1.default.gray("-"),
]);
continue;
}
// Iterate over sorted symbols
const price = parseFloat(data.closePrice).toLocaleString("ko-KR");
const prevPrice = parseFloat(data.lastClosePrice || data.prevClosePrice);
const changeRate = parseFloat(data.chgRate);
const changeAmount = parseFloat(data.chgAmt);
let priceColor = chalk_1.default.white;
const currentClosePrice = parseFloat(data.closePrice);
if (currentClosePrice > prevPrice) {
priceColor = chalk_1.default.redBright;
}
else if (currentClosePrice < prevPrice) {
priceColor = chalk_1.default.cyanBright;
}
let rateColor = chalk_1.default.white;
if (changeRate > 0) {
rateColor = chalk_1.default.green;
}
else if (changeRate < 0) {
rateColor = chalk_1.default.red;
}
let profitLossRate = "-";
let profitLossAmount = "-";
let evaluationAmount = "-";
let purchaseAmount = "-";
let holdingQuantity = "-";
let avgPurchasePrice = "-";
let profitLossColor = chalk_1.default.white;
if (coinConfig && coinConfig.averagePurchasePrice > 0) {
const currentPrice = parseFloat(data.closePrice);
const avgPrice = coinConfig.averagePurchasePrice;
avgPurchasePrice = avgPrice.toLocaleString("ko-KR");
const rate = ((currentPrice - avgPrice) / avgPrice) * 100;
profitLossRate = `${rate.toFixed(2)}%`;
let balance = 0;
balance += coinConfig.balance ? coinConfig.balance : 0;
balance += coinConfig.locked ? coinConfig.locked : 0;
if (balance > 0) {
const pnl = (currentPrice - avgPrice) * balance;
totalProfitLossAmount += pnl;
profitLossAmount = `${pnl.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
})} KRW`;
const evalAmount = currentPrice * balance;
totalEvaluationAmount += evalAmount;
evaluationAmount = `${evalAmount.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
})} KRW`;
const purchAmount = avgPrice * balance;
totalPurchaseAmount += purchAmount;
purchaseAmount = `${purchAmount.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
})} KRW`;
holdingQuantity = `${balance.toLocaleString("ko-KR")}`;
}
if (rate > 0) {
profitLossColor = chalk_1.default.green;
}
else if (rate < 0) {
profitLossColor = chalk_1.default.red;
}
}
const highPriceNum = parseFloat(data.highPrice);
const lowPriceNum = parseFloat(data.lowPrice);
const prevClosePriceNum = parseFloat(data.prevClosePrice);
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`),
rateColor(`${changeRate.toFixed(2)}%`),
rateColor(`${changeAmount.toLocaleString("ko-KR")} KRW`),
parseFloat(data.volumePower).toFixed(2),
profitLossColor(profitLossAmount),
profitLossColor(profitLossRate),
holdingQuantity,
avgPurchasePrice,
purchaseAmount,
evaluationAmount,
parseFloat(data.prevClosePrice).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)}`;
if (totalPurchaseAmount > 0) {
const formattedPurchase = totalPurchaseAmount.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
});
const formattedEval = totalEvaluationAmount.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
});
const formattedPnl = totalProfitLossAmount.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
});
const pnlColor = totalProfitLossAmount > 0
? chalk_1.default.green
: totalProfitLossAmount < 0
? chalk_1.default.red
: chalk_1.default.white;
marketSentiment += ` | 총 매수금액: ${formattedPurchase} KRW`;
marketSentiment += ` | 총 평가금액: ${formattedEval} KRW`;
marketSentiment += ` | 총 평가손익: ${pnlColor(`${formattedPnl} KRW`)}`;
}
const krwHoldings = krwBalance + krwLocked;
if (krwHoldings > 0) {
marketSentiment += ` | 보유원화: ${krwHoldings.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
})} KRW`;
}
if (krwBalance > 0) {
marketSentiment += ` | 주문가능원화: ${krwBalance.toLocaleString("ko-KR", { maximumFractionDigits: 0 })} KRW`;
}
if (userPoints > 0) {
marketSentiment += ` | 포인트: ${userPoints.toLocaleString("ko-KR", {
maximumFractionDigits: 0,
})}`;
}
}
else {
marketSentiment = "전체 시장: 데이터 부족";
sentimentColor = chalk_1.default.gray;
}
// 화면 출력을 위한 버퍼 생성
const output = [];
output.push(chalk_1.default.bold("Bithumb 실시간 시세 (메뉴: /1:시세, /2:미체결, Ctrl+C:종료) - 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}개)`));
}
readline.cursorTo(process.stdout, 0, 0);
readline.clearScreenDown(process.stdout);
process.stdout.write(output.join("\n"));
process.stdout.write("\n명령어: /1(시세), /2(미체결), /exit(종료)");
rl.prompt(true);
}
async function fetchOpenOrders() {
if (!apiConfig) {
return [];
}
const endpoint = "/v1/orders";
const queryParams = {
limit: 100,
page: 1,
order_by: "desc",
// states: ["wait", "watch"], // 미체결 상태
};
const query = querystring.stringify(queryParams);
const alg = "SHA512";
const hash = crypto.createHash(alg);
const queryHash = hash.update(query, "utf-8").digest("hex");
const payload = {
access_key: apiConfig.bithumb_api_key,
nonce: (0, uuid_1.v4)(),
timestamp: Date.now(),
query_hash: queryHash,
query_hash_alg: alg,
};
const jwtToken = jwt.sign(payload, apiConfig.bithumb_secret_key);
const config = {
headers: {
Authorization: `Bearer ${jwtToken}`,
},
};
try {
const response = await axios_1.default.get(`${BITHUMB_API_BASE_URL}${endpoint}?${query}`, config);
if (response.status === 200) {
return response.data;
}
return [];
}
catch (error) {
// console.error(
// "Error fetching open orders:",
// error.response ? error.response.data : error.message
// );
return [];
}
}
async function drawOpenOrdersView() {
readline.cursorTo(process.stdout, 0, 0);
readline.clearScreenDown(process.stdout);
process.stdout.write("미체결 내역을 불러오는 중...");
const openOrders = await fetchOpenOrders();
const table = new cli_table3_1.default({
head: [
"코인",
"주문종류",
"현재가",
"주문가격",
"괴리율",
"평균매수가",
"현재수익률",
"예상수익률",
"주문수량",
"미체결수량",
"총 금액",
"주문일시",
],
colWidths: [24, 10, 18, 18, 12, 18, 12, 12, 15, 15, 20, 25],
});
if (openOrders.length === 0) {
table.push([{ colSpan: 12, content: "미체결 내역이 없습니다." }]);
}
else {
openOrders.sort((a, b) => {
if (a.market < b.market) {
return -1;
}
if (a.market > b.market) {
return 1;
}
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
for (const order of openOrders) {
const marketParts = order.market.split("-");
const symbolForLookup = `${marketParts[1]}_${marketParts[0]}`;
const koreanName = marketInfo[symbolForLookup]?.korean_name;
const displayName = koreanName
? `${symbolForLookup.replace("_KRW", "")} ${koreanName}`
: symbolForLookup;
let currentPrice = 0;
const currentTicker = realTimeData[symbolForLookup];
if (currentTicker) {
currentPrice = parseFloat(currentTicker.closePrice);
}
else {
try {
const tickerResponse = await axios_1.default.get(`${BITHUMB_API_BASE_URL}/public/ticker/${symbolForLookup}`);
if (tickerResponse.data.status === "0000") {
currentPrice = parseFloat(tickerResponse.data.data.closing_price);
}
}
catch (e) {
/* ignore */
}
}
const currentPriceDisplay = currentPrice > 0 ? currentPrice.toLocaleString("ko-KR") : "N/A";
const orderPrice = parseFloat(order.price || "0");
let discrepancyRate = "-";
let discrepancyColor = chalk_1.default.white;
if (currentPrice > 0 && orderPrice > 0) {
const rate = ((orderPrice - currentPrice) / currentPrice) * 100;
if (rate > 0) {
discrepancyColor = chalk_1.default.green;
discrepancyRate = `+${rate.toFixed(2)}%`;
}
else if (rate < 0) {
discrepancyColor = chalk_1.default.red;
discrepancyRate = `${rate.toFixed(2)}%`;
}
else {
discrepancyRate = `${rate.toFixed(2)}%`;
}
}
const orderType = order.side === "bid" ? chalk_1.default.red("매수") : chalk_1.default.cyan("매도");
const orderPriceDisplay = orderPrice.toLocaleString("ko-KR");
const volume = parseFloat(order.volume || "0").toLocaleString("ko-KR");
const remaining_volume = parseFloat(order.remaining_volume || "0").toLocaleString("ko-KR");
const total = (orderPrice * parseFloat(order.volume || "0")).toLocaleString("ko-KR", { maximumFractionDigits: 0 });
const date = new Date(order.created_at).toLocaleString("ko-KR");
const coinConfig = appConfig.coins.find((c) => `${c.symbol}_${c.unit_currency || "KRW"}` === symbolForLookup);
const icon = coinConfig?.icon || iconMap[symbolForLookup] || " ";
let avgPurchasePriceDisplay = "-";
let profitLossRateDisplay = "-";
let profitLossColor = chalk_1.default.white;
if (coinConfig && coinConfig.averagePurchasePrice > 0) {
const avgPrice = coinConfig.averagePurchasePrice;
avgPurchasePriceDisplay = avgPrice.toLocaleString("ko-KR");
if (currentPrice > 0) {
const rate = ((currentPrice - avgPrice) / avgPrice) * 100;
if (rate > 0) {
profitLossColor = chalk_1.default.green;
profitLossRateDisplay = `+${rate.toFixed(2)}%`;
}
else if (rate < 0) {
profitLossColor = chalk_1.default.red;
profitLossRateDisplay = `${rate.toFixed(2)}%`;
}
else {
profitLossRateDisplay = `${rate.toFixed(2)}%`;
}
}
}
let expectedProfitRateDisplay = "-";
let expectedProfitRateColor = chalk_1.default.white;
if (order.side === "ask" &&
coinConfig &&
coinConfig.averagePurchasePrice > 0 &&
orderPrice > 0) {
const avgPrice = coinConfig.averagePurchasePrice;
const expectedRate = ((orderPrice - avgPrice) / avgPrice) * 100;
if (expectedRate > 0) {
expectedProfitRateColor = chalk_1.default.green;
expectedProfitRateDisplay = `+${expectedRate.toFixed(2)}%`;
}
else if (expectedRate < 0) {
expectedProfitRateColor = chalk_1.default.red;
expectedProfitRateDisplay = `${expectedRate.toFixed(2)}%`;
}
else {
expectedProfitRateDisplay = `${expectedRate.toFixed(2)}%`;
}
}
table.push([
chalk_1.default.yellow(`${icon} ${displayName}`),
orderType,
`${currentPriceDisplay} ${marketParts[0]}`,
`${orderPriceDisplay} ${marketParts[0]}`,
discrepancyColor(discrepancyRate),
avgPurchasePriceDisplay,
profitLossColor(profitLossRateDisplay),
expectedProfitRateColor(expectedProfitRateDisplay),
volume,
remaining_volume,
`${total} ${marketParts[0]}`,
date,
]);
}
}
const output = [];
output.push(chalk_1.default.bold("Bithumb 미체결 내역 (메뉴: /1:시세, /2:미체결, Ctrl+C:종료) - Debate300.com"));
output.push(table.toString());
readline.cursorTo(process.stdout, 0, 0);
readline.clearScreenDown(process.stdout);
process.stdout.write(output.join("\n"));
process.stdout.write("\n명령어: /1(시세), /2(미체결), /exit(종료)");
rl.prompt(true);
}
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
new node_notifier_1.default.NotificationCenter().notify({
title: title,
message: message,
sound: true,
wait: false,
}, function (error, response) {
if (error)
console.error("Notification Error:", error);
});
}
}
function connect() {
// Prevent multiple connection attempts if one is already connecting or open
if (ws &&
(ws.readyState === ws_1.default.CONNECTING || ws.readyState === ws_1.default.OPEN)) {
return;
}
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"], // 자정 기준 변동률
};
if (ws) {
// Add null check here
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
}
}
if (currentView === "market") {
if (!redrawTimeout) {
redrawTimeout = setTimeout(() => {
drawMarketView();
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 연결이 종료되었습니다. ${RECONNECT_INTERVAL / 1000}초 후 재연결을 시도합니다.`));
ws = null;
if (redrawTimeout) {
clearTimeout(redrawTimeout);
redrawTimeout = null;
}
setTimeout(connect, RECONNECT_INTERVAL);
});
}
// 프로그램 시작
initializeAppConfig().then(() => {
connect();
if (apiConfig) {
schedulePeriodicUpdates();
}
rl.on("line", (line) => {
const command = line.trim().toLowerCase();
switch (command) {
case "/1":
case "/시세":
currentView = "market";
drawMarketView();
break;
case "/2":
case "/미체결":
currentView = "open_orders";
drawOpenOrdersView();
break;
case "/exit":
process.exit(0);
break;
default:
if (command.startsWith("/")) {
process.stdout.write("알 수 없는 명령어입니다. 사용 가능한 명령어: /1, /2, /exit\n");
}
rl.prompt();
}
}).on("close", () => {
process.exit(0);
});
drawMarketView();
});