diff --git a/gas_data_collect.gs b/gas_data_collect.gs
new file mode 100644
index 0000000..0b8a1c7
--- /dev/null
+++ b/gas_data_collect.gs
@@ -0,0 +1,4831 @@
+// =========================================================================
+// GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY
+// Generated At: 2026-06-21 20:47:17 KST
+// Source Files: src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs, src/gas_adapter_parts/gdc_02_account_satellite.gs
+// Source Hash: 9018659b3190a98307df69862d2bbdf877a195bf3d1494a2161cc1869533e82a
+// =========================================================================
+
+// --- Source: src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs ---
+// gas_data_collect.gs - Data collection & assembly layer
+// Fetch infrastructure, data fetchers, buildTickerRow_, runDataFeed
+// GAS global scope: functions in gas_data_feed.gs / gas_lib.gs callable directly
+
+function beginFetchSession_(label = "manual") {
+ const props = PropertiesService.getScriptProperties();
+
+ try {
+ const keys = props.getKeys();
+ let budgetCleared = 0;
+ let circuitExpired = 0;
+ const now = Date.now();
+ for (const k of keys) {
+ if (k.startsWith("fetch_budget_")) {
+ props.deleteProperty(k);
+ budgetCleared++;
+ } else if (k.startsWith("fetch_circuit_")) {
+ // 만료된 circuit breaker 자동 정리: until < now인 경우 제거.
+ // isFetchCircuitOpen_()도 자가 치유하지만, 세션 시작 시 선제 정리로
+ // 불필요한 PropertiesService read를 줄이고 상태를 명시적으로 초기화.
+ try {
+ const raw = props.getProperty(k);
+ if (raw) {
+ const data = JSON.parse(raw);
+ if (!data?.until || now >= Number(data.until)) {
+ props.deleteProperty(k);
+ const failKey = k.replace("fetch_circuit_", "fetch_fail_");
+ props.deleteProperty(failKey);
+ circuitExpired++;
+ }
+ }
+ } catch (_) {
+ props.deleteProperty(k);
+ circuitExpired++;
+ }
+ }
+ }
+ if (budgetCleared > 0 || circuitExpired > 0) {
+ Logger.log("[beginFetchSession_] budget_cleared=" + budgetCleared + " circuit_expired=" + circuitExpired);
+ }
+ } catch (e) {
+ Logger.log("[beginFetchSession_] Error clearing old properties: " + e.message);
+ }
+
+ props.setProperty("fetch_session_id", Utilities.getUuid());
+ props.setProperty("fetch_session_label", String(label ?? "manual"));
+ props.setProperty("fetch_session_started_at", new Date().toISOString());
+ props.setProperty("fetch_session_updated_at", new Date().toISOString());
+}
+
+function setFetchSessionLabel_(label = "manual") {
+ const props = PropertiesService.getScriptProperties();
+ let sid = props.getProperty("fetch_session_id");
+ if (!sid) {
+ beginFetchSession_(label);
+ return;
+ }
+ props.setProperty("fetch_session_label", String(label ?? "manual"));
+ props.setProperty("fetch_session_updated_at", new Date().toISOString());
+}
+
+function clearFetchCache() {
+ const props = PropertiesService.getScriptProperties();
+ const keys = props.getKeys();
+ for (const k of keys) {
+ if (k.startsWith("fetch_fail_") || k.startsWith("fetch_circuit_") || k.startsWith("fetch_budget_") || k.startsWith("cs_")) {
+ props.deleteProperty(k);
+ }
+ }
+ // Note: CacheService doesn't have a flushAll, but since we rely heavily on PropertiesService for circuit breakers,
+ // clearing the circuits will force a fresh fetch attempt and overwrite the cache.
+ Logger.log("Fetch cache and circuit breakers cleared.");
+}
+
+// 일부 배포본에서 gas_lib.gs 로딩이 누락돼도 runDataFeed 초기화를 살리기 위한 안전 경로.
+// gas_lib.gs의 동일 함수가 존재하면 그 구현을 우선 사용한다.
+const _gasCompatRoot_ = (typeof globalThis !== "undefined") ? globalThis : this;
+function _installCompat_(name, fn) {
+ if (typeof _gasCompatRoot_[name] !== "function") {
+ _gasCompatRoot_[name] = fn;
+ _gasCompatRoot_._gasCompatFallbackUsed_ = true;
+ }
+}
+
+const _gasCompatFallbacks_ = {
+ getSpreadsheet_: function() {
+ let _ssCacheDataCollect_ = _gasCompatRoot_._ssCacheDataCollect_ || null;
+ if (_ssCacheDataCollect_) return _ssCacheDataCollect_;
+ try {
+ if (typeof SPREADSHEET_ID !== "undefined" && SPREADSHEET_ID) {
+ _ssCacheDataCollect_ = SpreadsheetApp.openById(SPREADSHEET_ID);
+ _gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_;
+ return _ssCacheDataCollect_;
+ }
+ } catch (e) {
+ Logger.log(`getSpreadsheet_ fallback openById failed: ${e.message}`);
+ }
+ _ssCacheDataCollect_ = SpreadsheetApp.getActiveSpreadsheet();
+ _gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_;
+ return _ssCacheDataCollect_;
+ },
+ readSettingsTab_: function() {
+ const result = {};
+ try {
+ const ss = getSpreadsheet_();
+ const sheet = ss.getSheetByName("settings");
+ if (!sheet) {
+ Logger.log("readSettingsTab_: settings 탭 없음");
+ return result;
+ }
+ const data = sheet.getDataRange().getValues();
+ const SKIP_KEYS = new Set(["key", "updated", "date", "항목", "파라미터"]);
+ for (let i = 0; i < data.length; i++) {
+ const rawKey = String(data[i][0] ?? "").trim();
+ if (!rawKey || SKIP_KEYS.has(rawKey.toLowerCase())) continue;
+ const val = data[i][1];
+ if (val !== "" && val != null) result[rawKey] = val;
+ }
+ } catch (e) {
+ Logger.log(`readSettingsTab_ fallback error: ${e.message}`);
+ }
+ return result;
+ },
+ readPerformanceSheet_: function() {
+ const DEFAULT = {
+ bayesian_multiplier: 0.5,
+ bayesian_label: "medium_confidence",
+ trades_used: 0,
+ win_rate_30: null,
+ net_expectancy_30: null,
+ consecutive_losses: 0,
+ bayesian_data_source: "default",
+ };
+ try {
+ const ss = getSpreadsheet_();
+ const sheet = ss.getSheetByName("performance");
+ if (!sheet) return DEFAULT;
+ const data = sheet.getDataRange().getValues();
+ if (data.length < 3) return DEFAULT;
+ const hdr = data[1].map(h => String(h).trim());
+ const pnlIdx = hdr.indexOf("pnl_pct");
+ const exitIdx = hdr.indexOf("exit_date");
+ const exitDateIdx = hdr.indexOf("exit_date");
+ if (pnlIdx < 0 || exitIdx < 0) return DEFAULT;
+
+ const closed = [];
+ for (let i = 2; i < data.length; i++) {
+ const exitVal = data[i][exitIdx];
+ if (!exitVal || String(exitVal).trim() === "") continue;
+ const pnl = parseFloat(data[i][pnlIdx]);
+ if (!Number.isFinite(pnl)) continue;
+ const exitRaw = exitDateIdx >= 0 ? data[i][exitDateIdx] : exitVal;
+ const exitMs = exitRaw instanceof Date && !isNaN(exitRaw.getTime())
+ ? exitRaw.getTime()
+ : new Date(exitRaw).getTime();
+ closed.push({ pnl, exitMs: Number.isFinite(exitMs) ? exitMs : 0 });
+ }
+ if (closed.length === 0) return DEFAULT;
+ closed.sort((a, b) => b.exitMs - a.exitMs);
+ const recent = closed.slice(0, 30).map(r => r.pnl);
+ const n = recent.length;
+ if (n < 5) return DEFAULT;
+
+ const wins = recent.filter(x => x > 0).length;
+ const losses = recent.filter(x => x < 0).length;
+ const sum = recent.reduce((a, b) => a + b, 0);
+ const winRate = (wins / n) * 100;
+ const avg = sum / n;
+ const label = avg >= 2 ? "high_confidence" : avg >= 0 ? "medium_confidence" : "low_confidence";
+
+ return {
+ bayesian_multiplier: label === "high_confidence" ? 1.0 : label === "medium_confidence" ? 0.5 : 0.25,
+ bayesian_label: label,
+ trades_used: n,
+ win_rate_30: winRate,
+ net_expectancy_30: avg,
+ consecutive_losses: losses,
+ bayesian_data_source: "actual",
+ };
+ } catch (e) {
+ Logger.log(`readPerformanceSheet_ fallback error: ${e.message}`);
+ return DEFAULT;
+ }
+ },
+ calcKrxBizDaysDiff_: function(dateStr) {
+ if (!dateStr) return 999;
+ const norm = String(dateStr).replace(/\./g, "-");
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999;
+
+ const now = new Date();
+ const kstMs = now.getTime() + 9 * 3600 * 1000;
+ const kstNow = new Date(kstMs);
+ const todayStr = kstNow.toISOString().slice(0, 10);
+
+ let d = new Date(norm + "T00:00:00Z");
+ const end = new Date(todayStr + "T00:00:00Z");
+ if (d > end) return -1;
+ if (d.toISOString().slice(0, 10) === todayStr) return 0;
+
+ let count = 0;
+ const cur = new Date(d);
+ while (cur < end) {
+ cur.setDate(cur.getDate() + 1);
+ const dow = cur.getDay();
+ if (dow !== 0 && dow !== 6) count++;
+ }
+ return count;
+ },
+ isStalePriceDate_: function(dateStr, bizDaysThreshold = 1) {
+ const diff = calcKrxBizDaysDiff_(dateStr);
+ return diff > bizDaysThreshold;
+ },
+ calcValSurgeStatus: function(valSurge) {
+ if (!Number.isFinite(valSurge)) return "DATA_MISSING";
+ if (valSurge < THRESHOLDS.VAL_SURGE_WATCH) return "OK";
+ if (valSurge < THRESHOLDS.VAL_SURGE_HOT) return "WATCH";
+ if (valSurge < THRESHOLDS.VAL_SURGE_EXHAUSTED) return "HOT";
+ return "EXHAUSTED";
+ },
+ calcLiquidityStatus: function(avgTradingValue5D) {
+ if (!Number.isFinite(avgTradingValue5D)) return "DATA_MISSING";
+ if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_PREFERRED_M) return "PREFERRED";
+ if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_OK_M) return "OK";
+ return "LOW";
+ },
+ calcSpreadStatus: function(spreadPct) {
+ if (!Number.isFinite(spreadPct)) return "QUOTE_NO_MATCH";
+ if (spreadPct <= THRESHOLDS.SPREAD_OK_PCT) return "OK";
+ if (spreadPct <= THRESHOLDS.SPREAD_WARN_PCT) return "WATCH";
+ return "BLOCK";
+ }
+};
+
+for (const [name, fn] of Object.entries(_gasCompatFallbacks_)) {
+ _installCompat_(name, fn);
+}
+
+function getFetchSessionId_() {
+ const props = PropertiesService.getScriptProperties();
+ let sid = props.getProperty("fetch_session_id");
+ if (!sid) {
+ sid = Utilities.getUuid();
+ props.setProperty("fetch_session_id", sid);
+ props.setProperty("fetch_session_label", "auto");
+ props.setProperty("fetch_session_started_at", new Date().toISOString());
+ props.setProperty("fetch_session_updated_at", new Date().toISOString());
+ }
+ return sid;
+}
+
+function cacheJsonGet_(key) {
+ const raw = CacheService.getScriptCache().get(key);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw);
+ } catch (_) {
+ return null;
+ }
+}
+
+function cacheJsonSet_(key, value, ttlSeconds) {
+ try {
+ CacheService.getScriptCache().put(key, JSON.stringify(value), ttlSeconds);
+ } catch (_) {}
+}
+
+function fetchBudgetKey_(source, bucket) {
+ const safeBucket = String(bucket ?? "global").replace(/[^A-Za-z0-9_.%-]/g, "_");
+ return `fetch_budget_${getFetchSessionId_()}_${source}_${safeBucket}`;
+}
+
+function fetchFailureKey_(source) {
+ return `fetch_fail_${source}`;
+}
+
+function fetchCircuitKey_(source) {
+ return `fetch_circuit_${source}`;
+}
+
+function isFetchCircuitOpen_(source) {
+ const props = PropertiesService.getScriptProperties();
+ const raw = props.getProperty(fetchCircuitKey_(source));
+ if (!raw) return false;
+ try {
+ const data = JSON.parse(raw);
+ if (!data?.until) {
+ props.deleteProperty(fetchCircuitKey_(source));
+ return false;
+ }
+ if (Date.now() >= Number(data.until)) {
+ props.deleteProperty(fetchCircuitKey_(source));
+ props.deleteProperty(fetchFailureKey_(source));
+ return false;
+ }
+ return true;
+ } catch (_) {
+ props.deleteProperty(fetchCircuitKey_(source));
+ return false;
+ }
+}
+
+function consumeFetchBudget_(source, bucket) {
+ const props = PropertiesService.getScriptProperties();
+ const budget = FETCH_GOVERNANCE.budget[source] ?? 1;
+ const key = fetchBudgetKey_(source, bucket);
+ const used = Number(props.getProperty(key) ?? "0") + 1;
+ props.setProperty(key, String(used));
+ return used <= budget;
+}
+
+function recordFetchSuccess_(source) {
+ const props = PropertiesService.getScriptProperties();
+ props.deleteProperty(fetchFailureKey_(source));
+ props.deleteProperty(fetchCircuitKey_(source));
+}
+
+function recordFetchFailure_(source) {
+ const props = PropertiesService.getScriptProperties();
+ const key = fetchFailureKey_(source);
+ const failures = Number(props.getProperty(key) ?? "0") + 1;
+ props.setProperty(key, String(failures));
+ if (failures >= FETCH_GOVERNANCE.failureLimit) {
+ props.setProperty(fetchCircuitKey_(source), JSON.stringify({
+ until: Date.now() + FETCH_GOVERNANCE.coolDownMs,
+ failures,
+ }));
+ }
+}
+
+const CACHE_VERSION = "v3_";
+
+function getCachedFetchResult_(cacheKey) {
+ return cacheJsonGet_(CACHE_VERSION + cacheKey);
+}
+
+function setCachedFetchResult_(cacheKey, result, ok, ttlOkKey) {
+ const ttl = ok ? (FETCH_GOVERNANCE.ttl[ttlOkKey] ?? FETCH_GOVERNANCE.ttl.naver_quote_ok) : FETCH_GOVERNANCE.ttl.failure;
+ cacheJsonSet_(CACHE_VERSION + cacheKey, result, ttl);
+}
+
+function annotateFetchValue_(result, source, bucket) {
+ const annotated = { ...(result || {}) };
+ const now = new Date();
+ const fetchedAt = annotated.fetched_at ? new Date(annotated.fetched_at) : now;
+ annotated.fetched_at = Utilities.formatDate(fetchedAt, "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ss");
+ const ageMinutes = Math.max(0, Math.round((now.getTime() - fetchedAt.getTime()) / 60000));
+ annotated.value_age_minutes = ageMinutes;
+
+ let dataStatus = "UNKNOWN";
+ let stale = false;
+ const dateCandidate = annotated.priceDate || annotated.date || annotated.updated_at || null;
+ if (typeof dateCandidate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateCandidate)) {
+ stale = isStalePriceDate_(dateCandidate);
+ dataStatus = stale ? "STALE" : "FRESH";
+ annotated.value_date = dateCandidate;
+ }
+ if (annotated.rows && Array.isArray(annotated.rows) && annotated.rows.length > 0) {
+ const firstRow = annotated.rows[0] || {};
+ const rowDate = firstRow.date || firstRow.Date || firstRow.priceDate || firstRow.updated_at;
+ if (typeof rowDate === "string" && /^\d{4}[-.]\d{2}[-.]\d{2}$/.test(rowDate)) {
+ const normalized = rowDate.replace(/\./g, "-");
+ stale = stale || isStalePriceDate_(normalized);
+ dataStatus = stale ? "STALE" : dataStatus === "UNKNOWN" ? "FRESH" : dataStatus;
+ annotated.value_date = annotated.value_date || normalized;
+ }
+ }
+ if (dataStatus === "UNKNOWN") {
+ dataStatus = ageMinutes <= 180 ? "FRESH" : "STALE";
+ }
+ annotated.data_value_status = dataStatus;
+ annotated.scrape_block_risk = source.startsWith("naver_") || source.startsWith("yahoo_")
+ ? (dataStatus === "STALE" ? "HIGH" : ageMinutes > 720 ? "MEDIUM" : "LOW")
+ : "LOW";
+ annotated.used_for = dataStatus === "STALE" ? "REFERENCE_ONLY" : "EXECUTION";
+ annotated.data_value_reason = dataStatus === "STALE"
+ ? `stale_or_old:${source}/${bucket}`
+ : `fresh:${source}/${bucket}`;
+ return annotated;
+}
+
+// ── Fetch 공통 래퍼 (P2-C) ─────────────────────────────────────────────────
+// cache 확인 → stale 재수집 판단 → circuit 확인 → budget 소비 → fetchFn 실행 → 결과 캐싱.
+// source: FETCH_GOVERNANCE 의 source 키 (예: "naver_flow")
+// bucket: consumeFetchBudget_ 의 bucket 파라미터 (종목코드 또는 심볼)
+// emptyFallback: circuit/budget 차단 시 반환할 기본값 객체 ({ ok:false, ... })
+// fetchFn: () → 결과 객체. try/catch 불필요 (래퍼가 처리). ok 필드로 성공/실패 판단.
+function withFetchCache_(cacheKey, source, bucket, emptyFallback, fetchFn) {
+ const cached = getCachedFetchResult_(cacheKey);
+ if (cached) {
+ const annotated = annotateFetchValue_(cached, source, bucket);
+ // Stale-revalidate: 캐시 데이터가 영업일 기준 오래됐으면 캐시 무효화 후 즉시 re-fetch.
+ // 주가·수급 데이터는 D-1(STALE)이면 당일 데이터로 교체해야 의사결정에 사용 가능.
+ // 호가(quote)는 30분 TTL이 짧아서 자연 만료되므로 stale revalidate 불필요.
+ if (annotated.data_value_status === "STALE"
+ && source !== "naver_quote"
+ && source !== "yahoo_quote") {
+ try { CacheService.getScriptCache().remove(CACHE_VERSION + cacheKey); } catch (_) {}
+ Logger.log("[STALE_REVALIDATE] " + source + "/" + bucket + " — 캐시 무효화 후 re-fetch");
+ // fall through to re-fetch below
+ } else {
+ return annotated;
+ }
+ }
+ if (isFetchCircuitOpen_(source)) {
+ return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_CIRCUIT_OPEN", source }, source, bucket);
+ }
+ if (!consumeFetchBudget_(source, bucket)) {
+ return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_BUDGET_EXCEEDED", source }, source, bucket);
+ }
+ let result;
+ try {
+ result = fetchFn();
+ } catch (e) {
+ result = { ...emptyFallback, ok: false, error: e.message, source };
+ }
+ result = annotateFetchValue_(result, source, bucket);
+ if (result.ok) {
+ recordFetchSuccess_(source);
+ setCachedFetchResult_(cacheKey, result, true, `${source}_ok`);
+ } else {
+ recordFetchFailure_(source);
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ }
+ return result;
+}
+
+// ── Naver frgn.naver 파서 ─────────────────────────────────────────────────
+function fetchNaverFlow(code) {
+ const ticker = normalizeTickerCode(code);
+ const cacheKey = `naver_flow_${ticker}`;
+ return withFetchCache_(cacheKey, "naver_flow", ticker, { ok: false, rows: [], isFlowStale: false }, () => {
+ const resp = UrlFetchApp.fetch(`https://finance.naver.com/item/frgn.naver?code=${code}&page=1`, {
+ headers: { "Accept-Language": "ko-KR,ko;q=0.9" },
+ muteHttpExceptions: true
+ });
+ const html = resp.getContentText("EUC-KR");
+ const rows = [];
+ const trPattern = /
]*>([\s\S]*?)<\/tr>/g;
+ let trMatch;
+ while ((trMatch = trPattern.exec(html)) !== null) {
+ const tds = [];
+ const tdPattern = /]*>([\s\S]*?)<\/td>/g;
+ let td;
+ while ((td = tdPattern.exec(trMatch[1])) !== null) {
+ tds.push(td[1].replace(/<[^>]+>/g, "").replace(/ /g, "").trim());
+ }
+ if (tds.length < 7 || !/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue;
+ const n = s => { const v = s.replace(/,/g,"").replace(/[+]/g,"").trim(); return isNaN(+v)||!v ? 0 : +v; };
+ const inst = n(tds[5]), frgn = n(tds[6]);
+ rows.push({ date: tds[0], inst, frgn, indiv: -(frgn + inst) });
+ if (rows.length >= 20) break;
+ }
+ const isFlowStale = rows.length > 0 && isStalePriceDate_(rows[0].date.replace(/\./g, "-"));
+ return { ok: rows.length >= 5, rows, source: "naver_flow", isFlowStale };
+ });
+}
+
+// ── Yahoo Finance 가격 조회 ───────────────────────────────────────────────
+function fetchYahooPrice(code) {
+ // 한국 종목/ETF 코드: 6자리 알파뉴메릭 → .KS suffix. ^ 기호로 시작하는 글로벌 지수는 제외.
+ const sym0 = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code;
+ const sym = sym0.replace(/\^/g, "%5E");
+ const cacheKey = `yahoo_price_${sym}`;
+ return withFetchCache_(cacheKey, "yahoo_price", sym0, { ok: false }, () => {
+ const resp = UrlFetchApp.fetch(`https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=3mo`, {
+ muteHttpExceptions: true,
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
+ });
+ if (resp.getResponseCode() !== 200) return { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_price" };
+ const closes = JSON.parse(resp.getContentText())
+ ?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? [];
+ if (closes.length < 5) return { ok: false, source: "yahoo_price" };
+ const last = closes[closes.length-1];
+ const d5 = closes[Math.max(0, closes.length-6)];
+ const d10 = closes[Math.max(0, closes.length-11)];
+ const d20 = closes[Math.max(0, closes.length-21)];
+ return { ok: true, close: last,
+ ret5D: ((last/d5 -1)*100).toFixed(2),
+ ret10D: ((last/d10-1)*100).toFixed(2),
+ ret20D: ((last/d20-1)*100).toFixed(2),
+ source: "yahoo_price" };
+ });
+}
+
+function fetchYahooMarketMetrics(code) {
+ const sym = normalizeYahooSymbol(code);
+ const cacheKey = `yahoo_quote_${sym}`;
+ const cached = getCachedFetchResult_(cacheKey);
+ if (cached) return cached;
+ if (isFetchCircuitOpen_("yahoo_quote")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_quote", quoteStatus: "QUOTE_CIRCUIT_OPEN" };
+ if (!consumeFetchBudget_("yahoo_quote", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_quote", quoteStatus: "QUOTE_BUDGET_EXCEEDED" };
+ const apiUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(sym)}`;
+ let apiError = "";
+ let apiHttpStatus = null;
+ function extractQuotedNumber_(text, marker, limitChars) {
+ const start = text.indexOf(marker);
+ if (start < 0) return null;
+ const segment = text.slice(start + marker.length, start + marker.length + limitChars);
+ const match = segment.match(/([0-9,]+(?:\.[0-9]+)?)\s*x/i);
+ return match ? parseKrNum_(match[1]) : null;
+ }
+ try {
+ const resp = UrlFetchApp.fetch(apiUrl, {
+ muteHttpExceptions: true,
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
+ });
+ apiHttpStatus = resp.getResponseCode();
+ let data = null;
+ if (apiHttpStatus === 200) {
+ try {
+ data = JSON.parse(resp.getContentText());
+ } catch (e) {
+ apiError = `JSON_${e.message}`;
+ }
+ } else {
+ apiError = `HTTP ${apiHttpStatus}`;
+ }
+
+ const item = data?.quoteResponse?.result?.[0];
+ const marketPrice = Number(item?.regularMarketPrice) || null;
+ // Yahoo v7 quote API에서 추가 기본 지표 추출 (이미 수신된 응답 재활용)
+ const yahooBeta = Number.isFinite(Number(item?.beta)) ? Number(item?.beta) : null;
+ const yahooH52 = Number.isFinite(Number(item?.fiftyTwoWeekHigh)) ? Number(item?.fiftyTwoWeekHigh) : null;
+ const yahooL52 = Number.isFinite(Number(item?.fiftyTwoWeekLow)) ? Number(item?.fiftyTwoWeekLow) : null;
+ const yahooDiv = Number.isFinite(Number(item?.trailingAnnualDividendYield)) ? Number(item?.trailingAnnualDividendYield) * 100 : null;
+ let source = "yahoo_quote_api";
+ let quoteStatus = "QUOTE_API_NO_MATCH";
+ let resolvedBid = Number.isFinite(Number(item?.bid)) ? Number(item?.bid) : null;
+ let resolvedAsk = Number.isFinite(Number(item?.ask)) ? Number(item?.ask) : null;
+
+ if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) {
+ const htmlUrl = `https://finance.yahoo.com/quote/${encodeURIComponent(sym)}?webview=1`;
+ const htmlResp = UrlFetchApp.fetch(htmlUrl, {
+ muteHttpExceptions: true,
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
+ });
+ if (htmlResp.getResponseCode() === 200) {
+ const text = htmlResp.getContentText();
+ const bidFromTitle = extractQuotedNumber_(text, 'title="Bid"', 160);
+ const askFromTitle = extractQuotedNumber_(text, 'title="Ask"', 160);
+ if (Number.isFinite(bidFromTitle) && Number.isFinite(askFromTitle) && bidFromTitle > 0 && askFromTitle > 0) {
+ resolvedBid = bidFromTitle;
+ resolvedAsk = askFromTitle;
+ source = "yahoo_quote_html";
+ quoteStatus = "QUOTE_HTML_FALLBACK";
+ } else {
+ const rawBid = text.match(/"bid"[^0-9]*([0-9.]+)/i);
+ const rawAsk = text.match(/"ask"[^0-9]*([0-9.]+)/i);
+ const candidateBid = rawBid ? parseKrNum_(rawBid[1]) : null;
+ const candidateAsk = rawAsk ? parseKrNum_(rawAsk[1]) : null;
+ if (Number.isFinite(candidateBid) && Number.isFinite(candidateAsk) && candidateBid > 0 && candidateAsk > 0) {
+ resolvedBid = candidateBid;
+ resolvedAsk = candidateAsk;
+ source = "yahoo_quote_html";
+ quoteStatus = "QUOTE_HTML_FALLBACK";
+ }
+ }
+ if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) {
+ quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_NO_MATCH";
+ }
+ } else {
+ quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_BLOCKED";
+ }
+ }
+
+ const spreadPct = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0
+ ? ((resolvedAsk - resolvedBid) / ((resolvedAsk + resolvedBid) / 2)) * 100
+ : null;
+ const ok = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0;
+ const result = {
+ ok,
+ source,
+ quoteStatus,
+ bid: resolvedBid,
+ ask: resolvedAsk,
+ spreadPct,
+ marketPrice,
+ beta: yahooBeta,
+ high52W: yahooH52,
+ low52W: yahooL52,
+ divYield: yahooDiv,
+ httpStatus: apiHttpStatus,
+ error: apiError || ""
+ };
+ if (ok) {
+ recordFetchSuccess_("yahoo_quote");
+ setCachedFetchResult_(cacheKey, result, true, "yahoo_quote_ok");
+ } else {
+ recordFetchFailure_("yahoo_quote");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ }
+ return result;
+ } catch (e) {
+ const result = { ok: false, error: e.message, source: "yahoo_quote", quoteStatus: "QUOTE_ERROR" };
+ recordFetchFailure_("yahoo_quote");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+}
+
+function fetchNaverMarketMetrics(code) {
+ const ticker = normalizeTickerCode(code);
+ const cacheKey = `naver_quote_${ticker}`;
+ const cached = getCachedFetchResult_(cacheKey);
+ if (cached) return cached;
+ if (isFetchCircuitOpen_("naver_quote")) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_CIRCUIT_OPEN", httpStatus: null };
+ if (!consumeFetchBudget_("naver_quote", ticker)) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_BUDGET_EXCEEDED", httpStatus: null };
+ const url = `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(code)}`;
+ try {
+ const resp = UrlFetchApp.fetch(url, {
+ muteHttpExceptions: true,
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" }
+ });
+ const httpStatus = resp.getResponseCode();
+ if (httpStatus !== 200) {
+ const result = { ok: false, source: "naver_main", quoteStatus: `NAVER_QUOTE_HTTP_${httpStatus}`, httpStatus };
+ recordFetchFailure_("naver_quote");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+
+ const html = resp.getContentText("EUC-KR");
+ const currentMatch = html.match(/오늘의시세\s+([0-9,]+)\s+포인트/i) || html.match(/현재가\s+([0-9,]+)/i);
+ const currentPrice = currentMatch ? parseKrNum_(currentMatch[1]) : null;
+
+ const perMatch = html.match(/([\d,.]+)<\/em>/);
+ const pbrMatch = html.match(/([\d,.]+)<\/em>/);
+ const epsMatch = html.match(/([\d,.-]+)<\/em>/);
+ const per = perMatch ? parseKrNum_(perMatch[1]) : null;
+ const pbr = pbrMatch ? parseKrNum_(pbrMatch[1]) : null;
+ const eps = epsMatch ? parseKrNum_(epsMatch[1]) : null;
+
+ // 배당수익률 — Naver main 페이지 _dvr ID
+ const dvrMatch = html.match(/([\d,.]+)<\/em>/);
+ const dvr = dvrMatch ? parseKrNum_(dvrMatch[1]) : null;
+
+ // 52주 최고/최저 — Naver main 페이지 여러 패턴 시도
+ const parseNum_ = s => { const v = parseFloat(String(s ?? "").replace(/,/g, "")); return Number.isFinite(v) && v > 0 ? v : null; };
+ const h52m = html.match(/52[주週]최고[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) ||
+ html.match(/52주\s*최고가?[\s\S]{0,100}?]*>([\d,]+)<\/em>/) ||
+ html.match(/high52[^>]*>\s*([\d,]+)/) ||
+ html.match(/([\d,]+)<\/em>/);
+ const l52m = html.match(/52[주週]최저[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) ||
+ html.match(/52주\s*최저가?[\s\S]{0,100}?]*>([\d,]+)<\/em>/) ||
+ html.match(/low52[^>]*>\s*([\d,]+)/) ||
+ html.match(/([\d,]+)<\/em>/);
+ const naverHigh52W = h52m ? parseNum_(h52m[1]) : null;
+ const naverLow52W = l52m ? parseNum_(l52m[1]) : null;
+
+ const askPrices = [];
+ const bidPrices = [];
+ const askRowPattern = /[\s\S]*?| \s*([0-9,]+)\s*<\/td>\s* | \s*([0-9,]+)\s*<\/td>/g;
+ const bidRowPattern = / | [\s\S]*?| \s*<\/td>\s* | \s*([0-9,]+)\s*<\/td>\s* | \s*([0-9,]+)\s*<\/td>/g;
+ let m;
+ while ((m = askRowPattern.exec(html)) !== null) {
+ const ask = parseKrNum_(m[2]);
+ if (Number.isFinite(ask) && ask > 0) askPrices.push(ask);
+ }
+ while ((m = bidRowPattern.exec(html)) !== null) {
+ const bid = parseKrNum_(m[1]);
+ if (Number.isFinite(bid) && bid > 0) bidPrices.push(bid);
+ }
+
+ const bid = bidPrices.length ? Math.max(...bidPrices) : null;
+ const ask = askPrices.length ? Math.min(...askPrices) : null;
+ const spreadPct = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0
+ ? ((ask - bid) / ((ask + bid) / 2)) * 100
+ : null;
+ const ok = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0;
+
+ const result = {
+ ok,
+ source: "naver_main",
+ quoteStatus: ok ? "NAVER_QUOTE_OK" : "NAVER_QUOTE_NO_MATCH",
+ bid,
+ ask,
+ spreadPct,
+ marketPrice: Number.isFinite(currentPrice) ? currentPrice : null,
+ per,
+ pbr,
+ eps,
+ dvr,
+ high52W: naverHigh52W,
+ low52W: naverLow52W,
+ httpStatus
+ };
+ if (ok) {
+ recordFetchSuccess_("naver_quote");
+ setCachedFetchResult_(cacheKey, result, true, "naver_quote_ok");
+ } else {
+ recordFetchFailure_("naver_quote");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ }
+ return result;
+ } catch (e) {
+ const result = { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_ERROR", error: e.message };
+ recordFetchFailure_("naver_quote");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+}
+
+// Backward-compatible thin wrapper.
+// Older callers still expect fetchNaverQuoteMetrics().
+function fetchNaverQuoteMetrics(code) {
+ return fetchNaverMarketMetrics(code);
+}
+
+function fetchNaverOhlcMetrics(code) {
+ const ticker = normalizeTickerCode(code);
+ const cacheKey = `naver_ohlc_${ticker}`;
+ const cached = getCachedFetchResult_(cacheKey);
+ if (cached) return cached;
+ if (isFetchCircuitOpen_("naver_ohlc")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "naver_ohlc" };
+ if (!consumeFetchBudget_("naver_ohlc", ticker)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "naver_ohlc" };
+ const rows = [];
+ try {
+ for (let page = 1; page <= 7 && rows.length < 65; page++) {
+ const url = `https://finance.naver.com/item/sise_day.naver?code=${encodeURIComponent(ticker)}&page=${page}`;
+ const resp = UrlFetchApp.fetch(url, {
+ muteHttpExceptions: true,
+ headers: {
+ "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)",
+ "Referer": `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(ticker)}`
+ }
+ });
+ if (resp.getResponseCode() !== 200) continue;
+ const html = resp.getContentText("EUC-KR");
+ const trPattern = / | ]*>([\s\S]*?)<\/tr>/g;
+ let trMatch;
+ while ((trMatch = trPattern.exec(html)) !== null) {
+ const tdPattern = /| ]*>([\s\S]*?)<\/td>/g;
+ const tds = [];
+ let td;
+ while ((td = tdPattern.exec(trMatch[1])) !== null) {
+ tds.push(td[1].replace(/<[^>]+>/g, "").replace(/ /g, "").replace(/\s+/g, " ").trim());
+ }
+ if (tds.length < 7) continue;
+ if (!/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue;
+ const n = (s) => {
+ const v = String(s ?? "").replace(/,/g, "").replace(/[+]/g, "").trim();
+ return v && !isNaN(+v) ? +v : null;
+ };
+ const close = n(tds[1]);
+ const open = n(tds[3]);
+ const high = n(tds[4]);
+ const low = n(tds[5]);
+ const volume = n(tds[6]);
+ if ([close, open, high, low, volume].some(v => v == null)) continue;
+ rows.push({
+ date: tds[0],
+ open,
+ high,
+ low,
+ close,
+ volume
+ });
+ if (rows.length >= 65) break;
+ }
+ }
+ if (rows.length < 21) {
+ const result = { ok: false, error: `NAVER_OHLC_ROWS_${rows.length}`, source: "naver_ohlc" };
+ recordFetchFailure_("naver_ohlc");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+ const latest = rows[0];
+ const derived = calcDerivedPriceMetrics(rows, true);
+ const atr20 = calcAtr20(rows.slice().reverse());
+ const avg5 = avgTradingValueM(rows.slice(1).reverse(), 5);
+ const avg20 = avgTradingValueM(rows.slice(1).reverse(), 20);
+ const currentValue = tradingValueM(latest);
+ const quote = fetchNaverMarketMetrics(ticker);
+ const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0
+ ? ((currentValue / avg5) - 1) * 100
+ : null;
+ const isPriceStale = isStalePriceDate_(latest.date);
+ const result = {
+ ok: true,
+ source: "Naver Finance sise_day.naver",
+ rows: rows.slice().reverse(),
+ priceDate: latest.date,
+ isPriceStale,
+ close: latest.close,
+ open: derived.open,
+ high: derived.high,
+ low: derived.low,
+ volume: derived.volume,
+ prevClose: derived.prevClose,
+ avgVolume5D: derived.avgVolume5D,
+ ma20: derived.ma20,
+ ma60: derived.ma60,
+ ret5D: derived.ret5D,
+ ret10D: derived.ret10D,
+ ret20D: derived.ret20D,
+ ret60D: derived.ret60D,
+ atr20,
+ atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null,
+ valSurge,
+ avgTradingValue5D: avg5,
+ avgTradingValue20D: avg20,
+ bid: Number.isFinite(quote.bid) ? quote.bid : null,
+ ask: Number.isFinite(quote.ask) ? quote.ask : null,
+ spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null,
+ marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : latest.close,
+ quoteSource: quote.source ?? "naver_main",
+ quoteStatus: quote.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
+ quoteHttpStatus: quote.httpStatus ?? null
+ };
+ recordFetchSuccess_("naver_ohlc");
+ setCachedFetchResult_(cacheKey, result, true, "naver_ohlc_ok");
+ return result;
+ } catch (e) {
+ const result = { ok: false, error: e.message, source: "naver_ohlc" };
+ recordFetchFailure_("naver_ohlc");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+}
+
+// ── 에러 처리 레이어 ─────────────────────────────────────────────────────────
+// severity: "CRITICAL" | "WARN" | "INFO"
+// CRITICAL: 시트 쓰기 실패, pre-read 실패 → 전체 실행 영향
+// WARN: 개별 종목 fetch 실패 → 해당 종목만 영향
+// INFO: 캐시 관련 오류 → 무시 가능
+function handleFetchError_(context, e, severity) {
+ Logger.log(`[${severity}] ${context}: ${e}`);
+}
+
+function normalizeYahooSymbol(code) {
+ let sym = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code;
+ return sym.replace(/\^/g, "%5E");
+}
+
+function normalizeTickerCode(code) {
+ const raw = String(code ?? "").trim();
+ if (!raw) return "";
+ if (/^[0-9]+$/.test(raw)) return raw.padStart(6, "0");
+ if (/^[0-9A-Z]+$/i.test(raw) && raw.length < 6) return raw.padStart(6, "0");
+ return raw;
+}
+
+function fetchYahooOhlcMetrics(code) {
+ const sym = normalizeYahooSymbol(code);
+ const cacheKey = `yahoo_chart_${sym}`;
+ const cached = getCachedFetchResult_(cacheKey);
+ if (cached) return cached;
+ if (isFetchCircuitOpen_("yahoo_chart")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_chart" };
+ if (!consumeFetchBudget_("yahoo_chart", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_chart" };
+ const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=6mo`;
+ try {
+ const resp = UrlFetchApp.fetch(url, {
+ muteHttpExceptions: true,
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
+ });
+ if (resp.getResponseCode() !== 200) {
+ const result = { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_chart" };
+ recordFetchFailure_("yahoo_chart");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+ const data = JSON.parse(resp.getContentText());
+ const chartResult = data?.chart?.result?.[0];
+ const ts = chartResult?.timestamp ?? [];
+ const q = chartResult?.indicators?.quote?.[0] ?? {};
+ const rows = [];
+ for (let i = 0; i < ts.length; i++) {
+ const open = q.open?.[i];
+ const high = q.high?.[i];
+ const low = q.low?.[i];
+ const close = q.close?.[i];
+ const volume = q.volume?.[i];
+ if ([open, high, low, close, volume].some(v => v == null || isNaN(+v))) continue;
+ const d = new Date(ts[i] * 1000);
+ rows.push({
+ date: Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd"),
+ open: +open,
+ high: +high,
+ low: +low,
+ close: +close,
+ volume: +volume
+ });
+ }
+ if (rows.length < 21) {
+ const result = { ok: false, error: `OHLC_ROWS_${rows.length}`, source: "yahoo_chart" };
+ recordFetchFailure_("yahoo_chart");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+ const latest = rows[rows.length - 1];
+ const derived = calcDerivedPriceMetrics(rows, false);
+ const atr20 = calcAtr20(rows);
+ const avg5 = avgTradingValueM(rows.slice(0, -1), 5);
+ const avg20 = avgTradingValueM(rows.slice(0, -1), 20);
+ const currentValue = tradingValueM(latest);
+ let quote = fetchNaverMarketMetrics(code);
+ if (!quote.ok) quote = fetchYahooMarketMetrics(code);
+ const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0
+ ? ((currentValue / avg5) - 1) * 100
+ : null;
+ const result = {
+ ok: true,
+ source: "Yahoo Finance chart",
+ rows,
+ priceDate: latest.date,
+ isPriceStale: isStalePriceDate_(latest.date),
+ close: latest.close,
+ open: derived.open,
+ high: derived.high,
+ low: derived.low,
+ volume: derived.volume,
+ prevClose: derived.prevClose,
+ avgVolume5D: derived.avgVolume5D,
+ ma20: derived.ma20,
+ ma60: derived.ma60,
+ ret5D: derived.ret5D,
+ ret10D: derived.ret10D,
+ ret20D: derived.ret20D,
+ ret60D: derived.ret60D,
+ atr20,
+ atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null,
+ valSurge,
+ avgTradingValue5D: avg5,
+ avgTradingValue20D: avg20,
+ bid: Number.isFinite(quote.bid) ? quote.bid : null,
+ ask: Number.isFinite(quote.ask) ? quote.ask : null,
+ spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null,
+ marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : null,
+ quoteSource: quote.source ?? "QUOTE_NO_MATCH",
+ quoteStatus: quote.quoteStatus ?? "QUOTE_NO_MATCH",
+ quoteHttpStatus: quote.httpStatus ?? null
+ };
+ recordFetchSuccess_("yahoo_chart");
+ setCachedFetchResult_(cacheKey, result, true, "yahoo_chart_ok");
+ return result;
+ } catch (e) {
+ const result = { ok: false, error: e.message, source: "yahoo_chart" };
+ recordFetchFailure_("yahoo_chart");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+}
+
+function fetchNaverDisclosureNotices(code) {
+ const ticker = normalizeTickerCode(code);
+ const cacheKey = `naver_notice_${ticker}`;
+ const cached = getCachedFetchResult_(cacheKey);
+ if (cached) return cached;
+ if (isFetchCircuitOpen_("naver_notice")) return { status: "NAVER_NOTICE_CIRCUIT_OPEN", source: "Naver Finance news_notice.naver", list: [] };
+ if (!consumeFetchBudget_("naver_notice", ticker)) return { status: "NAVER_NOTICE_BUDGET_EXCEEDED", source: "Naver Finance news_notice.naver", list: [] };
+ const url = `https://finance.naver.com/item/news_notice.naver?code=${code}&page=1`;
+ try {
+ const resp = UrlFetchApp.fetch(url, {
+ headers: {
+ Referer: `https://finance.naver.com/item/main.naver?code=${code}`,
+ Accept: "text/html,application/xhtml+xml"
+ },
+ muteHttpExceptions: true
+ });
+ if (resp.getResponseCode() !== 200) {
+ const result = { status: `NAVER_NOTICE_HTTP_${resp.getResponseCode()}`, source: "Naver Finance news_notice.naver", list: [] };
+ recordFetchFailure_("naver_notice");
+ setCachedFetchResult_(cacheKey, result, false, "failure");
+ return result;
+ }
+ const html = resp.getContentText("EUC-KR");
+ const rows = [];
+ const trMatches = html.match(/ | /gi) || [];
+ for (const tr of trMatches) {
+ const text = tr
+ .replace(/ |