Files

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();