ee3e799de1
주요 변경: - tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규 * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합 * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일) - src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규 * Logger.log / getSpreadsheet_() 로 run_all 연동 수정 - src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs * _mergePositionRecord_(): 소수주 중복 행 합산 신규 * parseInt → parseFloat (qty, availQty) - src/gas_adapter_parts/gdf_01_price_metrics.gs * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL - spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63) - spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
908 lines
35 KiB
JavaScript
908 lines
35 KiB
JavaScript
/**
|
|
* gas_event_calendar.gs — Market Event Calendar Harness (v2: Yahoo + Naver scrapers)
|
|
*
|
|
* 스크래핑 전략:
|
|
* - Yahoo Finance: __NEXT_DATA__ JSON 추출 (Next.js 내장 데이터)
|
|
* - Naver Finance: HTML 테이블 파싱 (경제지표 일정 + 실적발표)
|
|
* - 공통: fetchWithCache_() — CacheService(4h) + 지수 백오프 + stale fallback
|
|
*
|
|
* 블록킹 대응:
|
|
* - Chrome UA + Referer 헤더로 봇 판정 회피
|
|
* - 429/503 → 재시도, 403/401 → 즉시 stale 사용
|
|
* - 파싱 실패 시 빈 배열 반환 (에러 전파 없음)
|
|
*/
|
|
|
|
const CFG = {
|
|
SPREADSHEET_ID: '1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM',
|
|
SHEET_NAME: 'event_calendar',
|
|
TIME_ZONE: 'Asia/Seoul',
|
|
DATE_FORMAT: 'yyyy-MM-dd',
|
|
ALERT_EMAIL: '',
|
|
|
|
REQUIRED_HEADERS: ['Date', 'Event', 'Type', 'Impact', 'Alert'],
|
|
ALL_HEADERS: ['Date','Event','Type','Impact','Alert','DaysLeft','AlertStatus','LastCheckedAt','Source','SourceUrl','Key'],
|
|
|
|
IMPACT_ALERT_WINDOW_DAYS: { HIGH: 7, MEDIUM: 3, LOW: 1 },
|
|
VALID_TYPES: ['FOMC','US_CPI','US_PPI','US_PCE','US_NFP','EARNINGS','EXPIRY','BOK','KR_CPI','BOJ','FX','BOND','CUSTOM'],
|
|
VALID_IMPACTS: ['HIGH','MEDIUM','LOW'],
|
|
|
|
JSON_SOURCE_PROPERTY: 'EVENT_JSON_URL',
|
|
CSV_SOURCE_PROPERTY: 'EVENT_CSV_URL',
|
|
|
|
// 캐시·재시도
|
|
CACHE_TTL_SEC: 4 * 60 * 60,
|
|
STALE_PROP_PREFIX: 'stale_url:',
|
|
MAX_RETRIES: 2,
|
|
RETRY_BASE_MS: 1500,
|
|
|
|
// 스크래핑
|
|
YAHOO_DAYS_AHEAD: 60, // Yahoo: 오늘부터 N일 앞까지 수집
|
|
SCRAPE_SLEEP_MS: 700, // 요청 간 대기 (ms) — rate limit 회피
|
|
CHROME_UA: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
};
|
|
|
|
|
|
/* ── 메뉴 ─────────────────────────────────────────────────────────────────── */
|
|
|
|
function onOpen() {
|
|
SpreadsheetApp.getUi()
|
|
.createMenu('Market Calendar')
|
|
.addItem('초기 설정', 'setup')
|
|
.addItem('검증 및 정렬', 'validateAndSort')
|
|
.addItem('임박 이벤트 알림 발송', 'sendEventAlerts')
|
|
.addSeparator()
|
|
.addItem('Trading Economics 새로고침', 'refreshFromTradingEconomics')
|
|
.addItem('Naver Finance 새로고침', 'refreshFromNaver')
|
|
.addItem('외부 URL 소스 새로고침', 'refreshFromSources')
|
|
.addSeparator()
|
|
.addItem('프로퍼티 캐시 청소', 'cleanUpProperties')
|
|
.addItem('샘플 데이터 삽입', 'loadSampleDataIfEmpty')
|
|
.addItem('매일 실행 트리거 설치', 'createDailyTrigger')
|
|
.addItem('트리거 삭제', 'deleteProjectTriggers')
|
|
.addToUi();
|
|
}
|
|
|
|
function setup() {
|
|
cleanUpProperties(); // 한도 초과 상태 해제를 위해 프로퍼티 캐시 청소 먼저 수행
|
|
ensureSheetAndHeaders_();
|
|
validateAndSort();
|
|
createDailyTrigger();
|
|
toast_('event_calendar 설정 완료', 5);
|
|
}
|
|
|
|
function runDaily() {
|
|
// refreshFromTradingEconomics(); // 로컬 수집 방식을 사용하므로 원격 실행은 건너뜁니다.
|
|
refreshFromNaver();
|
|
refreshFromSources();
|
|
validateAndSort();
|
|
sendEventAlerts();
|
|
}
|
|
|
|
|
|
/* ── 검증·정렬 ────────────────────────────────────────────────────────────── */
|
|
|
|
function validateAndSort() {
|
|
const sheet = ensureSheetAndHeaders_();
|
|
const hmap = getHeaderMap_(sheet);
|
|
const lastRow = sheet.getLastRow();
|
|
if (lastRow < 2) { toast_('데이터 없음', 3); return; }
|
|
|
|
const now = Utilities.formatDate(new Date(), CFG.TIME_ZONE, 'yyyy-MM-dd HH:mm:ss');
|
|
const today = todayKst_();
|
|
const range = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn());
|
|
const values = range.getValues();
|
|
|
|
const I = {
|
|
date: hmap.Date-1, event: hmap.Event-1, type: hmap.Type-1,
|
|
impact: hmap.Impact-1, days: hmap.DaysLeft-1,
|
|
status: hmap.AlertStatus-1, checked: hmap.LastCheckedAt-1, key: hmap.Key-1,
|
|
};
|
|
|
|
const rows = values.map(row => {
|
|
const d = coerceDate_(row[I.date]);
|
|
const event = String(row[I.event] || '').trim();
|
|
const type = String(row[I.type] || '').trim().toUpperCase();
|
|
const impact = String(row[I.impact] || '').trim().toUpperCase();
|
|
|
|
const errs = [];
|
|
if (!d) errs.push('ERROR: invalid date');
|
|
if (!event) errs.push('ERROR: empty event');
|
|
if (type && !CFG.VALID_TYPES.includes(type)) errs.push('WARN: unknown type');
|
|
if (impact && !CFG.VALID_IMPACTS.includes(impact)) errs.push('WARN: unknown impact');
|
|
|
|
if (d) { row[I.date] = d; row[I.days] = daysBetween_(today, d); } else row[I.days] = '';
|
|
if (!row[I.key] && d && event) row[I.key] = buildKey_(d, event, type);
|
|
if (errs.length) row[I.status] = errs.join(' | ');
|
|
row[I.checked] = now;
|
|
row[I.type] = type;
|
|
row[I.impact] = impact;
|
|
return row;
|
|
});
|
|
|
|
rows.sort((a, b) => {
|
|
const da = coerceDate_(a[I.date]), db = coerceDate_(b[I.date]);
|
|
if (!da && !db) return 0; if (!da) return 1; if (!db) return -1;
|
|
return da - db;
|
|
});
|
|
|
|
range.setValues(rows);
|
|
sheet.getRange(2, hmap.Date, Math.max(lastRow-1,1), 1).setNumberFormat(CFG.DATE_FORMAT);
|
|
applyFormatting_(sheet, hmap);
|
|
toast_('검증 및 정렬 완료', 3);
|
|
}
|
|
|
|
|
|
/* ── 이메일 알림 ──────────────────────────────────────────────────────────── */
|
|
|
|
function sendEventAlerts() {
|
|
Logger.log('[sendEventAlerts] 이메일 알림 발송 기능 비활성화 (사용자 요청)');
|
|
toast_('이메일 알림 발송 건너뜀 (비활성화)', 3);
|
|
return;
|
|
|
|
const todayStr = Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT);
|
|
const props = PropertiesService.getScriptProperties();
|
|
const due = [];
|
|
|
|
rows.forEach(item => {
|
|
const impact = String(item.Impact || '').toUpperCase();
|
|
const daysLeft = Number(item.DaysLeft);
|
|
if (!impact || isNaN(daysLeft) || daysLeft < 0) return;
|
|
if (daysLeft > (CFG.IMPACT_ALERT_WINDOW_DAYS[impact] || 0)) return;
|
|
const sentKey = `sent:${todayStr}:${item.Key || buildKey_(coerceDate_(item.Date), item.Event, item.Type)}`;
|
|
if (props.getProperty(sentKey)) return;
|
|
due.push({ ...item, DaysLeft: daysLeft, sentKey });
|
|
});
|
|
|
|
if (!due.length) { toast_('오늘 발송할 알림 없음', 3); return; }
|
|
|
|
const to = CFG.ALERT_EMAIL || Session.getActiveUser().getEmail();
|
|
if (!to) throw new Error('ALERT_EMAIL 또는 사용자 이메일 필요');
|
|
|
|
MailApp.sendEmail({ to, subject: `[Market Calendar] 임박 이벤트 ${due.length}건`, body: buildEmailBody_(due) });
|
|
|
|
due.forEach(item => {
|
|
props.setProperty(item.sentKey, '1');
|
|
if (hmap.AlertStatus) sheet.getRange(item.__row, hmap.AlertStatus).setValue(`SENT: ${todayStr}`);
|
|
});
|
|
toast_(`알림 발송 완료: ${due.length}건`, 4);
|
|
}
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════ *
|
|
* Trading Economics 스크래퍼
|
|
* ══════════════════════════════════════════════════════════════════════════ */
|
|
|
|
function refreshFromTradingEconomics() {
|
|
return; // 로컬 수집 방식으로 이관되어 비활성화
|
|
let sourceName = 'Trading Economics';
|
|
let events = fetchTradingEconomicsCalendar_(CFG.YAHOO_DAYS_AHEAD);
|
|
|
|
if (!events.length) {
|
|
Logger.log('[TradingEconomics] 차단 또는 결과 없음. Yahoo Finance 오늘 날짜 수집으로 Fallback합니다.');
|
|
events = fetchYahooCalendar_();
|
|
sourceName = 'Yahoo Fallback';
|
|
}
|
|
|
|
if (!events.length) {
|
|
toast_('캘린더: 수집된 이벤트 없음 (야후/TE 모두 차단 또는 일정 없음)', 4);
|
|
return;
|
|
}
|
|
|
|
upsertEvents_(events);
|
|
toast_(`${sourceName} 갱신: ${events.length}건`, 4);
|
|
}
|
|
|
|
function fetchTradingEconomicsCalendar_(daysAhead) {
|
|
Logger.log('[TradingEconomics] 로컬(클라이언트) 수집 방식을 사용하므로 구글 서버의 직접 호출은 건너뜁니다.');
|
|
return [];
|
|
|
|
const today = todayKst_();
|
|
const startDateStr = Utilities.formatDate(today, CFG.TIME_ZONE, 'yyyy-MM-dd');
|
|
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() + (daysAhead || 60));
|
|
const endDateStr = Utilities.formatDate(end, CFG.TIME_ZONE, 'yyyy-MM-dd');
|
|
|
|
const cache = CacheService.getScriptCache();
|
|
const cacheKey = `te_cal_parsed:${startDateStr}:${endDateStr}`;
|
|
const cachedData = cache.get(cacheKey);
|
|
|
|
if (cachedData !== null) {
|
|
try {
|
|
const parsed = JSON.parse(cachedData);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed;
|
|
}
|
|
} catch(e) {
|
|
Logger.log(`[TradingEconomics] 캐시 파싱 실패: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
const url = "https://tradingeconomics.com/calendar";
|
|
const headers = {
|
|
'User-Agent': CFG.CHROME_UA,
|
|
'Cookie': `cal-custom-range=${startDateStr}|${endDateStr}`
|
|
};
|
|
|
|
let events = [];
|
|
try {
|
|
const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers);
|
|
if (html) {
|
|
events = parseTradingEconomicsHtml_(html);
|
|
if (events.length > 0) {
|
|
cache.put(cacheKey, JSON.stringify(events), 12 * 60 * 60);
|
|
}
|
|
}
|
|
} catch(e) {
|
|
Logger.log(`[TradingEconomics] 실패: ${e.message}`);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
function fetchYahooCalendar_() {
|
|
const events = [];
|
|
const today = todayKst_();
|
|
const dateStr = Utilities.formatDate(today, CFG.TIME_ZONE, CFG.DATE_FORMAT);
|
|
|
|
const cache = CacheService.getScriptCache();
|
|
const cacheKey = `yahoo_cal_parsed:${dateStr}`;
|
|
const cachedData = cache.get(cacheKey);
|
|
|
|
if (cachedData !== null) {
|
|
try {
|
|
const parsed = JSON.parse(cachedData);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed;
|
|
}
|
|
} catch(e) {
|
|
Logger.log(`[Yahoo Fallback] 캐시 파싱 실패: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// 야후는 day 파라미터가 무시되므로 오늘 날짜 1일치만 fetch합니다.
|
|
const url = `https://finance.yahoo.com/calendar/economic?day=${dateStr}`;
|
|
const headers = { 'User-Agent': 'Mozilla/5.0 (compatible; GAS/1.0)' };
|
|
|
|
try {
|
|
const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers);
|
|
if (html) {
|
|
const dailyEvents = parseYahooHtml_(html, today);
|
|
if (dailyEvents.length > 0) {
|
|
cache.put(cacheKey, JSON.stringify(dailyEvents), 12 * 60 * 60);
|
|
events.push(...dailyEvents);
|
|
}
|
|
}
|
|
} catch(e) {
|
|
Logger.log(`[Yahoo Fallback] 실패: ${e.message}`);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
function parseYahooHtml_(html, dateHint) {
|
|
const trMatches = html.match(/<tr[^>]*data-testid="data-table-v2-row"[\s\S]*?<\/tr>/gi);
|
|
if (!trMatches || !trMatches.length) {
|
|
Logger.log('[Yahoo Fallback] table row 없음');
|
|
return [];
|
|
}
|
|
|
|
const events = [];
|
|
const dateStr = Utilities.formatDate(dateHint, CFG.TIME_ZONE, CFG.DATE_FORMAT);
|
|
|
|
for (let i = 0; i < trMatches.length; i++) {
|
|
const trHtml = trMatches[i];
|
|
|
|
const getCell_ = (testId) => {
|
|
const regex = new RegExp(`data-testid-cell=["']${testId}["'][^>]*>([\\s\\S]*?)</td>`, 'i');
|
|
const m = trHtml.match(regex);
|
|
if (m) {
|
|
let val = m[1].replace(/<[^>]+>/g, ' ');
|
|
val = val.replace(/\s+/g, ' ').trim();
|
|
return val;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const eventName = getCell_('econ_release');
|
|
const country = getCell_('country_code');
|
|
const actual = getCell_('after_release_actual');
|
|
const estimate = getCell_('consensus_estimate');
|
|
const prior = getCell_('prior_release_actual');
|
|
|
|
if (!eventName) continue;
|
|
|
|
let rawImpact = 'LOW';
|
|
if (trHtml.toLowerCase().includes('high') || trHtml.toLowerCase().includes('red') || trHtml.toLowerCase().includes('priority-3')) {
|
|
rawImpact = 'HIGH';
|
|
} else if (trHtml.toLowerCase().includes('medium') || trHtml.toLowerCase().includes('orange') || trHtml.toLowerCase().includes('priority-2')) {
|
|
rawImpact = 'MEDIUM';
|
|
}
|
|
|
|
const type = guessEventType_(eventName, country);
|
|
const finalImpact = guessImpact_(type, eventName) || rawImpact;
|
|
|
|
const countryUpper = String(country || '').toUpperCase().trim();
|
|
const allowedCountries = ['US', 'KR', 'JP'];
|
|
|
|
if (!allowedCountries.includes(countryUpper)) {
|
|
continue;
|
|
}
|
|
|
|
if (type === 'CUSTOM' && finalImpact === 'LOW') {
|
|
continue;
|
|
}
|
|
|
|
let alertText = '';
|
|
if (actual && actual !== '-') alertText += `Act: ${actual} `;
|
|
if (estimate && estimate !== '-') alertText += `Est: ${estimate} `;
|
|
if (prior && prior !== '-') alertText += `Prev: ${prior}`;
|
|
alertText = alertText.trim();
|
|
|
|
events.push({
|
|
Date: dateStr,
|
|
Event: eventName,
|
|
Type: type,
|
|
Impact: finalImpact,
|
|
Alert: alertText,
|
|
Source: 'Yahoo Finance',
|
|
SourceUrl: 'https://finance.yahoo.com/calendar/economic',
|
|
});
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
function parseTradingEconomicsHtml_(html) {
|
|
// data-event가 들어있는 모든 tr 매칭
|
|
const trMatches = html.match(/<tr[^>]*data-event="[^"]*"[\s\S]*?<\/tr>/gi);
|
|
if (!trMatches) {
|
|
Logger.log('[TradingEconomics] event table row 없음');
|
|
return [];
|
|
}
|
|
|
|
const events = [];
|
|
|
|
for (let i = 0; i < trMatches.length; i++) {
|
|
const trHtml = trMatches[i];
|
|
|
|
// td들로 쪼개기
|
|
const tdMatches = trHtml.match(/<td[^>]*>([\s\S]*?)<\/td>/gi);
|
|
if (!tdMatches || tdMatches.length < 9) continue;
|
|
|
|
// 1) 날짜 추출 (td[0]의 class 속성)
|
|
const td0 = tdMatches[0];
|
|
const dateMatch = td0.match(/class=["'](\d{4}-\d{2}-\d{2})["']/i);
|
|
if (!dateMatch) continue;
|
|
const dateStr = dateMatch[1];
|
|
|
|
// 2) 중요도 추출 (td[0] 내부의 calendar-date-N 클래스)
|
|
let impact = 'LOW';
|
|
if (td0.includes('calendar-date-3')) {
|
|
impact = 'HIGH';
|
|
} else if (td0.includes('calendar-date-2')) {
|
|
impact = 'MEDIUM';
|
|
}
|
|
|
|
// 3) 국가 코드 추출 (td[3] 내부 텍스트)
|
|
const td3 = tdMatches[3];
|
|
const countryIsoMatch = td3.match(/>([^<]+)</);
|
|
const countryIso = countryIsoMatch ? countryIsoMatch[1].trim().toUpperCase() : '';
|
|
|
|
// 4) 이벤트 이름 추출 (td[4] 내부의 calendar-event 링크 텍스트)
|
|
const td4 = tdMatches[4];
|
|
const eventMatch = td4.match(/class=["']calendar-event["'][^>]*>([^<]+)/i);
|
|
if (!eventMatch) continue;
|
|
const eventName = eventMatch[1].trim();
|
|
|
|
// 5) Actual, Previous, Consensus 값 추출 (HTML 태그 제거 및 공백 정규화)
|
|
const cleanTdText = (tdHtml) => {
|
|
let val = tdHtml.replace(/<[^>]+>/g, ' ');
|
|
val = val.replace(/\s+/g, ' ').trim();
|
|
return val;
|
|
};
|
|
|
|
const actualVal = cleanTdText(tdMatches[5]);
|
|
const previousVal = cleanTdText(tdMatches[6]);
|
|
const consensusVal = cleanTdText(tdMatches[7]);
|
|
|
|
// 6) 국가 필터링 (US, KR, JP만 수집)
|
|
const allowedCountries = ['US', 'KR', 'JP'];
|
|
if (!allowedCountries.includes(countryIso)) {
|
|
continue;
|
|
}
|
|
|
|
const type = guessEventType_(eventName, countryIso);
|
|
const finalImpact = guessImpact_(type, eventName) || impact;
|
|
|
|
// 중요도가 LOW이면서 핵심 분류 유형(FOMC 등)이 아닌 일반 CUSTOM 데이터는 제외
|
|
if (type === 'CUSTOM' && finalImpact === 'LOW') {
|
|
continue;
|
|
}
|
|
// ──────────────────────────
|
|
|
|
let alertText = '';
|
|
if (actualVal && actualVal !== '-') alertText += `Act: ${actualVal} `;
|
|
if (consensusVal && consensusVal !== '-') alertText += `Est: ${consensusVal} `;
|
|
if (previousVal && previousVal !== '-') alertText += `Prev: ${previousVal}`;
|
|
alertText = alertText.trim();
|
|
|
|
events.push({
|
|
Date: dateStr,
|
|
Event: eventName,
|
|
Type: type,
|
|
Impact: finalImpact,
|
|
Alert: alertText,
|
|
Source: 'Trading Economics',
|
|
SourceUrl: 'https://tradingeconomics.com/calendar',
|
|
});
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════ *
|
|
* Naver Finance 스크래퍼
|
|
*
|
|
* 1) 경제지표 일정: https://finance.naver.com/market/news/economic.naver
|
|
* → 날짜·제목이 포함된 뉴스 목록 테이블 파싱
|
|
*
|
|
* 2) 실적 발표: https://finance.naver.com/market/news/announce.naver
|
|
* → 기업 실적 발표 일정 테이블 파싱
|
|
*
|
|
* 블록킹 대응:
|
|
* - Referer: https://finance.naver.com/ 필수
|
|
* - Accept-Language: ko-KR 설정
|
|
* - 429 → fetchWithCache_ 재시도·stale 자동 처리
|
|
* ══════════════════════════════════════════════════════════════════════════ */
|
|
|
|
function refreshFromNaver() {
|
|
const events = fetchNaverCalendar_();
|
|
if (!events.length) { toast_('Naver: 수집된 이벤트 없음 (차단 또는 일정 없음)', 4); return; }
|
|
upsertEvents_(events);
|
|
toast_(`Naver Finance 갱신: ${events.length}건`, 4);
|
|
}
|
|
|
|
function fetchNaverCalendar_() {
|
|
const headers = {
|
|
'User-Agent': CFG.CHROME_UA,
|
|
'Referer': 'https://finance.naver.com/',
|
|
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
|
};
|
|
|
|
const events = [];
|
|
|
|
// 1. 경제 속보 뉴스 리스트 긁기 (EUC-KR 인코딩)
|
|
try {
|
|
const url = 'https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=258';
|
|
const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers, 'EUC-KR');
|
|
if (html) events.push(...parseNaverNewsList_(html, 'KR', 'Naver 뉴스 속보', url));
|
|
} catch(e) { Logger.log('[Naver News List] ' + e.message); }
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Naver Finance 뉴스 목록 HTML에서 기사 제목과 발행일을 추출.
|
|
*/
|
|
function parseNaverNewsList_(html, region, sourceName, sourceUrl) {
|
|
const events = [];
|
|
|
|
// articleSubject 및 wdate 추출용 정규식
|
|
const subjectPattern = /<dd class=["']articleSubject["']>[\s\S]*?<a href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
const wdatePattern = /<span class=["']wdate["']>([\s\S]*?)<\/span>/i;
|
|
|
|
let match;
|
|
while ((match = subjectPattern.exec(html)) !== null) {
|
|
const link = match[1].trim();
|
|
const titleRaw = match[2].trim();
|
|
|
|
// HTML 태그 제거 및 공백 정규화
|
|
const eventName = titleRaw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
if (eventName.length < 5 || /^\d+$/.test(eventName)) continue;
|
|
|
|
// 이 매치 직후 최대 1000자 범위 내에서 가장 가까운 wdate 매칭
|
|
const pos = subjectPattern.lastIndex;
|
|
const subHtml = html.substring(pos, pos + 1000);
|
|
const dateMatch = wdatePattern.exec(subHtml);
|
|
|
|
if (dateMatch) {
|
|
const dateStrRaw = dateMatch[1].trim();
|
|
const dateOnly = dateStrRaw.split(' ')[0]; // YYYY-MM-DD
|
|
const eventDate = coerceDate_(dateOnly);
|
|
|
|
if (eventDate && eventName) {
|
|
const type = guessEventType_(eventName, region);
|
|
events.push({
|
|
Date: eventDate,
|
|
Event: eventName,
|
|
Type: type,
|
|
Impact: guessImpact_(type, eventName),
|
|
Alert: '',
|
|
Source: sourceName,
|
|
SourceUrl: 'https://finance.naver.com' + link,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
|
|
/* ── 외부 URL 소스 (기존 유지) ───────────────────────────────────────────── */
|
|
|
|
function refreshFromSources() {
|
|
const props = PropertiesService.getScriptProperties();
|
|
const jsonUrl = props.getProperty(CFG.JSON_SOURCE_PROPERTY);
|
|
const csvUrl = props.getProperty(CFG.CSV_SOURCE_PROPERTY);
|
|
if (!jsonUrl && !csvUrl) { toast_('외부 URL 없음 — Script Properties 확인', 4); return; }
|
|
|
|
const events = [];
|
|
if (jsonUrl) events.push(...fetchEventsFromJson_(jsonUrl));
|
|
if (csvUrl) events.push(...fetchEventsFromCsv_(csvUrl));
|
|
if (!events.length) { toast_('외부 소스 이벤트 없음', 3); return; }
|
|
|
|
upsertEvents_(events);
|
|
validateAndSort();
|
|
toast_(`외부 이벤트 갱신: ${events.length}건`, 5);
|
|
}
|
|
|
|
function fetchEventsFromJson_(url) {
|
|
const text = fetchWithCache_(url);
|
|
if (!text) return [];
|
|
const parsed = JSON.parse(text);
|
|
if (!Array.isArray(parsed)) throw new Error('JSON source는 배열이어야 합니다.');
|
|
return parsed.map(normalizeEvent_);
|
|
}
|
|
|
|
function fetchEventsFromCsv_(url) {
|
|
const text = fetchWithCache_(url);
|
|
if (!text) return [];
|
|
const csv = Utilities.parseCsv(text);
|
|
if (csv.length < 2) return [];
|
|
const headers = csv[0].map(h => String(h || '').trim());
|
|
return csv.slice(1).map(row => {
|
|
const obj = {};
|
|
headers.forEach((h, i) => { obj[h] = row[i]; });
|
|
return normalizeEvent_(obj);
|
|
});
|
|
}
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════ *
|
|
* fetchWithCache_ — CacheService + 재시도 + stale fallback
|
|
*
|
|
* signature: fetchWithCache_(url, ttlSec?, extraHeaders?, encoding?)
|
|
* - ttlSec: 캐시 유효기간 (기본 CFG.CACHE_TTL_SEC)
|
|
* - extraHeaders: 추가 HTTP 헤더 (스크래핑 시 UA/Referer 주입용)
|
|
* - encoding: 응답 문자셋 인코딩 (기본 'UTF-8')
|
|
* ══════════════════════════════════════════════════════════════════════════ */
|
|
function fetchWithCache_(url, ttlSec, extraHeaders, encoding) {
|
|
const cache = CacheService.getScriptCache();
|
|
const cacheKey = 'url:' + md5_(url);
|
|
|
|
// 1. Cache HIT
|
|
const hit = cache.get(cacheKey);
|
|
if (hit !== null) return hit;
|
|
|
|
// 2. Fetch with retry
|
|
const opts = {
|
|
muteHttpExceptions: true,
|
|
followRedirects: true,
|
|
headers: Object.assign({ 'User-Agent': CFG.CHROME_UA }, extraHeaders || {}),
|
|
};
|
|
|
|
const charset = encoding || 'UTF-8';
|
|
|
|
for (let attempt = 0; attempt <= CFG.MAX_RETRIES; attempt++) {
|
|
if (attempt > 0) Utilities.sleep(CFG.RETRY_BASE_MS * attempt);
|
|
let resp;
|
|
try { resp = UrlFetchApp.fetch(url, opts); }
|
|
catch(e) { Logger.log(`[fetch] 예외 (${attempt}): ${e.message}`); continue; }
|
|
|
|
const code = resp.getResponseCode();
|
|
if (code === 429 || code === 503) { Utilities.sleep(2500 * (attempt+1)); continue; } // 일시 블록
|
|
if (code === 403 || code === 401) { Logger.log(`[fetch] ${code} 영구 블록: ${url}`); break; }
|
|
if (code < 200 || code >= 300) { Logger.log(`[fetch] HTTP ${code} (${attempt}): ${url}`); continue; }
|
|
|
|
const text = resp.getContentText(charset);
|
|
try {
|
|
cache.put(cacheKey, text, ttlSec || CFG.CACHE_TTL_SEC);
|
|
} catch(e) {
|
|
// 100KB 초과 HTML은 캐싱 크기 제한으로 실패하는 것이 정상이므로 로그 남기지 않고 패스
|
|
}
|
|
return text;
|
|
}
|
|
|
|
Logger.log(`[fetch] 실패: ${url}`);
|
|
return null;
|
|
}
|
|
|
|
|
|
/* ── Upsert / Sample ─────────────────────────────────────────────────────── */
|
|
|
|
function upsertEvents_(events) {
|
|
if (!events.length) return;
|
|
const sheet = ensureSheetAndHeaders_();
|
|
const hmap = getHeaderMap_(sheet);
|
|
const rowByKey = {};
|
|
getDataObjects_(sheet, hmap).forEach(item => { if (item.Key) rowByKey[item.Key] = item.__row; });
|
|
|
|
events.forEach(ev => {
|
|
const d = coerceDate_(ev.Date);
|
|
if (!d || !ev.Event) return;
|
|
const type = String(ev.Type || 'CUSTOM').toUpperCase();
|
|
const key = ev.Key || buildKey_(d, ev.Event, type);
|
|
const vals = { Date:d, Event:ev.Event, Type:type, Impact:String(ev.Impact||'MEDIUM').toUpperCase(),
|
|
Alert:ev.Alert||'', Source:ev.Source||'', SourceUrl:ev.SourceUrl||'', Key:key };
|
|
|
|
if (rowByKey[key]) {
|
|
Object.keys(vals).forEach(h => { if (hmap[h]) sheet.getRange(rowByKey[key], hmap[h]).setValue(vals[h]); });
|
|
} else {
|
|
const row = new Array(sheet.getLastColumn()).fill('');
|
|
Object.keys(vals).forEach(h => { if (hmap[h]) row[hmap[h]-1] = vals[h]; });
|
|
sheet.appendRow(row);
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadSampleDataIfEmpty() {
|
|
const sheet = ensureSheetAndHeaders_();
|
|
if (sheet.getLastRow() > 1) { toast_('이미 데이터 있음 — 삽입 생략', 4); return; }
|
|
sheet.getRange(2,1,6,5).setValues([
|
|
['2026-06-17','FOMC 금리결정','FOMC','HIGH','금리동결 시 KOSPI +1~2% 기대'],
|
|
['2026-07-28','FOMC 금리결정','FOMC','HIGH',''],
|
|
['2026-06-11','미국 CPI (5월)','US_CPI','HIGH','예상치 상회 시 당일 신규매수 자제'],
|
|
['2026-07-15','미국 CPI (6월)','US_CPI','HIGH','FOMC 전 마지막 CPI'],
|
|
['2026-06-20','삼성전자 1Q 잠정실적','EARNINGS','HIGH','반도체 섹터 선행 지표'],
|
|
['2026-06-15','옵션만기일','EXPIRY','MEDIUM','변동성 확대 구간 주의'],
|
|
]);
|
|
validateAndSort();
|
|
}
|
|
|
|
|
|
/* ── 트리거 ──────────────────────────────────────────────────────────────── */
|
|
|
|
function createDailyTrigger() {
|
|
const fn = 'runDaily';
|
|
ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === fn).forEach(t => ScriptApp.deleteTrigger(t));
|
|
ScriptApp.newTrigger(fn).timeBased().everyDays(1).atHour(8).create();
|
|
toast_('매일 오전 8시 트리거 설치 완료', 4);
|
|
}
|
|
|
|
function deleteProjectTriggers() {
|
|
ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
|
|
toast_('트리거 삭제 완료', 4);
|
|
}
|
|
|
|
function setJsonSourceUrl() { _saveUrlProp_(CFG.JSON_SOURCE_PROPERTY, 'EVENT_JSON_URL'); }
|
|
function setCsvSourceUrl() { _saveUrlProp_(CFG.CSV_SOURCE_PROPERTY, 'EVENT_CSV_URL'); }
|
|
function _saveUrlProp_(k, label) {
|
|
const v = Browser.inputBox(label + '를 입력하세요.');
|
|
if (v && v !== 'cancel') PropertiesService.getScriptProperties().setProperty(k, v);
|
|
}
|
|
|
|
|
|
/* ── 이벤트 타입·임팩트 추론 헬퍼 ───────────────────────────────────────── */
|
|
|
|
/**
|
|
* 이벤트 이름으로 타입을 추론.
|
|
* region: 'US' | 'KR' (기본 'US')
|
|
*/
|
|
const TYPE_MAP_ = [
|
|
{ keys: ['FOMC','연준','Federal Open Market','Fed Rate'], type: 'FOMC' },
|
|
{ keys: ['CPI','소비자물가','Consumer Price'], type: null }, // region 분기
|
|
{ keys: ['PPI','생산자물가','Producer Price'], type: 'US_PPI' },
|
|
{ keys: ['PCE','개인소비지출','Personal Consumption'], type: 'US_PCE' },
|
|
{ keys: ['NFP','비농업','Nonfarm','Payroll'], type: 'US_NFP' },
|
|
{ keys: ['실적','잠정실적','Earnings','EPS','Revenue'], type: 'EARNINGS' },
|
|
{ keys: ['옵션만기','선물만기','만기일','Expiry','Triple Witching'], type: 'EXPIRY' },
|
|
{ keys: ['한국은행','금통위','BOK','Bank of Korea'], type: 'BOK' },
|
|
{ keys: ['환율','FX','Dollar','달러'], type: 'FX' },
|
|
{ keys: ['국채','채권','Bond','Treasury'], type: 'BOND' },
|
|
{ keys: ['BOJ','일본은행','Bank of Japan','BOJ Rate','BOJ Interest'], type: 'BOJ' },
|
|
];
|
|
|
|
function guessEventType_(eventName, region) {
|
|
const upper = String(eventName || '').toUpperCase();
|
|
const reg = String(region || '').toUpperCase().trim();
|
|
|
|
for (const rule of TYPE_MAP_) {
|
|
if (rule.keys.some(k => upper.includes(k.toUpperCase()))) {
|
|
if (rule.type === null) {
|
|
// CPI 분기: 한국 CPI vs 미국 CPI (타국 CPI는 CUSTOM 처리하여 오인 방지)
|
|
if (reg === 'KR' || upper.includes('한국') || upper.includes('KR')) return 'KR_CPI';
|
|
if (reg === 'US' || upper.includes('미국') || upper.includes('US')) return 'US_CPI';
|
|
return 'CUSTOM';
|
|
}
|
|
|
|
// PPI, PCE, NFP, FOMC 등 미국 전용 타입들은 국가 코드가 US인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리
|
|
const usOnlyTypes = ['US_PPI', 'US_PCE', 'US_NFP', 'FOMC'];
|
|
if (usOnlyTypes.includes(rule.type) && reg !== 'US' && reg !== '') {
|
|
return 'CUSTOM';
|
|
}
|
|
|
|
// BOJ 일본은행 전용 타입은 국가 코드가 JP인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리
|
|
if (rule.type === 'BOJ' && reg !== 'JP' && reg !== '') {
|
|
return 'CUSTOM';
|
|
}
|
|
|
|
return rule.type;
|
|
}
|
|
}
|
|
return 'CUSTOM';
|
|
}
|
|
|
|
/** 타입 기반 기본 임팩트 */
|
|
function guessImpact_(type, eventName) {
|
|
const highTypes = ['FOMC','US_CPI','US_NFP','BOK','KR_CPI','BOJ'];
|
|
const medTypes = ['US_PPI','US_PCE','EARNINGS','EXPIRY'];
|
|
if (highTypes.includes(type)) return 'HIGH';
|
|
if (medTypes.includes(type)) return 'MEDIUM';
|
|
return 'LOW';
|
|
}
|
|
|
|
|
|
/* ── 내부 헬퍼 (compact) ─────────────────────────────────────────────────── */
|
|
|
|
function safeGet_(obj, keys) {
|
|
return keys.reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), obj);
|
|
}
|
|
|
|
function getSpreadsheet_() {
|
|
return CFG.SPREADSHEET_ID ? SpreadsheetApp.openById(CFG.SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
|
|
}
|
|
|
|
function ensureSheetAndHeaders_() {
|
|
const ss = getSpreadsheet_();
|
|
const sheet = ss.getSheetByName(CFG.SHEET_NAME) || ss.insertSheet(CFG.SHEET_NAME);
|
|
const lastCol = Math.max(sheet.getLastColumn(), 1);
|
|
const existing = sheet.getRange(1,1,1,lastCol).getValues()[0].map(h => String(h||'').trim());
|
|
if (!existing.some(Boolean)) {
|
|
sheet.getRange(1,1,1,CFG.ALL_HEADERS.length).setValues([CFG.ALL_HEADERS]);
|
|
return sheet;
|
|
}
|
|
const missing = CFG.ALL_HEADERS.filter(h => !existing.includes(h));
|
|
if (missing.length) sheet.getRange(1, sheet.getLastColumn()+1, 1, missing.length).setValues([missing]);
|
|
const hmap = getHeaderMap_(sheet);
|
|
CFG.REQUIRED_HEADERS.forEach(h => { if (!hmap[h]) throw new Error(`필수 헤더 없음: ${h}`); });
|
|
return sheet;
|
|
}
|
|
|
|
function getHeaderMap_(sheet) {
|
|
const map = {};
|
|
sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0]
|
|
.forEach((h,i) => { const k=String(h||'').trim(); if(k) map[k]=i+1; });
|
|
return map;
|
|
}
|
|
|
|
function getDataObjects_(sheet, hmap) {
|
|
const lastRow = sheet.getLastRow();
|
|
if (lastRow < 2) return [];
|
|
const headers = Object.keys(hmap);
|
|
const lastCol = sheet.getLastColumn();
|
|
return sheet.getRange(2,1,lastRow-1,lastCol).getValues().map((row,r) => {
|
|
const obj = { __row: r+2 };
|
|
headers.forEach(h => { obj[h] = row[hmap[h]-1]; });
|
|
return obj;
|
|
});
|
|
}
|
|
|
|
function normalizeEvent_(obj) {
|
|
return {
|
|
Date: obj.Date || obj.date,
|
|
Event: obj.Event || obj.event || obj.title || obj.name,
|
|
Type: obj.Type || obj.type || 'CUSTOM',
|
|
Impact: obj.Impact || obj.impact || 'MEDIUM',
|
|
Alert: obj.Alert || obj.alert || '',
|
|
Source: obj.Source || obj.source || '',
|
|
SourceUrl: obj.SourceUrl || obj.sourceUrl || obj.url || '',
|
|
Key: obj.Key || obj.key || '',
|
|
};
|
|
}
|
|
|
|
function coerceDate_(v) {
|
|
if (v instanceof Date && !isNaN(v)) return new Date(v.getFullYear(), v.getMonth(), v.getDate());
|
|
if (typeof v === 'string') {
|
|
const m = v.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
|
|
if (m) return new Date(+m[1], +m[2]-1, +m[3]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function todayKst_() {
|
|
return coerceDate_(Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT));
|
|
}
|
|
|
|
function daysBetween_(a, b) {
|
|
return Math.round(
|
|
(new Date(b.getFullYear(),b.getMonth(),b.getDate()) -
|
|
new Date(a.getFullYear(),a.getMonth(),a.getDate())) / 86400000
|
|
);
|
|
}
|
|
|
|
function buildKey_(dateObj, eventName, type) {
|
|
return md5_([Utilities.formatDate(dateObj,CFG.TIME_ZONE,CFG.DATE_FORMAT),
|
|
String(type||'').toUpperCase(), String(eventName||'').trim()].join('|'));
|
|
}
|
|
|
|
function md5_(text) {
|
|
return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text, Utilities.Charset.UTF_8)
|
|
.map(b => ('0'+(b<0?b+256:b).toString(16)).slice(-2)).join('');
|
|
}
|
|
|
|
function buildEmailBody_(events) {
|
|
const fmt = d => d instanceof Date ? Utilities.formatDate(d,CFG.TIME_ZONE,CFG.DATE_FORMAT) : String(d);
|
|
return [
|
|
'시장 이벤트 임박 알림','',
|
|
'기준: '+Utilities.formatDate(new Date(),CFG.TIME_ZONE,'yyyy-MM-dd HH:mm:ss'),'',
|
|
...events.flatMap((item,i) => [
|
|
`${i+1}. [${item.Impact}] ${fmt(item.Date)} / D-${item.DaysLeft}`,
|
|
` Event: ${item.Event}`, ` Type: ${item.Type}`,
|
|
...(item.Alert?[` Alert: ${item.Alert}`]:[]),'',
|
|
]),
|
|
'이 알림은 자동 알림이며 투자 판단의 최종 근거가 아닙니다.',
|
|
].join('\n');
|
|
}
|
|
|
|
function applyFormatting_(sheet, hmap) {
|
|
const lastRow = Math.max(sheet.getLastRow(),1), lastCol = Math.max(sheet.getLastColumn(),1);
|
|
sheet.getRange(1,1,1,lastCol).setFontWeight('bold');
|
|
sheet.setFrozenRows(1);
|
|
for (let c=1;c<=lastCol;c++) sheet.autoResizeColumn(c);
|
|
if (lastRow >= 2) {
|
|
if (hmap.Impact) sheet.getRange(2,hmap.Impact, lastRow-1,1).setFontWeight('bold');
|
|
if (hmap.DaysLeft) sheet.getRange(2,hmap.DaysLeft,lastRow-1,1).setNumberFormat('0');
|
|
}
|
|
}
|
|
|
|
function toast_(msg, sec) {
|
|
try {
|
|
const activeSs = SpreadsheetApp.getActive();
|
|
if (activeSs) {
|
|
activeSs.toast(msg, 'Market Calendar', sec);
|
|
} else {
|
|
Logger.log('[TOAST] ' + msg);
|
|
}
|
|
} catch (e) {
|
|
Logger.log('[TOAST] ' + msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 용량을 극도로 많이 소모하는 Script Properties의 캐시성 데이터(stale_url, cal_parsed 등)를 청소.
|
|
* SPREADSHEET_ID 나 sf_w2_ranks_json 같은 중요 설정/운영 데이터는 보호합니다.
|
|
*/
|
|
function cleanUpProperties() {
|
|
const props = PropertiesService.getScriptProperties();
|
|
const keys = props.getKeys();
|
|
let deleteCount = 0;
|
|
|
|
// SPREADSHEET_ID, sf_w2_ranks_json, EVENT_JSON_URL, EVENT_CSV_URL, HARNESS_VERBOSE_LOG 등 설정은 제외
|
|
const protectedKeys = ['SPREADSHEET_ID', 'sf_w2_ranks_json', 'EVENT_JSON_URL', 'EVENT_CSV_URL', 'HARNESS_VERBOSE_LOG'];
|
|
|
|
keys.forEach(k => {
|
|
if (protectedKeys.includes(k)) {
|
|
return;
|
|
}
|
|
|
|
// 캐시 관련 접두사를 가진 항목 및 임시 런타임 상태값 삭제
|
|
const shouldDelete =
|
|
k.indexOf('stale_url:') === 0 ||
|
|
k.indexOf('yahoo_cal_parsed:') === 0 ||
|
|
k.indexOf('te_cal_parsed:') === 0 ||
|
|
k.indexOf('url:') === 0 ||
|
|
k.indexOf('fetch_budget_') === 0 ||
|
|
k.indexOf('fetch_fail_') === 0 ||
|
|
k.indexOf('fetch_circuit_') === 0 ||
|
|
k.indexOf('fetch_session_') === 0 ||
|
|
k.indexOf('cs_') === 0;
|
|
|
|
if (shouldDelete) {
|
|
props.deleteProperty(k);
|
|
deleteCount++;
|
|
}
|
|
});
|
|
|
|
toast_(`프로퍼티 캐시 청소 완료: ${deleteCount}건 삭제`, 5);
|
|
}
|