// =========================================================================
// 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(/ |