Files
QuantEngineByItz/gas_event_calendar.gs
T
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- 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>
2026-06-13 13:20:14 +09:00

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&section_id=101&section_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);
}