#!/usr/bin/env node /** * TQQQ 실시간 모니터 (크론용) * * Finnhub REST API로 실시간 가격 조회 (무료 플랜) * Auto-retry 포함 */ const https = require('https'); const fs = require('fs'); const path = require('path'); // ============================================================================ // Configuration // ============================================================================ const CONFIG = { FINNHUB_API_KEY: process.env.FINNHUB_API_KEY || JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.openclaw/openclaw.json'), 'utf8')) .env?.FINNHUB_API_KEY, SYMBOL: 'TQQQ', MAX_RETRIES: 3, TIMEOUT: 10000, // 10초 // 환율 (실시간 조회 가능하면 업그레이드) USD_KRW: 1465.09 }; // ============================================================================ // Finnhub API 호출 // ============================================================================ function fetchFinnhubQuote(symbol) { return new Promise((resolve, reject) => { const url = `https://finnhub.io/api/v1/quote?symbol=${symbol}&token=${CONFIG.FINNHUB_API_KEY}`; const req = https.get(url, { timeout: CONFIG.TIMEOUT }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const json = JSON.parse(data); // Finnhub는 에러 시에도 200 리턴하고 error 필드 포함 if (json.error) { reject(new Error(`Finnhub API error: ${json.error}`)); } else if (json.c !== undefined) { resolve(json); } else { reject(new Error('Invalid response from Finnhub')); } } catch (error) { reject(new Error(`JSON parse error: ${error.message}`)); } }); }); req.on('error', (error) => { reject(error); }); req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); }); } // ============================================================================ // Retry Logic // ============================================================================ async function fetchWithRetry(symbol, maxRetries = 3) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`🔄 Attempt ${attempt}/${maxRetries}...`); const result = await fetchFinnhubQuote(symbol); console.log(`✅ Success on attempt ${attempt}`); return result; } catch (error) { lastError = error; console.error(`❌ Attempt ${attempt} failed: ${error.message}`); if (attempt < maxRetries) { const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); console.log(`⏳ Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; } // ============================================================================ // Market Status // ============================================================================ function getMarketStatus() { 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 { status: 'closed', label: '⏸️ 주말 휴장 - 마지막 종가' }; } // 정규장: 09:30 - 16:00 EST if (totalMinutes >= 9 * 60 + 30 && totalMinutes < 16 * 60) { return { status: 'market', label: '🟢 정규장 실시간' }; } // 애프터마켓: 16:00 - 20:00 EST if (totalMinutes >= 16 * 60 && totalMinutes < 20 * 60) { return { status: 'aftermarket', label: '🟡 애프터마켓 종가' }; } // 프리마켓: 04:00 - 09:30 EST if (totalMinutes >= 4 * 60 && totalMinutes < 9 * 60 + 30) { return { status: 'premarket', label: '🟠 프리마켓 가격' }; } // 장 마감: 20:00 - 04:00 EST return { status: 'closed', label: '⏸️ 장 마감 - 마지막 종가' }; } function getNextMarketOpen() { const now = new Date(); const kstTime = new Date(now.getTime() + (9 * 60 * 60 * 1000)); // KST = UTC+9 // 다음 정규장 시작: 09:30 EST = 23:30 KST const nextOpen = new Date(kstTime); nextOpen.setHours(23, 30, 0, 0); // 이미 지났으면 내일 if (kstTime.getHours() >= 23 && kstTime.getMinutes() >= 30) { nextOpen.setDate(nextOpen.getDate() + 1); } // 주말이면 다음 월요일 const day = nextOpen.getDay(); if (day === 0) nextOpen.setDate(nextOpen.getDate() + 1); // 일요일 → 월요일 if (day === 6) nextOpen.setDate(nextOpen.getDate() + 2); // 토요일 → 월요일 return nextOpen.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } // ============================================================================ // Format Output // ============================================================================ function formatOutput(data) { const currentPrice = data.c; // Current price const change = data.d; // Change const changePercent = data.dp; // Change percent const high = data.h; // Day high const low = data.l; // Day low const open = data.o; // Day open const prevClose = data.pc; // Previous close const timestamp = new Date(data.t * 1000); // Unix timestamp to JS Date const krwPrice = Math.round(currentPrice * CONFIG.USD_KRW); const krwLow = Math.round(low * CONFIG.USD_KRW); const krwHigh = Math.round(high * CONFIG.USD_KRW); const marketStatus = getMarketStatus(); // MEMORY.md에서 포지션 정보 읽기 let position = null; try { const memoryPath = path.join(process.env.HOME, 'openclaw/MEMORY.md'); const memoryContent = fs.readFileSync(memoryPath, 'utf8'); // "현재 상황:" 섹션 파싱 const statusMatch = memoryContent.match(/현재 상황:\s*(.+?)(?=\n\n|\n\*\*)/s); if (statusMatch) { const statusText = statusMatch[1]; if (statusText.includes('재진입 대기')) { position = { type: 'waiting', cash: '$9,000' }; } else if (statusText.includes('손절 완료')) { position = { type: 'exited', cash: '$9,000' }; } } } catch (error) { console.error(`⚠️ Failed to read MEMORY.md: ${error.message}`); } // 출력 console.log('\n📊 TQQQ 스냅샷 (Finnhub API)\n'); console.log(`${marketStatus.label}\n`); console.log('╭─────────────────┬───────────────────╮'); console.log('│ 항목 │ 값 │'); console.log('├─────────────────┼───────────────────┤'); console.log(`│ 현재가 (USD) │ $${currentPrice.toFixed(2).padStart(15)} │`); console.log(`│ 현재가 (KRW) │ ₩${krwPrice.toLocaleString('ko-KR').padStart(15)} │`); console.log(`│ 전일 종가 │ $${prevClose.toFixed(2).padStart(15)} │`); const changeIcon = change >= 0 ? '▲' : '▼'; const changeColor = change >= 0 ? '' : ''; console.log(`│ 변동 (전일比) │ ${changeIcon} $${Math.abs(change).toFixed(2)} (${changePercent.toFixed(2)}%)${' '.repeat(Math.max(0, 5 - changePercent.toFixed(2).length))} │`); console.log(`│ 일중 범위 │ $${low.toFixed(2)} ~ $${high.toFixed(2)}${' '.repeat(Math.max(0, 4 - (low.toFixed(2).length + high.toFixed(2).length)))} │`); console.log(`│ 일중 범위 (KRW) │ ₩${krwLow.toLocaleString('ko-KR')} ~ ₩${krwHigh.toLocaleString('ko-KR')}${' '.repeat(Math.max(0, 3 - (krwLow.toLocaleString('ko-KR').length + krwHigh.toLocaleString('ko-KR').length - 14)))} │`); console.log(`│ 환율 │ $1 = ₩${CONFIG.USD_KRW.toLocaleString('ko-KR').padStart(10)} │`); console.log('╰─────────────────┴───────────────────╯'); // 변동률 경고 if (Math.abs(changePercent) >= 4) { console.log(`\n⚠️ ${Math.abs(changePercent).toFixed(1)}% 변동 - 주의 필요!`); } // 포지션 정보 if (position) { if (position.type === 'waiting') { console.log('\n💰 포지션: 재진입 대기 중'); console.log(` 현금: ${position.cash}`); // 재진입 기회 분석 if (currentPrice <= 45.00) { console.log(` 🟢 재진입 기회: $45 이하 (바닥 근처)`); } else if (currentPrice >= 50.00) { console.log(` 🟢 추세 전환 신호: $50 돌파`); } else if (currentPrice <= 48.00) { console.log(` 🟡 관망 영역: 아직 비쌈`); } else { console.log(` 🟡 관망 중: 진입 타이밍 대기`); } } else if (position.type === 'exited') { console.log('\n💰 포지션: 손절 완료'); console.log(` 현금: ${position.cash}`); } } // 장 마감 시 다음 장 시작 시간 표시 if (marketStatus.status === 'closed') { console.log(`\n⏰ 다음 장 시작: ${getNextMarketOpen()} (정규장)`); } console.log('\n✅ 데이터 출처: Finnhub API'); console.log(` 조회 시각: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`); } // ============================================================================ // Main // ============================================================================ async function main() { console.log('🚀 TQQQ 실시간 모니터링 (Finnhub)\n'); // API 키 검증 if (!CONFIG.FINNHUB_API_KEY) { console.error('❌ FINNHUB_API_KEY not found'); console.error(' Check: ~/.openclaw/openclaw.json → env.FINNHUB_API_KEY'); process.exit(1); } try { const data = await fetchWithRetry(CONFIG.SYMBOL, CONFIG.MAX_RETRIES); formatOutput(data); process.exit(0); } catch (error) { console.error('\n❌ Failed after all retries'); console.error(` Error: ${error.message}`); process.exit(1); } } // ============================================================================ // Run // ============================================================================ if (require.main === module) { main(); } module.exports = { fetchFinnhubQuote, fetchWithRetry, CONFIG };