Files
bot/index.js
2025-08-30 20:21:14 +09:00

239 lines
6.5 KiB
JavaScript

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 position = null;
let trades = [];
let curPrice = null;
async function bootstrap() {
try {
const ohlcv = await ex.fetchOHLCV(cfg.cctxSymbol, cfg.ohlcvInterval, undefined, 200);
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}`);
} 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;
// 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 > 300) 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()
});
// 재연결 시도 로직 추가 가능
});
} 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,
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;
position = {
id: `m${Date.now()}`,
side: sig.side,
entry: curPrice,
qty: +qty,
sl: sig.sl,
tp: sig.tp,
openTime: Date.now(),
fee,
initialBalance: balance + fee
};
logger.info(`[FILLED] ${sig.side} 시장가 @${curPrice} qty=${qty}`, { position });
}
function checkExit() {
if (!position || curPrice === null) return;
const { side, entry, qty, sl, tp } = 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);
}