diff --git a/src/gas/collection/gdc_01_fetch_fundamentals.gs b/src/gas/collection/gdc_01_fetch_fundamentals.gs
deleted file mode 100644
index bfa7e08..0000000
--- a/src/gas/collection/gdc_01_fetch_fundamentals.gs
+++ /dev/null
@@ -1,2637 +0,0 @@
-// 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(/ |