mirror of
https://git.hmsn.ink/coin/bot.git
synced 2026-03-20 00:02:16 +09:00
239 lines
6.5 KiB
JavaScript
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, 672);
|
|
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;
|
|
// 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 > 672) 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);
|
|
} |