294 lines
8.0 KiB
JavaScript
294 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* TQQQ 하이브리드 모니터링 시스템
|
|
*
|
|
* 정규장 (09:30-16:00 EST): Finnhub WebSocket (실시간)
|
|
* 확장 시간 (04:00-09:30, 16:00-20:00 EST): Polygon API (1분 폴링)
|
|
*
|
|
* Stop-Loss: $47.00
|
|
*/
|
|
|
|
const WebSocket = require('ws');
|
|
const https = require('https');
|
|
const { exec } = require('child_process');
|
|
|
|
// 환경변수 로드
|
|
const FINNHUB_API_KEY = process.env.FINNHUB_API_KEY || '';
|
|
const POLYGON_API_KEY = process.env.POLYGON_API_KEY || '';
|
|
const STOP_LOSS_PRICE = parseFloat(process.env.TQQQ_STOP_LOSS || '47.00');
|
|
const TICKER = 'TQQQ';
|
|
|
|
// Discord 알림 설정
|
|
const DISCORD_CHANNEL = '1469190686145384513'; // #jarvis-market
|
|
|
|
// 상태 관리
|
|
let lastPrice = null;
|
|
let alertSent = false;
|
|
let consecutiveBreaches = 0;
|
|
const BREACH_THRESHOLD = 3; // 3회 연속 확인 후 알림
|
|
|
|
/**
|
|
* 현재 시간이 정규장인지 확인 (EST 기준)
|
|
*/
|
|
function isMarketHours() {
|
|
const now = new Date();
|
|
const estOffset = -5 * 60; // EST = UTC-5
|
|
const estTime = new Date(now.getTime() + (estOffset + now.getTimezoneOffset()) * 60000);
|
|
|
|
const day = estTime.getDay(); // 0=일, 6=토
|
|
const hour = estTime.getHours();
|
|
const minute = estTime.getMinutes();
|
|
const totalMinutes = hour * 60 + minute;
|
|
|
|
// 주말 제외
|
|
if (day === 0 || day === 6) return false;
|
|
|
|
// 정규장: 09:30 - 16:00 EST
|
|
const marketOpen = 9 * 60 + 30; // 09:30
|
|
const marketClose = 16 * 60; // 16:00
|
|
|
|
return totalMinutes >= marketOpen && totalMinutes < marketClose;
|
|
}
|
|
|
|
/**
|
|
* 확장 시간인지 확인 (프리마켓 + 애프터마켓)
|
|
*/
|
|
function isExtendedHours() {
|
|
const now = new Date();
|
|
const estOffset = -5 * 60;
|
|
const estTime = new Date(now.getTime() + (estOffset + now.getTimezoneOffset()) * 60000);
|
|
|
|
const day = estTime.getDay();
|
|
const hour = estTime.getHours();
|
|
const minute = estTime.getMinutes();
|
|
const totalMinutes = hour * 60 + minute;
|
|
|
|
// 주말 제외
|
|
if (day === 0 || day === 6) return false;
|
|
|
|
// 프리마켓: 04:00 - 09:30 EST
|
|
const premarketStart = 4 * 60;
|
|
const premarketEnd = 9 * 60 + 30;
|
|
|
|
// 애프터마켓: 16:00 - 20:00 EST
|
|
const aftermarketStart = 16 * 60;
|
|
const aftermarketEnd = 20 * 60;
|
|
|
|
return (totalMinutes >= premarketStart && totalMinutes < premarketEnd) ||
|
|
(totalMinutes >= aftermarketStart && totalMinutes < aftermarketEnd);
|
|
}
|
|
|
|
/**
|
|
* Stop-Loss 체크 및 알림
|
|
*/
|
|
function checkStopLoss(price, source) {
|
|
lastPrice = price;
|
|
|
|
if (price < STOP_LOSS_PRICE) {
|
|
consecutiveBreaches++;
|
|
console.log(`⚠️ [${source}] Price: $${price.toFixed(2)} | Stop-Loss: $${STOP_LOSS_PRICE} | Breaches: ${consecutiveBreaches}/${BREACH_THRESHOLD}`);
|
|
|
|
if (consecutiveBreaches >= BREACH_THRESHOLD && !alertSent) {
|
|
sendDiscordAlert(price, source);
|
|
alertSent = true;
|
|
}
|
|
} else {
|
|
if (consecutiveBreaches > 0) {
|
|
console.log(`✅ [${source}] Price recovered: $${price.toFixed(2)}`);
|
|
}
|
|
consecutiveBreaches = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discord 알림 전송
|
|
*/
|
|
function sendDiscordAlert(price, source) {
|
|
const message = `🚨 **TQQQ Stop-Loss 트리거**
|
|
|
|
**현재가:** $${price.toFixed(2)}
|
|
**손절선:** $${STOP_LOSS_PRICE.toFixed(2)}
|
|
**소스:** ${source}
|
|
**시각:** ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}
|
|
|
|
⚠️ 즉시 확인 필요!`;
|
|
|
|
const cmd = `message action:send channel:discord target:"${DISCORD_CHANNEL}" message:"${message.replace(/"/g, '\\"')}"`;
|
|
|
|
exec(`openclaw ${cmd}`, (error, stdout, stderr) => {
|
|
if (error) {
|
|
console.error(`❌ Discord 알림 실패: ${error.message}`);
|
|
} else {
|
|
console.log('✅ Discord 알림 전송 완료');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Finnhub WebSocket (정규장)
|
|
*/
|
|
function startFinnhubWebSocket() {
|
|
console.log('🔌 Finnhub WebSocket 연결 시작...');
|
|
|
|
const ws = new WebSocket(`wss://ws.finnhub.io?token=${FINNHUB_API_KEY}`);
|
|
|
|
ws.on('open', () => {
|
|
console.log('✅ Finnhub WebSocket 연결됨');
|
|
ws.send(JSON.stringify({ type: 'subscribe', symbol: TICKER }));
|
|
console.log(`📡 ${TICKER} 구독 시작`);
|
|
});
|
|
|
|
ws.on('message', (data) => {
|
|
const message = JSON.parse(data);
|
|
|
|
if (message.type === 'trade' && message.data && message.data.length > 0) {
|
|
const trades = message.data;
|
|
trades.forEach(trade => {
|
|
const price = trade.p;
|
|
checkStopLoss(price, 'Finnhub WebSocket');
|
|
});
|
|
}
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error(`❌ Finnhub WebSocket 에러: ${error.message}`);
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log('🔌 Finnhub WebSocket 연결 종료');
|
|
// 정규장 시간이면 재연결
|
|
if (isMarketHours()) {
|
|
console.log('🔄 5초 후 재연결...');
|
|
setTimeout(startFinnhubWebSocket, 5000);
|
|
}
|
|
});
|
|
|
|
return ws;
|
|
}
|
|
|
|
/**
|
|
* Polygon API 폴링 (확장 시간)
|
|
*/
|
|
function pollPolygonAPI() {
|
|
const url = `https://api.polygon.io/v2/last/trade/${TICKER}?apiKey=${POLYGON_API_KEY}`;
|
|
|
|
https.get(url, (res) => {
|
|
let data = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
|
|
res.on('end', () => {
|
|
try {
|
|
const json = JSON.parse(data);
|
|
|
|
if (json.status === 'OK' && json.results && json.results.p) {
|
|
const price = json.results.p;
|
|
checkStopLoss(price, 'Polygon API');
|
|
} else {
|
|
console.error(`❌ Polygon API 에러: ${json.error || 'Unknown'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ JSON 파싱 에러: ${error.message}`);
|
|
}
|
|
});
|
|
}).on('error', (error) => {
|
|
console.error(`❌ Polygon API 요청 실패: ${error.message}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 메인 루프
|
|
*/
|
|
function main() {
|
|
console.log('🚀 TQQQ 하이브리드 모니터링 시작');
|
|
console.log(`📊 Ticker: ${TICKER}`);
|
|
console.log(`🛑 Stop-Loss: $${STOP_LOSS_PRICE.toFixed(2)}`);
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
|
|
// API 키 검증
|
|
if (!FINNHUB_API_KEY) {
|
|
console.error('❌ FINNHUB_API_KEY 환경변수 필요');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!POLYGON_API_KEY) {
|
|
console.error('❌ POLYGON_API_KEY 환경변수 필요');
|
|
process.exit(1);
|
|
}
|
|
|
|
let currentMode = null;
|
|
let ws = null;
|
|
let pollingInterval = null;
|
|
|
|
// 1분마다 모드 체크 및 전환
|
|
setInterval(() => {
|
|
const isMarket = isMarketHours();
|
|
const isExtended = isExtendedHours();
|
|
|
|
if (isMarket && currentMode !== 'market') {
|
|
console.log('\n🔔 정규장 시작 → Finnhub WebSocket 모드');
|
|
currentMode = 'market';
|
|
|
|
// Polygon 폴링 중지
|
|
if (pollingInterval) {
|
|
clearInterval(pollingInterval);
|
|
pollingInterval = null;
|
|
}
|
|
|
|
// Finnhub WebSocket 시작
|
|
ws = startFinnhubWebSocket();
|
|
|
|
} else if (isExtended && currentMode !== 'extended') {
|
|
console.log('\n🔔 확장 시간 시작 → Polygon 폴링 모드');
|
|
currentMode = 'extended';
|
|
|
|
// Finnhub WebSocket 중지
|
|
if (ws) {
|
|
ws.close();
|
|
ws = null;
|
|
}
|
|
|
|
// Polygon 폴링 시작 (1분마다)
|
|
pollingInterval = setInterval(pollPolygonAPI, 60000);
|
|
pollPolygonAPI(); // 즉시 1회 실행
|
|
|
|
} else if (!isMarket && !isExtended && currentMode !== 'closed') {
|
|
console.log('\n🔔 장 마감 → 대기 모드');
|
|
currentMode = 'closed';
|
|
|
|
// 모든 모니터링 중지
|
|
if (ws) {
|
|
ws.close();
|
|
ws = null;
|
|
}
|
|
if (pollingInterval) {
|
|
clearInterval(pollingInterval);
|
|
pollingInterval = null;
|
|
}
|
|
|
|
// 알림 상태 리셋
|
|
alertSent = false;
|
|
consecutiveBreaches = 0;
|
|
}
|
|
}, 60000); // 1분마다 체크
|
|
|
|
// 초기 모드 설정
|
|
if (isMarketHours()) {
|
|
currentMode = 'market';
|
|
ws = startFinnhubWebSocket();
|
|
} else if (isExtendedHours()) {
|
|
currentMode = 'extended';
|
|
pollingInterval = setInterval(pollPolygonAPI, 60000);
|
|
pollPolygonAPI();
|
|
} else {
|
|
currentMode = 'closed';
|
|
console.log('⏸️ 현재 장 마감 시간 (대기 중)');
|
|
}
|
|
}
|
|
|
|
// 프로그램 시작
|
|
main();
|