import ccxt from 'ccxt'; import WebSocket from 'ws'; import fs from 'fs'; import { aiSignal } from './strategy.js'; import logger from './logger.js'; const cfg = JSON.parse(fs.readFileSync('./config.json', 'utf8')); const ex = new ccxt.bybit({ apiKey: '', secret: '', options: { defaultType: 'swap' } }); let bars = []; let balance = cfg.startBalance; let pending = null; let position = null; let trades = []; let curPrice = null; async function bootstrap() { try { const ohlcv = await ex.fetchOHLCV(cfg.cctxSymbol, cfg.ohlcvInterval, undefined, 112); bars = ohlcv.map(c => ({ t: c[0], o: c[1], h: c[2], l: c[3], c: c[4], v: c[5] })); logger.info(`Bootstrapped ${bars.length} bars | Balance: ${balance} interval: ${cfg.ohlcvInterval}`); } catch (error) { logger.critical('초기화 중 심각한 오류 발생', error, { stage: 'bootstrap' }); process.exit(1); } } function initWs() { try { const ws = new WebSocket(`wss://stream.bybit.com/v5/public/linear`); ws.on('open', () => { logger.info('WebSocket 연결 성공'); ws.send(JSON.stringify({ op: 'subscribe', args: [ `kline.${cfg.interval}.${cfg.symbol}`, `tickers.${cfg.symbol}`, ] })); }); ws.on('message', (msg) => { try { const m = JSON.parse(msg); if (m.op !== 'subscribe') { if (m.topic?.startsWith('tickers')) { const t = m.data; if (t) { if(t.lastPrice) { curPrice = +t.lastPrice; confirmTrade(); // logger.debug('현재 가격 업데이트', { curPrice }); } } } if (m.topic?.startsWith('kline')) { const k = m.data[0]; if (!k || !k.confirm) return; const bar = { t: +k.start, o: +k.open, h: +k.high, l: +k.low, c: +k.close, v: +k.volume }; bars.push(bar); if (bars.length > 112) bars.shift(); logger.debug('새로운 분봉 수신', { timestamp: new Date(bar.t).toISOString(), open: bar.o, high: bar.h, low: bar.l, close: bar.c, volume: bar.v }); onNewBar(bar); } } } catch (e) { logger.error('WebSocket 메시지 처리 중 오류', e, { rawMessage: msg.toString().substr(0, 100) + '...' }); } }); ws.on('error', (error) => { logger.error('WebSocket 연결 오류', error); }); ws.on('close', (code, reason) => { logger.warn('WebSocket 연결 종료', { code, reason: reason.toString() }); // 재연결 시도 로직 추가 가능 initWs() }); } catch (error) { logger.critical('WebSocket 초기화 실패', error); setTimeout(initWs, 5000); // 5초 후 재시도 } } async function onNewBar(bar) { logger.debug(`새로운 ${cfg.interval}분 봉 수신`, { timestamp: new Date(bar.t).toISOString(), open: bar.o, high: bar.h, low: bar.l, close: bar.c, volume: bar.v }); if (position) { logger.debug(`진입 보류: 이미 포지션 존재`, { position: { side: position.side, entry: position.entry, sl: position.sl, tp: position.tp, qty: position.qty } }); return; } const sig = await aiSignal(bars); if(sig.side !== 'HOLD') enterMarket(sig); } function enterMarket(sig) { const qty = ((balance * cfg.leverage) / curPrice).toFixed(4); const notional = curPrice * +qty; const fee = notional * cfg.feeTaker / 100; balance -= fee; pending = { id: `m${Date.now()}`, side: sig.side, entry: sig.price, qty: +qty, sl: sig.sl, tp: sig.side === 'LONG' ? sig.tp - (sig.tp * 0.0005) : sig.tp + (sig.tp * 0.0005), openTime: Date.now(), fee, initialBalance: balance + fee }; logger.info(`[PENDING] ${sig.side} 지정가 @${sig.price} qty=${qty}`, { pending }); } function confirmTrade() { if(pending === null) return; let flag = false; if(pending.side === 'LONG' ) { if(pending.entry <= curPrice) { flag = true; } } else if(pending.side === 'SHORT') { if(pending.entry >= curPrice) { flag = true; } } if(flag) { position = {...pending} pending = null; logger.info(`[FILLED] ${position.side} 지정가 @${position.entry} qty=${position.qty}`, { position }); } } function forceExit() { if(position) { const pnl = (position.side === 'LONG' ? curPrice - position.entry : position.entry - curPrice) * position.qty; const fee = position.qty * curPrice * (cfg.feeTaker / 100); const netPnl = pnl - fee; balance += netPnl; const tradeRecord = { ...position, closed: true, exitPrice: curPrice, exitReason: 'force Exit', pnl: netPnl, closeTime: Date.now(), finalBalance: balance, leverage: cfg.leverage }; trades.push(tradeRecord); fs.writeFileSync(cfg.logPath, JSON.stringify(trades, null, 2)); logger.info(`[FORCEEXIT] @${curPrice.toFixed(4)} | PnL=${netPnl.toFixed(2)} | Balance=${balance.toFixed(2)}`, { trade: tradeRecord, market: { currentPrice: curPrice } }); // 종료된 포지션 로깅 logger.logPosition(tradeRecord, `강제 청산 포지션 종료`); position = null; } } function checkExit() { if (!position || curPrice === null) return; if (Date.now() - position.openTime >= (1000 * 60) *cfg.forceTime) { forceExit() return; } const { id, side, entry, qty, sl, tp, fe, ib} = position; let exit = null; if (side === 'LONG') { if (curPrice >= tp) exit = 'TP'; if (curPrice <= sl) exit = 'SL'; } else { if (curPrice <= tp) exit = 'TP'; if (curPrice >= sl) exit = 'SL'; } if (!exit) return; const pnl = (side === 'LONG' ? curPrice - entry : entry - curPrice) * qty; const fee = qty * curPrice * (cfg.feeTaker / 100); const netPnl = pnl - fee; balance += netPnl; // 음수 잔고 방지를 위한 체크 if (balance < 0) { logger.critical('음수 잔고 발생! 시스템 중지 필요', { balanceBefore: balance - netPnl, pnl, fee, netPnl, position }); balance = 0; // 안전장치 } const tradeRecord = { ...position, closed: true, exitPrice: curPrice, exitReason: exit, pnl: netPnl, closeTime: Date.now(), finalBalance: balance, leverage: cfg.leverage }; trades.push(tradeRecord); fs.writeFileSync(cfg.logPath, JSON.stringify(trades, null, 2)); logger.info(`[EXIT] ${exit} @${curPrice.toFixed(4)} | PnL=${netPnl.toFixed(2)} | Balance=${balance.toFixed(2)}`, { trade: tradeRecord, market: { currentPrice: curPrice } }); // 종료된 포지션 로깅 logger.logPosition(tradeRecord, `포지션 종료 (${exit})`); position = null; } // 시스템 시작 try { await bootstrap(); initWs(); setInterval(checkExit, 1000); } catch (error) { logger.critical('시스템 시작 중 심각한 오류', error); process.exit(1); }