// ========================================================================= // 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(//gi, " ") .replace(//gi, " ") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") .replace(/\s+/g, " ") .trim(); const m = text.match(/^(.+?)\s+(KOSCOM|연합뉴스|이데일리|머니투데이|한국경제|매일경제|뉴스핌|아시아경제|서울경제|파이낸셜뉴스)\s+(\d{4}\.\d{2}\.\d{2})$/); if (!m) continue; rows.push({ report_nm: m[1].trim(), source: m[2], rcept_dt: m[3].replace(/\./g, "") }); } const result = { status: rows.length ? "NAVER_NOTICE_OK" : "NAVER_NOTICE_EMPTY", source: "Naver Finance news_notice.naver", list: rows }; if (rows.length) { recordFetchSuccess_("naver_notice"); setCachedFetchResult_(cacheKey, result, true, "naver_notice_ok"); } else { recordFetchFailure_("naver_notice"); setCachedFetchResult_(cacheKey, result, false, "failure"); } return result; } catch (e) { const result = { status: `NAVER_NOTICE_ERROR:${e.message}`, source: "Naver Finance news_notice.naver", list: [] }; recordFetchFailure_("naver_notice"); setCachedFetchResult_(cacheKey, result, false, "failure"); return result; } } function summarizeDisclosureNotices(disclosureResult) { const list = disclosureResult.list || []; const names = list.map(x => String(x.report_nm ?? "")); const catalyst = names.filter(nm => DART_CATALYST_KEYWORDS.some(k => nm.includes(k))).slice(0, 3); const risk = names.filter(nm => DART_RISK_KEYWORDS.some(k => nm.includes(k))).slice(0, 3); const recentDate = list[0]?.rcept_dt ? `${list[0].rcept_dt.slice(0,4)}-${list[0].rcept_dt.slice(4,6)}-${list[0].rcept_dt.slice(6,8)}` : ""; return { status: disclosureResult.status, source: disclosureResult.source, recentDate, catalyst: catalyst.join(" | "), risk: risk.join(" | "), count: list.length }; } function mapLatestPerformanceByTicker_(performanceRows) { const map = {}; (performanceRows || []).forEach(function(row) { const ticker = normalizeTickerCode(row.ticker || row.Ticker || ""); if (!ticker) return; const exitDate = parseIsoDateYmd_(row.exit_date || row.Exit_Date || row.exitDate); const exitMs = exitDate ? Date.parse(exitDate + "T00:00:00+09:00") : 0; const current = map[ticker]; if (!current || exitMs >= current.exitMs) { map[ticker] = { row: row, exitMs: exitMs }; } }); return map; } function buildBackdataFeatureBankRowsV1_(nowIso, holdings, dfMap, hAlpha, hApex) { const perfMap = mapLatestPerformanceByTicker_(sheetToJson("performance")); const alphaMap = {}; ((hAlpha || {}).per_holding || []).forEach(function(row) { const ticker = normalizeTickerCode(row.ticker || row.Ticker || ""); if (ticker) alphaMap[ticker] = row; }); const followMap = {}; ((hApex || {}).follow_through_json || []).forEach(function(row) { const ticker = normalizeTickerCode(row.ticker || row.Ticker || ""); if (ticker) followMap[ticker] = row; }); const profitMap = {}; ((hApex || {}).profit_preservation_json || []).forEach(function(row) { const ticker = normalizeTickerCode(row.ticker || row.Ticker || ""); if (ticker) profitMap[ticker] = row; }); const distributionMap = {}; ((hApex || {}).distribution_risk_json || []).forEach(function(row) { const ticker = normalizeTickerCode(row.ticker || row.Ticker || ""); if (ticker) distributionMap[ticker] = row; }); const rows = []; (holdings || []).forEach(function(h) { const ticker = normalizeTickerCode(h.ticker || h.Ticker || ""); if (!ticker) return; const df = dfMap[ticker] || {}; const perf = perfMap[ticker] ? perfMap[ticker].row : {}; const alpha = alphaMap[ticker] || {}; const follow = followMap[ticker] || {}; const profit = profitMap[ticker] || {}; const dist = distributionMap[ticker] || {}; const entryDate = parseIsoDateYmd_(h.entry_date || h.entryDate || h.entry_date_iso); const recordDate = parseIsoDateYmd_(perf.exit_date || perf.exitDate || h.last_updated || nowIso); const holdingDays = perf.holding_days != null && perf.holding_days !== "" ? parseInt(perf.holding_days, 10) : (entryDate ? daysBetweenIso_(entryDate, nowIso) : null); const sourceOrigin = "GAS_AUTO"; const entryPrice = Number.isFinite(h.avgCost) ? h.avgCost : Number.isFinite(h.average_cost) ? h.average_cost : Number.isFinite(df.limitPriceEst) ? df.limitPriceEst : null; const closeAtEntry = Number.isFinite(df.close) ? df.close : null; const maePct = perf.max_adverse_excursion_pct != null ? parseFloat(perf.max_adverse_excursion_pct) : (perf.mae_pct != null ? parseFloat(perf.mae_pct) : null); const mfePct = perf.max_favorable_excursion_pct != null ? parseFloat(perf.max_favorable_excursion_pct) : (perf.mfe_pct != null ? parseFloat(perf.mfe_pct) : null); rows.push([ recordDate, `BK-${ticker}-${String(recordDate || nowIso).replace(/-/g, "")}`, entryDate || parseIsoDateYmd_(df.priceDate || df.updatedAt || nowIso), ticker, h.name || df.name || "", h.account || h.account_type || "", h.entry_stage || h.entryStage || df.entryModeGate || "", sourceOrigin, entryPrice, closeAtEntry, Number.isFinite(df.ma20) ? df.ma20 : null, Number.isFinite(df.ma60) ? df.ma60 : null, Number.isFinite(df.atr20) ? df.atr20 : null, Number.isFinite(df.volumeRatio) ? df.volumeRatio : (Number.isFinite(df.avgVolume5d) && Number.isFinite(df.volume) && df.avgVolume5d > 0 ? round2_(df.volume / df.avgVolume5d) : null), Number.isFinite(df.flowCredit) ? round2_(df.flowCredit) : (Number.isFinite(alpha.flow_credit) ? round2_(alpha.flow_credit) : null), Number.isFinite(df.rsi14) ? round2_(df.rsi14) : null, Number.isFinite(alpha["late_chase_risk_score"]) ? alpha["late_chase_risk_score"] : null, Number.isFinite(follow["follow_through_score"]) ? follow["follow_through_score"] : null, Number.isFinite(df.breakoutScore) ? round2_(df.breakoutScore) : (Number.isFinite(df["breakout_score"]) ? round2_(df["breakout_score"]) : null), Number.isFinite(profit["rebound_preservation_score"]) ? profit["rebound_preservation_score"] : null, follow.follow_through_state || alpha.buy_permission_state || df.timingAction || df.Timing_Action || "", perf.exit_reason || perf.exitReason || df.sellReason || df.Sell_Reason || "", perf.pnl_pct != null ? parseFloat(perf.pnl_pct) : (Number.isFinite(h.return_pct) ? h.return_pct : (Number.isFinite(df.profitPct) ? df.profitPct : null)), holdingDays, maePct, mfePct ]); }); rows.sort(function(a, b) { const ao = String(a[7] || ""); const bo = String(b[7] || ""); if (ao !== bo) return ao < bo ? -1 : 1; const ar = String(a[0] || ""); const br = String(b[0] || ""); if (ar !== br) return ar < br ? 1 : -1; return String(a[1] || "").localeCompare(String(b[1] || "")); }); return rows; } function syncBackdataFeatureBank_(now, holdings, dfMap, hAlpha, hApex) { const rows = buildBackdataFeatureBankRowsV1_(formatIso_(now), holdings, dfMap, hAlpha, hApex); const headers = [ "Record_Date","Trade_ID","Signal_Date","Ticker","Name","Account","Entry_Stage","Source_Origin", "Entry_Price","Close_At_Entry","MA20_At_Entry","MA60_At_Entry","ATR20_At_Entry", "Volume_Ratio_5D","Flow_Credit","RSI14_At_Entry","Late_Chase_Risk_Score","Follow_Through_Score", "Breakout_Score","Rebound_Preservation_Score","Setup_Decision","Exit_Reason","PnL_Pct", "Holding_Days","MAE_Pct","MFE_Pct" ]; const totalRows = upsertToSheetByKey("backdata_feature_bank", headers, rows, "Trade_ID"); Logger.log(`backdata_feature_bank 완료: 실행반영=${rows.length}행, 누적총계=${totalRows}행`); return rows; } // Naver 컨센서스 페이지에서 EPS 추정치 변화 방향(UP/FLAT/DOWN) 자동 판별 function fetchNaverConsensusData(code) { const ticker = normalizeTickerCode(code); const cacheKey = `naver_consensus_${ticker}`; return withFetchCache_(cacheKey, "naver_consensus", ticker, { ok: false, epsRevisionStatus: "DATA_MISSING" }, () => { const resp = UrlFetchApp.fetch( `https://finance.naver.com/item/coinfo.naver?code=${encodeURIComponent(code)}&target=estimate`, { muteHttpExceptions: true, followRedirects: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" } } ); if (resp.getResponseCode() !== 200) return { ok: false, epsRevisionStatus: "DATA_MISSING" }; const html = resp.getContentText("EUC-KR"); return { ok: true, epsRevisionStatus: parseNaverEpsRevision_(html), targetPrice: parseNaverTargetPrice_(html) }; }); } // Naver 컨센서스 HTML에서 EPS 개정 방향 파싱 // 전략1: EPS 행(현재) vs 1개월 전 행 수치 비교 (1% 이상 변화 기준) // 전략2: 3개월 전 vs 현재 비교 (전략1 실패 시) // 전략3: 방향 아이콘 클래스 감지 (ico_up / ico_dn 계열) function parseNaverEpsRevision_(html) { // 전략1: 현재 vs 1개월 전 EPS 수치 직접 비교 const epsRowMatch = html.match(/EPS(?:\s*\(원\))?[^<]*(?:<[^>]+>)*[\s\S]*?<\/tr>/i); const prev1mMatch = html.match(/1개월\s*전[\s\S]*?<\/tr>/); const prev3mMatch = html.match(/3개월\s*전[\s\S]*?<\/tr>/); const compareEps_ = (row1, row2) => { if (!row1 || !row2) return null; const curr = extractLastTdNum_(row1); const prev = extractLastTdNum_(row2); if (!Number.isFinite(curr) || !Number.isFinite(prev) || prev === 0) return null; const chg = (curr - prev) / Math.abs(prev) * 100; if (chg > 1) return "UP"; if (chg < -1) return "DOWN"; return "FLAT"; }; const r1 = compareEps_(epsRowMatch?.[0], prev1mMatch?.[0]); if (r1) return r1; const r3 = compareEps_(epsRowMatch?.[0], prev3mMatch?.[0]); if (r3) return r3; // 전략2: 아이콘 클래스 패턴 (Naver HTML 버전별 차이 대응) if (/ico_up\b|class="up"|blind[^"]*상향|상향\s*조정/.test(html)) return "UP"; if (/ico_dn\b|class="dn"|blind[^"]*하향|하향\s*조정/.test(html)) return "DOWN"; // 전략3: 추정치 테이블에서 현재/이전 컬럼 비교 (th 기반) const tblMatch = html.match(/추정치[\s\S]{0,3000}?<\/table>/i); if (tblMatch) { const nums = [...tblMatch[0].matchAll(/]*>\s*([-\d,]+)\s*<\/td>/g)] .map(m => parseKrNum_(m[1])) .filter(v => v !== null && Math.abs(v) > 10); if (nums.length >= 2) { const chg = (nums[0] - nums[1]) / Math.abs(nums[1]) * 100; if (chg > 1) return "UP"; if (chg < -1) return "DOWN"; return "FLAT"; } } return "DATA_MISSING"; } // Naver 컨센서스 HTML에서 목표주가 파싱 function parseNaverTargetPrice_(html) { const candidates = [ /목표주가[\s\S]{0,400}?]*>\s*([\d,]+)\s*<\/em>/, /목표주가[\s\S]{0,400}?]*>\s*([\d,]+)\s*<\/td>/, /목표주가[\s\S]{0,300}?>\s*([\d,]+)\s*원/, /"targetPrice"[^>]*>\s*([\d,]+)/i, ]; for (const pat of candidates) { const m = html.match(pat); if (m) { const val = parseKrNum_(m[1]); if (val !== null && val > 1000) return val; // 1000원 이상만 유효 } } return null; } function extractLastTdNum_(rowHtml) { const matches = [...rowHtml.matchAll(/]*>\s*([-\d,]+)\s*<\/td>/g)]; if (!matches.length) return null; return parseKrNum_(matches[matches.length - 1][1]); } // Yahoo Finance earningsTrend에서 EPS 추정치 변화 방향 판별 (Naver 실패 시 fallback) function fetchYahooConsensusEps(code) { const sym = normalizeYahooSymbol(code); const cacheKey = `yahoo_consensus_${sym}`; return withFetchCache_(cacheKey, "yahoo_quote", sym, { ok: false, epsRevisionStatus: "DATA_MISSING" }, () => { const resp = UrlFetchApp.fetch( `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(sym)}?modules=earningsTrend`, { muteHttpExceptions: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } } ); if (resp.getResponseCode() !== 200) return { ok: false, epsRevisionStatus: "DATA_MISSING" }; const data = JSON.parse(resp.getContentText()); const trends = data?.quoteSummary?.result?.[0]?.earningsTrend?.trend ?? []; // 가장 가까운 분기(0q) 우선, 없으면 내년(+1y) const trend = trends.find(t => t.period === "0q") || trends.find(t => t.period === "+1y") || trends[0]; if (!trend) return { ok: false, epsRevisionStatus: "DATA_MISSING", epsGrowth1y: null }; const current = trend.epsTrend?.current?.raw ?? null; const ago30 = trend.epsTrend?.["30daysAgo"]?.raw ?? null; let epsRevisionStatus = "DATA_MISSING"; if (Number.isFinite(current) && Number.isFinite(ago30) && ago30 !== 0) { const chg = (current - ago30) / Math.abs(ago30) * 100; epsRevisionStatus = chg > 1 ? "UP" : chg < -1 ? "DOWN" : "FLAT"; } // A2: EPS 1년 성장률 — KOSDAQ PEG 게이트 입력값 // earningsTrend +1y 의 earningsEstimate.growth (직접 제공되는 경우) // 없으면 (+1y avg - 0y avg) / abs(0y avg) * 100 으로 계산 let epsGrowth1y = null; const trend1y = trends.find(t => t.period === "+1y"); const trend0y = trends.find(t => t.period === "0y"); if (trend1y) { const directGrowth = trend1y.earningsEstimate?.growth?.raw; if (Number.isFinite(directGrowth)) { epsGrowth1y = parseFloat((directGrowth * 100).toFixed(1)); } else { const eps1y = trend1y.earningsEstimate?.avg?.raw ?? null; const eps0y = trend0y?.earningsEstimate?.avg?.raw ?? null; if (Number.isFinite(eps1y) && Number.isFinite(eps0y) && eps0y !== 0) { epsGrowth1y = parseFloat(((eps1y - eps0y) / Math.abs(eps0y) * 100).toFixed(1)); } } } return { ok: true, epsRevisionStatus, epsGrowth1y }; }); } // Yahoo Finance quoteSummary — 목표주가·Beta 폴백 (Naver 실패 시) // financialData: targetMeanPrice / defaultKeyStatistics: beta function fetchYahooTargetPrice(code) { const sym = normalizeYahooSymbol(code); const cacheKey = `yahoo_target_${sym}`; return withFetchCache_(cacheKey, "yahoo_financials", sym, { ok: false, targetPrice: null, beta: null, earningsDate: null, exDividendDate: null, dividendPerShare: null }, () => { const resp = UrlFetchApp.fetch( `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(sym)}?modules=financialData,defaultKeyStatistics,calendarEvents`, { muteHttpExceptions: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } } ); if (resp.getResponseCode() !== 200) { return { ok: false, targetPrice: null, beta: null, earningsDate: null }; } const data = JSON.parse(resp.getContentText()); const qr = data?.quoteSummary?.result?.[0] ?? {}; const fd = qr.financialData ?? {}; const dks = qr.defaultKeyStatistics ?? {}; const cal = qr.calendarEvents ?? {}; const earningsDates = cal.earnings?.earningsDate ?? []; // ── 재무 건전성 필드 (2026-05-18_FINANCIAL_HEALTH_V1 + OCF 추가) ────────── const roePctRaw = fd.returnOnEquity?.raw; const opMarginRaw = fd.operatingMargins?.raw; const deRaw = fd.debtToEquity?.raw ?? dks.debtToEquity?.raw; const currentRatioRaw = fd.currentRatio?.raw; const fcfRaw = fd.freeCashflow?.raw; const ocfRaw = fd.operatingCashflow?.raw; // OCF 추가 const revGrowthRaw = fd.revenueGrowth?.raw; return { ok: true, targetPrice: fd.targetMeanPrice?.raw ?? null, beta: dks.beta?.raw ?? null, earningsDate: earningsDates.length > 0 ? (earningsDates[0].fmt ?? null) : null, exDividendDate: cal.exDividendDate?.fmt ?? null, dividendPerShare: dks.lastDividendValue?.raw ?? null, // 재무 건전성 — Yahoo financialData / defaultKeyStatistics roePct: Number.isFinite(roePctRaw) ? roePctRaw * 100 : null, operatingMarginPct: Number.isFinite(opMarginRaw) ? opMarginRaw * 100 : null, debtToEquity: Number.isFinite(deRaw) ? deRaw : null, currentRatio: Number.isFinite(currentRatioRaw) ? currentRatioRaw : null, fcfB: Number.isFinite(fcfRaw) ? fcfRaw / 1e8 : null, // 억원 ocfB: Number.isFinite(ocfRaw) ? ocfRaw / 1e8 : null, // 억원 (OCF) revenueGrowthPct: Number.isFinite(revGrowthRaw) ? revGrowthRaw * 100 : null, }; }); } // ── 펀더멘털 7일 장기 캐시 레이어 ──────────────────────────────────────────── // 분기별 지표(ROE/OPM/OCF/FCF)는 자주 바뀌지 않으므로 7일 캐시로 호출 횟수를 절감해 // 스크래핑 차단 위험을 사전에 방지한다. // PropertiesService를 사용(CacheService 최대 6시간 한계 극복). const FUNDAMENTAL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7일(ms) function getFundamentalCache_(ticker) { try { const props = PropertiesService.getScriptProperties(); const raw = props.getProperty('fund_cache_' + ticker); if (!raw) return null; const entry = JSON.parse(raw); if (!entry || !entry.ts || !entry.data) return null; if (Date.now() - entry.ts > FUNDAMENTAL_CACHE_TTL_MS) { props.deleteProperty('fund_cache_' + ticker); return null; } return entry.data; } catch(_) { return null; } } function setFundamentalCache_(ticker, data) { try { PropertiesService.getScriptProperties().setProperty( 'fund_cache_' + ticker, JSON.stringify({ ts: Date.now(), data: data }) ); } catch(_) {} } // ── 네이버 금융 ROE/OPM fallback ──────────────────────────────────────────── // 야후 Finance가 차단됐을 때 네이버 main.naver HTML에서 ROE/OPM을 보완한다. // 호출 후 결과는 fundamentalCache에 병합되어 7일 캐시에 저장된다. function fetchNaverFundamentals_(ticker) { const cacheKey = 'naver_fund_' + ticker; const cached = getCachedFetchResult_(cacheKey); if (cached) return cached; const emptyFallback = { ok: false, roePct: null, operatingMarginPct: null, source: 'naver_fund' }; if (isFetchCircuitOpen_('naver_fund')) return emptyFallback; if (!consumeFetchBudget_('naver_fund', ticker)) return emptyFallback; try { const url = 'https://finance.naver.com/item/main.naver?code=' + encodeURIComponent(ticker); const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, followRedirects: true }); if (resp.getResponseCode() !== 200) { recordFetchFailure_('naver_fund'); return emptyFallback; } const html = resp.getContentText('UTF-8'); // ROE: 2024년 이후 네이버 "ROE(지배주주)" 키워드로 변경됨 (구: 자기자본이익률) // OPM: 키워드 이후 여러 줄 아래 에 수치 위치 let roePct = null, opMarginPct = null; const roeM = html.match(/ROE\(지배주주\)<\/strong><\/th>[\s\S]*?]*>[\s\S]*?([\d.-]+)/); if (roeM) roePct = parseFloat(roeM[1]); const opM = html.match(/영업이익률<\/strong><\/th>[\s\S]*?]*>[\s\S]*?([\d.-]+)/); if (opM) opMarginPct = parseFloat(opM[1]); const result = { ok: roePct !== null || opMarginPct !== null, roePct: Number.isFinite(roePct) ? roePct : null, operatingMarginPct: Number.isFinite(opMarginPct) ? opMarginPct : null, source: 'naver_fund' }; if (result.ok) { recordFetchSuccess_('naver_fund'); setCachedFetchResult_(cacheKey, result, true, 'naver_fund_ok'); } else { recordFetchFailure_('naver_fund'); } return result; } catch(e) { recordFetchFailure_('naver_fund'); return emptyFallback; } } // ── 펀더멘털 통합 수집 (캐시 → 야후 → 네이버 fallback) ────────────────────── // 호출처: _addTickerFundamentals_ 내에서 t.code 기준 호출 // sym 인자는 사용되지 않으므로 무시 (하위 호환 유지) function fetchFundamentalsWithCache_(ticker, sym, yahooFin) { // 1) 7일 캐시 확인 const cached = getFundamentalCache_(ticker); if (cached) { Logger.log('[FUND_CACHE_HIT] ' + ticker + ' ttl=7d'); return Object.assign({}, yahooFin, cached, { source: 'fund_cache' }); } // 한국 종목코드 패턴 (6자리 숫자 기반) — Yahoo는 ROE/OPM 미제공 → 네이버 직접 호출 const isKrMarket = /^\d{5,6}[A-Z0-9]*$/.test(ticker); // 숫자+문자 혼합의 국내 ETF/특수코드(예: 0117V0)는 Yahoo보다 Naver 우선 시도 const isDomesticEtfLike = /^\d{4,6}[A-Z][A-Z0-9]*$/.test(ticker); const preferNaver = isKrMarket || isDomesticEtfLike; // 2) 야후 수집 판단 — 한국 종목은 스킵 const hasYahoo = !preferNaver && yahooFin && yahooFin.ok && (Number.isFinite(yahooFin.roePct) || Number.isFinite(yahooFin.operatingMarginPct)); let fund = yahooFin || { ok: false }; // 3) 야후 미보유 또는 한국 종목 → 네이버 직접 호출 if (!hasYahoo) { if (isKrMarket) { Logger.log('[DEBUG][FUND_NAVER_KR] ' + ticker + ' — 한국 종목, 네이버 직접 호출'); } else if (isDomesticEtfLike) { Logger.log('[DEBUG][FUND_NAVER_ETF] ' + ticker + ' — 국내 ETF/특수코드, 네이버 우선 호출'); } else { Logger.log('[DEBUG][FUND_NAVER_FALLBACK] ' + ticker + ' — 야후 데이터 없음, 네이버 시도'); } const naverFund = fetchNaverFundamentals_(ticker); if (naverFund.ok) { fund = Object.assign({}, fund, { roePct: fund.roePct ?? naverFund.roePct, operatingMarginPct: fund.operatingMarginPct ?? naverFund.operatingMarginPct, ok: true, source: isKrMarket ? 'naver_fund_kr' : (isDomesticEtfLike ? 'naver_fund_etf' : 'naver_fund_fallback') }); Logger.log('[DEBUG][FUND_NAVER_OK] ' + ticker + ' source=' + fund.source + ' ROE=' + fund.roePct + ' OPM=' + fund.operatingMarginPct); } else if (isDomesticEtfLike) { Logger.log('[INFO][FUND_ETF_NO_DATA] ' + ticker + ' — ETF/특수코드, 재무제표 없음 (정상)'); } else { Logger.log('[WARN][FUND_NAVER_FAIL] ' + ticker + ' source=' + (isKrMarket ? 'naver_fund_kr' : 'naver_fund_fallback') + ' reason=' + String(naverFund.error || naverFund.quoteStatus || 'NO_DATA')); } } // 4) 수집 결과를 7일 캐시에 저장 (ok=true인 경우만) if (fund.ok && (Number.isFinite(fund.roePct) || Number.isFinite(fund.operatingMarginPct))) { setFundamentalCache_(ticker, { roePct: fund.roePct, operatingMarginPct: fund.operatingMarginPct, debtToEquity: fund.debtToEquity, currentRatio: fund.currentRatio, fcfB: fund.fcfB, ocfB: fund.ocfB, revenueGrowthPct: fund.revenueGrowthPct, cached_at: new Date().toISOString(), }); Logger.log('[FUND_CACHE_SET] ' + ticker + ' ROE=' + fund.roePct + ' OPM=' + fund.operatingMarginPct); } return fund; } // EPS_Revision_Status 기존 입력값 보존 — writeToSheet의 clearContents 전에 호출 function readExistingEpsRevision_(sheetName) { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName(sheetName); if (!sheet || sheet.getLastRow() < 3) return {}; const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues(); const headers = data[0]; const tickerIdx = headers.indexOf("Ticker"); const epsRevIdx = headers.indexOf("EPS_Revision_Status"); if (tickerIdx < 0 || epsRevIdx < 0) return {}; const valid = new Set(["UP", "FLAT", "DOWN"]); const result = {}; for (let i = 1; i < data.length; i++) { const ticker = String(data[i][tickerIdx]).trim(); const val = String(data[i][epsRevIdx]).trim().toUpperCase(); if (ticker && valid.has(val)) result[ticker] = val; } return result; } // ── FC(Frontier/탐색) 손실 예산 월별 집계 ──────────────────────────────────── // performance 탭의 fc_bucket=Y 거래 중 당월 청산 건의 손실합 계산. // 반환: { fc_used_pct: number|null, fc_budget_pct: 2.5, fc_status: string, trades: integer } function calcFcBudget_(totalAssetKrw, budgetPctOverride) { const BUDGET_PCT = Number.isFinite(budgetPctOverride) && budgetPctOverride > 0 ? budgetPctOverride : 2.5; const result = { fc_used_pct: null, fc_budget_pct: BUDGET_PCT, fc_status: "UNKNOWN (performance 탭 없음)", trades: 0 }; try { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName("performance"); if (!sheet) return result; const data = sheet.getDataRange().getValues(); if (data.length < 3) return result; const hdr = data[1].map(h => String(h).trim()); const pnlIdx = hdr.indexOf("pnl_pct"); const exitIdx = hdr.indexOf("exit_date"); const fcIdx = hdr.indexOf("fc_bucket"); const entryIdx= hdr.indexOf("entry_price"); const qtyIdx = hdr.indexOf("quantity"); if (pnlIdx < 0 || exitIdx < 0 || fcIdx < 0) return { ...result, fc_status: "UNKNOWN (performance 탭 구조 불일치)" }; const thisMonth = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM"); let fcLossKrw = 0; let fcTrades = 0; for (let i = 2; i < data.length; i++) { const fcBucket = String(data[i][fcIdx]).trim().toUpperCase(); if (fcBucket !== "Y") continue; const exitVal = data[i][exitIdx]; if (!exitVal || String(exitVal).trim() === "") continue; // 당월 청산 건만 const exitStr = exitVal instanceof Date ? Utilities.formatDate(exitVal, "Asia/Seoul", "yyyy-MM") : String(exitVal).trim().substring(0, 7); if (exitStr !== thisMonth) continue; const pnl = parseFloat(data[i][pnlIdx]); if (!Number.isFinite(pnl) || pnl >= 0) continue; // 손실만 집계 // KRW 손실 계산: entry_price × qty × |pnl_pct/100| const entry = parseFloat(data[i][entryIdx]); const qty = parseInt(data[i][qtyIdx]); if (Number.isFinite(entry) && Number.isFinite(qty) && qty > 0) { fcLossKrw += entry * qty * Math.abs(pnl) / 100; } fcTrades++; } if (fcTrades === 0) { return { fc_used_pct: 0, fc_budget_pct: BUDGET_PCT, fc_status: `OK (0% / ${BUDGET_PCT}% 사용)`, trades: 0 }; } if (!Number.isFinite(totalAssetKrw) || totalAssetKrw <= 0) { return { fc_used_pct: null, fc_budget_pct: BUDGET_PCT, fc_status: `UNKNOWN (총자산 미제공, 손실${fcTrades}건)`, trades: fcTrades }; } const usedPct = (fcLossKrw / totalAssetKrw) * 100; const remaining = BUDGET_PCT - usedPct; const fcStatus = usedPct >= BUDGET_PCT ? `EXHAUSTED (${usedPct.toFixed(1)}% >= ${BUDGET_PCT}% 예산 초과)` : `OK (${usedPct.toFixed(1)}% / ${BUDGET_PCT}% — 잔여 ${remaining.toFixed(1)}%)`; return { fc_used_pct: parseFloat(usedPct.toFixed(2)), fc_budget_pct: BUDGET_PCT, fc_status: fcStatus, trades: fcTrades }; } catch(e) { handleFetchError_("calcFcBudget_", e, "WARN"); return { ...result, fc_status: "ERROR: " + e.message }; } } // ── orbit_gap 계산 — spec/01_objective_profile.yaml:orbit_monthly_tracker ── // orbit_gap(%) = 목표누적수익률_to_date - 실제누적수익률_to_date // 목표누적수익률 = (target/start)^(elapsed_months/total_months) - 1 (기하평균 보간) // 반환: { ok, orbit_gap_pct, orbit_state, offensive_slot_adj, cash_floor_adj, detail, ... } function calcOrbitGap_(settings) { const NONE = (detail) => ({ ok: false, orbit_gap_pct: null, orbit_state: "UNKNOWN", offensive_slot_adj: 0, cash_floor_adj: 0, detail, }); const startAsset = parseFloat(settings["orbit_start_asset_krw"]); const targetAsset = parseFloat(settings["orbit_target_asset_krw"]); const currentAsset = parseFloat(settings["total_asset_krw"]); const startYMRaw = settings["orbit_start_yyyymm"]; // "2026-01" or Sheets date const endYMRaw = settings["orbit_end_yyyymm"]; // "2028-12" or Sheets date if (!Number.isFinite(startAsset) || startAsset <= 0) return NONE("orbit_start_asset_krw 미설정"); if (!Number.isFinite(targetAsset) || targetAsset <= 0) return NONE("orbit_target_asset_krw 미설정"); if (!Number.isFinite(currentAsset) || currentAsset <= 0) return NONE("total_asset_krw 미설정"); const parseYM = value => { if (value instanceof Date && !isNaN(value.getTime())) { return value.getFullYear() * 12 + (value.getMonth() + 1); } const s = String(value ?? "").trim(); if (!s) return null; const m = s.match(/^(\d{4})[-./년\s]*(\d{1,2})/); if (!m) return null; const y = parseInt(m[1], 10); const mo = parseInt(m[2], 10); if (!Number.isFinite(y) || !Number.isFinite(mo) || mo < 1 || mo > 12) return null; return y * 12 + mo; }; const now = new Date(); const nowYM = now.getFullYear() * 12 + (now.getMonth() + 1); const startYM_int = parseYM(startYMRaw); const endYM_int = parseYM(endYMRaw); if (startYM_int === null || endYM_int === null) return NONE("orbit_start/end_yyyymm 파싱 실패"); const totalMonths = endYM_int - startYM_int; const elapsedMonths = Math.max(0, nowYM - startYM_int); if (totalMonths <= 0) return NONE("orbit_end_yyyymm이 orbit_start_yyyymm 이전"); const frac = Math.min(elapsedMonths / totalMonths, 1); const targetCumRet = Math.pow(targetAsset / startAsset, frac) - 1; const actualCumRet = currentAsset / startAsset - 1; const orbitGap = parseFloat(((targetCumRet - actualCumRet) * 100).toFixed(2)); let orbitState, slotAdj, cashAdj; if (orbitGap > 3) { orbitState = "significantly_behind"; slotAdj = 2; cashAdj = -2; } else if (orbitGap > 1) { orbitState = "mild_behind"; slotAdj = 1; cashAdj = -1; } else if (orbitGap < -2) { orbitState = "ahead_of_target"; slotAdj = 0; cashAdj = 1; } else { orbitState = "on_track"; slotAdj = 0; cashAdj = 0; } return { ok: true, elapsed_months: elapsedMonths, total_months: totalMonths, target_cum_return_pct: parseFloat((targetCumRet * 100).toFixed(2)), actual_cum_return_pct: parseFloat((actualCumRet * 100).toFixed(2)), orbit_gap_pct: orbitGap, orbit_state: orbitState, offensive_slot_adj: slotAdj, cash_floor_adj: cashAdj, detail: `gap=${orbitGap}%p target=${(targetCumRet*100).toFixed(1)}% actual=${(actualCumRet*100).toFixed(1)}% (${elapsedMonths}/${totalMonths}개월)`, }; } // ── orbit_gap → monthly_history 기록 ───────────────────────────────────────── // settings/info를 인자로 받으면 재사용 (runMacro 연쇄 시), 없으면 직접 읽기 (독립 실행 시). function runOrbitGap(settings, info) { if (!settings) settings = readSettingsTab_(); if (!info) info = calcOrbitGap_(settings); if (!info.ok) { Logger.log("runOrbitGap 스킵: " + info.detail); return; } const currentMonth = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM"); upsertMonthlyRow_(currentMonth, { Total_Asset: parseFloat(settings["total_asset_krw"]), Start_Asset: parseFloat(settings["orbit_start_asset_krw"]), Target_Asset: parseFloat(settings["orbit_target_asset_krw"]), Target_Return_Pct: info.target_cum_return_pct, Actual_Return_Pct: info.actual_cum_return_pct, Orbit_Gap_Pct: info.orbit_gap_pct, Orbit_State: info.orbit_state, Slot_Adj: info.offensive_slot_adj, Cash_Floor_Adj: info.cash_floor_adj, }); Logger.log(`monthly_history(orbit): ${currentMonth} gap=${info.orbit_gap_pct}%p (${info.orbit_state}) slot_adj=${info.offensive_slot_adj}`); } // 동일 티커 복수 행(소수 분리 등) 합산 — ex 를 in-place 갱신 function _mergePositionRecord_(ex, incoming) { // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/convert_xlsx_to_json.py:normalize_backdata_harness_payload const newQty = ex.quantity + incoming.quantity; const newAvail = (ex.available_quantity || 0) + (incoming.available_quantity || 0); const newMV = (ex.market_value || 0) + (incoming.market_value || 0); const newCost = (ex.total_cost || 0) + (incoming.total_cost || 0); const newPL = (ex.profit_loss || 0) + (incoming.profit_loss || 0); ex.quantity = newQty; ex.available_quantity = newAvail; ex.market_value = newMV; ex.total_cost = newCost; ex.profit_loss = newPL; ex.average_cost = newQty > 0 ? Math.round(newCost / newQty) : ex.average_cost; ex.entry_price = ex.average_cost; ex.return_pct = newCost > 0 ? parseFloat(((newPL / newCost) * 100).toFixed(2)) : ex.return_pct; // 이름: "(소수)" 접미사 없는 쪽 우선 if (ex.name.includes('소수') && !incoming.name.includes('소수')) { ex.name = incoming.name; } // 기타 스칼라 필드: 기존 값이 null 이면 incoming 값으로 채움 ['stop_price','highest_price','entry_date','entry_stage','position_type'].forEach(k => { if (ex[k] == null && incoming[k] != null) ex[k] = incoming[k]; }); } // ── account_snapshot 탭 읽기 → 계좌 캡처 확정 원장 ───────────────────────── // - 일반계좌: 종목별 보유수량·평단 + 현금 모두 제공 → Sell_Qty 직접 산출 가능 // - ISA/연금저축: 캡처 금액은 투자완료 계좌잔액 reference. 일반계좌 현금원장 합산 금지. // 개별 종목수량 미제공 시 Sell_Qty 산출 불가 // parse_status=CAPTURE_READ_OK + user_confirmed=Y 행만 실 데이터로 인정. function readAccountSnapshotMap_() { const settings_ = readSettingsTab_(); const confirmModeRaw_ = String((settings_ && settings_["account_snapshot_confirm_mode"]) || "STRICT_Y").trim().toUpperCase(); const allowAutoConfirm_ = confirmModeRaw_ === "AUTO_IF_PARSE_OK"; const makeCashBucket = () => ({ immediate_cash: null, settlement_cash_d2: null, available_cash: null, open_order_amount: 0 }); const result = { positions: {}, // 일반계좌 개별주 (Sell_Qty·stop_price 계산에 사용) isa_positions: {}, // ISA 개별주 (PCL 카운트 전용 — Sell_Qty 미사용) cash: makeCashBucket(), // 일반계좌 합산 (매수 가용 현금 기준) cash_by_account: { // 계좌 유형별 현금 분리 추적 "일반계좌": makeCashBucket(), "ISA": makeCashBucket(), "연금저축": makeCashBucket(), }, rows_read: 0, rows_confirmed: 0, rows_parse_ok_unconfirmed: 0, rows_auto_confirmed: 0, confirm_mode: confirmModeRaw_, account_types_seen: new Set(), // 캡처된 계좌 유형 목록 }; try { const sheet = getSpreadsheet_().getSheetByName("account_snapshot"); if (!sheet) return result; const data = sheet.getDataRange().getValues(); if (data.length < 3) return result; const hdr = data[1].map(h => String(h ?? "").trim()); const idx = name => hdr.indexOf(name); const tickerIdx = idx("ticker"); const nameIdx = idx("name"); const accountIdx = idx("account"); const acctTypeIdx = idx("account_type"); // ISA / 연금저축 / 일반계좌 구분 const qtyIdx = idx("holding_quantity"); const availQtyIdx = idx("available_quantity"); const avgIdx = idx("average_cost"); const totalCostIdx = idx("total_cost"); const curIdx = idx("current_price"); const mvIdx = idx("market_value"); const profitIdx = idx("profit_loss"); const retPctIdx = idx("return_pct"); const immIdx = idx("immediate_cash"); const d2Idx = idx("settlement_cash_d2"); const availIdx = idx("available_cash"); const openIdx = idx("open_order_amount"); const statusIdx = idx("parse_status"); const confirmedIdx = idx("user_confirmed"); const capturedIdx = idx("captured_at"); const stopIdx = idx("stop_price"); const highIdx = idx("highest_price_since_entry"); const entryDateIdx = idx("entry_date"); const stageIdx = idx("entry_stage"); const posTypeIdx = idx("position_type"); const lastUpdIdx = idx("last_updated"); for (let i = 2; i < data.length; i++) { const row = data[i]; const parseStatus = statusIdx >= 0 ? String(row[statusIdx] ?? "").trim() : ""; const confirmed = confirmedIdx >= 0 ? String(row[confirmedIdx] ?? "").trim().toUpperCase() : ""; if (!parseStatus && !confirmed) continue; result.rows_read++; const isParseOk = parseStatus === "CAPTURE_READ_OK"; const hasConfirm = ["Y", "YES", "TRUE", "1"].includes(confirmed); const isConfirmed = isParseOk && (hasConfirm || (allowAutoConfirm_ && !confirmed)); if (isParseOk && !hasConfirm) { result.rows_parse_ok_unconfirmed++; } if (isParseOk && !hasConfirm && allowAutoConfirm_ && !confirmed) { result.rows_auto_confirmed++; } if (!isConfirmed) continue; result.rows_confirmed++; const acctType = acctTypeIdx >= 0 ? String(row[acctTypeIdx] ?? "").trim() : "일반계좌"; const isRestrictedAcct = acctType === "ISA" || acctType === "연금저축"; result.account_types_seen.add(acctType); // 현금/잔액 — 계좌 유형별 버킷에 기록 const immediateCash = immIdx >= 0 ? parseFloat(row[immIdx]) : NaN; const settlementCash = d2Idx >= 0 ? parseFloat(row[d2Idx]) : NaN; const availableCash = availIdx >= 0 ? parseFloat(row[availIdx]) : NaN; const openOrderAmount = openIdx >= 0 ? parseFloat(row[openIdx]) : NaN; const cashBucket = result.cash_by_account[acctType] ?? makeCashBucket(); if (!result.cash_by_account[acctType]) result.cash_by_account[acctType] = cashBucket; if (Number.isFinite(immediateCash)) cashBucket.immediate_cash = (cashBucket.immediate_cash ?? 0) + immediateCash; if (Number.isFinite(settlementCash)) cashBucket.settlement_cash_d2 = (cashBucket.settlement_cash_d2 ?? 0) + settlementCash; if (Number.isFinite(availableCash)) cashBucket.available_cash = (cashBucket.available_cash ?? 0) + availableCash; if (Number.isFinite(openOrderAmount)) cashBucket.open_order_amount = (cashBucket.open_order_amount ?? 0) + openOrderAmount; // result.cash: 일반계좌 현금만 포트폴리오 cash ledger로 사용 // ISA/연금저축 값은 투자완료 계좌잔액 reference로만 보관 if (!isRestrictedAcct) { if (Number.isFinite(immediateCash)) result.cash.immediate_cash = (result.cash.immediate_cash ?? 0) + immediateCash; if (Number.isFinite(settlementCash)) result.cash.settlement_cash_d2 = (result.cash.settlement_cash_d2 ?? 0) + settlementCash; if (Number.isFinite(availableCash)) result.cash.available_cash = (result.cash.available_cash ?? 0) + availableCash; if (Number.isFinite(openOrderAmount)) result.cash.open_order_amount = (result.cash.open_order_amount ?? 0) + openOrderAmount; } // 연금저축 = ETF 전용 계좌 → 개별주 포지션 매핑 완전 스킵 if (acctType === "연금저축") continue; const ticker = tickerIdx >= 0 ? normalizeTickerCode(row[tickerIdx]) : ""; const qty = qtyIdx >= 0 ? parseFloat(row[qtyIdx]) : NaN; if (!ticker || !Number.isFinite(qty) || qty <= 0) continue; const availQty = availQtyIdx >= 0 ? parseFloat(row[availQtyIdx]) : NaN; const totalCost = totalCostIdx >= 0 ? parseFloat(row[totalCostIdx]) : NaN; const profitLoss = profitIdx >= 0 ? parseFloat(row[profitIdx]) : NaN; const retPct = retPctIdx >= 0 ? parseFloat(row[retPctIdx]) : NaN; const stopPrice = stopIdx >= 0 ? parseFloat(row[stopIdx]) : NaN; const highPrice = highIdx >= 0 ? parseFloat(row[highIdx]) : NaN; const entryDateRaw = entryDateIdx >= 0 ? row[entryDateIdx] : ""; const lastUpdRaw = lastUpdIdx >= 0 ? row[lastUpdIdx] : ""; const normalizeDateCell = value => value instanceof Date ? Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd") : String(value ?? "").trim().substring(0, 10); const posRecord = { ticker, name: nameIdx >= 0 ? String(row[nameIdx] ?? "").trim() : "", account: accountIdx >= 0 ? String(row[accountIdx] ?? "").trim() : "", account_type: acctType, quantity: qty, available_quantity: Number.isFinite(availQty) ? availQty : null, average_cost: avgIdx >= 0 ? parseFloat(row[avgIdx]) : null, entry_price: avgIdx >= 0 ? parseFloat(row[avgIdx]) : null, total_cost: Number.isFinite(totalCost) ? totalCost : null, current_price: curIdx >= 0 ? parseFloat(row[curIdx]) : null, market_value: mvIdx >= 0 ? parseFloat(row[mvIdx]) : null, profit_loss: Number.isFinite(profitLoss) ? profitLoss : null, return_pct: Number.isFinite(retPct) ? retPct : null, stop_price: Number.isFinite(stopPrice) && stopPrice > 0 ? stopPrice : null, highest_price: Number.isFinite(highPrice) && highPrice > 0 ? highPrice : null, entry_date: entryDateRaw ? normalizeDateCell(entryDateRaw) : "", entry_stage: stageIdx >= 0 ? String(row[stageIdx] ?? "").trim() : "", position_type: posTypeIdx >= 0 && String(row[posTypeIdx] ?? "").trim().toLowerCase() === "core" ? "core" : "satellite", last_updated: lastUpdRaw ? normalizeDateCell(lastUpdRaw) : "", parse_status: parseStatus, user_confirmed: "Y", captured_at: capturedIdx >= 0 ? row[capturedIdx] : "", }; if (acctType === "ISA") { // ISA 개별주: PCL 카운트 전용. Sell_Qty·stop_price·Total_Heat 계산에는 미사용. if (result.isa_positions[ticker]) { _mergePositionRecord_(result.isa_positions[ticker], posRecord); } else { result.isa_positions[ticker] = posRecord; } } else { // 일반계좌: 전체 기능(stop_price, Sell_Qty, Total_Heat 등) 활성 // 동일 티커 중복 행(소수 분리 계좌 등) → 수량·평가액·원가 합산 if (result.positions[ticker]) { _mergePositionRecord_(result.positions[ticker], posRecord); } else { result.positions[ticker] = posRecord; } } } result.account_types_seen = [...result.account_types_seen]; // Set → Array } catch(e) { handleFetchError_("readAccountSnapshotMap_", e, "WARN"); } if (result.rows_read > 0 && result.rows_confirmed === 0 && result.rows_parse_ok_unconfirmed > 0) { Logger.log( "[ACCOUNT_SNAPSHOT_CONFIRMATION_BLOCK] parse_ok_unconfirmed=" + result.rows_parse_ok_unconfirmed + " mode=" + result.confirm_mode + " (hint: settings.account_snapshot_confirm_mode=AUTO_IF_PARSE_OK 또는 user_confirmed=Y 입력)" ); } return result; } // ── account_snapshot 탭 초기화 (캡처 원장 + 선택 포지션 상태 컬럼) ─────────────── // runDataFeed() 시작 시 자동 호출 — 탭 없으면 생성, 있으면 헤더 점검 후 즉시 반환. // 샘플 행(parse_status="SAMPLE")은 GAS가 읽지 않으므로 실 데이터에 영향 없음. function initAccountSnapshotTemplate_() { const SS = getSpreadsheet_(); const SHEET_NAME = "account_snapshot"; const HEADERS = [ "captured_at", "account", "account_type", "ticker", "name", "holding_quantity", "available_quantity", "average_cost", "total_cost", "current_price", "market_value", "profit_loss", "return_pct", "immediate_cash", "settlement_cash_d2", "available_cash", "open_order_amount", "monthly_contribution_limit", "monthly_contribution_used", "parse_status", "user_confirmed", "stop_price", "highest_price_since_entry", "entry_date", "entry_stage", "position_type", "last_updated", ]; const TEXT_COLS = new Set(["captured_at","ticker","parse_status","user_confirmed","account","account_type","name","entry_date","entry_stage","position_type","last_updated"]); const H = {}; // 컬럼명 → 인덱스 빠른 참조 HEADERS.forEach((h, i) => { H[h] = i; }); const numCols = HEADERS.length; let sheet = SS.getSheetByName(SHEET_NAME); const existed = !!sheet; // ① 탭 존재 + 헤더 행이 있으면 → 누락 컬럼만 뒤에 추가하고 즉시 반환 (데이터 보호) if (existed) { const existingData = sheet.getDataRange().getValues(); const hasHeader = existingData.length >= 2 && existingData[1].some(v => String(v).trim() !== ""); if (hasHeader) { const existingHdr = existingData[1].map(h => String(h).trim()); const missing = HEADERS.filter(h => !existingHdr.includes(h)); if (missing.length > 0) { const startCol = existingHdr.length + 1; sheet.getRange(2, startCol, 1, missing.length).setValues([missing]); sheet.getRange(2, startCol, 1, missing.length).setFontWeight("bold").setBackground("#d9ead3"); missing.forEach((h, i) => { if (TEXT_COLS.has(h)) sheet.getRange(2, startCol + i, 100, 1).setNumberFormat("@"); }); Logger.log(`initAccountSnapshotTemplate_: account_snapshot 누락컬럼 추가=${missing.join(",")}`); } return { action: "skipped_existing", sheet: SHEET_NAME, rows: existingData.length - 2, missing_headers: missing }; } } // ② 탭 없으면 생성, 있지만 비어 있으면 초기화 if (!sheet) sheet = SS.insertSheet(SHEET_NAME); sheet.clearContents(); sheet.clearFormats(); // 행1: 안내 메모 const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); sheet.getRange(1, 1).setValue( `[account_snapshot] HTS 캡처->ChatGPT 파싱->A3 붙여넣기 탭 | ${numCols}컬럼 | 초기화: ${now} KST | SAMPLE 행은 실제 캡처 후 삭제` ); // 행2: 컬럼 헤더 (볼드) sheet.getRange(2, 1, 1, numCols).setValues([HEADERS]); sheet.getRange(2, 1, 1, numCols).setFontWeight("bold").setBackground("#d9ead3"); // 텍스트 포맷 — setValues 전 적용 필수 (Ticker 등 숫자 변환 방지) HEADERS.forEach((h, i) => { if (TEXT_COLS.has(h)) sheet.getRange(2, i + 1, 100, 1).setNumberFormat("@"); }); // ── 샘플 데이터 (parse_status="SAMPLE" → GAS readAccountSnapshotMap_ 무시) ── const sampleDate = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd") + " 09:30"; function makeRow(fields) { const row = new Array(numCols).fill(""); Object.entries(fields).forEach(([k, v]) => { if (H[k] !== undefined) row[H[k]] = v; }); return row; } const sampleRows = [ makeRow({ captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌", ticker: "005930", name: "삼성전자", holding_quantity: 100, available_quantity: 100, average_cost: 68000, total_cost: 6800000, current_price: 75000, market_value: 7500000, profit_loss: 700000, return_pct: 10.3, immediate_cash: 3500000, settlement_cash_d2: 4200000, available_cash: 3500000, open_order_amount: 0, parse_status: "SAMPLE", user_confirmed: "N", }), makeRow({ captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌", ticker: "000660", name: "SK하이닉스", holding_quantity: 30, available_quantity: 30, average_cost: 180000, total_cost: 5400000, current_price: 210000, market_value: 6300000, profit_loss: 900000, return_pct: 16.7, parse_status: "SAMPLE", user_confirmed: "N", }), makeRow({ captured_at: sampleDate, account: "ISA", account_type: "ISA", ticker: "012450", name: "한화에어로스페이스", holding_quantity: 10, available_quantity: 10, average_cost: 980000, total_cost: 9800000, current_price: 1216000, market_value: 12160000, profit_loss: 2360000, return_pct: 24.1, monthly_contribution_limit: 4000000, monthly_contribution_used: 1500000, parse_status: "SAMPLE", user_confirmed: "N", }), // 현금 전용 행 (보유종목 없음) makeRow({ captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌", name: "현금", immediate_cash: 3500000, settlement_cash_d2: 4200000, available_cash: 3500000, open_order_amount: 0, parse_status: "SAMPLE", user_confirmed: "N", }), ]; sheet.getRange(3, 1, sampleRows.length, numCols).setValues(sampleRows); // 샘플 행 배경색 — 실제 데이터와 구분 sheet.getRange(3, 1, sampleRows.length, numCols).setBackground("#fff2cc"); Logger.log(`initAccountSnapshotTemplate_: ${existed ? "재초기화" : "신규생성"} | 탭="${SHEET_NAME}" | 샘플행=${sampleRows.length}`); return { action: existed ? "reinit" : "created", sheet: SHEET_NAME, columns: numCols, sample_rows: sampleRows.length, note: "SAMPLE 행은 parse_status=SAMPLE → GAS가 무시. 실제 HTS 캡처 붙여넣기 후 삭제.", next_steps: [ "HTS 보유종목 화면 캡처 → ChatGPT 첨부 (capture_parse_prompt.md 포함)", "ChatGPT TSV 출력 → account_snapshot 탭 A3 셀 선택 → Ctrl+V", "SAMPLE 행 삭제 후 runDataFeed() 재실행", ], }; } function calcPerformanceBuyBias_(performance) { const p = performance || {}; const multiplier = Number.isFinite(p.bayesian_multiplier) ? p.bayesian_multiplier : 0.5; const tradesUsed = Number.isFinite(p.trades_used) ? p.trades_used : 0; const netExp = Number.isFinite(p.net_expectancy_30) ? p.net_expectancy_30 : null; const consLoss = Number.isFinite(p.consecutive_losses) ? p.consecutive_losses : 0; // [Phase 4] CAPITAL_STYLE_ALLOCATION_V3 연계: 데이터 품질 갭 분석 const legacyQuality = Number.isFinite(p.legacy_investment_quality_score) ? p.legacy_investment_quality_score : 13; const modernQuality = Number.isFinite(p.modern_investment_quality_score) ? p.modern_investment_quality_score : 69; const qualityGap = Math.abs(modernQuality - legacyQuality); let entryBlock = false; let quantityMult = multiplier; let reason = "performance_default"; let confidenceCap = 1.0; if (qualityGap >= 20) { confidenceCap = 0.5; reason = "quality_gap_penalty"; quantityMult = Math.min(quantityMult, 0.5); } if (consLoss >= 5) { entryBlock = true; quantityMult = 0; reason = "no_bet"; } else if (tradesUsed < 5) { quantityMult = Math.min(quantityMult, 0.5); reason = (reason === "quality_gap_penalty") ? reason + "|data_short" : "data_short"; } else if (Number.isFinite(netExp) && netExp < 0) { quantityMult = Math.min(quantityMult, 0.25); reason = "negative_expectancy"; } else if (Number.isFinite(netExp) && netExp >= 3.0 && multiplier >= 1.0) { quantityMult = (confidenceCap < 1.0) ? confidenceCap : 1.0; reason = (reason === "quality_gap_penalty") ? reason + "|high_bet_capped" : "high_bet"; } else if (multiplier >= 0.5) { reason = (reason === "quality_gap_penalty") ? reason : "standard"; } return { entry_block: entryBlock, quantity_multiplier: quantityMult, effective_confidence_cap: confidenceCap, label: String(p.bayesian_label ?? "medium_confidence"), reason: reason, }; } function runDataFeed() { if (typeof isRunAllOrchestrated_ === "function" && isRunAllOrchestrated_()) { setFetchSessionLabel_("runDataFeed"); } else { beginFetchSession_("runDataFeed"); } // [PROPOSAL50] P2-2: YAML-GAS 커버리지 감사 — 실행마다 커버리지 기록 갱신 try { auditYamlGasCoverage_(); } catch(e) { Logger.log('[YGCA] audit error: ' + e.message); } if (_gasCompatRoot_._gasCompatFallbackUsed_) { Logger.log("[GAS_COMPAT_FALLBACK] gas_lib.gs helper fallback activated — redeploy full Apps Script project to restore canonical helpers."); } // account_snapshot 탭 없으면 자동 생성 (캡처 원장 + 선택 포지션 상태 헤더) initAccountSnapshotTemplate_(); // settings 탭 — 사용자 입력 파라미터 (total_asset_krw, risk_budget_override 등) const settings = readSettingsTab_(); ensureAccountSnapshotConfirmModeSetting_(settings); let totalAssetKrw_ = Number.isFinite(parseFloat(settings["total_asset_krw"])) ? parseFloat(settings["total_asset_krw"]) : null; const riskBudget_ = Number.isFinite(parseFloat(settings["risk_budget_override"])) ? Math.min(0.02, Math.max(0, parseFloat(settings["risk_budget_override"]))) : 0.007; // POSITION_SIZE_V1 기본값 // Bayesian multiplier — performance 탭 기반 자동 계산 (spec/17_performance_contract.yaml) const bayesian = readPerformanceSheet_(); Logger.log(`Bayesian: ${bayesian.bayesian_label} (${bayesian.bayesian_multiplier}×) trades=${bayesian.trades_used}`); const accountSnapshot_ = readAccountSnapshotMap_(); Logger.log( "[ACCOUNT_SNAPSHOT_STATUS] rows_read=" + accountSnapshot_.rows_read + " confirmed=" + accountSnapshot_.rows_confirmed + " parse_ok_unconfirmed=" + (accountSnapshot_.rows_parse_ok_unconfirmed || 0) + " auto_confirmed=" + (accountSnapshot_.rows_auto_confirmed || 0) + " mode=" + (accountSnapshot_.confirm_mode || "STRICT_Y") ); if (accountSnapshot_.rows_read > 0 && accountSnapshot_.rows_confirmed === 0 && (accountSnapshot_.rows_parse_ok_unconfirmed || 0) > 0) { upsertOperationalWarningSetting_( "account_snapshot_confirmation_warning", "[ACCOUNT_CONFIRMATION_REQUIRED] parse_ok_unconfirmed=" + accountSnapshot_.rows_parse_ok_unconfirmed + ", mode=" + (accountSnapshot_.confirm_mode || "STRICT_Y") + ", action=user_confirmed=Y 입력 또는 account_snapshot_confirm_mode=AUTO_IF_PARSE_OK" ); } else { upsertOperationalWarningSetting_("account_snapshot_confirmation_warning", ""); } const settlementCashD2_ = Number.isFinite(parseFloat(settings["settlement_cash_d2_krw"])) ? parseFloat(settings["settlement_cash_d2_krw"]) : accountSnapshot_.cash.settlement_cash_d2; if (String((settings["cash_floor_status_override"] || "")).trim()) { // no-op: 수동 오버라이드가 있으면 런타임 경고 판단을 덮지 않음 } if (settlementCashD2_ === 0 || settlementCashD2_ === null || settlementCashD2_ === undefined) { // 현금 원장 정보 부족을 별도 경고로 남긴다. upsertOperationalWarningSetting_( "cash_ledger_warning", "[CASH_LEDGER_WARNING] settlement_cash_d2_krw가 0 또는 미입력 상태" ); } else { upsertOperationalWarningSetting_("cash_ledger_warning", ""); } const weeklyTargetCashPct_ = Number.isFinite(parseFloat(settings["weekly_target_cash_pct"])) ? parseFloat(settings["weekly_target_cash_pct"]) : null; const headers = [ // ── 기본 수급·가격 ───────────────────────────────────────────────────── "Ticker","Name","Price_Date","Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows","Updated_At", "Price_Status","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D", "MA20","MA60","Ret5D","Ret10D","Ret20D","Ret60D", "ATR20","ATR20_Pct","Val_Surge_Pct", "AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit", "Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status", "Flow5D_Status","Flow20D_Status","Ind5D_Status","Val_Surge_Status", "DART_Status","DART_Source","DART_Catalyst","DART_Risk", // ── 밸류에이션 ───────────────────────────────────────────────────────── "Forward_PE","PBR","EPS","EPS_Revision_Status","EPS_Growth_1Y_Pct", "DividendYield","DPS","Beta","High52W","Low52W","Pct_52W_High","Pct_From_52W_Low","Target_Price","Upside_Pct", "Earnings_Date","Days_To_Earnings","Ex_Dividend_Date","Days_To_Ex_Div", // ── 재무 건전성 (2026-05-18_FINANCIAL_HEALTH_V1 + OCF_B 추가) ──────────── "ROE_Pct","Operating_Margin_Pct","Debt_To_Equity","Current_Ratio","FCF_B","OCF_B","Revenue_Growth_Pct", // ── 진입 가격·기대우위·수량 자동 추정 ─────────────────────────────────── "Limit_Price_Est","Stop_Price_Est","Stop_Price_Source","EE_Est","Pos_Size_Qty","Pos_Size_Constraint", // ── 익절 사다리·타임스탑 자동 계산 (TAKE_PROFIT_LADDER_V1) ────────────── "TP1_Price","TP1_Qty","TP2_Price","TP2_Qty","Time_Stop_Date","Days_To_Time_Stop", // ── 포지션 모니터링 ────────────────────────────────────────────────────── "Weight_Pct","Profit_Pct","Unrealized_PnL","Stage2_Gate","Band_Status","Position_Count_Status", // ── F1 기술적 타이밍 지표 ──────────────────────────────────────────────── "MA20_Slope","Disparity","RSI14","BB_Width","BB_Position","BB_Upper","BB_Lower", // ── F2 진입 모드 게이트 ────────────────────────────────────────────────── "Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason", // ── F3 매도 타이밍 신호 ────────────────────────────────────────────────── "Exit_Signal_Detail", // ── F5 타이밍 종합 액션 ──────────────────────────────────────────────── "Timing_Score_Entry","Timing_Score_Exit","Timing_Action","Timing_Block_Reason", // ── F6 매도 액션·수량·가격 ─────────────────────────────────────────── "Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Price_Source","Sell_Price_Basis", "Sell_Execution_Window","Sell_Order_Type","Sell_Reason","Sell_Validation", "Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason", // ── F6A 계좌 캡처·주간 리밸런싱 검증 ──────────────────────────────── "Account_Holding_Qty","Account_Avg_Cost","Account_Market_Value","Account_Parse_Status", "Rule_Sell_Qty","Rebalance_Target_Cash_Pct","Rebalance_Need_KRW","Override_Sell_Qty","Override_Reason","Override_Validation", // ── F7 최종 룰엔진 액션·우선순위 ───────────────────────────────────── "Final_Action","Action_Priority","Priority_Score","Final_Rank","Decision_Source", // ── 수급·점수 자동 계산 ──────────────────────────────────────────────── "Flow_Credit","Trailing_Stop_Price", "SS001_P","SS001_V","SS001_F","SS001_E","SS001_M","SS001_VAL","SS001_Total","SS001_Norm_Score","SS001_Grade", "PEG","PEG_Gate", // ── 돌파 파일럿 게이트 ───────────────────────────────────────────────── "Breakout_Score","Breakout_Gate", // ── anti_climax_buy_gate S1~S5 ──────────────────────────────────────── "AC_S1","AC_S2","AC_S3","AC_S4","AC_S5","AC_Total","AC_Gate", // ── daily_leader_scan C1~C5 ──────────────────────────────────────────── "C1_Price","C2_RelStr","C3_VolSurge","C4_Flow","C5_Sector","Leader_Scan_Total","Leader_Gate", // ── 상대약세 청산 신호 RW1~RW5 (RW1·RW3은 sector_flow 이력 기반) ───── "RW1","RW2","RW3","RW4","RW5","RW_Partial", // ── BRT_V1 + RS_VERDICT_V2 + COMPOSITE_VERDICT_V1 + SAQG/RAG ─────── "Stock_Drawdown_From_High_Pct","Excess_Drawdown_PctP","Recovery_Ratio_5D","Recovery_Ratio_20D", "Downside_Beta","RS_Line_20D_Slope","RS_Line_60D_Slope","BRT_Verdict","BRT_Method", "Excess_Ret_10D","RS_Verdict_V1_Raw","RS_Verdict","Composite_Verdict", "SAQG_V1","SAQG_Penalty","SAQG_Failed_Filters","RAG_Verdict","RAG_Reason", // ── 데이터 품질 ──────────────────────────────────────────────────────── "Missing_Fields","Next_Source_To_Check","Action_Reason","Action_Params","Allowed_Action", // ── 포트폴리오 레벨 매도 우선순위 (sell_priority_engine) ────────────── "Sell_Priority_Score" ]; const rows = []; const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const savedEpsRevision = readExistingEpsRevision_("data_feed"); // 버킷 할당 누산기 (루프 종료 후 _bucketSnapshot_에 기록) let _coreTotalPct = 0, _satTotalPct = 0; // F4 trailing stop 갱신 대기열 초기화 _trailingStopUpdates_ = []; // account_snapshot pre-read — 보유수량·평단·선택 stop/highest/stage 상태의 단일 원장 const positionStopMap_ = {}; // ticker → { stop_price, entry_price, quantity, entry_date, entry_stage, position_type, highest_price } Object.keys(accountSnapshot_.positions).forEach(ticker => { const snap = accountSnapshot_.positions[ticker]; positionStopMap_[ticker] = { stop_price: Number.isFinite(snap.stop_price) && snap.stop_price > 0 ? snap.stop_price : null, entry_price: Number.isFinite(snap.average_cost) && snap.average_cost > 0 ? snap.average_cost : null, quantity: snap.quantity, highest_price: Number.isFinite(snap.highest_price) && snap.highest_price > 0 ? snap.highest_price : null, entry_date: snap.entry_date || snap.last_updated || null, entry_stage: snap.entry_stage || null, position_type: snap.position_type || "satellite", account_quantity: snap.quantity, account_average_cost: Number.isFinite(snap.average_cost) ? snap.average_cost : null, account_market_value: Number.isFinite(snap.market_value) ? snap.market_value : null, account_parse_status: snap.parse_status, account_user_confirmed: snap.user_confirmed, account: snap.account || "", }; }); // WBS-1.2: total_asset_krw 실시간 재계산 (2-pass differential: HTS 기준 + Naver 가격 델타) // 구 방식(D2현금 + Naver 주가 합산)은 ISA·연금저축·CMA ~10M을 누락해 과소계상됨. // 수정: HTS 캡처 총액을 기준으로 개별주 Naver-HTS 가격 차이(delta)만 반영한다. if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) { let priceUpdateKrw = 0, updateCount = 0; for (const ticker of Object.keys(positionStopMap_)) { const priceMetrics = resolveDataFeedPriceMetrics(ticker); const pos = positionStopMap_[ticker]; const qty = pos.quantity; const htsMv = pos.account_market_value; if (priceMetrics.ok && Number.isFinite(priceMetrics.close) && Number.isFinite(qty) && Number.isFinite(htsMv) && htsMv > 0) { priceUpdateKrw += (priceMetrics.close * qty) - htsMv; updateCount++; } } if (updateCount > 0) { const liveTotal = totalAssetKrw_ + priceUpdateKrw; if (liveTotal > 0) { totalAssetKrw_ = liveTotal; Logger.log(`[WBS-1.2] total_asset_krw 재계산 완료: ${totalAssetKrw_} KRW (delta: ${priceUpdateKrw}, ${updateCount}종목 반영)`); } } } // Total_Heat 사전 계산 — HF005(≥10% 매수 차단) + caution(7~10% 수량 감액)에 사용 // positionStopMap_ 완성 후 즉시 계산. ATR 추정 폴백: entry_price × 8% (보수적) let globalHeatPct_ = null; // null = 계산 불가, number = heat% if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) { let heatKrw = 0; for (const [, pos] of Object.entries(positionStopMap_)) { const qty = pos.quantity; const ep = pos.entry_price; if (!Number.isFinite(qty) || qty <= 0 || !Number.isFinite(ep) || ep <= 0) continue; const sp = Number.isFinite(pos.stop_price) && pos.stop_price > 0 && pos.stop_price < ep ? pos.stop_price : ep * 0.92; // spec: 미설정 시 8% 고정(보수적 추정) heatKrw += (ep - sp) * qty; } globalHeatPct_ = parseFloat((heatKrw / totalAssetKrw_ * 100).toFixed(2)); Logger.log(`Total_Heat pre-calc: ${globalHeatPct_}% (${Object.keys(positionStopMap_).length}개 포지션)`); } // ── 종목 수 집계 → PCL 상태 산출 (2026-05-18_POSITION_STRATEGY_V1) ────────── // positionStopMap_ = 일반계좌 개별주 (stop_price·Sell_Qty·Total_Heat 계산용) // accountSnapshot_.isa_positions = ISA 개별주 (PCL 카운트 전용) // 연금저축 = ETF 전용, 카운트 제외 // 일반계좌 하드 상한: 8종목(ROTATE_REQUIRED), 경보: 7종목(CAUTION) // ISA 하드 상한: 4종목(ROTATE_REQUIRED), 경보: 3종목(CAUTION) const _taxCore_ = Object.values(positionStopMap_).filter(p => p.position_type === "core").length; const _taxSat_ = Object.values(positionStopMap_).filter(p => p.position_type !== "core").length; const _taxTotal_ = Object.keys(positionStopMap_).length; const _isaTotal_ = Object.keys(accountSnapshot_.isa_positions ?? {}).length; const _taxStatus_ = _taxTotal_ >= 8 ? "ROTATE_REQUIRED" : _taxTotal_ === 7 ? "CAUTION" : "PASS"; const _isaStatus_ = _isaTotal_ >= 4 ? "ROTATE_REQUIRED" : _isaTotal_ === 3 ? "CAUTION" : "PASS"; const positionCountStatus_ = `일반계좌:${_taxStatus_}(코어${_taxCore_}/위성${_taxSat_}/계${_taxTotal_}) | ISA:${_isaStatus_}(계${_isaTotal_})`; // macro 탭 pre-read — KOSPI Ret5/10/20/60D(BRT/RS용) + REGIME_PRELIM(SS001_M용) let globalKospiRet5D_ = null; let globalKospiRet10D_ = null; let globalKospiRet20D_ = null; let globalKospiRet60D_ = null; let globalKospiDrawdown_ = null; let globalRegimePrelim_ = null; try { const macroSheet = getSpreadsheet_().getSheetByName("macro"); if (macroSheet) { const mData = macroSheet.getDataRange().getValues(); let headerRowIdx = 0; for (let r = 0; r < Math.min(5, mData.length); r++) { const row = mData[r] ?? []; if (row.indexOf("Symbol") >= 0 && row.indexOf("Name") >= 0) { headerRowIdx = r; break; } } const mHdr = mData[headerRowIdx] ?? []; const symIdx = mHdr.indexOf("Symbol"); const nameIdx = mHdr.indexOf("Name"); const closeIdx = mHdr.indexOf("Close"); const ma60Idx = mHdr.indexOf("MA60"); const ret5DIdx = mHdr.indexOf("Ret5D"); const ret10DIdx = mHdr.indexOf("Ret10D"); const ret20DIdx = mHdr.indexOf("Ret20D"); const ret60DIdx = mHdr.indexOf("Ret60D"); for (let i = headerRowIdx + 1; i < mData.length; i++) { const sym = symIdx >= 0 ? String(mData[i][symIdx]).trim() : ""; const name = nameIdx >= 0 ? String(mData[i][nameIdx]).trim() : ""; if (name === "KOSPI") { const r5 = ret5DIdx >= 0 ? parseFloat(mData[i][ret5DIdx]) : NaN; const r10 = ret10DIdx >= 0 ? parseFloat(mData[i][ret10DIdx]) : NaN; const r20 = ret20DIdx >= 0 ? parseFloat(mData[i][ret20DIdx]) : NaN; const r60 = ret60DIdx >= 0 ? parseFloat(mData[i][ret60DIdx]) : NaN; const close = closeIdx >= 0 ? parseFloat(mData[i][closeIdx]) : NaN; const ma60 = ma60Idx >= 0 ? parseFloat(mData[i][ma60Idx]) : NaN; if (Number.isFinite(r5)) globalKospiRet5D_ = r5; if (Number.isFinite(r10)) globalKospiRet10D_ = r10; if (Number.isFinite(r20)) globalKospiRet20D_ = r20; if (Number.isFinite(r60)) globalKospiRet60D_ = r60; if (Number.isFinite(close) && Number.isFinite(ma60) && ma60 > 0) { globalKospiDrawdown_ = Math.max(0, parseFloat(((1 - close / Math.max(close, ma60)) * 100).toFixed(2))); } else if (Number.isFinite(r60) && r60 < 0) { globalKospiDrawdown_ = Math.abs(r60); } } if (sym === "REGIME_PRELIM" && closeIdx >= 0) { const rv = String(mData[i][closeIdx]).trim(); if (rv) globalRegimePrelim_ = rv; } if (globalKospiRet10D_ !== null && globalKospiRet20D_ !== null && globalRegimePrelim_ !== null) break; } } } catch(e) { handleFetchError_("runDataFeed:macro pre-read", e, "CRITICAL"); } // sector_flow 전회 실행 결과 통합 pre-read: rank, ETF수익률, 수급, RW1/RW3, 섹터 밸류에이션 const sectorFlowData_ = {}; // sector_name → { rank, etfRet10D, smart5, smart20, rw1, rw3, medianPE, medianPBR } try { const sfSheet = getSpreadsheet_().getSheetByName("sector_flow"); if (sfSheet) { const sfData = sfSheet.getDataRange().getValues(); const sfHdr = sfData[1] ?? []; const sNameIdx = sfHdr.indexOf("Sector"); const rankIdx = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank"); const scoreIdx = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score"); const etfR10Idx = sfHdr.indexOf("Sector_Ret10D") >= 0 ? sfHdr.indexOf("Sector_Ret10D") : (sfHdr.indexOf("ETF_Ret10D") >= 0 ? sfHdr.indexOf("ETF_Ret10D") : sfHdr.indexOf("Sector_Ret20D")); const smart5Idx = sfHdr.indexOf("SmartMoney_5D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_5D_KRW") : sfHdr.indexOf("Frg_5D_SUM"); const smart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM"); const rw1Idx = sfHdr.indexOf("RW1"); const rw3Idx = sfHdr.indexOf("RW3"); const medPeIdx = sfHdr.indexOf("Sector_Median_PE"); const medPbrIdx = sfHdr.indexOf("Sector_Median_PBR"); if (sNameIdx >= 0) { for (let i = 2; i < sfData.length; i++) { const sName = String(sfData[i][sNameIdx]).trim(); if (!sName || sName === "Sector") continue; const rank = rankIdx >= 0 ? parseInt(sfData[i][rankIdx]) : null; sectorFlowData_[sName] = { rank: Number.isFinite(rank) ? rank : null, score: scoreIdx >= 0 ? parseFloat(sfData[i][scoreIdx]) : null, etfRet10D: etfR10Idx >= 0 ? parseFloat(sfData[i][etfR10Idx]) : null, smart5: smart5Idx >= 0 ? parseFloat(sfData[i][smart5Idx]) : null, smart20: smart20Idx >= 0 ? parseFloat(sfData[i][smart20Idx]) : null, rw1: rw1Idx >= 0 ? parseInt(sfData[i][rw1Idx]) : 0, rw3: rw3Idx >= 0 ? parseInt(sfData[i][rw3Idx]) : 0, medianPE: medPeIdx >= 0 ? parseFloat(sfData[i][medPeIdx]) : null, medianPBR: medPbrIdx >= 0 ? parseFloat(sfData[i][medPbrIdx]) : null, }; } } } } catch(e) { handleFetchError_("runDataFeed:sector_flow pre-read", e, "CRITICAL"); } // core_satellite 탭 pre-read — RS_Pct_20D → SS001_P 상대강도 점수용 const csRsPctMap_ = {}; // ticker → RS_Pct_20D (0~100) try { const csSheet = getSpreadsheet_().getSheetByName("core_satellite"); if (csSheet) { const csData = csSheet.getDataRange().getValues(); const csHdr = csData[1] ?? []; const csTkIdx = csHdr.indexOf("Ticker"); const csRsPctIdx = csHdr.indexOf("RS_Pct_20D"); if (csTkIdx >= 0 && csRsPctIdx >= 0) { for (let i = 2; i < csData.length; i++) { const tk = String(csData[i][csTkIdx]).trim(); if (!tk) continue; const rsPct = parseFloat(csData[i][csRsPctIdx]); if (Number.isFinite(rsPct)) csRsPctMap_[tk] = rsPct; } } } } catch(e) { handleFetchError_("runDataFeed:core_satellite pre-read", e, "CRITICAL"); } const preReads = { positionStopMap_, globalHeatPct_, globalKospiRet5D_, globalKospiRet10D_, globalKospiRet20D_, globalKospiRet60D_, globalKospiDrawdown_, globalRegimePrelim_, sectorFlowData_, csRsPctMap_, riskBudget_, totalAssetKrw_, weeklyTargetCashPct_, bayesian, savedEpsRevision, today, positionCountStatus_, }; const activeTickers_ = (typeof getActiveTickers_ === "function") ? getActiveTickers_() : TICKERS; for (const t of activeTickers_) { const result = buildTickerRowV2_(t, preReads, _trailingStopUpdates_); rows.push(result.row); _coreTotalPct += result.corePctDelta; _satTotalPct += result.satPctDelta; Utilities.sleep(400); } // ── 섹터별 총노출 집계 (duplicate_exposure_rule 판별 + Sell_Priority_Score 입력) ── // spec: spec/risk/portfolio_exposure.yaml:duplicate_exposure_rule const _sectorExpMap_ = {}; { const wIdx_ = headers.indexOf("Weight_Pct"); const tIdx_ = headers.indexOf("Ticker"); rows.forEach(row => { const tk_ = String(row[tIdx_] ?? ""); const w_ = parseFloat(row[wIdx_]); if (!tk_ || !Number.isFinite(w_) || w_ <= 0) return; const sec_ = TICKER_SECTOR_MAP[tk_] ?? ""; if (sec_) _sectorExpMap_[sec_] = (_sectorExpMap_[sec_] || 0) + w_; }); } // ── Sell_Priority_Score 일괄 계산 (post-loop, 섹터집계 완료 후) ─────────── // spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring { const spIdx_ = headers.indexOf("Sell_Priority_Score"); if (spIdx_ >= 0) { rows.forEach(row => { const res_ = calcSellPriorityScore_(row, headers, _sectorExpMap_); row[spIdx_] = res_.score; }); } } const targetCashPctIdx = headers.indexOf("Rebalance_Target_Cash_Pct"); const needKrwIdx = headers.indexOf("Rebalance_Need_KRW"); const overrideQtyIdx = headers.indexOf("Override_Sell_Qty"); const overrideReasonIdx = headers.indexOf("Override_Reason"); const overrideValidationIdx = headers.indexOf("Override_Validation"); const sellActionIdx = headers.indexOf("Sell_Action"); const sellValidationIdx = headers.indexOf("Sell_Validation"); const sellLimitIdx = headers.indexOf("Sell_Limit_Price"); const ruleSellQtyIdx = headers.indexOf("Rule_Sell_Qty"); const accountQtyIdx = headers.indexOf("Account_Holding_Qty"); const finalActionIdx = headers.indexOf("Final_Action"); const priorityScoreForRebalIdx = headers.indexOf("Priority_Score"); if ( Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0 && Number.isFinite(settlementCashD2_) && Number.isFinite(weeklyTargetCashPct_) && weeklyTargetCashPct_ > 0 && targetCashPctIdx >= 0 && needKrwIdx >= 0 && overrideQtyIdx >= 0 && overrideReasonIdx >= 0 && overrideValidationIdx >= 0 ) { const targetCashKrw = totalAssetKrw_ * weeklyTargetCashPct_ / 100; const needKrw = Math.max(0, targetCashKrw - settlementCashD2_); rows.forEach(row => { row[targetCashPctIdx] = weeklyTargetCashPct_; row[needKrwIdx] = Math.round(needKrw); row[overrideValidationIdx] = needKrw > 0 ? "NOT_SELECTED" : "NO_REBALANCE_NEEDED"; }); if (needKrw > 0) { // ── 리밸런스 후보 확장 (sell_priority_engine 3단계 풀) ────────────────── // spec: portfolio_exposure.yaml:sell_priority_engine.hard_precedence // Tier 1: 기존 SELL_READY(매도신호 확정) → Tier 2: ETF(중복노출) → Tier 3: 손실위성(-10%↓) // 코어주도주(삼성전자·SK하이닉스)는 hard_stop 없으면 풀 제외 (spec:prohibition) const nameIdx_ = headers.indexOf("Name"); const profitIdx_ = headers.indexOf("Profit_Pct"); const tickerIdx_ = headers.indexOf("Ticker"); const spScoreIdx_ = headers.indexOf("Sell_Priority_Score"); let remaining = needKrw; rows .map((row, idx) => ({ row, idx })) .filter(item => { const row = item.row; const finalAction = String(row[finalActionIdx] ?? ""); const sellAction = String(row[sellActionIdx] ?? ""); const sellVal = String(row[sellValidationIdx] ?? ""); const name__ = String(row[nameIdx_] ?? ""); const ticker__ = String(row[tickerIdx_] ?? ""); const profitPct__ = parseFloat(row[profitIdx_]); const isEtf__ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name__); const isCL__ = (ticker__ === "005930" || ticker__ === "000660"); // hard_stop — core leader도 포함 if (finalAction === "EXIT_SIGNAL" || sellAction === "EXIT_100") return true; // Tier 1: SELL_READY (기존 로직) if (sellAction && sellAction !== "HOLD" && sellVal === "SIGNAL_CONFIRMED" && finalAction === "SELL_READY") return true; // Tier 2: ETF 중복노출 (코어리더 ETF 없으므로 isCL__ 체크 불필요) if (isEtf__) return true; // Tier 3: 손실 위성 -10% 이하, 코어리더 제외 if (!isEtf__ && !isCL__ && Number.isFinite(profitPct__) && profitPct__ <= -10) return true; return false; }) // Sell_Priority_Score 내림차순 → 점수 높을수록 먼저 현금 확보 대상 .sort((a, b) => { const as_ = parseFloat(a.row[spScoreIdx_]) || 0; const bs_ = parseFloat(b.row[spScoreIdx_]) || 0; return bs_ - as_; }) .forEach(item => { if (remaining <= 0) return; const row = item.row; const price = parseFloat(row[sellLimitIdx]); const name__= String(row[nameIdx_] ?? ""); const isEtf__ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name__); const finalAction__ = String(row[finalActionIdx] ?? ""); const tier__ = finalAction__ === "EXIT_SIGNAL" ? "①하드스탑" : finalAction__ === "SELL_READY" ? "②매도신호" : isEtf__ ? "③중복ETF" : "④손실위성"; // 방향 A: 수량 없음 — Override_Sell_Qty는 캡처 후 수동 계산 if (!Number.isFinite(price) || price <= 0) { row[overrideValidationIdx] = "NO_PRICE"; return; } row[overrideReasonIdx] = `[${tier__}] D+2 현금 ${weeklyTargetCashPct_}% 회복 — 수량 캡처 후 확인`; row[overrideValidationIdx] = "SIGNAL_ONLY_USER_CONFIRM"; }); } } const priorityIdx = headers.indexOf("Action_Priority"); const scoreIdx = headers.indexOf("Priority_Score"); const rankIdx = headers.indexOf("Final_Rank"); if (priorityIdx >= 0 && scoreIdx >= 0 && rankIdx >= 0) { rows .map((row, idx) => ({ row, idx })) .sort((a, b) => { const ap = parseFloat(a.row[priorityIdx]); const bp = parseFloat(b.row[priorityIdx]); if (ap !== bp) return ap - bp; const as = parseFloat(a.row[scoreIdx]); const bs = parseFloat(b.row[scoreIdx]); if (as !== bs) return bs - as; return a.idx - b.idx; }) .forEach((item, rank) => { item.row[rankIdx] = rank + 1; }); } // ── Fetch 품질 진단 집계 (runDataFeed 완료 직전) ────────────────────────── // Price_Status / DART_Status 기반으로 STALE·MISSING 비율 집계 후 Logger 출력. // STALE 비율 > 50%이면 다음 실행 시 캐시 전체 강제 갱신을 위해 경고를 settings에 기록. { const psIdx_ = headers.indexOf("Price_Status"); const dsIdx_ = headers.indexOf("DART_Status"); let priceOk_ = 0, priceStale_ = 0, priceMissing_ = 0; rows.forEach(row => { const ps = String(row[psIdx_] ?? ""); if (ps === "PRICE_OK") priceOk_++; else if (ps === "PRICE_STALE") priceStale_++; else priceMissing_++; }); const stalePct_ = rows.length > 0 ? Math.round(priceStale_ / rows.length * 100) : 0; Logger.log( `[FETCH_DIAG] 총 ${rows.length}종목 | PRICE_OK=${priceOk_} PRICE_STALE=${priceStale_}(${stalePct_}%) MISSING=${priceMissing_}` ); // STALE 과반수(>50%) — 다음 세션에서 캐시 전체 재수집 경고 if (stalePct_ > 50) { upsertOperationalWarningSetting_( "data_freshness_warning", `[STALE_MAJORITY] price_stale=${stalePct_}% — 다음 runDataFeed 전 clearFetchCache() 실행 권장` ); Logger.log("[FETCH_DIAG][WARN] STALE 과반수(" + stalePct_ + "%) — clearFetchCache() 자동 호출"); try { clearFetchCache(); } catch (_) {} } else { upsertOperationalWarningSetting_("data_freshness_warning", ""); } } writeToSheet("data_feed", headers, rows); Logger.log(`data_feed 완료: ${rows.length}종목`); // 버킷 스냅샷 저장 (runMacro → BUCKET_STATUS 행에 사용) _bucketSnapshot_ = { core_pct: parseFloat(_coreTotalPct.toFixed(2)), satellite_pct: parseFloat(_satTotalPct.toFixed(2)), ts: today, }; // F4: account_snapshot trailing stop 일괄 갱신 applyTrailingStopUpdates_(); // [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록 logDailyAssetHistory_(totalAssetKrw_, today); // 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다. if (!isRunAllOrchestrated_()) { runSectorFlow(); } } /** * [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록한다. */ function logDailyAssetHistory_(totalAsset, todayStr) { try { if (!Number.isFinite(totalAsset) || totalAsset <= 0) { Logger.log("[MDD_GUARD] totalAsset이 유효하지 않아 일별 기록을 건너뜁니다."); return; } // daily_history 시트 획득 또는 생성 var ss = getSpreadsheet_(); var sheet = ss.getSheetByName("daily_history"); if (!sheet) { sheet = ss.insertSheet("daily_history"); // 헤더 작성 sheet.appendRow(["Date", "Total_Asset_KRW", "Peak_Asset_KRW", "MDD_Pct"]); Logger.log("[MDD_GUARD] daily_history 시트를 신규 생성하고 헤더를 작성했습니다."); } // 기존 데이터 읽기 var data = sheet.getDataRange().getValues(); // 오늘 날짜가 이미 존재하는지 체크 (중복 기록 방지) var todayIndex = -1; for (var i = 1; i < data.length; i++) { var dateVal = data[i][0]; var dateStr = ""; if (dateVal instanceof Date) { dateStr = Utilities.formatDate(dateVal, "Asia/Seoul", "yyyy-MM-dd"); } else { dateStr = String(dateVal).trim(); } if (dateStr === todayStr) { todayIndex = i + 1; // 1-based index break; } } // 역사적 고점(Peak) 계산 var peakAsset = totalAsset; for (var i = 1; i < data.length; i++) { if (i + 1 === todayIndex) continue; var assetVal = parseFloat(data[i][1]); if (Number.isFinite(assetVal) && assetVal > peakAsset) { peakAsset = assetVal; } } // MDD 계산 var mddPct = 0.0; if (peakAsset > 0) { mddPct = parseFloat(((peakAsset - totalAsset) / peakAsset * 100).toFixed(2)); } if (todayIndex > 0) { // 이미 오늘 날짜가 있으면 해당 행 업데이트 sheet.getRange(todayIndex, 1, 1, 4).setValues([[todayStr, totalAsset, peakAsset, mddPct]]); Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 업데이트했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%"); } else { // 없으면 새 행 추가 sheet.appendRow([todayStr, totalAsset, peakAsset, mddPct]); Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 추가했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%"); } } catch(e) { Logger.log("[MDD_GUARD] daily_history 기록 실패: " + e.message); } } // --- Source: src/gas_adapter_parts/gdc_02_account_satellite.gs --- function ensureAccountSnapshotConfirmModeSetting_(settingsObj) { try { const settings = settingsObj || {}; const raw = String(settings["account_snapshot_confirm_mode"] || "").trim(); if (raw) return; const ss = getSpreadsheet_(); const sh = ss.getSheetByName("settings"); if (!sh) return; sh.appendRow(["account_snapshot_confirm_mode", "STRICT_Y", "STRICT_Y|AUTO_IF_PARSE_OK"]); settings["account_snapshot_confirm_mode"] = "STRICT_Y"; Logger.log("[SETTINGS_DEFAULT] account_snapshot_confirm_mode=STRICT_Y"); } catch (e) { Logger.log("[SETTINGS_DEFAULT][WARN] account_snapshot_confirm_mode 주입 실패: " + e.message); } } function upsertOperationalWarningSetting_(key, value) { try { const ss = getSpreadsheet_(); const sh = ss.getSheetByName("settings"); if (!sh) return; const data = sh.getDataRange().getValues(); for (let i = 0; i < data.length; i++) { if (String(data[i][0] || "").trim() === key) { sh.getRange(i + 1, 2).setValue(value); return; } } sh.appendRow([key, value, "auto-generated operational warning"]); } catch (e) { Logger.log("[SETTINGS_WARNING][WARN] " + key + " 갱신 실패: " + e.message); } } // ── buildTickerRow_ sub-functions ────────────────────────────────────────── function _tickerSetup_(t, preReads) { const { positionStopMap_, globalHeatPct_, globalKospiRet10D_, globalRegimePrelim_, sectorFlowData_, csRsPctMap_, riskBudget_, totalAssetKrw_, weeklyTargetCashPct_, bayesian, savedEpsRevision, today, positionCountStatus_, } = preReads; const isRiskOffRegime = globalRegimePrelim_ === "RISK_OFF" || globalRegimePrelim_ === "RISK_OFF_CANDIDATE"; const heatBlock = Number.isFinite(globalHeatPct_) && globalHeatPct_ >= 10; const heatCaution = Number.isFinite(globalHeatPct_) && globalHeatPct_ >= 7 && globalHeatPct_ < 10; const flow = fetchNaverFlow(t.code); const price = resolveDataFeedPriceMetrics(t.code); const valuation = fetchNaverMarketMetrics(t.code); const consensus = fetchNaverConsensusData(t.code); const notices = fetchNaverDisclosureNotices(t.code); const dartSummary = summarizeDisclosureNotices(notices); const frg5 = flow.rows.slice(0,5).reduce((s,r) => s+r.frgn, 0); const inst5 = flow.rows.slice(0,5).reduce((s,r) => s+r.inst, 0); const frg20 = flow.rows.reduce((s,r) => s+r.frgn, 0); const inst20 = flow.rows.reduce((s,r) => s+r.inst, 0); const indiv5 = -(frg5+inst5); // priceStatus 4단계 const priceStatus = !price.ok ? "PRICE_MISSING" : price.isFallbackQuote ? "PRICE_QUOTE_ONLY" : price.isPriceStale ? "PRICE_STALE" : "PRICE_OK"; const flow5Status = flow.ok ? `OK: ${frg5 > 0 ? "외국인 매수" : "외국인 매도"} / ${inst5 > 0 ? "기관 매수" : "기관 매도"}` : "DATA_MISSING"; const flow20Status = flow.ok ? "OK" : "DATA_MISSING"; const ind5Status = flow.ok ? "OK" : "DATA_MISSING"; const valSurgeStatus = calcValSurgeStatus(price.valSurge); const liquidityStatus = calcLiquidityStatus(Number(price.avgTradingValue5D)); const spreadStatus = calcSpreadStatus(Number(price.spreadPct)); const missing = []; if (!flow.ok) missing.push("Flow5D/Flow20D"); if (flow.ok && flow.isFlowStale) missing.push(`FLOW_STALE(${flow.rows[0]?.date ?? "?"})`); if (priceStatus === "PRICE_MISSING") missing.push("ATR20/Val_Surge"); if (priceStatus === "PRICE_QUOTE_ONLY") missing.push("PRICE_QUOTE_ONLY:MA/ATR결측"); if (priceStatus === "PRICE_STALE") missing.push(`PRICE_STALE(${price.priceDate})`); if (dartSummary.status === "NAVER_NOTICE_EMPTY" || String(dartSummary.status).startsWith("NAVER_NOTICE_ERROR")) missing.push("DART"); if (heatBlock) missing.push(`HF005:HEAT_BLOCK(${globalHeatPct_}%)`); if (heatCaution) missing.push(`HEAT_CAUTION(${globalHeatPct_}%→수량50%감액)`); if (isRiskOffRegime) missing.push(`REGIME_BLOCK(${globalRegimePrelim_})`); if (globalHeatPct_ === null) missing.push("TOTAL_HEAT_UNKNOWN"); const next = []; if (priceStatus === "PRICE_MISSING" || priceStatus === "PRICE_QUOTE_ONLY") next.push("Yahoo Finance chart"); if (missing.includes("DART")) next.push("Naver 공시공지"); if (missing.includes("Flow5D/Flow20D")) next.push("Naver frgn.naver"); const perfBias = calcPerformanceBuyBias_(bayesian); const posRec = positionStopMap_[t.code]; return { t, preReads, flow, price, valuation, consensus, dartSummary, frg5, inst5, frg20, inst20, indiv5, priceStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus, liquidityStatus, spreadStatus, missing, next, isRiskOffRegime, heatBlock, heatCaution, perfBias, posRec, today, positionCountStatus_, weeklyTargetCashPct_, }; } // ── Fundamentals: EPS, 52W, target price, dividends, financial health ──────── function _addTickerFundamentals_(ctx) { const { t, price, valuation, consensus, preReads } = ctx; const { savedEpsRevision, today } = preReads; // ── EPS_Revision_Status: Naver 우선, Yahoo 폴백, 기존값 최후 보존 ────── let epsRevisionStatus = ""; if (consensus.ok && consensus.epsRevisionStatus !== "DATA_MISSING") { epsRevisionStatus = consensus.epsRevisionStatus; } else { const yahooConsensus = fetchYahooConsensusEps(t.code); if (yahooConsensus.ok && yahooConsensus.epsRevisionStatus !== "DATA_MISSING") { epsRevisionStatus = yahooConsensus.epsRevisionStatus; } else { epsRevisionStatus = savedEpsRevision[t.code] ?? ""; } } // ── 배당수익률: Naver main(_dvr) 우선, Yahoo quote 폴백 ────────────── const naverDvr = Number.isFinite(valuation.dvr) ? valuation.dvr : null; const yahooQuote = fetchYahooMarketMetrics(t.code); const divYield = naverDvr ?? (Number.isFinite(yahooQuote.divYield) ? yahooQuote.divYield : ""); // ── Beta: Yahoo quote 우선, quoteSummary 폴백 ───────────────────────── let beta = Number.isFinite(yahooQuote.beta) ? yahooQuote.beta : null; // ── 52주 고저가: Naver main 우선, Yahoo quote 폴백 ──────────────────── const high52W = Number.isFinite(valuation.high52W) ? valuation.high52W : Number.isFinite(yahooQuote.high52W) ? yahooQuote.high52W : null; const low52W = Number.isFinite(valuation.low52W) ? valuation.low52W : Number.isFinite(yahooQuote.low52W) ? yahooQuote.low52W : null; const closeVal = price.ok && Number.isFinite(price.close) ? price.close : null; const pct52WHigh = Number.isFinite(high52W) && Number.isFinite(closeVal) && high52W > 0 ? ((closeVal / high52W - 1) * 100).toFixed(1) : ""; const pctFrom52WLow = Number.isFinite(low52W) && Number.isFinite(closeVal) && low52W > 0 ? ((closeVal / low52W - 1) * 100).toFixed(1) : ""; // ── 목표주가: Naver consensus 우선, Yahoo quoteSummary 폴백 ────────── let targetPrice = Number.isFinite(consensus.targetPrice) && consensus.targetPrice > 0 ? consensus.targetPrice : null; const yahooFin = fetchYahooTargetPrice(t.code); if (!targetPrice && yahooFin.ok && Number.isFinite(yahooFin.targetPrice) && yahooFin.targetPrice > 0) { targetPrice = yahooFin.targetPrice; } if (!beta && Number.isFinite(yahooFin.beta)) beta = yahooFin.beta; const upsidePct = Number.isFinite(targetPrice) && Number.isFinite(closeVal) && closeVal > 0 ? ((targetPrice / closeVal - 1) * 100).toFixed(1) : ""; // ── EPS 1년 성장률 ──────────────────────────────────────────────────── const epsGrowth1y = Number.isFinite(consensus.epsGrowth1y) ? consensus.epsGrowth1y : null; // ── DPS ─────────────────────────────────────────────────────────────── const dps = Number.isFinite(yahooFin.dividendPerShare) ? yahooFin.dividendPerShare : null; // ── 재무 건전성 7개 필드 (FINANCIAL_HEALTH_V1 + OCF_B + 7일 캐시 통합) ───── // ETF는 개별 재무제표가 없으므로 수집 스킵 const isEtfTicker_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(t.name ?? ""); let fundResult; if (isEtfTicker_) { Logger.log('[INFO][FUND_SKIP_ETF] ' + t.code + ' (' + (t.name ?? '') + ') — ETF, 펀더멘털 수집 불필요'); fundResult = { ok: false, source: 'etf_no_fundamentals' }; } else { fundResult = (typeof fetchFundamentalsWithCache_ === 'function') ? fetchFundamentalsWithCache_(t.code, t.code, yahooFin) : yahooFin; } const roePct = fundResult.ok && Number.isFinite(fundResult.roePct) ? fundResult.roePct : null; const opMarginPct = fundResult.ok && Number.isFinite(fundResult.operatingMarginPct) ? fundResult.operatingMarginPct : null; const debtToEquity = fundResult.ok && Number.isFinite(fundResult.debtToEquity) ? fundResult.debtToEquity : null; const currentRatio = fundResult.ok && Number.isFinite(fundResult.currentRatio) ? fundResult.currentRatio : null; const fcfB = fundResult.ok && Number.isFinite(fundResult.fcfB) ? fundResult.fcfB : null; const ocfB = fundResult.ok && Number.isFinite(fundResult.ocfB) ? fundResult.ocfB : null; const revenueGrowthPct = fundResult.ok && Number.isFinite(fundResult.revenueGrowthPct) ? fundResult.revenueGrowthPct : null; // ── 실적 발표일 → 잔여 일수 ─────────────────────────────────────────── const earningsDateStr = yahooFin?.earningsDate ?? null; const tp = today.split("-").map(Number); const todayMs = Date.UTC(tp[0], tp[1] - 1, tp[2]); let daysToEarnings = ""; if (earningsDateStr) { const ep = earningsDateStr.split("-").map(Number); daysToEarnings = Math.round((Date.UTC(ep[0], ep[1]-1, ep[2]) - todayMs) / (1000*60*60*24)); } // ── 배당락일 → 잔여 일수 (A4) ────────────────────────────────────────── const exDividendDateStr = yahooFin?.exDividendDate ?? null; let daysToExDiv = ""; if (exDividendDateStr) { const xp = exDividendDateStr.split("-").map(Number); daysToExDiv = Math.round((Date.UTC(xp[0], xp[1]-1, xp[2]) - todayMs) / (1000*60*60*24)); } Object.assign(ctx, { epsRevisionStatus, epsGrowth1y, divYield, dps, beta, high52W, low52W, pct52WHigh, pctFrom52WLow, targetPrice, upsidePct, earningsDateStr, daysToEarnings, exDividendDateStr, daysToExDiv, roePct, opMarginPct, debtToEquity, currentRatio, fcfB, ocfB, revenueGrowthPct, }); } // ── [2026-05-21_BRT_HARNESS_V1] BRT/SAQG helpers ───────────────────────── function calcBenchmarkRelativeTimeseries_(price, high52W, preReads) { const k5 = preReads.globalKospiRet5D_; const k20 = preReads.globalKospiRet20D_; const k60 = preReads.globalKospiRet60D_; const kDrawdown = preReads.globalKospiDrawdown_; const close = price && price.ok && Number.isFinite(price.close) ? price.close : null; const stockDrawdown = Number.isFinite(high52W) && Number.isFinite(close) && high52W > 0 ? parseFloat((Math.max(0, (1 - close / high52W) * 100)).toFixed(2)) : null; const excessDrawdown = stockDrawdown !== null && Number.isFinite(kDrawdown) ? parseFloat((stockDrawdown - kDrawdown).toFixed(2)) : null; const ret5 = price && Number.isFinite(price.ret5D) ? price.ret5D : null; const ret20 = price && Number.isFinite(price.ret20D) ? price.ret20D : null; const ret60 = price && Number.isFinite(price.ret60D) ? price.ret60D : null; const rec5 = ret5 !== null && Number.isFinite(k5) && k5 > 0 ? parseFloat((ret5 / k5).toFixed(3)) : null; const rec20 = ret20 !== null && Number.isFinite(k20) && k20 > 0 ? parseFloat((ret20 / k20).toFixed(3)) : null; const downsideBeta = ret20 !== null && Number.isFinite(k20) && k20 < 0 ? parseFloat((ret20 / k20).toFixed(3)) : null; // [C-2] RS ratio slopes: change in RS ratio per day across windows // slope = (rsRatio_longWindow - rsRatio_shortWindow) / daysBetween // Positive = relative strength improving; negative = deteriorating const rsRatio5d = (ret5 !== null && Number.isFinite(k5) && k5 !== 0) ? ret5 / k5 : null; const rsRatio20d = (ret20 !== null && Number.isFinite(k20) && k20 !== 0) ? ret20 / k20 : null; const rsRatio60d = (ret60 !== null && Number.isFinite(k60) && k60 !== 0) ? ret60 / k60 : null; const slope20 = (rsRatio5d !== null && rsRatio20d !== null) ? parseFloat(((rsRatio20d - rsRatio5d) / 15).toFixed(4)) : (ret20 !== null && Number.isFinite(k20) ? parseFloat(((ret20 - k20) / 20).toFixed(4)) : null); const slope60 = (rsRatio20d !== null && rsRatio60d !== null) ? parseFloat(((rsRatio60d - rsRatio20d) / 40).toFixed(4)) : (ret60 !== null && Number.isFinite(k60) ? parseFloat(((ret60 - k60) / 60).toFixed(4)) : null); const brtMethod = (rsRatio5d !== null && rsRatio20d !== null) ? "RS_RATIO_MULTI_WINDOW_PROXY" : "PROXY_FROM_RET20_RET60"; let verdict = "UNKNOWN"; if (excessDrawdown !== null && rec20 !== null && slope20 !== null) { if (excessDrawdown >= 10 && (rec20 < 0.50 || (slope60 !== null && slope60 < 0))) verdict = "BROKEN"; else if (excessDrawdown <= 0 && rec20 >= 1.20 && slope20 > 0) verdict = "LEADER"; else if (excessDrawdown >= 5 || rec20 < 0.80 || slope20 < 0) verdict = "LAGGARD"; else verdict = "MARKET"; } return { stock_drawdown_from_high_pct: stockDrawdown, excess_drawdown_pctp: excessDrawdown, recovery_ratio_5d: rec5, recovery_ratio_20d: rec20, downside_beta: downsideBeta, rs_ratio_5d: rsRatio5d, rs_ratio_20d: rsRatio20d, rs_ratio_60d: rsRatio60d, rs_line_20d_slope: slope20, rs_line_60d_slope: slope60, brt_verdict: verdict, brt_method: brtMethod, }; } function fuseRsVerdictV2_(rsV1, brtVerdict) { const v1 = rsV1 || "UNKNOWN"; const brt = brtVerdict || "UNKNOWN"; if (brt === "BROKEN" && v1 === "LEADER") return "LAGGARD"; if (v1 === "BROKEN" || brt === "BROKEN") return "BROKEN"; if (brt === "LEADER" && v1 === "LAGGARD") return "MARKET"; if (v1 === "LAGGARD" || brt === "LAGGARD") return "LAGGARD"; if (v1 === "LEADER" && brt === "LEADER") return "LEADER"; if (v1 === "UNKNOWN" && brt === "UNKNOWN") return "UNKNOWN"; return "MARKET"; } function calcSatelliteAlphaQualityGate_(args) { if (args.position_type === "core") { return { saqg_v1: "EXEMPT", saqg_penalty: 0, saqg_failed_filters: "" }; } if (args.ss001_grade === "D" || args.rs_verdict === "BROKEN") { return { saqg_v1: "EXCLUDED", saqg_penalty: 99, saqg_failed_filters: args.ss001_grade === "D" ? "D_GRADE" : "RS_BROKEN" }; } const failed = []; let penalty = 0; const coreFailures = []; if (!(Number.isFinite(args.ret20D) && Number.isFinite(args.kospiRet20D) && args.ret20D > args.kospiRet20D)) { failed.push("F1_relative_return"); coreFailures.push("F1"); penalty += 2; } if (!((Number.isFinite(args.recovery_ratio_20d) && args.recovery_ratio_20d >= 1.20) || (Number.isFinite(args.recovery_ratio_5d) && args.recovery_ratio_5d >= 1.30))) { failed.push("F2_recovery_power"); coreFailures.push("F2"); penalty += 2; } if (!(Number.isFinite(args.excess_drawdown_pctp) && args.excess_drawdown_pctp <= 5)) { failed.push("F3_downside_protection"); coreFailures.push("F3"); penalty += 2; } if (!(Number.isFinite(args.frg5) && args.frg5 > 0 || Number.isFinite(args.inst5) && args.inst5 > 0)) { failed.push("F4_institutional_flow"); penalty += 1; } if (!["LEADER", "MARKET"].includes(args.rs_verdict)) { failed.push("F5_sector_leadership"); penalty += 1; } let status = "ELIGIBLE"; if (penalty >= 3 || coreFailures.length >= 2) status = "EXCLUDED"; else if (penalty > 0) status = "WATCHLIST_ONLY"; return { saqg_v1: status, saqg_penalty: penalty, saqg_failed_filters: failed.join("|") }; } // ── Gates & scores: entry sizing, breakout/anti-climax/leader/RW gates, // FLOW_CREDIT, SS001, TP ladder, position monitoring, F4 trailing stop ──── function _addTickerGates_(ctx, trailingStopUpdates) { const { t, price, flow, posRec, preReads, targetPrice, epsRevisionStatus, epsGrowth1y, valuation, frg5, inst5, frg20, inst20, indiv5, heatCaution, high52W } = ctx; const { positionStopMap_, riskBudget_, totalAssetKrw_, bayesian, globalRegimePrelim_, globalKospiRet10D_, globalKospiRet20D_, csRsPctMap_, sectorFlowData_, globalHeatPct_ } = preReads; // ── 진입가·손절가·기대우위 추정 (Bayesian multiplier) ───────────────── const limitPriceEst = price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20) ? Math.round(price.close + price.atr20 * 0.05) : ""; const stopPriceActual = posRec ? posRec.stop_price : null; const stopPriceEst = stopPriceActual != null ? stopPriceActual : (price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20) ? Math.round(Math.max(price.close * 0.92, price.close - price.atr20 * THRESHOLDS.ATR_STOP_MULT)) : ""); const stopPriceSource = stopPriceActual != null ? "account_snapshot" : "ATR추정"; let eeEst = ""; if (bayesian.bayesian_multiplier > 0 && limitPriceEst !== "" && stopPriceEst !== "" && limitPriceEst > stopPriceEst && Number.isFinite(targetPrice) && targetPrice > limitPriceEst) { eeEst = ((targetPrice - limitPriceEst) / (limitPriceEst - stopPriceEst) * bayesian.bayesian_multiplier - 0.003).toFixed(2); } else if (bayesian.bayesian_multiplier === 0) { eeEst = "0 (no_bet)"; } // ── Pos_Size_Qty 추정: POSITION_SIZE_V1 간략 버전 ───────────────────── let posSizeQty = ""; let posConstraint = ""; if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0 && price.ok && Number.isFinite(price.atr20) && price.atr20 > 0 && Number.isFinite(price.close) && price.close > 0 && bayesian.bayesian_multiplier > 0) { const atrQty = Math.floor(totalAssetKrw_ * riskBudget_ * bayesian.bayesian_multiplier / (price.atr20 * THRESHOLDS.ATR_STOP_MULT)); const weightQty = Math.floor(totalAssetKrw_ * 0.05 / price.close); let rawQty = Math.max(0, Math.min(atrQty, weightQty)); const bindingLabel = atrQty <= weightQty ? `ATR(${atrQty}주)` : `Weight(${weightQty}주)`; if (heatCaution && rawQty > 0) { rawQty = Math.max(1, Math.floor(rawQty * 0.5)); posConstraint = `${bindingLabel}→Heat감액(${globalHeatPct_}%)`; } else { posConstraint = bindingLabel; } posSizeQty = rawQty; } // ── Breakout Pilot Score ─────────────────────────────────────────────── const priceDev = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && price.ma20 > 0 ? (price.close / price.ma20 - 1) * 100 : null; const vsTerm = price.ok && Number.isFinite(price.valSurge) ? price.valSurge / 10 : 0; const netFlowRatio = flow.ok && Number.isFinite(price.avgVolume5D) && price.avgVolume5D > 0 ? Math.min(5, Math.max(-5, (frg5 + inst5) / price.avgVolume5D)) : 0; const breakoutScore = Number.isFinite(priceDev) ? parseFloat((priceDev + vsTerm + netFlowRatio).toFixed(1)) : ""; const breakoutGate = breakoutScore !== "" ? (breakoutScore > 15 ? "ALLOW" : "WAIT") : ""; // ── anti_climax_buy_gate S1~S5 ──────────────────────────────────────── const ret5Dval = price.ok && Number.isFinite(parseFloat(price.ret5D)) ? parseFloat(price.ret5D) : null; const ac_s1 = Number.isFinite(ret5Dval) ? (ret5Dval >= 25 ? 1 : 0) : 0; const ac_s2 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0 ? (price.avgTradingValue5D >= price.avgTradingValue20D * 3.0 ? 1 : 0) : 0; const hlRange = price.ok && Number.isFinite(price.high) && Number.isFinite(price.low) ? price.high - price.low : 0; const ac_s3 = price.ok && Number.isFinite(price.high) && Number.isFinite(price.close) && hlRange > 0 ? ((price.high - price.close) / hlRange >= 0.35 ? 1 : 0) : 0; const ac_s4 = flow.ok && frg5 < 0 && inst5 < 0 ? 1 : 0; const ac_s5 = flow.ok && indiv5 > 0 && (frg5 < 0 || inst5 < 0) ? 1 : 0; const ac_total = ac_s1 + ac_s2 + ac_s3 + ac_s4 + ac_s5; const ac_gate = ac_total >= 3 ? "BLOCK" : ac_total === 2 ? "CAUTION" : "CLEAR"; // ── daily_leader_scan C1~C5 자동 계산 ───────────────────────────────── const c1 = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && Number.isFinite(price.high) && Number.isFinite(price.low) ? (price.close >= price.ma20 && price.close >= (price.high - (price.high - price.low) * 0.3) ? 1 : 0) : 0; const ret10DKospi = globalKospiRet10D_ ?? null; const c2 = price.ok && Number.isFinite(price.ret10D) && Number.isFinite(ret10DKospi) ? ((price.ret10D - ret10DKospi) >= 3 ? 1 : 0) : 0; const c3 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0 ? (price.avgTradingValue5D >= price.avgTradingValue20D * 1.5 ? 1 : 0) : 0; const c4 = flow.ok && (frg5 > 0 || inst5 > 0) ? 1 : 0; // C5: Tier_1 + Rotation_Rank<=3 -> 1.0 / Tier_1+rank>3 or Tier_2 -> 0.5 / Tier_3 -> 0 const tickerSector = TICKER_SECTOR_MAP[t.code] ?? null; const sfSector = tickerSector ? (sectorFlowData_[tickerSector] ?? null) : null; const sectorRank = sfSector?.rank ?? null; const sectorTier = tickerSector ? (SECTOR_TIER_MAP[tickerSector] ?? "Tier_3") : "Tier_3"; let c5; if (sectorTier === "Tier_1") { c5 = sectorRank !== null ? (sectorRank <= 3 ? 1.0 : 0.5) : 0.5; } else if (sectorTier === "Tier_2") { c5 = 0.5; } else { c5 = 0; } const leaderTotal = c1 + c2 + c3 + c4 + c5; const leaderGate = leaderTotal >= 4 ? "EXPLORE_CANDIDATE" : leaderTotal >= 3 ? "WATCH_ONLY" : "BELOW_THRESHOLD"; // ── 상대약세 청산 신호 RW1~RW5 자동 계산 ─────────────────────────────── const etfRet10D = sfSector?.etfRet10D ?? null; const rw1 = sfSector?.rw1 ?? 0; const rw2 = price.ok && Number.isFinite(price.ret10D) && Number.isFinite(etfRet10D) ? ((price.ret10D - etfRet10D) <= -5 ? 1 : 0) : 0; const rw3 = sfSector?.rw3 ?? 0; const rw4 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0 ? (price.avgTradingValue5D / price.avgTradingValue20D <= 0.60 ? 1 : 0) : 0; const rw5 = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && Number.isFinite(price.ma60) ? (price.close < price.ma20 && price.close < price.ma60 ? 1 : 0) : 0; const rw_partial = rw1 + rw2 + rw3 + rw4 + rw5; // ── FLOW_CREDIT_V1 ──────────────────────────────────────────────────── const fc_c1 = price.ok && Number.isFinite(price.close) && ((Number.isFinite(price.open) && price.close >= price.open) || (Number.isFinite(price.prevClose) && price.close > price.prevClose)) ? 1 : 0; const fc_c2 = price.ok && Number.isFinite(price.volume) && Number.isFinite(price.avgVolume5D) && price.avgVolume5D > 0 ? (price.volume >= price.avgVolume5D * 1.20 ? 1 : 0) : 0; const fc_c3 = flow.ok && (frg5 + inst5) > 0 ? 1 : 0; const flowCredit = (fc_c1 === 0 && fc_c2 === 0) ? 0 : parseFloat((fc_c1 * 0.30 + fc_c2 * 0.30 + fc_c3 * 0.40).toFixed(2)); // ── TRAILING_STOP_PRICE_V1 (포지션 탭 highest_price_since_entry 기반) ── const posHighest = positionStopMap_[t.code]?.highest_price ?? null; let trailingStopPrice = ""; if (Number.isFinite(posHighest) && posHighest > 0 && price.ok && Number.isFinite(price.atr20)) { trailingStopPrice = Math.round(posHighest - price.atr20 * THRESHOLDS.ATR_STOP_MULT); } // ── SS001 종목 점수 자동 계산 ───────────────────────────────────────── const ss001 = calcSS001Score_({ rsPct20D: csRsPctMap_[t.code] ?? null, avgTV5D: price.avgTradingValue5D, avgTV20D: price.avgTradingValue20D, flowCredit, epsRevisionStatus, regimePrelim: globalRegimePrelim_, isKosdaq: KOSDAQ_TICKERS.has(t.code), sfMedPE: sfSector?.medianPE ?? null, sfMedPBR: sfSector?.medianPBR ?? null, forwardPE: Number.isFinite(valuation.per) ? valuation.per : null, pbrVal: Number.isFinite(valuation.pbr) ? valuation.pbr : null, epsGrowth1y, }); const { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, ss001_norm, ss001_grade, pegVal, pegGate } = ss001; // ── BENCHMARK_RELATIVE_TIMESERIES_V1 — KOSPI 대비 시계열 상대평가 ────────── const brt = calcBenchmarkRelativeTimeseries_(price, high52W, preReads); // ── RS_VERDICT_V1 → RS_VERDICT_V2 — 상대강도 판정 (spec/13_formula_registry.yaml) ── const kospiRet10DForRS = globalKospiRet10D_ ?? null; const stockRet10DForRS = price.ok && Number.isFinite(price.ret10D) ? price.ret10D : null; const excess_ret_10d = (stockRet10DForRS !== null && kospiRet10DForRS !== null) ? parseFloat((stockRet10DForRS - kospiRet10DForRS).toFixed(2)) : null; let rs_verdict_v1_raw; if (excess_ret_10d === null) { rs_verdict_v1_raw = "UNKNOWN"; } else if (excess_ret_10d < -10 && rw_partial >= 3) { rs_verdict_v1_raw = "BROKEN"; } else if (excess_ret_10d < -3 || (excess_ret_10d < 0 && rw_partial >= 3)) { rs_verdict_v1_raw = "LAGGARD"; } else if (excess_ret_10d > 3 && flowCredit >= 0.6) { rs_verdict_v1_raw = "LEADER"; } else { rs_verdict_v1_raw = "MARKET"; } const rs_verdict = fuseRsVerdictV2_(rs_verdict_v1_raw, brt.brt_verdict); // ── COMPOSITE_VERDICT_V1 — SS001 × RS_VERDICT 매트릭스 ─────────────────── const _cvMatrix = { A: { LEADER: "PRIME_CANDIDATE", MARKET: "PRIME_CANDIDATE", LAGGARD: "WATCH_CANDIDATE", BROKEN: "EXIT_REVIEW", UNKNOWN: "WATCH_CANDIDATE" }, B: { LEADER: "PRIME_CANDIDATE", MARKET: "WATCH_CANDIDATE", LAGGARD: "REDUCE_CANDIDATE", BROKEN: "EXIT_REVIEW", UNKNOWN: "WATCH_CANDIDATE" }, C: { LEADER: "WATCH_CANDIDATE", MARKET: "REDUCE_CANDIDATE", LAGGARD: "REDUCE_CANDIDATE", BROKEN: "CLOSE_POSITION", UNKNOWN: "REDUCE_CANDIDATE" }, D: { LEADER: "REDUCE_CANDIDATE", MARKET: "REDUCE_CANDIDATE", LAGGARD: "CLOSE_POSITION", BROKEN: "CLOSE_POSITION", UNKNOWN: "REDUCE_CANDIDATE" }, }; const composite_verdict = _cvMatrix[ss001_grade]?.[rs_verdict] ?? "WATCH_CANDIDATE"; const saqg = calcSatelliteAlphaQualityGate_({ position_type: posRec?.position_type ?? "satellite", ss001_grade, ret20D: price.ok && Number.isFinite(price.ret20D) ? price.ret20D : null, kospiRet20D: globalKospiRet20D_, recovery_ratio_5d: brt.recovery_ratio_5d, recovery_ratio_20d: brt.recovery_ratio_20d, excess_drawdown_pctp: brt.excess_drawdown_pctp, frg5, inst5, rs_verdict, }); // ── TAKE_PROFIT_LADDER_V1 ───────────────────────────────────────────── let tp1Price = "", tp1Qty = "", tp2Price = "", tp2Qty = ""; let timeStopDate = "", daysToTimeStop = ""; if (posRec && Number.isFinite(posRec.entry_price) && Number.isFinite(posRec.quantity) && posRec.quantity > 0) { const avgCost = posRec.entry_price; const heldQty = posRec.quantity; if (posRec.position_type === "core") { const q1 = Math.floor(heldQty * 0.25); const q2 = Math.floor((heldQty - q1) * 0.40); tp1Price = Math.round(avgCost * THRESHOLDS.TP_CORE_1); tp1Qty = q1; tp2Price = Math.round(avgCost * THRESHOLDS.TP_CORE_2); tp2Qty = q2; } else { const q1 = Math.floor(heldQty * 0.50); const q2 = Math.floor((heldQty - q1) * 0.50); tp1Price = Math.round(avgCost * THRESHOLDS.TP_SAT_1); tp1Qty = q1; tp2Price = Math.round(avgCost * THRESHOLDS.TP_SAT_2); tp2Qty = q2; } // Time_Stop: stage_1=60D, stage_2=30D const stageLimit = posRec.entry_stage === "stage_2" ? THRESHOLDS.TIME_STOP_STAGE2 : THRESHOLDS.TIME_STOP_STAGE1; // 기본 60일 if (posRec.entry_date) { try { const entryMs = new Date(posRec.entry_date).getTime(); if (!isNaN(entryMs)) { const tsMs = entryMs + stageLimit * 86400000; timeStopDate = Utilities.formatDate(new Date(tsMs), "Asia/Seoul", "yyyy-MM-dd"); daysToTimeStop = Math.round((tsMs - Date.now()) / 86400000); } } catch(_) {} } } // ── 포지션 모니터링 (Weight_Pct / Profit_Pct / PnL / Stage2_Gate / Band_Status) ── let weightPct = "", profitPct = "", unrealizedPnl = "", stage2Gate = "", bandStatus = ""; let corePctDelta = 0, satPctDelta = 0; if (posRec && Number.isFinite(posRec.entry_price) && Number.isFinite(posRec.quantity) && posRec.quantity > 0) { const ep = posRec.entry_price; const qty = posRec.quantity; const cl = price.ok && Number.isFinite(price.close) ? price.close : null; if (cl !== null && Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) { weightPct = parseFloat(((cl * qty) / totalAssetKrw_ * 100).toFixed(2)); } if (cl !== null) { profitPct = parseFloat(((cl - ep) / ep * 100).toFixed(2)); unrealizedPnl = Math.round((cl - ep) * qty); } if (posRec.entry_stage === "stage_1" && cl !== null) { stage2Gate = ((cl - ep) / ep * 100 >= THRESHOLDS.STAGE2_GATE_MIN_PCT) ? "PASS" : "PENDING"; } else if (posRec.entry_stage) { stage2Gate = "N/A"; } if (weightPct !== "" && posRec.position_type !== "core") { bandStatus = weightPct > THRESHOLDS.SAT_BAND_MAX ? "OVERWEIGHT" : weightPct >= 3 ? "IN_BAND" : "UNDERWEIGHT"; } else if (weightPct !== "") { bandStatus = "CORE_" + (weightPct > 10 ? "HIGH" : weightPct >= 3 ? "MID" : "LOW"); } if (weightPct !== "") { if (posRec.position_type === "core") corePctDelta = weightPct; else satPctDelta = weightPct; } } // ── F4 Trailing Stop 갱신 대기열 ───────────────────────────────────── if (posRec && posRec.quantity > 0 && price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)) { const curHigh = Number.isFinite(posRec.highest_price) ? posRec.highest_price : 0; const curStop = Number.isFinite(posRec.stop_price) ? posRec.stop_price : 0; const entryPrice = Number.isFinite(posRec.entry_price) ? posRec.entry_price : 0; if (price.close > curHigh) { const newStop = parseFloat((price.close - price.atr20 * THRESHOLDS.ATR_STOP_MULT).toFixed(0)); // PS002: 손절선 단조 상승 보장 if (newStop > curStop && (entryPrice <= 0 || newStop < entryPrice)) { trailingStopUpdates.push({ ticker: t.code, new_highest: price.close, new_stop: newStop }); } } } Object.assign(ctx, { limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint, breakoutScore, breakoutGate, ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate, c1, c2, c3, c4, c5, leaderTotal, leaderGate, rw1, rw2, rw3, rw4, rw5, rw_partial, flowCredit, trailingStopPrice, ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, ss001_norm, ss001_grade, pegVal, pegGate, excess_ret_10d, rs_verdict, composite_verdict, tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop, weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus, corePctDelta, satPctDelta, }); } // ── Decision: F1-F3 timing, sell decision, allowed/final action, reason/params function _addTickerRoute_(ctx) { // THIN_ADAPTER: [unknown] delegated to Python — src/quant_engine/inject_computed_harness.py:calc_semiconductor_cluster const { t, price, flow, dartSummary, posRec, preReads, priceStatus, isRiskOffRegime, heatBlock, heatCaution, perfBias, liquidityStatus, spreadStatus, stopPriceEst, trailingStopPrice, tp1Price, tp1Qty, tp2Price, profitPct, daysToTimeStop, ac_gate, rw_partial, rw1, rw2, rw3, rw4, rw5, flowCredit, leaderTotal, leaderGate, ss001_grade, ss001_norm, ss001_total, rs_verdict, excess_ret_10d, weightPct, posSizeQty } = ctx; const brt = ctx.brt || { brt_verdict: "UNKNOWN", brt_method: "DATA_MISSING" }; const saqg = ctx.saqg || { saqg_v1: "EXEMPT" }; const { globalRegimePrelim_, globalHeatPct_ } = preReads; // ── F1 기술적 타이밍 지표 ──────────────────────────────────────────── const timing = (price.ok && Array.isArray(price.rows) && price.rows.length >= 21) ? calcTimingMetrics_(price.rows) : {}; const ma20Slope = timing.ma20Slope ?? ""; const disparity = timing.disparity ?? ""; const rsi14 = timing.rsi14 ?? ""; const bbWidth = timing.bbWidth ?? ""; const bbPosition = timing.bbPosition ?? ""; const bbUpper = timing.bbUpper ?? ""; const bbLower = timing.bbLower ?? ""; const cashFloorStatus_ = Number.isFinite(globalHeatPct_) ? (globalHeatPct_ >= 10 ? "HARD_BLOCK" : globalHeatPct_ >= 7 ? "TRIM_REQUIRED" : "PASS") : "PASS"; // ── F2 진입 모드 게이트 ────────────────────────────────────────────── const entryModeResult = (price.ok && Object.keys(timing).length) ? calcEntryMode_(timing, price) : { mode: "NEUTRAL", gate: "PENDING", reason: "데이터부족" }; const entryMode = entryModeResult.mode; const entryModeGate = entryModeResult.gate; const entryModeReason = entryModeResult.reason; // ── F3 매도 타이밍 신호 ────────────────────────────────────────────── const exitSignalDetail = (posRec && posRec.quantity > 0 && price.ok && Object.keys(timing).length) ? calcExitSignalDetail_(timing, price) : ""; const timingRoute = calcTimingRoute_({ priceStatus, atr20: price.atr20, flowCredit, leaderTotal, leaderGate, acGate: ac_gate, rwPartial: rw_partial, entryMode, entryModeGate, exitSignalDetail, rsi14, disparity, ma20Slope, spreadPct: price.spreadPct, avgTradeValue5D: price.avgTradingValue5D, profitPct, daysToTimeStop, }); const timingScoreEntry = timingRoute.entry_score; const timingScoreExit = timingRoute.exit_score; const timingAction = timingRoute.action; const timingBlockReason = timingRoute.reason; const isEtf_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(t.name ?? ""); const sellRoute = calcSellRoute_({ close: price.close, stopPrice: stopPriceEst, trailingStop: trailingStopPrice, tp1Price, tp2Price, profitPct, rwPartial: rw_partial, timingExitScore: timingScoreExit, daysToTimeStop, timingAction, exitSignalDetail, acGate: ac_gate, regime: globalRegimePrelim_ ?? "", atr20: price.atr20, cashFloorStatus: cashFloorStatus_, liquidityStatus: String(price.liquidityStatus ?? price.Liquidity_Status ?? ""), spreadStatus: String(price.spreadStatus ?? price.Spread_Status ?? ""), accountType: posRec?.account_type ?? "", isCoreLeader: indexOfArr_(CORE_TICKERS, t.code) >= 0, isEtf: isEtf_, }); const cashPreservePlan = calcCashPreservationPlan_({ sellAction: sellRoute.action, cashFloorStatus: cashFloorStatus_, regime: globalRegimePrelim_ ?? "", isCoreLeader: indexOfArr_(CORE_TICKERS, t.code) >= 0, isEtf: isEtf_, liquidityStatus: String(price.liquidityStatus ?? price.Liquidity_Status ?? ""), spreadStatus: String(price.spreadStatus ?? price.Spread_Status ?? ""), accountType: posRec?.account_type ?? "", profitPct, rwPartial: rw_partial, reboundHoldbackScore: 0, }); let sellAction = sellRoute.action; let sellRatioPct = sellRoute.ratio_pct; if (sellAction !== "EXIT_100" && sellAction !== "TRAILING_STOP_BREACH" && Number.isFinite(cashPreservePlan.recommended_ratio) && cashPreservePlan.recommended_ratio > 0 && cashPreservePlan.recommended_ratio < sellRatioPct) { sellRatioPct = cashPreservePlan.recommended_ratio; if (sellRatioPct <= 25) sellAction = "TRIM_25"; else if (sellRatioPct <= 33) sellAction = "TRIM_33"; else sellAction = "TRIM_50"; sellRoute.action = sellAction; sellRoute.ratio_pct = sellRatioPct; sellRoute.reason = `${sellRoute.reason}|CASH_PRESERVE:${cashPreservePlan.style}`; } const sellLimitPrice = sellRoute.limit_price; const sellPriceSource = sellRoute.price_source; const sellPriceBasis = sellRoute.price_basis; const sellExecutionWindow = sellRoute.execution_window; const sellOrderType = sellRoute.order_type; const sellReason = sellRoute.reason; const sellValidation = sellRoute.validation; const accountHoldingQty = Number.isFinite(posRec?.account_quantity) ? posRec.account_quantity : ""; const accountAvgCost = Number.isFinite(posRec?.account_average_cost) ? posRec.account_average_cost : ""; const accountMarketValue = Number.isFinite(posRec?.account_market_value) ? posRec.account_market_value : ""; const accountParseStatus = posRec?.account_parse_status ?? ""; // account_snapshot CAPTURE_READ_OK 시 Sell_Qty 직접 산출 const sellQtyCalc = (() => { if (accountParseStatus !== "CAPTURE_READ_OK") return ""; const heldQty = Number.isFinite(posRec?.account_quantity) ? posRec.account_quantity : 0; if (heldQty <= 0 || !sellRatioPct || sellAction === "HOLD") return ""; const availQty = Number.isFinite(posRec?.available_quantity) && posRec.available_quantity > 0 ? posRec.available_quantity : heldQty; return Math.max(1, Math.min(Math.round(heldQty * sellRatioPct / 100), availQty)); })(); const ruleSellQty = (() => { const heldQty = Number.isFinite(accountHoldingQty) ? accountHoldingQty : 0; if (heldQty <= 0 || !sellRatioPct) return ""; const calc = Math.floor(heldQty * sellRatioPct / 100); return calc > 0 ? calc : ""; })(); // ── Allowed_Action ─────────────────────────────────────────────────── // 우선순위: 데이터 품질 → HF005 → DART → 유동성 → REGIME매수차단 → RW청산 → SS001 let action; if (priceStatus !== "PRICE_OK" || !Number.isFinite(price.atr20)) { action = "OBSERVE_ONLY"; } else if (heatBlock) { action = posRec?.quantity > 0 ? "HOLD" : "NO_ADD"; } else if (dartSummary?.risk) { action = "HOLD_NO_ADD"; } else if (!flow.ok || (Number.isFinite(price.avgTradingValue5D) && price.avgTradingValue5D < 50) || (Number.isFinite(price.spreadPct) && price.spreadPct > 0.8)) { action = "NO_ADD"; } else if (timingAction === "STOP_OR_TIME_EXIT_READY" || rw_partial >= 3) { action = "EXIT_SIGNAL"; } else if (timingAction === "EXIT_REVIEW" || rw_partial >= 2) { action = "REVIEW_EXIT"; } else if (timingAction === "NO_BUY_OVERHEATED") { action = "HOLD_NO_ADD"; } else if (isRiskOffRegime) { // Issue 3: RISK_OFF/RISK_OFF_CANDIDATE 레짐에서 신규 매수 차단 action = posRec?.quantity > 0 ? "HOLD" : "NO_ADD"; } else if (saqg.saqg_v1 === "EXCLUDED") { action = "HOLD_NO_ADD"; } else if (saqg.saqg_v1 === "WATCHLIST_ONLY") { action = "WATCH_CANDIDATE"; } else if (ss001_grade === "A" && (timingAction === "BUY_STAGE1_READY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY")) { action = (heatCaution || perfBias.entry_block || perfBias.quantity_multiplier <= 0) ? "WATCH_CANDIDATE" : timingAction; } else if (ss001_grade === "A") { action = "WATCH_CANDIDATE"; } else if (ss001_grade === "B" && timingAction !== "HOLD_NO_TIMING_EDGE") { action = (heatCaution || perfBias.entry_block || perfBias.quantity_multiplier <= 0) ? "WATCH_CANDIDATE" : (timingAction === "WATCH_TIMING_SETUP" ? "WATCH_CANDIDATE" : timingAction); } else if (ss001_grade === "B") { action = "WATCH_CANDIDATE"; } else if (ss001_grade === "C") { action = "HOLD"; } else { action = "HOLD_NO_ADD"; } // ── RAG_V1: CLA 레짐 위성 신규 BUY 알파 게이트 ───────────────────────── const _BUY_ACTIONS_ = new Set(["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY", "BUY_PULLBACK_WAIT"]); let rag_v1 = "EXEMPT", rag_reason = "no_buy_action"; if (_BUY_ACTIONS_.has(action)) { const _rag = validateReplacementAlpha_({ posRec, globalRegimePrelim_, rs_verdict, ss001_norm, excess_ret_10d, }); rag_v1 = _rag.rag_v1; rag_reason = _rag.rag_reason; if (rag_v1 === 'FAIL') action = "HOLD"; } if (saqg.saqg_v1 === "EXCLUDED" || saqg.saqg_v1 === "WATCHLIST_ONLY") { rag_reason = rag_reason === "no_buy_action" ? ("saqg_" + saqg.saqg_v1) : rag_reason + "|saqg_" + saqg.saqg_v1; } const finalRoute = calcFinalRoute_({ sellAction, sellValidation, allowedAction: action, timingAction, timingScoreEntry, timingScoreExit, ss001Total: ss001_total, flowCredit, leaderTotal, rwPartial: rw_partial, profitPct, daysToTimeStop, weightPct, acGate: ac_gate, liquidityStatus, spreadStatus, dartRisk: !!(dartSummary?.risk), missingFields: ctx.missing.length ? ctx.missing.join(" | ") : "", }); const finalAction = finalRoute.final_action; const actionPriority = finalRoute.action_priority; const priorityScore = finalRoute.priority_score; const routeSource = finalRoute.route_source; // ── Action_Reason: "왜 이 액션인가" — B-2 ──────────────────────────── const sellDetailLabel_ = { "EXIT_100": "손절전량(100%)", "TRIM_70": "RW강도매도(70%)", "TRAILING_STOP_BREACH": "트레일링이탈(70%)", "TRIM_50": "RW부분매도(50%)", "TRIM_33": "RW초기경보(33%)", "TRIM_25": "RW약세감지(25%)", "PROFIT_TRIM_50": "익절(50%)", "PROFIT_TRIM_35": "익절(35%)", "PROFIT_TRIM_25": "익절(25%)", "TAKE_PROFIT_TIER1":"TP1익절(25%)", "TIME_EXIT_100": "타임스탑만료(100%)", "TIME_TRIM_50": "타임스탑근접(50%)", "TIME_TRIM_25": "타임스탑예고(25%)", "REGIME_TRIM_50": "레짐포트축소(50%)", }; let actionReason = ""; if (finalAction === "SELL_READY") { const lbl_ = sellDetailLabel_[sellAction] ?? sellAction; actionReason = `${lbl_} ${sellRatioPct}% @${sellLimitPrice}원 [${sellReason}]`; } else if (finalAction === "EXIT_SIGNAL" || finalAction === "EXIT_REVIEW") { const rwItems_ = [rw1&&"RW1",rw2&&"RW2",rw3&&"RW3",rw4&&"RW4",rw5&&"RW5"].filter(Boolean); actionReason = `RW${rw_partial}(${rwItems_.join("+")})${exitSignalDetail ? " "+exitSignalDetail : ""}`; } else if (["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"].includes(finalAction)) { const r_ = Number.isFinite(rsi14) ? rsi14.toFixed(0) : "?"; const d_ = Number.isFinite(disparity) ? disparity.toFixed(1) : "?"; const f_ = Number.isFinite(flowCredit) ? flowCredit.toFixed(2) : "?"; actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점 RSI${r_} 이격${d_}% FC${f_}`; } else if (finalAction === "WATCH_TIMING_SETUP" || action === "WATCH_CANDIDATE") { const why_ = timingBlockReason || entryModeReason || "-"; const perf_ = perfBias.entry_block ? `PERF_BLOCK(${perfBias.reason})` : (perfBias.quantity_multiplier < 1 ? `PERF_CAUTION(${perfBias.reason})` : ""); actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점 타이밍미충족(${why_})${perf_ ? " | " + perf_ : ""}`; } else if (action === "HOLD") { actionReason = heatBlock ? `HeatBlock(${globalHeatPct_}%)` : isRiskOffRegime ? globalRegimePrelim_ : `SS001:${ss001_grade}`; } else if (action === "NO_ADD") { const why_ = []; if (!flow.ok) why_.push("수급이탈"); if (Number.isFinite(price.avgTradingValue5D) && price.avgTradingValue5D < 50) why_.push(`거래대금${price.avgTradingValue5D.toFixed(0)}억`); if (Number.isFinite(price.spreadPct) && price.spreadPct > 0.8) why_.push(`스프레드${price.spreadPct.toFixed(2)}%`); if (isRiskOffRegime) why_.push(globalRegimePrelim_); actionReason = why_.join("|") || "유동성차단"; } else if (action === "HOLD_NO_ADD") { if (dartSummary?.risk) { actionReason = `DART:${dartSummary.risk}`; } else if (timingAction === "NO_BUY_OVERHEATED" || ac_gate === "BLOCK") { actionReason = `과열(${timingBlockReason||ac_gate||""})`; } else { actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점`; } } else if (action === "OBSERVE_ONLY") { actionReason = `DATA_UNAVAIL(${priceStatus})`; } // ── C-3: Action_Params — 실행 파라미터 압축 ────────────────────────── let actionParams = ""; if (finalAction === "SELL_READY") { const ratio_ = Number.isFinite(sellRatioPct) ? `${sellRatioPct}%` : "?%"; const price_ = Number.isFinite(sellLimitPrice) ? `@${sellLimitPrice.toLocaleString()}원` : "@?원"; const win_ = sellExecutionWindow || ""; const ord_ = sellOrderType || ""; actionParams = [ratio_, price_, win_, ord_].filter(Boolean).join(" | "); } else if (finalAction === "EXIT_SIGNAL" || finalAction === "EXIT_REVIEW") { actionParams = "RW신호 — 캡처 후 수량 확인"; } else if (["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"].includes(finalAction)) { const qty_ = Number.isFinite(posSizeQty) ? `목표 ${posSizeQty}주` : ""; const stop_ = Number.isFinite(stopPriceEst) ? `손절 ${stopPriceEst.toLocaleString()}원` : ""; const tp1_ = Number.isFinite(tp1Price) ? `TP1 ${tp1Price.toLocaleString()}원(${tp1Qty ?? "?"}주)` : ""; const perf_ = perfBias.quantity_multiplier < 1 ? `PF_${perfBias.reason}:${perfBias.quantity_multiplier}x` : ""; actionParams = [qty_, stop_, tp1_, perf_].filter(Boolean).join(" | "); } else if (finalAction === "WATCH_TIMING_SETUP") { const perf_ = perfBias.entry_block ? `PERF_BLOCK(${perfBias.reason})` : perfBias.quantity_multiplier < 1 ? `PERF_CAUTION(${perfBias.reason})` : ""; actionParams = [`대기 — ${timingBlockReason || entryModeReason || "-"}`, perf_].filter(Boolean).join(" | "); } Object.assign(ctx, { ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower, entryMode, entryModeGate, entryModeReason, exitSignalDetail, timingScoreEntry, timingScoreExit, timingAction, timingBlockReason, sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis, sellExecutionWindow, sellOrderType, sellReason, sellValidation, cashPreservePlan, accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus, ruleSellQty, finalAction, actionPriority, priorityScore, routeSource, actionReason, actionParams, action, brt, saqg, rag_v1, rag_reason, }); } function buildTickerRowV2_(t, preReads, trailingStopUpdates) { const ctx = _tickerSetup_(t, preReads); _addTickerFundamentals_(ctx); _addTickerGates_(ctx, trailingStopUpdates); _addTickerRoute_(ctx); const { flow, price, valuation, dartSummary, frg5, inst5, indiv5, frg20, inst20, priceStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus, liquidityStatus, spreadStatus, today, positionCountStatus_, weeklyTargetCashPct_, epsRevisionStatus, epsGrowth1y, divYield, dps, beta, high52W, low52W, pct52WHigh, pctFrom52WLow, targetPrice, upsidePct, earningsDateStr, daysToEarnings, exDividendDateStr, daysToExDiv, roePct, opMarginPct, debtToEquity, currentRatio, fcfB, ocfB, revenueGrowthPct, limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint, breakoutScore, breakoutGate, ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate, c1, c2, c3, c4, c5, leaderTotal, leaderGate, rw1, rw2, rw3, rw4, rw5, rw_partial, flowCredit, trailingStopPrice, ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, ss001_norm, ss001_grade, pegVal, pegGate, excess_ret_10d, rs_verdict_v1_raw, rs_verdict, composite_verdict, brt, saqg, tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop, weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus, corePctDelta, satPctDelta, ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower, entryMode, entryModeGate, entryModeReason, exitSignalDetail, timingScoreEntry, timingScoreExit, timingAction, timingBlockReason, sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis, sellExecutionWindow, sellOrderType, sellReason, sellValidation, cashPreservePlan, accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus, ruleSellQty, finalAction, actionPriority, priorityScore, routeSource, actionReason, actionParams, action, missing, next, rag_v1, rag_reason, } = ctx; const row = [ // ── 기본 수급·가격 (11 + 29 = 40) ────────────────────────────────── t.code, t.name, flow.rows[0]?.date ?? today, frg5, inst5, indiv5, frg20, inst20, flow.ok ? "Y" : "N", String(flow.rows.length), today, priceStatus, price.ok ? price.close : "", price.ok && Number.isFinite(price.open) ? price.open : "", price.ok && Number.isFinite(price.prevClose) ? price.prevClose : "", price.ok && Number.isFinite(price.high) ? price.high : "", price.ok && Number.isFinite(price.low) ? price.low : "", price.ok && Number.isFinite(price.volume) ? price.volume : "", price.ok && Number.isFinite(price.avgVolume5D) ? Math.round(price.avgVolume5D) : "", price.ok && Number.isFinite(price.ma20) ? price.ma20.toFixed(2) : "", price.ok && Number.isFinite(price.ma60) ? price.ma60.toFixed(2) : "", price.ok && Number.isFinite(price.ret5D) ? parseFloat(price.ret5D).toFixed(2) : "", // Ret5D price.ok && Number.isFinite(price.ret10D) ? price.ret10D.toFixed(2) : "", price.ok && Number.isFinite(price.ret20D) ? price.ret20D.toFixed(2) : "", price.ok && Number.isFinite(price.ret60D) ? price.ret60D.toFixed(2) : "", price.ok ? Math.round(price.atr20) : "", price.ok && Number.isFinite(price.atr20Pct) ? price.atr20Pct.toFixed(2) : "", price.ok && Number.isFinite(price.valSurge) ? price.valSurge.toFixed(1) : "", Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D.toFixed(2) : "", Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D.toFixed(2) : "", Number.isFinite(price.avgTradingValue5D) ? Math.round(price.avgTradingValue5D * 1000000) : "", Number.isFinite(price.avgTradingValue20D) ? Math.round(price.avgTradingValue20D * 1000000) : "", "KRW", Number.isFinite(price.bid) ? price.bid : "N/A", Number.isFinite(price.ask) ? price.ask : "N/A", Number.isFinite(price.spreadPct) ? price.spreadPct.toFixed(2) : "N/A", spreadStatus, price.source ?? "N/A", price.quoteSource ?? "QUOTE_NO_MATCH", price.quoteStatus ?? "QUOTE_NO_MATCH", liquidityStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus, dartSummary.status, dartSummary.source, dartSummary.catalyst, dartSummary.risk, // ── 밸류에이션 (5+9+4) ───────────────────────────────────────────── Number.isFinite(valuation.per) ? valuation.per : "", Number.isFinite(valuation.pbr) ? valuation.pbr : "", Number.isFinite(valuation.eps) ? valuation.eps : "", epsRevisionStatus, epsGrowth1y != null ? epsGrowth1y : "", // EPS_Growth_1Y_Pct Number.isFinite(divYield) ? Number(divYield).toFixed(2) : (divYield !== "" ? divYield : ""), dps != null ? dps : "", // DPS Number.isFinite(beta) ? Number(beta).toFixed(2) : "", Number.isFinite(high52W) ? high52W : "", Number.isFinite(low52W) ? low52W : "", pct52WHigh, pctFrom52WLow, Number.isFinite(targetPrice) ? Math.round(targetPrice) : "", upsidePct, earningsDateStr ?? "", daysToEarnings !== "" ? daysToEarnings : "", exDividendDateStr ?? "", // Ex_Dividend_Date daysToExDiv !== "" ? daysToExDiv : "", // Days_To_Ex_Div // ── 재무 건전성 (7) (FINANCIAL_HEALTH_V1 + OCF_B 추가, 7일 캐시) ───────── roePct != null ? Number(roePct).toFixed(1) : "", opMarginPct != null ? Number(opMarginPct).toFixed(1) : "", debtToEquity != null ? Number(debtToEquity).toFixed(1) : "", currentRatio != null ? Number(currentRatio).toFixed(2) : "", fcfB != null ? Number(fcfB).toFixed(1) : "", ocfB != null ? Number(ocfB).toFixed(1) : "", // OCF_B (억원) revenueGrowthPct != null ? Number(revenueGrowthPct).toFixed(1) : "", // ── 진입가·손절가·기대우위·수량 추정 (5) ────────────────────────── limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint, // ── 익절 사다리·타임스탑 (6) ───────────────────────────────────── tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop, // ── 포지션 모니터링 (6) ─────────────────────────────────────────── weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus, positionCountStatus_ ?? "", // Position_Count_Status // ── F1 기술적 타이밍 지표 (7) ──────────────────────────────────── ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower, // ── F2 진입 모드 게이트 (3) ────────────────────────────────────── entryMode, entryModeGate, entryModeReason, // ── F3 매도 타이밍 신호 (1) ────────────────────────────────────── exitSignalDetail, // ── F5 타이밍 종합 액션 (4) ────────────────────────────────────── timingScoreEntry, timingScoreExit, timingAction, timingBlockReason, // ── F6 매도 액션·수량·가격 (10) ───────────────────────────────── sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis, sellExecutionWindow, sellOrderType, sellReason, sellValidation, cashPreservePlan.style, cashPreservePlan.recommended_ratio, cashPreservePlan.reasons, // ── F6A 계좌 캡처·주간 리밸런싱 검증 (10) ─────────────────────── accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus, ruleSellQty, weeklyTargetCashPct_ ?? "", "", "", "", "", // ── F7 최종 룰엔진 액션·우선순위 (5) ──────────────────────────── finalAction, actionPriority, priorityScore, "", routeSource, // ── 수급·점수 자동 계산 (13: SS001_Norm_Score 추가) ─────────────────── flowCredit, trailingStopPrice, ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, parseFloat(ss001_norm.toFixed(1)), ss001_grade, pegVal, pegGate, // ── Breakout 게이트 (2) ──────────────────────────────────────────── breakoutScore, breakoutGate, // ── anti_climax_buy_gate (7) ────────────────────────────────────── ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate, // ── daily_leader_scan C1~C5 (7) ─────────────────────────────────── c1, c2, c3, c4, c5, leaderTotal, leaderGate, // ── RW 청산 신호 (6) ────────────────────────────────────────────── rw1, rw2, rw3, rw4, rw5, rw_partial, // ── BRT_V1 + RS_VERDICT_V2 + COMPOSITE_VERDICT_V1 + SAQG/RAG ────── brt.stock_drawdown_from_high_pct !== null ? brt.stock_drawdown_from_high_pct : "", brt.excess_drawdown_pctp !== null ? brt.excess_drawdown_pctp : "", brt.recovery_ratio_5d !== null ? brt.recovery_ratio_5d : "", brt.recovery_ratio_20d !== null ? brt.recovery_ratio_20d : "", brt.downside_beta !== null ? brt.downside_beta : "", brt.rs_line_20d_slope !== null ? brt.rs_line_20d_slope : "", brt.rs_line_60d_slope !== null ? brt.rs_line_60d_slope : "", brt.brt_verdict, brt.brt_method, excess_ret_10d !== null ? excess_ret_10d : "", rs_verdict_v1_raw, rs_verdict, composite_verdict, saqg.saqg_v1, saqg.saqg_penalty, saqg.saqg_failed_filters, rag_v1, rag_reason, // ── 데이터 품질 (5) ─────────────────────────────────────────────── missing.length ? missing.join(" | ") : "", next.length ? next.join(" | ") : "", actionReason, actionParams, action ]; return { row, corePctDelta, satPctDelta }; } // ── 1일 수익률 보조 함수 ────────────────────────────────────────────────────── function fetchYahooPrice1D(sym) { const cacheKey = `yahoo_price1d_${sym}`; const cached = getCachedFetchResult_(cacheKey); if (cached) return cached; if (isFetchCircuitOpen_("yahoo_chart")) return "N/A"; if (!consumeFetchBudget_("yahoo_chart", sym)) return "N/A"; sym = sym.replace(/\^/g, "%5E"); const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=5d`; try { const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); if (resp.getResponseCode() !== 200) { recordFetchFailure_("yahoo_chart"); cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure); return "N/A"; } const data = JSON.parse(resp.getContentText()); const closes = data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? []; if (closes.length < 2) { recordFetchFailure_("yahoo_chart"); cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure); return "N/A"; } const last = closes[closes.length-1]; const prev = closes[closes.length-2]; const result = ((last/prev-1)*100).toFixed(2); cacheJsonSet_(cacheKey, result, FETCH_GOVERNANCE.ttl.yahoo_chart_ok); recordFetchSuccess_("yahoo_chart"); return result; } catch(e) { recordFetchFailure_("yahoo_chart"); cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure); return "N/A"; } } // ── Core Satellite 청크 실행 ──────────────────────────────────────────── // 위성 후보군 스크리닝용 출력. 보유 종목 완성도 매트릭스는 data_feed가 본체. // 100종목 이상 한 번에 실행하면 6분 제한 초과 위험 → 50종목씩 청크로 분할 (현재 유니버스 약 40여 개는 1번 실행에 완료됨) // 트리거: runCoreSatelliteChunk → 매일 17:00~18:00, 별도 스크립트 프로젝트 권장 const CHUNK_SIZE = 50; function runCoreSatelliteBatch() { if (typeof isRunAllOrchestrated_ === "function" && isRunAllOrchestrated_()) { if (typeof setFetchSessionLabel_ === "function") { setFetchSessionLabel_("runCoreSatelliteBatch"); } } else { beginFetchSession_("runCoreSatelliteBatch"); } const props = PropertiesService.getScriptProperties(); const universe = getCoreSatelliteUniverse(); // 아래 함수 참조 const totalChunks = Math.ceil(universe.length / CHUNK_SIZE); const TIMEOUT_BUDGET_SEC = 210; const startTime = new Date().getTime(); let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10); let rowIdx = parseInt(props.getProperty("cs_row_idx") ?? "0", 10); const schemaVersion = props.getProperty("cs_schema_version") ?? ""; if (chunkIdx === 0 || schemaVersion !== SCHEMA_VERSION) { resetCoreSatelliteChunks(); props.setProperty("cs_schema_version", SCHEMA_VERSION); chunkIdx = 0; rowIdx = 0; } if (chunkIdx >= totalChunks) { // 모든 청크 완료 → unified 탭 업데이트 후 리셋 runCoreSatelliteFinalize(); props.setProperty("cs_chunk_idx", "0"); writeCoreSatelliteStatus_("FINALIZED", universe.length, universe.length, totalChunks, 0, "all chunks already complete"); Logger.log("core_satellite: 모든 청크 완료 → finalize"); return; } const slice = universe.slice(chunkIdx * CHUNK_SIZE, (chunkIdx+1) * CHUNK_SIZE); const dataFeedMap = buildDataFeedPriceMap(); const dataFeedSellMap_ = buildDataFeedSellMap_(); const sheetName = `cs_chunk_${chunkIdx}`; const headers = [ "Ticker","Name","Sector", "Price_Date","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D","MA20","MA60","Ret10D","Ret20D","Ret60D","Price_Source","ATR20","ATR20_Pct","Val_Surge_Pct","AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit","Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status", "Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows", "ETF_Ret5D","Rotation_Score","Alert_Level","Smart_Money", "DART_Status","DART_Source","DART_Catalyst","DART_Risk", "Missing_Fields","Next_Source_To_Check","Allowed_Action", "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation", "Action_Reason","Action_Params","Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason", "Timing_Action","Timing_Score_Entry","Timing_Score_Exit","Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason","Exit_Signal_Detail", "Candidate_Quality_Grade","T1_Forced_Sell_Risk_Score","T1_Forced_Sell_Risk_State","T1_Forced_Sell_Risk_Reason", "Sell_Conflict_Score","Sell_Conflict_State","Sell_Conflict_Reason","Execution_Recommendation_State","Execution_Recommendation_Reason", "ChunkIdx","AsOfDate", "RS_Rank_20D","RS_Pct_20D" ]; const rows = []; const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); if (rowIdx > 0) { const existingSheet = getSpreadsheet_().getSheetByName(sheetName); if (existingSheet) { const existingData = existingSheet.getDataRange().getValues(); if (existingData.length > 2) { for (const row of existingData.slice(2)) { const normalized = Array.isArray(row) ? row.slice(0, headers.length) : []; while (normalized.length < headers.length) normalized.push(""); rows.push(normalized); } } } } if (rowIdx >= slice.length) { props.setProperty("cs_row_idx", "0"); props.setProperty("cs_chunk_idx", String(chunkIdx + 1)); return runCoreSatelliteBatch(); } for (; rowIdx < slice.length; rowIdx++) { const t = slice[rowIdx]; const flow = fetchNaverFlow(t.code); const price = resolveSatellitePriceMetrics(t.code, dataFeedMap); const notices = fetchNaverDisclosureNotices(t.code); const dartSummary = summarizeDisclosureNotices(notices); const frg5 = flow.rows.slice(0,5).reduce((s,r)=>s+r.frgn, 0); const inst5 = flow.rows.slice(0,5).reduce((s,r)=>s+r.inst, 0); const frg20 = flow.rows.reduce((s,r)=>s+r.frgn, 0); const inst20= flow.rows.reduce((s,r)=>s+r.inst, 0); const indiv5= -(frg5+inst5); Utilities.sleep(400); const score = calcRotationScore(frg5, inst5, frg20, inst20, indiv5, null); const alert = calcAlert(score, frg5, inst5); const smart = calcSmartMoney(frg5, inst5, indiv5); const priceStatus = price.ok ? "PRICE_OK" : "PRICE_MISSING"; const liquidityStatus = calcLiquidityStatus(Number(price.avgTradingValue5D)); const spreadStatus = calcSpreadStatus(Number(price.spreadPct)); const valSurgeStatus = calcValSurgeStatus(price.valSurge); const missing = []; if (!flow.ok) missing.push("FLOW"); if (!price.ok) missing.push("ATR20"); if (dartSummary.status === "NAVER_NOTICE_EMPTY" || String(dartSummary.status).startsWith("NAVER_NOTICE_ERROR")) missing.push("DART"); const next = []; if (missing.includes("ATR20")) next.push("Yahoo Finance chart"); if (missing.includes("DART")) next.push("Naver 공시공지"); if (missing.includes("FLOW")) next.push("Naver frgn.naver"); const action = buildAllowedAction(score, priceStatus, price.atr20, dartSummary, flow.ok, price.avgTradingValue5D, price.spreadPct); const sellRow_ = dataFeedSellMap_[normalizeTickerCode(t.code)] ?? null; const sellFinal_ = String(sellRow_?.Final_Action ?? "").trim(); const sellAction_ = String(sellRow_?.Sell_Action ?? "").trim(); const sellHasSignal_ = sellFinal_ === "SELL_READY" || sellFinal_ === "EXIT_SIGNAL" || sellFinal_ === "EXIT_REVIEW"; const sellValidation_ = String(sellRow_?.Sell_Validation ?? "").trim() || (sellHasSignal_ ? "SIGNAL_CONFIRMED" : "SIGNAL_ONLY"); const sellRatio_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Ratio_Pct)) ? Number(sellRow_?.Sell_Ratio_Pct) : 0) : 0; const sellQty_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Qty)) ? Number(sellRow_?.Sell_Qty) : 0) : 0; const sellLimit_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Limit_Price)) ? Number(sellRow_?.Sell_Limit_Price) : "") : ""; const actionReason_ = sellHasSignal_ ? String(sellRow_?.Action_Reason ?? "") : ""; const actionParams_ = sellHasSignal_ ? String(sellRow_?.Action_Params ?? "") : ""; const cashStyle_ = sellHasSignal_ ? String(sellRow_?.Cash_Preserve_Style ?? "NONE") : "NONE"; const cashRatio_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Cash_Preserve_Ratio)) ? Number(sellRow_?.Cash_Preserve_Ratio) : 0) : 0; const cashReason_ = sellHasSignal_ ? String(sellRow_?.Cash_Preserve_Reason ?? "") : ""; const timingLocal_ = (price.ok && Array.isArray(price.rows) && price.rows.length >= 21) ? calcTimingMetrics_(price.rows) : {}; const entryLocal_ = (price.ok && Object.keys(timingLocal_).length) ? calcEntryMode_(timingLocal_, price) : { mode: "NEUTRAL", gate: "PENDING", reason: "데이터부족" }; const localTimingRoute_ = calcTimingRoute_({ priceStatus, atr20: price.atr20, flowCredit: "", leaderTotal: "", leaderGate: "", acGate: "", rwPartial: sellRow_?.RW_Partial, entryMode: entryLocal_.mode, entryModeGate: entryLocal_.gate, exitSignalDetail: "", rsi14: timingLocal_.rsi14, disparity: timingLocal_.disparity, ma20Slope: timingLocal_.ma20Slope, spreadPct: price.spreadPct, avgTradeValue5D: price.avgTradingValue5D, profitPct: sellRow_?.Profit_Pct, daysToTimeStop: sellRow_?.Days_To_Time_Stop, }); const timingAction_ = String(sellRow_?.Timing_Action ?? localTimingRoute_.action ?? ""); const timingEntry_ = Number.isFinite(Number(sellRow_?.Timing_Score_Entry)) ? Number(sellRow_?.Timing_Score_Entry) : localTimingRoute_.entry_score; const timingExit_ = Number.isFinite(Number(sellRow_?.Timing_Score_Exit)) ? Number(sellRow_?.Timing_Score_Exit) : localTimingRoute_.exit_score; const entryMode_ = String(sellRow_?.Entry_Mode ?? entryLocal_.mode ?? ""); const entryGate_ = String(sellRow_?.Entry_Mode_Gate ?? entryLocal_.gate ?? ""); const entryReason_ = String(sellRow_?.Entry_Mode_Reason ?? entryLocal_.reason ?? ""); const exitSignalDetail_ = String(sellRow_?.Exit_Signal_Detail ?? ""); const candidateQuality_ = calcCoreCandidateQualityGrade_({ rotationScore: score, flowOk: flow.ok ? "Y" : "N", priceStatus, liquidityStatus, dartRisk: dartSummary.risk, missingFields: missing.join(" | "), }); const t1Risk_ = calcT1ForcedSellRisk_({ sellAction: sellAction_, sellValidation: sellValidation_, timingScoreExit: timingExit_, rwPartial: sellRow_?.RW_Partial, rsi14: timingLocal_.rsi14, disparity: timingLocal_.disparity, valSurgePct: price.valSurge, ret5D: price.ret5D, dartRisk: dartSummary.risk, lateChaseRiskScore: sellRow_ ? sellRow_["Late_Chase_Risk_Score"] : "", distributionRiskScore: sellRow_ ? sellRow_["Distribution_Risk_Score"] : "", }); const sellConflict_ = calcSellConflictScore_({ sellFinal: sellFinal_, sellAction: sellAction_, cashPreserveStyle: cashStyle_, allowedAction: action, }); const executionState_ = calcCoreSatelliteExecutionState_({ candidateQualityGrade: candidateQuality_, timingAction: timingAction_, entryModeGate: entryGate_, t1State: t1Risk_.state, sellConflictState: sellConflict_.state, allowedAction: action, }); const executionReason_ = [ `quality=${candidateQuality_}`, `timing=${timingAction_}`, `t1=${t1Risk_.state}`, `sell_conflict=${sellConflict_.state}`, ].join("|"); rows.push([ t.code, t.name, t.sector ?? "", price.ok ? price.priceDate : today, price.ok ? price.close : "N/A", price.ok && Number.isFinite(price.open) ? price.open : "N/A", price.ok && Number.isFinite(price.prevClose) ? price.prevClose : "N/A", price.ok && Number.isFinite(price.high) ? price.high : "N/A", price.ok && Number.isFinite(price.low) ? price.low : "N/A", price.ok && Number.isFinite(price.volume) ? price.volume : "N/A", price.ok && Number.isFinite(price.avgVolume5D) ? Math.round(price.avgVolume5D) : "N/A", price.ok && Number.isFinite(price.ma20) ? Number(price.ma20).toFixed(2) : "N/A", price.ok && Number.isFinite(price.ma60) ? Number(price.ma60).toFixed(2) : "N/A", price.ok && Number.isFinite(price.ret10D) ? Number(price.ret10D).toFixed(2) : "N/A", price.ok && Number.isFinite(price.ret20D) ? Number(price.ret20D).toFixed(2) : "N/A", price.ok && Number.isFinite(price.ret60D) ? Number(price.ret60D).toFixed(2) : "N/A", price.ok ? String(price.source ?? "") : "N/A", price.ok && Number.isFinite(price.atr20) ? Math.round(price.atr20) : "N/A", price.ok && Number.isFinite(price.atr20Pct) ? Number(price.atr20Pct).toFixed(2) : "N/A", price.ok && Number.isFinite(price.valSurge) ? Number(price.valSurge).toFixed(1) : "N/A", Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D.toFixed(2) : "N/A", Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D.toFixed(2) : "N/A", Number.isFinite(price.avgTradingValue5D) ? Math.round(price.avgTradingValue5D * 1000000) : "N/A", Number.isFinite(price.avgTradingValue20D) ? Math.round(price.avgTradingValue20D * 1000000) : "N/A", "KRW", Number.isFinite(price.bid) ? price.bid : "N/A", Number.isFinite(price.ask) ? price.ask : "N/A", Number.isFinite(price.spreadPct) ? price.spreadPct.toFixed(2) : "N/A", spreadStatus, price.source ?? "N/A", price.quoteSource ?? "QUOTE_NO_MATCH", price.quoteStatus ?? "QUOTE_NO_MATCH", liquidityStatus, frg5, inst5, indiv5, frg20, inst20, flow.ok ? "Y" : "N", String(flow.rows.length), "N/A", score, alert, smart, dartSummary.status, dartSummary.source, dartSummary.catalyst, dartSummary.risk, missing.length ? missing.join(" | ") : "", next.length ? next.join(" | ") : "", action, sellFinal_ || "HOLD", sellAction_ || "HOLD", sellRatio_, sellQty_, sellLimit_, sellValidation_, actionReason_, actionParams_, cashStyle_, cashRatio_, cashReason_, timingAction_, timingEntry_, timingExit_, entryMode_, entryGate_, entryReason_, exitSignalDetail_, candidateQuality_, t1Risk_.score, t1Risk_.state, t1Risk_.reason, sellConflict_.score, sellConflict_.state, sellConflict_.reason, executionState_, executionReason_, String(chunkIdx), today, "", "" // RS_Rank_20D, RS_Pct_20D — finalize 단계에서 채워짐 ]); const elapsedSec = (new Date().getTime() - startTime) / 1000; if (elapsedSec > TIMEOUT_BUDGET_SEC) { writeToSheet(sheetName, headers, rows); props.setProperty("cs_chunk_idx", String(chunkIdx)); props.setProperty("cs_row_idx", String(rowIdx + 1)); writeCoreSatelliteStatus_( "PARTIAL_SAVED", universe.length, Math.min(chunkIdx * CHUNK_SIZE + rowIdx + 1, universe.length), totalChunks, chunkIdx, `chunk ${chunkIdx + 1}/${totalChunks} partial saved at row ${rowIdx + 1}/${slice.length}` ); Logger.log(`core_satellite chunk ${chunkIdx} partial saved at row ${rowIdx + 1}/${slice.length}`); throw new Error("PARTIAL_SAVE_REQUESTED"); } } // 청크 데이터를 임시 시트에 누적 저장 writeToSheet(sheetName, headers, rows); props.setProperty("cs_chunk_idx", String(chunkIdx + 1)); props.setProperty("cs_row_idx", "0"); writeCoreSatelliteStatus_( chunkIdx + 1 >= totalChunks ? "FINALIZING" : "IN_PROGRESS", universe.length, Math.min((chunkIdx + 1) * CHUNK_SIZE, universe.length), totalChunks, chunkIdx + 1, `chunk ${chunkIdx + 1}/${totalChunks} written` ); Logger.log(`core_satellite chunk ${chunkIdx}/${totalChunks-1} 완료: ${rows.length}종목`); // 마지막 청크는 같은 실행에서 즉시 finalize한다. if (chunkIdx + 1 >= totalChunks) { runCoreSatelliteFinalize(); props.setProperty("cs_chunk_idx", "0"); props.setProperty("cs_row_idx", "0"); writeCoreSatelliteStatus_("COMPLETE", universe.length, universe.length, totalChunks, 0, "finalize complete"); Logger.log("core_satellite: 마지막 청크 완료 → finalize"); } } function writeCoreSatelliteStatus_(status, universeCount, processedCount, totalChunks, nextChunkIdx, detail) { const updatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); const coverage = universeCount > 0 ? roundNum((processedCount / universeCount) * 100, 2) : 0; PropertiesService.getScriptProperties().setProperty("cs_status", JSON.stringify({ status, universeCount, processedCount, coveragePct: coverage, chunkSize: CHUNK_SIZE, totalChunks, nextChunkIdx, updatedAt, detail: detail || "" })); } function runCoreSatelliteFinalize() { // 모든 cs_chunk_N 탭을 합쳐 core_satellite 탭에 기록 const ss = getSpreadsheet_(); const allRows = []; const headers = [ "Ticker","Name","Sector", "Price_Date","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D","MA20","MA60","Ret10D","Ret20D","Ret60D","Price_Source","ATR20","ATR20_Pct","Val_Surge_Pct","AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit","Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status", "Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows", "ETF_Ret5D","Rotation_Score","Alert_Level","Smart_Money", "DART_Status","DART_Source","DART_Catalyst","DART_Risk", "Missing_Fields","Next_Source_To_Check","Allowed_Action", "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation", "Action_Reason","Action_Params","Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason", "Timing_Action","Timing_Score_Entry","Timing_Score_Exit","Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason","Exit_Signal_Detail", "Candidate_Quality_Grade","T1_Forced_Sell_Risk_Score","T1_Forced_Sell_Risk_State","T1_Forced_Sell_Risk_Reason", "Sell_Conflict_Score","Sell_Conflict_State","Sell_Conflict_Reason","Execution_Recommendation_State","Execution_Recommendation_Reason", "ChunkIdx","AsOfDate", "RS_Rank_20D","RS_Pct_20D" ]; let chunkIdx = 0; while (true) { const s = ss.getSheetByName(`cs_chunk_${chunkIdx}`); if (!s) break; const data = s.getDataRange().getValues(); // row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터 if (data.length > 2) { for (const row of data.slice(2)) { const normalized = Array.isArray(row) ? row.slice(0, headers.length) : []; while (normalized.length < headers.length) normalized.push(""); allRows.push(normalized); } } s.hideSheet(); // 임시 탭 숨김 chunkIdx++; } if (allRows.length === 0) return; // ── 섹터별 Ret20D 상대강도 순위 계산 ────────────────────────────────── // Sector=index 2, Ret20D=index 14, RS_Rank_20D=index 53, RS_Pct_20D=index 54 const SECTOR_IDX = 2; const RET20D_IDX = 14; const RS_RANK_IDX = headers.indexOf("RS_Rank_20D"); const RS_PCT_IDX = headers.indexOf("RS_Pct_20D"); const TICKER_IDX = headers.indexOf("Ticker"); if (TICKER_IDX >= 0) { allRows.forEach(r => { r[TICKER_IDX] = normalizeTickerCode(r[TICKER_IDX]); }); } const sectorGroups = {}; allRows.forEach((r, i) => { const sector = String(r[SECTOR_IDX] ?? "").trim(); const ret20D = parseFloat(r[RET20D_IDX]); if (!sector || !Number.isFinite(ret20D)) return; if (!sectorGroups[sector]) sectorGroups[sector] = []; sectorGroups[sector].push({ rowIdx: i, ret20D }); }); for (const group of Object.values(sectorGroups)) { group.sort((a, b) => b.ret20D - a.ret20D); // 수익률 높을수록 rank=1 group.forEach((item, rankIdx) => { allRows[item.rowIdx][RS_RANK_IDX] = rankIdx + 1; // 백분위: 1위=100, 꼴찌=0 (섹터 내 상대 위치) allRows[item.rowIdx][RS_PCT_IDX] = Math.round((1 - rankIdx / group.length) * 100); }); } writeToSheet("core_satellite", headers, allRows); writeCoreSatelliteStatus_("COMPLETE", allRows.length, allRows.length, chunkIdx, 0, "core_satellite finalized from chunk sheets"); deleteCoreSatelliteChunkSheets_("finalize complete"); Logger.log(`core_satellite finalize 완료: ${allRows.length}종목`); } function deleteCoreSatelliteChunkSheets_(reason) { const ss = getSpreadsheet_(); const sheets = ss.getSheets(); let deleted = 0; for (const sheet of sheets) { const name = sheet.getName(); if (/^cs_chunk_\d+$/.test(name)) { ss.deleteSheet(sheet); deleted++; } } if (deleted > 0) { Logger.log(`core_satellite 임시 청크 시트 삭제: ${deleted}개 (${reason || "cleanup"})`); } return deleted; } // ── Rotation Score / Alert / SmartMoney 공통 계산 ──────────────────────────── function calcRotationScore(frg5, inst5, frg20, inst20, indiv5, etf) { let score = 0; if (frg5>0 && inst5>0) score+=40; else if(frg5>0||inst5>0) score+=20; if (frg20>0 && inst20>0) score+=20; else if(frg20>0||inst20>0) score+=10; if (etf?.ok) { if(+etf.ret5D>0) score+=10; if(+etf.ret20D>0) score+=10; } if ((frg5>0||inst5>0) && indiv5<0) score+=10; return Math.max(0, Math.min(100, score)); } function calcAlert(score, frg5, inst5) { return score>=70&&frg5>0&&inst5>0 ? "INFLOW_STRONG" : score>=50 ? "INFLOW_MODERATE" : score>=30 ? "NEUTRAL" : frg5<0&&inst5<0 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION"; } function calcSmartMoney(frg5, inst5, indiv5) { return frg5>0&&inst5>0&&indiv5<0 ? "STRONG" : (frg5>0||inst5>0)&&indiv5<0 ? "MODERATE" : frg5>0||inst5>0 ? "WEAK" : "ABSENT"; } function buildDataFeedPriceMap() { const holdings = sheetToJson("data_feed"); const map = {}; for (const row of holdings) { const ticker = normalizeTickerCode(row.Ticker); if (!ticker) continue; map[ticker] = row; } return map; } function buildDataFeedSellMap_() { const holdings = sheetToJson("data_feed"); const map = {}; for (const row of holdings) { const ticker = normalizeTickerCode(row.Ticker); if (!ticker) continue; map[ticker] = row; } return map; } function resolveDataFeedPriceMetrics(code) { const ticker = normalizeTickerCode(code); const price = fetchNaverOhlcMetrics(ticker); if (price.ok) return price; const yahooOhlc = fetchYahooOhlcMetrics(ticker); if (yahooOhlc.ok) return yahooOhlc; const naverFallback = fetchNaverMarketMetrics(ticker); if (naverFallback.ok) { return { ok: true, source: "Naver Finance main", isFallbackQuote: true, // OHLC 없음 — MA/ATR/ValSurge 결측 isPriceStale: false, // 실시간 호가 — 날짜 스테일 아님 priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"), close: Number(naverFallback.marketPrice), open: null, high: null, low: null, volume: null, prevClose: null, avgVolume5D: null, ma20: null, ma60: null, ret10D: null, ret20D: null, ret60D: null, atr20: null, atr20Pct: null, valSurge: null, avgTradingValue5D: null, avgTradingValue20D: null, ret5D: null, bid: Number.isFinite(naverFallback.bid) ? naverFallback.bid : null, ask: Number.isFinite(naverFallback.ask) ? naverFallback.ask : null, spreadPct: Number.isFinite(naverFallback.spreadPct) ? naverFallback.spreadPct : null, quoteSource: naverFallback.source ?? "naver_main", quoteStatus: naverFallback.quoteStatus ?? "NAVER_QUOTE_NO_MATCH", quoteHttpStatus: naverFallback.httpStatus ?? null, error: price.error || naverFallback.error || "" }; } const fallback = fetchYahooPrice(ticker); if (fallback.ok) { return { ok: true, source: "Yahoo Finance close", isFallbackQuote: true, // OHLC 없음 — MA/ATR/ValSurge 결측 isPriceStale: false, // 실시간 가격 — 날짜 스테일 아님 priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"), close: Number(fallback.close), open: null, high: null, low: null, volume: null, prevClose: null, avgVolume5D: null, ma20: null, ma60: null, ret10D: null, ret20D: fallback.ret20D, ret60D: null, atr20: null, atr20Pct: null, valSurge: null, avgTradingValue5D: null, avgTradingValue20D: null, bid: null, ask: null, spreadPct: null, quoteSource: "QUOTE_NO_MATCH", quoteStatus: "QUOTE_NO_MATCH", ret5D: fallback.ret5D, ret20D: fallback.ret20D, error: price.error || fallback.error || "" }; } return { ok: false, error: price.error || fallback.error || "PRICE_MISSING" }; } function resolveSatellitePriceMetrics(code, dataFeedMap) { const ticker = normalizeTickerCode(code); const local = dataFeedMap?.[ticker] || null; if (local && String(local.Price_Status ?? "").toUpperCase() === "PRICE_OK") { const parseNum = (v) => (v === "" || v == null || isNaN(Number(v))) ? null : Number(v); const close = parseNum(local.Close); const atr20 = parseNum(local.ATR20); const avgTradingValue5D = parseNum(local.AvgTradingValue_5D_M ?? local.Avg_TradingValue_5D_M ?? local.AvgTradeValue_5D_M); const avgTradingValue20D = parseNum(local.AvgTradingValue_20D_M ?? local.Avg_TradingValue_20D_M ?? local.AvgTradeValue_20D_M); const bid = parseNum(local.Bid); const ask = parseNum(local.Ask); const spreadPct = parseNum(local.Spread_Pct ?? local.SpreadPct); return { ok: Number.isFinite(close), source: "data_feed", priceDate: String(local.Price_Date ?? ""), close: close !== null ? close : "", open: parseNum(local.Open), high: parseNum(local.High), low: parseNum(local.Low), volume: parseNum(local.Volume), prevClose: parseNum(local.PrevClose), avgVolume5D: parseNum(local.AvgVolume_5D), ma20: parseNum(local.MA20), ma60: parseNum(local.MA60), ret10D: parseNum(local.Ret10D), ret20D: parseNum(local.Ret20D), ret60D: parseNum(local.Ret60D), atr20: atr20, atr20Pct: parseNum(local.ATR20_Pct), valSurge: parseNum(local.Val_Surge_Pct), avgTradingValue5D: avgTradingValue5D, avgTradingValue20D: avgTradingValue20D, bid: bid, ask: ask, spreadPct: spreadPct, quoteSource: String(local.Quote_Source ?? local.quoteSource ?? local.Spread_Source ?? "data_feed"), quoteStatus: String(local.Quote_Status ?? local.quoteStatus ?? "QUOTE_NO_MATCH") }; } const price = fetchNaverOhlcMetrics(ticker); if (price.ok) return price; const yahooOhlc = fetchYahooOhlcMetrics(ticker); if (yahooOhlc.ok) return yahooOhlc; const naverFallback = fetchNaverMarketMetrics(ticker); if (naverFallback.ok) { return { ok: true, source: "Naver Finance main", isFallbackQuote: true, isPriceStale: false, priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"), close: Number(naverFallback.marketPrice), open: null, high: null, low: null, volume: null, prevClose: null, avgVolume5D: null, ma20: null, ma60: null, ret10D: null, ret20D: null, ret60D: null, atr20: null, atr20Pct: null, valSurge: null, avgTradingValue5D: null, avgTradingValue20D: null, bid: Number.isFinite(naverFallback.bid) ? naverFallback.bid : null, ask: Number.isFinite(naverFallback.ask) ? naverFallback.ask : null, spreadPct: Number.isFinite(naverFallback.spreadPct) ? naverFallback.spreadPct : null, quoteSource: naverFallback.source ?? "naver_main", quoteStatus: naverFallback.quoteStatus ?? "NAVER_QUOTE_NO_MATCH", quoteHttpStatus: naverFallback.httpStatus ?? null, ret5D: null, ret20D: null }; } const fallback = fetchYahooPrice(ticker); if (fallback.ok) { return { ok: true, source: "Yahoo Finance close", isFallbackQuote: true, isPriceStale: false, priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"), close: Number(fallback.close), atr20: null, atr20Pct: null, valSurge: null, avgTradingValue5D: null, avgTradingValue20D: null, bid: null, ask: null, spreadPct: null, quoteSource: "QUOTE_NO_MATCH", quoteStatus: "QUOTE_NO_MATCH", ret5D: fallback.ret5D, ret20D: fallback.ret20D }; } return { ok: false, error: price.error || fallback.error || "PRICE_MISSING" }; } function fetchTrendingTickers() { const cacheKey = `trending_tickers_${Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd")}`; const cached = getCachedFetchResult_(cacheKey); if (cached && Array.isArray(cached)) return cached; const url = "https://finance.naver.com/sise/sise_quant.naver"; // 거래상위 (Top Volume) const tickers = []; try { const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); if (resp.getResponseCode() === 200) { const html = resp.getContentText("EUC-KR"); const pattern = /([^<]+)<\/a>/g; let match; while ((match = pattern.exec(html)) !== null) { const name = match[2].trim(); // ETF/ETN 등 제외 if (!name.includes("KODEX") && !name.includes("TIGER") && !name.includes("인버스") && !name.includes("레버리지") && !name.includes("KOSEF") && !name.includes("HANARO") && !name.includes("KBSTAR") && !name.includes("ACE")) { tickers.push({ code: match[1], name: name, sector: "Dynamic(거래상위)" }); } if (tickers.length >= 10) break; // Top 10 Dynamic stocks } } } catch(e) { handleFetchError_("fetchTrendingTickers", e, "WARN"); } if (tickers.length > 0) { cacheJsonSet_(CACHE_VERSION + cacheKey, tickers, 12 * 60 * 60); } return tickers; } // ── Core Satellite 종목 유니버스 ────────────────────────────────────────────── // 실제 운용 시 ScriptProperties 또는 별도 시트에서 로드 권장 function getCoreSatelliteUniverse() { const ss = getSpreadsheet_(); let sheetUniverse = ss.getSheetByName("universe"); let list = []; let purgedCount = 0; const nowMs = Date.now(); const MAX_DAYS = 14; // 동적 발굴 종목의 기본 모니터링 수명 (14일간 눌림목 추적) const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); // 1. 기존 유니버스 시트가 있으면 읽어온다. if (sheetUniverse) { const data = sheetUniverse.getDataRange().getValues(); if (data.length > 1) { for (let i = 1; i < data.length; i++) { const r = data[i]; if (!r[0]) continue; const code = normalizeTickerCode(r[0]); const name = String(r[1]||"").trim(); const sector = String(r[2]||"").trim(); const addedDateStr = String(r[3]||"").trim() || todayStr; // 없으면 오늘로 간주 // 14일 경과된 Dynamic 종목 자동 삭제 로직 (사용자가 섹터명을 바꾸면 영구보존) if (sector.toUpperCase().startsWith("DYNAMIC")) { const addedMs = Date.parse(addedDateStr); if (!isNaN(addedMs) && (nowMs - addedMs) / (1000 * 60 * 60 * 24) > MAX_DAYS) { purgedCount++; continue; // 리스트에 추가하지 않음 (삭제) } } list.push({ code, name, sector, addedDate: addedDateStr }); } } } else { // 시트가 없으면 새로 생성하고 헤더를 쓴다. sheetUniverse = ss.insertSheet("universe"); sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]); sheetUniverse.getRange("A:A").setNumberFormat("@"); // 코드 열 텍스트 지정 } // 2. 읽어온 데이터가 아예 없으면 (최초 실행) 기본 AI 리스트로 초기화한다. if (list.length === 0) { const defaults = [ // AI 반도체 & 메모리 { code:"005930", name:"삼성전자", sector:"반도체" }, { code:"000660", name:"SK하이닉스", sector:"반도체" }, { code:"042700", name:"한미반도체", sector:"반도체" }, { code:"007660", name:"이수페타시스",sector:"반도체/PCB" }, { code:"403870", name:"HPSP", sector:"반도체/장비" }, { code:"058470", name:"리노공업", sector:"반도체/부품" }, // AI 전력/인프라/발전 { code:"010120", name:"LS ELECTRIC",sector:"AI전력/기기" }, { code:"267260", name:"HD현대일렉트릭",sector:"AI전력/기기" }, { code:"298040", name:"효성중공업", sector:"AI전력/기기" }, { code:"006260", name:"LS", sector:"AI전력/전선" }, { code:"001440", name:"대한전선", sector:"AI전력/전선" }, { code:"034020", name:"두산에너빌리티",sector:"AI인프라/발전" }, { code:"028050", name:"삼성E&A", sector:"AI인프라/EPC" }, // 방산 { code:"012450", name:"한화에어로스페이스",sector:"방산" }, { code:"064350", name:"현대로템", sector:"방산" }, { code:"079550", name:"LIG넥스원", sector:"방산" }, // 조선 { code:"329180", name:"HD현대중공업",sector:"조선" }, { code:"042660", name:"한화오션", sector:"조선" }, { code:"009540", name:"HD한국조선해양",sector:"조선" }, // 자동차 { code:"005380", name:"현대차", sector:"자동차" }, { code:"000270", name:"기아", sector:"자동차" }, // 은행 / 증권 / 지주회사 { code:"105560", name:"KB금융", sector:"은행" }, { code:"055550", name:"신한지주", sector:"은행" }, { code:"024110", name:"기업은행", sector:"은행" }, { code:"071050", name:"한국금융지주", sector:"증권" }, { code:"006800", name:"미래에셋증권", sector:"증권" }, { code:"005940", name:"NH투자증권", sector:"증권" }, { code:"180640", name:"한진칼", sector:"지주회사" }, { code:"267250", name:"HD현대", sector:"지주회사" }, { code:"034730", name:"SK", sector:"지주회사" }, // 바이오 { code:"207940", name:"삼성바이오로직스",sector:"바이오" }, { code:"068270", name:"셀트리온", sector:"바이오" }, { code:"128940", name:"한미약품", sector:"바이오" }, { code:"000100", name:"유한양행", sector:"바이오" }, // 2차전지 { code:"373220", name:"LG에너지솔루션",sector:"2차전지" }, { code:"006400", name:"삼성SDI", sector:"2차전지" }, { code:"003670", name:"포스코퓨처엠",sector:"2차전지" }, // 지주/기타 { code:"028260", name:"삼성물산", sector:"지주회사" } ]; list = defaults.map(t => ({ ...t, addedDate: todayStr })); const initialRows = list.map(t => [t.code, t.name, t.sector, t.addedDate]); // 헤더가 없을 수 있으므로 전체 초기화 sheetUniverse.clearContents(); sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]); sheetUniverse.getRange(2, 1, initialRows.length, 4).setValues(initialRows); } // 3. 만료된 종목이 있어서 정리(Purge)를 했다면 시트를 새로고침 if (purgedCount > 0) { sheetUniverse.clearContents(); sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]); if (list.length > 0) { const validRows = list.map(t => [t.code, t.name, t.sector, t.addedDate]); sheetUniverse.getRange(2, 1, validRows.length, 4).setValues(validRows); } Logger.log(`[Auto-Clean] 14일 경과된 노이즈 종목 ${purgedCount}개 자동 삭제 완료.`); } // 4. 동적 주도주(거래급증) 자동 발굴 const dynamicTickers = fetchTrendingTickers(); const existingCodes = new Set(list.map(x => x.code)); const newDiscovered = []; for (const t of dynamicTickers) { if (!existingCodes.has(t.code)) { t.sector = "Dynamic"; t.addedDate = todayStr; list.push(t); newDiscovered.push([t.code, t.name, t.sector, t.addedDate]); existingCodes.add(t.code); } } // 5. 새롭게 발견된 주도주 시트 바닥에 추가 if (newDiscovered.length > 0) { const lastRow = sheetUniverse.getLastRow() || 1; sheetUniverse.getRange(lastRow + 1, 1, newDiscovered.length, 4).setValues(newDiscovered); Logger.log(`[Discovery] 새로운 주도주 ${newDiscovered.length}개 발견 및 universe 시트에 영구 저장 완료.`); } return list; } function resetCoreSatelliteChunks() { const ss = getSpreadsheet_(); const props = PropertiesService.getScriptProperties(); deleteCoreSatelliteChunkSheets_("reset before chunk run"); props.setProperty("cs_chunk_idx", "0"); props.setProperty("cs_row_idx", "0"); writeCoreSatelliteStatus_("RESET", 0, 0, 0, 0, "chunk sheets cleared"); } /** * data_feed 시트에서 ticker → 시장데이터 맵 구성 * H2~H5에 필요한 컬럼을 모두 읽는다. */ function buildDataFeedMap_(ss) { var map = {}; var sheet = ss.getSheetByName(DATA_FEED_SHEET_NAME); if (!sheet) return map; var data = sheet.getDataRange().getValues(); if (data.length <= DF_HEADER_ROW_IDX) return map; var headers = data[DF_HEADER_ROW_IDX]; var c = buildColIdx_(headers); for (var i = DF_HEADER_ROW_IDX + 1; i < data.length; i++) { var row = data[i]; var ticker = normTicker_(c['Ticker'] !== undefined ? row[c['Ticker']] : ''); if (!ticker) continue; map[ticker] = { ticker: ticker, name: strCol_(row, c, 'Name'), atr20: numCol_(row, c, 'ATR20'), close: numCol_(row, c, 'Close') || numCol_(row, c, 'Close_Price'), ma20: numCol_(row, c, 'MA20'), ma60: numCol_(row, c, 'MA60'), ma20Slope: numCol_(row, c, 'MA20_Slope'), rsi14: numCol_(row, c, 'RSI14'), bbPosition: numCol_(row, c, 'BB_Position'), leaderTotal: numCol_(row, c, 'Leader_Scan_Total'), leaderGate: strCol_(row, c, 'Leader_Gate'), bandStatus: strCol_(row, c, 'Band_Status'), grade: strCol_(row, c, 'SS001_Grade') || strCol_(row, c, 'Grade'), flowCredit: numCol_(row, c, 'Flow_Credit'), flowOk: strCol_(row, c, 'Flow_OK'), rwPartial: numCol_(row, c, 'RW_Partial'), finalAction: strCol_(row, c, 'Final_Action'), sellSignal: strCol_(row, c, 'Sell_Signal'), sellRatioPct: numCol_(row, c, 'Sell_Ratio_Pct'), sellLimitPrice: numCol_(row, c, 'Sell_Limit_Price'), weightTargetPct: numCol_(row, c, 'Weight_Target_Pct') || numCol_(row, c, 'Target_Weight_Pct'), avgTradeVal5d: numCol_(row, c, 'AvgTradeValue_5D_M'), avgTradeVal20d: numColN_(row, c, 'AvgTradeValue_20D_M'), // H6: secular_leader 과열신호 3번째 조건 valSurgePct: numColN_(row, c, 'Val_Surge_Pct'), targetPrice: numColN_(row, c, 'Target_Price'), upsidePct: numColN_(row, c, 'Upside_Pct'), positionClass: strCol_(row, c, 'Position_Class') || strCol_(row, c, 'position_class'), isDuplicateEtf: strCol_(row, c, 'Duplicate_ETF') === 'Y' || strCol_(row, c, 'Is_Duplicate_ETF') === 'Y', frg5d: numColN_(row, c, 'Frg_5D'), inst5d: numColN_(row, c, 'Inst_5D'), frg20d: numColN_(row, c, 'Frg_20D'), inst20d: numColN_(row, c, 'Inst_20D'), ret5d: numColN_(row, c, 'Ret5D'), ret20d: numColN_(row, c, 'Ret20D'), prevClose: numColN_(row, c, 'PrevClose'), high: numColN_(row, c, 'High'), low: numColN_(row, c, 'Low'), volume: numColN_(row, c, 'Volume'), avgVolume5d: numColN_(row, c, 'AvgVolume_5D'), sellQty: numColN_(row, c, 'Sell_Qty'), // M3: 선행 계산값 직접 사용 acTotal: numColN_(row, c, 'AC_Total'), // H3: secular_leader_gate acGate: strCol_(row, c, 'AC_Gate'), // H3: anti_climax 판정 liquidityStatus: strCol_(row, c, 'Liquidity_Status'), spreadStatus: strCol_(row, c, 'Spread_Status'), dartRiskStatus: strCol_(row, c, 'DART_Risk') || strCol_(row, c, 'DART_Status'), dartRisk: strCol_(row, c, 'DART_Risk'), eventHoldDays: numColN_(row, c, 'Event_Hold_Days'), // M4: 이벤트 홀드 잔여일 high52w: numColN_(row, c, 'High_52W') || numColN_(row, c, 'High52W'), // L4 // ── [2026-05-21_BRT_HARNESS_V1] BRT/RS/Composite 판정 ────────────── stock_drawdown_from_high_pct: numColN_(row, c, 'Stock_Drawdown_From_High_Pct'), excess_drawdown_pctp: numColN_(row, c, 'Excess_Drawdown_PctP'), recovery_ratio_5d: numColN_(row, c, 'Recovery_Ratio_5D'), recovery_ratio_20d: numColN_(row, c, 'Recovery_Ratio_20D'), downside_beta: numColN_(row, c, 'Downside_Beta'), rs_line_20d_slope: numColN_(row, c, 'RS_Line_20D_Slope'), rs_line_60d_slope: numColN_(row, c, 'RS_Line_60D_Slope'), brt_verdict: strCol_(row, c, 'BRT_Verdict'), brt_method: strCol_(row, c, 'BRT_Method'), excess_ret_10d: numColN_(row, c, 'Excess_Ret_10D'), rs_verdict_v1_raw: strCol_(row, c, 'RS_Verdict_V1_Raw'), rs_verdict: strCol_(row, c, 'RS_Verdict'), composite_verdict: strCol_(row, c, 'Composite_Verdict'), saqg_v1: strCol_(row, c, 'SAQG_V1'), saqg_penalty: numColN_(row, c, 'SAQG_Penalty'), saqg_failed_filters: strCol_(row, c, 'SAQG_Failed_Filters'), rag_v1: strCol_(row, c, 'RAG_Verdict'), rag_reason: strCol_(row, c, 'RAG_Reason'), // ── 재무 건전성 — FINANCIAL_HEALTH_V1 + OCF_B (7일 캐시 수집) ─────────── roe_pct: numColN_(row, c, 'ROE_Pct'), opm_pct: numColN_(row, c, 'Operating_Margin_Pct'), debt_ratio_pct: numColN_(row, c, 'Debt_To_Equity'), current_ratio: numColN_(row, c, 'Current_Ratio'), free_cf_krw: numColN_(row, c, 'FCF_B') != null ? numColN_(row, c, 'FCF_B') * 1e8 : null, // 억원 → 원 operating_cf_krw: numColN_(row, c, 'OCF_B') != null ? numColN_(row, c, 'OCF_B') * 1e8 : null, // 억원 → 원 (신규) revenue_growth_pct: numColN_(row, c, 'Revenue_Growth_Pct'), }; } return map; } /** * account_snapshot 파싱 * 반환값: H1 집계값(aggregate) + H2~H5용 holdings 배열(per-holding) */ function parseAccountSnapshot_(ss, totalAssetKrw, dfMap) { var result = { capturedAt: null, immediateCashKrw: 0, settlementCashD2Krw: 0, openOrderAmountKrw: 0, totalHeatKrw: 0, heatRowsCount: 0, heatAtrEstimated: false, derivedTotalAsset: 0, holdings: [] }; var sheet = ss.getSheetByName(AS_SHEET_NAME); if (!sheet) { Logger.log('[HARNESS] account_snapshot 시트 없음'); return result; } // ── 환율(USD_KRW) 로드 ────────────────────────────────────────────────── var usdKrw = 1400; // default fallback try { var macroSheet = ss.getSheetByName("macro"); if (macroSheet) { var mData = macroSheet.getDataRange().getValues(); var headerRowIdx = 0; for (var r = 0; r < Math.min(5, mData.length); r++) { var row = mData[r] ?? []; if (row.indexOf("Symbol") >= 0 && row.indexOf("Name") >= 0) { headerRowIdx = r; break; } } var nameIdx = mData[headerRowIdx].indexOf("Name"); var closeIdx = mData[headerRowIdx].indexOf("Close"); if (nameIdx >= 0 && closeIdx >= 0) { for (var i = headerRowIdx + 1; i < mData.length; i++) { if (String(mData[i][nameIdx]).trim() === "USD_KRW") { var val = parseFloat(mData[i][closeIdx]); if (Number.isFinite(val) && val > 0) { usdKrw = val; break; } } } } } } catch (e) { Logger.log('[WARN] parseAccountSnapshot_ 환율 로드 실패: ' + e.message); } var data = sheet.getDataRange().getValues(); if (data.length <= AS_HEADER_ROW_IDX) return result; var headers = data[AS_HEADER_ROW_IDX]; var c = buildColIdx_(headers); var marketValueSum = 0; for (var i = AS_HEADER_ROW_IDX + 1; i < data.length; i++) { var row = data[i]; var parseStatus = strCol_(row, c, 'parse_status'); if (parseStatus !== 'CAPTURE_READ_OK') continue; // captured_at (첫 유효 행 기준) if (!result.capturedAt && c['captured_at'] !== undefined) { var rawTs = row[c['captured_at']]; if (rawTs) result.capturedAt = rawTs instanceof Date ? rawTs : new Date(rawTs); } var accountType = strCol_(row, c, 'account_type') || strCol_(row, c, 'Account_Type') || '일반계좌'; var isRestrictedAcct = accountType === 'ISA' || accountType === '연금저축'; var holdingQty = numCol_(row, c, 'holding_quantity'); var immCash = numCol_(row, c, 'immediate_cash'); var d2Cash = numCol_(row, c, 'settlement_cash_d2'); var openOrder = numCol_(row, c, 'open_order_amount'); var mktValue = numCol_(row, c, 'market_value'); var ticker = normTicker_(c['ticker'] !== undefined ? row[c['ticker']] : ''); var isUsTicker = /^[A-Z]+$/.test(ticker); if (isUsTicker) { var currPrice = numCol_(row, c, 'current_price'); if (currPrice > 0 && holdingQty > 0) { mktValue = Math.round(currPrice * holdingQty * usdKrw); } } if (!isRestrictedAcct) { if (immCash > 0) result.immediateCashKrw += immCash; if (d2Cash > 0) result.settlementCashD2Krw += d2Cash; if (openOrder > 0) result.openOrderAmountKrw += openOrder; } if (mktValue > 0) marketValueSum += mktValue; var userConfirmed = strCol_(row, c, 'user_confirmed'); if (holdingQty <= 0 || userConfirmed !== 'Y') continue; // 보유 포지션 처리 var avgCost = numCol_(row, c, 'average_cost'); if (isUsTicker && avgCost > 0) { avgCost = round2_(avgCost * usdKrw); } var stopPrice = numCol_(row, c, 'stop_price'); if (isUsTicker && stopPrice > 0) { stopPrice = round2_(stopPrice * usdKrw); } var dfRow = dfMap[ticker] || {}; var atr20 = dfRow.atr20 || 0; var stopSrc = 'MANUAL'; // stop_price 미입력 또는 비정상 → STOP_PRICE_CORE_V1 계산 if (stopPrice <= 0 || stopPrice >= avgCost) { if (atr20 > 0 && avgCost > 0) { var atrPct = atr20 / avgCost * 100; var atrMul = atrPct >= 8 ? 2.0 : 1.5; stopPrice = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul); stopSrc = 'COMPUTED_ATR'; } else { stopPrice = avgCost * 0.92; stopSrc = 'COMPUTED_PCT'; } result.heatAtrEstimated = true; } // Total Heat (H1) var heatI = (avgCost - stopPrice) * holdingQty; if (heatI > 0) { result.totalHeatKrw += heatI; result.heatRowsCount++; } // stop_price 이탈 감지 var close = dfRow.close || 0; if (isUsTicker) { var currPrice = numCol_(row, c, 'current_price'); close = currPrice > 0 ? round2_(currPrice * usdKrw) : round2_(close * usdKrw); } var stopBreach = close > 0 && stopPrice > 0 && close <= stopPrice; // weight_pct: settings의 total_asset 기준으로 계산, 없으면 account_snapshot 컬럼 var weightPct = totalAssetKrw > 0 && mktValue > 0 ? round2_(mktValue / totalAssetKrw * 100) : numCol_(row, c, 'weight_pct') || numCol_(row, c, 'Weight_Pct'); var highestPriceSinceEntry = numCol_(row, c, 'highest_price_since_entry'); var returnPct = numColN_(row, c, 'return_pct'); var entryDateStr = strCol_(row, c, 'entry_date') || ''; var holdingDays = 0; if (entryDateStr) { var entryMs = new Date(entryDateStr).getTime(); if (!isNaN(entryMs)) holdingDays = Math.floor((Date.now() - entryMs) / 86400000); } result.holdings.push({ ticker: ticker, name: strCol_(row, c, 'name') || strCol_(row, c, 'Name') || dfRow.name || '', account: accountType, holdingQty: holdingQty, avgCost: avgCost, stopPrice: stopPrice, stopPriceSrc: stopSrc, stopBreach: stopBreach, marketValue: mktValue, weightPct: weightPct, close: close, profitPct: Number.isFinite(returnPct) ? returnPct : null, holdingDays: holdingDays, highestPriceSinceEntry: highestPriceSinceEntry > 0 ? highestPriceSinceEntry : null, entryDate: entryDateStr, parseStatus: parseStatus }); } result.derivedTotalAsset = result.settlementCashD2Krw + marketValueSum; return result; } // FORMULA_STUB: CASH_RATIOS_V1 — 현금비중 (calcCashRatios_) GAS 미구현, settlement_cash/total_asset 계산 (GAS_REFERENCE_ONLY)