diff --git a/src/gas/collection/gdc_01_fetch_fundamentals.gs b/src/gas/collection/gdc_01_fetch_fundamentals.gs deleted file mode 100644 index bfa7e08..0000000 --- a/src/gas/collection/gdc_01_fetch_fundamentals.gs +++ /dev/null @@ -1,2637 +0,0 @@ -// gas_data_collect.gs - Data collection & assembly layer -// Fetch infrastructure, data fetchers, buildTickerRow_, runDataFeed -// GAS global scope: functions in gas_data_feed.gs / gas_lib.gs callable directly - -function beginFetchSession_(label = "manual") { - const props = PropertiesService.getScriptProperties(); - - try { - const keys = props.getKeys(); - let budgetCleared = 0; - let circuitExpired = 0; - const now = Date.now(); - for (const k of keys) { - if (k.startsWith("fetch_budget_")) { - props.deleteProperty(k); - budgetCleared++; - } else if (k.startsWith("fetch_circuit_")) { - // 만료된 circuit breaker 자동 정리: until < now인 경우 제거. - // isFetchCircuitOpen_()도 자가 치유하지만, 세션 시작 시 선제 정리로 - // 불필요한 PropertiesService read를 줄이고 상태를 명시적으로 초기화. - try { - const raw = props.getProperty(k); - if (raw) { - const data = JSON.parse(raw); - if (!data?.until || now >= Number(data.until)) { - props.deleteProperty(k); - const failKey = k.replace("fetch_circuit_", "fetch_fail_"); - props.deleteProperty(failKey); - circuitExpired++; - } - } - } catch (_) { - props.deleteProperty(k); - circuitExpired++; - } - } - } - if (budgetCleared > 0 || circuitExpired > 0) { - Logger.log("[beginFetchSession_] budget_cleared=" + budgetCleared + " circuit_expired=" + circuitExpired); - } - } catch (e) { - Logger.log("[beginFetchSession_] Error clearing old properties: " + e.message); - } - - props.setProperty("fetch_session_id", Utilities.getUuid()); - props.setProperty("fetch_session_label", String(label ?? "manual")); - props.setProperty("fetch_session_started_at", new Date().toISOString()); - props.setProperty("fetch_session_updated_at", new Date().toISOString()); -} - -function setFetchSessionLabel_(label = "manual") { - const props = PropertiesService.getScriptProperties(); - let sid = props.getProperty("fetch_session_id"); - if (!sid) { - beginFetchSession_(label); - return; - } - props.setProperty("fetch_session_label", String(label ?? "manual")); - props.setProperty("fetch_session_updated_at", new Date().toISOString()); -} - -function clearFetchCache() { - const props = PropertiesService.getScriptProperties(); - const keys = props.getKeys(); - for (const k of keys) { - if (k.startsWith("fetch_fail_") || k.startsWith("fetch_circuit_") || k.startsWith("fetch_budget_") || k.startsWith("cs_")) { - props.deleteProperty(k); - } - } - // Note: CacheService doesn't have a flushAll, but since we rely heavily on PropertiesService for circuit breakers, - // clearing the circuits will force a fresh fetch attempt and overwrite the cache. - Logger.log("Fetch cache and circuit breakers cleared."); -} - -// 일부 배포본에서 gas_lib.gs 로딩이 누락돼도 runDataFeed 초기화를 살리기 위한 안전 경로. -// gas_lib.gs의 동일 함수가 존재하면 그 구현을 우선 사용한다. -const _gasCompatRoot_ = (typeof globalThis !== "undefined") ? globalThis : this; -function _installCompat_(name, fn) { - if (typeof _gasCompatRoot_[name] !== "function") { - _gasCompatRoot_[name] = fn; - _gasCompatRoot_._gasCompatFallbackUsed_ = true; - } -} - -const _gasCompatFallbacks_ = { - getSpreadsheet_: function() { - let _ssCacheDataCollect_ = _gasCompatRoot_._ssCacheDataCollect_ || null; - if (_ssCacheDataCollect_) return _ssCacheDataCollect_; - try { - if (typeof SPREADSHEET_ID !== "undefined" && SPREADSHEET_ID) { - _ssCacheDataCollect_ = SpreadsheetApp.openById(SPREADSHEET_ID); - _gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_; - return _ssCacheDataCollect_; - } - } catch (e) { - Logger.log(`getSpreadsheet_ fallback openById failed: ${e.message}`); - } - _ssCacheDataCollect_ = SpreadsheetApp.getActiveSpreadsheet(); - _gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_; - return _ssCacheDataCollect_; - }, - readSettingsTab_: function() { - const result = {}; - try { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("settings"); - if (!sheet) { - Logger.log("readSettingsTab_: settings 탭 없음"); - return result; - } - const data = sheet.getDataRange().getValues(); - const SKIP_KEYS = new Set(["key", "updated", "date", "항목", "파라미터"]); - for (let i = 0; i < data.length; i++) { - const rawKey = String(data[i][0] ?? "").trim(); - if (!rawKey || SKIP_KEYS.has(rawKey.toLowerCase())) continue; - const val = data[i][1]; - if (val !== "" && val != null) result[rawKey] = val; - } - } catch (e) { - Logger.log(`readSettingsTab_ fallback error: ${e.message}`); - } - return result; - }, - readPerformanceSheet_: function() { - const DEFAULT = { - bayesian_multiplier: 0.5, - bayesian_label: "medium_confidence", - trades_used: 0, - win_rate_30: null, - net_expectancy_30: null, - consecutive_losses: 0, - bayesian_data_source: "default", - }; - try { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("performance"); - if (!sheet) return DEFAULT; - const data = sheet.getDataRange().getValues(); - if (data.length < 3) return DEFAULT; - const hdr = data[1].map(h => String(h).trim()); - const pnlIdx = hdr.indexOf("pnl_pct"); - const exitIdx = hdr.indexOf("exit_date"); - const exitDateIdx = hdr.indexOf("exit_date"); - if (pnlIdx < 0 || exitIdx < 0) return DEFAULT; - - const closed = []; - for (let i = 2; i < data.length; i++) { - const exitVal = data[i][exitIdx]; - if (!exitVal || String(exitVal).trim() === "") continue; - const pnl = parseFloat(data[i][pnlIdx]); - if (!Number.isFinite(pnl)) continue; - const exitRaw = exitDateIdx >= 0 ? data[i][exitDateIdx] : exitVal; - const exitMs = exitRaw instanceof Date && !isNaN(exitRaw.getTime()) - ? exitRaw.getTime() - : new Date(exitRaw).getTime(); - closed.push({ pnl, exitMs: Number.isFinite(exitMs) ? exitMs : 0 }); - } - if (closed.length === 0) return DEFAULT; - closed.sort((a, b) => b.exitMs - a.exitMs); - const recent = closed.slice(0, 30).map(r => r.pnl); - const n = recent.length; - if (n < 5) return DEFAULT; - - const wins = recent.filter(x => x > 0).length; - const losses = recent.filter(x => x < 0).length; - const sum = recent.reduce((a, b) => a + b, 0); - const winRate = (wins / n) * 100; - const avg = sum / n; - const label = avg >= 2 ? "high_confidence" : avg >= 0 ? "medium_confidence" : "low_confidence"; - - return { - bayesian_multiplier: label === "high_confidence" ? 1.0 : label === "medium_confidence" ? 0.5 : 0.25, - bayesian_label: label, - trades_used: n, - win_rate_30: winRate, - net_expectancy_30: avg, - consecutive_losses: losses, - bayesian_data_source: "actual", - }; - } catch (e) { - Logger.log(`readPerformanceSheet_ fallback error: ${e.message}`); - return DEFAULT; - } - }, - calcKrxBizDaysDiff_: function(dateStr) { - if (!dateStr) return 999; - const norm = String(dateStr).replace(/\./g, "-"); - if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999; - - const now = new Date(); - const kstMs = now.getTime() + 9 * 3600 * 1000; - const kstNow = new Date(kstMs); - const todayStr = kstNow.toISOString().slice(0, 10); - - let d = new Date(norm + "T00:00:00Z"); - const end = new Date(todayStr + "T00:00:00Z"); - if (d > end) return -1; - if (d.toISOString().slice(0, 10) === todayStr) return 0; - - let count = 0; - const cur = new Date(d); - while (cur < end) { - cur.setDate(cur.getDate() + 1); - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) count++; - } - return count; - }, - isStalePriceDate_: function(dateStr, bizDaysThreshold = 1) { - const diff = calcKrxBizDaysDiff_(dateStr); - return diff > bizDaysThreshold; - }, - calcValSurgeStatus: function(valSurge) { - if (!Number.isFinite(valSurge)) return "DATA_MISSING"; - if (valSurge < THRESHOLDS.VAL_SURGE_WATCH) return "OK"; - if (valSurge < THRESHOLDS.VAL_SURGE_HOT) return "WATCH"; - if (valSurge < THRESHOLDS.VAL_SURGE_EXHAUSTED) return "HOT"; - return "EXHAUSTED"; - }, - calcLiquidityStatus: function(avgTradingValue5D) { - if (!Number.isFinite(avgTradingValue5D)) return "DATA_MISSING"; - if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_PREFERRED_M) return "PREFERRED"; - if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_OK_M) return "OK"; - return "LOW"; - }, - calcSpreadStatus: function(spreadPct) { - if (!Number.isFinite(spreadPct)) return "QUOTE_NO_MATCH"; - if (spreadPct <= THRESHOLDS.SPREAD_OK_PCT) return "OK"; - if (spreadPct <= THRESHOLDS.SPREAD_WARN_PCT) return "WATCH"; - return "BLOCK"; - } -}; - -for (const [name, fn] of Object.entries(_gasCompatFallbacks_)) { - _installCompat_(name, fn); -} - -function getFetchSessionId_() { - const props = PropertiesService.getScriptProperties(); - let sid = props.getProperty("fetch_session_id"); - if (!sid) { - sid = Utilities.getUuid(); - props.setProperty("fetch_session_id", sid); - props.setProperty("fetch_session_label", "auto"); - props.setProperty("fetch_session_started_at", new Date().toISOString()); - props.setProperty("fetch_session_updated_at", new Date().toISOString()); - } - return sid; -} - -function cacheJsonGet_(key) { - const raw = CacheService.getScriptCache().get(key); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch (_) { - return null; - } -} - -function cacheJsonSet_(key, value, ttlSeconds) { - try { - CacheService.getScriptCache().put(key, JSON.stringify(value), ttlSeconds); - } catch (_) {} -} - -function fetchBudgetKey_(source, bucket) { - const safeBucket = String(bucket ?? "global").replace(/[^A-Za-z0-9_.%-]/g, "_"); - return `fetch_budget_${getFetchSessionId_()}_${source}_${safeBucket}`; -} - -function fetchFailureKey_(source) { - return `fetch_fail_${source}`; -} - -function fetchCircuitKey_(source) { - return `fetch_circuit_${source}`; -} - -function isFetchCircuitOpen_(source) { - const props = PropertiesService.getScriptProperties(); - const raw = props.getProperty(fetchCircuitKey_(source)); - if (!raw) return false; - try { - const data = JSON.parse(raw); - if (!data?.until) { - props.deleteProperty(fetchCircuitKey_(source)); - return false; - } - if (Date.now() >= Number(data.until)) { - props.deleteProperty(fetchCircuitKey_(source)); - props.deleteProperty(fetchFailureKey_(source)); - return false; - } - return true; - } catch (_) { - props.deleteProperty(fetchCircuitKey_(source)); - return false; - } -} - -function consumeFetchBudget_(source, bucket) { - const props = PropertiesService.getScriptProperties(); - const budget = FETCH_GOVERNANCE.budget[source] ?? 1; - const key = fetchBudgetKey_(source, bucket); - const used = Number(props.getProperty(key) ?? "0") + 1; - props.setProperty(key, String(used)); - return used <= budget; -} - -function recordFetchSuccess_(source) { - const props = PropertiesService.getScriptProperties(); - props.deleteProperty(fetchFailureKey_(source)); - props.deleteProperty(fetchCircuitKey_(source)); -} - -function recordFetchFailure_(source) { - const props = PropertiesService.getScriptProperties(); - const key = fetchFailureKey_(source); - const failures = Number(props.getProperty(key) ?? "0") + 1; - props.setProperty(key, String(failures)); - if (failures >= FETCH_GOVERNANCE.failureLimit) { - props.setProperty(fetchCircuitKey_(source), JSON.stringify({ - until: Date.now() + FETCH_GOVERNANCE.coolDownMs, - failures, - })); - } -} - -const CACHE_VERSION = "v3_"; - -function getCachedFetchResult_(cacheKey) { - return cacheJsonGet_(CACHE_VERSION + cacheKey); -} - -function setCachedFetchResult_(cacheKey, result, ok, ttlOkKey) { - const ttl = ok ? (FETCH_GOVERNANCE.ttl[ttlOkKey] ?? FETCH_GOVERNANCE.ttl.naver_quote_ok) : FETCH_GOVERNANCE.ttl.failure; - cacheJsonSet_(CACHE_VERSION + cacheKey, result, ttl); -} - -function annotateFetchValue_(result, source, bucket) { - const annotated = { ...(result || {}) }; - const now = new Date(); - const fetchedAt = annotated.fetched_at ? new Date(annotated.fetched_at) : now; - annotated.fetched_at = Utilities.formatDate(fetchedAt, "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ss"); - const ageMinutes = Math.max(0, Math.round((now.getTime() - fetchedAt.getTime()) / 60000)); - annotated.value_age_minutes = ageMinutes; - - let dataStatus = "UNKNOWN"; - let stale = false; - const dateCandidate = annotated.priceDate || annotated.date || annotated.updated_at || null; - if (typeof dateCandidate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateCandidate)) { - stale = isStalePriceDate_(dateCandidate); - dataStatus = stale ? "STALE" : "FRESH"; - annotated.value_date = dateCandidate; - } - if (annotated.rows && Array.isArray(annotated.rows) && annotated.rows.length > 0) { - const firstRow = annotated.rows[0] || {}; - const rowDate = firstRow.date || firstRow.Date || firstRow.priceDate || firstRow.updated_at; - if (typeof rowDate === "string" && /^\d{4}[-.]\d{2}[-.]\d{2}$/.test(rowDate)) { - const normalized = rowDate.replace(/\./g, "-"); - stale = stale || isStalePriceDate_(normalized); - dataStatus = stale ? "STALE" : dataStatus === "UNKNOWN" ? "FRESH" : dataStatus; - annotated.value_date = annotated.value_date || normalized; - } - } - if (dataStatus === "UNKNOWN") { - dataStatus = ageMinutes <= 180 ? "FRESH" : "STALE"; - } - annotated.data_value_status = dataStatus; - annotated.scrape_block_risk = source.startsWith("naver_") || source.startsWith("yahoo_") - ? (dataStatus === "STALE" ? "HIGH" : ageMinutes > 720 ? "MEDIUM" : "LOW") - : "LOW"; - annotated.used_for = dataStatus === "STALE" ? "REFERENCE_ONLY" : "EXECUTION"; - annotated.data_value_reason = dataStatus === "STALE" - ? `stale_or_old:${source}/${bucket}` - : `fresh:${source}/${bucket}`; - return annotated; -} - -// ── Fetch 공통 래퍼 (P2-C) ───────────────────────────────────────────────── -// cache 확인 → stale 재수집 판단 → circuit 확인 → budget 소비 → fetchFn 실행 → 결과 캐싱. -// source: FETCH_GOVERNANCE 의 source 키 (예: "naver_flow") -// bucket: consumeFetchBudget_ 의 bucket 파라미터 (종목코드 또는 심볼) -// emptyFallback: circuit/budget 차단 시 반환할 기본값 객체 ({ ok:false, ... }) -// fetchFn: () → 결과 객체. try/catch 불필요 (래퍼가 처리). ok 필드로 성공/실패 판단. -function withFetchCache_(cacheKey, source, bucket, emptyFallback, fetchFn) { - const cached = getCachedFetchResult_(cacheKey); - if (cached) { - const annotated = annotateFetchValue_(cached, source, bucket); - // Stale-revalidate: 캐시 데이터가 영업일 기준 오래됐으면 캐시 무효화 후 즉시 re-fetch. - // 주가·수급 데이터는 D-1(STALE)이면 당일 데이터로 교체해야 의사결정에 사용 가능. - // 호가(quote)는 30분 TTL이 짧아서 자연 만료되므로 stale revalidate 불필요. - if (annotated.data_value_status === "STALE" - && source !== "naver_quote" - && source !== "yahoo_quote") { - try { CacheService.getScriptCache().remove(CACHE_VERSION + cacheKey); } catch (_) {} - Logger.log("[STALE_REVALIDATE] " + source + "/" + bucket + " — 캐시 무효화 후 re-fetch"); - // fall through to re-fetch below - } else { - return annotated; - } - } - if (isFetchCircuitOpen_(source)) { - return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_CIRCUIT_OPEN", source }, source, bucket); - } - if (!consumeFetchBudget_(source, bucket)) { - return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_BUDGET_EXCEEDED", source }, source, bucket); - } - let result; - try { - result = fetchFn(); - } catch (e) { - result = { ...emptyFallback, ok: false, error: e.message, source }; - } - result = annotateFetchValue_(result, source, bucket); - if (result.ok) { - recordFetchSuccess_(source); - setCachedFetchResult_(cacheKey, result, true, `${source}_ok`); - } else { - recordFetchFailure_(source); - setCachedFetchResult_(cacheKey, result, false, "failure"); - } - return result; -} - -// ── Naver frgn.naver 파서 ───────────────────────────────────────────────── -function fetchNaverFlow(code) { - const ticker = normalizeTickerCode(code); - const cacheKey = `naver_flow_${ticker}`; - return withFetchCache_(cacheKey, "naver_flow", ticker, { ok: false, rows: [], isFlowStale: false }, () => { - const resp = UrlFetchApp.fetch(`https://finance.naver.com/item/frgn.naver?code=${code}&page=1`, { - headers: { "Accept-Language": "ko-KR,ko;q=0.9" }, - muteHttpExceptions: true - }); - const html = resp.getContentText("EUC-KR"); - const rows = []; - const trPattern = /]*>([\s\S]*?)<\/tr>/g; - let trMatch; - while ((trMatch = trPattern.exec(html)) !== null) { - const tds = []; - const tdPattern = /]*>([\s\S]*?)<\/td>/g; - let td; - while ((td = tdPattern.exec(trMatch[1])) !== null) { - tds.push(td[1].replace(/<[^>]+>/g, "").replace(/ /g, "").trim()); - } - if (tds.length < 7 || !/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue; - const n = s => { const v = s.replace(/,/g,"").replace(/[+]/g,"").trim(); return isNaN(+v)||!v ? 0 : +v; }; - const inst = n(tds[5]), frgn = n(tds[6]); - rows.push({ date: tds[0], inst, frgn, indiv: -(frgn + inst) }); - if (rows.length >= 20) break; - } - const isFlowStale = rows.length > 0 && isStalePriceDate_(rows[0].date.replace(/\./g, "-")); - return { ok: rows.length >= 5, rows, source: "naver_flow", isFlowStale }; - }); -} - -// ── Yahoo Finance 가격 조회 ─────────────────────────────────────────────── -function fetchYahooPrice(code) { - // 한국 종목/ETF 코드: 6자리 알파뉴메릭 → .KS suffix. ^ 기호로 시작하는 글로벌 지수는 제외. - const sym0 = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code; - const sym = sym0.replace(/\^/g, "%5E"); - const cacheKey = `yahoo_price_${sym}`; - return withFetchCache_(cacheKey, "yahoo_price", sym0, { ok: false }, () => { - const resp = UrlFetchApp.fetch(`https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=3mo`, { - muteHttpExceptions: true, - headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } - }); - if (resp.getResponseCode() !== 200) return { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_price" }; - const closes = JSON.parse(resp.getContentText()) - ?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? []; - if (closes.length < 5) return { ok: false, source: "yahoo_price" }; - const last = closes[closes.length-1]; - const d5 = closes[Math.max(0, closes.length-6)]; - const d10 = closes[Math.max(0, closes.length-11)]; - const d20 = closes[Math.max(0, closes.length-21)]; - return { ok: true, close: last, - ret5D: ((last/d5 -1)*100).toFixed(2), - ret10D: ((last/d10-1)*100).toFixed(2), - ret20D: ((last/d20-1)*100).toFixed(2), - source: "yahoo_price" }; - }); -} - -function fetchYahooMarketMetrics(code) { - const sym = normalizeYahooSymbol(code); - const cacheKey = `yahoo_quote_${sym}`; - const cached = getCachedFetchResult_(cacheKey); - if (cached) return cached; - if (isFetchCircuitOpen_("yahoo_quote")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_quote", quoteStatus: "QUOTE_CIRCUIT_OPEN" }; - if (!consumeFetchBudget_("yahoo_quote", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_quote", quoteStatus: "QUOTE_BUDGET_EXCEEDED" }; - const apiUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(sym)}`; - let apiError = ""; - let apiHttpStatus = null; - function extractQuotedNumber_(text, marker, limitChars) { - const start = text.indexOf(marker); - if (start < 0) return null; - const segment = text.slice(start + marker.length, start + marker.length + limitChars); - const match = segment.match(/([0-9,]+(?:\.[0-9]+)?)\s*x/i); - return match ? parseKrNum_(match[1]) : null; - } - try { - const resp = UrlFetchApp.fetch(apiUrl, { - muteHttpExceptions: true, - headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } - }); - apiHttpStatus = resp.getResponseCode(); - let data = null; - if (apiHttpStatus === 200) { - try { - data = JSON.parse(resp.getContentText()); - } catch (e) { - apiError = `JSON_${e.message}`; - } - } else { - apiError = `HTTP ${apiHttpStatus}`; - } - - const item = data?.quoteResponse?.result?.[0]; - const marketPrice = Number(item?.regularMarketPrice) || null; - // Yahoo v7 quote API에서 추가 기본 지표 추출 (이미 수신된 응답 재활용) - const yahooBeta = Number.isFinite(Number(item?.beta)) ? Number(item?.beta) : null; - const yahooH52 = Number.isFinite(Number(item?.fiftyTwoWeekHigh)) ? Number(item?.fiftyTwoWeekHigh) : null; - const yahooL52 = Number.isFinite(Number(item?.fiftyTwoWeekLow)) ? Number(item?.fiftyTwoWeekLow) : null; - const yahooDiv = Number.isFinite(Number(item?.trailingAnnualDividendYield)) ? Number(item?.trailingAnnualDividendYield) * 100 : null; - let source = "yahoo_quote_api"; - let quoteStatus = "QUOTE_API_NO_MATCH"; - let resolvedBid = Number.isFinite(Number(item?.bid)) ? Number(item?.bid) : null; - let resolvedAsk = Number.isFinite(Number(item?.ask)) ? Number(item?.ask) : null; - - if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) { - const htmlUrl = `https://finance.yahoo.com/quote/${encodeURIComponent(sym)}?webview=1`; - const htmlResp = UrlFetchApp.fetch(htmlUrl, { - muteHttpExceptions: true, - headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } - }); - if (htmlResp.getResponseCode() === 200) { - const text = htmlResp.getContentText(); - const bidFromTitle = extractQuotedNumber_(text, 'title="Bid"', 160); - const askFromTitle = extractQuotedNumber_(text, 'title="Ask"', 160); - if (Number.isFinite(bidFromTitle) && Number.isFinite(askFromTitle) && bidFromTitle > 0 && askFromTitle > 0) { - resolvedBid = bidFromTitle; - resolvedAsk = askFromTitle; - source = "yahoo_quote_html"; - quoteStatus = "QUOTE_HTML_FALLBACK"; - } else { - const rawBid = text.match(/"bid"[^0-9]*([0-9.]+)/i); - const rawAsk = text.match(/"ask"[^0-9]*([0-9.]+)/i); - const candidateBid = rawBid ? parseKrNum_(rawBid[1]) : null; - const candidateAsk = rawAsk ? parseKrNum_(rawAsk[1]) : null; - if (Number.isFinite(candidateBid) && Number.isFinite(candidateAsk) && candidateBid > 0 && candidateAsk > 0) { - resolvedBid = candidateBid; - resolvedAsk = candidateAsk; - source = "yahoo_quote_html"; - quoteStatus = "QUOTE_HTML_FALLBACK"; - } - } - if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) { - quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_NO_MATCH"; - } - } else { - quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_BLOCKED"; - } - } - - const spreadPct = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0 - ? ((resolvedAsk - resolvedBid) / ((resolvedAsk + resolvedBid) / 2)) * 100 - : null; - const ok = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0; - const result = { - ok, - source, - quoteStatus, - bid: resolvedBid, - ask: resolvedAsk, - spreadPct, - marketPrice, - beta: yahooBeta, - high52W: yahooH52, - low52W: yahooL52, - divYield: yahooDiv, - httpStatus: apiHttpStatus, - error: apiError || "" - }; - if (ok) { - recordFetchSuccess_("yahoo_quote"); - setCachedFetchResult_(cacheKey, result, true, "yahoo_quote_ok"); - } else { - recordFetchFailure_("yahoo_quote"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - } - return result; - } catch (e) { - const result = { ok: false, error: e.message, source: "yahoo_quote", quoteStatus: "QUOTE_ERROR" }; - recordFetchFailure_("yahoo_quote"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } -} - -function fetchNaverMarketMetrics(code) { - const ticker = normalizeTickerCode(code); - const cacheKey = `naver_quote_${ticker}`; - const cached = getCachedFetchResult_(cacheKey); - if (cached) return cached; - if (isFetchCircuitOpen_("naver_quote")) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_CIRCUIT_OPEN", httpStatus: null }; - if (!consumeFetchBudget_("naver_quote", ticker)) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_BUDGET_EXCEEDED", httpStatus: null }; - const url = `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(code)}`; - try { - const resp = UrlFetchApp.fetch(url, { - muteHttpExceptions: true, - headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" } - }); - const httpStatus = resp.getResponseCode(); - if (httpStatus !== 200) { - const result = { ok: false, source: "naver_main", quoteStatus: `NAVER_QUOTE_HTTP_${httpStatus}`, httpStatus }; - recordFetchFailure_("naver_quote"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } - - const html = resp.getContentText("EUC-KR"); - const currentMatch = html.match(/오늘의시세\s+([0-9,]+)\s+포인트/i) || html.match(/현재가\s+([0-9,]+)/i); - const currentPrice = currentMatch ? parseKrNum_(currentMatch[1]) : null; - - const perMatch = html.match(/([\d,.]+)<\/em>/); - const pbrMatch = html.match(/([\d,.]+)<\/em>/); - const epsMatch = html.match(/([\d,.-]+)<\/em>/); - const per = perMatch ? parseKrNum_(perMatch[1]) : null; - const pbr = pbrMatch ? parseKrNum_(pbrMatch[1]) : null; - const eps = epsMatch ? parseKrNum_(epsMatch[1]) : null; - - // 배당수익률 — Naver main 페이지 _dvr ID - const dvrMatch = html.match(/([\d,.]+)<\/em>/); - const dvr = dvrMatch ? parseKrNum_(dvrMatch[1]) : null; - - // 52주 최고/최저 — Naver main 페이지 여러 패턴 시도 - const parseNum_ = s => { const v = parseFloat(String(s ?? "").replace(/,/g, "")); return Number.isFinite(v) && v > 0 ? v : null; }; - const h52m = html.match(/52[주週]최고[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) || - html.match(/52주\s*최고가?[\s\S]{0,100}?]*>([\d,]+)<\/em>/) || - html.match(/high52[^>]*>\s*([\d,]+)/) || - html.match(/([\d,]+)<\/em>/); - const l52m = html.match(/52[주週]최저[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) || - html.match(/52주\s*최저가?[\s\S]{0,100}?]*>([\d,]+)<\/em>/) || - html.match(/low52[^>]*>\s*([\d,]+)/) || - html.match(/([\d,]+)<\/em>/); - const naverHigh52W = h52m ? parseNum_(h52m[1]) : null; - const naverLow52W = l52m ? parseNum_(l52m[1]) : null; - - const askPrices = []; - const bidPrices = []; - const askRowPattern = /[\s\S]*?\s*([0-9,]+)\s*<\/td>\s*\s*([0-9,]+)\s*<\/td>/g; - const bidRowPattern = /[\s\S]*?\s*<\/td>\s*\s*([0-9,]+)\s*<\/td>\s*\s*([0-9,]+)\s*<\/td>/g; - let m; - while ((m = askRowPattern.exec(html)) !== null) { - const ask = parseKrNum_(m[2]); - if (Number.isFinite(ask) && ask > 0) askPrices.push(ask); - } - while ((m = bidRowPattern.exec(html)) !== null) { - const bid = parseKrNum_(m[1]); - if (Number.isFinite(bid) && bid > 0) bidPrices.push(bid); - } - - const bid = bidPrices.length ? Math.max(...bidPrices) : null; - const ask = askPrices.length ? Math.min(...askPrices) : null; - const spreadPct = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0 - ? ((ask - bid) / ((ask + bid) / 2)) * 100 - : null; - const ok = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0; - - const result = { - ok, - source: "naver_main", - quoteStatus: ok ? "NAVER_QUOTE_OK" : "NAVER_QUOTE_NO_MATCH", - bid, - ask, - spreadPct, - marketPrice: Number.isFinite(currentPrice) ? currentPrice : null, - per, - pbr, - eps, - dvr, - high52W: naverHigh52W, - low52W: naverLow52W, - httpStatus - }; - if (ok) { - recordFetchSuccess_("naver_quote"); - setCachedFetchResult_(cacheKey, result, true, "naver_quote_ok"); - } else { - recordFetchFailure_("naver_quote"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - } - return result; - } catch (e) { - const result = { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_ERROR", error: e.message }; - recordFetchFailure_("naver_quote"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } -} - -// Backward-compatible thin wrapper. -// Older callers still expect fetchNaverQuoteMetrics(). -function fetchNaverQuoteMetrics(code) { - return fetchNaverMarketMetrics(code); -} - -function fetchNaverOhlcMetrics(code) { - const ticker = normalizeTickerCode(code); - const cacheKey = `naver_ohlc_${ticker}`; - const cached = getCachedFetchResult_(cacheKey); - if (cached) return cached; - if (isFetchCircuitOpen_("naver_ohlc")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "naver_ohlc" }; - if (!consumeFetchBudget_("naver_ohlc", ticker)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "naver_ohlc" }; - const rows = []; - try { - for (let page = 1; page <= 7 && rows.length < 65; page++) { - const url = `https://finance.naver.com/item/sise_day.naver?code=${encodeURIComponent(ticker)}&page=${page}`; - const resp = UrlFetchApp.fetch(url, { - muteHttpExceptions: true, - headers: { - "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", - "Referer": `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(ticker)}` - } - }); - if (resp.getResponseCode() !== 200) continue; - const html = resp.getContentText("EUC-KR"); - const trPattern = /]*>([\s\S]*?)<\/tr>/g; - let trMatch; - while ((trMatch = trPattern.exec(html)) !== null) { - const tdPattern = /]*>([\s\S]*?)<\/td>/g; - const tds = []; - let td; - while ((td = tdPattern.exec(trMatch[1])) !== null) { - tds.push(td[1].replace(/<[^>]+>/g, "").replace(/ /g, "").replace(/\s+/g, " ").trim()); - } - if (tds.length < 7) continue; - if (!/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue; - const n = (s) => { - const v = String(s ?? "").replace(/,/g, "").replace(/[+]/g, "").trim(); - return v && !isNaN(+v) ? +v : null; - }; - const close = n(tds[1]); - const open = n(tds[3]); - const high = n(tds[4]); - const low = n(tds[5]); - const volume = n(tds[6]); - if ([close, open, high, low, volume].some(v => v == null)) continue; - rows.push({ - date: tds[0], - open, - high, - low, - close, - volume - }); - if (rows.length >= 65) break; - } - } - if (rows.length < 21) { - const result = { ok: false, error: `NAVER_OHLC_ROWS_${rows.length}`, source: "naver_ohlc" }; - recordFetchFailure_("naver_ohlc"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } - const latest = rows[0]; - const derived = calcDerivedPriceMetrics(rows, true); - const atr20 = calcAtr20(rows.slice().reverse()); - const avg5 = avgTradingValueM(rows.slice(1).reverse(), 5); - const avg20 = avgTradingValueM(rows.slice(1).reverse(), 20); - const currentValue = tradingValueM(latest); - const quote = fetchNaverMarketMetrics(ticker); - const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0 - ? ((currentValue / avg5) - 1) * 100 - : null; - const isPriceStale = isStalePriceDate_(latest.date); - const result = { - ok: true, - source: "Naver Finance sise_day.naver", - rows: rows.slice().reverse(), - priceDate: latest.date, - isPriceStale, - close: latest.close, - open: derived.open, - high: derived.high, - low: derived.low, - volume: derived.volume, - prevClose: derived.prevClose, - avgVolume5D: derived.avgVolume5D, - ma20: derived.ma20, - ma60: derived.ma60, - ret5D: derived.ret5D, - ret10D: derived.ret10D, - ret20D: derived.ret20D, - ret60D: derived.ret60D, - atr20, - atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null, - valSurge, - avgTradingValue5D: avg5, - avgTradingValue20D: avg20, - bid: Number.isFinite(quote.bid) ? quote.bid : null, - ask: Number.isFinite(quote.ask) ? quote.ask : null, - spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null, - marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : latest.close, - quoteSource: quote.source ?? "naver_main", - quoteStatus: quote.quoteStatus ?? "NAVER_QUOTE_NO_MATCH", - quoteHttpStatus: quote.httpStatus ?? null - }; - recordFetchSuccess_("naver_ohlc"); - setCachedFetchResult_(cacheKey, result, true, "naver_ohlc_ok"); - return result; - } catch (e) { - const result = { ok: false, error: e.message, source: "naver_ohlc" }; - recordFetchFailure_("naver_ohlc"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } -} - -// ── 에러 처리 레이어 ───────────────────────────────────────────────────────── -// severity: "CRITICAL" | "WARN" | "INFO" -// CRITICAL: 시트 쓰기 실패, pre-read 실패 → 전체 실행 영향 -// WARN: 개별 종목 fetch 실패 → 해당 종목만 영향 -// INFO: 캐시 관련 오류 → 무시 가능 -function handleFetchError_(context, e, severity) { - Logger.log(`[${severity}] ${context}: ${e}`); -} - -function normalizeYahooSymbol(code) { - let sym = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code; - return sym.replace(/\^/g, "%5E"); -} - -function normalizeTickerCode(code) { - const raw = String(code ?? "").trim(); - if (!raw) return ""; - if (/^[0-9]+$/.test(raw)) return raw.padStart(6, "0"); - if (/^[0-9A-Z]+$/i.test(raw) && raw.length < 6) return raw.padStart(6, "0"); - return raw; -} - -function fetchYahooOhlcMetrics(code) { - const sym = normalizeYahooSymbol(code); - const cacheKey = `yahoo_chart_${sym}`; - const cached = getCachedFetchResult_(cacheKey); - if (cached) return cached; - if (isFetchCircuitOpen_("yahoo_chart")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_chart" }; - if (!consumeFetchBudget_("yahoo_chart", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_chart" }; - const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=6mo`; - try { - const resp = UrlFetchApp.fetch(url, { - muteHttpExceptions: true, - headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } - }); - if (resp.getResponseCode() !== 200) { - const result = { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_chart" }; - recordFetchFailure_("yahoo_chart"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } - const data = JSON.parse(resp.getContentText()); - const chartResult = data?.chart?.result?.[0]; - const ts = chartResult?.timestamp ?? []; - const q = chartResult?.indicators?.quote?.[0] ?? {}; - const rows = []; - for (let i = 0; i < ts.length; i++) { - const open = q.open?.[i]; - const high = q.high?.[i]; - const low = q.low?.[i]; - const close = q.close?.[i]; - const volume = q.volume?.[i]; - if ([open, high, low, close, volume].some(v => v == null || isNaN(+v))) continue; - const d = new Date(ts[i] * 1000); - rows.push({ - date: Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd"), - open: +open, - high: +high, - low: +low, - close: +close, - volume: +volume - }); - } - if (rows.length < 21) { - const result = { ok: false, error: `OHLC_ROWS_${rows.length}`, source: "yahoo_chart" }; - recordFetchFailure_("yahoo_chart"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } - const latest = rows[rows.length - 1]; - const derived = calcDerivedPriceMetrics(rows, false); - const atr20 = calcAtr20(rows); - const avg5 = avgTradingValueM(rows.slice(0, -1), 5); - const avg20 = avgTradingValueM(rows.slice(0, -1), 20); - const currentValue = tradingValueM(latest); - let quote = fetchNaverMarketMetrics(code); - if (!quote.ok) quote = fetchYahooMarketMetrics(code); - const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0 - ? ((currentValue / avg5) - 1) * 100 - : null; - const result = { - ok: true, - source: "Yahoo Finance chart", - rows, - priceDate: latest.date, - isPriceStale: isStalePriceDate_(latest.date), - close: latest.close, - open: derived.open, - high: derived.high, - low: derived.low, - volume: derived.volume, - prevClose: derived.prevClose, - avgVolume5D: derived.avgVolume5D, - ma20: derived.ma20, - ma60: derived.ma60, - ret5D: derived.ret5D, - ret10D: derived.ret10D, - ret20D: derived.ret20D, - ret60D: derived.ret60D, - atr20, - atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null, - valSurge, - avgTradingValue5D: avg5, - avgTradingValue20D: avg20, - bid: Number.isFinite(quote.bid) ? quote.bid : null, - ask: Number.isFinite(quote.ask) ? quote.ask : null, - spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null, - marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : null, - quoteSource: quote.source ?? "QUOTE_NO_MATCH", - quoteStatus: quote.quoteStatus ?? "QUOTE_NO_MATCH", - quoteHttpStatus: quote.httpStatus ?? null - }; - recordFetchSuccess_("yahoo_chart"); - setCachedFetchResult_(cacheKey, result, true, "yahoo_chart_ok"); - return result; - } catch (e) { - const result = { ok: false, error: e.message, source: "yahoo_chart" }; - recordFetchFailure_("yahoo_chart"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } -} - -function fetchNaverDisclosureNotices(code) { - const ticker = normalizeTickerCode(code); - const cacheKey = `naver_notice_${ticker}`; - const cached = getCachedFetchResult_(cacheKey); - if (cached) return cached; - if (isFetchCircuitOpen_("naver_notice")) return { status: "NAVER_NOTICE_CIRCUIT_OPEN", source: "Naver Finance news_notice.naver", list: [] }; - if (!consumeFetchBudget_("naver_notice", ticker)) return { status: "NAVER_NOTICE_BUDGET_EXCEEDED", source: "Naver Finance news_notice.naver", list: [] }; - const url = `https://finance.naver.com/item/news_notice.naver?code=${code}&page=1`; - try { - const resp = UrlFetchApp.fetch(url, { - headers: { - Referer: `https://finance.naver.com/item/main.naver?code=${code}`, - Accept: "text/html,application/xhtml+xml" - }, - muteHttpExceptions: true - }); - if (resp.getResponseCode() !== 200) { - const result = { status: `NAVER_NOTICE_HTTP_${resp.getResponseCode()}`, source: "Naver Finance news_notice.naver", list: [] }; - recordFetchFailure_("naver_notice"); - setCachedFetchResult_(cacheKey, result, false, "failure"); - return result; - } - const html = resp.getContentText("EUC-KR"); - const rows = []; - const trMatches = html.match(//gi) || []; - for (const tr of trMatches) { - const text = tr - .replace(//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) { - 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 update cycle) - let liveTotalAssetKrw = Number.isFinite(settlementCashD2_) ? settlementCashD2_ : 0; - for (const ticker of Object.keys(positionStopMap_)) { - const priceMetrics = resolveDataFeedPriceMetrics(ticker); - const qty = positionStopMap_[ticker].quantity; - if (priceMetrics.ok && Number.isFinite(priceMetrics.close) && Number.isFinite(qty)) { - liveTotalAssetKrw += priceMetrics.close * qty; - } - } - if (liveTotalAssetKrw > 0) { - totalAssetKrw_ = liveTotalAssetKrw; - Logger.log(`[WBS-1.2] total_asset_krw 실시간 재계산 완료: ${totalAssetKrw_} KRW (현금: ${settlementCashD2_})`); - } - - // 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); - } -} - - diff --git a/src/gas/collection/gdc_02_account_satellite.gs b/src/gas/collection/gdc_02_account_satellite.gs deleted file mode 100644 index 21acf83..0000000 --- a/src/gas/collection/gdc_02_account_satellite.gs +++ /dev/null @@ -1,2160 +0,0 @@ -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) { - 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:"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; -} diff --git a/src/gas/collection/gdf_01_price_metrics.gs b/src/gas/collection/gdf_01_price_metrics.gs deleted file mode 100644 index 6fcbbcf..0000000 --- a/src/gas/collection/gdf_01_price_metrics.gs +++ /dev/null @@ -1,2448 +0,0 @@ -/** - * gas_data_feed.gs — Google Apps Script 버전 - * - * Phase 2: GAS에서 Naver Finance를 직접 호출해 데이터 수집. - * EUC-KR 인코딩을 GAS 네이티브로 처리 (iconv 불필요). - * - * 배포 방법: - * 1. script.google.com → 새 프로젝트 - * 2. 이 파일 붙여넣기 - * 3. 트리거 설정: runDataFeed → 시간 기반 → 매일 → 16:30~17:30 - * - * 실행 시간 전략 (GAS 6분 제한): - * - data_feed: 보유 10종목만 → ~30초 - * - sector_flow: 11섹터×3종목 → ~3분 - * - macro/unified: 단순 집계 → ~30초 - * - core_satellite(100종목): 별도 트리거, 청크 분할 실행 - * - * 하네스 통합: - * - buildHarnessContext_()와 관련 헬퍼는 이 파일에 직접 포함된다. - * - 별도 하네스 파일 없이 이 파일 하나만 배포해도 된다. - */ - -const SPREADSHEET_ID = "1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM"; -const SCHEMA_VERSION = "2026-05-15-qg2"; -const TICKERS_BASE = [ - { code: "005930", name: "삼성전자" }, - { code: "000660", name: "SK하이닉스" }, - { code: "000270", name: "기아" }, - { code: "091160", name: "KODEX 반도체" }, - { code: "064350", name: "현대로템" }, - { code: "012450", name: "한화에어로스페이스" }, - { code: "028050", name: "삼성E&A" }, - { code: "010120", name: "LS ELECTRIC" }, - { code: "0117V0", name: "TIGER AI전력기기" }, - { code: "494670", name: "TIGER 조선TOP10" }, - { code: "471990", name: "KODEX AI반도체핵심장비" }, -]; - -// TICKERS 우선순위: TICKERS_BASE → account_snapshot 보유종목 → watch_tickers_override 수동 추가. -// account_snapshot에 보유수량(qty > 0)이 있는 종목은 TICKERS_BASE에 없어도 자동 포함된다. -function getActiveTickers_() { - let tickers = TICKERS_BASE.slice(); - const existingCodes = new Set(tickers.map(t => t.code)); - - // ── 1. account_snapshot 자동 동기 ───────────────────────────────────────── - // parse_status=CAPTURE_READ_OK + holding_quantity > 0 인 KR 종목을 자동 포함. - // 미국 종목(GOOGL/MSFT 등, 순 알파벳 코드)은 Naver Finance 조회 불가 → skip. - // 소수주(qty < 1)도 동일 종목코드가 이미 추가됐으면 중복 추가 없음. - try { - const ss = getSpreadsheet_(); - const snapSh = ss.getSheetByName("account_snapshot"); - if (snapSh) { - const snapData = snapSh.getDataRange().getValues(); - // account_snapshot은 row 1(index 0) = 안내, row 2(index 1) = 헤더 - const headerRowIdx = snapData.length >= 2 ? 1 : 0; - const hdr = snapData[headerRowIdx].map(h => String(h).trim()); - const codeIdx = hdr.indexOf("ticker"); - const nameIdx = hdr.indexOf("name"); - const qtyIdx = hdr.indexOf("holding_quantity"); - const parseIdx = hdr.indexOf("parse_status"); - const ptIdx = hdr.indexOf("position_type"); - if (codeIdx >= 0) { - for (let i = headerRowIdx + 1; i < snapData.length; i++) { - const row = snapData[i]; - const rawCode = String(row[codeIdx] || "").trim(); - if (!rawCode) continue; - // 미국 종목 skip: GOOGL, MSFT, NVDA 등 순수 알파벳은 Naver 조회 불가 - if (/^[A-Za-z]+$/.test(rawCode)) { - Logger.log("[TICKERS_SNAPSHOT] US종목 skip: " + rawCode); - continue; - } - const normCode = normalizeTickerCode(rawCode); - if (!normCode) continue; - if (parseIdx >= 0) { - const ps = String(row[parseIdx] || "").trim().toUpperCase(); - if (ps && ps !== "CAPTURE_READ_OK") continue; - } - const qty = parseFloat(row[qtyIdx] ?? 0) || 0; - if (qty <= 0) continue; - if (!existingCodes.has(normCode)) { - const name = nameIdx >= 0 ? String(row[nameIdx] || normCode).trim() : normCode; - tickers.push({ code: normCode, name: name }); - existingCodes.add(normCode); - Logger.log("[TICKERS_SNAPSHOT] 자동 추가: " + normCode + " (" + name + ") qty=" + qty); - } - } - } - } - } catch (e) { - Logger.log("[TICKERS_SNAPSHOT][WARN] account_snapshot 읽기 실패: " + e.message); - } - - // ── 2. watch_tickers_override 수동 추가 (settings 탭) ────────────────────── - // 형식: "코드1:이름1,코드2:이름2" — 위 두 소스에 없는 종목을 수동 추가할 때 사용. - try { - const ss = getSpreadsheet_(); - const sh = ss.getSheetByName("settings"); - if (sh) { - const data = sh.getDataRange().getValues(); - for (let i = 0; i < data.length; i++) { - if (String(data[i][0] || "").trim() !== "watch_tickers_override") continue; - const raw = String(data[i][1] || "").trim(); - if (!raw) break; - raw.split(",").forEach(entry => { - const [code, name] = entry.trim().split(":").map(s => s.trim()); - const normCode = normalizeTickerCode(code || ""); - if (normCode && !existingCodes.has(normCode)) { - tickers.push({ code: normCode, name: name || normCode }); - existingCodes.add(normCode); - Logger.log("[TICKERS_OVERRIDE] 수동 추가: " + normCode + " (" + (name || normCode) + ")"); - } - }); - break; - } - } - } catch (e) { - Logger.log("[TICKERS_OVERRIDE][WARN] settings 읽기 실패: " + e.message); - } - - Logger.log("[getActiveTickers_] 최종 종목 수: " + tickers.length - + " (base=" + TICKERS_BASE.length + " total=" + tickers.length + ")"); - return tickers; -} - -// 하위 호환: 기존 코드가 TICKERS를 직접 참조하는 경우를 위해 별칭 유지. -// runDataFeed()는 getActiveTickers_()를 호출해 동적 목록을 사용. -const TICKERS = TICKERS_BASE; - -// 종목 → 섹터 매핑 (sector_flow의 Sector_Rank → C5 daily_leader_scan에 사용) -const TICKER_SECTOR_MAP = { - "005930": "반도체", "000660": "반도체", "042700": "반도체", - "010120": "AI전력", "267260": "AI전력", "006260": "AI전력", - "012450": "방산", "079550": "방산", "047810": "방산", "064350": "방산", - "329180": "조선", "042660": "조선", "009540": "조선", - "028050": "건설/EPC","000720": "건설/EPC","006360": "건설/EPC", - "005380": "자동차", "000270": "자동차", "012330": "자동차", - "105560": "금융/은행","055550": "금융/은행","086790": "금융/은행", - "373220": "2차전지","006400": "2차전지","051910": "2차전지", - "207940": "바이오", "068270": "바이오", "128940": "바이오", - "099440": "원전", "023450": "원전", "015760": "원전", - "028260": "소비재", "097950": "소비재", "004370": "소비재", - // ETF — 해당 섹터로 매핑 - "091160": "반도체", "0117V0": "AI전력", "494670": "조선", - "471990": "반도체", // KODEX AI반도체핵심장비 (누락 추가) - "266410": "바이오", "091180": "자동차", "091170": "금융/은행", - "305720": "2차전지","139220": "소비재", -}; - -// 섹터 → Tier 매핑 (C5 daily_leader_scan 점수 정밀화) -// Tier_1=1.0(+rank≤3), Tier_2=0.5 고정, Tier_3=0 -const SECTOR_TIER_MAP = { - "반도체": "Tier_1", - "AI전력": "Tier_1", - "방산": "Tier_1", - "조선": "Tier_1", - "자동차": "Tier_2", - "2차전지": "Tier_2", - "바이오": "Tier_2", - "원전": "Tier_2", - "건설/EPC": "Tier_3", - "금융/은행":"Tier_3", - "소비재": "Tier_3", -}; - -// KOSDAQ 상장 종목 Set — SS001_VAL_KOSDAQ_PEG(max 12pt) 적용 대상 -// 현재 보유 10종목은 모두 KOSPI 상장. KOSDAQ 종목 편입 시 코드 추가. -const KOSDAQ_TICKERS = new Set([ - // e.g., "035900", "003230" -]); - -const DART_CATALYST_KEYWORDS = [ - "수주", - "계약", - "실적", - "공급", - "납품", - "증설", - "합병", - "인수", - "배당", - "자사주", -]; - -const DART_RISK_KEYWORDS = [ - "감자", - // "정정" 제거: DART 제목 앞 접두어로 잠정실적·계약체결 등 모든 공시에 붙어 오탐 유발 - "상장폐지", - "관리종목", - "횡령", - "배임", - "불성실", - "소송", - "회생", - "유상증자", - "감사의견", // 감사의견 거절·한정 - "공시번복", // 공시 내용 번복 (실질적 정정) - "조사", // 금감원 조사 -]; - -// GAS_CACHE_MAX_TTL: GAS CacheService 최대 허용 TTL = 21600초(6시간). -// 초과 시 put()이 silently fail(try/catch 흡수) → 캐시 저장 안됨 → 매번 re-fetch 유발. -const GAS_CACHE_MAX_TTL = 21600; - -const FETCH_GOVERNANCE = { - budget: { - naver_flow: 1, - naver_quote: 1, - naver_ohlc: 1, - naver_notice: 1, - naver_consensus: 1, - naver_fund: 1, // 펀더멘털 fallback (분기별, 7일 캐시 우선) - yahoo_price: 1, - yahoo_quote: 1, - yahoo_chart: 1, - yahoo_financials: 1, - }, - ttl: { - naver_flow_ok: GAS_CACHE_MAX_TTL, // 6h (GAS 최대값) - naver_quote_ok: 30 * 60, // 30분 (장중 실시간 호가) - naver_ohlc_ok: GAS_CACHE_MAX_TTL, // 6h — 수정: 43200 → 21600 (GAS 초과 버그 fix) - naver_notice_ok: 4 * 60 * 60, // 4h - naver_consensus_ok: 4 * 60 * 60, // 4h - // 펀더멘털은 분기별 데이터 — CacheService 6h 저장 후 PropertiesService 7일 캐시로 이중 방어 - naver_fund_ok: GAS_CACHE_MAX_TTL, // 6h - yahoo_price_ok: GAS_CACHE_MAX_TTL, // 6h - yahoo_quote_ok: 30 * 60, // 30분 - yahoo_chart_ok: GAS_CACHE_MAX_TTL, // 6h - yahoo_financials_ok: GAS_CACHE_MAX_TTL, // 6h - failure: 10 * 60, // 10분 (재시도 대기) - }, - failureLimit: 3, - coolDownMs: 3 * 60 * 60 * 1000, -}; - -// ── 운영 임계값 상수 (magic number 50개+ → 단일 위치로 통합) ──────────────── -// 수치 변경 시 반드시 이 블록만 수정. 코드 본문 하드코딩 금지. -const THRESHOLDS = { - // Val_Surge_Pct 상태 구간 (%) - VAL_SURGE_WATCH: 15, - VAL_SURGE_HOT: 35, - VAL_SURGE_EXHAUSTED: 50, - // 유동성 — 5D 평균 거래대금 (백만원) - LIQUIDITY_PREFERRED_M: 100, - LIQUIDITY_OK_M: 50, - // 호가 스프레드 (%) - SPREAD_OK_PCT: 0.25, - SPREAD_WARN_PCT: 0.50, - // Take Profit 승수 (진입가 대비) - TP_CORE_1: 1.15, // core 1차 +15% - TP_CORE_2: 1.25, // core 2차 +25% - TP_SAT_1: 1.10, // satellite 1차 +10% - TP_SAT_2: 1.20, // satellite 2차 +20% - // Time Stop (calendar days) - TIME_STOP_STAGE1: 60, - TIME_STOP_STAGE2: 30, - // Bucket 할당 목표 범위 (%) - BUCKET_CORE_MIN: 60, - BUCKET_CORE_MAX: 72, - BUCKET_SAT_MIN: 10, - BUCKET_SAT_MAX: 25, - BUCKET_CASH_MIN: 10, - BUCKET_CASH_MAX: 22, - // Satellite 단일종목 비중 상한 (%) - SAT_BAND_MAX: 7, - // Orbit Gap 경보 (%p) - ORBIT_MILD_BEHIND: 1, - ORBIT_SIGNIFICANT_BEHIND: 3, - ORBIT_AHEAD_TARGET: -2, - // 포지션 수량 위험 예산 (기본 — settings 탭 override 가능) - DEFAULT_RISK_BUDGET: 0.007, - // ATR 기반 손절 승수 - ATR_STOP_MULT: 1.5, - ATR_TRAILING_MULT: 1.5, - ATR_STOP_MULT_HIGH: 2.0, // ATR20_Pct >= 8% 고변동성 종목 전용 - // Stage2 진입 최소 수익 (%) - STAGE2_GATE_MIN_PCT: 1.5, - // ── Sell_Priority_Score 산출 상수 (spec: portfolio_exposure.yaml:sell_priority_engine) ── - SP_HARD_STOP: 50, // EXIT_SIGNAL / EXIT_100 - SP_SELL_SIGNAL: 40, // SELL_READY / TRIM 신호 확정 - SP_HOLDINGS_ROTATE: 20, // EXIT_REVIEW / 보유주 교체 후보 - SP_TAKE_PROFIT: 10, // Profit_Pct >= 10% (익절 후보) - SP_ETF_DUPLICATE: 20, // ETF + 섹터노출 >= 20% (중복노출 상한 초과) - SP_ETF_MODERATE: 15, // ETF + 섹터노출 >= 10% - SP_CASH_LARGE: 15, // Weight_Pct >= 3% (현금 회복 효과 대) - SP_CASH_MID: 10, // Weight_Pct >= 1% - SP_CASH_SMALL: 3, - SP_RW4: 20, // RW_Partial >= 4 - SP_RW3: 15, // RW_Partial == 3 - SP_RW2: 8, // RW_Partial == 2 - SP_BELOW_MA20: 8, // close < MA20 - SP_LOSS_SATELLITE: 12, // 손실 >= -10%, 위성, 비코어리더 - SP_OVERWEIGHT_LARGE: 12, // 목표비중 초과 >= 5%p - SP_OVERWEIGHT_MID: 6, // 목표비중 초과 >= 2%p - SP_CORE_LEADER: -20, // 직접 코어 주도주 + 상승추세 (패널티) - SP_SS001_A: -12, // SS001 A등급 (패널티) - SP_DUPLICATE_THRESH: 20, // 섹터노출 중복 판정 기준 (%) -}; - - -function getKrxMarketSessionStatus_(dt) { - const d = dt instanceof Date ? dt : new Date(dt || new Date()); - if (isNaN(d.getTime())) { - return { open: false, reason: "invalid_datetime" }; - } - const kst = new Date(d.getTime() + 9 * 60 * 60 * 1000); - const day = kst.getUTCDay(); - const minutes = kst.getUTCHours() * 60 + kst.getUTCMinutes(); - const open = day >= 1 && day <= 5 && minutes >= 9 * 60 && minutes < 15 * 60 + 30; - return { - open: open, - reason: open ? "MARKET_OPEN" : "MARKET_CLOSED", - kst_date: Utilities.formatDate(kst, "Asia/Seoul", "yyyy-MM-dd"), - kst_time: Utilities.formatDate(kst, "Asia/Seoul", "HH:mm:ss"), - }; -} - -// account_snapshot freshness 확인 — last_updated/captured_at 최신 행 기준 경과일 반환 -function checkAccountSnapshotFreshness_() { - try { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("account_snapshot"); - if (!sheet) return { fresh: false, reason: "account_snapshot 탭 없음" }; - const session = getKrxMarketSessionStatus_(new Date()); - const data = sheet.getDataRange().getValues(); - if (data.length < 3) return { fresh: true, reason: "보유 원장 없음" }; - const hdr = data[1].map(h => String(h).trim()); - const luIdx = hdr.indexOf("last_updated") >= 0 ? hdr.indexOf("last_updated") : hdr.indexOf("captured_at"); - const qtyIdx = hdr.indexOf("holding_quantity"); - const statusIdx = hdr.indexOf("parse_status"); - const confirmedIdx = hdr.indexOf("user_confirmed"); - if (luIdx < 0) return { fresh: null, reason: "last_updated/captured_at 컬럼 없음" }; - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - let latestDate = null; - for (let i = 2; i < data.length; i++) { - const parseStatus = statusIdx >= 0 ? String(data[i][statusIdx] ?? "").trim() : ""; - const confirmed = confirmedIdx >= 0 ? String(data[i][confirmedIdx] ?? "").trim().toUpperCase() : ""; - if (parseStatus !== "CAPTURE_READ_OK" || !["Y", "YES", "TRUE", "1"].includes(confirmed)) continue; - const qty = parseInt(data[i][qtyIdx]); - if (!Number.isFinite(qty) || qty <= 0) continue; - const raw = data[i][luIdx]; - const d = raw instanceof Date - ? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM-dd") - : String(raw).trim().substring(0, 10); - if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d > (latestDate ?? "")) latestDate = d; - } - if (!latestDate) return { fresh: null, reason: "last_updated 미입력" }; - const daysDiff = Math.round((new Date(today) - new Date(latestDate)) / 86400000); - return { - fresh: daysDiff <= 1, - last_updated: latestDate, - days_stale: daysDiff, - reason: daysDiff <= 1 ? "최신" : `${daysDiff}일 경과 (${latestDate})`, - collection_allowed: session.open, - market_session_open: session.open, - market_session_reason: session.reason, - }; - } catch(e) { - return { fresh: null, reason: "읽기 오류: " + e.message }; - } -} - -function snapshotExecutionGate_(freshness) { - if (!freshness || freshness.fresh == null) { - return { - status: "BLOCK_EXECUTION", - reason: freshness && freshness.reason ? freshness.reason : "account_snapshot freshness unknown", - }; - } - if (freshness.fresh === false) { - return { - status: "REVIEW_ONLY", - reason: freshness.reason || "snapshot stale — proposal only", - }; - } - return { - status: "ALLOW_EXECUTION", - reason: freshness.reason || "최신", - }; -} - -function calcDerivedPriceMetrics(rows, latestFirst) { - if (!Array.isArray(rows) || rows.length === 0) return {}; - const ordered = latestFirst ? rows.slice().reverse() : rows.slice(); // oldest -> latest - const latest = ordered[ordered.length - 1] || {}; - const previous = ordered[ordered.length - 2] || {}; - const prior = (n) => ordered[ordered.length - 1 - n] || null; - const lastN = (n) => ordered.slice(Math.max(0, ordered.length - n)); - const prevN = (n) => ordered.slice(Math.max(0, ordered.length - 1 - n), ordered.length - 1); - return { - open: Number.isFinite(latest.open) ? latest.open : null, - high: Number.isFinite(latest.high) ? latest.high : null, - low: Number.isFinite(latest.low) ? latest.low : null, - volume: Number.isFinite(latest.volume) ? latest.volume : null, - prevClose: Number.isFinite(previous.close) ? previous.close : null, - avgVolume5D: prevN(5).length >= 5 ? avgNumber_(prevN(5).map(r => r.volume)) : null, - ma20: lastN(20).length >= 20 ? avgNumber_(lastN(20).map(r => r.close)) : null, - ma60: lastN(60).length >= 60 ? avgNumber_(lastN(60).map(r => r.close)) : null, - ret2D: prior(2) ? pctReturn_(latest.close, prior(2).close) : null, - ret5D: prior(5) ? pctReturn_(latest.close, prior(5).close) : null, - ret10D: prior(10) ? pctReturn_(latest.close, prior(10).close) : null, - ret20D: prior(20) ? pctReturn_(latest.close, prior(20).close) : null, - ret60D: prior(60) ? pctReturn_(latest.close, prior(60).close) : null, - }; -} - -// ── F1: 기술적 타이밍 지표 계산 ────────────────────────────────────────────── -// rows: oldest→latest OHLCV 배열. 25행 이상 필요. -function calcTimingMetrics_(rows) { - if (!Array.isArray(rows) || rows.length < 21) return {}; - const closes = rows.map(r => r.close); - const n = closes.length; - const close = closes[n - 1]; - - // MA20 slope: (오늘 MA20 - 5일전 MA20) / 5일전 MA20 × 100 - const ma20Today = closes.slice(n - 20).reduce((a, b) => a + b, 0) / 20; - let ma20Slope = null; - if (n >= 25) { - const ma20_5ago = closes.slice(n - 25, n - 5).reduce((a, b) => a + b, 0) / 20; - if (ma20_5ago > 0) ma20Slope = parseFloat(((ma20Today - ma20_5ago) / ma20_5ago * 100).toFixed(3)); - } - - // 이격도: (종가/MA20 - 1) × 100 - const disparity = ma20Today > 0 ? parseFloat(((close / ma20Today - 1) * 100).toFixed(2)) : null; - - // RSI 14 (Wilder's smoothed) - const rsi14 = calcRsi14_(closes); - - // 볼린저 밴드 (20일, 2σ) - const bb20 = closes.slice(n - 20); - const bbMean = bb20.reduce((a, b) => a + b, 0) / 20; - const bbVar = bb20.reduce((s, c) => s + Math.pow(c - bbMean, 2), 0) / 20; - const bbStd = Math.sqrt(bbVar); - const bbUpper = bbMean + 2 * bbStd; - const bbLower = bbMean - 2 * bbStd; - const bbWidth = bbMean > 0 ? parseFloat(((bbUpper - bbLower) / bbMean * 100).toFixed(2)) : null; - const bbPos = (bbUpper > bbLower) ? parseFloat(((close - bbLower) / (bbUpper - bbLower) * 100).toFixed(1)) : null; - - return { - ma20Slope, - disparity, - rsi14, - bbWidth, - bbPosition: bbPos, - bbUpper: Math.round(bbUpper), - bbLower: Math.round(bbLower), - }; -} - -// RSI14 — Wilder 방식. 최대 50개 바 사용해 초기화 편향 최소화. -// 14개만 초기화하면 ±5~8pt 오차 발생 — 사용 가능한 전체 데이터로 안정화. -function calcRsi14_(closes) { - if (closes.length < 15) return null; - const lookback = Math.min(closes.length, 50); - const c = closes.slice(closes.length - lookback); - let avgGain = 0, avgLoss = 0; - for (let i = 1; i <= 14; i++) { - const d = c[i] - c[i - 1]; - if (d > 0) avgGain += d; else avgLoss -= d; - } - avgGain /= 14; avgLoss /= 14; - for (let i = 15; i < c.length; i++) { - const d = c[i] - c[i - 1]; - avgGain = (avgGain * 13 + Math.max(0, d)) / 14; - avgLoss = (avgLoss * 13 + Math.max(0, -d)) / 14; - } - if (avgLoss === 0) return 100; - return parseFloat((100 - 100 / (1 + avgGain / avgLoss)).toFixed(1)); -} - -// ── F2: Entry Mode 게이트 ───────────────────────────────────────────────────── -// PULLBACK: 눌림목 매수 조건 / BREAKOUT: 돌파 매수 조건 / NEUTRAL: 대기 -function calcEntryMode_(timing, price) { - const { ma20Slope, disparity, rsi14 } = timing; - if (!Number.isFinite(disparity) || !Number.isFinite(rsi14)) { - return { mode: "NEUTRAL", gate: "PENDING", reason: "지표_부족" }; - } - const trendUp = Number.isFinite(ma20Slope) && ma20Slope > 0; - const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : 0; - const pct52H = Number.isFinite(price.pct52WHigh) ? price.pct52WHigh : -100; - - // 과열 — 두 전략 모두 진입 금지 - if (disparity > 12 || rsi14 > 75) { - return { mode: "OVERBOUGHT", gate: "BLOCK", reason: `과열(이격${disparity}%_RSI${rsi14})` }; - } - // 눌림목: 이격도 -5~+4% + MA20 상승 + RSI 35~58 - if (trendUp && disparity >= -5 && disparity <= 4 && rsi14 >= 35 && rsi14 <= 58) { - return { mode: "PULLBACK", gate: "PASS", reason: `눌림목(이격${disparity}%_RSI${rsi14})` }; - } - // 돌파: 52주 고점 -5% 이내 + 거래량 폭발 + RSI 50~72 + MA20 상승 - if (trendUp && pct52H >= -5 && valSurge >= 50 && rsi14 > 50 && rsi14 <= 72) { - return { mode: "BREAKOUT", gate: "PASS", reason: `돌파(52WH${pct52H.toFixed(1)}%_VOL+${valSurge.toFixed(0)}%)` }; - } - // MA20 하락 추세 - if (!trendUp && Number.isFinite(ma20Slope)) { - return { mode: "NEUTRAL", gate: "PENDING", reason: `MA20하락추세(slope${ma20Slope.toFixed(2)}%)` }; - } - return { mode: "NEUTRAL", gate: "PENDING", reason: `조건미충족(이격${disparity}%_RSI${rsi14})` }; -} - -// ── F3: 매도 타이밍 신호 ────────────────────────────────────────────────────── -// 복수 신호 발생 시 파이프(|) 구분. 포지션 없으면 빈 문자열. -function calcExitSignalDetail_(timing, price) { - const signals = []; - const { disparity, rsi14, ma20Slope } = timing; - const ret5D = Number.isFinite(price.ret5D) ? parseFloat(price.ret5D) : null; - const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : null; - - // 거래량 소진: 5일 수익률 양수인데 거래대금 평균 대비 -20% 미만 - if (ret5D !== null && ret5D > 0 && valSurge !== null && valSurge < -20) { - signals.push("VOL_EXHAUSTION"); - } - // MA20 붕괴: 종가 < MA20 AND MA20 하락 - if (price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && - price.close < price.ma20 && Number.isFinite(ma20Slope) && ma20Slope < 0) { - signals.push("MA20_BREAK"); - } - // 극단 과열: 이격도 > 15% - if (Number.isFinite(disparity) && disparity > 15) signals.push("DISPARITY_TOP"); - // RSI 과매수: RSI > 75 - if (Number.isFinite(rsi14) && rsi14 > 75) signals.push("RSI_OVERBOUGHT"); - - return signals.join("|"); -} - -// ── F5: 타이밍 종합 액션 ────────────────────────────────────────────────────── -// 종목 점수(SS001)와 별개로 "지금 무엇을 할지"를 분리한다. -var calcEntryTimingSignal_ = function(ctx) { - const reasons = []; - let entryScore = 0; - let exitScore = 0; - - const entryGate = String(ctx.entryModeGate ?? ""); - const entryMode = String(ctx.entryMode ?? ""); - const leaderGate = String(ctx.leaderGate ?? ""); - const acGate = String(ctx.acGate ?? ""); - const exitSignal = String(ctx.exitSignalDetail ?? ""); - const flowCredit = parseFloat(ctx.flowCredit); - const leaderTotal = parseFloat(ctx.leaderTotal); - const rwPartial = parseInt(ctx.rwPartial, 10); - const rsi14 = parseFloat(ctx.rsi14); - const disparity = parseFloat(ctx.disparity); - const ma20Slope = parseFloat(ctx.ma20Slope); - const spreadPct = parseFloat(ctx.spreadPct); - const avgTradeValue5D = parseFloat(ctx.avgTradeValue5D); - const profitPct = parseFloat(ctx.profitPct); - const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10); - - if (entryGate === "PASS") { entryScore += 25; reasons.push(`entry_${entryMode}`); } - else if (entryGate === "BLOCK") { entryScore -= 25; reasons.push("entry_block"); } - - if (Number.isFinite(leaderTotal)) { - if (leaderTotal >= 4) { entryScore += 20; reasons.push("leader_scan>=4"); } - else if (leaderTotal >= 3) { entryScore += 10; reasons.push("leader_watch"); } - } - if (leaderGate === "PASS" || leaderGate === "EXPLORE_CANDIDATE") entryScore += 10; - - if (Number.isFinite(flowCredit)) { - if (flowCredit >= 0.7) { entryScore += 20; reasons.push("flow_strong"); } - else if (flowCredit >= 0.4) { entryScore += 10; reasons.push("flow_partial"); } - } - - if (acGate === "CLEAR") { entryScore += 15; reasons.push("anti_climax_clear"); } - else if (acGate === "CAUTION") { entryScore += 5; reasons.push("anti_climax_caution"); } - else if (acGate === "BLOCK") { entryScore -= 35; exitScore += 15; reasons.push("anti_climax_block"); } - - if (Number.isFinite(ma20Slope)) { - if (ma20Slope > 0) entryScore += 8; - else { entryScore -= 8; exitScore += 8; reasons.push("ma20_down"); } - } - if (Number.isFinite(disparity)) { - if (disparity >= -5 && disparity <= 4) entryScore += 10; - else if (disparity > 4 && disparity <= 8) entryScore += 5; - else if (disparity > 12) { entryScore -= 25; exitScore += 20; reasons.push("overextended"); } - else if (disparity < -10) { entryScore -= 10; exitScore += 10; reasons.push("trend_damage"); } - } - if (Number.isFinite(rsi14)) { - if (rsi14 >= 40 && rsi14 <= 65) entryScore += 10; - else if (rsi14 > 65 && rsi14 <= 72) entryScore += 4; - else if (rsi14 > 75) { entryScore -= 25; exitScore += 20; reasons.push("rsi_overbought"); } - else if (rsi14 < 35) { entryScore -= 5; exitScore += 8; reasons.push("weak_rsi"); } - } - if (Number.isFinite(avgTradeValue5D) && avgTradeValue5D >= 50 - && (!Number.isFinite(spreadPct) || spreadPct <= 0.8)) { - entryScore += 10; - } else { - entryScore -= 15; - reasons.push("liquidity_or_spread_fail"); - } - - // RW: 수급 기반 상대약세 — 신뢰도 높아 25pt/건 (구: 20pt). 기술지표: 노이즈 多로 10pt/건 (구: 18pt). - // 결과: RW=0 + 기술신호 4개 = 40pt → EXIT_REVIEW 미도달. RW=1 + 기술신호 2개 = 45pt → 대기. - // RW=2 단독 = 50pt → EXIT_REVIEW. RW=3 단독 = 75pt → STOP_OR_TIME_EXIT_READY. - if (Number.isFinite(rwPartial)) exitScore += Math.min(100, Math.max(0, rwPartial) * 25); - if (exitSignal) exitScore += exitSignal.split("|").filter(Boolean).length * 10; - if (Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0 && daysToTimeStop <= 7) { - exitScore += 20; - reasons.push("time_stop_near"); - } - if (Number.isFinite(profitPct) && profitPct >= 10) { - exitScore += 15; - reasons.push("profit_protect_zone"); - } - - entryScore = Math.max(0, Math.min(100, Math.round(entryScore))); - exitScore = Math.max(0, Math.min(100, Math.round(exitScore))); - - let action = "HOLD_NO_TIMING_EDGE"; - if (ctx.priceStatus !== "PRICE_OK" || !Number.isFinite(parseFloat(ctx.atr20))) { - action = "OBSERVE_DATA_MISSING"; - } else if (exitScore >= 75 || (Number.isFinite(rwPartial) && rwPartial >= 4)) { - action = "STOP_OR_TIME_EXIT_READY"; - } else if (exitScore >= 50 || (Number.isFinite(rwPartial) && rwPartial >= 3)) { - action = "EXIT_REVIEW"; - } else if (entryGate === "BLOCK" || acGate === "BLOCK" || entryMode === "OVERBOUGHT") { - action = "NO_BUY_OVERHEATED"; - } else if (entryScore >= 75 && entryGate === "PASS" && leaderTotal >= 4) { - action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_STAGE1_READY"; - } else if (entryScore >= 60 && entryGate === "PASS") { - action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_PULLBACK_WAIT"; - } else if (leaderTotal >= 3 || flowCredit >= 0.4) { - action = "WATCH_TIMING_SETUP"; - } - - return { - entry_score: entryScore, - exit_score: exitScore, - action, - reason: reasons.slice(0, 6).join("|"), - }; -} - -// Backward-compatible thin wrapper. -// Existing data_feed callers still expect calcTimingRoute_. -var calcTimingRoute_ = function(ctx) { - return calcEntryTimingSignal_(ctx || {}); -} - -// ── F6: 매도 신호·가격 산출 (방향 A: 수량 계산은 GAS 담당 아님) ────────────────── -// Sell_Qty는 GAS에서 산출하지 않는다. 신호 종류 + 가격 + 비율만 출력. -// 보유수량 × 비율 계산은 사용자가 ChatGPT에 캡처를 제공하는 단계에서 처리한다. -var calcExitSellAction_ = function(ctx) { - const close = parseFloat(ctx.close); - const stopPrice = parseFloat(ctx.stopPrice); - const trailingStop = parseFloat(ctx.trailingStop); - const tp1Price = parseFloat(ctx.tp1Price); - const tp2Price = parseFloat(ctx.tp2Price); - const profitPct = parseFloat(ctx.profitPct); - const rwPartial = parseInt(ctx.rwPartial, 10); - const timingExitScore = parseFloat(ctx.timingExitScore); - const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10); - const timingAction = String(ctx.timingAction ?? ""); - const exitSignal = String(ctx.exitSignalDetail ?? ""); - const acGate = String(ctx.acGate ?? ""); - // sell_signal_priority level 2: REGIME_RISK_OFF (spec/exit/stop_loss.yaml) - const regime = String(ctx.regime ?? ""); - const atr20 = parseFloat(ctx.atr20); - - let action = "HOLD"; - let ratio = 0; - let reason = ""; - let price = ""; - let priceSource = ""; - let priceBasis = ""; - let executionWindow = ""; - let orderType = ""; - - const stopCandidate = Number.isFinite(trailingStop) && trailingStop > 0 - ? trailingStop - : Number.isFinite(stopPrice) && stopPrice > 0 - ? stopPrice - : Number.isFinite(close) && close > 0 - ? close * 0.995 - : null; - const protectiveLimit = Number.isFinite(close) && close > 0 - ? Math.round(Math.min(close * 0.995, stopCandidate ?? close * 0.995)) - : ""; - // ATR 기반 보호 하한: close - ATR20×0.3 (변동성 비례 버퍼). ATR 없으면 0.5% 폴백. - const atrBuffer = Number.isFinite(atr20) && atr20 > 0 ? atr20 * 0.3 : (Number.isFinite(close) ? close * 0.005 : 0); - const closeProtectLimit = Number.isFinite(close) && close > 0 ? Math.round(close - atrBuffer) : ""; - - // priority 1: hard stop / strong RW exit (spec sell_signal_priority level 1) - if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) { - action = "EXIT_100"; - ratio = 100; - reason = rwPartial >= 4 ? "RW_EXIT_STRONG" : "STOP_OR_TIME_EXIT_READY"; - price = protectiveLimit; - priceSource = Number.isFinite(trailingStop) ? "TRAILING_STOP" : "STOP_OR_CLOSE"; - priceBasis = Number.isFinite(trailingStop) ? "TRAILING_STOP_TRIGGER" : "STOP_OR_CLOSE_PROTECT"; - executionWindow = "INTRADAY_ON_TRIGGER"; - orderType = "PROTECTIVE_LIMIT_SELL"; - // priority 2: REGIME_TRIM_50 — 방향 A에서 개별 종목 신호 아님. - // RISK_OFF 레짐 포트폴리오 축소 경고는 getDailyBrief() 매크로 섹션에서 처리. - // priority 3: RW 신호 강 (spec level 3) - } else if (rwPartial >= 3 || timingExitScore >= 75) { - action = "TRIM_70"; - ratio = 70; - reason = rwPartial >= 3 ? "RW_EXIT" : "TIMING_EXIT_SCORE"; - price = protectiveLimit; - priceSource = "RISK_REDUCTION"; - priceBasis = "RISK_REDUCTION_CLOSE_PROTECT"; - executionWindow = "INTRADAY_AFTER_09_30"; - orderType = "PROTECTIVE_LIMIT_SELL"; - // priority 4: trailing stop 가격 직접 이탈 (spec level 4) — timingAction과 독립적으로 직접 비교 - } else if (Number.isFinite(trailingStop) && trailingStop > 0 && Number.isFinite(close) && close <= trailingStop) { - action = "TRAILING_STOP_BREACH"; - ratio = 70; - reason = "TRAILING_STOP_PRICE_BREACH"; - price = Math.round(trailingStop); // 트레일링 스탑 이탈: 스탑 가격 자체가 보호선 — min 적용 금지 - priceSource = "TRAILING_STOP_PRICE"; - priceBasis = "TRAILING_STOP_TRIGGER"; - executionWindow = "INTRADAY_ON_TRIGGER"; - orderType = "PROTECTIVE_LIMIT_SELL"; - // priority 4 (계속): RW 신호 중 (spec level 3 하위) - // RW=0 + 기술지표만으로는 TRIM_50 차단 — 수급 확인(rwPartial>=1) 필수 - } else if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) { - action = "TRIM_50"; - ratio = 50; - reason = rwPartial >= 2 ? "RW_REVIEW" : "TIMING_EXIT_REVIEW"; - price = closeProtectLimit; - priceSource = "RELATIVE_WEAKNESS_CLOSE"; - priceBasis = "PRIOR_CLOSE_X_0.998"; - executionWindow = "INTRADAY_AFTER_09_30"; - orderType = "LIMIT_SELL"; - // priority 4b: RW 약세 초기 + 기술지표 경계 — 33% 선제 경량화 - } else if (rwPartial >= 1 && timingExitScore >= 30) { - action = "TRIM_33"; - ratio = 33; - reason = "RW_EARLY_WARNING"; - price = closeProtectLimit; - priceSource = "EARLY_WARNING_CLOSE"; - priceBasis = "PRIOR_CLOSE_X_0.998"; - executionWindow = "INTRADAY_AFTER_09_30"; - orderType = "LIMIT_SELL"; - // priority 4c: RW 약세 감지 단독 (기술지표 미확인) — 25% 최소 경계 - } else if (rwPartial >= 1) { - action = "TRIM_25"; - ratio = 25; - reason = "RW_SIGNAL_ONLY"; - price = closeProtectLimit; - priceSource = "SIGNAL_ONLY_CLOSE"; - priceBasis = "PRIOR_CLOSE_X_0.998"; - executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; - orderType = "LIMIT_SELL"; - // priority 5: 익절 사다리 (spec level 5) — time_stop보다 우선 - } else if (Number.isFinite(profitPct) && profitPct >= 50) { - action = "PROFIT_TRIM_50"; - ratio = 50; - reason = "PROFIT_PROTECT_50"; - price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit; - priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT"; - priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998"; - executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; - orderType = "LIMIT_SELL"; - } else if (Number.isFinite(profitPct) && profitPct >= 30) { - action = "PROFIT_TRIM_35"; - ratio = 35; - reason = "PROFIT_PROTECT_30"; - price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit; - priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT"; - priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998"; - executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; - orderType = "LIMIT_SELL"; - } else if (Number.isFinite(profitPct) && profitPct >= 20) { - action = "PROFIT_TRIM_25"; - ratio = 25; - reason = "PROFIT_PROTECT_20"; - price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit; - priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT"; - priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998"; - executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; - orderType = "LIMIT_SELL"; - } else if (Number.isFinite(profitPct) && profitPct >= 10) { - action = "TAKE_PROFIT_TIER1"; - ratio = 25; - reason = "TP1_PROFIT_10PCT"; - price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit; - priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT"; - priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998"; - executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; - orderType = "LIMIT_SELL"; - // priority 6: 시간 손절 (spec level 6) — 익절 사다리보다 후순위; 손절·레짐·RW 없을 때만 도달 - } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 0) { - action = "TIME_EXIT_100"; - ratio = 100; - reason = "TIME_STOP_EXPIRED"; - price = protectiveLimit; - priceSource = "TIME_STOP_CLOSE"; - priceBasis = "TIME_STOP_CLOSE_PROTECT"; - executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; - orderType = "PROTECTIVE_LIMIT_SELL"; - } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) { - action = "TIME_TRIM_50"; - ratio = 50; - reason = "TIME_STOP_NEAR"; - price = closeProtectLimit; - priceSource = "TIME_STOP_NEAR_CLOSE"; - priceBasis = "ATR_PROTECT_LIMIT"; - executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; - orderType = "LIMIT_SELL"; - // priority 6b: 타임스탑 14일 이내 조기 경보 — 25% 선제 축소 - } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 14) { - action = "TIME_TRIM_25"; - ratio = 25; - reason = "TIME_STOP_APPROACHING"; - price = closeProtectLimit; - priceSource = "TIME_STOP_APPROACHING_CLOSE"; - priceBasis = "ATR_PROTECT_LIMIT"; - executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; - orderType = "LIMIT_SELL"; - } - - const cashPreservePlan = calcCashPreservationPlan_({ - sellAction: action, - cashFloorStatus: String(ctx.cashFloorStatus ?? ""), - regime, - isCoreLeader: !!ctx.isCoreLeader, - isEtf: !!ctx.isEtf, - liquidityStatus: String(ctx.liquidityStatus ?? ""), - spreadStatus: String(ctx.spreadStatus ?? ""), - accountType: String(ctx.accountType ?? ""), - profitPct, - rwPartial, - reboundHoldbackScore: parseFloat(ctx.reboundHoldbackScore), - }); - if (action !== "EXIT_100" && action !== "TRAILING_STOP_BREACH" && action !== "HOLD") { - const targetRatio = cashPreservePlan.recommended_ratio; - if (Number.isFinite(targetRatio) && targetRatio > 0 && targetRatio < ratio) { - ratio = targetRatio; - if (ratio <= 25) action = "TRIM_25"; - else if (ratio <= 33) action = "TRIM_33"; - else action = "TRIM_50"; - reason = reason ? `${reason}|CASH_PRESERVE:${cashPreservePlan.style}` : `CASH_PRESERVE:${cashPreservePlan.style}`; - } - } - - // SL003_PRIORITY_MATRIX: 복수 손절 조건 동시 발동 시 max(prices) 적용 — spec/exit/stop_loss.yaml - // TP 계열(PROFIT_TRIM_*, TAKE_PROFIT_TIER1)은 별도 프레임워크이므로 이 블록 적용 제외 - const isStopTypeAction_ = /^(EXIT_100|TRIM_70|TRAILING_STOP_BREACH|TRIM_50|TRIM_33|TRIM_25|TIME_EXIT_100|TIME_TRIM_50|TIME_TRIM_25)$/.test(action); - if (isStopTypeAction_ && Number.isFinite(close) && close > 0) { - const slpCands_ = []; - const pushSlp_ = (src, p) => { if (Number.isFinite(p) && p > 0) slpCands_.push({ src, p }); }; - if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) pushSlp_("HARD_STOP", protectiveLimit); - // REGIME 후보는 방향 A에서 포트폴리오 레벨 처리 — SL003에서 제외 - if (rwPartial >= 3 || timingExitScore >= 75) pushSlp_("RW_TRIM70", protectiveLimit); - if (Number.isFinite(trailingStop) && trailingStop > 0 && close <= trailingStop) - pushSlp_("TRAILING", Math.round(trailingStop)); // 트레일링 스탑 가격이 보호선 - if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) pushSlp_("RW_TRIM50", closeProtectLimit); - if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) pushSlp_("TIME_STOP", closeProtectLimit); - if (slpCands_.length >= 2) { - const maxSlp_ = slpCands_.reduce((a, b) => b.p > a.p ? b : a); - const curPrice_ = parseFloat(price); - if (maxSlp_.p > (Number.isFinite(curPrice_) ? curPrice_ : 0)) { - price = maxSlp_.p; - priceSource = "PRIORITY_MATRIX_MAX"; - priceBasis = `SL003_MAX(${slpCands_.map(c => `${c.src}:${c.p}`).join("|")})`; - } - } - } - - // 방향 A: 수량 계산 없음. 가격이 유효하면 SIGNAL_CONFIRMED. - let validation = "NO_SELL_ACTION"; - if (action !== "HOLD") { - validation = (Number.isFinite(parseFloat(price)) && parseFloat(price) > 0) - ? "SIGNAL_CONFIRMED" - : "NO_SELL_PRICE"; - } - - return { - action, - ratio_pct: ratio, - limit_price: price, - price_source: priceSource, - price_basis: priceBasis, - execution_window: executionWindow, - order_type: orderType, - reason, - validation, - cash_preserve_style: cashPreservePlan.style, - cash_preserve_ratio: cashPreservePlan.recommended_ratio, - cash_preserve_reason: cashPreservePlan.reasons, - }; -} - -// Backward-compatible thin wrapper. -// Existing data_feed callers still expect calcSellRoute_. -var calcSellRoute_ = function(ctx) { - return calcExitSellAction_(ctx || {}); -} - -// ── [2026-05-21_CLA_HARNESS_V1] REPLACEMENT_ALPHA_GATE_V1 ─────────────────── -/** - * CLA 레짐에서 위성 신규 BUY 전 코어 대비 알파 우위 검증. - * spec/13_formula_registry.yaml:REPLACEMENT_ALPHA_GATE_V1 - * @return {{ rag_v1: 'PASS'|'FAIL'|'EXEMPT', rag_reason: string }} - */ -function validateReplacementAlpha_(ctx) { - const posRec = ctx.posRec; - if (posRec && posRec.position_type === 'core') { - return { rag_v1: 'EXEMPT', rag_reason: 'core_exempt' }; - } - const r = String(ctx.globalRegimePrelim_ || '').toUpperCase(); - const isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA'; - if (!isCLA) return { rag_v1: 'EXEMPT', rag_reason: 'regime_not_cla' }; - - const rsVerdict = String(ctx.rs_verdict || 'UNKNOWN'); - const ss001Norm = typeof ctx.ss001_norm === 'number' ? ctx.ss001_norm : null; - const excessRet10d = typeof ctx.excess_ret_10d === 'number' ? ctx.excess_ret_10d : null; - const coreAvgSS001 = typeof ctx.coreAvgSS001 === 'number' ? ctx.coreAvgSS001 : 60; - - const condA = ['LEADER', 'MARKET'].includes(rsVerdict); - const condB = ss001Norm !== null && ss001Norm >= coreAvgSS001 - 10; - const condC = excessRet10d !== null && excessRet10d >= -5; - const condD = excessRet10d === null || excessRet10d >= 0 || rsVerdict === 'LEADER'; - - const pass = condA && condB && condC && condD; - return { - rag_v1: pass ? 'PASS' : 'FAIL', - rag_reason: !condA ? 'rs_verdict_weak' : - !condB ? 'ss001_below_core' : - !condC ? 'excess_ret_breach' : - !condD ? 'rs_slope_negative' : 'pass' - }; -} - -// ── F7: 최종 액션 우선순위 엔진 ───────────────────────────────────────────── -// LLM이 호출마다 임의 판단하지 않도록 최종 액션·순위 점수를 룰 엔진에서 고정한다. -var calcPortfolioActionRoute_ = function(ctx) { - const sellAction = String(ctx.sellAction ?? "HOLD"); - const sellValidation = String(ctx.sellValidation ?? ""); - const allowedAction = String(ctx.allowedAction ?? ""); - const timingAction = String(ctx.timingAction ?? ""); - const timingEntry = parseFloat(ctx.timingScoreEntry); - const timingExit = parseFloat(ctx.timingScoreExit); - const ss001Total = parseFloat(ctx.ss001Total); - const flowCredit = parseFloat(ctx.flowCredit); - const leaderTotal = parseFloat(ctx.leaderTotal); - const rwPartial = parseFloat(ctx.rwPartial); - const profitPct = parseFloat(ctx.profitPct); - const daysToTimeStop = parseFloat(ctx.daysToTimeStop); - const weightPct = parseFloat(ctx.weightPct); - const acGate = String(ctx.acGate ?? ""); - const liquidityStatus = String(ctx.liquidityStatus ?? ""); - const spreadStatus = String(ctx.spreadStatus ?? ""); - const dartRisk = !!ctx.dartRisk; - const missingFields = String(ctx.missingFields ?? ""); - - let finalAction = "HOLD"; - let actionPriority = 99; - let sourceTag = "RULE_ENGINE"; - - if (sellAction !== "HOLD" && sellValidation === "SIGNAL_CONFIRMED") { - // 미보유(weightPct=0) 종목에 SELL_READY를 주면 주문수량=0 이므로 WATCH_EXIT_SIGNAL 로 다운그레이드 - if (!(weightPct > 0)) { - finalAction = "WATCH_EXIT_SIGNAL"; - actionPriority = 35; - } else { - finalAction = "SELL_READY"; - actionPriority = 10; - } - } else if (allowedAction === "EXIT_SIGNAL" || timingAction === "STOP_OR_TIME_EXIT_READY") { - finalAction = "EXIT_SIGNAL"; - actionPriority = 28; - } else if (allowedAction === "REVIEW_EXIT" || timingAction === "EXIT_REVIEW") { - finalAction = "EXIT_REVIEW"; - actionPriority = 32; - } else if (timingAction === "NO_BUY_OVERHEATED" && !dartRisk) { - finalAction = "NO_BUY_OVERHEATED"; - actionPriority = 50; - } else if (allowedAction === "BUY_STAGE1_READY" || timingAction === "BUY_STAGE1_READY") { - finalAction = "BUY_STAGE1_READY"; - actionPriority = 60; - } else if (allowedAction === "BUY_BREAKOUT_PILOT_ONLY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY") { - finalAction = "BUY_BREAKOUT_PILOT_ONLY"; - actionPriority = 70; - } else if (allowedAction === "BUY_PULLBACK_WAIT" || timingAction === "BUY_PULLBACK_WAIT") { - finalAction = "BUY_PULLBACK_WAIT"; - actionPriority = 80; - } else if (allowedAction === "WATCH_CANDIDATE") { - finalAction = "WATCH_TIMING_SETUP"; - actionPriority = 90; - } - - if (missingFields) sourceTag = "RULE_ENGINE_WITH_MISSING_DATA"; - - const timeStopUrgency = Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0 - ? Math.max(0, 20 - Math.min(20, daysToTimeStop * 3)) - : 0; - const overweightPenalty = Number.isFinite(weightPct) && weightPct > 7 ? 15 : 0; - const overheatPenalty = acGate === "BLOCK" ? 30 : acGate === "CAUTION" ? 10 : 0; - const liquidityPenalty = - ["LOW", "DATA_MISSING"].includes(liquidityStatus) || - ["BLOCK", "WIDE", "QUOTE_NO_MATCH"].includes(spreadStatus) - ? 15 - : 0; - - let priorityScore; - if (actionPriority <= 40) { - priorityScore = - (Number.isFinite(timingExit) ? timingExit : 0) * 0.35 + - (Number.isFinite(rwPartial) ? rwPartial : 0) * 15 + - Math.max(0, Number.isFinite(profitPct) ? profitPct : 0) * 0.30 + - timeStopUrgency + - overweightPenalty; - } else if (actionPriority >= 50 && actionPriority <= 80) { - priorityScore = - (Number.isFinite(timingEntry) ? timingEntry : 0) * 0.35 + - (Number.isFinite(ss001Total) ? ss001Total : 0) * 0.30 + - (Number.isFinite(flowCredit) ? flowCredit : 0) * 20 + - (Number.isFinite(leaderTotal) ? leaderTotal : 0) * 5 - - overheatPenalty - - liquidityPenalty; - } else { - priorityScore = - (Number.isFinite(timingEntry) ? timingEntry : 0) * 0.20 + - (Number.isFinite(timingExit) ? timingExit : 0) * 0.20 + - (Number.isFinite(flowCredit) ? flowCredit : 0) * 10; - } - - return { - final_action: finalAction, - action_priority: actionPriority, - priority_score: parseFloat(Math.max(0, priorityScore).toFixed(2)), - source_tag: sourceTag, - }; -} - -// Backward-compatible thin wrapper. -// Existing data_feed callers still expect calcFinalRoute_. -var calcFinalRoute_ = function(ctx) { - const d = calcPortfolioActionRoute_(ctx || {}); - return { - final_action: d.final_action, - action_priority: d.action_priority, - priority_score: d.priority_score, - route_source: d.source_tag, - }; -} - -// ── SS001 종목 점수 계산 (spec/08_scoring_rules.yaml SS001_SECTOR_MODEL_SCORE) ── -// runDataFeed 루프에서 분리. 1개 종목 → 점수 객체 반환. -// ctx 필드: rsPct20D, avgTV5D, avgTV20D, flowCredit, epsRevisionStatus, -// regimePrelim, isKosdaq, sfMedPE, sfMedPBR, forwardPE, pbrVal, epsGrowth1y -function calcSS001Score_(ctx) { - // SS001_P: price_strength (max 25) — RS_Pct_20D → percentile 변환 - const rsPercentile = Number.isFinite(ctx.rsPct20D) ? (100 - ctx.rsPct20D) : null; - const ss001_p = rsPercentile !== null ? (rsPercentile <= 30 ? 25 : rsPercentile <= 60 ? 15 : 0) : 0; - - // SS001_V: volume_quality (max 15) - const volRatio = Number.isFinite(ctx.avgTV5D) && Number.isFinite(ctx.avgTV20D) && ctx.avgTV20D > 0 - ? ctx.avgTV5D / ctx.avgTV20D : null; - const ss001_v = volRatio !== null ? (volRatio >= 1.20 ? 15 : volRatio >= 0.80 ? 8 : 0) : 0; - - // SS001_F: flow_quality (max 25) - const fc = ctx.flowCredit ?? 0; - const ss001_f = fc >= 0.70 ? 25 : fc >= 0.40 ? 12 : 0; - - // SS001_E: earnings_revision (max 20) - const ss001_e = ctx.epsRevisionStatus === "UP" ? 20 : ctx.epsRevisionStatus === "FLAT" ? 10 : 0; - - // SS001_M: macro_regime (max 10) - const r = ctx.regimePrelim ?? ""; - const ss001_m = (r === "RISK_ON" || r === "LEADER_CONCENTRATION" || r === "SECULAR_LEADER_RISK_ON") - ? 10 : r === "NEUTRAL" ? 5 : 0; - - // SS001_VAL: valuation (max 5 KOSPI / max 12 KOSDAQ) - let ss001_val = 0, pegVal = "", pegGate = ""; - if (ctx.isKosdaq) { - const epsG = Number.isFinite(ctx.epsGrowth1y) && ctx.epsGrowth1y > 0 ? ctx.epsGrowth1y : null; - if (Number.isFinite(ctx.forwardPE) && epsG !== null) { - pegVal = parseFloat((ctx.forwardPE / epsG).toFixed(2)); - pegGate = pegVal <= 1.5 ? "PASS" : pegVal <= 2.5 ? "CAUTION" : "REJECT"; - ss001_val = pegVal <= 1.0 ? 12 : pegVal <= 1.5 ? 9 : pegVal <= 2.0 ? 5 : pegVal <= 2.5 ? 2 : 0; - } else if (Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0) { - pegGate = "FALLBACK"; - ss001_val = ctx.forwardPE <= ctx.sfMedPE * 2.0 ? 9 : ctx.forwardPE <= ctx.sfMedPE * 3.0 ? 4 : 0; - } - } else { - const peOk = Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0; - const pbrOk = Number.isFinite(ctx.pbrVal) && Number.isFinite(ctx.sfMedPBR) && ctx.sfMedPBR > 0; - if (peOk || pbrOk) { - const atOrBelow = (peOk && ctx.forwardPE <= ctx.sfMedPE) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR); - const at1_5x = (peOk && ctx.forwardPE <= ctx.sfMedPE * 1.5) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR * 1.5); - ss001_val = atOrBelow ? 5 : at1_5x ? 2 : 0; - } - } - - const ss001_total = ss001_p + ss001_v + ss001_f + ss001_e + ss001_m + ss001_val; - const ss001_norm = ss001_total / (ctx.isKosdaq ? 107 : 100) * 100; - const ss001_grade = ss001_norm >= 80 ? "A" : ss001_norm >= 65 ? "B" : ss001_norm >= 50 ? "C" : "D"; - - return { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, - ss001_total, ss001_norm, ss001_grade, pegVal, pegGate }; -} - -function buildAllowedAction(score, priceStatus, atr20, dartSummary, flowOk, avgTradingValue5D, spreadPct) { - if (priceStatus !== "PRICE_OK" || !Number.isFinite(atr20)) return "OBSERVE_ONLY"; - if (dartSummary?.risk) return "HOLD_NO_ADD"; - if (!flowOk) return "NO_ADD"; - if (Number.isFinite(avgTradingValue5D) && avgTradingValue5D < 50) return "NO_ADD"; - if (Number.isFinite(spreadPct) && spreadPct > 0.8) return "NO_ADD"; - if (score >= 70 && dartSummary?.status === "NAVER_NOTICE_EMPTY") return "HOLD"; - if (score >= 50) return "CONDITIONAL_HOLD"; - return "SELL_ALLOWED"; -} - -function calcCoreCandidateQualityGrade_(ctx) { - const score = parseFloat(ctx.rotationScore); - const flowOk = String(ctx.flowOk ?? "") === "Y" || ctx.flowOk === true; - const priceStatus = String(ctx.priceStatus ?? ""); - const liquidityStatus = String(ctx.liquidityStatus ?? ""); - const dartRisk = String(ctx.dartRisk ?? "").trim(); - const missing = String(ctx.missingFields ?? "").trim(); - if (priceStatus !== "PRICE_OK" || missing || dartRisk || ["LOW", "DATA_MISSING"].includes(liquidityStatus)) return "D"; - if (Number.isFinite(score) && score >= 80 && flowOk) return "A"; - if (Number.isFinite(score) && score >= 65 && flowOk) return "B"; - if (Number.isFinite(score) && score >= 50) return "C"; - return "D"; -} - -function calcT1ForcedSellRisk_(ctx) { - let score = 0; - const reasons = []; - const sellAction = String(ctx.sellAction ?? ""); - const sellValidation = String(ctx.sellValidation ?? ""); - const timingExit = parseFloat(ctx.timingScoreExit); - const rwPartial = parseFloat(ctx.rwPartial); - const rsi14 = parseFloat(ctx.rsi14); - const disparity = parseFloat(ctx.disparity); - const valSurge = parseFloat(ctx.valSurgePct); - const ret5D = parseFloat(ctx.ret5D); - const dartRisk = String(ctx.dartRisk ?? "").trim(); - const lateChase = parseFloat(ctx.lateChaseRiskScore); - const distribution = parseFloat(ctx.distributionRiskScore); - - if (sellAction && sellAction !== "HOLD" && sellValidation !== "NO_SELL_ACTION") { - score += 40; - reasons.push("SELL_ACTION_ACTIVE"); - } - if (Number.isFinite(timingExit) && timingExit >= 50) { - score += 25; - reasons.push("TIMING_EXIT>=50"); - } - if (Number.isFinite(rwPartial) && rwPartial >= 2) { - score += 25; - reasons.push("RW>=2"); - } - if (Number.isFinite(distribution) && distribution >= 70) { - score += 30; - reasons.push("DISTRIBUTION>=70"); - } - if (Number.isFinite(lateChase) && lateChase >= 70) { - score += 25; - reasons.push("LATE_CHASE>=70"); - } - if ((Number.isFinite(rsi14) && rsi14 > 75) || (Number.isFinite(disparity) && disparity > 12)) { - score += 20; - reasons.push("OVERHEATED"); - } - if (Number.isFinite(valSurge) && valSurge >= 40 && Number.isFinite(ret5D) && ret5D > 8) { - score += 15; - reasons.push("SURGE_AFTER_RUNUP"); - } - if (dartRisk) { - score += 30; - reasons.push("DART_RISK"); - } - score = Math.max(0, Math.min(100, Math.round(score))); - const state = score >= 70 ? "BUY_BLOCKED_T1_EXIT_RISK" : score >= 50 ? "WATCH_ONLY_T1_RISK" : "PASS"; - return { score, state, reason: reasons.join("|") || "PASS" }; -} - -function calcSellConflictScore_(ctx) { - let score = 0; - const reasons = []; - const sellFinal = String(ctx.sellFinal ?? ""); - const sellAction = String(ctx.sellAction ?? ""); - const cashStyle = String(ctx.cashPreserveStyle ?? ""); - const allowedAction = String(ctx.allowedAction ?? ""); - if (["SELL_READY", "EXIT_SIGNAL", "EXIT_REVIEW"].includes(sellFinal) || (sellAction && sellAction !== "HOLD")) { - score += 55; - reasons.push("SELL_SIGNAL_ACTIVE"); - } - if (cashStyle && cashStyle !== "NONE") { - score += 20; - reasons.push("CASH_PRESERVE_ACTIVE"); - } - if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) { - score += 20; - reasons.push("NO_ADD_GATE"); - } - score = Math.max(0, Math.min(100, Math.round(score))); - const state = score >= 70 ? "BUY_BLOCKED_SELL_CONFLICT" : score >= 40 ? "SELL_OR_TRIM_FIRST" : "PASS"; - return { score, state, reason: reasons.join("|") || "PASS" }; -} - -function calcCoreSatelliteExecutionState_(ctx) { - const quality = String(ctx.candidateQualityGrade ?? ""); - const timingAction = String(ctx.timingAction ?? ""); - const entryGate = String(ctx.entryModeGate ?? ""); - const t1State = String(ctx.t1State ?? ""); - const sellConflictState = String(ctx.sellConflictState ?? ""); - const allowedAction = String(ctx.allowedAction ?? ""); - if (sellConflictState === "BUY_BLOCKED_SELL_CONFLICT" || sellConflictState === "SELL_OR_TRIM_FIRST") return sellConflictState; - if (t1State === "BUY_BLOCKED_T1_EXIT_RISK" || t1State === "WATCH_ONLY_T1_RISK") return t1State; - if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) return "BUY_BLOCKED_PORTFOLIO_GUARD"; - if (quality === "A" && entryGate === "PASS" && ["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY"].includes(timingAction)) return "BUY_PILOT_ALLOWED"; - if (quality === "A" || quality === "B") { - if (entryGate === "PASS") return "WATCH_BREAKOUT_RETEST"; - return "WATCH_PULLBACK"; - } - return "CANDIDATE_ONLY"; -} - -function calcApexTradePlan_(h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState) { - var buyState = 'BLOCKED'; - var buyReasons = []; - if (h1.cashFloorStatus !== 'PASS') buyReasons.push('cash_floor_not_pass'); - if (h1.heatGate === 'BLOCK_NEW_BUY') buyReasons.push('heat_block_new_buy'); - if (distRow.anti_distribution_state !== 'PASS') buyReasons.push('distribution_' + distRow.anti_distribution_state); - if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyReasons.length === 0) buyState = 'ALLOW_PILOT'; - else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyReasons.length === 0) buyState = 'ALLOW_ADD_ON'; - else if (buyReasons.length === 0) buyState = 'WATCH'; - if (saqgState === 'EXCLUDED') { - buyState = 'BLOCKED'; - buyReasons.push('saqg_EXCLUDED'); - } else if (saqgState === 'WATCHLIST_ONLY' && (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON')) { - buyState = 'WATCH'; - buyReasons.push('saqg_WATCHLIST_ONLY'); - } - - var style = 'URGENT_LIQUIDITY_TRIM'; - if ((df.rsi14 && df.rsi14 < 35) || (df.bbPosition && df.bbPosition < 20) || (df.ma20 && h.close && h.close < df.ma20 * 0.92)) { - style = 'OVERSOLD_REBOUND_SELL'; - } else if (distRow.anti_distribution_state === 'BLOCK_BUY') { - style = 'DISTRIBUTION_EXIT'; - } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20' - || profitRow.profit_preservation_state === 'PROFIT_LOCK_30' - || profitRow.profit_preservation_state === 'APEX_TRAILING') { - style = 'PROFIT_PROTECT_TRIM'; - } - - var baseQty = typeof sq.sell_qty === 'number' ? sq.sell_qty : 0; - var close = h.close || df.close || 0; - var prevClose = df.prevClose || close; - var atr20 = df.atr20 || 0; - var holdingQty = h.holdingQty || 0; - var shortfallMin = cashShortfallInfo.cash_shortfall_min_krw || 0; - - var immediateQty; - var reboundQty; - var k2Emergency; - if (style === 'OVERSOLD_REBOUND_SELL') { - var halfQty = Math.floor(baseQty / 2); - var halfExpectedKrw = halfQty * close; - k2Emergency = shortfallMin > 0 && (halfExpectedKrw * 2 < shortfallMin); - if (k2Emergency) { - immediateQty = baseQty; - reboundQty = 0; - } else { - immediateQty = halfQty; - reboundQty = Math.max(0, baseQty - halfQty); - } - var overSoldCap = holdingQty; - if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') { - overSoldCap = Math.floor(holdingQty * 0.40); - } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') { - overSoldCap = Math.floor(holdingQty * 0.35); - } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10') { - overSoldCap = Math.floor(holdingQty * 0.30); - } else { - overSoldCap = Math.floor(holdingQty * 0.50); - } - immediateQty = Math.min(immediateQty, overSoldCap); - } else { - k2Emergency = false; - var capPct = 50; - if (style === 'PROFIT_PROTECT_TRIM') { - if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') capPct = 50; - else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') capPct = 35; - else capPct = 25; - } else if (style === 'DISTRIBUTION_EXIT') { - capPct = 50; - } - immediateQty = Math.min(baseQty, Math.floor(holdingQty * capPct / 100)); - reboundQty = 0; - } - - var hasPosition = holdingQty > 0; - var tranchePhase; - var currentTrancheAllowedPct; - var nextTrancheCondition; - if (!hasPosition) { - if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyState === 'ALLOW_PILOT') { - tranchePhase = 'TRANCHE_1_PILOT'; - currentTrancheAllowedPct = 30; - nextTrancheCondition = 'CONFIRMED_ADD_ON'; - } else { - tranchePhase = 'WAIT_PILOT_SETUP'; - currentTrancheAllowedPct = 0; - nextTrancheCondition = 'ALPHA_LEAD_SCORE_GTE_75_AND_DISTRIBUTION_PASS'; - } - } else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyState === 'ALLOW_ADD_ON') { - tranchePhase = 'TRANCHE_2_ADD_ON'; - currentTrancheAllowedPct = 30; - nextTrancheCondition = 'SECONDARY_PULLBACK_TO_MA20'; - } else if (alphaRow.close_vs_ma20_pct !== null && alphaRow.close_vs_ma20_pct <= 2 - && profitRow.profit_pct > 3 && ftRow.follow_through_state !== 'FAILED_BREAKOUT' - && buyState === 'ALLOW_ADD_ON') { - tranchePhase = 'TRANCHE_3_PULLBACK_ADD'; - currentTrancheAllowedPct = 40; - nextTrancheCondition = 'HOLD_FULL_POSITION'; - } else { - tranchePhase = 'HOLD_CURRENT'; - currentTrancheAllowedPct = 0; - nextTrancheCondition = ftRow.follow_through_state === 'FAILED_BREAKOUT' - ? 'RECOVERY_ABOVE_MA20' : 'CONFIRMED_ADD_ON_OR_PULLBACK'; - } - - var sellRawPrice = null; - if (close > 0) { - if (style === 'URGENT_LIQUIDITY_TRIM') { - sellRawPrice = prevClose > 0 ? Math.min(close, prevClose * 0.998) : close * 0.998; - } else if (style === 'OVERSOLD_REBOUND_SELL') { - sellRawPrice = close; - } else if (style === 'DISTRIBUTION_EXIT') { - sellRawPrice = atr20 > 0 ? close - 0.25 * atr20 : close * 0.997; - } else if (style === 'PROFIT_PROTECT_TRIM') { - var ratchetStop = priceRow.ratchet_stop_price || 0; - sellRawPrice = ratchetStop > 0 ? Math.max(ratchetStop, close * 0.999) : close * 0.999; - } - } - var buyRawPrice = null; - if (close > 0) { - if (buyState === 'ALLOW_PILOT') { - buyRawPrice = Math.min(close * 1.002, df.ma20 > 0 ? df.ma20 * 1.08 : close * 1.002); - } else if (buyState === 'ALLOW_ADD_ON') { - buyRawPrice = prevClose > 0 ? Math.min(close * 1.002, prevClose * 1.01) : close * 1.002; - } - } - var normalizedSellPrice = (sellRawPrice && sellRawPrice > 0) ? tickNormalize_(sellRawPrice) : null; - var normalizedBuyPrice = (buyRawPrice && buyRawPrice > 0) ? tickNormalize_(buyRawPrice) : null; - var htsLimitPrice = orderRow.limit_price_krw - ? tickNormalize_(orderRow.limit_price_krw) - : normalizedSellPrice || normalizedBuyPrice; - - return { - buyState: buyState, - buyReasons: buyReasons, - style: style, - immediateQty: immediateQty, - reboundQty: reboundQty, - k2Emergency: k2Emergency, - tranchePhase: tranchePhase, - currentTrancheAllowedPct: currentTrancheAllowedPct, - nextTrancheCondition: nextTrancheCondition, - normalizedSellPrice: normalizedSellPrice, - normalizedBuyPrice: normalizedBuyPrice, - htsLimitPrice: htsLimitPrice, - }; -} - -// ── account_snapshot 읽기 → TOTAL_HEAT_V1 계산 ─────────────────────────────── -// account_snapshot이 보유수량·평단·선택 손절가의 단일 원장이다. -// stop_price 미입력이면 ATR 기반 추정으로 대체. -// total_asset_krw를 인수로 받아야 정확한 열%를 계산할 수 있음; 미제공 시 null. -function readAccountSnapshotHeat_(total_asset_krw) { - const UNKNOWN = { total_heat_pct: null, total_heat_krw: null, - hf005_status: "UNKNOWN (account_snapshot 없음)", positions_count: 0 }; - try { - const ss = getSpreadsheet_(); - const snapshot = readAccountSnapshotMap_(); - if (!snapshot.rows_confirmed) return UNKNOWN; - - // data_feed ATR20 읽기 (stop_price 미입력 시 추정용) - const atrMap = {}; - try { - const dfSheet = ss.getSheetByName("data_feed"); - if (dfSheet) { - const dfData = dfSheet.getDataRange().getValues(); - const dfHdr = dfData[1]?.map(h => String(h).trim()) ?? []; - const dfTkr = dfHdr.indexOf("Ticker"); - const dfAtr = dfHdr.indexOf("ATR20"); - const dfClose= dfHdr.indexOf("Close"); - if (dfTkr >= 0 && dfAtr >= 0 && dfClose >= 0) { - for (let i = 2; i < dfData.length; i++) { - const tk = String(dfData[i][dfTkr]).trim(); - const atr = parseFloat(dfData[i][dfAtr]); - const cls = parseFloat(dfData[i][dfClose]); - if (tk && Number.isFinite(atr) && Number.isFinite(cls)) atrMap[tk] = { atr20: atr, close: cls }; - } - } - } - } catch(e2) { } - - let totalHeatKrw = 0; - let posCount = 0; - let hasEstimate = false; - const details = []; - - Object.values(snapshot.positions).forEach(pos => { - const qty = parseInt(pos.quantity, 10); - if (!Number.isFinite(qty) || qty <= 0) return; - const entry = parseFloat(pos.average_cost ?? pos.entry_price); - if (!Number.isFinite(entry) || entry <= 0) return; - - let stop = parseFloat(pos.stop_price); - if (!Number.isFinite(stop) || stop <= 0) { - // ATR 기반 추정 - const tk = pos.ticker; - const atrInfo = atrMap[tk]; - if (atrInfo) { - stop = entry - atrInfo.atr20 * THRESHOLDS.ATR_TRAILING_MULT; - hasEstimate = true; - } else { - stop = entry * 0.92; // 8% 고정 추정 - hasEstimate = true; - } - } - if (stop >= entry) return; // PS002 위반 행 건너뜀 - - const heatKrw = (entry - stop) * qty; - totalHeatKrw += heatKrw; - posCount++; - details.push(`${qty}주×${Math.round(entry-stop)}원`); - }); - - if (posCount === 0) return { total_heat_pct: 0, total_heat_krw: 0, - hf005_status: "PASS (포지션 없음)", positions_count: 0 }; - - const estTag = hasEstimate ? "(ATR추정)" : ""; - if (!Number.isFinite(total_asset_krw) || total_asset_krw <= 0) { - return { - total_heat_pct: null, - total_heat_krw: Math.round(totalHeatKrw), - hf005_status: `UNKNOWN (총자산 미제공)${estTag}`, - positions_count: posCount, - }; - } - - const heatPct = (totalHeatKrw / total_asset_krw) * 100; - const hf005 = heatPct >= 10 - ? `BLOCK (>= 10%: ${heatPct.toFixed(1)}%)${estTag}` - : `PASS (< 10%: ${heatPct.toFixed(1)}%)${estTag}`; - - return { - total_heat_pct: parseFloat(heatPct.toFixed(2)), - total_heat_krw: Math.round(totalHeatKrw), - hf005_status: hf005, - positions_count: posCount, - }; - } catch(e) { - handleFetchError_("readAccountSnapshotHeat_", e, "WARN"); - return { total_heat_pct: null, total_heat_krw: null, - hf005_status: "ERROR: " + e.message, positions_count: 0 }; - } -} - -// 상승 추세 보존 점수: 높을수록 매도 우선순위를 늦춘다. -function calcReboundHoldbackScore_(ctx) { - const close = parseFloat(ctx.close); - const ma20 = parseFloat(ctx.ma20); - const ma60 = parseFloat(ctx.ma60); - const ma20Slope = parseFloat(ctx.ma20Slope); - const rsi14 = parseFloat(ctx.rsi14); - const bbPosition = parseFloat(ctx.bbPosition); - const flowCredit = parseFloat(ctx.flowCredit); - const leaderTotal = parseFloat(ctx.leaderTotal); - const leaderGate = String(ctx.leaderGate ?? ""); - const bandStatus = String(ctx.bandStatus ?? ""); - const profitPct = parseFloat(ctx.profitPct); - const isCoreLeader = !!ctx.isCoreLeader; - - let score = 0; - const reasons = []; - const aboveMa20 = Number.isFinite(close) && Number.isFinite(ma20) && close >= ma20; - const aboveMa60 = Number.isFinite(close) && Number.isFinite(ma60) && close >= ma60; - - if (isCoreLeader && aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) { - score += 12; - reasons.push("core_uptrend:+12"); - } else if (aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) { - score += 8; - reasons.push("trend_hold:+8"); - } - - if (Number.isFinite(leaderTotal) && leaderTotal >= 80) { - score += 6; - reasons.push("leader_total:+6"); - } else if (leaderGate === "PASS") { - score += 4; - reasons.push("leader_pass:+4"); - } - - if (Number.isFinite(flowCredit) && flowCredit >= 0.7) { - score += 6; - reasons.push("flow_strong:+6"); - } - - if (Number.isFinite(rsi14)) { - if (rsi14 <= 62) { - score += 4; - reasons.push("rsi_room:+4"); - } else if (rsi14 >= 72) { - score -= 6; - reasons.push("rsi_hot:-6"); - } - } - - if (Number.isFinite(bbPosition) && bbPosition <= 0.7) { - score += 3; - reasons.push("bb_room:+3"); - } - - if (bandStatus === "UNDERWEIGHT") { - score += 3; - reasons.push("band_under:+3"); - } - - if (Number.isFinite(profitPct) && profitPct >= 0 && aboveMa20 && aboveMa60) { - score += 3; - reasons.push("runner:+3"); - } - - return { - score: Math.max(0, Math.min(30, score)), - reasons: reasons.join(" | "), - }; -} - -// 현금확보 시 반등 보존형 감축 계획. -// score는 sell_priority_score에서 보호 보너스로 쓰고, recommended_ratio는 주문 감축비율로 쓴다. -function calcCashPreservationPlan_(ctx) { - const cashFloorStatus = String(ctx.cashFloorStatus ?? ""); - const regime = String(ctx.regime ?? ""); - const sellAction = String(ctx.sellAction ?? ctx.action ?? ""); - const isSellLike = /(SELL|TRIM|EXIT)/.test(sellAction); - const isCoreLeader = !!ctx.isCoreLeader; - const isEtf = !!ctx.isEtf; - const liquidityStatus = String(ctx.liquidityStatus ?? ""); - const spreadStatus = String(ctx.spreadStatus ?? ""); - const accountType = String(ctx.accountType ?? ""); - const profitPct = parseFloat(ctx.profitPct); - const rwPartial = parseInt(ctx.rwPartial, 10) || 0; - const reboundHoldback = parseFloat(ctx.reboundHoldbackScore); - const holdbackScore = Number.isFinite(reboundHoldback) ? reboundHoldback : 0; - - let recommendedRatio = isSellLike ? 50 : 0; - let style = "STEP_50"; - let protectionBonus = 0; - const reasons = []; - - if (isCoreLeader && holdbackScore >= 12) { - style = "CORE_LAST"; - recommendedRatio = cashFloorStatus === "TRIM_REQUIRED" ? 25 : 0; - protectionBonus += 12; - reasons.push("core_last"); - } else if (holdbackScore >= 18) { - style = "STEP_25"; - recommendedRatio = 25; - protectionBonus += 10; - reasons.push("strong_rebound"); - } else if (holdbackScore >= 10) { - style = "STEP_33"; - recommendedRatio = 33; - protectionBonus += 6; - reasons.push("rebound_preserve"); - } - - if (isEtf && holdbackScore < 10) { - protectionBonus -= 2; - reasons.push("etf_cash_raise"); - } - - if (cashFloorStatus === "TRIM_REQUIRED" || /RISK_OFF/.test(regime)) { - protectionBonus += 2; - reasons.push("cash_preserve"); - } - - if (liquidityStatus === "LOW" || spreadStatus === "WIDE" || spreadStatus === "BLOCK") { - protectionBonus += 4; - reasons.push("impact_avoid"); - } - - if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct > 0) { - protectionBonus += profitPct >= 20 ? 3 : 2; - reasons.push("tax_drag"); - } else if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct < 0) { - protectionBonus -= 2; - reasons.push("tax_loss_harvest"); - } - - if (rwPartial >= 3 && !isCoreLeader) { - recommendedRatio = Math.max(recommendedRatio, 50); - protectionBonus -= 4; - reasons.push("rw_force"); - } - - if (cashFloorStatus === "HARD_BLOCK") { - recommendedRatio = Math.max(recommendedRatio, 50); - reasons.push("cash_hard_block"); - } - - if (!isSellLike) recommendedRatio = 0; - recommendedRatio = Math.max(0, Math.min(50, recommendedRatio)); - - return { - style, - recommended_ratio: recommendedRatio, - protection_bonus: Math.max(0, Math.round(protectionBonus)), - reasons: reasons.join(" | "), - }; -} - -// ── 메인: 보유 종목 완성도 매트릭스 ───────────────────────────────────── -// data_feed는 보유 종목 원장 + 완성도 매트릭스의 canonical output. -// ── Sell_Priority_Score 산출 헬퍼 ──────────────────────────────────────────── -// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring -// 입력: row 배열(data_feed headers 순서), headers 배열, sectorExposureMap(섹터→총비중%) -// 반환: { score, breakdown, priority_level, is_etf, is_core_leader } -// 호출 시점: runDataFeed post-loop(섹터집계 완료 후) & getDailyBrief/runSellPriority -var calcSellSignalSanityScore_ = function(row, headers, sectorExposureMap) { - const get = (col) => { - const i = headers.indexOf(col); - return i >= 0 ? row[i] : undefined; - }; - const flt = (col) => { const v = parseFloat(get(col)); return Number.isFinite(v) ? v : null; }; - - const finalAction = String(get("Final_Action") ?? ""); - const sellAction = String(get("Sell_Action") ?? ""); - const ticker = String(get("Ticker") ?? ""); - const name_ = String(get("Name") ?? ""); - const rwPartial = parseInt(get("RW_Partial")) || 0; - const weightPct = flt("Weight_Pct") ?? 0; - const profitPct = flt("Profit_Pct"); - const close_ = flt("Close"); - const ma20_ = flt("MA20"); - const ma60_ = flt("MA60"); - const ma20Slope_ = flt("MA20_Slope"); - const rsi14_ = flt("RSI14"); - const bbPos_ = flt("BB_Position"); - const flowCredit_ = flt("Flow_Credit"); - const leaderTotal_= flt("Leader_Scan_Total"); - const leaderGate_ = String(get("Leader_Gate") ?? ""); - const bandStatus_ = String(get("Band_Status") ?? ""); - const ss001Grade = String(get("SS001_Grade") ?? ""); - const liquidityStatus_ = String(get("Liquidity_Status") ?? ""); - const avgTradeValue5DM_ = flt("AvgTradeValue_5D_M"); - const avgTradeValue5DKrw_= flt("AvgTradeValue_5D_KRW"); - const spreadStatus_ = String(get("Spread_Status") ?? ""); - const accountType_ = String(get("account_type") ?? get("Account_Type") ?? ""); - const taxCostEstimate_ = flt("Tax_Cost_Estimate"); - - // ETF 여부: 이름 패턴 기준 - const isEtf = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name_); - // 직접 코어 주도주 (삼성전자·SK하이닉스) - const isCoreLeader = (ticker === "005930" || ticker === "000660"); - // 상승추세 여부 - const inUptrend = Number.isFinite(close_) && Number.isFinite(ma20_) && close_ >= ma20_; - // 섹터 총노출 - const sector = TICKER_SECTOR_MAP[ticker] ?? ""; - const sectorExp = (sectorExposureMap ?? {})[sector] ?? 0; - - let score = 0; - const breakdown = []; - - // ── 1. hard_precedence_points ───────────────────────────────────────────── - if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") { - score += THRESHOLDS.SP_HARD_STOP; - breakdown.push(`hard_stop:+${THRESHOLDS.SP_HARD_STOP}`); - } else if (finalAction === "SELL_READY" || - sellAction.includes("TRIM") || sellAction.includes("EXIT")) { - score += THRESHOLDS.SP_SELL_SIGNAL; - breakdown.push(`sell_signal:+${THRESHOLDS.SP_SELL_SIGNAL}`); - } else if (finalAction === "EXIT_REVIEW") { - score += THRESHOLDS.SP_HOLDINGS_ROTATE; - breakdown.push(`exit_review:+${THRESHOLDS.SP_HOLDINGS_ROTATE}`); - } else if (Number.isFinite(profitPct) && profitPct >= 10) { - score += THRESHOLDS["SP_TAKE_PROFIT"]; - breakdown.push(`take_profit:+${THRESHOLDS["SP_TAKE_PROFIT"]}`); - } - - // ── 2. duplicate_exposure_points (ETF 중복 노출) ────────────────────────── - if (isEtf) { - if (sectorExp >= THRESHOLDS.SP_DUPLICATE_THRESH) { - score += THRESHOLDS.SP_ETF_DUPLICATE; - breakdown.push(`etf_dup(${sector}${sectorExp.toFixed(1)}%):+${THRESHOLDS.SP_ETF_DUPLICATE}`); - } else if (sectorExp >= 10) { - score += THRESHOLDS.SP_ETF_MODERATE; - breakdown.push(`etf_moderate:+${THRESHOLDS.SP_ETF_MODERATE}`); - } - } - - // ── 3. cash_relief_points (보유 비중 → 현금 회복 기여) ──────────────────── - if (weightPct >= 3) { - score += THRESHOLDS.SP_CASH_LARGE; - breakdown.push(`cash_${weightPct.toFixed(1)}%:+${THRESHOLDS.SP_CASH_LARGE}`); - } else if (weightPct >= 1) { - score += THRESHOLDS.SP_CASH_MID; - breakdown.push(`cash_mid:+${THRESHOLDS.SP_CASH_MID}`); - } else { - score += THRESHOLDS.SP_CASH_SMALL; - } - - // ── 4. weakness_points ─────────────────────────────────────────────────── - if (rwPartial >= 4) { - score += THRESHOLDS.SP_RW4; - breakdown.push(`rw${rwPartial}:+${THRESHOLDS.SP_RW4}`); - } else if (rwPartial === 3) { - score += THRESHOLDS.SP_RW3; - breakdown.push(`rw3:+${THRESHOLDS.SP_RW3}`); - } else if (rwPartial === 2) { - score += THRESHOLDS.SP_RW2; - breakdown.push(`rw2:+${THRESHOLDS.SP_RW2}`); - } - if (Number.isFinite(close_) && Number.isFinite(ma20_) && close_ < ma20_) { - score += THRESHOLDS.SP_BELOW_MA20; - breakdown.push(`below_ma20:+${THRESHOLDS.SP_BELOW_MA20}`); - } - // 손실 위성: -10% 이하, 비ETF, 비코어리더 - if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) { - score += THRESHOLDS.SP_LOSS_SATELLITE; - breakdown.push(`loss_sat(${profitPct.toFixed(1)}%):+${THRESHOLDS.SP_LOSS_SATELLITE}`); - } - - // ── 5. overweight_points ───────────────────────────────────────────────── - const targetW = isEtf ? 7 : (isCoreLeader ? 15 : 7); - const excess = weightPct - targetW; - if (excess >= 5) { - score += THRESHOLDS.SP_OVERWEIGHT_LARGE; - breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_LARGE}`); - } else if (excess >= 2) { - score += THRESHOLDS.SP_OVERWEIGHT_MID; - breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_MID}`); - } - - // ── 6. core_quality_protection_points (음수 패널티) ────────────────────── - if (isCoreLeader && inUptrend) { - score += THRESHOLDS.SP_CORE_LEADER; // -20 - breakdown.push(`core_leader_uptrend:${THRESHOLDS.SP_CORE_LEADER}`); - } - if (ss001Grade === "A") { - score += THRESHOLDS.SP_SS001_A; // -12 - breakdown.push(`ss001_A:${THRESHOLDS.SP_SS001_A}`); - } - - const reboundHoldback_ = calcReboundHoldbackScore_({ - close: close_, - ma20: ma20_, - ma60: ma60_, - ma20Slope: ma20Slope_, - rsi14: rsi14_, - bbPosition: bbPos_, - flowCredit: flowCredit_, - leaderTotal: leaderTotal_, - leaderGate: leaderGate_, - bandStatus: bandStatus_, - profitPct: profitPct, - isCoreLeader: isCoreLeader, - }); - if (reboundHoldback_.score > 0) { - score -= reboundHoldback_.score; - breakdown.push(`rebound_holdback:-${reboundHoldback_.score}${reboundHoldback_.reasons ? `(${reboundHoldback_.reasons})` : ""}`); - } - - const preservationPlan_ = calcCashPreservationPlan_({ - sellAction: finalAction, - cashFloorStatus: String(get("Cash_Floor_Status") ?? ""), - regime: String(get("Market_Regime") ?? ""), - isCoreLeader: isCoreLeader, - isEtf: isEtf, - liquidityStatus: liquidityStatus_, - spreadStatus: spreadStatus_, - accountType: accountType_, - profitPct: profitPct, - rwPartial: rwPartial, - reboundHoldbackScore: reboundHoldback_.score, - }); - if (preservationPlan_.protection_bonus > 0) { - score -= preservationPlan_.protection_bonus; - breakdown.push(`cash_preserve:-${preservationPlan_.protection_bonus}${preservationPlan_.reasons ? `(${preservationPlan_.reasons})` : ""}`); - } - - if (liquidityStatus_ === "OK" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ >= 1000) || (Number.isFinite(avgTradeValue5DKrw_) && avgTradeValue5DKrw_ >= 1000000000)) { - score += 5; - breakdown.push("liquidity_ok:+5"); - } else if (liquidityStatus_ === "LOW" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ > 0 && avgTradeValue5DM_ < 100)) { - score -= 10; - breakdown.push("liquidity_low:-10"); - } - - let taxPenalty = 3; - let taxReason = "tax_unknown"; - if (accountType_ === "ISA" || accountType_ === "연금저축") { - taxPenalty = 0; - taxReason = "tax_exempt"; - } else if (Number.isFinite(taxCostEstimate_) && taxCostEstimate_ > 0) { - taxPenalty = Math.min(10, Math.round(taxCostEstimate_)); - taxReason = "tax_cost_estimate"; - } else if (Number.isFinite(profitPct) && profitPct > 0) { - taxPenalty = profitPct >= 20 ? 10 : 5; - taxReason = "tax_drag"; - } else if (Number.isFinite(profitPct) && profitPct < 0) { - taxPenalty = -5; - taxReason = "tax_loss_harvest"; - } - score -= taxPenalty; - breakdown.push(`tax_penalty:-${taxPenalty}${taxReason ? `(${taxReason})` : ""}`); - - // 우선순위 단계 레이블 (spec: funding_order ①~④) - let priority_level; - if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") { - priority_level = "1_hard_stop"; - } else if (finalAction === "SELL_READY") { - priority_level = "2_sell_signal"; - } else if (isEtf && sectorExp >= 10) { - priority_level = "3_duplicate_etf"; - } else if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) { - priority_level = "4_loss_satellite"; - } else if (!isCoreLeader && rwPartial >= 3) { - priority_level = "5_rw_weakness"; - } else if (Number.isFinite(profitPct) && profitPct >= 10) { - priority_level = "6_profit_lock"; - } else if (isCoreLeader && inUptrend) { - priority_level = "9_core_leader_last"; - } else { - priority_level = "7_general_rebalance"; - } - - return { - score: Math.min(100, Math.max(0, score)), - breakdown: breakdown.join(" | "), - priority_level, - is_etf: isEtf, - is_core_leader: isCoreLeader, - sector, - sector_exposure_pct: parseFloat(sectorExp.toFixed(1)), - rebound_holdback_score: reboundHoldback_.score, - rebound_holdback_reason: reboundHoldback_.reasons, - cash_preserve_style: preservationPlan_.style, - cash_preserve_ratio: preservationPlan_.recommended_ratio, - cash_preserve_reason: preservationPlan_.reasons, - }; -} - -// Backward-compatible thin wrapper. -// Existing data_feed callers still expect calcSellPriorityScore_. -var calcSellPriorityScore_ = function(row, headers, sectorExposureMap) { - return calcSellSignalSanityScore_(row, headers, sectorExposureMap); -}; - -// ── sell_priority_engine: 전 보유종목 매도 우선순위 순위표 ────────────────── -// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine -// doGet: ?view=sell_priority -// 활성화 조건: 현금 < 목표, REGIME_TRIM_50, 또는 SELL/TRIM 후보 2개 이상 -// 핵심 보호 원칙: SK하이닉스·삼성전자(코어 주도주)는 hard_stop 없이는 마지막 순위 -function runSellPriority() { - const port = getPortfolioJson(); - const macro = getMacroJson(); - const holdings = port.holdings ?? []; - const regime_ = String(macro.market_regime ?? ""); - const computedAt_ = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ssXXX"); - - // 섹터 노출 집계 - const sectorExpMap_ = {}; - holdings.forEach(h => { - const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? ""; - const w_ = parseFloat(h.Weight_Pct); - if (sec_ && Number.isFinite(w_) && w_ > 0) - sectorExpMap_[sec_] = (sectorExpMap_[sec_] || 0) + w_; - }); - - const validWeightCount_ = holdings.filter(h => { - const w = parseFloat(h.Weight_Pct); - return Number.isFinite(w) && w > 0; - }).length; - const missingWeightCount_ = holdings.length - validWeightCount_; - const asConfirmStats_ = getAccountSnapshotConfirmStats_(); - - const rows_ = holdings - .filter(h => { - const w = parseFloat(h.Weight_Pct); - return Number.isFinite(w) && w > 0; - }) - .map(h => { - const isEtf_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(h.Name); - const isCL_ = (h.Ticker === "005930" || h.Ticker === "000660"); - const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? ""; - const sExp_ = sectorExpMap_[sec_] ?? 0; - const pctP_ = parseFloat(h.Profit_Pct); - const rw_ = parseInt(h.RW_Partial) || 0; - const cl_ = parseFloat(h.Close); - const ma20_ = parseFloat(h.MA20); - const ma60_ = parseFloat(h.MA60); - const ma20Slope_ = parseFloat(h.MA20_Slope); - const rsi14_ = parseFloat(h.RSI14); - const bbPos_ = parseFloat(h.BB_Position); - const flowCredit_ = parseFloat(h.Flow_Credit); - const leaderTotal_ = parseFloat(h.Leader_Scan_Total); - const leaderGate_ = String(h.Leader_Gate ?? ""); - const bandStatus_ = String(h.Band_Status ?? ""); - const inUp_ = Number.isFinite(cl_) && Number.isFinite(ma20_) && cl_ >= ma20_; - - const precomp = parseFloat(h.Sell_Priority_Score); - let score_; - if (Number.isFinite(precomp)) { - score_ = precomp; - } else { - score_ = 0; - if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") score_ += 50; - else if (h.Final_Action === "SELL_READY") score_ += 40; - else if (isEtf_ && sExp_ >= THRESHOLDS.SP_DUPLICATE_THRESH) score_ += 20; - if (rw_ >= 4) score_ += 20; else if (rw_ === 3) score_ += 15; else if (rw_ === 2) score_ += 8; - if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) score_ += 12; - if (isCL_ && inUp_) score_ -= 20; - if (h.SS001_Grade === "A") score_ -= 12; - score_ = Math.max(0, score_); - } - - const reboundHoldback_ = calcReboundHoldbackScore_({ - close: cl_, - ma20: ma20_, - ma60: ma60_, - ma20Slope: ma20Slope_, - rsi14: rsi14_, - bbPosition: bbPos_, - flowCredit: flowCredit_, - leaderTotal: leaderTotal_, - leaderGate: leaderGate_, - bandStatus: bandStatus_, - profitPct: pctP_, - isCoreLeader: isCL_, - }); - const preservationPlan_ = calcCashPreservationPlan_({ - sellAction: h.Sell_Action, - cashFloorStatus: String(macro.cash_floor_status ?? ""), - regime: regime_, - isCoreLeader: isCL_, - isEtf: isEtf_, - liquidityStatus: String(h.Liquidity_Status ?? h.LiquidityStatus ?? ""), - spreadStatus: String(h.Spread_Status ?? h.SpreadStatus ?? ""), - accountType: String(h.account_type ?? h.Account_Type ?? ""), - profitPct: pctP_, - rwPartial: rw_, - reboundHoldbackScore: reboundHoldback_.score, - }); - const netScore_ = Number.isFinite(precomp) - ? Math.min(100, Math.max(0, score_)) - : Math.min(100, Math.max(0, score_ - reboundHoldback_.score)); - const actionGroup_ = - (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") ? "EXIT" : - String(h.Sell_Action ?? "").startsWith("TRIM") ? "TRIM" : - String(h.Sell_Action ?? "") === "HOLD" ? "HOLD" : "WATCH"; - const actionGroupOrder_ = - actionGroup_ === "EXIT" ? 1 : - actionGroup_ === "TRIM" ? 2 : - actionGroup_ === "HOLD" ? 3 : 4; - - let tier_, tierLabel_; - if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") { - tier_ = 1; tierLabel_ = "①하드스탑"; - } else if (h.Final_Action === "SELL_READY") { - tier_ = 2; tierLabel_ = "②매도신호"; - } else if (isEtf_ && sExp_ >= 10) { - tier_ = 3; tierLabel_ = "③중복ETF"; - } else if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) { - tier_ = 4; tierLabel_ = "④손실위성"; - } else if (!isCL_ && rw_ >= 3) { - tier_ = 5; tierLabel_ = "⑤RW약세"; - } else if (!isCL_ && Number.isFinite(pctP_) && pctP_ >= 10) { - tier_ = 6; tierLabel_ = "⑥익절후보"; - } else if (isCL_ && inUp_) { - tier_ = 9; tierLabel_ = "⑨코어주도주[마지막]"; - } else { - tier_ = 7; tierLabel_ = "⑦일반"; - } - - return { - rank: 0, - tier: tier_, tier_label: tierLabel_, - ticker: h.Ticker, name: h.Name, - weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct, - rw_partial: rw_, ss001_grade: h.SS001_Grade, - sector: sec_, sector_exp_pct: parseFloat(sExp_.toFixed(1)), - is_etf: isEtf_, is_core_leader: isCL_, - final_action: h.Final_Action, sell_action: h.Sell_Action, - action_group: actionGroup_, - action_group_order: actionGroupOrder_, - sell_ratio_pct: h.Sell_Ratio_Pct, - sell_qty: h.Sell_Qty, - sell_limit_price: h.Sell_Limit_Price, - sell_validation: h.Sell_Validation, - action_reason: h.Action_Reason, - action_params: h.Action_Params ?? "", - score: netScore_, - sell_priority_score: netScore_, - raw_sell_priority_score: score_, - rebound_holdback_score: reboundHoldback_.score, - rebound_holdback_reason: reboundHoldback_.reasons, - cash_preserve_style: preservationPlan_.style, - cash_preserve_ratio: preservationPlan_.recommended_ratio, - cash_preserve_reason: preservationPlan_.reasons, - trim_style: isCL_ && inUp_ - ? "CORE_LAST" - : reboundHoldback_.score >= 18 - ? "STEP_25" - : reboundHoldback_.score >= 10 - ? "STEP_33" - : "STEP_50", - hold_reason: (isCL_ && inUp_) - ? "core_leader_uptrend — 매도 마지막(spec:portfolio_exposure.yaml:funding_order④)" : "", - quantity_note: "매도수량은 HTS 캡처 제공 후 결정. 미제공 시 수량 기재 금지(spec:00_execution_contract.yaml:P1규칙).", - }; - }) - // Hard-lock sort policy: tier asc -> score desc -> action_group_order asc - .sort((a, b) => a.tier - b.tier || b.sell_priority_score - a.sell_priority_score || a.action_group_order - b.action_group_order); - - rows_.forEach((r, i) => { r.rank = i + 1; }); - - const sheetHeaders_ = [ - "Rank","Tier","Tier_Label","Action_Group","Ticker","Name","Sector","Weight_Pct","Profit_Pct", - "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation", - "Sell_Priority_Score","Raw_Sell_Priority_Score","Rebound_Holdback_Score", - "Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason", - "Trim_Style","Hold_Reason","Action_Reason","Action_Params", - "Computed_At","Engine_Version","Sort_Policy_ID","Source_Context_Checksum" - ]; - const sourceContextChecksum_ = computeStringChecksum_(JSON.stringify({ - market_regime: regime_, - cash_floor_status: String(macro.cash_floor_status ?? ""), - holdings_count: rows_.length, - holdings_keys: rows_.map(r => `${r.ticker}:${r.final_action}:${r.sell_action}:${r.tier}:${r.sell_priority_score}`) - })); - let sheetRows_ = rows_.map(r => ([ - r.rank, - r.tier, - r.tier_label, - r.action_group, - r.ticker, - r.name, - r.sector, - r.weight_pct, - r.profit_pct, - r.final_action, - r.sell_action, - r.sell_ratio_pct ?? "", - r.sell_qty ?? "", - r.sell_limit_price, - r.sell_validation ?? "", - r.sell_priority_score, - r.raw_sell_priority_score, - r.rebound_holdback_score, - r.cash_preserve_style, - r.cash_preserve_ratio, - r.cash_preserve_reason, - r.trim_style, - r.hold_reason, - r.action_reason ?? "", - r.action_params, - computedAt_, - "sell_priority_engine_v2", - "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION", - sourceContextChecksum_, - ])); - - // 데이터 준비 미흡 상태를 빈 시트로 숨기지 않고 명시적으로 기록한다. - if (!sheetRows_.length) { - sheetRows_ = [[ - 0, - "", - "DATA_MISSING", - "WATCH", - "", - "매도우선순위 산출 불가", - "", - "", - "", - "", - "", - "", - "", - "", - "DATA_MISSING", - "", - "", - "", - "", - "", - "", - "", - "", - `[SELL_PRIORITY_INPUT_MISSING] holdings=${holdings.length}, valid_weight=${validWeightCount_}, missing_weight=${missingWeightCount_}`, - "runDataFeed/account_snapshot 갱신 후 재실행 필요" - + ` | account_snapshot confirmed=${asConfirmStats_.confirmed_rows}/${asConfirmStats_.rows_read}` - + ` parse_ok_unconfirmed=${asConfirmStats_.parse_ok_unconfirmed}`, - computedAt_, - "sell_priority_engine_v2", - "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION", - sourceContextChecksum_, - ]]; - } - writeToSheet("sell_priority", sheetHeaders_, sheetRows_); - - const cashPct_ = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? ""); - - return { - engine: "sell_priority_engine_v2", - status: rows_.length ? "READY" : "DATA_MISSING", - activation_reason: regime_.includes("RISK_OFF") ? "REGIME_TRIM_50" - : (Number.isFinite(cashPct_) && cashPct_ < 10 - ? `cash_below_floor(${cashPct_.toFixed(1)}%)` : "manual_request"), - market_regime: regime_, - computed_at: computedAt_, - engine_version: "sell_priority_engine_v2", - sort_policy_id: "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION", - sector_exposure: sectorExpMap_, - prohibition: [ - "주도주(SK하이닉스·삼성전자) 매도는 hard_stop 또는 thesis 훼손 근거 필수(tier=9는 마지막).", - "매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).", - ], - source_context_checksum: sourceContextChecksum_, - diagnostics: { - holdings_count: holdings.length, - valid_weight_count: validWeightCount_, - missing_weight_count: missingWeightCount_, - account_snapshot_rows_read: asConfirmStats_.rows_read, - account_snapshot_confirmed_rows: asConfirmStats_.confirmed_rows, - account_snapshot_parse_ok_unconfirmed: asConfirmStats_.parse_ok_unconfirmed, - }, - sell_priority_checksum: computeStringChecksum_(JSON.stringify(rows_.map(function(r) { - return { - rank: r.rank, - ticker: r.ticker, - tier: r.tier, - sell_priority_score: r.sell_priority_score, - final_action: r.final_action, - sell_action: r.sell_action - }; - }))), - sell_priority_table: rows_, - candidates: rows_, // backward-compat alias - }; -} - -function getAccountSnapshotConfirmStats_() { - var out = { rows_read: 0, confirmed_rows: 0, parse_ok_unconfirmed: 0 }; - try { - var ss = getSpreadsheet_(); - var sh = ss.getSheetByName("account_snapshot"); - if (!sh) return out; - var data = sh.getDataRange().getValues(); - if (!data || data.length < 3) return out; - var hdr = data[1].map(function(h) { return String(h || "").trim(); }); - var statusIdx = hdr.indexOf("parse_status"); - var confIdx = hdr.indexOf("user_confirmed"); - if (statusIdx < 0) return out; - for (var i = 2; i < data.length; i++) { - var parseStatus = String(data[i][statusIdx] || "").trim(); - var confirmed = confIdx >= 0 ? String(data[i][confIdx] || "").trim().toUpperCase() : ""; - if (!parseStatus && !confirmed) continue; - out.rows_read++; - var isParseOk = parseStatus === "CAPTURE_READ_OK"; - var hasConfirm = confirmed === "Y" || confirmed === "YES" || confirmed === "TRUE" || confirmed === "1"; - if (isParseOk && hasConfirm) out.confirmed_rows++; - if (isParseOk && !hasConfirm) out.parse_ok_unconfirmed++; - } - } catch (e) {} - return out; -} - -// ============================================================================ -// INTEGRATED HARNESS -// ============================================================================ - -/** - * [HARNESS] gas_data_feed.gs 통합 하네스 — H3 확장판 - * - * H1: 포트폴리오 가드 (intraday_lock, cash_floor, total_heat) - * H2: 매도후보 순위 (Sell_Priority_Score 0~100 clamp, tier 배정) - * H3: 수량 사전산출 (Sell_Qty, POSITION_SIZE_V1 입력값) - * H4: 가격 사전산출 (STOP_PRICE_CORE_V1 + TICK_NORMALIZER_V1) - * H5: 결정 상태머신 게이팅 (Final_Action per ticker + gate_trace) - * H6: Blueprint 무결성 해시 (row_count + CRC32_V1 checksum, LLM 위변조 탐지) - * - * 호출: runEventRisk() 완료 후 runHarnessRefresh_() → buildHarnessContext_() - * 출력: harness_context 시트 (key-value) - * → Python converter → blueprint_checksum 검증 → JSON data._harness_context - * → LLM: HARNESS_AUTHORITATIVE_V3 지침 (Zero-Adjective + Structured Output) - * - * 버전: 2026-05-18-H3 - */ - -// ── 상수 ───────────────────────────────────────────────────────────────────── -var HARNESS_VERSION = '2026-05-22-3RD_HARNESS_V1'; -var HARNESS_SHEET_NAME = 'harness_context'; -var AS_SHEET_NAME = 'account_snapshot'; -var SETTINGS_SHEET_NAME = 'settings'; -var DATA_FEED_SHEET_NAME = 'data_feed'; - -// 헤더 행 위치 (0-indexed) -var AS_HEADER_ROW_IDX = 1; -var DF_HEADER_ROW_IDX = 1; - -// 코어 주도주 — tier=9 (마지막 매도 순위) 고정 -// spec/risk/portfolio_exposure.yaml:regime_leading_sector_protection -var CORE_TICKERS = ['005930', '000660']; // 삼성전자, SK하이닉스 - -// P4: 장중 차단 키워드 (spec/00_execution_contract.yaml:P4.keyword_lock) -var INTRADAY_BLOCKED_KEYWORDS = ['EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY']; -var INTRADAY_CUTOFF_MINUTES = 15 * 60 + 30; // 15:30 KST - -// P4: 장중 허용 액션 목록 — 이 목록 외 모든 매도/매수 액션은 장중 금지 -// (차단목록 기반 다운그레이드를 통과한 후 최종 허용 여부를 이중 검증) -var INTRADAY_ALLOWED_ACTIONS = [ - 'HOLD', 'WATCH', 'TRIM_25', 'TRIM_33', 'TRIM_50', - 'OBSERVE_DATA_MISSING', 'INSUFFICIENT_DATA', 'NO_BUY_OVERHEATED' -]; - -// Heat 게이트 (spec/13_formula_registry.yaml:TOTAL_HEAT_V1.gates) -// L3: 국면별 동적 임계값으로 대체 — calcDynamicHeatThresholds_() 참조 -var HEAT_HARD_BLOCK_PCT = 10.0; // fallback (regime unknown) -var HEAT_HALVE_PCT = 7.0; // fallback (regime unknown) - -/** - * L3: DYNAMIC_HEAT_GATE_V1 - * 국면에 따라 Heat Gate 임계값을 동적으로 반환한다. - * spec/13b_harness_formulas.yaml:DYNAMIC_HEAT_GATE_V1 - * @param {string} regime — marketRegime string - * @return {{hardBlock: number, halve: number}} - */ -var calcHeatThresholdsByRegime_ = function(regime) { - var r = String(regime || '').toUpperCase(); - if (r.indexOf('EVENT_SHOCK') >= 0) return { hardBlock: 5.0, halve: 3.5 }; - if (r.indexOf('RISK_OFF') >= 0) return { hardBlock: 7.0, halve: 5.0 }; - if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { hardBlock: 13.0, halve: 9.0 }; - if (r.indexOf('RISK_ON') >= 0) return { hardBlock: 12.0, halve: 8.5 }; - // NEUTRAL or unknown - return { hardBlock: 10.0, halve: 7.0 }; -} - -// cash_floor MRS 구간 (spec/risk/portfolio_exposure.yaml:cash_floor.regime_numbers) -var CASH_FLOOR_BY_MRS = [ - { maxMrs: 3, minPct: 7, label: 'normal' }, - { maxMrs: 7, minPct: 10, label: 'overheated_or_event_week' }, - { maxMrs: 10, minPct: 15, label: 'risk_off' } -]; - -// KRX 호가단위 테이블 (spec/13_formula_registry.yaml:TICK_NORMALIZER_V1) -var TICK_TABLE = [ - { maxPrice: 2000, tick: 1 }, - { maxPrice: 5000, tick: 5 }, - { maxPrice: 20000, tick: 10 }, - { maxPrice: 50000, tick: 50 }, - { maxPrice: 200000, tick: 100 }, - { maxPrice: 500000, tick: 500 } - // >= 500000: tick = 1000 -]; - -// Sell_Priority_Score 컴포넌트 가중치 -// spec/risk/portfolio_exposure.yaml:candidate_scoring.components -var SP = { - HARD_STOP_BREACH: 50, - CASH_FLOOR_TRIM: 40, - DUPLICATE_ETF: 30, - HOLDING_TRIM_ROTATE: 20, - TAKE_PROFIT_BASE: 10, - DUP_SAME_SECTOR: 20, - CASH_RELIEF_GE3: 15, - CASH_RELIEF_1_3: 10, - CASH_RELIEF_LT1: 3, - RW_GE4: 20, - RW_3: 15, - RW_2: 8, - FLOW_NEGATIVE: 8, - BELOW_MA20: 8, - OVERWEIGHT_5P: 12, - OVERWEIGHT_2P: 6, - LIQUIDITY_OK: 5, - LIQUIDITY_LOW: -10, - TAX_UNKNOWN: 3, - CORE_LEADER: 20, - A_GRADE_CORE: 12 -}; - -// POSITION_SIZE_V1 기본 위험예산 (spec/13_formula_registry.yaml:RISK_BUDGET_CASCADE_V1) -var BASE_RISK_BUDGET = 0.007; // 총자산의 0.7% - - -// ── 메인 함수 ──────────────────────────────────────────────────────────────── - -/** - * buildHarnessContext_ - * GAS 확정값을 harness_context 시트에 기록한다. - * runEventRisk() 완료 후 runHarnessRefresh_()가 호출한다. - */ -function buildHarnessContext_() { - var ss = getSpreadsheet_(); - var now = new Date(); - - logHarnessSub_('[HARNESS_LAYER] L0: prepareHarnessContextInputs_'); - var harnessInputs = prepareHarnessContextInputs_(ss) || {}; - var settings = harnessInputs.settings; - var performance = harnessInputs.performance; - var totalAsset = harnessInputs.totalAsset; - var mrsScore = harnessInputs.mrsScore; - var dfMap = harnessInputs.dfMap; - var asResult = harnessInputs.asResult || {}; - asResult.holdings = Array.isArray(asResult.holdings) ? asResult.holdings : []; - var kospiRet5d = harnessInputs.kospiRet5d; - var kospiRet20d = harnessInputs.kospiRet20d || 0; - var sectorFlowRadar = harnessInputs.sectorFlowRadar; - var harnessState = harnessInputs.harnessState || {}; - var intradayLock = harnessState.intradayLock; - var capturedAtIso = harnessState.capturedAtIso; - var snapshotFreshness = harnessState.snapshotFreshness; - var snapshotGate = harnessState.snapshotGate; - var cashFloorInfo = harnessState.cashFloorInfo; - var cashShortfallInfo = harnessState.cashShortfallInfo; - var drawdownGuard = harnessState.drawdownGuard; - var winLossStreakGuard = harnessState.winLossStreakGuard; - var marketRegime = harnessState.marketRegime; - var regimeTrimGuidance = harnessState.regimeTrimGuidance; - var regimeTransitionAlert = harnessState.regimeTransitionAlert; - var regimeSizeScale = harnessState.regimeSizeScale; - var regimeCashMinPct = harnessState.regimeCashMinPct; - var heatThresholds = harnessState.heatThresholds; - var heatGate = harnessState.heatGate; - var actions = harnessState.actions; - var h1 = harnessState.h1; - h1.kospiRet20d = kospiRet20d; - var settlementCashPct = harnessState.settlementCashPct; - var totalHeatPct = harnessState.totalHeatPct; - var buyPowerKrw = harnessState.buyPowerKrw; - try { - if (cashFloorInfo && cashFloorInfo.status === 'HARD_BLOCK') { - writeSettingValue_(ss, 'cash_floor_hard_block_warning', - '[CASH_FLOOR_HARD_BLOCK] 신규 매수 차단 상태 — 현금 회복(TRIM) 우선'); - } else { - writeSettingValue_(ss, 'cash_floor_hard_block_warning', ''); - } - } catch (e) { - Logger.log('[WARN] cash_floor_hard_block_warning 기록 실패: ' + e.message); - } - logHarnessSub_('[HARNESS_LAYER] L1: assembleHarnessCoreLayers_ holdings=' + (asResult.holdings || []).length); - var coreLayers = assembleHarnessCoreLayers_( - ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw, - settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate, - cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime, - regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct, - heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar - ); - coreLayers = coreLayers || {}; - var h2 = coreLayers.h2 || {}; - var h3 = coreLayers.h3 || {}; - var h4 = coreLayers.h4 || {}; - var h5 = coreLayers.h5 || {}; - var orderBlueprint = Array.isArray(coreLayers.orderBlueprint) ? coreLayers.orderBlueprint : []; - logHarnessSub_('[HARNESS_LAYER] L1 done: h2.candidates=' + ((h2 && h2.candidates) ? h2.candidates.length : 0) - + ' h3.sellQty=' + ((h3 && h3.sellQty) ? h3.sellQty.length : 0) - + ' h4.prices=' + ((h4 && h4.prices) ? h4.prices.length : 0) - + ' h5.decisions=' + ((h5 && h5["decisions"]) ? h5["decisions"].length : 0) - + ' blueprint=' + (orderBlueprint ? orderBlueprint.length : 0)); - - logHarnessSub_('[HARNESS_LAYER] L2: assembleHarnessRiskLayers_'); - var riskLayers = assembleHarnessRiskLayers_( - ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4 - ); - riskLayers = riskLayers || {}; - var portfolioBetaGate = riskLayers.portfolioBetaGate; - var eventRiskRows = riskLayers.eventRiskRows; - var sectorConcentration = riskLayers.sectorConcentration; - var tpLadderRows = riskLayers.tpLadderRows; - var stopAdequacyRows = riskLayers.stopAdequacyRows; - var staleRows = riskLayers.staleRows; - var singlePositionWeightCap = riskLayers.singlePositionWeightCap; - var semiconductorClusterGate = riskLayers.semiconductorClusterGate; - var portfolioDrawdownGate = riskLayers.portfolioDrawdownGate; - var positionCountLimit = riskLayers.positionCountLimit; - var stopBreachAlert = riskLayers.stopBreachAlert; - var relativeStopSignal = riskLayers.relativeStopSignal; - var tpTriggerAlert = riskLayers.tpTriggerAlert; - var heatConcentrationAlert = riskLayers.heatConcentrationAlert; - var sectorMomentumRows = riskLayers.sectorMomentumRows; - logHarnessSub_('[HARNESS_LAYER] L2 done'); - - logHarnessSub_('[HARNESS_LAYER] L3: assembleHarnessAlphaLayers_'); - var alphaLayers = assembleHarnessAlphaLayers_( - ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, - orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, drawdownGuard, snapshotGate, - cashFloorInfo, portfolioBetaGate, sectorConcentration, portfolioDrawdownGate, - winLossStreakGuard, positionCountLimit, singlePositionWeightCap, semiconductorClusterGate, - stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, - heatGate - ); - alphaLayers = alphaLayers || {}; - var hAlpha = alphaLayers.hAlpha; - var hApex = alphaLayers.hApex; - var backdataRows = alphaLayers.backdataRows; - var dfgResult = alphaLayers.dfgResult; - var claExitJson = alphaLayers.claExitJson; - var slgRows = alphaLayers.slgRows; - var pcgResult = alphaLayers.pcgResult; - var portfolioHealthScore = alphaLayers.portfolioHealthScore; - hAlpha = hAlpha || {}; - hApex = hApex || {}; - // [PROPOSAL51-FIX] P2-B: portfolio_health_score 숫자형 보장 (Export Gate CHECK_7 연동) - // portfolioHealthScore 객체를 hApex에 숫자 필드로 주입 (기존엔 hApex에 미등록 → Boolean/undefined) - if (portfolioHealthScore) { - var phsVal = portfolioHealthScore.score; - hApex.portfolio_health_score = (typeof phsVal === 'number' && !isNaN(phsVal)) ? phsVal : 50; - hApex.portfolio_health_label = portfolioHealthScore.label || 'CAUTION'; - hApex.portfolio_health_json = portfolioHealthScore; - } - if (relativeStopSignal) hApex.relative_stop_signal = relativeStopSignal; - logHarnessSub_('[HARNESS_LAYER] L3 done'); - - logHarnessSub_('[HARNESS_LAYER] L4: finalizeHarnessContextRows_'); - finalizeHarnessContextRows_( - ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, - heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct, - buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha, - regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard, - portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale, - regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap, - semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, - stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, - portfolioHealthScore - ); - logHarnessSub_('[HARNESS_LAYER] L4 done'); - - var sellCandidatesCount = ((h2 && h2.candidates) ? h2.candidates.length : 0); - var routeCount = ((h5 && h5["decisions"]) ? h5["decisions"].length : 0); - Logger.log('[HARNESS H2] 완료' - + ' | intraday=' + intradayLock - + ' | cash=' + settlementCashPct + '%' - + ' | heat=' + totalHeatPct + '%' - + ' | cashFloor=' + cashFloorInfo.status - + ' | heatGate=' + heatGate - + ' | perf=' + performance.bayesian_label + '×' + performance.bayesian_multiplier - + ' | sellCandidates=' + sellCandidatesCount - + ' | decisions=' + routeCount - + ' | alphaShield_critical=' + (hAlpha.critical_alert_count || 0) - + ' | apex_buy_blocks=' + (hApex.apex_block_count || 0)); - if (routeCount > 0 && sellCandidatesCount === 0) { - Logger.log('[LOG_METRIC_MISMATCH_WARN] decisions>0 이지만 sellCandidates=0 (정책/입력 상태 점검 필요)'); - } -} - - diff --git a/src/gas/engines/gdf_02_harness_assembly.gs b/src/gas/engines/gdf_02_harness_assembly.gs deleted file mode 100644 index cd1679e..0000000 --- a/src/gas/engines/gdf_02_harness_assembly.gs +++ /dev/null @@ -1,2216 +0,0 @@ -function shouldEmitHarnessVerboseLogs_() { - try { - var props = PropertiesService.getScriptProperties(); - var profile = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase(); - if (profile === 'DEBUG') return true; - if (profile === 'NORMAL') return false; - // Backward compatibility - var v = props.getProperty('HARNESS_VERBOSE_LOG'); - return String(v || '').toLowerCase() === 'true'; - } catch (e) { - return false; - } -} - -function logHarnessSub_(msg) { - if (shouldEmitHarnessVerboseLogs_()) Logger.log(msg); -} - -function setHarnessLogProfile_(profile) { - var p = String(profile || '').toUpperCase(); - if (p !== 'NORMAL' && p !== 'DEBUG') { - throw new Error("setHarnessLogProfile_: profile must be 'NORMAL' or 'DEBUG'"); - } - var props = PropertiesService.getScriptProperties(); - props.setProperty('HARNESS_LOG_PROFILE', p); - if (p === 'DEBUG') props.setProperty('HARNESS_VERBOSE_LOG', 'true'); - if (p === 'NORMAL') props.deleteProperty('HARNESS_VERBOSE_LOG'); - Logger.log('[HARNESS_LOG_PROFILE] set to ' + p); - return { profile: p, formula_id: 'HARNESS_LOG_PROFILE_V1' }; -} - -function setHarnessLogProfileNormal_() { - return setHarnessLogProfile_('NORMAL'); -} - -function setHarnessLogProfileDebug_() { - return setHarnessLogProfile_('DEBUG'); -} - -function getHarnessLogProfile_() { - var profile = 'NORMAL'; - var verboseFallback = false; - try { - var props = PropertiesService.getScriptProperties(); - var p = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase(); - if (p === 'NORMAL' || p === 'DEBUG') profile = p; - verboseFallback = String(props.getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true'; - } catch (e) {} - return { - profile: profile, - verbose_fallback: verboseFallback, - formula_id: 'HARNESS_LOG_PROFILE_V1' - }; -} - - -function assembleHarnessCoreLayers_( - ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw, - settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate, - cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime, - regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct, - heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar -) { - var h2 = calcSellPriority_(asResult.holdings, dfMap, h1); - var h3 = calcQuantities_(asResult.holdings, dfMap, totalAsset, buyPowerKrw, h1); - var h4 = calcPrices_(asResult.holdings, dfMap, marketRegime); - var h5 = runRouteFlow_(asResult.holdings, dfMap, h1); - var orderBlueprint = buildOrderBlueprint_(asResult.holdings, dfMap, { - intradayLock: intradayLock, - heatGate: heatGate, - cashFloorStatus: cashFloorInfo.status, - blockedActions: actions.blocked - }, h3, h4, h5); - return { - h2: h2, - h3: h3, - h4: h4, - h5: h5, - orderBlueprint: orderBlueprint - }; -} - - -function assembleHarnessRiskLayers_( - ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4 -) { - var portfolioBetaGate = calcPortfolioBetaGate_(asResult.holdings, dfMap, kospiRet5d, marketRegime); - var eventRiskRows = calcEventRiskHoldGate_(asResult.holdings, dfMap); - var sectorConcentration = calcSectorConcentrationGate_(asResult.holdings, marketRegime); - var tpLadderRows = calcTpQuantityLadder_(asResult.holdings, h4); - var stopAdequacyRows = calcStopAdequacyRows_(asResult.holdings, dfMap); - var staleRows = calcHoldingStaleReview_(asResult.holdings); - // KOSPI 반도체 시총 비중 — settings 시트에서만 입력. 미설정 시 0 (DATA_MISSING 처리) - // 주의: 하드코딩 기본값 금지. 실제 비중은 KRX/FnGuide 시총 데이터에서 수동 입력. - var kospiSemiWt = toNumber_(settings['kospi_semi_weight_pct']) || 0; - var kospiSamsungWt = toNumber_(settings['kospi_samsung_weight_pct']) || 0; - var kospiHynixWt = toNumber_(settings['kospi_hynix_weight_pct']) || 0; - var singlePositionWeightCap = calcSinglePositionWeightCap_(asResult.holdings, marketRegime, kospiSamsungWt, kospiHynixWt); - var semiconductorClusterGate = calcSemiconductorClusterGate_(asResult.holdings, marketRegime, kospiSemiWt); - var portfolioDrawdownGate = calcPortfolioDrawdownGate_(totalAsset, ss, settings); - var positionCountLimit = calcPositionCountLimit_(asResult.holdings, marketRegime); - var stopBreachAlert = calcStopBreachAlert_(asResult.holdings, dfMap); - var kospiRet20d_ = readKospiRet20d_(ss); - var relativeStopSignal = calcRelativeStopSignal_(asResult.holdings, dfMap, kospiRet20d_); - var tpTriggerAlert = calcTpTriggerAlert_(asResult.holdings, dfMap, h4, tpLadderRows); - var heatConcentrationAlert = calcHeatConcentrationAlert_(asResult.holdings, asResult.totalHeatKrw); - var sectorMomentumRows = calcSectorRotationMomentum_(sectorFlowRadar); - return { - portfolioBetaGate: portfolioBetaGate, - eventRiskRows: eventRiskRows, - sectorConcentration: sectorConcentration, - tpLadderRows: tpLadderRows, - stopAdequacyRows: stopAdequacyRows, - staleRows: staleRows, - singlePositionWeightCap: singlePositionWeightCap, - semiconductorClusterGate: semiconductorClusterGate, - portfolioDrawdownGate: portfolioDrawdownGate, - positionCountLimit: positionCountLimit, - stopBreachAlert: stopBreachAlert, - relativeStopSignal: relativeStopSignal, - tpTriggerAlert: tpTriggerAlert, - heatConcentrationAlert: heatConcentrationAlert, - sectorMomentumRows: sectorMomentumRows - }; -} - - -function assembleHarnessAlphaLayers_( - ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, - orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, drawdownGuard, snapshotGate, - cashFloorInfo, portfolioBetaGate, sectorConcentration, portfolioDrawdownGate, - winLossStreakGuard, positionCountLimit, singlePositionWeightCap, semiconductorClusterGate, - stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, - heatGate -) { - logHarnessSub_('[HARNESS_SUB] L3-A: assembleHarnessAlphaRadar_'); - var alphaLayer = assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar); - logHarnessSub_('[HARNESS_SUB] L3-B: assembleHarnessApexLayer_'); - var apexLayer = assembleHarnessApexLayer_( - ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, - h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo - ); - logHarnessSub_('[HARNESS_SUB] L3-C: syncBackdataFeatureBank_'); - var hAlphaResult = alphaLayer.hAlpha; - var hApexResult = apexLayer.hApex; - var backdataRows = syncBackdataFeatureBank_(now, asResult.holdings, dfMap, hAlphaResult, hApexResult); - hApexResult.backdata_feature_bank_json = backdataRows; - hApexResult.backdata_learning_lock = true; - var dfgResult = apexLayer.dfgResult; - var claExitJson = apexLayer.claExitJson; - var slgRows = apexLayer.slgRows; - var pcgResult = apexLayer.pcgResult; - logHarnessSub_('[HARNESS_SUB] L3-D: calcHarnessPortfolioHealthScore_'); - var portfolioHealthScore = calcHarnessPortfolioHealthScore_({ - heat_gate: heatGate, - cash_floor_status: cashFloorInfo.status, - drawdown_guard_state: drawdownGuard.state, - snapshot_execution_gate: snapshotGate.status, - portfolio_beta_gate: (portfolioBetaGate || {}).gate_status, - sector_concentration: (sectorConcentration || {}).gate_status, - portfolio_drawdown_gate: (portfolioDrawdownGate || {}).gate, - win_loss_streak_state: winLossStreakGuard.state, - position_count_gate: (positionCountLimit || {}).gate_status, - single_position_weight: (singlePositionWeightCap || {}).gate_status, - semiconductor_cluster: (semiconductorClusterGate || {}).gate_status, - stop_breach_gate: (stopBreachAlert || {}).gate, - tp_trigger_gate: (tpTriggerAlert || {}).gate, - heat_concentration_gate: (heatConcentrationAlert || {}).gate, - regime_transition_type: (regimeTransitionAlert || {}).transition_type - }); - // [PROPOSAL50] P1-C: M5 V1.1 — 반도체 클러스터 한도 2배 초과 시 4주 의무 감축 계획 - var mandatoryReduction = calcMandatoryReductionPlan_( - semiconductorClusterGate, asResult.holdings, dfMap, h3, totalAsset - ); - hApexResult.mandatory_reduction_json = mandatoryReduction; - if (mandatoryReduction.is_mandatory) { - Logger.log('[M5_V1.1] MANDATORY_REDUCTION: ' + mandatoryReduction.current_excess_pct - + '%p 초과 → 주당 ' + mandatoryReduction.weekly_reduction_target_krw + '원 감축 필요'); - } - return { - hAlpha: hAlphaResult, - hApex: hApexResult, - backdataRows: backdataRows, - dfgResult: dfgResult, - claExitJson: claExitJson, - slgRows: slgRows, - pcgResult: pcgResult, - portfolioHealthScore: portfolioHealthScore - }; -} - - -function assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar) { - var hAlpha = calcAlphaShield_(asResult.holdings, dfMap, kospiRet5d, sectorFlowRadar); - return { hAlpha: hAlpha }; -} - - -function assembleHarnessApexLayer_( - ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, - h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo -) { - logHarnessSub_('[HARNESS_SUB] L3-B1: assembleHarnessApexCore_'); - var apexCore = assembleHarnessApexCore_( - ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, - h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso - ); - logHarnessSub_('[HARNESS_SUB] L3-B2: applyApexProposal46Suite_'); - var hApex = applyApexProposal46Suite_( - ss, asResult.holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, apexCore.hApex - ); - hApex = hApex || {}; - orderBlueprint = Array.isArray(orderBlueprint) ? orderBlueprint : []; - logHarnessSub_('[HARNESS_SUB] L3-B3: buildRoutingTrace_'); - // [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — export_gate 완료 후 라우팅 trace 확정 - var routingTrace = buildRoutingTrace_( - (h1 && h1.intradayLock) || false, cashFloorInfo, hApex, capturedAtIso - ); - hApex.routing_trace_json = routingTrace; - hApex.routing_serving_trace_v2_json = buildRoutingServingTraceV2_(routingTrace, hApex); - logHarnessSub_('[HARNESS_SUB] L3-B4: buildWatchLedger_'); - // [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — validation_status != PASS 행 물리 분리 - hApex.watch_ledger_json = buildWatchLedger_(orderBlueprint, h4); - logHarnessSub_('[HARNESS_SUB] L3-B5: buildShadowLedger_'); - // [PROPOSAL50] H10: SHADOW_LEDGER_V1 — BLOCKED 블루프린트 투명 원장 - hApex.shadow_ledger_json = buildShadowLedger_(orderBlueprint, dfMap); - logHarnessSub_('[HARNESS_SUB] L3-B6: calcTrimPlanMinCash_'); - // [PROPOSAL50] G2: TRIM_PLAN_MIN_CASH_V1 — 최소 현금 TRIM 계획 결정론적 산출 - // h2는 sell_priority 결과 객체; sell_priority_table 배열만 전달 - hApex.trim_plan_to_min_cash_json = calcTrimPlanMinCash_( - asResult.holdings, dfMap, cashShortfallInfo, - (h2 && h2.sell_priority_table) ? h2.sell_priority_table : (Array.isArray(h2) ? h2 : []) - ); - // [PROPOSAL51] P1-C: CRDL-V1 — 현금회복 3분리 표시 잠금 (trim_plan 확정 후) - hApex.cash_recovery_display_json = calcCashRecoveryDisplayLock_( - hApex.scrs_v2_json || {}, - hApex.trim_plan_to_min_cash_json || [], - cashShortfallInfo || {} - ); - logHarnessSub_('[HARNESS_SUB] L3-B7: calcLlmServingConstraint_'); - // [PROPOSAL50] D2: LLM_SERVING_CONSTRAINT_V1 — 12가지 금지행동 체크 (보고서 조립 직전) - hApex.llm_serving_constraint_json = calcLlmServingConstraint_(hApex); - logHarnessSub_('[HARNESS_SUB] L3-B8: calcDeterministicServingLock_'); - // [PROPOSAL50] P2-1: DSLE-V1 — 서빙 잠금 (파이프라인 최종 단계) - var servingLock = calcDeterministicServingLock_(hApex, capturedAtIso, now); - hApex.serving_lock_json = servingLock; - return { - hApex: hApex, - dfgResult: apexCore.dfgResult, - claExitJson: apexCore.claExitJson, - slgRows: apexCore.slgRows, - pcgResult: apexCore.pcgResult - }; -} - - -function assembleHarnessApexCore_( - ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, - h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso -) { - logHarnessSub_('[HARNESS_SUB] L3-B1a: calcApexExecutionHarness_'); - var hApex = calcApexExecutionHarness_( - asResult.holdings, dfMap, sectorFlowRadar, kospiRet5d, - h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime - ); - logHarnessSub_('[HARNESS_SUB] L3-B1b: applyApexPostProcessing_'); - var apexPost = applyApexPostProcessing_( - ss, now, capturedAtIso, asResult.holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex - ); - return { - hApex: apexPost.hApex, - dfgResult: apexPost.dfgResult, - claExitJson: apexPost.claExitJson, - slgRows: apexPost.slgRows, - pcgResult: apexPost.pcgResult - }; -} - - -function prepareHarnessContextInputs_(ss) { - // 공통 데이터 읽기와 H1 사전 게이트를 분리해 buildHarnessContext_()를 얇게 유지한다. - var settings = readSettings_(ss); - var performance = readPerformanceSheet_(); - var totalAsset = toNumber_(settings['total_asset_krw']); - var mrsScore = toNumber_(settings['mrs_score'] || settings['MRS'] || 5); - var dfMap = buildDataFeedMap_(ss); - var asResult = parseAccountSnapshot_(ss, totalAsset, dfMap); - var kospiRet5d = readKospiRet5d_(ss); - var kospiRet20d = readKospiRet20d_(ss); - var sectorFlowRadar = readSectorFlowForRadar_(ss); - - if (totalAsset <= 0) totalAsset = asResult.derivedTotalAsset; - - var harnessState = calcHarnessPortfolioGuardState_( - ss, asResult, settings, performance, totalAsset, mrsScore - ); - - return { - settings: settings, - performance: performance, - totalAsset: totalAsset, - mrsScore: mrsScore, - dfMap: dfMap, - asResult: asResult, - kospiRet5d: kospiRet5d, - kospiRet20d: kospiRet20d, - sectorFlowRadar: sectorFlowRadar, - harnessState: harnessState - }; -} - - -function finalizeHarnessContextRows_( - ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, - heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct, - buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha, - regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard, - portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale, - regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap, - semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, - stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, - portfolioHealthScore -) { - var rows = buildHarnessRows_( - now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore, - asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions, - performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance, - cashShortfallInfo, hApex, sectorMomentumRows, - drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, - regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows, - singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate, - winLossStreakGuard, positionCountLimit, - stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, - regimeTransitionAlert, portfolioHealthScore - ); - assertHarnessRowsComplete_(rows); - writeHarnessSheet_(ss, rows, now); - return rows; -} - - -function calcHarnessPortfolioHealthScore_(gateMap) { - return calcPortfolioHealthScore_(gateMap); -} - - -function calcHarnessPortfolioGuardState_(ss, asResult, settings, performance, totalAsset, mrsScore) { - var settlementCashPct = totalAsset > 0 - ? round2_(asResult.settlementCashD2Krw / totalAsset * 100) : 0; - var totalHeatPct = totalAsset > 0 - ? round2_(asResult.totalHeatKrw / totalAsset * 100) : 0; - var buyPowerKrw = asResult.settlementCashD2Krw - asResult.openOrderAmountKrw; - - var intradayLock = calcIntradayLock_(asResult.capturedAt); - var capturedAtIso = asResult.capturedAt ? formatIso_(asResult.capturedAt) : ''; - var snapshotFreshness = checkAccountSnapshotFreshness_(); - var snapshotGate = snapshotExecutionGate_(snapshotFreshness); - var cashFloorInfo = calcCashFloor_(mrsScore, settlementCashPct); - var cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore); - - var drawdownGuard = calcDrawdownGuard_(performance); - var winLossStreakGuard = calcWinLossStreakGuard_(performance); - var marketRegime = readMacroRegime_(ss); - var regimeTrimGuidance = calcRegimeTrimGuidance_(marketRegime); - var regimeTransitionAlert = calcRegimeTransitionAlert_(marketRegime, ss, settings); - var regimeSizeScale = calcRegimeSizeScale_(marketRegime); - var regimeCashMinPct = calcRegimeCashUplift_(marketRegime, cashFloorInfo.minPct); - if (regimeCashMinPct > cashFloorInfo.minPct) { - cashFloorInfo.minPct = regimeCashMinPct; - cashFloorInfo.status = settlementCashPct >= regimeCashMinPct ? 'OK' : 'BELOW_FLOOR'; - cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore); - } - var heatThresholds = calcHeatThresholdsByRegime_(marketRegime); - var heatGate = totalHeatPct >= heatThresholds.hardBlock ? 'BLOCK_NEW_BUY' - : totalHeatPct >= heatThresholds.halve ? 'HALVE_NEW_BUY_QUANTITY' - : 'ALLOW_CONTINUE'; - var actions = calcActions_(intradayLock, heatGate, cashFloorInfo.status); - - var h1 = { - intradayLock: intradayLock, - snapshotExecutionGate: snapshotGate.status, - snapshotExecutionReason: snapshotGate.reason, - accountSnapshotFreshness: snapshotFreshness, - heatGate: heatGate, - heatGateThresholdPct: heatThresholds.hardBlock, - drawdownBuyScale: drawdownGuard.buy_scale, - drawdownGuardState: drawdownGuard.state, - regimeSizeScale: regimeSizeScale.scale, - winLossStreakBuyScale: winLossStreakGuard.buy_scale, - winLossStreakState: winLossStreakGuard.state, - cashFloorStatus: cashFloorInfo.status, - cashFloorMinPct: cashFloorInfo.minPct, - totalAsset: totalAsset, - buyPowerKrw: buyPowerKrw, - performanceMultiplier: performance.bayesian_multiplier, - performanceLabel: performance.bayesian_label, - performanceBuyBias: calcPerformanceBuyBias_(performance), - }; - - return { - settlementCashPct: settlementCashPct, - totalHeatPct: totalHeatPct, - buyPowerKrw: buyPowerKrw, - intradayLock: intradayLock, - capturedAtIso: capturedAtIso, - snapshotFreshness: snapshotFreshness, - snapshotGate: snapshotGate, - cashFloorInfo: cashFloorInfo, - cashShortfallInfo: cashShortfallInfo, - drawdownGuard: drawdownGuard, - winLossStreakGuard: winLossStreakGuard, - marketRegime: marketRegime, - regimeTrimGuidance: regimeTrimGuidance, - regimeTransitionAlert: regimeTransitionAlert, - regimeSizeScale: regimeSizeScale, - regimeCashMinPct: regimeCashMinPct, - heatThresholds: heatThresholds, - heatGate: heatGate, - actions: actions, - h1: h1, - }; -} - - -function applyApexPostProcessing_(ss, now, capturedAtIso, holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex) { - // ── [2026-05-21_SPRINT_B] Sprint B 게이트 산출 ─────────────────────────────── - logHarnessSub_('[HARNESS_SUB] L3-B1b-1: calcHarnessDataFreshnessGate_'); - var dfgResult = calcHarnessDataFreshnessGate_(capturedAtIso, now); - logHarnessSub_('[HARNESS_SUB] L3-B1b-2: calcClaRegimeExitCondition_'); - var claExitJson = calcClaRegimeExitCondition_(dfMap, marketRegime); - logHarnessSub_('[HARNESS_SUB] L3-B1b-3: calcSatelliteLifecycleGate_'); - var slgRows = calcSatelliteLifecycleGate_( - holdings, dfMap, hApex.alpha_evaluation_window_json || [] - ); - logHarnessSub_('[HARNESS_SUB] L3-B1b-4: calcPortfolioCorrelationGate_'); - var pcgResult = calcPortfolioCorrelationGate_( - holdings, dfMap, totalAsset, kospiRet5d - ); - - // Direction DFG: STALE_WARN/STALE_BLOCK → SAQG ELIGIBLE 하향 - if (dfgResult.data_freshness_status === 'STALE_WARN' - || dfgResult.data_freshness_status === 'STALE_BLOCK') { - (hApex.saqg_json || []).forEach(function(r) { - if (r.saqg_v1 === 'ELIGIBLE') { - r.saqg_v1 = 'WATCHLIST_ONLY'; - r.hts_allowed = false; - r.saqg_downgraded_by = 'DFG_' + dfgResult.data_freshness_status; - } - }); - } - hApex.data_freshness_json = dfgResult; - hApex.cla_regime_exit_json = claExitJson; - hApex.satellite_lifecycle_gate_json = slgRows; - hApex.portfolio_correlation_gate_json = pcgResult; - - // [C-1] AFL: alpha history upsert (T+20/T+60 graduated holdings) - try { - appendAlphaHistory_(ss, hApex.alpha_evaluation_window_json || [], holdings, dfMap, marketRegime); - } catch(e) { - Logger.log("[AFL] appendAlphaHistory_ error: " + e.message); - } - try { - hApex.alpha_feedback_json = runAlphaFeedbackLoop_(); - } catch(e) { - Logger.log("[AFL] runAlphaFeedbackLoop_ error: " + e.message); - hApex.alpha_feedback_json = getAlphaFeedbackJson_(); - } - - // Direction PCG: CORRELATION_BLOCK → 약한 위성 BUY 추가 차단 - if (pcgResult.correlation_gate_status === 'CORRELATION_BLOCK') { - (hApex.buy_permission_json || []).forEach(function(bp) { - var h = holdings.find(function(x) { return x.ticker === bp.ticker; }); - if (!h || h.position_type === 'core') return; - var df = dfMap[bp.ticker] || {}; - var slg = slgRows.find(function(r) { return r.ticker === bp.ticker; }); - var weakSignal = df.rs_verdict === 'LAGGARD' || df.brt_verdict === 'BROKEN' - || (slg && (slg.lifecycle_stage === 'REVIEW' || slg.lifecycle_stage === 'EXIT')); - if (weakSignal && bp.buy_permission_state !== 'BLOCKED') { - bp.buy_permission_state = 'BLOCKED'; - bp.blocked_reason_codes = (bp.blocked_reason_codes || []) - .concat(['CORRELATION_BLOCK_WEAK_SATELLITE']); - } - }); - } - - return { - hApex: hApex, - dfgResult: dfgResult, - claExitJson: claExitJson, - slgRows: slgRows, - pcgResult: pcgResult, - }; -} - - -function applyApexProposal46Suite_(ss, holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, hApex) { - logHarnessSub_('[HARNESS_SUB] L3-B2a: applyApexMacroAlphaSuite_'); - hApex = applyApexMacroAlphaSuite_(holdings, dfMap, hApex); - logHarnessSub_('[HARNESS_SUB] L3-B2b: applyApexProtectionAndFeedbackSuite_'); - hApex = applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex); - logHarnessSub_('[HARNESS_SUB] L3-B2c: applyApexConsistencySuite_'); - hApex = applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now); - return hApex; -} - - -function applyApexMacroAlphaSuite_(holdings, dfMap, hApex) { - return applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex); -} - - -function applyApexMacroEventSuite_(hApex) { - return applyApexMacroEventSuiteImpl_(hApex); -} - - -function applyApexPredictiveAlphaSuite_(holdings, dfMap, hApex) { - return applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex); -} - - -function applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) { - logHarnessSub_('[HARNESS_SUB] L3-B2b-i: applyApexCashPreservationSuite_'); - hApex = applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex); - logHarnessSub_('[HARNESS_SUB] L3-B2b-ii: applyApexFeedbackSignalSuite_'); - hApex = applyApexFeedbackSignalSuite_(holdings, dfMap, hApex); - return hApex; -} - - -function applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) { - // PA3: CASH_PRESERVATION_SELL_ENGINE_V2 - var cpseRows = calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3); - hApex.cash_preservation_sell_json = cpseRows; - // [PROPOSAL50] P1-2: SCRS-V2 — 주식가치 보호 + 반등 포착 통합 현금확보 엔진 - var scrsResult = calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex); - hApex.scrs_v2_json = scrsResult; - // [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 D-3일 사전 분배 감지 - var ppMap = {}; - ((hApex.profit_preservation_json) || []).forEach(function(pp) { - var tk = (pp.ticker || pp.ticker_code || '').toString(); - if (tk) ppMap[tk] = pp; - }); - hApex.proactive_sell_radar_json = calcProactiveSellRadarV2_(holdings, dfMap, ppMap); - return hApex; -} - - -function applyApexFeedbackSignalSuite_(holdings, dfMap, hApex) { - // anti_late_entry_json set first — watch_breakout uses ALE grade to filter grade-F chasers - logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-0: anti_late_entry_json'); - hApex.anti_late_entry_json = calcAntiLateEntryGateV2_(holdings, dfMap); - logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-1: applyApexWatchBreakoutSuite_'); - hApex = applyApexWatchBreakoutSuite_(holdings, dfMap, hApex); - logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-2: applyApexAntiWhipsawSuite_'); - hApex = applyApexAntiWhipsawSuite_(holdings, dfMap, hApex); - logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-3: applyApexAlphaHistorySuite_'); - hApex = applyApexAlphaHistorySuite_(hApex); - return hApex; -} - - -function applyApexWatchBreakoutSuite_(holdings, dfMap, hApex) { - return applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex); -} - - -function applyApexAntiWhipsawSuite_(holdings, dfMap, hApex) { - // [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1 - var awrRows = calcAntiWhipsawReentryGateV1_( - hApex.sell_candidates_json || [], dfMap, holdings - ); - hApex.anti_whipsaw_reentry_json = awrRows; - return hApex; -} - - -function applyApexAlphaHistorySuite_(hApex) { - // [PROPOSAL48_C7] alpha_history T20/T60 통계 집계 — T+5 피드백 루프 가시화 - hApex.alpha_history_summary_json = getAlphaHistorySummary_(); - return hApex; -} - - -function applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now) { - // PA5: CONSISTENCY_VALIDATOR_V2 - var cvResult = calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now); - hApex.consistency_report_json = cvResult; - hApex.consistency_score = cvResult.consistency_score; - hApex.cv_verdict = cvResult.cv_verdict; - // [PROPOSAL51] P0-B: SPSV2 — 매도 주문 3중 가격 검증 (Export Gate 전에 실행) - hApex.order_blueprint_json = calcSellPriceSanityV2_( - hApex.order_blueprint_json || [], - hApex.profit_preservation_json || [] - ); - - // [PROPOSAL51] P2-D: SEQG-V1 — 매도 실행 품질 채점 (SPSV2 후, Export Gate 전) - hApex.sell_execution_quality_json = calcSellExecutionQualityGate_( - hApex.order_blueprint_json || [], - [], // holdings은 hApex에 직접 포함되지 않아 PSR 데이터만으로 채점 - hApex.proactive_sell_radar_json || [] - ); - - // [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1 — cluster gate ↔ mandatory_reduction 정합성 - hApex.cluster_sync_result_json = syncSemiconductorCluster_(hApex); - - // [PROPOSAL51] P0-D: PHL-V1 — 5계층 가격 단일화 잠금 (SPSV2 통과 후) - hApex.price_hierarchy_json = applyPriceHierarchyLockAll_(hApex); - - // [PROPOSAL51] P1-B: DQG-V2 — 필드 충족률 데이터 완성도 게이트 - hApex.data_quality_gate_v2_json = calcDataQualityGateV2_(hApex); - - // [PROPOSAL53] P0-A: FUNDAMENTAL_QUALITY_GATE_V1 - hApex.fundamental_quality_json = calcFundamentalQualityGateV1_(asResult.holdings || [], dfMap || {}); - // [PROPOSAL53] P0-B: HORIZON_ALLOCATION_LOCK_V1 - hApex.horizon_allocation_json = calcHorizonAllocationLockV1_(asResult.holdings || [], hApex); - // [PROPOSAL53] P0-C: SMART_MONEY_LIQUIDITY_GATE_V1 - hApex.smart_money_liquidity_json = calcSmartMoneyLiquidityGateV1_(asResult.holdings || [], hApex); - // [PROPOSAL54] P0.5 확장 하네스 - hApex.fundamental_multifactor_json = calcFundamentalMultiFactorScoreV2_(asResult.holdings || [], dfMap || {}); - hApex.earnings_growth_quality_json = calcEarningsGrowthQualityGateV1_(asResult.holdings || [], dfMap || {}); - hApex.market_share_proxy_json = calcMarketShareMomentumProxyV1_(asResult.holdings || [], dfMap || {}, hApex); - hApex.cashflow_stability_json = calcCashflowStabilityGateV1_(asResult.holdings || [], dfMap || {}); - hApex.routing_explain_json = calcRoutingExplainLockV1_(hApex); - hApex.gs_formula_mirror_json = buildGsFormulaMirrorV1_(); - // [PROPOSAL54 P0.6] 신규 5개 하네스 실거래 BUY 차단 연동 - hApex.order_blueprint_json = applyProposal54BuyBlockLocks_(hApex.order_blueprint_json || [], hApex); - - // [PROPOSAL51-FIX-ORDER] calcExportGate_ 호출 전 portfolio_health_score 숫자형 보장. - // buildHarnessContext_()의 FIX(line 2272)보다 이 함수가 먼저 실행되므로 - // 여기서 재확인하지 않으면 CHECK_7이 항상 undefined를 본다. - if (typeof hApex.portfolio_health_score !== 'number' || isNaN(hApex.portfolio_health_score)) { - var _phsJson = hApex.portfolio_health_json; - var _phsRaw = _phsJson && _phsJson.score; - hApex.portfolio_health_score = (typeof _phsRaw === 'number' && !isNaN(_phsRaw)) ? _phsRaw : 0; - } - - // [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단 - var egResult = calcExportGate_(hApex, asResult, cashFloorInfo); - hApex.export_gate_json = egResult; - hApex.json_validation_status = egResult.json_validation_status; - hApex.hts_entry_allowed = egResult.hts_entry_allowed; - return hApex; -} - -/** - * GS Formula Mirror V1 - * Python 보조 도구로 생성되는 공식들을 GAS 하네스 계층에서도 명시적으로 추적한다. - * 목적: YAML↔GS 커버리지의 소스오브트루스를 GAS 쪽에 고정. - */ -function buildGsFormulaMirrorV1_() { - var formulaIds = [ - 'BLANK_CELL_AUDIT_V1', - 'CASHFLOW_QUALITY_SIGNAL_V1', - 'EARNINGS_QUALITY_SIGNAL_V1', - 'EJCE_VIEW_RENDERER_V1', - 'FUNDAMENTAL_MULTIFACTOR_V3', - 'FUNDAMENTAL_RAW_INGEST_V1', - 'GROWTH_RATE_SIGNAL_V1', - 'HORIZON_CLASSIFICATION_V1', - 'LIQUIDITY_FLOW_SIGNAL_V1', - 'MARKET_SHARE_SIGNAL_V2', - 'PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1', - 'RATCHET_TRAILING_GENERAL_V1', - 'ROUTING_EXECUTION_LOG_TABLE_V1', - 'SMART_CASH_RECOVERY_V3', - 'SMART_MONEY_FLOW_SIGNAL_V2', - 'VALUE_PRESERVATION_SCORER_V1' - ]; - var rows = []; - for (var i = 0; i < formulaIds.length; i++) { - rows.push({ - formula_id: formulaIds[i], - implementation_layer: 'GAS_MIRROR', - mirror_state: 'DECLARED', - formula_id_source: 'GS_FORMULA_MIRROR_V1' - }); - } - return { - formula_id: 'GS_FORMULA_MIRROR_V1', - rows: rows - }; -} - -function applyProposal54BuyBlockLocks_(blueprint, hApex) { - blueprint = Array.isArray(blueprint) ? blueprint : []; - function toMap_(obj, key, condFn) { - var m = {}; - var rows = (obj && obj.rows) || []; - if (!Array.isArray(rows)) return m; - rows.forEach(function(r) { - var tk = String((r || {})[key] || ''); - if (!tk) return; - m[tk] = condFn(r || {}); - }); - return m; - } - var fm = (hApex && hApex.fundamental_multifactor_json) || {}; - var egq = (hApex && hApex.earnings_growth_quality_json) || {}; - var msp = (hApex && hApex.market_share_proxy_json) || {}; - var cfs = (hApex && hApex.cashflow_stability_json) || {}; - - var fmMap = toMap_(fm, 'ticker', function(r){ return Number(r.score_0_100 || 0) < 60; }); - var egqMap = toMap_(egq, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; }); - var mspMap = toMap_(msp, 'ticker', function(r){ return String(r.proxy_state || '') === 'LOSING'; }); - var cfsMap = toMap_(cfs, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; }); - - return blueprint.map(function(row) { - var r = Object.assign({}, row); - var orderType = String(r.order_type || '').toUpperCase(); - var isBuy = orderType === 'BUY' || orderType === 'ADD_ON' || orderType === 'STAGED_BUY'; - if (!isBuy) return r; - var tk = String(r.ticker || ''); - var blocks = []; - // 충돌 우선순위: Cashflow/Fundamental 계열 > Share/Earnings - if (cfsMap[tk]) blocks.push('CASHFLOW_STABILITY_GATE_V1'); - if (fmMap[tk]) blocks.push('FUNDAMENTAL_MULTI_FACTOR_SCORE_V2'); - if (mspMap[tk]) blocks.push('MARKET_SHARE_MOMENTUM_PROXY_V1'); - if (egqMap[tk]) blocks.push('EARNINGS_GROWTH_QUALITY_GATE_V1'); - if (blocks.length > 0) { - r.validation_status = 'BLOCKED'; - r.blocked_by_gate = blocks.join('|'); - r.rationale_code = (r.rationale_code ? String(r.rationale_code) + '|' : '') + 'P054_BUY_BLOCK:' + r.blocked_by_gate; - if (typeof r.quantity === 'number' && r.quantity > 0) r.quantity = 0; - } - return r; - }); -} - -function calcFundamentalMultiFactorScoreV2_(holdings, dfMap) { - holdings = Array.isArray(holdings) ? holdings : []; - dfMap = dfMap || {}; - var rows = holdings.map(function(h) { - var tk = String(h.ticker || ''); - var df = dfMap[tk] || {}; - var m = { - roe: toNumber_(df.roe_pct), - opm: toNumber_(df.opm_pct), - rev: toNumber_(df.revenue_growth_pct), - opg: toNumber_(df.op_income_growth_pct), - share: toNumber_(df.market_share_proxy_pct), - ocf: toNumber_(df.operating_cf_krw), - fcf: toNumber_(df.free_cf_krw), - debt: toNumber_(df.debt_ratio_pct) - }; - var score = 0; - var fail = []; - if (m.roe !== null && m.roe >= 8) score += 15; else fail.push('ROE'); - if (m.opm !== null && m.opm >= 8) score += 15; else fail.push('OPM'); - if (m.rev !== null && m.rev >= 0) score += 15; else fail.push('REV_GROWTH'); - if (m.opg !== null && m.opg >= 0) score += 15; else fail.push('OP_GROWTH'); - if (m.share !== null && m.share >= 0) score += 10; else fail.push('SHARE_PROXY'); - if (m.ocf !== null && m.ocf > 0) score += 15; else fail.push('OCF'); - if (m.fcf !== null && m.fcf > 0) score += 10; else fail.push('FCF'); - if (m.debt !== null && m.debt <= 200) score += 5; else fail.push('DEBT'); - var grade = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 50 ? 'C' : 'D'; - return { - ticker: tk, - name: h.name || '', - score_0_100: score, - grade: grade, - buy_allowed: score >= 60 && fail.length <= 4, - fail_reasons: fail - }; - }); - return { formula_id: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', rows: rows }; -} - -function calcEarningsGrowthQualityGateV1_(holdings, dfMap) { - holdings = Array.isArray(holdings) ? holdings : []; - dfMap = dfMap || {}; - var rows = holdings.map(function(h) { - var tk = String(h.ticker || ''); - var df = dfMap[tk] || {}; - var q1 = toNumber_(df.eps_growth_qoq_pct); - var y1 = toNumber_(df.eps_growth_yoy_pct); - var trend = (q1 !== null && y1 !== null && q1 >= 0 && y1 >= 0) ? 'ACCELERATING' : - (q1 !== null && y1 !== null && q1 < 0 && y1 < 0) ? 'DECELERATING' : 'MIXED'; - var gate = trend === 'DECELERATING' ? 'BLOCK_BUY' : 'PASS_OR_WATCH'; - return { ticker: tk, name: h.name || '', trend: trend, consistency: (trend === 'MIXED' ? 'LOW' : 'HIGH'), gate: gate }; - }); - return { formula_id: 'EARNINGS_GROWTH_QUALITY_GATE_V1', rows: rows }; -} - -function calcMarketShareMomentumProxyV1_(holdings, dfMap, hApex) { - holdings = Array.isArray(holdings) ? holdings : []; - dfMap = dfMap || {}; - var alphaMap = {}; - ((hApex && hApex.alpha_lead_json) || []).forEach(function(r){ alphaMap[String(r.ticker || '')] = r; }); - var rows = holdings.map(function(h) { - var tk = String(h.ticker || ''); - var df = dfMap[tk] || {}; - var alpha = alphaMap[tk] || {}; - var rev = toNumber_(df.revenue_growth_pct); - var rs = toNumber_(alpha.alpha_lead_score); - var state = (rev !== null && rev < 0) || (rs !== null && rs < 50) ? 'LOSING' : - (rev !== null && rev > 5 && rs !== null && rs >= 70) ? 'GAINING' : 'NEUTRAL'; - return { ticker: tk, name: h.name || '', proxy_state: state, confidence_band: state === 'NEUTRAL' ? 'MEDIUM' : 'HIGH' }; - }); - return { formula_id: 'MARKET_SHARE_MOMENTUM_PROXY_V1', rows: rows }; -} - -function calcCashflowStabilityGateV1_(holdings, dfMap) { - holdings = Array.isArray(holdings) ? holdings : []; - dfMap = dfMap || {}; - var rows = holdings.map(function(h) { - var tk = String(h.ticker || ''); - var df = dfMap[tk] || {}; - var ocf = toNumber_(df.operating_cf_krw); - var fcf = toNumber_(df.free_cf_krw); - var accrual = toNumber_(df.accrual_ratio_pct); - var unstable = (ocf !== null && ocf <= 0) || (fcf !== null && fcf <= 0); - var accrRisk = (accrual !== null && accrual > 10); - return { - ticker: tk, - name: h.name || '', - stability_state: unstable ? 'UNSTABLE' : 'STABLE', - accrual_risk_flag: !!accrRisk, - gate: (unstable && accrRisk) ? 'BLOCK_BUY' : 'PASS_OR_WATCH' - }; - }); - return { formula_id: 'CASHFLOW_STABILITY_GATE_V1', rows: rows }; -} - -function calcRoutingExplainLockV1_(hApex) { - var eg = (hApex && hApex.export_gate_json) || {}; - return { - formula_id: 'ROUTING_DECISION_EXPLAIN_LOCK_V1', - gate_path: ['FUNDAMENTAL_MULTI_FACTOR_SCORE_V2','EARNINGS_GROWTH_QUALITY_GATE_V1','MARKET_SHARE_MOMENTUM_PROXY_V1','CASHFLOW_STABILITY_GATE_V1','EXPORT_GATE_V2'], - blocked_by: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'), - override_allowed: false - }; -} - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL53] 신규 P0 하네스 4종 -// ═══════════════════════════════════════════════════════════════════════ -function calcFundamentalQualityGateV1_(holdings, dfMap) { - holdings = Array.isArray(holdings) ? holdings : []; - dfMap = dfMap || {}; - var rows = holdings.map(function(h) { - var tk = String(h.ticker || ''); - var df = dfMap[tk] || {}; - var roe = toNumber_(df.roe_pct); - var opGrowth = toNumber_(df.op_income_growth_pct); - var debt = toNumber_(df.debt_ratio_pct); - var ocf = toNumber_(df.operating_cf_krw); - var pe = toNumber_(df.pe_ttm); - var pass = 0; - var fail = []; - if (roe !== null && roe >= 8) pass++; else fail.push('ROE_WEAK_OR_MISSING'); - if (opGrowth !== null && opGrowth >= 0) pass++; else fail.push('OP_GROWTH_WEAK_OR_MISSING'); - if (debt !== null && debt <= 200) pass++; else fail.push('DEBT_RATIO_HIGH_OR_MISSING'); - if (ocf !== null && ocf > 0) pass++; else fail.push('OCF_WEAK_OR_MISSING'); - if (pe !== null && pe > 0 && pe <= 35) pass++; else fail.push('PE_BAND_OUT_OR_MISSING'); - var grade = pass >= 4 ? 'A' : pass >= 3 ? 'B' : pass >= 2 ? 'C' : 'D'; - return { - ticker: tk, - name: h.name || '', - grade: grade, - score: pass, - buy_allowed: pass >= 3, - fail_reasons: fail, - formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1' - }; - }); - return { - formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1', - rows: rows - }; -} - -function calcHorizonAllocationLockV1_(holdings, hApex) { - holdings = Array.isArray(holdings) ? holdings : []; - var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || 0) || 0; - var cap = { SHORT: 25, MID: 45, LONG: 70, UNKNOWN: 0 }; - var bucketSum = { SHORT: 0, MID: 0, LONG: 0, UNKNOWN: 0 }; - var rows = holdings.map(function(h) { - var bucket = String(h.invest_horizon || h.horizon_bucket || 'UNKNOWN').toUpperCase(); - if (!cap.hasOwnProperty(bucket)) bucket = 'UNKNOWN'; - var v = toNumber_(h.marketValue) || toNumber_(h.market_value_krw) || toNumber_(h.close) * (toNumber_(h.holdingQty) || 0) || 0; - bucketSum[bucket] += v; - return { ticker: String(h.ticker || ''), name: h.name || '', bucket: bucket, market_value_krw: v }; - }); - var byBucket = Object.keys(bucketSum).map(function(k) { - var pct = totalAsset > 0 ? (bucketSum[k] / totalAsset * 100) : 0; - return { - bucket: k, - cap_pct: cap[k], - current_pct: Math.round(pct * 100) / 100, - violation: pct > cap[k] - }; - }); - return { - formula_id: 'HORIZON_ALLOCATION_LOCK_V1', - bucket_summary: byBucket, - rows: rows - }; -} - -function calcSmartMoneyLiquidityGateV1_(holdings, hApex) { - holdings = Array.isArray(holdings) ? holdings : []; - var radarMap = {}; - ((hApex && hApex.proactive_sell_radar_json) || []).forEach(function(r) { - radarMap[String(r.ticker || '')] = r; - }); - var rows = holdings.map(function(h) { - var tk = String(h.ticker || ''); - var r = radarMap[tk] || {}; - var flowState = Number(r.score || 0) >= 6 ? 'OUTFLOW_RISK' : 'NEUTRAL'; - var liqState = Number(r.liquidity_5d_bn || 0) > 0 && Number(r.liquidity_5d_bn) < 80 ? 'LOW' : 'NORMAL'; - var mode = (flowState === 'OUTFLOW_RISK' && liqState === 'LOW') ? 'SELL_SPLIT_ONLY' : 'NORMAL'; - return { - ticker: tk, - name: h.name || '', - flow_state: flowState, - liquidity_state: liqState, - execution_mode: mode, - buy_allowed: mode === 'NORMAL', - formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1' - }; - }); - return { - formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1', - rows: rows - }; -} - -function buildRoutingServingTraceV2_(routingTrace, hApex) { - var rt = routingTrace || {}; - var eg = (hApex && hApex.export_gate_json) || {}; - return { - trace_version: 'V2', - llm_serving_budget: 0, - request_route: rt.request_route || 'PIPELINE_EOD_BATCH', - bundle_selected: rt.bundle_selected || 'retirement_portfolio_compact', - prompt_entrypoint: rt.prompt_entrypoint || 'prompts/analysis_prompt.md', - gate_path: ['DATA_QUALITY_GATE_V2', 'SELL_PRICE_SANITY_V2', 'EXPORT_GATE_V2'], - final_block_reason: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'), - json_validation_status: eg.json_validation_status || rt.json_validation_status || 'PENDING_EXPORT', - formula_id: 'ROUTING_SERVING_DECISION_TRACE_V2' - }; -} - - -// ── H1 헬퍼 ────────────────────────────────────────────────────────────────── - -/** - * readMacroRegime_ - * macro 시트의 REGIME_PRELIM 행에서 시장 국면 값 읽기 - * buildHarnessContext_()에서 국면별 감축 가이던스 산출에 사용 - */ -function readMacroRegime_(ss) { - try { - var sh = ss.getSheetByName('macro'); - if (!sh) return 'UNKNOWN'; - var data = sh.getDataRange().getValues(); - for (var i = 0; i < data.length; i++) { - if (String(data[i][0] || '') === 'REGIME_PRELIM' - || String(data[i][1] || '') === 'Market_Regime_Prelim') { - return String(data[i][3] || 'UNKNOWN'); - } - } - return 'UNKNOWN'; - } catch(e) { - Logger.log('[HARNESS] readMacroRegime_ error: ' + e.message); - return 'UNKNOWN'; - } -} - -/** - * calcRegimeTrimGuidance_ - * REGIME_TRIM_WEIGHT_V1: 시장 국면별 위성/주도주 감축 비율 결정론적 산출 - * LLM이 "조정기엔 5~10%" 같은 주관적 판단을 내리는 것을 하네스에서 선점 - * spec/13_formula_registry.yaml:REGIME_TRIM_WEIGHT_V1 참조 - */ -function calcRegimeTrimGuidance_(regime) { - switch (regime) { - case 'SECULAR_LEADER_RISK_ON': - case 'RISK_ON': - return { - phase: 'ADVANCE', - satellite_trim_pct_min: 0, - satellite_trim_pct_max: 5, - leader_trim_pct_min: 0, - leader_trim_pct_max: 0, - priority_order: 'HOLD_ALL > 약한위성_5%이하 > 중복ETF', - new_buy_gate: 'ALLOWED_IF_HEAT_PASS', - description: '상승기: 주도주 보유 극대화. 감축 최소화.' - }; - case 'LEADER_CONCENTRATION': - case 'NEUTRAL': - return { - phase: 'PULLBACK_IN_UPTREND', - satellite_trim_pct_min: 5, - satellite_trim_pct_max: 10, - leader_trim_pct_min: 0, - leader_trim_pct_max: 5, - priority_order: '약한위성 > 중복ETF > 주도주_소량헤지', - new_buy_gate: 'BLOCKED', - description: '조정/횡보기: 위성 부분 감축. 주도주 소량 헤지 가능.' - }; - case 'RISK_OFF_CANDIDATE': - return { - phase: 'DISTRIBUTION', - satellite_trim_pct_min: 10, - satellite_trim_pct_max: 25, - leader_trim_pct_min: 5, - leader_trim_pct_max: 10, - priority_order: '중복ETF > 약한위성 > 주도주_이익잠금', - new_buy_gate: 'BLOCKED', - description: '분배장 경고: 위성 우선 감축. 현금 목표 12% 이상.' - }; - case 'RISK_OFF': - case 'EVENT_SHOCK': - return { - phase: 'BREAKDOWN', - satellite_trim_pct_min: 25, - satellite_trim_pct_max: 50, - leader_trim_pct_min: 10, - leader_trim_pct_max: 25, - priority_order: '코어보호해제 > 전종목감축검토', - new_buy_gate: 'HARD_BLOCKED', - description: '추세붕괴/이벤트쇼크: 전면 감축. 코어 예외 없음.' - }; - default: - return { - phase: 'UNKNOWN', - satellite_trim_pct_min: 0, - satellite_trim_pct_max: 0, - leader_trim_pct_min: 0, - leader_trim_pct_max: 0, - priority_order: 'DATA_MISSING_REGIME — 국면 미확인', - new_buy_gate: 'BLOCKED', - description: '국면 미확인: 신규매수 보류. macro 재실행 후 재판정.' - }; - } -} - -function calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore) { - var targetCashPct = Math.max(5 + (mrsScore / 10) * 15, cashFloorInfo.minPct); - var d2Krw = asResult.settlementCashD2Krw || 0; - var asset = Number.isFinite(totalAsset) ? totalAsset : 0; - return { - cash_current_pct_d2: asset > 0 ? Math.round(d2Krw / asset * 10000) / 100 : 0, - cash_target_pct: targetCashPct, - cash_shortfall_min_krw: Math.max(0, Math.round(asset * cashFloorInfo.minPct / 100 - d2Krw)), - cash_shortfall_target_krw: Math.max(0, Math.round(asset * targetCashPct / 100 - d2Krw)) - }; -} - -/** - * SECULAR_LEADER_REGIME_GATE_V1 - * 삼성전자(005930)·SK하이닉스(000660) secular_leader_profit_lock 결정론적 발동 게이트. - * spec/exit/take_profit.yaml:secular_leader_profit_lock.activation_required_all 완전 구현. - * 반환: { active, status, reasons } - */ -function calcSecularLeaderGate_(ticker, marketRegime, df, holdingQty) { - var SECULAR_TICKERS = ['005930', '000660']; - var reasons = []; - - if (SECULAR_TICKERS.indexOf(ticker) < 0) { - return { active: false, status: 'NOT_APPLICABLE', reasons: ['not_secular_leader_ticker'] }; - } - - // ── 비활성 조건 검사 (any one → 즉시 비활성) ──────────────────────────── - var close = df.close || 0; - var ma20 = df.ma20 || 0; - var frg5d = typeof df.frg5d === 'number' ? df.frg5d : null; - var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null; - var acTotal = typeof df.acTotal === 'number' ? df.acTotal : 0; - - var deactivationReasons = []; - - if (marketRegime !== 'SECULAR_LEADER_RISK_ON') { - deactivationReasons.push('regime_not_secular(' + marketRegime + ')'); - } - if (close > 0 && ma20 > 0 && close <= ma20) { - deactivationReasons.push('close(' + close + ')<=MA20(' + ma20 + ')'); - } - if (acTotal >= 3) { - deactivationReasons.push('anti_climax_gate>=' + acTotal); - } - if (frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0) { - deactivationReasons.push('dual_outflow:frg5d(' + frg5d + ')_inst5d(' + inst5d + ')'); - } - - if (deactivationReasons.length > 0) { - return { - active: false, - status: 'DEACTIVATED', - reasons: deactivationReasons - }; - } - - // ── 활성화 조건 검사 (all must pass) ──────────────────────────────────── - var activationFails = []; - - if (!(holdingQty > 0)) { - activationFails.push('no_holding_quantity'); - } - if (close <= 0 || ma20 <= 0) { - activationFails.push('close_or_ma20_missing'); - } else if (close <= ma20) { - activationFails.push('close_below_ma20'); - } - var flowOk = df.flowOk === 'Y'; - var flowPos = (frg5d !== null && frg5d > 0) || (inst5d !== null && inst5d > 0); - if (!flowOk || !flowPos) { - activationFails.push('flow_condition_fail(flowOk=' + df.flowOk + ',frg5d=' + frg5d + ',inst5d=' + inst5d + ')'); - } - - if (activationFails.length > 0) { - return { - active: false, - status: 'ACTIVATION_FAIL', - reasons: activationFails - }; - } - - return { - active: true, - status: 'ACTIVE', - reasons: ['regime=SECULAR_LEADER_RISK_ON', 'close>MA20', 'flow_ok', 'holding_confirmed'] - }; -} - -function calcIntradayLock_(capturedAt) { - if (!capturedAt) return false; - var d = capturedAt instanceof Date ? capturedAt : new Date(capturedAt); - if (isNaN(d.getTime())) return false; - var kstMin = ((d.getUTCHours() + 9) % 24) * 60 + d.getUTCMinutes(); - return kstMin < INTRADAY_CUTOFF_MINUTES; -} - -/** - * N1: POSITION_SIZE_REGIME_SCALE_V1 - * 국면에 따라 atrQty 기반 매수 수량의 스케일 배수를 반환한다. - * M1(DrawdownGuard) 이후에 추가로 적용되는 독립적 국면 방어층. - * @param {string} regime - * @return {{ scale, regime_applied }} - */ -function calcRegimeSizeScale_(regime) { - var r = String(regime || '').toUpperCase(); - if (r.indexOf('EVENT_SHOCK') >= 0) return { scale: 0.25, regime_applied: regime }; - if (r.indexOf('RISK_OFF') >= 0) return { scale: 0.50, regime_applied: regime }; - if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { scale: 1.2, regime_applied: regime }; - if (r.indexOf('RISK_ON') >= 0) return { scale: 1.1, regime_applied: regime }; - return { scale: 1.0, regime_applied: regime }; // NEUTRAL -} - -/** - * N5: REGIME_CASH_UPLIFT_V1 - * 국면에 따라 현금 최소 비율을 상향하는 오버라이드를 반환한다. - * MRS 기반 calcCashFloor_ 결과보다 높을 때만 적용된다. - * @param {string} regime - * @param {number} mrsCashMinPct — 현재 MRS 기반 최소 현금 % - * @return {number} effectiveMinPct - */ -function calcRegimeCashUplift_(regime, mrsCashMinPct) { - var r = String(regime || '').toUpperCase(); - var regimeMin = 0; - if (r.indexOf('EVENT_SHOCK') >= 0) regimeMin = 20; - else if (r.indexOf('RISK_OFF') >= 0) regimeMin = 15; - else if (r.indexOf('RISK_ON') >= 0) regimeMin = 5; // 완화 - // NEUTRAL: regimeMin=0 → MRS값 그대로 - return Math.max(mrsCashMinPct, regimeMin); -} - -/** - * N3: STOP_PRICE_ADEQUACY_V1 - * 보유 종목의 수동 손절가가 ATR 기반 권고 손절가 대비 적정한지 검증한다. - * manual_stop < recommended_stop × 0.85 → STOP_WIDE (너무 넓어 Heat 과소 반영) - * manual_stop < recommended_stop × 0.60 → STOP_CRITICAL (손절 의지 없음 수준) - * @param {Array} holdings - * @param {Object} dfMap - * @return {Array} stop_adequacy rows - */ -function calcStopAdequacyRows_(holdings, dfMap) { - return holdings.map(function(h) { - var df = dfMap[h.ticker] || {}; - var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : null; - var close = df.close || h.close || 0; - var avgCost = h.avgCost || 0; - - var recommendedStop = null; - if (atr20 && close > 0 && avgCost > 0) { - var atrMul = (atr20 / avgCost * 100 >= 8) ? 2.0 : 1.5; - recommendedStop = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul); - recommendedStop = tickNormalize_(recommendedStop); - } - - var status = 'PASS'; - var stopGap = null; - if (recommendedStop !== null && h.stopPrice > 0) { - stopGap = round2_((recommendedStop - h.stopPrice) / recommendedStop * 100); - if (h.stopPrice < recommendedStop * 0.60) status = 'STOP_CRITICAL'; - else if (h.stopPrice < recommendedStop * 0.85) status = 'STOP_WIDE'; - } else if (!atr20) { - status = 'INSUFFICIENT_DATA'; - } - - return { - ticker: h.ticker, - name: h.name || '', - manual_stop: h.stopPrice || null, - recommended_stop: recommendedStop, - stop_gap_pct: stopGap, - adequacy_status: status, - stop_price_src: h.stopPriceSrc || 'UNKNOWN', - formula_id: 'STOP_PRICE_ADEQUACY_V1' - }; - }); -} - -/** - * N4: HOLDING_STALE_REVIEW_V1 - * 보유 기간이 60일을 초과한 종목에 STALE_POSITION 플래그를 표시한다. - * account_snapshot의 entry_date 컬럼 기반. 없으면 ENTRY_DATE_MISSING. - * @param {Array} holdings — entryDate 필드 포함 - * @return {Array} holding_stale rows - */ -function calcHoldingStaleReview_(holdings) { - var nowMs = Date.now(); - var STALE_DAYS = 60; - var REVIEW_DAYS = 30; - - return holdings.map(function(h) { - var entryDateStr = h.entryDate || null; - var holdingDays = null; - var status = 'ENTRY_DATE_MISSING'; - - if (entryDateStr) { - var entryMs = new Date(entryDateStr).getTime(); - if (Number.isFinite(entryMs) && entryMs > 0) { - holdingDays = Math.floor((nowMs - entryMs) / 86400000); - if (holdingDays > STALE_DAYS) status = 'STALE_POSITION'; - else if (holdingDays > REVIEW_DAYS) status = 'REVIEW_SOON'; - else status = 'FRESH'; - } - } - - return { - ticker: h.ticker, - name: h.name || '', - entry_date: entryDateStr, - holding_days: holdingDays, - stale_status: status, - formula_id: 'HOLDING_STALE_REVIEW_V1' - }; - }); -} - -/** - * P1: STOP_BREACH_ALERT_V1 - * 보유 종목 중 close <= stop_price인 종목을 즉시 경보한다. - * close <= stop_price → BREACH_IMMEDIATE_EXIT - * close <= stop_price × 1.03 → STOP_APPROACHING - * @param {Array} holdings - * @param {Object} dfMap - * @return {{ gate, alerts }} - */ -function calcStopBreachAlert_(holdings, dfMap) { - var gate = 'PASS'; - var alerts = holdings.map(function(h) { - var df = dfMap[h.ticker] || {}; - var close = h.close || df.close || 0; - var stopPrc = h.stopPrice || 0; - var status = 'PASS'; - var gapPct = null; - if (close > 0 && stopPrc > 0) { - gapPct = round2_((close - stopPrc) / stopPrc * 100); - if (close <= stopPrc) { - status = 'BREACH_IMMEDIATE_EXIT'; - gate = 'BREACH'; - } else if (close <= stopPrc * 1.03) { - status = 'STOP_APPROACHING'; - if (gate === 'PASS') gate = 'APPROACHING'; - } - } else { - status = 'INSUFFICIENT_DATA'; - } - return { ticker: h.ticker, name: h.name || '', close: close, stop_price: stopPrc, stop_src: h.stopPriceSrc || 'UNKNOWN', gap_pct: gapPct, status: status, formula_id: 'STOP_BREACH_ALERT_V1' }; - }); - return { gate: gate, alerts: alerts }; -} - -/** - * P1-BIS: RELATIVE_STOP_SIGNAL_V1 - * 시장 베타 보정 후 초과수익(20D) 기반 상대 손절 신호. - * k=2.0 → threshold = -k × σ_proxy; ABS_FLOOR=-20%; TIME_STOP=60일+음수 초과수익 - * @param {Array} holdings - * @param {Object} dfMap - * @param {number} kospiRet20d — KOSPI 20D 수익률 (%) - * @return {{ gate, signals }} - */ -function calcRelativeStopSignal_(holdings, dfMap, kospiRet20d) { - var K = 2.0; - var ABS_FLOOR = -20.0; - var gate = 'PASS'; - var signals = holdings.map(function(h) { - var df = dfMap[h.ticker] || {}; - var ret20d = typeof df.ret20d === 'number' ? df.ret20d : parseFloat(df.ret20d); - var atr20 = typeof df.atr20 === 'number' ? df.atr20 : parseFloat(df.atr20); - var close = h.close || df.close || 0; - var profitPct = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct); - var holdDays = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0; - - if (!Number.isFinite(ret20d) || !Number.isFinite(atr20) || close <= 0) { - return { ticker: h.ticker, name: h.name || '', signal: false, - signal_type: 'INSUFFICIENT_DATA', details: {}, formula_id: 'RELATIVE_STOP_SIGNAL_V1' }; - } - - var betaProxy = 1.0; - if (typeof kospiRet20d === 'number' && Math.abs(kospiRet20d) >= 0.5) { - betaProxy = Math.min(3.0, Math.max(0.3, ret20d / kospiRet20d)); - } - var excessRet = ret20d - betaProxy * kospiRet20d; - var sigmaProxy = (atr20 / close * 100) * Math.sqrt(20); - var threshold = -K * sigmaProxy; - - var relBreach = excessRet < threshold; - var absBreach = Number.isFinite(profitPct) && profitPct < ABS_FLOOR; - var timeBreach = holdDays >= 60 && excessRet < 0; - var triggered = relBreach || absBreach || timeBreach; - var signalType = absBreach ? 'ABS_FLOOR' : (relBreach ? 'REL_EXCESS' : (timeBreach ? 'TIME_STOP' : 'PASS')); - - if (triggered && gate === 'PASS') gate = 'TRIGGERED'; - - return { - ticker: h.ticker, - name: h.name || '', - signal: triggered, - signal_type: signalType, - details: { - beta_proxy: round2_(betaProxy), - excess_ret20d: round2_(excessRet), - sigma_proxy: round2_(sigmaProxy), - threshold: round2_(threshold), - profit_pct: Number.isFinite(profitPct) ? round2_(profitPct) : null, - hold_days: holdDays - }, - formula_id: 'RELATIVE_STOP_SIGNAL_V1' - }; - }); - return { gate: gate, signals: signals }; -} - -/** - * P3: ABSOLUTE_RISK_STOP_V1 - * stop adequacy rows를 절대 리스크 손절 taxonomy에 맞춰 표준화한다. - * @param {Array} holdings - * @param {Object} dfMap - * @return {{ gate, rows }} - */ -function calcAbsoluteRiskStopV1_(holdings, dfMap) { - var rows = calcStopAdequacyRows_(holdings, dfMap).map(function(r) { - var stopPrice = Number.isFinite(r.manual_stop) && r.manual_stop > 0 - ? r.manual_stop - : r.recommended_stop; - return { - ticker: r.ticker, - name: r.name || '', - stop_price: Number.isFinite(stopPrice) ? round2_(stopPrice) : null, - stop_quantity: null, - adequacy_status: r.adequacy_status, - stop_gap_pct: r.stop_gap_pct, - formula_id: 'ABSOLUTE_RISK_STOP_V1' - }; - }); - var gate = rows.some(function(r) { return r.adequacy_status === 'STOP_CRITICAL'; }) ? 'BLOCK' : 'PASS'; - return { gate: gate, rows: rows }; -} - -/** - * P3: RELATIVE_UNDERPERF_ALERT_V1 - * 상대약세 경보를 표준 taxonomy로 감싼다. - * @param {Array} holdings - * @param {Object} dfMap - * @param {number} kospiRet20d - * @return {{ gate, rows }} - */ -function calcRelativeUnderperfAlertV1_(holdings, dfMap, kospiRet20d) { - var result = calcRelativeStopSignal_(holdings, dfMap, kospiRet20d); - return { - gate: result.gate, - rows: result.signals.map(function(r) { - return { - ticker: r.ticker, - name: r.name || '', - signal: !!r.signal, - signal_type: r.signal_type, - details: r.details || {}, - formula_id: 'RELATIVE_UNDERPERF_ALERT_V1' - }; - }) - }; -} - -/** - * P3: STOP_ACTION_LADDER_V1 - * exit sell action 결과를 손절/익절/시간손절 taxonomy로 표준화한다. - * @param {Object} ctx - * @return {{ formula_id, action, ratio_pct, limit_price, price_basis, reason, validation }} - */ -var calcStopActionLadderV1_ = function(ctx) { - var d = calcExitSellAction_(ctx || {}); - return { - formula_id: 'STOP_ACTION_LADDER_V1', - action: d.action, - ratio_pct: d.ratio_pct, - limit_price: d.limit_price, - price_basis: d.price_basis, - reason: d.reason, - validation: d.validation, - order_type: d.order_type, - price_source: d.price_source - }; -} - - -/** - * P2: TP_TRIGGER_ALERT_V1 - * close >= tp1_price / tp2_price인 종목을 감지하고 tp_quantity_ladder_json과 연계한다. - * 익절 가격 도달 시 즉각 수량을 확정론적으로 제공한다. - * @param {Array} holdings - * @param {Object} dfMap - * @param {Object} h4 — calcPrices_() 반환값 (h4.prices 배열) - * @param {Array} tpLadderRows — calcTpQuantityLadder_() 반환값 - * @return {{ gate, triggered }} - */ -function calcTpTriggerAlert_(holdings, dfMap, h4, tpLadderRows) { - var priceMap = {}; - (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; }); - var ladderMap = {}; - (tpLadderRows || []).forEach(function(r) { ladderMap[r.ticker] = r; }); - - var gate = 'PASS'; - var triggered = []; - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var close = h.close || df.close || 0; - var pr = priceMap[h.ticker] || {}; - var lr = ladderMap[h.ticker] || {}; - var tp1 = typeof pr.tp1_price === 'number' ? pr.tp1_price : null; - var tp2 = typeof pr.tp2_price === 'number' ? pr.tp2_price : null; - var tp1Hit = tp1 !== null && close > 0 && close >= tp1; - var tp2Hit = tp2 !== null && close > 0 && close >= tp2; - if (!tp1Hit && !tp2Hit) return; - if (gate === 'PASS') gate = 'TRIGGERED'; - triggered.push({ - ticker: h.ticker, - name: h.name || '', - close: close, - tp1_price: tp1, - tp2_price: tp2, - tp1_triggered: tp1Hit, - tp2_triggered: tp2Hit, - tp1_qty: lr.tp1_qty !== undefined ? lr.tp1_qty : null, - tp2_qty: lr.tp2_qty !== undefined ? lr.tp2_qty : null, - qty_source: lr.qty_source || 'NO_LADDER', - formula_id: 'TP_TRIGGER_ALERT_V1' - }); - }); - return { gate: gate, triggered: triggered }; -} - -/** - * P3: HEAT_CONCENTRATION_ALERT_V1 - * 단일 종목이 전체 Total Heat의 50% 이상을 차지하면 HEAT_CONCENTRATED 경보. - * 해당 종목 급락 시 total_heat_pct가 급변해 게이트가 무력화되는 리스크 차단. - * @param {Array} holdings — avgCost, stopPrice, holdingQty 포함 - * @param {number} totalHeatKrw - * @return {{ gate, by_holding }} - */ -function calcHeatConcentrationAlert_(holdings, totalHeatKrw) { - if (!totalHeatKrw || totalHeatKrw <= 0) { - return { gate: 'INSUFFICIENT_DATA', by_holding: [], formula_id: 'HEAT_CONCENTRATION_ALERT_V1' }; - } - var gate = 'PASS'; - var rows = holdings.map(function(h) { - var heatI = (h.avgCost > 0 && h.stopPrice > 0 && h.holdingQty > 0) - ? (h.avgCost - h.stopPrice) * h.holdingQty : 0; - var sharePct = round2_(heatI / totalHeatKrw * 100); - var status = sharePct >= 50 ? 'HEAT_CONCENTRATED' : 'PASS'; - if (status === 'HEAT_CONCENTRATED') gate = 'HEAT_CONCENTRATED'; - return { ticker: h.ticker, name: h.name || '', heat_krw: Math.round(heatI), heat_share_pct: sharePct, status: status, formula_id: 'HEAT_CONCENTRATION_ALERT_V1' }; - }); - return { gate: gate, by_holding: rows }; -} - -/** - * P4: REGIME_TRANSITION_ALERT_V1 - * settings.prev_market_regime와 현재 국면을 비교해 전환 유형을 산출한다. - * UPGRADE(완화) / DOWNGRADE(긴축) / LATERAL_SHIFT / NO_CHANGE - * 실행 후 current regime을 settings에 자동 기록. - * @param {string} marketRegime - * @param {Object} ss - * @param {Object} settings - * @return {{ transition_type, prev_regime, current_regime, affected_gates }} - */ -function calcRegimeTransitionAlert_(marketRegime, ss, settings) { - var prevRegime = String(settings['prev_market_regime'] || '').trim(); - var curr = String(marketRegime || '').toUpperCase(); - var prev = prevRegime.toUpperCase(); - writeSettingValue_(ss, 'prev_market_regime', marketRegime); - - if (!prevRegime || prev === curr) { - return { transition_type: 'NO_CHANGE', prev_regime: prevRegime || null, current_regime: marketRegime, affected_gates: [], formula_id: 'REGIME_TRANSITION_ALERT_V1' }; - } - - var RANK = { 'EVENT_SHOCK': 0, 'RISK_OFF': 1, 'NEUTRAL': 2, 'RISK_ON': 3, 'SECULAR_LEADER': 4 }; - var getRank = function(r) { - if (r.indexOf('SECULAR_LEADER') >= 0) return 4; - if (r.indexOf('RISK_ON') >= 0) return 3; - if (r.indexOf('NEUTRAL') >= 0) return 2; - if (r.indexOf('RISK_OFF') >= 0) return 1; - if (r.indexOf('EVENT_SHOCK') >= 0) return 0; - return 2; - }; - var transitionType = getRank(curr) > getRank(prev) ? 'UPGRADE' - : getRank(curr) < getRank(prev) ? 'DOWNGRADE' - : 'LATERAL_SHIFT'; - var AFFECTED = [ - 'DYNAMIC_HEAT_GATE_V1', 'POSITION_SIZE_REGIME_SCALE_V1', 'REGIME_CASH_UPLIFT_V1', - 'PORTFOLIO_BETA_GATE_V1', 'SECTOR_CONCENTRATION_LIMIT_V1', - 'SEMICONDUCTOR_CLUSTER_GATE_V1', 'SINGLE_POSITION_WEIGHT_CAP_V1', 'POSITION_COUNT_LIMIT_V1' - ]; - return { transition_type: transitionType, prev_regime: prevRegime, current_regime: marketRegime, affected_gates: AFFECTED, formula_id: 'REGIME_TRANSITION_ALERT_V1' }; -} - -/** - * P5: PORTFOLIO_HEALTH_SCORE_V1 - * 모든 게이트 상태를 집계해 HEALTHY/CAUTION/CRITICAL 단일 레이블을 산출한다. - * CRITICAL 게이트 1개 이상, 또는 CAUTION 게이트 3개 이상 → CRITICAL - * CAUTION 게이트 1~2개 → CAUTION, 0개 → HEALTHY - * score = max(0, 100 - critical×30 - caution×10) - * @param {Object} gateMap — { gate_id: gate_status_string } - * @return {{ label, score, critical_count, caution_count, blocked_gates }} - */ -function calcPortfolioHealthScore_(gateMap) { - var CRITICAL = ['BLOCK_NEW_BUY', 'HARD_BLOCK', 'NO_BUY', 'DRAWDOWN_FORCE_RISK_OFF', - 'POSITION_COUNT_BLOCK', 'CLUSTER_BLOCK', 'BREACH', - 'OVER_BETA', 'BLOCK_SECTOR', 'STOP_CRITICAL']; - var CAUTION = ['HALVE_NEW_BUY_QUANTITY', 'TRIM_REQUIRED', 'REDUCE_BUY', 'CAUTION_BUY', - 'DRAWDOWN_CAUTION', 'WARN_BETA', 'WARN_TOP2', 'OVERWEIGHT_TRIM', - 'EDGE_DEGRADED', 'EDGE_WEAK', 'EDGE_CRITICAL', 'APPROACHING', - 'TRIGGERED', 'HEAT_CONCENTRATED', 'DOWNGRADE']; - var critCount = 0, warnCount = 0, blocked = []; - Object.keys(gateMap).forEach(function(name) { - var val = String(gateMap[name] || '').trim(); - if (CRITICAL.indexOf(val) >= 0) { - critCount++; - blocked.push({ gate: name, status: val, severity: 'CRITICAL' }); - } else if (CAUTION.indexOf(val) >= 0) { - warnCount++; - blocked.push({ gate: name, status: val, severity: 'CAUTION' }); - } - }); - var label = (critCount >= 1 || warnCount >= 3) ? 'CRITICAL' - : warnCount >= 1 ? 'CAUTION' - : 'HEALTHY'; - return { - label: label, - score: Math.max(0, 100 - critCount * 30 - warnCount * 10), - critical_count: critCount, - caution_count: warnCount, - blocked_gates: blocked, - gate_input_count: Object.keys(gateMap).length, - formula_id: 'PORTFOLIO_HEALTH_SCORE_V1' - }; -} - -/** - * O1: SINGLE_POSITION_WEIGHT_CAP_V1 - * 개별 종목 비중이 국면별 상한(NEUTRAL:20%, RISK_OFF:15%)을 초과하면 OVERWEIGHT_TRIM. - * M5(섹터 편중)와 독립적인 종목 단위 비중 하드 캡. - * @param {Array} holdings — weightPct 포함 - * @param {string} marketRegime - * @return {{ gate_status, cap_pct, by_position }} - */ -/** - * LEADER_POSITION_WEIGHT_CAP_V1 - * 삼성전자(005930), SK하이닉스(000660)에 대해 KOSPI 시총 비중 기반 차등 한도 적용. - * spec/strategy/semiconductor_concentration_policy.yaml 기준. - * - * 배경: 삼성전자 KOSPI 비중 ~23%. 기존 고정 20% 한도는 시장 비중보다 낮아 - * 주도주를 사실상 과소보유 강제. 국면별로 시장 비중 × 배수를 허용한다. - * - * @param {Array} holdings - * @param {string} marketRegime - * @param {number} kospiSamsungWeightPct — settings.kospi_samsung_weight_pct (기본 23) - * @param {number} kospiHynixWeightPct — settings.kospi_hynix_weight_pct (기본 12) - */ -function calcSinglePositionWeightCap_(holdings, marketRegime, kospiSamsungWeightPct, kospiHynixWeightPct) { - var r = String(marketRegime || '').toUpperCase(); - var isEventShock = r.indexOf('EVENT_SHOCK') >= 0; - var isRiskOff = isEventShock || r.indexOf('RISK_OFF') >= 0; - var isRiskOn = r.indexOf('RISK_ON') >= 0 && !isRiskOff; - var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0; - - // settings에서 KOSPI 개별 종목 비중 읽기 (KRX/FnGuide 시총 데이터 기반 수동 입력) - // 미입력(0) 시 mktWtProvided=false → 정책 기반 고정 한도만 적용 - var smWt = (Number.isFinite(kospiSamsungWeightPct) && kospiSamsungWeightPct > 0) - ? kospiSamsungWeightPct : 0; - var hxWt = (Number.isFinite(kospiHynixWeightPct) && kospiHynixWeightPct > 0) - ? kospiHynixWeightPct : 0; - var smWtProvided = smWt > 0; - var hxWtProvided = hxWt > 0; - - // 일반 종목 한도 (기존 유지) - var defaultCap = isRiskOff ? 15.0 : (isRiskOn ? 22.0 : 20.0); - - var gate = 'PASS'; - var rows = holdings.map(function(h) { - var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0; - var tickerCap; - - if (h.ticker === '005930') { - // 삼성전자 — 국면별 정책 한도 (EXPERT_PRIOR, calibration_registry 등록) - // KOSPI 비중 제공 시: 비중×배수 vs 정책 한도 중 큰 값 - // KOSPI 비중 미제공 시: 정책 한도만 (추측값 삽입 금지) - if (isEventShock) - tickerCap = 15.0; - else if (isRiskOff) - tickerCap = 18.0; - else if (isSecularLeader) - tickerCap = smWtProvided ? Math.max(50.0, smWt * 2.20) : 50.0; - else if (isRiskOn) - tickerCap = smWtProvided ? Math.max(40.0, smWt * 1.70) : 40.0; - else // NEUTRAL - tickerCap = smWtProvided ? Math.max(28.0, smWt * 1.20) : 28.0; - - } else if (h.ticker === '000660') { - // SK하이닉스 — 국면별 정책 한도 - if (isEventShock) - tickerCap = 10.0; - else if (isRiskOff) - tickerCap = 12.0; - else if (isSecularLeader) - tickerCap = hxWtProvided ? Math.max(28.0, hxWt * 2.50) : 28.0; - else if (isRiskOn) - tickerCap = hxWtProvided ? Math.max(22.0, hxWt * 1.80) : 22.0; - else // NEUTRAL - tickerCap = hxWtProvided ? Math.max(15.0, hxWt * 1.20) : 15.0; - - } else { - tickerCap = defaultCap; - } - - tickerCap = round2_(tickerCap); - var status = wPct > tickerCap ? 'OVERWEIGHT_TRIM' : 'PASS'; - if (status === 'OVERWEIGHT_TRIM') gate = 'OVERWEIGHT_TRIM'; - - return { - ticker: h.ticker, - name: h.name || '', - weight_pct: wPct, - cap_pct: tickerCap, - status: status, - is_leader: (h.ticker === '005930' || h.ticker === '000660'), - formula_id: 'LEADER_POSITION_WEIGHT_CAP_V1' - }; - }); - - return { - gate_status: gate, - cap_pct: defaultCap, - kospi_samsung_weight: smWtProvided ? round2_(smWt) : 'DATA_MISSING_SET_IN_SETTINGS', - kospi_hynix_weight: hxWtProvided ? round2_(hxWt) : 'DATA_MISSING_SET_IN_SETTINGS', - by_position: rows, - formula_id: 'LEADER_POSITION_WEIGHT_CAP_V1' - }; -} - -/** - * O2: SEMICONDUCTOR_CLUSTER_GATE_V1 - * 005930(삼성전자) + 000660(SK하이닉스) 합산 비중이 상한을 초과하면 CLUSTER_BLOCK. - * 두 종목이 같은 사이클에서 동반 하락하는 상관 리스크 통제. - * @param {Array} holdings - * @param {string} marketRegime - * @return {{ gate_status, combined_pct, cap_pct, holdings }} - */ -/** - * MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1 - * 반도체 클러스터 한도를 KOSPI 시총 비중 기반으로 동적 산출한다. - * spec/strategy/semiconductor_concentration_policy.yaml 기준. - * - * 배경: 삼성+하이닉스 KOSPI 비중 ~35%. 기존 고정 25% 한도는 주도장에서 - * 시장 대비 필연적 언더퍼폼을 강제. 시장 비중은 최소 허용해야 한다. - * - * @param {Array} holdings - * @param {string} marketRegime - * @param {number} kospiSemiWeightPct — settings.kospi_semi_weight_pct (기본 35) - */ -function calcSemiconductorClusterGate_(holdings, marketRegime, kospiSemiWeightPct) { - var r = String(marketRegime || '').toUpperCase(); - var isEventShock = r.indexOf('EVENT_SHOCK') >= 0; - var isRiskOff = isEventShock || r.indexOf('RISK_OFF') >= 0; - var isRiskOn = r.indexOf('RISK_ON') >= 0 && !isRiskOff; - var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0; - var isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA'; - - // settings에서 KOSPI 반도체 시총 비중 읽기 (사용자가 KRX 데이터 기반으로 직접 입력) - // 0 또는 미입력이면 DATA_MISSING — 아래 정책 기반 한도만 적용 - var mktWt = (Number.isFinite(kospiSemiWeightPct) && kospiSemiWeightPct > 0) - ? kospiSemiWeightPct : 0; - var mktWtProvided = mktWt > 0; - - // 국면별 정책 한도 (EXPERT_PRIOR — calibration_registry.yaml 등록값) - // 주의: KOSPI 비중은 KRX/FnGuide 시총 데이터 기준으로 settings에서만 입력. - // 하드코딩 추정치 사용 금지. settings 미입력 시 정책 한도만 적용. - var capPct, gateMode; - if (isEventShock) { - capPct = mktWtProvided ? Math.max(20.0, mktWt * 0.60) : 20.0; - gateMode = 'DEFENSIVE_STRICT'; - } else if (isRiskOff) { - capPct = mktWtProvided ? Math.max(25.0, mktWt * 0.80) : 25.0; - gateMode = 'DEFENSIVE'; - } else if (isSecularLeader || isCLA) { - capPct = 65.0; - gateMode = 'SECULAR_LEADER'; - } else if (isRiskOn) { - capPct = mktWtProvided ? Math.max(45.0, mktWt * 1.30) : 45.0; - gateMode = 'RISK_ON_OVERWEIGHT'; - } else { - capPct = mktWtProvided ? Math.max(35.0, mktWt * 1.00) : 35.0; - gateMode = 'MARKET_NEUTRAL'; - } - - // CLA 상태에서는 KODEX 반도체(229200)도 클러스터에 포함 - var SEMI_BASE = ['005930', '000660']; - var SEMI_CLA = ['005930', '000660', '229200']; - var clusterTickers = isCLA ? SEMI_CLA : SEMI_BASE; - - var total = 0; - var clusterRows = []; - holdings.forEach(function(h) { - if (clusterTickers.indexOf(h.ticker) >= 0) { - var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0; - total += wPct; - clusterRows.push({ ticker: h.ticker, name: h.name || '', weight_pct: wPct }); - } - }); - - // 게이트 판정 - // WARN 경계: mktWt 제공 시 mktWt × 0.90, 미제공 시 capPct × 0.80 - var warnThreshold = mktWtProvided ? mktWt * 0.90 : capPct * 0.80; - var gate, clusterState; - if (total >= capPct) { - if (isRiskOff) { - gate = 'CLUSTER_BLOCK'; - clusterState = 'CLUSTER_HOLD_ONLY'; - } else { - gate = 'CLUSTER_OVERWEIGHT_TRIM'; - clusterState = 'CLUSTER_HOLD_ONLY'; - } - } else if (total >= warnThreshold) { - if (isSecularLeader || isCLA) { - gate = 'CLUSTER_HOLD_ONLY'; - clusterState = 'CLUSTER_HOLD_ONLY'; - } else { - gate = 'CLUSTER_OVERWEIGHT_WARN'; - clusterState = 'CLUSTER_OPEN'; - } - } else { - gate = 'PASS'; - clusterState = 'CLUSTER_OPEN'; - } - - return { - gate_status: gate, - cluster_state: clusterState, - cluster_id: 'SEMICONDUCTOR_KR', - cluster_tickers: clusterTickers, - combined_pct: round2_(total), - cap_pct: round2_(capPct), - kospi_semi_weight: mktWtProvided ? round2_(mktWt) : 'DATA_MISSING_SET_IN_SETTINGS', - kospi_weight_provided: mktWtProvided, - gate_mode: gateMode, - holdings: clusterRows, - formula_id: 'MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1' - }; -} - -/** - * SATELLITE_FAILURE_GATE_V1 - * 위성 집단 실패 추적 — spec/13_formula_registry.yaml:SATELLITE_FAILURE_GATE_V1 - * @param {Array} satelliteRows — { composite_verdict, rs_verdict, ret20d, excess_ret_10d } - * @return {{ sfg_v1, sfg_reason, sfg_broken_count, sfg_failure_rate }} - */ -function calcSatelliteFailureGate_(satelliteRows) { - if (!satelliteRows || satelliteRows.length === 0) { - return { sfg_v1: 'CLEAR', sfg_reason: 'no_satellite_data', - sfg_broken_count: 0, sfg_failure_rate: 0, - formula_id: 'SATELLITE_FAILURE_GATE_V1' }; - } - var brokenCount = 0, failureCount = 0; - var totalRet20d = 0, totalExcess = 0, retCount = 0; - - satelliteRows.forEach(function(row) { - var cv = row.composite_verdict || ''; - var rv = row.rs_verdict || ''; - if (cv === 'CLOSE_POSITION' || rv === 'BROKEN') brokenCount++; - if (cv === 'REDUCE_CANDIDATE' || cv === 'EXIT_REVIEW' || cv === 'CLOSE_POSITION') failureCount++; - if (typeof row.ret20d === 'number') { totalRet20d += row.ret20d; retCount++; } - if (typeof row.excess_ret_10d === 'number') totalExcess += row.excess_ret_10d; - }); - - var n = satelliteRows.length; - var failureRate = n > 0 ? failureCount / n : 0; - var avgRet20d = retCount > 0 ? totalRet20d / retCount : 0; - var avgExcess = n > 0 ? totalExcess / n : 0; - - var condA = brokenCount >= 3; - var condB = failureRate >= 0.60; - var condC = avgRet20d <= -10 && avgExcess <= -8; // ret20d는 % 단위 (e.g. -10.5) - var triggered = condA || condB || condC; - - return { - sfg_v1: triggered ? 'TRIGGERED' : 'CLEAR', - sfg_reason: condA ? ('broken_count_' + brokenCount) : - condB ? ('failure_rate_' + Math.round(failureRate * 100) + 'pct') : - condC ? 'avg_excess_drawdown_breach' : 'clear', - sfg_broken_count: brokenCount, - sfg_failure_rate: parseFloat(failureRate.toFixed(3)), - formula_id: 'SATELLITE_FAILURE_GATE_V1' - }; -} - -/** - * SATELLITE_AGGREGATE_PNL_GATE_V1 - * 위성 합산 손익이 코어 수익을 얼마나 잠식하는지 결정론적으로 산출한다. - */ -function calcSatelliteAggregatePnlGate_(holdings) { - var corePnl = 0, satellitePnl = 0, coreCount = 0, satelliteCount = 0; - (holdings || []).forEach(function(h) { - var pnl = typeof h.profit_loss === 'number' ? h.profit_loss - : typeof h.unrealizedPnl === 'number' ? h.unrealizedPnl - : typeof h.unrealized_pnl_krw === 'number' ? h.unrealized_pnl_krw : 0; - if (h.position_type === 'core') { - corePnl += pnl; coreCount++; - } else { - satellitePnl += pnl; satelliteCount++; - } - }); - var ratio = corePnl > 0 ? Math.abs(Math.min(0, satellitePnl)) / corePnl : null; - var status = ratio === null ? 'INSUFFICIENT_DATA' - : ratio >= 0.50 ? 'SAPG_CRITICAL' - : ratio >= 0.25 ? 'SAPG_ALERT' - : 'PASS'; - return { - sapg_status: status, - core_total_pnl_krw: Math.round(corePnl), - satellite_total_pnl_krw: Math.round(satellitePnl), - satellite_loss_to_core_gain_ratio: ratio === null ? null : round2_(ratio), - core_count: coreCount, - satellite_count: satelliteCount, - formula_id: 'SATELLITE_AGGREGATE_PNL_GATE_V1' - }; -} - -function calcCashCreationPurposeLockRow_(h, df, sfgResult) { - var cv = df.composite_verdict || null; - var rv = df.rs_verdict || null; - var brt = df.brt_verdict || null; - var excessDrawdown = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null; - var rec20 = typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null; - var valid = false; - var reasons = []; - if (['REDUCE_CANDIDATE', 'EXIT_REVIEW', 'CLOSE_POSITION'].includes(cv)) { valid = true; reasons.push('composite_verdict_' + cv); } - if (rv === 'BROKEN' || brt === 'BROKEN') { valid = true; reasons.push('relative_broken'); } - if (excessDrawdown !== null && excessDrawdown >= 10 && rec20 !== null && rec20 < 0.50) { valid = true; reasons.push('excess_drawdown_no_recovery'); } - if (sfgResult && sfgResult.sfg_v1 === 'TRIGGERED' && h.position_type !== 'core') { valid = true; reasons.push('sfg_v1_TRIGGERED'); } - return { - ticker: h.ticker, - name: h.name || df.name || '', - position_type: h.position_type || 'unknown', - sell_reason_validity: valid ? 'VALID_SELL_REASON' : 'INVALID_SELL_REASON', - valid_reason_codes: reasons, - reinvestment_allowed: false, - formula_id: 'CASH_CREATION_PURPOSE_LOCK_V1' - }; -} - - -// ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ────────────────────────── -// 위성 보유 종목의 진입 이후 경과 영업일을 판단하여 T+20/T+60 알파 게이트를 산출한다. -// 벤치마크: 삼성전자(005930) + SK하이닉스(000660) 평균 ret20D/ret60D (프록시). -// position_type=core 종목은 EXEMPT 처리하여 게이트 판정에서 제외한다. -function calcAlphaEvaluationWindow_(holdings, dfMap) { - var samsung = dfMap['005930'] || {}; - var hynix = dfMap['000660'] || {}; - - // 코어 벤치마크 수익률 프록시 - var coreRet20Vals = []; - if (Number.isFinite(samsung.ret20D)) coreRet20Vals.push(samsung.ret20D); - if (Number.isFinite(hynix.ret20D)) coreRet20Vals.push(hynix.ret20D); - var coreRet20d = coreRet20Vals.length > 0 - ? coreRet20Vals.reduce(function(s,v){return s+v;},0) / coreRet20Vals.length : null; - - var coreRet60Vals = []; - if (Number.isFinite(samsung.ret60D)) coreRet60Vals.push(samsung.ret60D); - if (Number.isFinite(hynix.ret60D)) coreRet60Vals.push(hynix.ret60D); - var coreRet60d = coreRet60Vals.length > 0 - ? coreRet60Vals.reduce(function(s,v){return s+v;},0) / coreRet60Vals.length : null; - - var aewRows = []; - - holdings.forEach(function(h) { - if (!h.ticker) return; - - // core 종목 — 알파 게이트 평가 대상 아님 - if (h.position_type === 'core') { - aewRows.push({ - ticker: h.ticker, - name: h.name || '', - position_type: 'core', - entry_date: h.entry_date || '', - days_since_entry: null, - satellite_return_pct: null, - core_benchmark_ret20d: coreRet20d, - core_benchmark_ret60d: coreRet60d, - t20_reached: false, - t20_vs_core_pctp: null, - t20_alpha_gate: 'EXEMPT', - t60_reached: false, - t60_vs_core_pctp: null, - t60_alpha_gate: 'EXEMPT', - evaluation_method: 'EXEMPT_CORE', - formula_id: 'ALPHA_EVALUATION_WINDOW_V1' - }); - return; - } - - var daysSinceEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null; - var satRetPct = typeof h.return_pct === 'number' && Number.isFinite(h.return_pct) - ? h.return_pct : null; - - // entry_date 없거나 미래 날짜 — 데이터 누락 - var validEntry = daysSinceEntry !== null && daysSinceEntry >= 0; - var t20Reached = validEntry && daysSinceEntry >= 20; - var t60Reached = validEntry && daysSinceEntry >= 60; - - var t20VsCorePctp = null; - var t20AlphaGate = validEntry ? (t20Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING'; - var t60VsCorePctp = null; - var t60AlphaGate = validEntry ? (t60Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING'; - - // T+20 평가 — 위성 총수익률 vs 코어 20D 수익률 (프록시) - if (t20Reached && satRetPct !== null && coreRet20d !== null) { - t20VsCorePctp = round2_(satRetPct - coreRet20d); - t20AlphaGate = t20VsCorePctp < -3 ? 'T20_ALPHA_FAIL' - : t20VsCorePctp >= 0 ? 'PASS' - : 'NEUTRAL'; - } - - // T+60 평가 — 위성 총수익률 vs 코어 60D 수익률 (프록시) - if (t60Reached && satRetPct !== null && coreRet60d !== null) { - t60VsCorePctp = round2_(satRetPct - coreRet60d); - t60AlphaGate = t60VsCorePctp < -5 ? 'T60_ALPHA_FAIL' - : t60VsCorePctp >= 0 ? 'PASS' - : 'NEUTRAL'; - } - - aewRows.push({ - ticker: h.ticker, - name: h.name || '', - position_type: h.position_type || 'satellite', - entry_date: h.entry_date || '', - days_since_entry: daysSinceEntry, - satellite_return_pct: satRetPct, - core_benchmark_ret20d: coreRet20d, - core_benchmark_ret60d: coreRet60d, - t20_reached: t20Reached, - t20_vs_core_pctp: t20VsCorePctp, - t20_alpha_gate: t20AlphaGate, - t60_reached: t60Reached, - t60_vs_core_pctp: t60VsCorePctp, - t60_alpha_gate: t60AlphaGate, - // PROXY 경고: satRetPct는 진입~현재 총수익률; 코어 벤치마크는 20D/60D rolling - // 동일 기간 비교가 아니므로 진입 시점이 20~60일 이내인 경우 오차 있음 - evaluation_method: 'PROXY_FROM_RETURN_PCT_VS_CORE_ROLLING', - formula_id: 'ALPHA_EVALUATION_WINDOW_V1' - }); - }); - - return aewRows; -} - - -// ───────────────────────────────────────────────────────────────────────────── -// [2026-05-21_SPRINT_B] Sprint B — 4개 하네스 게이트 -// ───────────────────────────────────────────────────────────────────────────── - -// ── B-1: HARNESS_DATA_FRESHNESS_GATE_V1 ───────────────────────────────────── -// account_snapshot capturedAt 기준으로 영업일 신선도를 판정한다. -// STALE_BLOCK(5일+) → 주문표 생성 차단. STALE_WARN(3-4일) → SAQG ELIGIBLE 하향. -function calcHarnessDataFreshnessGate_(capturedAtIso, now) { - // capturedAtIso: "yyyy-MM-dd HH:mm:ss" or "yyyy-MM-dd" — 날짜만 추출 - var marketDateStr = capturedAtIso ? String(capturedAtIso).substring(0, 10) : null; - if (!marketDateStr || !/^\d{4}-\d{2}-\d{2}$/.test(marketDateStr)) { - return { - data_freshness_status: 'UNKNOWN', - data_age_business_days: null, - market_date: null, - freshness_degraded_gates: ['ALL_GATES_UNCERTAIN'], - formula_id: 'HARNESS_DATA_FRESHNESS_GATE_V1' - }; - } - - var ageDays = calcKrxBizDaysDiff_(marketDateStr); - var status = ageDays <= 1 ? 'FRESH' - : ageDays === 2 ? 'STALE_1D' - : ageDays <= 4 ? 'STALE_WARN' - : 'STALE_BLOCK'; - var degraded = []; - if (status === 'STALE_WARN') degraded = ['BRT_RELIABILITY_LOW', 'SAQG_ELIGIBLE_DOWNGRADE']; - if (status === 'STALE_BLOCK') degraded = ['BRT_BLOCKED', 'SAQG_BLOCKED', 'ORDER_GENERATION_BLOCKED']; - - return { - data_freshness_status: status, - data_age_business_days: ageDays, - market_date: marketDateStr, - freshness_degraded_gates: degraded, - formula_id: 'HARNESS_DATA_FRESHNESS_GATE_V1' - }; -} - -// ── B-2: SATELLITE_LIFECYCLE_GATE_V1 ──────────────────────────────────────── -// 위성 종목에 WATCH/PILOT/CONFIRMED/REVIEW/EXIT 5단계 라이프사이클을 부여한다. -// brt_verdict, composite_verdict, excess_drawdown_pctp, AEW t20_alpha_gate를 조합해 -// 현재 상태에서 가장 적절한 단계를 결정론적으로 산출한다. -function calcSatelliteLifecycleGate_(holdings, dfMap, aewRows) { - var aewMap = {}; - (aewRows || []).forEach(function(r) { if (r.ticker) aewMap[r.ticker] = r; }); - - return holdings.map(function(h) { - if (h.position_type === 'core') { - return { - ticker: h.ticker, - name: h.name || '', - position_type: 'core', - lifecycle_stage: 'CORE_EXEMPT', - lifecycle_transition_reason: 'core_position', - lifecycle_days_in_stage: null, - review_warning: null, - formula_id: 'SATELLITE_LIFECYCLE_GATE_V1' - }; - } - - var df = dfMap[h.ticker] || {}; - var aew = aewMap[h.ticker] || {}; - var cv = df.composite_verdict || 'UNKNOWN'; - var brt = df.brt_verdict || 'UNKNOWN'; - var exDd = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null; - var t20g = aew.t20_alpha_gate || 'NOT_YET'; - var t20v = typeof aew.t20_vs_core_pctp === 'number' ? aew.t20_vs_core_pctp : null; - var daysEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null; - - var stage = 'PILOT'; - var reason = 'default_pilot'; - - // ── EXIT 조건 (최우선) ───────────────────────────────────────────────── - if (brt === 'BROKEN') { - stage = 'EXIT'; reason = 'brt_BROKEN'; - } else if (cv === 'CLOSE_POSITION') { - stage = 'EXIT'; reason = 'composite_CLOSE_POSITION'; - } else if (exDd !== null && exDd >= 15) { - stage = 'EXIT'; reason = 'excess_drawdown_15pct'; - } else if (t20g === 'T20_ALPHA_FAIL' && t20v !== null && t20v < -10) { - stage = 'EXIT'; reason = 'T20_ALPHA_FAIL_severe'; - - // ── REVIEW 조건 ────────────────────────────────────────────────────── - } else if (brt === 'LAGGARD') { - stage = 'REVIEW'; reason = 'brt_LAGGARD'; - } else if (cv === 'REDUCE_CANDIDATE') { - stage = 'REVIEW'; reason = 'composite_REDUCE'; - } else if (t20g === 'T20_ALPHA_FAIL') { - stage = 'REVIEW'; reason = 'T20_ALPHA_FAIL'; - } else if (exDd !== null && exDd >= 8) { - stage = 'REVIEW'; reason = 'excess_drawdown_8pct'; - - // ── CONFIRMED 조건 ───────────────────────────────────────────────── - } else if (daysEntry !== null && daysEntry >= 20 - && t20g === 'PASS' - && (cv === 'PRIME_CANDIDATE' || cv === 'WATCH_CANDIDATE') - && (brt === 'LEADER' || brt === 'MARKET')) { - stage = 'CONFIRMED'; reason = 't20_pass_market_or_leader'; - - // ── PILOT 조건 (기본) ─────────────────────────────────────────────── - } else if (daysEntry !== null && daysEntry < 20) { - stage = 'PILOT'; reason = 'within_20d_of_entry'; - } else { - stage = 'PILOT'; reason = 'pending_t20_evaluation'; - } - - // 4주 REVIEW 경보 (Direction SLG) - var reviewWarn = (stage === 'REVIEW' && daysEntry !== null && daysEntry >= 20) - ? '4주_REVIEW_비중50%_감축검토' : null; - - return { - ticker: h.ticker, - name: h.name || df.name || '', - position_type: h.position_type || 'satellite', - lifecycle_stage: stage, - lifecycle_transition_reason: reason, - lifecycle_days_in_stage: daysEntry, - review_warning: reviewWarn, - composite_verdict: cv, - brt_verdict: brt, - excess_drawdown_pctp: exDd, - t20_alpha_gate: t20g, - formula_id: 'SATELLITE_LIFECYCLE_GATE_V1' - }; - }); -} - -// ── B-3: CLA_REGIME_EXIT_CONDITION_V1 ─────────────────────────────────────── -// CONCENTRATED_LEADER_ADVANCE 국면의 종료 조건을 탐지한다. -// 삼성전자(005930) + SK하이닉스(000660)를 대상으로 5개 신호를 평가하고 -// 가중치 합산으로 CLA_ACTIVE / CLA_EXIT_WARNING / CLA_EXIT_CONFIRMED를 결정한다. -/** - * SECULAR_LEADER_AUTO_DETECT_V1 - * spec/strategy/semiconductor_concentration_policy.yaml 조건 기반 - * 반도체 주도주 자동 감지 → SECULAR_LEADER_RISK_ON 국면 진입 권고. - * - * 감지 조건 (가중치 합산 ≥ 6 → is_secular_leader=true): - * SL1 (w=3): 삼성 또는 하이닉스 RS_Ratio ≥ 1.5 (5일 연속) - * SL2 (w=2): 외인+기관 동반순매수 3일 이상 - * SL3 (w=2): 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 이상 초과 - * SL4 (w=1): 반도체 섹터 5D 거래대금 > 20D 거래대금 × 1.3 - * - * @param {Object} dfMap — buildDataFeedMap_() 반환값 - * @param {string} marketRegime - * @param {number} kospiRet5d — KOSPI 5일 수익률 - * @return {{ is_secular_leader, score, signals, recommendation, formula_id }} - */ -function calcSecularLeaderAutoDetect_(dfMap, marketRegime, kospiRet5d) { - var SECULAR_TICKERS = ['005930', '000660']; - var THRESHOLD = 6; - var score = 0; - var signals = []; - var kospiRet = typeof kospiRet5d === 'number' ? kospiRet5d : 0; - - // SL1: RS_Ratio ≥ 1.5 — 삼성 또는 하이닉스 - var sl1Hit = SECULAR_TICKERS.some(function(tk) { - var df = dfMap[tk] || {}; - var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio - : (typeof df.rs_ratio === 'number' ? df.rs_ratio : null); - return rsRatio !== null && rsRatio >= 1.5; - }); - if (sl1Hit) { score += 3; signals.push('SL1_RS_RATIO_GTE_1.5(w=3)'); } - - // SL2: 외인+기관 동반순매수 3일 이상 — 양 종목 중 하나 - var sl2Hit = SECULAR_TICKERS.some(function(tk) { - var df = dfMap[tk] || {}; - var frg = typeof df.frg5d === 'number' ? df.frg5d : -1; - var ins = typeof df.inst5d === 'number' ? df.inst5d : -1; - return frg > 0 && ins > 0; // 5일 누적 동반순매수 = 3일 이상 추정 - }); - if (sl2Hit) { score += 2; signals.push('SL2_FRG_INST_CO_BUY(w=2)'); } - - // SL3: 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 초과 (대표 종목 프록시) - var semiRet5d = null; - SECULAR_TICKERS.forEach(function(tk) { - var df = dfMap[tk] || {}; - if (typeof df.ret5d === 'number' && (semiRet5d === null || df.ret5d > semiRet5d)) { - semiRet5d = df.ret5d; - } - }); - if (semiRet5d !== null && semiRet5d - kospiRet >= 5.0) { - score += 2; - signals.push('SL3_SECTOR_OUTPERFORM_5PCT(w=2)'); - } - - // SL4: 반도체 섹터 거래대금 급증 (대표 종목 avgTradeVal5d/20d 프록시) - var sl4Hit = SECULAR_TICKERS.some(function(tk) { - var df = dfMap[tk] || {}; - var val5 = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0; - var val20 = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0; - return val5 > 0 && val20 > 0 && val5 > val20 * 1.3; - }); - if (sl4Hit) { score += 1; signals.push('SL4_TRADE_VALUE_SURGE(w=1)'); } - - var isSecularLeader = score >= THRESHOLD; - var currentRegime = String(marketRegime || '').toUpperCase(); - var alreadyActive = currentRegime.indexOf('SECULAR_LEADER') >= 0; - - // 종료 조건: RS_Ratio < 1.0 3일 or 외인+기관 동반순매도 5일 - var exitSignals = []; - SECULAR_TICKERS.forEach(function(tk) { - var df = dfMap[tk] || {}; - var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio - : (typeof df.rs_ratio === 'number' ? df.rs_ratio : null); - if (rsRatio !== null && rsRatio < 1.0) exitSignals.push(tk + '_RS_BELOW_1.0'); - var frg = typeof df.frg5d === 'number' ? df.frg5d : 0; - var ins = typeof df.inst5d === 'number' ? df.inst5d : 0; - if (frg < 0 && ins < 0) exitSignals.push(tk + '_CO_SELL'); - }); - - return { - is_secular_leader: isSecularLeader, - score: score, - threshold: THRESHOLD, - signals: signals, - exit_signals: exitSignals, - already_active: alreadyActive, - recommendation: isSecularLeader && !alreadyActive - ? 'UPGRADE_TO_SECULAR_LEADER_RISK_ON' - : (alreadyActive && exitSignals.length >= 2 ? 'EXIT_SECULAR_LEADER' : 'MAINTAIN'), - formula_id: 'SECULAR_LEADER_AUTO_DETECT_V1' - }; -} - - diff --git a/src/gas/engines/gdf_03_portfolio_gates.gs b/src/gas/engines/gdf_03_portfolio_gates.gs deleted file mode 100644 index 415f00f..0000000 --- a/src/gas/engines/gdf_03_portfolio_gates.gs +++ /dev/null @@ -1,2246 +0,0 @@ -function calcClaRegimeExitCondition_(dfMap, marketRegime) { - var regime = String(marketRegime || '').toUpperCase(); - if (regime.indexOf('CONCENTRATED_LEADER') < 0 && regime.indexOf('CLA') < 0) { - return { - cla_exit_status: 'NOT_APPLICABLE', - cla_exit_signals_triggered: [], - cla_exit_total_weight: 0, - note: 'marketRegime not CLA', - formula_id: 'CLA_REGIME_EXIT_CONDITION_V1' - }; - } - - var sam = dfMap['005930'] || {}; - var hyn = dfMap['000660'] || {}; - var signals = []; - var w = 0; - - // S1: RS 약화 — 삼성 또는 하이닉스 rs_verdict = LAGGARD (weight 3) - if (sam.rs_verdict === 'LAGGARD' || sam.rs_verdict === 'BROKEN' - || hyn.rs_verdict === 'LAGGARD' || hyn.rs_verdict === 'BROKEN') { - signals.push('S1_rs_degradation'); w += 3; - } - - // S2: KOSPI 기여도 하락 프록시 — 두 종목 모두 LEADER 아님 (weight 2) - if (sam.brt_verdict !== 'LEADER' && hyn.brt_verdict !== 'LEADER' - && sam.brt_verdict !== 'UNKNOWN' && hyn.brt_verdict !== 'UNKNOWN') { - signals.push('S2_kospi_contribution_drop_proxy'); w += 2; - } - - // S3: 외국인 동반 순매도 — frg5d < 0 두 종목 (weight 2) - var samFrgNeg = Number.isFinite(sam.frg5d) && sam.frg5d < 0; - var hynFrgNeg = Number.isFinite(hyn.frg5d) && hyn.frg5d < 0; - if (samFrgNeg && hynFrgNeg) { - signals.push('S3_foreign_flow_reversal'); w += 2; - } - - // S4: 거래 에너지 소진 — volume < avgVolume5d*0.6 두 종목 (weight 1) - var samVolLow = Number.isFinite(sam.volume) && Number.isFinite(sam.avgVolume5d) - && sam.avgVolume5d > 0 && sam.volume < sam.avgVolume5d * 0.6; - var hynVolLow = Number.isFinite(hyn.volume) && Number.isFinite(hyn.avgVolume5d) - && hyn.avgVolume5d > 0 && hyn.volume < hyn.avgVolume5d * 0.6; - if (samVolLow && hynVolLow) { - signals.push('S4_volume_exhaustion'); w += 1; - } - - // S5: BRT 약화 — 두 종목 모두 brt_verdict = MARKET (LEADER에서 하락) (weight 2) - if (sam.brt_verdict === 'MARKET' && hyn.brt_verdict === 'MARKET') { - signals.push('S5_brt_degradation_from_leader'); w += 2; - } - - var status = w >= 5 ? 'CLA_EXIT_CONFIRMED' - : w >= 3 ? 'CLA_EXIT_WARNING' - : 'CLA_ACTIVE'; - - return { - cla_exit_status: status, - cla_exit_signals_triggered: signals, - cla_exit_total_weight: w, - samsung_rs: sam.rs_verdict || 'UNKNOWN', - samsung_brt: sam.brt_verdict || 'UNKNOWN', - hynix_rs: hyn.rs_verdict || 'UNKNOWN', - hynix_brt: hyn.brt_verdict || 'UNKNOWN', - formula_id: 'CLA_REGIME_EXIT_CONDITION_V1' - }; -} - -// ── B-4: PORTFOLIO_CORRELATION_GATE_V1 ────────────────────────────────────── -// 위성 포지션 간 ret20d 기반 프록시 상관관계를 산출하고, -// 상관관계 조정 실질 포트폴리오 베타(satellite_cluster_beta)를 계산한다. -// 20일 수익률 배열이 없으므로 방향 일치도로 상관관계를 추정(PROXY). -function calcPortfolioCorrelationGate_(holdings, dfMap, totalAsset, kospiRet5d) { - var satHoldings = holdings.filter(function(h) { return h.position_type !== 'core'; }); - if (satHoldings.length === 0) { - return { - satellite_cluster_beta: 0, - effective_portfolio_beta: 0, - high_corr_pairs: [], - correlation_gate_status: 'CORRELATION_PASS', - note: 'no_satellite_holdings', - formula_id: 'PORTFOLIO_CORRELATION_GATE_V1' - }; - } - - // 각 위성의 beta_proxy 및 weight_pct 계산 - var satItems = satHoldings.map(function(h) { - var df = dfMap[h.ticker] || {}; - var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; - var ret20d = typeof df.ret20d === 'number' ? df.ret20d : null; - // beta_proxy: ret5d / kospiRet5d if both available, else 1.0 - var beta = 1.0; - if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) { - beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); - } - // weight_pct: from h.weightPct (set by calcPortfolioBetaGate pipeline) or derived - var mv = typeof h.market_value === 'number' ? h.market_value : 0; - var wPct = (totalAsset > 0 && mv > 0) ? mv / totalAsset * 100 : 0; - if (typeof h.weightPct === 'number' && h.weightPct > 0) wPct = h.weightPct; - return { - ticker: h.ticker, - name: h.name || df.name || '', - beta: round2_(beta), - wPct: round2_(wPct), - w: wPct / 100, // fraction - ret20d: ret20d, - rs: df.rs_verdict || 'UNKNOWN', - brt: df.brt_verdict || 'UNKNOWN' - }; - }); - - // 프록시 상관관계: ret20d 방향 일치 + BRT 동방향 기반 - function proxyCorrPair(a, b) { - if (a.ret20d !== null && b.ret20d !== null) { - var sameDir = (a.ret20d >= 0) === (b.ret20d >= 0); - var bothNeg = a.ret20d < 0 && b.ret20d < 0; - if (bothNeg) return 0.80; // 동반 하락 — 가장 강한 동조 신호 - if (sameDir) return 0.65; // 같은 방향 수익 - return 0.15; // 반대 방향 — 분산 효과 - } - // 데이터 없으면 동업종 같은 BRT 상태이면 보수적으로 중간값 - if (a.brt === b.brt && a.brt !== 'UNKNOWN') return 0.60; - return 0.35; - } - - var highCorrPairs = []; - var totalSatW = satItems.reduce(function(s, x) { return s + x.w; }, 0); - if (totalSatW <= 0) totalSatW = 1; - - // 정규화된 위성 비중 (위성 합산=1) - var satNorm = satItems.map(function(x) { - return Object.assign({}, x, { wn: x.w / totalSatW }); - }); - - // 상관관계 행렬 및 satellite_cluster_beta (quadratic form → sqrt) - var quadForm = 0; - for (var i = 0; i < satNorm.length; i++) { - for (var j = 0; j < satNorm.length; j++) { - var corr = i === j ? 1.0 : proxyCorrPair(satNorm[i], satNorm[j]); - quadForm += satNorm[i].wn * satNorm[j].wn * satNorm[i].beta * satNorm[j].beta * corr; - if (i < j && corr >= 0.70) { - highCorrPairs.push({ - ticker1: satNorm[i].ticker, - ticker2: satNorm[j].ticker, - corr_proxy: round2_(corr), - both_negative: satNorm[i].ret20d !== null && satNorm[j].ret20d !== null - && satNorm[i].ret20d < 0 && satNorm[j].ret20d < 0 - }); - } - } - } - var satClusterBeta = round2_(Math.sqrt(Math.max(0, quadForm))); - - // 코어 단순 가중 베타 - var coreHoldings = holdings.filter(function(h) { return h.position_type === 'core'; }); - var coreWBetaSum = 0, coreWSum = 0; - coreHoldings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; - var beta = 1.0; - if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) { - beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); - } - var mv = typeof h.market_value === 'number' ? h.market_value : 0; - var w = (totalAsset > 0 && mv > 0) ? mv / totalAsset : 0; - if (typeof h.weightPct === 'number') w = h.weightPct / 100; - coreWBetaSum += w * beta; - coreWSum += w; - }); - var coreBeta = coreWSum > 0 ? round2_(coreWBetaSum / coreWSum * (coreWSum / 1.0)) : 0; - // effective = core_weighted_contribution + satellite_cluster_beta * sat_weight_fraction - var effectiveBeta = round2_(coreBeta + satClusterBeta * totalSatW); - - // 게이트 판정 - var gateStatus = (satClusterBeta > 1.5 && highCorrPairs.length >= 2) ? 'CORRELATION_BLOCK' - : (satClusterBeta > 1.2 || highCorrPairs.length >= 1) ? 'CORRELATION_WARN' - : 'CORRELATION_PASS'; - - return { - satellite_cluster_beta: satClusterBeta, - effective_portfolio_beta: effectiveBeta, - high_corr_pairs: highCorrPairs, - correlation_gate_status: gateStatus, - satellite_count: satHoldings.length, - evaluation_method: 'PROXY_FROM_RET20D_DIRECTION', - formula_id: 'PORTFOLIO_CORRELATION_GATE_V1' - }; -} - -function pickReferenceBenchmarkRet5d_(df, fallbackKospiRet5d) { - var keys = [ - ['nasdaq_ret5d', 'NASDAQ'], - ['nasdaqRet5d', 'NASDAQ'], - ['kosdaq_ret5d', 'KOSDAQ'], - ['kosdaqRet5d', 'KOSDAQ'], - ['benchmark_ret5d', 'BENCHMARK'], - ['benchmarkRet5d', 'BENCHMARK'], - ['kospi_ret5d', 'KOSPI'], - ['kospiRet5d', 'KOSPI'] - ]; - for (var i = 0; i < keys.length; i++) { - var key = keys[i][0]; - if (typeof (df || {})[key] === 'number') { - return { benchmark_ret5d: df[key], benchmark_used: keys[i][1] }; - } - } - if (typeof fallbackKospiRet5d === 'number') { - return { benchmark_ret5d: fallbackKospiRet5d, benchmark_used: 'KOSPI' }; - } - return { benchmark_ret5d: null, benchmark_used: 'UNKNOWN' }; -} - -function calcIndexRelativeHealthGate_(h, df, kospiRet5d) { - var stockRet5d = typeof df.ret5d === 'number' ? df.ret5d : null; - var bench = pickReferenceBenchmarkRet5d_(df, kospiRet5d); - var benchmarkRet5d = bench.benchmark_ret5d; - var benchmarkUsed = bench.benchmark_used; - var reasons = []; - var state = 'INSUFFICIENT_DATA'; - var directionMatch = null; - var retGapPctp = null; - var magnitudeExcessPctp = null; - - if (stockRet5d !== null && benchmarkRet5d !== null) { - directionMatch = (stockRet5d >= 0) === (benchmarkRet5d >= 0); - retGapPctp = round2_(stockRet5d - benchmarkRet5d); - magnitudeExcessPctp = round2_(Math.max(0, Math.abs(stockRet5d) - Math.abs(benchmarkRet5d) - 2)); - var benchmarkAbs = Math.abs(benchmarkRet5d); - var stockAbs = Math.abs(stockRet5d); - - if (!directionMatch && benchmarkAbs >= 1) { - state = 'DECOUPLED'; - reasons.push('direction_mismatch'); - } else if (stockRet5d < benchmarkRet5d - 3) { - state = 'UNDERPERFORMING'; - reasons.push('underperform_vs_benchmark'); - } else if (magnitudeExcessPctp >= 3 || (stockAbs >= benchmarkAbs + 4 && benchmarkAbs >= 1)) { - state = 'OVER_EXTENDED'; - reasons.push('magnitude_excess'); - } else { - state = 'HEALTHY'; - } - } else { - reasons.push('insufficient_benchmark_data'); - } - - return { - ticker: h.ticker, - name: h.name || df.name || '', - benchmark_used: benchmarkUsed, - stock_ret5d: stockRet5d, - benchmark_ret5d: benchmarkRet5d, - ret_gap_pctp: retGapPctp, - magnitude_excess_pctp: magnitudeExcessPctp, - direction_match: directionMatch, - relative_health_state: state, - reason_codes: reasons, - formula_id: 'INDEX_RELATIVE_HEALTH_GATE_V1' - }; -} - -/** - * O3: PORTFOLIO_DRAWDOWN_GATE_V1 - * 총자산 역대 고점(settings.portfolio_peak_krw) 대비 낙폭을 산출한다. - * -15% → DRAWDOWN_CAUTION, -20% → DRAWDOWN_FORCE_RISK_OFF. - * 현재 자산이 고점 초과 시 settings에 새 고점을 자동 기록. - * @param {number} totalAsset - * @param {Object} ss — Spreadsheet - * @param {Object} settings — readSettings_() 반환값 - * @return {{ gate, drawdown_pct, peak_krw, current_krw }} - */ -function calcPortfolioDrawdownGate_(totalAsset, ss, settings) { - var peakKrw = toNumber_(settings['portfolio_peak_krw'] || 0); - if (totalAsset > 0 && totalAsset > peakKrw) { - peakKrw = totalAsset; - writeSettingValue_(ss, 'portfolio_peak_krw', totalAsset); - } - if (peakKrw <= 0 || totalAsset <= 0) { - return { gate: 'INSUFFICIENT_DATA', drawdown_pct: null, peak_krw: peakKrw || null, current_krw: Math.round(totalAsset || 0), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' }; - } - var drawdownPct = round2_((peakKrw - totalAsset) / peakKrw * 100); - drawdownPct = Math.max(0, drawdownPct); - var gate = drawdownPct >= 20 ? 'DRAWDOWN_FORCE_RISK_OFF' - : drawdownPct >= 15 ? 'DRAWDOWN_CAUTION' - : 'PASS'; - return { gate: gate, drawdown_pct: drawdownPct, peak_krw: Math.round(peakKrw), current_krw: Math.round(totalAsset), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' }; -} - -/** - * O4: WIN_LOSS_STREAK_GUARD_V1 - * 최근 30거래 승률이 임계값 이하로 하락하면 신규 매수 비중을 축소한다. - * M1(연속 손절 횟수)과 독립적인 전체 승률 축 방어층. - * EDGE_CRITICAL(<30%): scale=0.25, EDGE_DEGRADED(<40%): scale=0.50, - * EDGE_WEAK(<45%): scale=0.75, EDGE_OK(>=45%): scale=1.0 - * @param {Object} performance — readPerformanceSheet_() 반환값 - * @return {{ state, win_rate_pct, trades_used, buy_scale }} - */ -function calcWinLossStreakGuard_(performance) { - var winRate = (performance && Number.isFinite(performance.win_rate_30)) ? performance.win_rate_30 : null; - var tradesUsed = (performance && Number.isFinite(performance.trades_used)) ? performance.trades_used : 0; - if (winRate === null || tradesUsed < 10) { - return { state: 'INSUFFICIENT_HISTORY', win_rate_pct: winRate !== null ? round2_(winRate * 100) : null, trades_used: tradesUsed, buy_scale: 1.0, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' }; - } - var state, scale; - if (winRate < 0.30) { state = 'EDGE_CRITICAL'; scale = 0.25; } - else if (winRate < 0.40) { state = 'EDGE_DEGRADED'; scale = 0.50; } - else if (winRate < 0.45) { state = 'EDGE_WEAK'; scale = 0.75; } - else { state = 'EDGE_OK'; scale = 1.0; } - return { state: state, win_rate_pct: round2_(winRate * 100), trades_used: tradesUsed, buy_scale: scale, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' }; -} - -/** - * O5: POSITION_COUNT_LIMIT_V1 - * 동시 보유 종목 수가 국면별 상한(NEUTRAL:8, RISK_OFF:6)을 초과하면 POSITION_COUNT_BLOCK. - * 과다 분산으로 인한 집중 모니터링 불가 및 Total Heat 과소 추정 방지. - * @param {Array} holdings - * @param {string} marketRegime - * @return {{ gate_status, position_count, max_count, excess_count }} - */ -function calcPositionCountLimit_(holdings, marketRegime) { - var r = String(marketRegime || '').toUpperCase(); - var isRiskOff = r.indexOf('EVENT_SHOCK') >= 0 || r.indexOf('RISK_OFF') >= 0; - var maxCount = isRiskOff ? 6 : 8; - var count = holdings.length; - return { - gate_status: count > maxCount ? 'POSITION_COUNT_BLOCK' : 'PASS', - position_count: count, - max_count: maxCount, - excess_count: Math.max(0, count - maxCount), - formula_id: 'POSITION_COUNT_LIMIT_V1' - }; -} - -/** - * M1: DRAWDOWN_GUARD_V1 - * 연속 손절 횟수에 따라 신규 매수 비중을 자동 축소한다. - * bayesian_multiplier=0(>=5회 연속 손실) 위에 추가 방어층으로 작동. - * @param {Object} performance — readPerformanceSheet_() 반환값 - * @return {{ state, buy_scale, consecutive_losses, reason }} - */ -function calcDrawdownGuard_(performance) { - var consLoss = (performance && Number.isFinite(performance.consecutive_losses)) - ? performance.consecutive_losses : 0; - var state, scale, reason; - if (consLoss >= 5) { - state = 'NO_BUY'; scale = 0.0; reason = 'consecutive_losses>=5_no_bet'; - } else if (consLoss >= 3) { - state = 'REDUCE_BUY'; scale = 0.5; reason = 'consecutive_losses>=3_reduce_50pct'; - } else if (consLoss >= 2) { - state = 'CAUTION_BUY'; scale = 0.75; reason = 'consecutive_losses>=2_reduce_25pct'; - } else { - state = 'NORMAL'; scale = 1.0; reason = 'no_drawdown'; - } - return { state: state, buy_scale: scale, consecutive_losses: consLoss, reason: reason }; -} - -/** - * M2: PORTFOLIO_BETA_GATE_V1 - * 보유 종목 가중평균 베타를 산출하고 국면별 상한과 비교한다. - * beta_proxy = ret5d / kospiRet5d (단, kospiRet5d <= 0이면 1.0 사용) - * @param {Array} holdings — parseAccountSnapshot_ 반환 holdings 배열 - * @param {Object} dfMap — buildDataFeedMap_() 반환값 - * @param {number} kospiRet5d - * @param {string} marketRegime - * @return {{ portfolio_beta, gate_status, beta_limit, per_holding_betas }} - */ -function calcPortfolioBetaGate_(holdings, dfMap, kospiRet5d, marketRegime) { - var BETA_LIMITS = (function(r) { - var rU = String(r || '').toUpperCase(); - if (rU.indexOf('EVENT_SHOCK') >= 0) return { over: 0.7, warn: 0.5 }; - if (rU.indexOf('RISK_OFF') >= 0) return { over: 0.8, warn: 0.6 }; - if (rU.indexOf('SECULAR_LEADER') >= 0 && rU.indexOf('RISK_ON') >= 0) return { over: 1.5, warn: 1.2 }; - if (rU.indexOf('RISK_ON') >= 0) return { over: 1.3, warn: 1.0 }; - return { over: 1.0, warn: 0.8 }; // NEUTRAL - })(marketRegime); - - var totalWeight = 0; - var weightedBetaSum = 0; - var perHolding = []; - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var w = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0; - var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; - var betaProxy = 1.0; - if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d > 0.5) { - betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); - } else if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d < -0.5) { - betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); - } - totalWeight += w; - weightedBetaSum += w * betaProxy; - perHolding.push({ - ticker: h.ticker, - name: h.name || '', - weight_pct: w, - beta_proxy: round2_(betaProxy), - ret5d: ret5d - }); - }); - - var portfolioBeta = totalWeight > 0 ? round2_(weightedBetaSum / totalWeight) : null; - var gateStatus = portfolioBeta === null ? 'INSUFFICIENT_DATA' - : portfolioBeta > BETA_LIMITS.over ? 'OVER_BETA' - : portfolioBeta > BETA_LIMITS.warn ? 'WARN_BETA' - : 'PASS'; - - return { - portfolio_beta: portfolioBeta, - gate_status: gateStatus, - beta_limit_over: BETA_LIMITS.over, - beta_limit_warn: BETA_LIMITS.warn, - regime_applied: marketRegime || 'UNKNOWN', - per_holding_betas: perHolding - }; -} - -/** - * M5: SECTOR_CONCENTRATION_LIMIT_V1 - * 단일 섹터 ≥40% 시 BLOCK_SECTOR, 상위2 합산 ≥65% 시 HALVE_SECTOR. - * @param {Array} holdings - * @param {string} marketRegime - * @return {{ gate_status, by_sector, sector_concentration_json }} - */ -function calcSectorConcentrationGate_(holdings, marketRegime) { - var sectorWeight = {}; - holdings.forEach(function(h) { - var sec = TICKER_SECTOR_MAP[h.ticker] || 'UNKNOWN'; - var w = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0; - sectorWeight[sec] = (sectorWeight[sec] || 0) + w; - }); - - var sectors = Object.keys(sectorWeight).map(function(s) { - return { sector: s, weight_pct: round2_(sectorWeight[s]) }; - }); - sectors.sort(function(a, b) { return b.weight_pct - a.weight_pct; }); - - // 임계값 — RISK_OFF/EVENT_SHOCK에서는 더 엄격 - var rU = String(marketRegime || '').toUpperCase(); - var blockThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 35 : 40; - var halveThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 55 : 65; - - var top2Sum = sectors.slice(0, 2).reduce(function(s, r) { return s + r.weight_pct; }, 0); - var overallGate = 'PASS'; - - sectors.forEach(function(r) { - if (r.weight_pct >= blockThresh) r.gate = 'BLOCK_NEW_BUY_THIS_SECTOR'; - else if (r.weight_pct >= halveThresh * 0.6) r.gate = 'WARN_CONCENTRATION'; - else r.gate = 'PASS'; - if (r.gate === 'BLOCK_NEW_BUY_THIS_SECTOR') overallGate = 'BLOCK_SECTOR'; - }); - if (overallGate === 'PASS' && top2Sum >= halveThresh) overallGate = 'WARN_TOP2'; - - return { - gate_status: overallGate, - top2_weight_sum: round2_(top2Sum), - block_threshold: blockThresh, - by_sector: sectors - }; -} - -/** - * M4: EVENT_RISK_HOLD_GATE_V1 - * DART 리스크 및 이벤트 홀드 기간 중인 종목에 신규 매수 홀드 게이트 적용. - * df.eventHoldDays (Event_Hold_Days 컬럼) <= 5이면 EVENT_HOLD. - * 컬럼 없으면 df.dartRiskStatus !== 'OK' 를 대체 기준으로 사용. - * @param {Array} holdings - * @param {Object} dfMap - * @return {Array} event_risk rows - */ -function calcEventRiskHoldGate_(holdings, dfMap) { - return holdings.map(function(h) { - var df = dfMap[h.ticker] || {}; - var holdDays = typeof df.eventHoldDays === 'number' ? df.eventHoldDays : null; - var dartRisk = (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK') - || String(df.dartRisk || '').toUpperCase() === 'Y'; - - var gateStatus, reason; - if (holdDays !== null && holdDays >= 0 && holdDays <= 5) { - gateStatus = 'EVENT_HOLD'; - reason = 'event_hold_days_le5:' + holdDays; - } else if (dartRisk) { - gateStatus = 'EVENT_HOLD'; - reason = 'dart_risk'; - } else { - gateStatus = 'PASS'; - reason = 'no_event_risk'; - } - return { - ticker: h.ticker, - name: h.name || '', - event_hold_gate: gateStatus, - event_hold_days: holdDays, - dart_risk: dartRisk, - reason: reason - }; - }); -} - -/** - * M3: TP_QUANTITY_LADDER_V1 - * prices_json의 TP1/TP2/TP3 가격 유효성 기반으로 분할 익절 수량을 자동 산출. - * 계좌 snapshot에 수동 입력(tp1_qty>0)이 있으면 우선 사용. - * @param {Array} holdings - * @param {Object} h4 — calcPrices_() 반환값 (.prices 배열) - * @return {Array} tp_quantity_ladder rows - */ -function calcTpQuantityLadder_(holdings, h4) { - var priceMap = {}; - (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; }); - - return holdings.map(function(h) { - var priceRow = priceMap[h.ticker] || {}; - var qty = h.holdingQty || 0; - - // 수동 입력 tp_qty 있으면 우선 사용 - var tp1Manual = typeof priceRow.tp1_qty === 'number' && priceRow.tp1_qty > 0 ? priceRow.tp1_qty : 0; - var tp2Manual = typeof priceRow.tp2_qty === 'number' && priceRow.tp2_qty > 0 ? priceRow.tp2_qty : 0; - var tp3Manual = typeof priceRow.tp3_qty === 'number' && priceRow.tp3_qty > 0 ? priceRow.tp3_qty : 0; - - var tp1Q, tp2Q, tp3Q, source; - if (tp1Manual > 0 && tp2Manual > 0) { - tp1Q = tp1Manual; - tp2Q = tp2Manual; - tp3Q = tp3Manual > 0 ? tp3Manual : Math.max(0, qty - tp1Q - tp2Q); - source = 'MANUAL'; - } else if (qty > 0) { - tp1Q = Math.floor(qty * 0.33); - tp2Q = Math.floor(qty * 0.33); - tp3Q = Math.max(0, qty - tp1Q - tp2Q); - source = 'AUTO_33PCT'; - } else { - tp1Q = tp2Q = tp3Q = 0; - source = 'NO_HOLDING'; - } - - return { - ticker: h.ticker, - name: h.name || '', - holding_qty: qty, - tp1_price: priceRow.tp1_price || null, - tp1_state: priceRow.tp1_state || null, - tp1_qty: tp1Q, - tp2_price: priceRow.tp2_price || null, - tp2_state: priceRow.tp2_state || null, - tp2_qty: tp2Q, - tp3_qty: tp3Q, - qty_source: source, - formula_id: 'TP_QUANTITY_LADDER_V1' - }; - }); -} - -function calcCashFloor_(mrsScore, settlementCashPct) { - var minPct = 10; - var regime = 'overheated_or_event_week'; - for (var k = 0; k < CASH_FLOOR_BY_MRS.length; k++) { - if (mrsScore <= CASH_FLOOR_BY_MRS[k].maxMrs) { - minPct = CASH_FLOOR_BY_MRS[k].minPct; - regime = CASH_FLOOR_BY_MRS[k].label; - break; - } - } - var status = settlementCashPct >= minPct ? 'PASS' - : settlementCashPct >= minPct * 0.7 ? 'TRIM_REQUIRED' - : 'HARD_BLOCK'; - return { minPct: minPct, regime: regime, status: status }; -} - -function calcActions_(intradayLock, heatGate, cashFloorStatus) { - var blocked = []; - var allowed = ['TRIM_25', 'TRIM_33', 'TRIM_50', 'HOLD', 'WATCH']; - if (intradayLock) { - blocked.push('EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY'); - } else { - allowed.push('EXIT_100', 'SELL_FULL'); - if (heatGate === 'BLOCK_NEW_BUY' || cashFloorStatus !== 'PASS') { - blocked.push('BUY', 'STAGED_BUY'); - } else { - allowed.push('BUY', 'STAGED_BUY'); - } - } - return { allowed: allowed, blocked: blocked }; -} - - -// ── H2: 매도후보 순위 하네스 ───────────────────────────────────────────────── - -/** - * calcSellPriority_ - * 보유 종목별 Sell_Priority_Score(0~100 clamp) + tier 배정, tier ASC / score DESC 정렬 - * spec/risk/portfolio_exposure.yaml:candidate_scoring - */ -function calcSellPriority_(holdings, dfMap, h1) { - var candidates = []; - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var raw = scoreSellCandidate_(h, df, h1); - - // 코어 주도주 tier=9 고정 - var isCoreLeader = indexOfArr_(CORE_TICKERS, h.ticker) >= 0; - var tier = isCoreLeader ? 9 : raw.tier; - - var clamped = Math.min(Math.max(raw.rawScore, 0), 100); - - candidates.push({ - rank: 0, // 정렬 후 부여 - ticker: h.ticker, - name: h.name || df.name || '', - account: h.account || '', - tier: tier, - score: clamped, - raw_score: raw.rawScore, - rebound_holdback_score: raw.reboundHoldback || 0, - rebound_holdback_reason: raw.reboundReason || '', - cash_preserve_style: raw.cashPreserveStyle || '', - cash_preserve_ratio: raw.cashPreserveRatio || 0, - cash_preserve_reason: raw.cashPreserveReason || '', - trim_style: isCoreLeader && df.close > 0 && df.ma20 > 0 && df.close >= df.ma20 - ? 'CORE_LAST' - : (raw.reboundHoldback || 0) >= 18 ? 'STEP_25' : (raw.reboundHoldback || 0) >= 10 ? 'STEP_33' : 'STEP_50', - clamp_applied: raw.rawScore !== clamped, - clamp_label: raw.rawScore !== clamped - ? ('[CLAMP 발동: raw=' + raw.rawScore + ' → ' + clamped + ']') : '', - reason: isCoreLeader ? '코어주도주보호(tier=9 고정)' : raw.reason, - stop_breach: h.stopBreach, - weight_pct: h.weightPct, - final_action: df.finalAction || '' - }); - }); - - // tier ASC, score DESC 정렬 - candidates.sort(function(a, b) { - if (a.tier !== b.tier) return a.tier - b.tier; - return b.score - a.score; - }); - candidates.forEach(function(c, idx) { c.rank = idx + 1; }); - - return { candidates: candidates, lock: true }; -} - -/** - * scoreSellCandidate_ - * 단일 종목 원시점수 + tier 계산 - * spec/risk/portfolio_exposure.yaml:candidate_scoring.components - */ -function scoreSellCandidate_(h, df, h1) { - var pts = 0; - var reasons = []; - var tier = 7; // 기본: 단순 수익실현 - var reboundHoldback = calcReboundHoldbackScore_({ - close: h.close, - ma20: df.ma20, - ma60: df.ma60, - ma20Slope: df.ma20Slope, - rsi14: df.rsi14, - bbPosition: df.bbPosition, - flowCredit: df.flowCredit, - leaderTotal: df.leaderTotal, - leaderGate: df.leaderGate, - bandStatus: df.bandStatus, - profitPct: df.profitPct, - isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0, - }); - - // ── 1. hard_precedence ──────────────────────────────────────────────────── - if (h.stopBreach) { - pts += SP.HARD_STOP_BREACH; - tier = Math.min(tier, 2); - reasons.push('stop_breach(' + SP.HARD_STOP_BREACH + ')'); - } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' || h1.cashFloorStatus === 'HARD_BLOCK') { - pts += SP.CASH_FLOOR_TRIM; - tier = Math.min(tier, 3); - reasons.push('cash_floor_trim(' + SP.CASH_FLOOR_TRIM + ')'); - } else if (df.isDuplicateEtf) { - pts += SP.DUPLICATE_ETF; - tier = Math.min(tier, 4); - reasons.push('duplicate_etf(' + SP.DUPLICATE_ETF + ')'); - } else { - var fa = (df.finalAction || '').toUpperCase(); - if (fa.indexOf('TRIM') >= 0 || fa.indexOf('ROTATE') >= 0 - || fa.indexOf('SELL') >= 0 || fa.indexOf('EXIT') >= 0) { - pts += SP.HOLDING_TRIM_ROTATE; - tier = Math.min(tier, 5); - reasons.push('holding_trim(' + SP.HOLDING_TRIM_ROTATE + ')'); - } else { - var profitLockBase = SP["TAKE_PROFIT_BASE"]; - pts += profitLockBase; - tier = Math.min(tier, 6); - reasons.push('profit_lock_base(' + profitLockBase + ')'); - } - } - - // ── 2. duplicate_exposure_points ───────────────────────────────────────── - if (df.isDuplicateEtf) { - pts += SP.DUP_SAME_SECTOR; - reasons.push('dup_sector_etf(' + SP.DUP_SAME_SECTOR + ')'); - } - - // ── 3. cash_relief_points ───────────────────────────────────────────────── - if (h1.totalAsset > 0 && h.marketValue > 0) { - var reliefPct = h.marketValue / h1.totalAsset * 100; - if (reliefPct >= 3) { - pts += SP.CASH_RELIEF_GE3; - reasons.push('cash_relief>=3%(' + SP.CASH_RELIEF_GE3 + ')'); - } else if (reliefPct >= 1) { - pts += SP.CASH_RELIEF_1_3; - reasons.push('cash_relief1~3%(' + SP.CASH_RELIEF_1_3 + ')'); - } else { - pts += SP.CASH_RELIEF_LT1; - reasons.push('cash_relief<1%(' + SP.CASH_RELIEF_LT1 + ')'); - } - } - - // ── 4. weakness_points ──────────────────────────────────────────────────── - var rw = df.rwPartial || 0; - if (rw >= 4) { pts += SP.RW_GE4; reasons.push('RW>=' + rw + '(' + SP.RW_GE4 + ')'); } - else if (rw === 3) { pts += SP.RW_3; reasons.push('RW=3(' + SP.RW_3 + ')'); } - else if (rw === 2) { pts += SP.RW_2; reasons.push('RW=2(' + SP.RW_2 + ')'); } - - var flowOk = (df.flowOk || '').toUpperCase(); - var flowCr = df.flowCredit; - if ((typeof flowCr === 'number' && flowCr < 0.5) - || flowOk === 'N' || flowOk === 'FALSE' || flowOk === '0') { - pts += SP.FLOW_NEGATIVE; - reasons.push('flow_neg(' + SP.FLOW_NEGATIVE + ')'); - } - - if (h.close > 0 && df.ma20 > 0 && h.close < df.ma20) { - pts += SP.BELOW_MA20; - reasons.push('below_MA20(' + SP.BELOW_MA20 + ')'); - } - - // ── 5. overweight_points ────────────────────────────────────────────────── - if (df.weightTargetPct > 0 && h.weightPct > 0) { - var overPct = h.weightPct - df.weightTargetPct; - if (overPct >= 5) { pts += SP.OVERWEIGHT_5P; reasons.push('overweight>5p(' + SP.OVERWEIGHT_5P + ')'); } - else if (overPct >= 2) { pts += SP.OVERWEIGHT_2P; reasons.push('overweight>2p(' + SP.OVERWEIGHT_2P + ')'); } - } - - // ── 6. liquidity_points ─────────────────────────────────────────────────── - var atv = df.avgTradeVal5d || 0; - if (atv >= 1000) { // 10억원 이상 (단위: 백만원) - pts += SP.LIQUIDITY_OK; reasons.push('liq_ok(' + SP.LIQUIDITY_OK + ')'); - } else if (atv > 0 && atv < 100) { // 1억원 미만 - pts += SP.LIQUIDITY_LOW; reasons.push('liq_low(' + SP.LIQUIDITY_LOW + ')'); - } - - // ── 7. tax_penalty_points (미확인 기본) ──────────────────────────────────── - pts -= SP.TAX_UNKNOWN; - reasons.push('tax_unknown(-' + SP.TAX_UNKNOWN + ')'); - - // ── 8. core_quality_protection_points (감점) ────────────────────────────── - if (indexOfArr_(CORE_TICKERS, h.ticker) >= 0) { - pts -= SP.CORE_LEADER; - reasons.push('core_leader(-' + SP.CORE_LEADER + ')'); - } else if ((df.grade || '').toUpperCase() === 'A') { - pts -= SP.A_GRADE_CORE; - reasons.push('A_grade(-' + SP.A_GRADE_CORE + ')'); - } - - if (reboundHoldback.score > 0) { - pts -= reboundHoldback.score; - reasons.push('rebound_holdback(-' + reboundHoldback.score + (reboundHoldback.reasons ? ' [' + reboundHoldback.reasons + ']' : '') + ')'); - } - - var cashPreservePlan = calcCashPreservationPlan_({ - sellAction: h.finalAction || df.finalAction || '', - cashFloorStatus: h1.cashFloorStatus || '', - regime: h1.regime || df.regime || '', - isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0, - isEtf: !!df.isDuplicateEtf, - liquidityStatus: String(df.liquidityStatus || df.Liquidity_Status || ''), - spreadStatus: String(df.spreadStatus || df.Spread_Status || ''), - accountType: String(h.account_type || h.accountType || ''), - profitPct: h.profitPct, - rwPartial: rw, - reboundHoldbackScore: reboundHoldback.score, - }); - if (cashPreservePlan.protection_bonus > 0) { - pts -= cashPreservePlan.protection_bonus; - reasons.push('cash_preserve(-' + cashPreservePlan.protection_bonus + (cashPreservePlan.reasons ? ' [' + cashPreservePlan.reasons + ']' : '') + ')'); - } - - return { - rawScore: Math.round(Math.min(100, Math.max(0, pts))), - tier: tier, - reason: reasons.join(', '), - reboundHoldback: reboundHoldback.score, - reboundReason: reboundHoldback.reasons, - cashPreserveStyle: cashPreservePlan.style, - cashPreserveRatio: cashPreservePlan.recommended_ratio, - cashPreserveReason: cashPreservePlan.reasons, - }; -} - - -// ── H3: 수량 하네스 ────────────────────────────────────────────────────────── - -/** - * calcQuantities_ - * Sell_Qty = floor(Sell_Ratio_Pct/100 × holding_quantity) — CAPTURE_READ_OK만 - * Buy_Qty: POSITION_SIZE_V1 atr_qty·cash_limit_qty 중간값 산출 - */ -function calcQuantities_(holdings, dfMap, totalAsset, buyPowerKrw, h1) { - var sellQty = []; - var buyQtyInputs = []; - var perfMult = Number.isFinite(h1.performanceMultiplier) ? h1.performanceMultiplier : 0.5; - var perfBias = h1.performanceBuyBias || calcPerformanceBuyBias_({ bayesian_multiplier: perfMult }); - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var sellRatio = df.sellRatioPct || 0; - var fa = (df.finalAction || '').toUpperCase(); - var hasSellSignal = fa === 'SELL_READY' || fa === 'EXIT_100' || fa === 'EXIT_SIGNAL' - || fa === 'EXIT_REVIEW' || fa.indexOf('TRIM') >= 0 || fa === 'TRAILING_STOP_BREACH'; - var sellQtyValue; - - // ── Sell_Qty (M3: 선행 계산된 Sell_Qty 컬럼 우선 사용) ─────────────── - if (h.holdingQty > 0 && hasSellSignal) { - if (df.sellQty > 0) { - // 데이터 피드에 이미 계산된 정수 수량 사용 (CAPTURE_READ_OK 기반) - sellQtyValue = Math.floor(df.sellQty); - } else if (sellRatio > 0) { - sellQtyValue = Math.floor(sellRatio / 100 * h.holdingQty); - } else { - sellQtyValue = 'CAPTURE_REQUIRED'; // 매도신호 있으나 수량 산출 불가 - } - } else if (h.holdingQty > 0) { - sellQtyValue = null; // 매도신호 없음 — CAPTURE_REQUIRED 오남용 방지 - } else { - sellQtyValue = 'NO_HOLDING'; - } - sellQty.push({ - ticker: h.ticker, - account: h.account || '', - name: h.name || df.name || '', - holding_qty: h.holdingQty || 0, - sell_ratio_pct: sellRatio || 0, - sell_qty: sellQtyValue - }); - - // ── Buy_Qty (BUY 후보이고 gates 통과 시만 산출) ─────────────────────── - var fa = (df.finalAction || '').toUpperCase(); - var isBuyCandidate = fa.indexOf('BUY') >= 0 || fa.indexOf('STAGED') >= 0; - var buyBlocked = h1.heatGate === 'BLOCK_NEW_BUY' - || h1.cashFloorStatus !== 'PASS' - || h1.intradayLock - || perfBias.entry_block; - - if (!isBuyCandidate || buyBlocked) return; - - var atr20 = df.atr20 || 0; - var close = h.close || df.close || 0; - - if (atr20 > 0 && close > 0 && totalAsset > 0) { - var riskKrw = totalAsset * BASE_RISK_BUDGET * perfMult; - riskKrw = riskKrw * perfBias.quantity_multiplier; - var atrQty = Math.floor(riskKrw / (atr20 * 1.5)); - var cashQty = Math.floor(buyPowerKrw / close); - var halve = h1.heatGate === 'HALVE_NEW_BUY_QUANTITY'; - if (halve) atrQty = Math.floor(atrQty / 2); - // M1: DRAWDOWN_GUARD_V1 추가 축소 - var dgScale = (h1.drawdownBuyScale !== undefined && h1.drawdownBuyScale < 1.0) - ? h1.drawdownBuyScale : 1.0; - if (dgScale < 1.0) atrQty = Math.floor(atrQty * dgScale); - // N1: POSITION_SIZE_REGIME_SCALE_V1 국면 스케일 - var rssScale = (typeof h1.regimeSizeScale === 'number') ? h1.regimeSizeScale : 1.0; - if (rssScale !== 1.0) atrQty = Math.floor(atrQty * rssScale); - // O4: WIN_LOSS_STREAK_GUARD_V1 승률 하락 시 추가 축소 - var wlScale = (typeof h1.winLossStreakBuyScale === 'number') ? h1.winLossStreakBuyScale : 1.0; - if (wlScale < 1.0) atrQty = Math.floor(atrQty * wlScale); - - buyQtyInputs.push({ - ticker: h.ticker, - account: h.account || '', - name: h.name || df.name || '', - atr_qty: atrQty, - cash_limit_qty: cashQty, - final_qty: Math.min(atrQty, cashQty), - atr20: atr20, - close: close, - halve_applied: halve, - perf_bias_reason: perfBias.reason, - perf_bias_mult: perfBias.quantity_multiplier, - missing: [] - }); - } else { - var missing = []; - if (!atr20) missing.push('ATR20'); - if (!close) missing.push('Close_Price'); - if (!totalAsset) missing.push('total_asset'); - buyQtyInputs.push({ - ticker: h.ticker, - account: h.account || '', - name: h.name || df.name || '', - final_qty: 'NO_BUY_QUANTITY', - missing: missing - }); - } - }); - - return { sellQty: sellQty, buyQtyInputs: buyQtyInputs }; -} - - -// ── H4: 가격 하네스 ────────────────────────────────────────────────────────── - -/** - * calcPrices_ - * 보유 종목별: - * STOP_PRICE_CORE_V1 → TICK_NORMALIZER_V1 - * TAKE_PROFIT_LADDER_V2 (tier1/tier2) → TICK_NORMALIZER_V1 - */ -function calcPrices_(holdings, dfMap, marketRegime) { - var prices = []; - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var atr20 = df.atr20 || 0; - var close = h.close || df.close || 0; - var avgCost = h.avgCost || 0; - var qty = h.holdingQty || 0; - - if (avgCost <= 0) { - prices.push({ - ticker: h.ticker, - account: h.account || '', - name: h.name || df.name || '', - error: 'NO_AVG_COST' - }); - return; - } - - var posClass = (df.positionClass || '').toLowerCase(); - var isCore = posClass === 'core' || posClass === 'core_leader' - || indexOfArr_(CORE_TICKERS, h.ticker) >= 0; - - // ── STOP_PRICE_CORE_V1 ──────────────────────────────────────────────── - // max(avgCost * 0.92, avgCost - ATR20 * atr_multiplier) - // atr_multiplier = 2.0 if atr20/close*100 >= 8, else 1.5 - var atrMul = 1.5; - var stopRaw; - if (atr20 > 0 && close > 0) { - atrMul = (atr20 / close * 100) >= 8 ? 2.0 : 1.5; - stopRaw = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul); - } else { - stopRaw = avgCost * 0.92; - } - var stopTick = tickNormalize_(stopRaw); - - // ── X4: ATR Ratchet (atr_early_ratchet + atr_trailing_universal) ───────── - // highest_price_since_entry 우선 사용 (account_snapshot 컬럼). - // 미입력 시 close 로 폴백 (일일 마감 기준 보수적 처리). - var maxPriceRef = (h.highestPriceSinceEntry && h.highestPriceSinceEntry > close) - ? h.highestPriceSinceEntry : close; - var ratchetApplied = 'NONE'; - var ratchetNote = ''; - var ratchetSrc = h.highestPriceSinceEntry ? 'highest_price_since_entry' : 'close_fallback'; - if (atr20 > 0 && maxPriceRef > 0 && avgCost > 0) { - var earlyTrigger = avgCost + atr20 * 1.0; - var trailingStopRaw = Math.max(maxPriceRef - atr20 * 2.0, 0); - var trailingStopTick = tickNormalize_(trailingStopRaw); - var breakevenTick = tickNormalize_(avgCost); - - if (maxPriceRef >= earlyTrigger) { - // 조기 본절 발동: stop >= breakeven - var ratchetedStop = Math.max(stopTick, trailingStopTick, breakevenTick); - if (ratchetedStop > stopTick) { - ratchetNote = 'early_ratchet[' + ratchetSrc + ']: max(' + maxPriceRef + ')>=avgCost+ATR(' + Math.round(earlyTrigger) - + ') → stop_floor=breakeven(' + breakevenTick + ')' - + ' | trailing=' + trailingStopTick; - stopTick = ratchetedStop; - ratchetApplied = 'EARLY_RATCHET+TRAILING'; - } else { - ratchetNote = 'early_ratchet_inactive: stop already>=' + stopTick; - ratchetApplied = 'EARLY_RATCHET_INACTIVE'; - } - } else if (trailingStopTick > stopTick) { - // 조기 본절 미발동, 트레일링만 적용 - ratchetNote = 'trailing_only[' + ratchetSrc + ']: max-ATR*2=' + trailingStopTick + '>stop_core=' + stopTick; - stopTick = trailingStopTick; - ratchetApplied = 'TRAILING_ONLY'; - } else { - ratchetApplied = 'PASS (stop_core >= trailing)'; - ratchetNote = 'trailing=' + trailingStopTick + ' <= stop_core=' + stopTick; - } - } else { - ratchetApplied = 'SKIP (atr/close/avgCost 부재)'; - } - - // ── TAKE_PROFIT_LADDER_V2 ───────────────────────────────────────────── - // tier_1: max(avgCost * pct1, avgCost + ATR20 * 1.5) - // tier_2: max(avgCost * pct2, avgCost + ATR20 * 3.0) - var pct1 = isCore ? 1.15 : 1.10; - var pct2 = isCore ? 1.25 : 1.20; - var tp1Raw, tp2Raw, ladderVer; - - if (atr20 > 0) { - tp1Raw = Math.max(avgCost * pct1, avgCost + atr20 * 1.5); - tp2Raw = Math.max(avgCost * pct2, avgCost + atr20 * 3.0); - ladderVer = 'V2_ATR'; - } else { - tp1Raw = avgCost * pct1; - tp2Raw = avgCost * pct2; - ladderVer = 'V1_FALLBACK'; - } - var tp1Tick = tickNormalize_(tp1Raw); - var tp2Tick = tickNormalize_(tp2Raw); - - // ── PROFIT_LOCK_STAGE_CLASSIFIER_V1 ────────────────────────────────────── - // spec/exit/take_profit.yaml:profit_lock_ratchet.ratchet_table 기준 - // 수익률 구간별 단계 분류 — LLM이 임의 판정하는 것을 하네스에서 선점 (Direction Q 준수) - var profitPct = (close > 0 && avgCost > 0) ? (close - avgCost) / avgCost * 100 : 0; - // spec/13_formula_registry.yaml:PROFIT_LOCK_STAGE_V1 단계명 기준 (B06 정정 2026-05-30) - var profitLockStage, ratchetStopOverride, ratchetPartialQty; - if (profitPct >= 60) { - profitLockStage = 'APEX_SUPER'; - ratchetStopOverride = tickNormalize_( - Math.max(avgCost * 1.40, atr20 > 0 ? close - atr20 * 1.2 : avgCost * 1.40) - ); - ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.50) : 0; - } else if (profitPct >= 40) { - profitLockStage = 'APEX_TRAILING'; - ratchetStopOverride = tickNormalize_( - Math.max(avgCost * 1.35, atr20 > 0 ? close - atr20 * 1.5 : avgCost * 1.35) - ); - ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.40) : 0; - } else if (profitPct >= 30) { - profitLockStage = 'PROFIT_LOCK_30'; - ratchetStopOverride = tickNormalize_(avgCost * 1.20); - ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.35) : 0; - } else if (profitPct >= 20) { - profitLockStage = 'PROFIT_LOCK_20'; - ratchetStopOverride = tickNormalize_(avgCost * 1.10); - ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.25) : 0; - } else if (profitPct >= 10) { - profitLockStage = 'PROFIT_LOCK_10'; - ratchetStopOverride = tickNormalize_(avgCost * 1.00); - ratchetPartialQty = 0; - } else if (profitPct >= 0) { - profitLockStage = 'BREAKEVEN_RATCHET'; - ratchetStopOverride = tickNormalize_(avgCost); - ratchetPartialQty = 0; - } else { - profitLockStage = 'NORMAL'; - ratchetStopOverride = null; - ratchetPartialQty = 0; - } - // profit_lock_ratchet 손절선이 기존 손절선보다 높으면 적용 (PROFIT_LOCK_RATCHET_V1) - if (ratchetStopOverride && ratchetStopOverride > stopTick) { - stopTick = ratchetStopOverride; - if (ratchetApplied === 'NONE' || ratchetApplied === 'SKIP (atr/close/avgCost 부재)') { - ratchetApplied = 'PROFIT_LOCK_RATCHET'; - ratchetNote = 'profit_lock_stage=' + profitLockStage + ' → stop→' + stopTick; - } - } - - // ── TP_VALIDITY_CHECK_V1: 현재가 이하 TP는 무효화 (HS009) ───────────────── - // tp_price <= close 이면 INVALID_TP_STALE — LLM에 null 전달하여 오표기 원천 차단 - var tp1State, tp2State; - if (close > 0) { - tp1State = tp1Tick > close ? 'PENDING' : 'TP1_ALREADY_TRIGGERED'; - tp2State = tp2Tick > close ? 'PENDING' : 'TP2_ALREADY_TRIGGERED'; - if (tp1State !== 'PENDING') tp1Tick = null; - if (tp2State !== 'PENDING') tp2Tick = null; - } else { - tp1State = 'UNKNOWN_NO_CLOSE'; - tp2State = 'UNKNOWN_NO_CLOSE'; - } - - // ── SECULAR_LEADER_REGIME_GATE_V1 (H3) ─────────────────────────────────── - // 삼성전자·SK하이닉스의 secular_leader_profit_lock 발동 여부를 결정론적 판정 - var slGate = calcSecularLeaderGate_(h.ticker, marketRegime || 'UNKNOWN', df, qty); - - // secular_leader_gate 활성 시 tp1 표시 조정 (profit_lock 구간별 차등) - if (slGate.active) { - if (profitLockStage === 'PROFIT_LOCK_10') { - // +10%: tier_1 부분익절 보류 — trailing_stop(본절) 상향만 - tp1State = 'DEFERRED_SECULAR_LEADER'; - tp1Tick = null; - } else if (profitLockStage === 'PROFIT_LOCK_20') { - // +20%: 과열신호 2개 미만이면 부분익절 보류 - var overheatSignals = 0; - if (typeof df.acTotal === 'number' && df.acTotal >= 2) overheatSignals++; - if (typeof df.frg5d === 'number' && df.frg5d < 0 && - typeof df.inst5d === 'number' && df.inst5d < 0) overheatSignals++; - if (typeof df.rsi14 === 'number' && df.rsi14 >= 80) overheatSignals++; - // H6: 거래대금 급증 과열신호 — AVG_TRADE_VALUE_SIGNAL_V1 - var atvSig = calcAvgTradeValueSignal_(h.ticker, df); - if (atvSig.overheat_triggered) overheatSignals++; - df._avg_trade_val_signal = atvSig; - if (overheatSignals < 2) { - tp1State = 'DEFERRED_SECULAR_LEADER_OVERHEAT_PENDING'; - tp1Tick = null; - } - } else if (profitLockStage === 'PROFIT_LOCK_30' - || profitLockStage === 'APEX_TRAILING' - || profitLockStage === 'APEX_SUPER') { - // +30%/APEX: trailing_stop 기반 관리로 전환 — 래칫 stop 우선 (TP는 참고용만) - tp1State = 'TRAILING_STOP_PRIORITY_SECULAR_LEADER'; - // tp1Tick 유지 — 참고용 유지하되 HTS 주문 표기는 별도 주석으로 처리 - } - } - - // TP 무효화 시 수량도 0 (무효 TP에 수량 기재 금지) - var tp1Q = (tp1Tick && qty > 0) ? Math.floor(qty * (isCore ? 0.25 : 0.33)) : 0; - var tp2Q = (tp2Tick && qty > 0) ? Math.floor((qty - tp1Q) * (isCore ? 0.40 : 0.50)) : 0; - var tp3Q = qty - tp1Q - tp2Q; - - prices.push({ - ticker: h.ticker, - account: h.account || '', - name: h.name || df.name || '', - position_class: isCore ? 'core' : 'satellite', - atr_mul_used: atrMul, - tick_size: getTickSize_(stopRaw), - ladder_version: ladderVer, - stop_price_raw: Math.round(stopRaw), - stop_price: stopTick, - tp1_price_raw: Math.round(tp1Raw), - tp1_price: tp1Tick, // null = TP1 이미 통과 (TP_VALIDITY_CHECK_V1) - tp1_state: tp1State, - tp1_qty: tp1Q, - tp2_price_raw: Math.round(tp2Raw), - tp2_price: tp2Tick, // null = TP2 이미 통과 - tp2_state: tp2State, - tp2_qty: tp2Q, - tp3_qty: tp3Q, - profit_pct: Math.round(profitPct * 10) / 10, - profit_lock_stage: profitLockStage, - ratchet_partial_qty: ratchetPartialQty, - atr20: atr20, - avg_cost: avgCost, - ratchet_applied: ratchetApplied, - ratchet_note: ratchetNote, - ratchet_price_src: ratchetSrc, - highest_price_since_entry: h.highestPriceSinceEntry || null, - secular_leader_gate_active: slGate.active, - secular_leader_gate_status: slGate.status, - secular_leader_gate_reasons: slGate.reasons - }); - }); - - return { prices: prices }; -} - - -// ── H5: 결정 상태머신 게이팅 ───────────────────────────────────────────────── - -/** - * runRouteFlow_ - * data_feed.Final_Action → H1 게이트 적용 → 확정 final_action + gate_trace - * 구현 게이트: STOP_BREACH → INTRADAY_LOCK → HEAT_GATE → CASH_FLOOR → EXIT_POLICY - * spec/09_decision_flow.yaml 핵심 경로 GAS 구현 - */ -function runRouteFlow_(holdings, dfMap, h1) { - var routes = []; - var traces = []; - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var baseFa = (df.finalAction || 'INSUFFICIENT_DATA').toUpperCase(); - var trace = []; - var finalFa = baseFa; - - // ── Gate 1a: Stop_Price Breach 감지 ────────────────────────────────── - if (h.stopBreach) { - if (h1.intradayLock) { - finalFa = 'TRIM_50'; // P4: 장중은 EXIT_100 금지 → TRIM_50 완화 - trace.push({ gate: 'STOP_BREACH', result: 'DOWNGRADE_P4', - reason: '장중(P4): stop_breach→TRIM_50 완화' }); - } else { - finalFa = 'EXIT_100'; - trace.push({ gate: 'STOP_BREACH', result: 'FORCE_EXIT', - reason: 'close(' + h.close + ')<=stop(' + h.stopPrice + ')' }); - } - } else { - trace.push({ gate: 'STOP_BREACH', result: 'PASS', reason: 'no_breach' }); - } - - // ── Gate 1a-bis: Relative Stop — 시장 베타 보정 손절 (TRIM_50) ───────── - if (finalFa !== 'EXIT_100') { - var rsDf = df; - var rsRet20d = typeof rsDf.ret20d === 'number' ? rsDf.ret20d : parseFloat(rsDf.ret20d); - var rsAtr20 = typeof rsDf.atr20 === 'number' ? rsDf.atr20 : parseFloat(rsDf.atr20); - var rsClose = h.close || rsDf.close || 0; - var rsPft = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct); - var rsHdays = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0; - var rsKospi = typeof h1.kospiRet20d === 'number' ? h1.kospiRet20d : 0; - - if (Number.isFinite(rsRet20d) && Number.isFinite(rsAtr20) && rsClose > 0) { - var rsBeta = (Math.abs(rsKospi) >= 0.5) ? Math.min(3.0, Math.max(0.3, rsRet20d / rsKospi)) : 1.0; - var rsExcess = rsRet20d - rsBeta * rsKospi; - var rsSigma = (rsAtr20 / rsClose * 100) * Math.sqrt(20); - var rsThresh = -2.0 * rsSigma; - var rsAbsFl = Number.isFinite(rsPft) && rsPft < -20.0; - var rsTimeSt = rsHdays >= 60 && rsExcess < 0; - var rsRelBr = rsExcess < rsThresh; - - if (rsAbsFl || rsRelBr || rsTimeSt) { - var rsType = rsAbsFl ? 'ABS_FLOOR' : (rsRelBr ? 'REL_EXCESS' : 'TIME_STOP'); - trace.push({ gate: 'RELATIVE_STOP', result: 'TRIM_50', - reason: rsType + ': excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) }); - if (finalFa === 'HOLD' || finalFa.indexOf('BUY') >= 0) finalFa = 'TRIM_50'; - } else { - trace.push({ gate: 'RELATIVE_STOP', result: 'PASS', - reason: 'excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) }); - } - } else { - trace.push({ gate: 'RELATIVE_STOP', result: 'SKIP', reason: 'insufficient_data' }); - } - } else { - trace.push({ gate: 'RELATIVE_STOP', result: 'INACTIVE', reason: 'stop_breach_exit_100' }); - } - - // ── Gate 1b: Intraday_Lock — 차단목록 다운그레이드 + 허용목록 이중검증 ── - if (h1.intradayLock) { - // 1단계: 차단 키워드 다운그레이드 - if (indexOfArr_(INTRADAY_BLOCKED_KEYWORDS, finalFa) >= 0) { - var downgraded = finalFa.indexOf('BUY') >= 0 ? 'WATCH' : 'TRIM_50'; - trace.push({ gate: 'INTRADAY_LOCK', result: 'DOWNGRADE', - reason: 'P4: ' + finalFa + '→' + downgraded }); - finalFa = downgraded; - } - // 2단계: 허용목록 이중검증 — 다운그레이드 후에도 허용 목록 외 액션 강제 WATCH - if (indexOfArr_(INTRADAY_ALLOWED_ACTIONS, finalFa) < 0) { - trace.push({ gate: 'INTRADAY_LOCK', result: 'FORCE_WATCH', - reason: 'P4_ALLOWLIST: ' + finalFa + ' not in allowed list→WATCH' }); - finalFa = 'WATCH'; - } else { - trace.push({ gate: 'INTRADAY_LOCK', result: 'PASS', reason: 'action_in_allowlist' }); - } - } else { - trace.push({ gate: 'INTRADAY_LOCK', result: 'INACTIVE', reason: 'post_market' }); - } - - // ── Gate 1c: Heat Gate — BUY 차단/감량 ──────────────────────────────── - if (h1.heatGate === 'BLOCK_NEW_BUY' && finalFa.indexOf('BUY') >= 0) { - trace.push({ gate: 'HEAT_GATE', result: 'BLOCK_BUY', - reason: 'total_heat>=10%: BUY→WATCH' }); - finalFa = 'WATCH'; - } else if (h1.heatGate === 'HALVE_NEW_BUY_QUANTITY' && finalFa.indexOf('BUY') >= 0) { - trace.push({ gate: 'HEAT_GATE', result: 'HALVE_QTY', - reason: 'total_heat>=7%: 수량 50% 감량 적용' }); - } else { - trace.push({ gate: 'HEAT_GATE', result: 'PASS', reason: h1.heatGate }); - } - - // ── Gate 1d: Mean Reversion Gate — 이격 과대 BUY 차단 (MRG001) ────────── - if (finalFa.indexOf('BUY') >= 0) { - var mrgClose = df.close || 0; - var mrgMa20 = df.ma20 || 0; - if (mrgClose > 0 && mrgMa20 > 0) { - var devRatio = round2_(mrgClose / mrgMa20); - if (devRatio >= 1.15) { - trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'BUY_HARD_BLOCK', - reason: 'MRG001: deviation_ratio(' + devRatio + ')>=1.15→BUY_HARD_BLOCK' }); - finalFa = 'WATCH'; - } else { - trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'PASS', - reason: 'deviation_ratio=' + devRatio + '<1.15' }); - } - } else { - trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'SKIP', - reason: 'close/ma20 missing' }); - } - } else { - trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'INACTIVE', - reason: 'action_not_BUY' }); - } - - // ── Gate 2: Cash Floor — BUY 차단, HOLD → TRIM 넛지 ─────────────────── - if (h1.cashFloorStatus === 'HARD_BLOCK' && finalFa.indexOf('BUY') >= 0) { - trace.push({ gate: 'CASH_FLOOR', result: 'HARD_BLOCK', - reason: 'immediate_cash= 0) { - trace.push({ gate: 'CASH_FLOOR', result: 'BUY_BLOCKED', - reason: 'TRIM_REQUIRED: BUY→WATCH' }); - finalFa = 'WATCH'; - } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' && finalFa === 'HOLD') { - trace.push({ gate: 'CASH_FLOOR', result: 'NUDGE_TRIM', - reason: 'TRIM_REQUIRED: HOLD→TRIM_33 권고' }); - finalFa = 'TRIM_33'; - } else { - trace.push({ gate: 'CASH_FLOOR', result: 'PASS', reason: h1.cashFloorStatus }); - } - - // ── Gate 3: Exit Policy — Sell_Signal 확인 ──────────────────────────── - var ss = (df.sellSignal || '').toUpperCase(); - if (ss === 'SIGNAL_CONFIRMED' || ss.indexOf('STOP') >= 0 - || ss.indexOf('EXIT') >= 0) { - trace.push({ gate: 'EXIT_POLICY', result: 'SELL_SIGNAL', - reason: 'data_feed.Sell_Signal=' + df.sellSignal }); - } else { - trace.push({ gate: 'EXIT_POLICY', result: 'PASS', reason: 'no_exit_signal' }); - } - - routes.push({ - ticker: h.ticker, - account: h.account || '', - name: h.name || df.name || '', - base_action: baseFa, - final_action: finalFa, - gate_changed: baseFa !== finalFa, - gate_trace: trace, - rs_verdict: df.rs_verdict || null - }); - - for (var t = 0; t < trace.length; t++) { - traces.push({ - ticker: h.ticker, - account: h.account || '', - state: trace[t].gate, - check_id: 'H5_' + trace[t].gate, - rule_ref: 'gas_data_feed.gs:' + trace[t].gate, - inputs_used: { - base_action: baseFa, - close: h.close, - stop_price: h.stopPrice, - intraday_lock: h1.intradayLock, - heat_gate_status: h1.heatGate, - cash_floor_status: h1.cashFloorStatus - }, - result: trace[t].result, - selected_action: finalFa, - blocked_actions: h1.blockedActions || [], - missing_inputs: [], - tie_breaker_applied: null, - reason: trace[t].reason - }); - } - }); - - return { ["decisions"]: routes, traces: traces, lock: true }; -} - -function findPriceRow_(priceRows, ticker) { - for (var i = 0; i < priceRows.length; i++) { - if (priceRows[i].ticker === ticker) return priceRows[i]; - } - return null; -} - -function findSellQtyRow_(sellRows, ticker) { - for (var i = 0; i < sellRows.length; i++) { - if (sellRows[i].ticker === ticker) return sellRows[i]; - } - return null; -} - -function findBuyQtyRow_(buyRows, ticker) { - for (var i = 0; i < buyRows.length; i++) { - if (buyRows[i].ticker === ticker) return buyRows[i]; - } - return null; -} - -function classifyOrderType_(signalCode, holding) { - if (holding && holding.stopBreach) return 'STOP_LOSS'; - if (signalCode.indexOf('BUY') >= 0) return 'BUY'; - if (signalCode.indexOf('EXIT') >= 0 || signalCode.indexOf('SELL') >= 0 - || signalCode.indexOf('TRIM') >= 0 || signalCode.indexOf('ROTATE') >= 0) { - return 'SELL'; - } - if (signalCode === 'HOLD') return 'HOLD'; - return 'WATCH'; -} - -function computeTrimQuantity_(finalAction, holdingQty, sellQtyValue) { - if (finalAction === 'TRIM_25') return Math.floor(holdingQty * 0.25); - if (finalAction === 'TRIM_33') return Math.floor(holdingQty * 0.33); - if (finalAction === 'TRIM_50') return Math.floor(holdingQty * 0.50); - if (typeof sellQtyValue === 'number') return sellQtyValue; - return null; -} - -function buildOrderBlueprint_(holdings, dfMap, h1, h3, h4, h5) { - var blueprint = []; - - var h5RouteRows_ = (h5 && h5["decisions"]) ? h5["decisions"] : []; - for (var i = 0; i < h5RouteRows_.length; i++) { - var routeRow = h5RouteRows_[i]; - var ticker = routeRow.ticker; - var finalAction = (routeRow.final_action || '').toUpperCase(); - var holding = null; - for (var j = 0; j < holdings.length; j++) { - if (holdings[j].ticker === ticker) { - holding = holdings[j]; - break; - } - } - if (!holding) continue; - - var df = dfMap[ticker] || {}; - var priceRow = findPriceRow_(h4.prices, ticker) || {}; - var sellRow = findSellQtyRow_(h3.sellQty, ticker) || {}; - var buyRow = findBuyQtyRow_(h3.buyQtyInputs, ticker) || {}; - var orderType = classifyOrderType_(finalAction, holding); - var limitPrice = null; - var quantity = null; - var validation = 'MANUAL_CHECK_REQUIRED'; - var rationaleCode = 'FINAL_ACTION:' + finalAction; - - // [Phase 1] NO_MERCY_JUDGMENT_GATE_V2: 손절가 이탈 시 절대 매도 강제 (LLM 개입 원천 차단) - var _closePrice = holding.close || df.close || 0; - var _stopPrice = priceRow.stop_price || holding.stopPrice || 0; - if (_closePrice > 0 && _stopPrice > 0 && _closePrice < _stopPrice) { - orderType = 'SELL'; - finalAction = 'EXIT_100'; - quantity = holding.holdingQty || 0; - limitPrice = tickNormalize_(_closePrice); - validation = (quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA'; - rationaleCode = 'EMERGENCY_SELL:NO_MERCY_JUDGMENT_GATE_V2'; - } else if (orderType === 'BUY') { - if (indexOfArr_(h1.blockedActions || [], 'BUY') >= 0 - || indexOfArr_(h1.blockedActions || [], 'STAGED_BUY') >= 0) { - validation = 'BLOCKED'; - rationaleCode = 'BLOCKED_ACTION:' + finalAction; - } else if (typeof buyRow.final_qty === 'number' && buyRow.final_qty > 0) { - limitPrice = tickNormalize_(holding.close || df.close || 0); - quantity = buyRow.final_qty; - validation = limitPrice > 0 ? 'PASS' : 'INSUFFICIENT_DATA'; - rationaleCode = 'POSITION_SIZE_V1:' + quantity; - } else { - validation = 'INSUFFICIENT_DATA'; - rationaleCode = 'NO_BUY_QUANTITY'; - } - } else if (indexOfArr_(h1.blockedActions || [], orderType) >= 0) { - validation = 'BLOCKED'; - rationaleCode = 'BLOCKED_ACTION:' + orderType; - } else if (orderType === 'STOP_LOSS') { - limitPrice = priceRow.stop_price || tickNormalize_(holding.stopPrice || 0); - quantity = holding.holdingQty || null; - validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA'; - rationaleCode = 'STOP_PRICE_CORE_V1:' + limitPrice; - } else if (orderType === 'SELL') { - if (finalAction === 'EXIT_100' || finalAction === 'SELL_FULL' || finalAction === 'EXIT_FULL') { - quantity = holding.holdingQty || null; - } else { - quantity = computeTrimQuantity_(finalAction, holding.holdingQty || 0, sellRow.sell_qty); - } - limitPrice = df.sellLimitPrice > 0 - ? tickNormalize_(df.sellLimitPrice) - : tickNormalize_(holding.close || df.close || 0); - validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA'; - rationaleCode = 'SELL_RULE:' + finalAction; - } else { - validation = 'BLOCKED'; - rationaleCode = 'NO_EXECUTION:' + finalAction; - } - - blueprint.push({ - account: holding.account || '일반계좌', - ticker: ticker, - name: holding.name || df.name || '', - current_holding_quantity: holding.holdingQty || 0, - average_cost_krw: holding.avgCost ? Math.round(holding.avgCost) : null, - current_price_krw: holding.close ? Math.round(holding.close) : null, - order_type: orderType, - mode: orderType === 'BUY' ? 'lead' : 'none', - limit_price_krw: limitPrice > 0 ? Math.round(limitPrice) : null, - quantity: typeof quantity === 'number' ? quantity : null, - // HS010: WATCH/BLOCKED/INSUFFICIENT_DATA 상태에서 가격·수량 null 강제 - // 사용자가 감시값을 HTS 주문으로 오인 입력하는 것을 원천 차단 - stop_price_krw: validation === 'PASS' ? (priceRow.stop_price || null) : null, - stop_quantity: validation === 'PASS' && orderType === 'BUY' && typeof quantity === 'number' ? quantity : null, - ["take_profit_price_krw"]: validation === 'PASS' ? (priceRow.tp1_price || null) : null, - ["take_profit_quantity"]: validation === 'PASS' ? (priceRow.tp1_qty || null) : null, - order_amount_krw: (limitPrice > 0 && typeof quantity === 'number') ? Math.round(limitPrice * quantity) : null, - validation_status: validation, - rationale_code: rationaleCode - }); - } - - return blueprint; -} - -/** - * SELL_PRICE_SANITY_V2 (SPSV2) — 매도 주문 3중 가격 검증 - * CHECK_1: limit_price < final_stop → INVALID_PRICE_INVERSION - * CHECK_2: stop_price < auto_trailing_stop → INVALID_TRAILING_STOP_BREACH - * CHECK_3: limit_price == 0 → INVALID_ZERO_PRICE - * validation_status를 인라인 재기록해 EXPORT_GATE가 자동 차단 - * @param {Array} blueprint — buildOrderBlueprint_ 반환값 - * @param {Array} profitPreservJson — profit_preservation_json (auto_trailing_stop 포함) - * @return {Array} blueprint with spsv2_verdict 필드 추가 - */ -function calcSellPriceSanityV2_(blueprint, profitPreservJson) { - var ppMap = {}; - (profitPreservJson || []).forEach(function(pp) { - var tk = (pp.ticker || pp.ticker_code || '').toString(); - if (tk) ppMap[tk] = pp; - }); - - return (blueprint || []).map(function(row) { - var ot = (row.order_type || '').toString().toUpperCase(); - if (ot !== 'SELL' && ot !== 'STOP_LOSS') { - return Object.assign({}, row, { spsv2_verdict: 'NOT_SELL_SKIP' }); - } - if ((row.validation_status || '').toString() !== 'PASS') { - return Object.assign({}, row, { spsv2_verdict: 'SPSV2_SKIP_NOT_PASS' }); - } - - var limitPrice = Number(row.limit_price_krw || 0); - var stopPrice = Number(row.stop_price_krw || 0); - var pp = ppMap[(row.ticker || row.ticker_code || '').toString()] || {}; - var autoTrailing = Number(pp.auto_trailing_stop || 0); - var finalStop = (autoTrailing > 0 && autoTrailing > stopPrice) ? autoTrailing : stopPrice; - - var check1 = (limitPrice > 0 && finalStop > 0 && limitPrice < finalStop) - ? 'INVALID_PRICE_INVERSION' : 'PASS'; - var check2 = (autoTrailing > 0 && stopPrice > 0 && stopPrice < autoTrailing) - ? 'INVALID_TRAILING_STOP_BREACH' : 'PASS'; - var check3 = (limitPrice > 0) ? 'PASS' : 'INVALID_ZERO_PRICE'; - - var verdict; - if (check1 !== 'PASS') verdict = check1; - else if (check2 !== 'PASS') verdict = check2; - else if (check3 !== 'PASS') verdict = check3; - else verdict = 'SPSV2_PASS'; - - var newValidation = (verdict === 'SPSV2_PASS') ? row.validation_status : verdict; - return Object.assign({}, row, { - spsv2_verdict: verdict, - final_stop_price: finalStop || stopPrice || null, - auto_trailing_stop_ref: autoTrailing || null, - validation_status: newValidation - }); - }); -} - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL51] P0-D: PRICE_HIERARCHY_LOCK_V1 (PHL-V1) -// 5계층 가격 단일화 잠금 — 표간 가격 혼재 완전 차단 -// LAYER_1(주문가) / LAYER_2(손절/익절) / LAYER_3(트레일링보정) / -// LAYER_4(반등트리거) / LAYER_5(참고방어가) -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcPriceHierarchyLock_ - * 단일 종목의 5계층 가격 분리 잠금. LAYER_5가 LAYER_1 위치에 나타나면 INVALID_LAYER_VIOLATION. - */ -function calcPriceHierarchyLock_(ticker, blueprintRow, priceRow, ppRow, scrsRow, propRefRow) { - var bp = blueprintRow || {}; - var pr = priceRow || {}; - var pp = ppRow || {}; - var sc = scrsRow || {}; - var ref = propRefRow || {}; - - var layer1 = toNumber_(bp.limit_price_krw) || null; - var layer2Stop = toNumber_(pr.stop_price) || null; - var layer2Tp1 = toNumber_(pr.tp1_price) || null; - var layer2Tp2 = toNumber_(pr.tp2_price) || null; - var layer3Trailing = toNumber_(pp.auto_trailing_stop) || null; - var layer4Rebound = toNumber_(sc.rebound_trigger_price) || null; - var layer5RefDef = toNumber_(ref.reference_defense_price) || null; - - var finalStop = (layer3Trailing !== null && layer2Stop !== null) - ? Math.max(layer2Stop, layer3Trailing) - : (layer2Stop || layer3Trailing || null); - - var violations = []; - if (layer5RefDef !== null && layer1 !== null && layer5RefDef === layer1) { - violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION', - detail: 'LAYER_5(ref=' + layer5RefDef + ')==LAYER_1(order=' + layer1 + ') — 참고방어가가 주문가로 오인됨' }); - } - if (layer4Rebound !== null && layer2Stop !== null && layer4Rebound === layer2Stop) { - violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION', - detail: 'LAYER_4(rebound=' + layer4Rebound + ')==LAYER_2(stop=' + layer2Stop + ') — 반등트리거가 손절가로 오인됨' }); - } - if (layer5RefDef !== null && layer1 !== null) { - var diffPct = Math.abs(layer5RefDef - layer1) / layer1 * 100; - if (diffPct < 5) { - violations.push({ ticker: ticker, type: 'LAYER_PROXIMITY_WARNING', - detail: 'LAYER_5(ref=' + layer5RefDef + ')과 LAYER_1(order=' + layer1 + ') ' + diffPct.toFixed(1) + '% 근접 — 혼동 위험' }); - } - } - - return { - formula_id: 'PRICE_HIERARCHY_LOCK_V1', - ticker: ticker, - layer1_limit_price: layer1, - layer2_stop_price: layer2Stop, - layer2_tp1_price: layer2Tp1, - layer2_tp2_price: layer2Tp2, - layer3_auto_trailing: layer3Trailing, - layer4_rebound_trigger: layer4Rebound, - layer5_reference_defense: layer5RefDef, - final_stop_price: finalStop, - layer_violations: violations, - violation_count: violations.filter(function(v) { return v.type === 'INVALID_LAYER_VIOLATION'; }).length - }; -} - -/** - * applyPriceHierarchyLockAll_ - * 전 종목 PHL-V1 일괄 실행 — hApex 내 모든 소스 참조 - */ -function applyPriceHierarchyLockAll_(hApex) { - var blueprints = (hApex && hApex.order_blueprint_json) || []; - var pricesJson = (hApex && hApex.prices_json) || []; - var ppJson = (hApex && hApex.profit_preservation_json) || []; - var scrsCombo = ((hApex && hApex.scrs_v2_json) || {}).selected_combo || []; - var propRef = (hApex && hApex.proposal_reference_json) || []; - - var priceMap = {}; pricesJson.forEach(function(r) { priceMap[(r.ticker||r.ticker_code||'').toString()] = r; }); - var ppMap = {}; ppJson.forEach(function(r) { ppMap[(r.ticker||r.ticker_code||'').toString()] = r; }); - var scrsMap = {}; scrsCombo.forEach(function(r) { scrsMap[(r.ticker||'').toString()] = r; }); - var refMap = {}; propRef.forEach(function(r) { refMap[(r.ticker||'').toString()] = r; }); - - var tickers = {}; - blueprints.forEach(function(bp) { tickers[(bp.ticker||bp.ticker_code||'')] = 1; }); - - return Object.keys(tickers).filter(Boolean).map(function(tk) { - var bp = blueprints.find(function(r) { return (r.ticker||r.ticker_code||'').toString() === tk; }) || {}; - return calcPriceHierarchyLock_(tk, bp, priceMap[tk], ppMap[tk], scrsMap[tk], refMap[tk]); - }); -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL51] P2-D: SELL_EXECUTION_QUALITY_GATE_V1 (SEQG-V1) -// 매도 실행 품질 채점 — 가격/수량/타이밍 3축 평가 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcSellExecutionQualityGate_ - * 매도 주문의 실행 품질을 3축(가격/수량/타이밍)으로 채점. - * - 가격축: limit_price vs stop_price 간격 충분성 - * - 수량축: 보유수량 대비 매도비율 적정성 (5~70% 범위) - * - 타이밍축: PSR-V2 신호 없을 때 매도 = 불필요 매도 위험 - * @param {Array} blueprint — order_blueprint_json (SPSV2 적용 후) - * @param {Array} holdings - * @param {Array} psrRows — proactive_sell_radar_json - * @return {Array} SEQG-V1 rows - */ -function calcSellExecutionQualityGate_(blueprint, holdings, psrRows) { - var holdMap = {}; - (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; }); - var psrMap = {}; - (psrRows || []).forEach(function(p) { psrMap[p.ticker] = p; }); - - return (blueprint || []).filter(function(row) { - return (row.order_type || '').toString().toUpperCase() === 'SELL' - || (row.order_type || '').toString().toUpperCase() === 'STOP_LOSS'; - }).map(function(row) { - var h = holdMap[(row.ticker || '').toString()] || {}; - var psr = psrMap[(row.ticker || '').toString()] || {}; - var limitPx = toNumber_(row.limit_price_krw) || 0; - var stopPx = toNumber_(row.stop_price_krw) || 0; - var qty = toNumber_(row.order_quantity) || 0; - var holdQty = toNumber_(h.holdingQty) || 1; - - // 가격축: stop과 limit 간격이 ATR20의 0.5배 이상 - var close = toNumber_(h.close) || limitPx || 1; - var priceSpread = (limitPx > 0 && stopPx > 0) ? (limitPx - stopPx) / close * 100 : 0; - var priceScore = priceSpread >= 2.0 ? 100 : priceSpread >= 1.0 ? 70 : 40; - - // 수량축: 보유량 대비 5%~70% - var sellRatio = holdQty > 0 ? qty / holdQty * 100 : 0; - var qtyScore = (sellRatio >= 5 && sellRatio <= 70) ? 100 - : (sellRatio > 0 && sellRatio < 5) ? 60 - : sellRatio > 70 ? 50 : 0; - - // 타이밍축: PSR-V2 CRITICAL/WARNING 있으면 타이밍 좋음 - var radarLevel = psr.radar_level || 'CLEAR'; - var timingScore = radarLevel === 'CRITICAL' ? 100 - : radarLevel === 'WARNING' ? 80 - : radarLevel === 'WATCH' ? 60 : 40; - - var totalScore = Math.round((priceScore + qtyScore + timingScore) / 3); - var grade = totalScore >= 80 ? 'A' : totalScore >= 65 ? 'B' : totalScore >= 50 ? 'C' : 'D'; - - return { - ticker: row.ticker, - name: row.name || (h.name || ''), - order_type: row.order_type, - price_score: priceScore, - quantity_score: qtyScore, - timing_score: timingScore, - total_score: totalScore, - execution_grade: grade, - sell_ratio_pct: Math.round(sellRatio * 10) / 10, - price_spread_pct: Math.round(priceSpread * 10) / 10, - radar_level_ref: radarLevel, - formula_id: 'SELL_EXECUTION_QUALITY_GATE_V1' - }; - }); -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 사전 분배 감지 (D-3일) -// 분배 전 3일 이내 조기 경보 → CRITICAL/WARNING/WATCH 단계 분류 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcProactiveSellRadarV2_ - * 8가지 사전 분배 감지 신호: 고가 근접+수축, 기관 순매도 전환, 개인 집중유입, - * 옵션 풋/콜 역전, 뉴스 감성 급락, 거래량 이상, RSI 다이버전스, 수익 보호 트리거. - * @param {Array} holdings - * @param {Object} dfMap - * @param {Object} profitPreservMap ticker→profitPreservRow 맵 - * @return {Array} PSR-V2 rows - */ -function calcProactiveSellRadarV2_(holdings, dfMap, profitPreservMap) { - var ppMap = profitPreservMap || {}; - return (holdings || []).map(function(h) { - var df = dfMap[h.ticker] || {}; - var close = toNumber_(h.close || df.close) || 0; - var high52w = toNumber_(df.high52w || df['High52W']) || 0; - var volume = toNumber_(df.volume) || 0; - var avgVol5d = toNumber_(df.avgVolume5d || df.avgVol5d) || 0; - var rsi14 = toNumber_(df.rsi14 || df['RSI14']) || 50; - var inst5d = toNumber_(df.inst5d || df['Inst_5D']) || 0; - var frg5d = toNumber_(df.frg5d || df['FRG_5D']) || 0; - var ret5d = toNumber_(df.ret5d || df['Ret5D']) || 0; - var sentScore = toNumber_(df.sentimentScore || df['Sentiment_Score']) || 0; - var pp = ppMap[h.ticker] || {}; - var autoTrail = toNumber_(pp.auto_trailing_stop) || 0; - var holdQty = toNumber_(h.holdingQty) || 0; - - var signals = []; - - // SIG_1: 고가 대비 2% 이내 + 거래량 30% 수축 (고점 분배 전형) - var nearHigh = high52w > 0 && close > 0 && (high52w - close) / high52w <= 0.02; - var volShrink = avgVol5d > 0 && volume > 0 && volume < avgVol5d * 0.7; - if (nearHigh && volShrink) signals.push({ id: 'SIG_1_HIGH_SHRINK', weight: 2.0 }); - - // SIG_2: 기관 5일 순매도 전환 (inst5d < -음수) - if (inst5d < 0) signals.push({ id: 'SIG_2_INST_SELL', weight: 2.0 }); - - // SIG_3: 개인 집중유입 비율 > 70% (설거지 전형) - var retailRatio = toNumber_(df.retailBuyRatio5d || df['Retail_Buy_Ratio_5D']) || 0; - if (retailRatio > 0.70) signals.push({ id: 'SIG_3_RETAIL_INFLOW', weight: 1.5 }); - - // SIG_4: 옵션 풋/콜 비율 역전 (put_call_ratio > 1.3) - var pcRatio = toNumber_(df.putCallRatio || df['Put_Call_Ratio']) || 0; - if (pcRatio > 1.3) signals.push({ id: 'SIG_4_PUT_CALL_INVERT', weight: 1.5 }); - - // SIG_5: 뉴스 감성 점수 급락 (sentiment < -20) - if (sentScore < -20) signals.push({ id: 'SIG_5_SENTIMENT_DROP', weight: 1.0 }); - - // SIG_6: 거래량 이상 급증 (vol > 1.5x 평균) + 음봉 - var open_ = toNumber_(df.open || df['Open']) || close; - var volSpike = avgVol5d > 0 && volume > avgVol5d * 1.5; - var bearCandle = close < open_ && close > 0; - if (volSpike && bearCandle) signals.push({ id: 'SIG_6_VOL_SPIKE_BEAR', weight: 1.5 }); - - // SIG_7: RSI 다이버전스 (rsi14 >= 70 + 5일 수익 감소) - if (rsi14 >= 70 && ret5d < 0) signals.push({ id: 'SIG_7_RSI_DIVERGE', weight: 1.5 }); - - // SIG_8: 수익 보호 트리거 근접 (close <= auto_trailing_stop * 1.02) - if (autoTrail > 0 && close > 0 && close <= autoTrail * 1.02) { - signals.push({ id: 'SIG_8_TRAIL_PROXIMITY', weight: 2.0 }); - } - - var weightedSum = signals.reduce(function(acc, s) { return acc + s.weight; }, 0); - var radarLevel = weightedSum >= 5.0 ? 'CRITICAL' - : weightedSum >= 3.0 ? 'WARNING' - : weightedSum >= 1.5 ? 'WATCH' - : 'CLEAR'; - - return { - ticker: h.ticker, - name: h.name || df.name || '', - radar_level: radarLevel, - weighted_sum: Math.round(weightedSum * 10) / 10, - signal_count: signals.length, - signals: signals.map(function(s) { return s.id; }), - rsi14: round2_(rsi14), - inst5d: round2_(inst5d), - retail_ratio: retailRatio ? round2_(retailRatio) : null, - auto_trail_ref: autoTrail || null, - formula_id: 'PROACTIVE_SELL_RADAR_V2' - }; - }); -} - - -/** - * L1: SECTOR_ROTATION_MOMENTUM_V1 - * 섹터 로테이션 모멘텀 추적 — rank_delta W1/W2 기반 RISING/STABLE/FADING/TOPPING_OUT 분류 - * 결과는 sector_rotation_momentum_json으로 buildHarnessRows_()에 전달된다. - * calcAlphaLeadRow_()에서 FADING/TOPPING_OUT 페널티 적용. - * @param {Object} sectorFlowData — readSectorFlowForRadar_() 반환값 - * @return {Array} sector_rotation_momentum_json rows - */ -function calcSectorRotationMomentum_(sectorFlowData) { - var rows = []; - var sectorNames = Object.keys(sectorFlowData || {}); - sectorNames.forEach(function(sName) { - var sf = sectorFlowData[sName]; - if (!sf || !Number.isFinite(sf.rank)) return; - var rankDeltaW1 = Number.isFinite(sf.prevRank) ? sf.rank - sf.prevRank : null; - var rankDeltaW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : null; - - var momentumState = 'STABLE'; - if (rankDeltaW1 !== null && rankDeltaW2 !== null) { - if (rankDeltaW1 >= 2 && rankDeltaW2 >= 2) { - // 1주일 및 2주일 연속 순위 하락 → 추세 약화 - momentumState = 'FADING'; - } else if (sf.rank <= 3 && rankDeltaW1 >= 1) { - // 상위권이지만 이미 하락 전환 → 고점 신호 - momentumState = 'TOPPING_OUT'; - } else if (rankDeltaW1 <= -2) { - // 순위 상승 → 로테이션 유입 - momentumState = 'RISING'; - } - } else if (rankDeltaW1 !== null) { - if (rankDeltaW1 >= 3) momentumState = 'FADING'; - else if (rankDeltaW1 <= -2) momentumState = 'RISING'; - } - - rows.push({ - sector: sName, - rank: sf.rank, - prev_rank_w1: Number.isFinite(sf.prevRank) ? sf.prevRank : null, - prev_rank_w2: Number.isFinite(sf.prevRankW2) ? sf.prevRankW2 : null, - rank_delta_w1: rankDeltaW1, - rank_delta_w2: rankDeltaW2, - momentum_state: momentumState, - formula_id: 'SECTOR_ROTATION_MOMENTUM_V1' - }); - }); - // 현재 순위 오름차순 정렬 - rows.sort(function(a, b) { return a.rank - b.rank; }); - return rows; -} - -/** - * calcAlphaShield_ - * 보유 종목별 Alpha-Shield 지표 계산: - * X1 deviation_ratio → MRG001 BUY_HARD_BLOCK (이격 차단) - * X3 rs_ratio → RS001 RS_LAGGARD (상대강도 부진) - * W1 수급 다이버전스 → DIVERGENCE_ALERT - * W2 오버행 압력 → OVERHANG_WARNING - * W3 섹터 로테이션 이탈 → ROTATION_WARNING - * W4 수급 감속 → FLOW_DECEL_WARNING - * critical_alert: 2개 이상 레이더 동시 발화 시 전면 재검토 강제 - */ -function calcAlphaShield_(holdings, dfMap, kospiRet5d, sectorFlowData) { - var perHolding = []; - var criticalAlerts = 0; - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var close = df.close || 0; - var ma20 = df.ma20 || 0; - - // ── X1: MEAN_REVERSION_GATE_V1 ───────────────────────────────────────── - var deviationRatio = (close > 0 && ma20 > 0) ? round2_(close / ma20) : null; - var mrgGate = deviationRatio === null ? 'INSUFFICIENT_DATA' - : deviationRatio >= 1.15 ? 'BUY_HARD_BLOCK' - : 'PASS'; - - // ── X3: RS_RATIO_V1 ──────────────────────────────────────────────────── - var stockRet5d = df.ret5d; // null = 컬럼 없음, 0 = 실제 0% - var rsRatio = (stockRet5d !== null && typeof kospiRet5d === 'number' && kospiRet5d !== 0) - ? round2_(stockRet5d / kospiRet5d) : null; - var rsStatus = rsRatio === null ? 'INSUFFICIENT_DATA' - : rsRatio < 0.80 ? 'RS_LAGGARD' : 'RS_OK'; - - // ── 공통 수급 데이터 ────────────────────────────────────────────────── - var frg5d = df.frg5d; // null = 컬럼 없음 - var inst5d = df.inst5d; - var frg20d = df.frg20d; - var volume = df.volume; - var avgVol5d = df.avgVolume5d; - var ma20Slope = typeof df.ma20Slope === 'number' ? df.ma20Slope : null; - - var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20; - var ma20SlopePositive = ma20Slope !== null && ma20Slope > 0; - var frgNetNeg = frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0; - var volRatio = (volume !== null && avgVol5d !== null && avgVol5d > 0) - ? round2_(volume / avgVol5d) : null; - - // ── W1: DIVERGENCE_SCORE_V1 ──────────────────────────────────────────── - var w1Status = 'INSUFFICIENT_DATA'; - if (frg5d !== null && inst5d !== null && ma20Slope !== null - && volume !== null && avgVol5d !== null) { - w1Status = (priceAboveMa20 && !ma20SlopePositive && frgNetNeg - && volRatio !== null && volRatio <= 0.80) - ? 'DIVERGENCE_ALERT' : 'CLEAR'; - } - - // ── W2: OVERHANG_PRESSURE_V1 ─────────────────────────────────────────── - var w2Status = 'INSUFFICIENT_DATA'; - var overhangPressure = null; - if (frg20d !== null && avgVol5d !== null && avgVol5d > 0) { - overhangPressure = round2_(Math.abs(frg20d) / (avgVol5d * 20)); - w2Status = (frg20d < 0 && overhangPressure > 0.30) ? 'OVERHANG_WARNING' : 'CLEAR'; - } - - // ── W3: SECTOR_ROTATION_RADAR_V1 ────────────────────────────────────── - var w3Status = 'INSUFFICIENT_DATA'; - var sectorName = TICKER_SECTOR_MAP[h.ticker] || null; - var sfRow = sectorName ? (sectorFlowData[sectorName] || null) : null; - var sectorRank = null; - var sectorPrvRank = null; - if (sfRow) { - sectorRank = sfRow.rank; - sectorPrvRank = sfRow.prevRank; - if (Number.isFinite(sfRow.rank) && Number.isFinite(sfRow.prevRank)) { - var dropW1 = sfRow.rank - sfRow.prevRank; - var dropW2 = Number.isFinite(sfRow.prevRankW2) - ? sfRow.rank - sfRow.prevRankW2 : dropW1; - w3Status = (dropW1 >= 3 && dropW2 >= 3) ? 'ROTATION_WARNING' : 'CLEAR'; - } - } - - // ── W4: FLOW_ACCELERATION_V1 ─────────────────────────────────────────── - var w4Status = 'INSUFFICIENT_DATA'; - var flowAccelRatio = null; - if (frg5d !== null && frg20d !== null) { - var buyEnergy20dAvg = frg20d / 4; - if (buyEnergy20dAvg > 0) { - flowAccelRatio = round2_(frg5d / buyEnergy20dAvg); - w4Status = (priceAboveMa20 && frg5d > 0 && flowAccelRatio < 0.50) - ? 'FLOW_DECEL_WARNING' : 'CLEAR'; - } else { - w4Status = 'CLEAR'; - } - } - - // ── 발화 집계 ──────────────────────────────────────────────────────── - var ALERT_STATUSES = ['DIVERGENCE_ALERT','OVERHANG_WARNING','ROTATION_WARNING','FLOW_DECEL_WARNING']; - var fires = [w1Status, w2Status, w3Status, w4Status].filter(function(s) { - return ALERT_STATUSES.indexOf(s) >= 0; - }).length; - if (fires >= 2) criticalAlerts++; - - perHolding.push({ - ticker: h.ticker, - name: h.name || '', - weight_pct: h.weightPct || 0, - // X1 MRG001 - deviation_ratio: deviationRatio, - mrg_gate: mrgGate, - // X3 RS001 - stock_ret5d: stockRet5d, - kospi_ret5d: typeof kospiRet5d === 'number' ? kospiRet5d : null, - rs_ratio: rsRatio, - rs_status: rsStatus, - // W1 - volume_ratio: volRatio, - w1_status: w1Status, - // W2 - overhang_pressure: overhangPressure, - w2_status: w2Status, - // W3 - sector: sectorName, - sector_rank: sectorRank, - sector_prev_rank: sectorPrvRank, - w3_status: w3Status, - // W4 - flow_accel_ratio: flowAccelRatio, - w4_status: w4Status, - // 종합 - radar_fires: fires, - critical_alert: fires >= 2 ? 'CRITICAL_ALERT' : 'OK' - }); - }); - - return { - per_holding: perHolding, - critical_alert_count: criticalAlerts, - lock: true - }; -} - -// ── APEX V1: 판단 자료 생성 시점 하네스 ───────────────────────────────────── - -/** - * calcRegimeAdjustedSellPriority_ [K3: 국면·섹터 연계 H2 동적 우선순위] - * 시장 국면(regime)에 따라 H2 매도후보의 매도 우선순위를 동적으로 조정한다. - * H2 원래 순위(rank)는 변경하지 않고 regime_priority_adjustment(음수=우선↑)와 - * final_regime_rank을 추가로 부여한다. - * LLM은 regime_adjusted_sell_priority_json을 H2보다 우선 참조하되, - * sell_priority_lock=true 이므로 최종 순위를 임의 재해석할 수 없다. - */ -function calcRegimeAdjustedSellPriority_(h2Candidates, regime, dfMap, kospiRet5d) { - var result = []; - h2Candidates.forEach(function(cand) { - var candScore = (typeof cand.sell_priority_score === 'number') ? cand.sell_priority_score : cand.score; - if (typeof candScore !== 'number' || !Number.isFinite(candScore)) { - throw new Error('SELL_PRIORITY_SCHEMA_INVALID: missing score field for ticker=' + cand.ticker); - } - var df = dfMap[cand.ticker] || {}; - var adj = 0; - var reason = 'NO_REGIME_ADJ'; - - if (regime === 'RISK_OFF' || regime === 'EVENT_SHOCK') { - // 추세 붕괴/충격 국면: KOSPI 대비 고베타(많이 떨어지는) 종목 우선 매도 - var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; - var kRet5d = typeof kospiRet5d === 'number' ? kospiRet5d : null; - if (ret5d !== null && kRet5d !== null && kRet5d < -1) { - var betaProxy = ret5d / kRet5d; - if (Number.isFinite(betaProxy) && betaProxy > 1.3) { - adj = -3; reason = 'high_beta_breakdown_sell_first'; - } else if (Number.isFinite(betaProxy) && betaProxy > 1.0) { - adj = -1; reason = 'above_beta_breakdown'; - } - } - // 수급 동반 이탈 종목 우선 - if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) { - adj = Math.min(adj, -2); reason = reason === 'NO_REGIME_ADJ' ? 'dual_outflow_breakdown' : reason; - } - } else if (regime === 'RISK_OFF_CANDIDATE') { - // 분배장 경고: 수급 약하고 flow_credit 낮은 종목 우선 - var fcOk = typeof df.flowCredit === 'number'; - if (fcOk && df.flowCredit < 0.30) { adj = -2; reason = 'low_flow_credit_distribution'; } - else if (fcOk && df.flowCredit < 0.45) { adj = -1; reason = 'moderate_low_flow_distribution'; } - } else if (regime === 'RISK_ON' || regime === 'SECULAR_LEADER_RISK_ON') { - // 상승기: 섹터 대비 상대적 약자 우선 정리 (리더 보호) - var sRet = typeof df.ret5d === 'number' ? df.ret5d : null; - var kRet = typeof kospiRet5d === 'number' ? kospiRet5d : null; - if (sRet !== null && kRet !== null && sRet < kRet - 3) { - adj = -2; reason = 'sector_lag_in_risk_on_trim'; - } - // 중복 ETF는 상승기에도 먼저 정리 - if (df.isDuplicateEtf) { adj = Math.min(adj, -2); reason = 'duplicate_etf_in_risk_on'; } - } else if (regime === 'LEADER_CONCENTRATION' || regime === 'NEUTRAL') { - // 조정기: AC(안티클라이막스) 발동 종목 우선 - if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) { - adj = -1; reason = 'anti_climax_in_pullback'; - } - } - - result.push({ - rank: cand.rank, - ticker: cand.ticker, - name: cand.name, - tier: cand.tier, - original_score: candScore, - trim_style: cand.trim_style || '', - regime_priority_adjustment: adj, - adjusted_sort_key: cand.tier * 100 + (cand.rank + adj), - adjustment_reason: reason, - regime_applied: regime - }); - }); - - result.sort(function(a, b) { return a.adjusted_sort_key - b.adjusted_sort_key; }); - result.forEach(function(r, i) { r.final_regime_rank = i + 1; }); - return result; -} - -function findCandidateByTicker_(candidates, ticker) { - for (var i = 0; i < (candidates || []).length; i++) { - if (candidates[i].ticker === ticker) return candidates[i]; - } - return null; -} - -function findOrderBlueprintRow_(orders, ticker) { - for (var i = 0; i < (orders || []).length; i++) { - if (orders[i].ticker === ticker) return orders[i]; - } - return null; -} - -function calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData) { - var close = df.close || h.close || 0; - var ma20 = df.ma20 || 0; - var high = df.high || close; - var low = df.low || close; - var volume = df.volume; - var avgVol5d = df.avgVolume5d; - var flowCredit = typeof df.flowCredit === 'number' ? df.flowCredit : null; - var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20; - var score = 0; - var reasons = []; - - if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) { - score += 30; reasons.push('smart_money_outflow'); - } - if (volume !== null && avgVol5d !== null && avgVol5d > 0 && volume < avgVol5d * 0.80) { - score += 20; reasons.push('volume_fade_after_surge'); - } - if (high > low && close > 0) { - var upperWickRatio = (high - close) / Math.max(high - low, 1); - if (upperWickRatio >= 0.45 && priceAboveMa20) { - score += 15; reasons.push('upper_wick_distribution'); - } - } - if (flowCredit !== null && flowCredit < 0.40) { - score += 20; reasons.push('flow_credit_low'); - } - if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number' && df.ret5d < kospiRet5d - 3) { - score += 15; reasons.push('sector_relative_lag'); - } - // J2: Anti-Climax Gate — 가격은 유지되나 수급 에너지 고갈 신호 (acGate / acTotal) - if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) { - score += 15; reasons.push('anti_climax_gate'); - } - if (typeof df.acTotal === 'number' && df.acTotal >= 2) { - score += 10; reasons.push('ac_total_gte2'); - } - // J2: 거래량 상승 국면에서 상승폭 축소 (가격 상승 + 거래량 급증 + 수익 미실현 구간) - if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 40 && priceAboveMa20 - && (flowCredit === null || flowCredit < 0.50)) { - score += 10; reasons.push('val_surge_no_flow_support'); - } - - // L4: PRE_DISTRIBUTION_EARLY_WARNING_V1 - // Signal 1: 신고점 근접 + 거래량 수축 — 분배 직전 전형적 패턴 - var high52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : null; - var nearNewHigh = (high52w !== null && close > 0 && close >= high52w * 0.97) - || (ma20 > 0 && close > ma20 * 1.15); // 52W high 미제공 시 MA20 +15% 이상 연장으로 대체 - if (nearNewHigh && volume !== null && avgVol5d !== null && avgVol5d > 0 - && volume < avgVol5d * 0.80) { - score += 12; reasons.push('new_high_volume_contraction'); - } - // Signal 2: 최근 급등 후 수급 약화 — 5일 +5% 이상 상승했으나 flow credit 저조 - if (typeof df.ret5d === 'number' && df.ret5d >= 5 - && flowCredit !== null && flowCredit < 0.45) { - score += 10; reasons.push('surge_weak_flow'); - } - - var state = score >= 70 ? 'BLOCK_BUY' : score >= 55 ? 'TRIM_REVIEW' : 'PASS'; - var preDistWarning = (reasons.indexOf('new_high_volume_contraction') >= 0 - || reasons.indexOf('surge_weak_flow') >= 0) ? 'EARLY_WARNING' : 'NONE'; - return { - ticker: h.ticker, - name: h.name || df.name || '', - ["distribution_risk_score"]: Math.min(100, Math.max(0, score)), - anti_distribution_state: state, - pre_distribution_warning: preDistWarning, - reason_codes: reasons, - formula_id: 'DISTRIBUTION_RISK_SCORE_V1' - }; -} - -function calcAlphaLeadRow_(h, df, sectorFlowData, distributionRow) { - var close = df.close || h.close || 0; - var ma20 = df.ma20 || 0; - var closeVsMa20Pct = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null; - var sectorName = TICKER_SECTOR_MAP[h.ticker] || null; - var sf = sectorName ? sectorFlowData[sectorName] : null; - var score = 0; - var lateChaseRisk = 0; - var reasons = []; - - if (sf && Number.isFinite(sf.rank) && sf.rank <= 2) { score += 20; reasons.push('sector_rank_top2'); } - - // L1: SECTOR_ROTATION_MOMENTUM_V1 — 로테이션 모멘텀 패널티 - if (sf && Number.isFinite(sf.rank) && Number.isFinite(sf.prevRank)) { - var rdW1 = sf.rank - sf.prevRank; - var rdW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : rdW1; - if (rdW1 >= 2 && rdW2 >= 2) { - score -= 15; reasons.push('sector_fading'); - } else if (sf.rank <= 3 && rdW1 >= 1) { - score -= 10; reasons.push('sector_topping_out'); - } - } - - if (typeof df.ret5d === 'number' && df.ret5d > 0) { score += 10; reasons.push('ret5d_positive'); } - if (df.frg5d !== null && df.inst5d !== null && (df.frg5d + df.inst5d) > 0) { score += 25; reasons.push('smart_money_inflow'); } - if (typeof df.leaderTotal === 'number') { score += Math.min(20, df.leaderTotal * 5); reasons.push('leader_scan'); } - if (typeof df.avgTradeVal5d === 'number' && df.avgTradeVal5d >= 50) { score += 10; reasons.push('liquidity_ok'); } - if (closeVsMa20Pct !== null && closeVsMa20Pct >= 0 && closeVsMa20Pct <= 6) { score += 15; reasons.push('ma20_controlled_extension'); } - - var lateChase = closeVsMa20Pct !== null && closeVsMa20Pct > 10; - if (closeVsMa20Pct !== null) { - if (closeVsMa20Pct > 10) lateChaseRisk += 60; - else if (closeVsMa20Pct > 6) lateChaseRisk += 25; - else if (closeVsMa20Pct > 3) lateChaseRisk += 10; - } - if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 60) { - lateChase = true; - lateChaseRisk += 25; - reasons.push('value_surge_extreme'); - } else if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 35) { - lateChaseRisk += 10; - } - if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') { - lateChase = true; - lateChaseRisk += 40; - reasons.push('distribution_block'); - } - if (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK') { - lateChase = true; - lateChaseRisk += 30; - reasons.push('dart_risk'); - } - - // N2: VOLUME_BREAKOUT_CONFIRM_V1 — 신고가 부근 거래량 미확인 시 뒷박 차단 - var n2High52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : 0; - var n2Vol = typeof df.volume === 'number' ? df.volume : 0; - var n2AvgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0; - if (n2High52w > 0 && close > 0 && close >= n2High52w * 0.97) { - if (n2AvgVol5d > 0 && n2Vol < n2AvgVol5d * 1.2) { - score -= 10; - lateChaseRisk += 15; - reasons.push('unconfirmed_breakout_volume'); - } - } - - var state = lateChase ? 'BLOCKED_LATE_CHASE' - : score >= 75 ? 'PILOT_ALLOWED' - : score >= 55 ? 'WATCH_ONLY' - : 'WATCH_ONLY'; - var buyState = state === 'PILOT_ALLOWED' ? 'ALLOW_PILOT' : (state === 'BLOCKED_LATE_CHASE' ? 'BLOCKED' : 'WATCH'); - return { - ticker: h.ticker, - name: h.name || df.name || '', - alpha_lead_score: Math.min(100, Math.max(0, Math.round(score))), - lead_entry_state: state, - allowed_tranche_pct: state === 'PILOT_ALLOWED' ? 30 : 0, - buy_permission_state: buyState, - close_vs_ma20_pct: closeVsMa20Pct === null ? null : round2_(closeVsMa20Pct), - ["late_chase_risk_score"]: Math.min(100, Math.max(0, Math.round(lateChaseRisk))), - blocked_reason_codes: lateChase ? ['late_chase_or_distribution'] : [], - reason_codes: reasons, - formula_id: 'ALPHA_LEAD_SCORE_V1' - }; -} - -function calcFollowThroughRow_(h, df) { - var close = df.close || h.close || 0; - var prevClose = df.prevClose || 0; - var ma5Proxy = prevClose || close; - var state = 'WAIT_PULLBACK'; - var score = 25; - var reasons = []; - if (close > 0 && df.ma20 > 0 && close < df.ma20) { - state = 'FAILED_BREAKOUT'; reasons.push('close_below_ma20'); score = 0; - } else if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) { - state = 'FAILED_BREAKOUT'; reasons.push('dual_outflow'); score = 0; - } else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy && df.frg5d !== null && df.frg5d > 0) { - state = 'CONFIRMED_ADD_ON'; reasons.push('price_hold_and_foreign_inflow'); score = 100; - } else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy) { - score = 60; - } - return { - ticker: h.ticker, - name: h.name || df.name || '', - follow_through_state: state, - follow_through_score: score, - reason_codes: reasons, - formula_id: 'FOLLOW_THROUGH_CONFIRM_V1' - }; -} - diff --git a/src/gas/engines/gdf_04_execution_quality.gs b/src/gas/engines/gdf_04_execution_quality.gs deleted file mode 100644 index 0c9acdf..0000000 --- a/src/gas/engines/gdf_04_execution_quality.gs +++ /dev/null @@ -1,2403 +0,0 @@ -function calcProfitPreservationRow_(h, df, priceRow, distributionRow) { - var close = df.close || h.close || 0; - var avgCost = h.avgCost || 0; - var profitPct = close > 0 && avgCost > 0 ? (close - avgCost) / avgCost * 100 : 0; - var state = 'NORMAL'; - var preserveScore = 100; - if (profitPct >= 30) state = 'PROFIT_LOCK_30'; - else if (profitPct >= 20) state = 'PROFIT_LOCK_20'; - else if (profitPct >= 10) state = 'PROFIT_LOCK_10'; - else if (profitPct >= 8 || (df.atr20 > 0 && close >= avgCost + df.atr20)) state = 'BREAKEVEN_RATCHET'; - if (state === 'PROFIT_LOCK_30') preserveScore = 20; - else if (state === 'PROFIT_LOCK_20') preserveScore = 40; - else if (state === 'PROFIT_LOCK_10') preserveScore = 60; - else if (state === 'BREAKEVEN_RATCHET') preserveScore = 80; - if (state === 'PROFIT_LOCK_30' && distributionRow && distributionRow.anti_distribution_state === 'PASS') { - state = 'APEX_TRAILING'; - } - if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') { - preserveScore = Math.max(0, preserveScore - 15); - } - - // L2: RATCHET_TRAILING_AUTO_V1 — ATR 기반 자동 트레일링 손절 계산 - var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : 0; - var ratchetStop = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0; - var highestClose = priceRow && typeof priceRow.highest_price_since_entry === 'number' - ? priceRow.highest_price_since_entry : close; - var autoTrailingStop = null; - var autoTrailingNote = null; - if (atr20 > 0 && (state === 'PROFIT_LOCK_30' || state === 'APEX_TRAILING')) { - var raw = Math.max(ratchetStop, highestClose - 2.0 * atr20); - autoTrailingStop = tickNormalize_(raw); - autoTrailingNote = 'max(ratchet,' + highestClose + '-2.0×ATR)'; - } else if (atr20 > 0 && state === 'PROFIT_LOCK_20') { - var raw = Math.max(ratchetStop, highestClose - 1.5 * atr20); - autoTrailingStop = tickNormalize_(raw); - autoTrailingNote = 'max(ratchet,' + highestClose + '-1.5×ATR)'; - } - - return { - ticker: h.ticker, - name: h.name || df.name || '', - profit_pct: round2_(profitPct), - profit_preservation_state: state, - rebound_preservation_score: Math.min(100, Math.max(0, Math.round(preserveScore))), - protected_stop_price: priceRow ? priceRow.stop_price : null, - ratchet_partial_qty: priceRow ? priceRow.ratchet_partial_qty : 0, - auto_trailing_stop: autoTrailingStop, - auto_trailing_note: autoTrailingNote, - formula_id: 'PROFIT_PRESERVATION_STATE_V1' - }; -} - -function calcExecutionQualityRow_(ticker, orderRow, df) { - var amount = orderRow && orderRow.order_amount_krw ? orderRow.order_amount_krw : 0; - var advKrw = 0; - if (typeof df.avgTradeVal5d === 'number') { - // AvgTradeValue_5D_M is usually million KRW in sheet label. - advKrw = df.avgTradeVal5d * 1000000; - } - var status = 'PASS'; - var splitCount = 1; - var reasons = []; - if (amount > 0 && advKrw > 0 && amount > advKrw * 0.03) { - status = 'BLOCKED_ADV_3PCT'; reasons.push('order_amount_gt_3pct_adv'); - } else if (amount > 0 && advKrw > 0 && amount > advKrw * 0.01) { - status = 'SPLIT_REQUIRED'; splitCount = 2; reasons.push('order_amount_gt_1pct_adv'); - } - if (df.spreadStatus && String(df.spreadStatus).indexOf('WIDE') >= 0) { - status = 'BLOCKED_SPREAD'; reasons.push('wide_spread'); - } - return { - ticker: ticker, - execution_quality_status: status, - split_count: splitCount, - child_order_amount_krw: splitCount > 1 ? Math.round(amount / splitCount) : amount, - hts_allowed: status === 'PASS', - reason_codes: reasons, - formula_id: 'EXECUTION_QUALITY_GUARD_V1' - }; -} - -// ── [2026-05-20_HARNESS_V5] H6: 뒷박 차단 — BREAKOUT_QUALITY_GATE_V2 ───────── -function calcBreakoutQualityGate_(h, df, alphaRow, distRow) { - var close = df.close || h.close || 0; - var prevClose = df.prevClose || close; - var ma20 = df.ma20 || 0; - var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null; - var volume = typeof df.volume === 'number' ? df.volume : null; - var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : null; - - var ret1d = (close > 0 && prevClose > 0) ? (close - prevClose) / prevClose * 100 : null; - var ret3d = typeof df.ret5d === 'number' ? df.ret5d * 0.6 : null; // ret5d 프록시 - var disparity = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null; - - var timingScoreExit = alphaRow && typeof alphaRow.timing_score_exit === 'number' ? alphaRow.timing_score_exit : 0; - var distributionRiskScore = distRow && typeof distRow["distribution_risk_score"] === 'number' ? distRow["distribution_risk_score"] : 0; - var lateChaseRiskScore = alphaRow && typeof alphaRow["late_chase_risk_score"] === 'number' ? alphaRow["late_chase_risk_score"] : 0; - - var score = 50; - var reasons = []; - - if (ret3d !== null && ret3d >= 7) { score -= 30; reasons.push('ret3d_gte7'); } - if (disparity !== null && disparity > 10) { score -= 25; reasons.push('disparity_gt10'); } - if (ret1d !== null && ret1d >= 4 && volume !== null && avgVol5d !== null - && avgVol5d > 0 && volume < avgVol5d * 0.9) { score -= 40; reasons.push('surge_day_low_vol'); } - if (rsi14 !== null && rsi14 > 75) { score -= 20; reasons.push('rsi14_gt75'); } - if (timingScoreExit >= 50) { score -= 50; reasons.push('timing_exit_gte50'); } - if (distributionRiskScore >= 70) { score -= 35; reasons.push('distribution_gte70'); } - if (lateChaseRiskScore >= 70) { score -= 30; reasons.push('late_chase_gte70'); } - - if (volume !== null && avgVol5d !== null && avgVol5d > 0 - && volume >= avgVol5d * 1.5 && ret1d !== null && ret1d >= 2 - && ret3d !== null && ret3d < 5) { score += 25; reasons.push('quality_breakout_vol'); } - if (disparity !== null && disparity >= 0 && disparity < 6) { score += 15; reasons.push('disparity_healthy'); } - if (rsi14 !== null && rsi14 >= 45 && rsi14 <= 65) { score += 10; reasons.push('rsi14_healthy'); } - - score = Math.max(0, Math.min(100, Math.round(score))); - var gate = score < 10 ? 'BLOCKED_LATE_CHASE' : score < 40 ? 'WATCH_COOLING_OFF' : 'PILOT_ALLOWED'; - - return { - ticker: h.ticker, - name: h.name || df.name || '', - breakout_quality_score: score, - breakout_quality_gate: gate, - reason_codes: reasons, - formula_id: 'BREAKOUT_QUALITY_GATE_V2', - version: '2026-05-20_HARNESS_V5' - }; -} - -// ── [2026-05-20_HARNESS_V5] H7: 가짜 매도 차단 — ANTI_WHIPSAW_HOLD_GATE_V1 ─── -function calcAntiWhipsawGate_(h, df, kospiRet5d) { - var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null; - var frg5d = typeof df.frg5d === 'number' ? df.frg5d : null; - var volSurge = typeof df.valSurgePct === 'number' ? df.valSurgePct : null; - var consecutiveSell5d = typeof df.consecutiveSellSignals5d === 'number' - ? df.consecutiveSellSignals5d : 0; - - var sectorRS5d = null; - if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number') { - var stockFactor = 1 + df.ret5d / 100; - var kospiFactor = 1 + kospiRet5d / 100; - sectorRS5d = kospiFactor > 0 ? stockFactor / kospiFactor * 100 : null; - } - - var score = 0; - var reasons = []; - - if (consecutiveSell5d >= 5) { score += 20; reasons.push('consecutive_sell_5d_gte5'); } - if (inst5d !== null && inst5d > 0) { score += 30; reasons.push('inst_net_buy'); } - if (frg5d !== null && frg5d > 0) { score += 20; reasons.push('frg_net_buy'); } - if (sectorRS5d !== null && sectorRS5d > 100) { score += 15; reasons.push('sector_outperforming'); } - if (volSurge !== null && volSurge >= 50) { score -= 25; reasons.push('vol_surge_50pct'); } - if (volSurge !== null && volSurge >= 100) { score -= 20; reasons.push('vol_surge_100pct'); } - - score = Math.max(-50, Math.min(100, Math.round(score))); - - // [V1.1] 자동 해제 조건 3개 — 충족 수에 따라 hold_days 결정 - var wClose = h.close || df.close || 0; - var wMa20 = typeof df.ma20 === 'number' ? df.ma20 : 0; - var clearCnt = 0; - var clearList = []; - if (inst5d !== null && inst5d > 0) { clearCnt++; clearList.push('inst_net_buy'); } - if (frg5d !== null && frg5d > 0) { clearCnt++; clearList.push('frg_net_buy'); } - if (wMa20 > 0 && wClose > 0 && wClose > wMa20) { clearCnt++; clearList.push('price_above_ma20'); } - - var gate, holdDays; - if (score >= 30) { - if (clearCnt >= 3) { gate = 'WHIPSAW_AUTO_RELEASED'; holdDays = 0; } - else if (clearCnt >= 2) { gate = 'WHIPSAW_WEAKENING'; holdDays = 1; } - else { gate = 'WHIPSAW_CONFIRMED'; holdDays = 3; } - } else if (score >= 10) { - gate = 'INCONCLUSIVE'; holdDays = 0; - } else { - gate = 'CONFIRMED_SELL'; holdDays = 0; - } - - return { - ticker: h.ticker, - name: h.name || df.name || '', - anti_whipsaw_score: score, - anti_whipsaw_gate: gate, - anti_whipsaw_hold_days: holdDays, - clear_conditions_count: clearCnt, - clear_conditions: clearList, - reason_codes: reasons, - formula_id: 'ANTI_WHIPSAW_HOLD_GATE_V1', - version: '2026-05-24_V1.1' - }; -} - -// ── [2026-05-20_HARNESS_V5] H8: 4경로 결정론적 현금확보 라우터 ───────────────── -function calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo) { - var posClass = String(h.positionClass || df.positionClass || '').toUpperCase(); - var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : 50; - var profitStage = priceRow && priceRow.profit_lock_stage - ? String(priceRow.profit_lock_stage) - : (profitRow ? String(profitRow.profit_preservation_state || 'NORMAL') : 'NORMAL'); - var secularPass = priceRow && priceRow.secular_leader_gate_active === false; // PASS = not active restriction - var emergencyFull = !!(cashShortfallInfo && cashShortfallInfo.emergency_full_sell); - var stopPrice = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0; - var close = df.close || h.close || 0; - var breachImmediate = stopPrice > 0 && close > 0 && close < stopPrice; - var stopBreachGate = breachImmediate ? 'BREACH' : 'PASS'; - - var route, routeLabel, rationale; - - if (emergencyFull || breachImmediate) { - route = 'ROUTE_D'; - routeLabel = '긴급 전량매도'; - rationale = emergencyFull ? 'emergency_full_sell=true' : 'close= 0 && rsi14 >= 35) { - route = 'ROUTE_A'; - routeLabel = '위성 비중 트림'; - rationale = 'SATELLITE+RSI14(' + rsi14 + ')>=35'; - } else if (rsi14 < 35) { - route = 'ROUTE_B'; - routeLabel = '과매도 분할 매도'; - rationale = 'RSI14(' + rsi14 + ')<35→K2_50/50'; - } else if (posClass.indexOf('CORE') >= 0 - && (profitStage === 'PROFIT_LOCK_STAGE_20' - || profitStage === 'PROFIT_LOCK_STAGE_30' - || profitStage === 'PROFIT_LOCK_20' - || profitStage === 'PROFIT_LOCK_30') - && secularPass) { - route = 'ROUTE_C'; - routeLabel = '코어 익절 잠금'; - rationale = 'CORE+' + profitStage + '+secular_PASS'; - } else { - route = 'NO_ACTION'; - routeLabel = '현금확보 비대상'; - rationale = 'no_condition_met'; - } - - return { - ticker: h.ticker, - name: h.name || df.name || '', - smart_cash_raise_route: route, - route_label: routeLabel, - rationale: rationale, - profit_lock_stage: profitStage, - stop_breach_gate: stopBreachGate, - emergency_full_sell: emergencyFull, - rebound_wait_pct: route === 'ROUTE_B' ? 50 : 0, - formula_id: 'SMART_CASH_RAISE_V2', - version: '2026-05-20_HARNESS_V5' - }; -} - -// ── [2026-05-20_HARNESS_V5] Gate 4b: O'Neil Follow-Through Day — FOLLOW_THROUGH_DAY_CONFIRM_V1 -// 돌파 당일(Day 0)에 즉시 매수 금지. Day 2~7 사이에 수익률+거래량 조건 충족 시만 BUY_PILOT_ALLOWED. -// daysSinceBreakout / retSinceBreakout / volumeBreakoutDay 이 df에 없으면 프록시 계산으로 후퇴. -function calcFollowThroughDayConfirm_(h, df) { - var ticker = h.ticker; - var name = h.name || df.name || ''; - - // ── 입력 수집 (실제 필드 우선, 프록시 fallback) ────────────────────────── - var daysSince = typeof df.daysSinceBreakout === 'number' ? df.daysSinceBreakout : null; - var retSince = typeof df.retSinceBreakout === 'number' ? df.retSinceBreakout : null; - var volToday = typeof df.volume === 'number' ? df.volume : null; - var volBreakout = typeof df.volumeBreakoutDay === 'number' ? df.volumeBreakoutDay : null; - - // 프록시: daysSinceBreakout — close vs MA20 돌파여부로 추정 - // MA20 이하에서 위로 올라온 직후이면 daysSince=0, 그 이전이면 null - if (daysSince === null) { - var close = df.close || h.close || 0; - var ma20 = df.ma20 || 0; - var prevClose = df.prevClose || close; - // 오늘 ma20 상향 돌파면 Day 0 - if (close > 0 && ma20 > 0 && close > ma20 && prevClose <= ma20) { - daysSince = 0; - } - // 이미 ma20 위에 있고 ret5d 존재 → days를 ret5d로 추정(보수적 5일 상한) - else if (close > 0 && ma20 > 0 && close > ma20 && typeof df.ret5d === 'number') { - // 5일 기준 프록시: 상승률이 클수록 이미 많이 경과했다고 가정 - daysSince = df.ret5d >= 7 ? 8 : df.ret5d >= 3 ? 4 : 2; - } - } - - // 프록시: retSinceBreakout — ret5d 사용 - if (retSince === null && typeof df.ret5d === 'number') { - retSince = df.ret5d; - } - - // 프록시: volBreakoutDay — avgVolume5d 사용 - if (volBreakout === null && typeof df.avgVolume5d === 'number') { - volBreakout = df.avgVolume5d; - } - - // ── 상태 분류 ────────────────────────────────────────────────────────────── - var state, result, reasons = []; - - if (daysSince === null) { - state = 'PENDING_DATA'; - result = 'WATCH_NO_BREAKOUT_TRACKED'; - reasons.push('days_since_breakout_null'); - - } else if (daysSince === 0) { - state = 'BREAKOUT_DAY_1'; - result = 'WATCH_FOLLOW_THROUGH_PENDING'; - reasons.push('day0_no_immediate_buy'); - - } else if (daysSince > 7) { - state = 'EXTENDED_FOLLOW'; - result = 'WATCH_TOO_LATE'; - reasons.push('days_since_gt7'); - - } else { - // daysSince 2~7 범위 - var volOk = (volToday !== null && volBreakout !== null && volBreakout > 0) - ? (volToday >= volBreakout * 0.9) : true; // 데이터 없으면 통과 - var retOk = (retSince !== null) ? (retSince >= 1.5) : false; - - if (retOk && volOk) { - state = 'FOLLOW_THROUGH_OK'; - result = 'BUY_PILOT_ALLOWED'; - reasons.push('days_' + daysSince + '_ret_' + (retSince !== null ? retSince.toFixed(1) : 'N/A')); - if (volOk) reasons.push('vol_confirmed'); - } else { - state = 'FOLLOW_THROUGH_FAIL'; - result = 'WATCH_RESET_REQUIRED'; - if (!retOk) reasons.push('ret_since_lt1.5pct'); - if (!volOk) reasons.push('vol_lt90pct_breakout_day'); - } - } - - return { - ticker: ticker, - name: name, - days_since_breakout: daysSince, - ret_since_breakout: retSince, - vol_ratio_vs_breakout_day: (volToday !== null && volBreakout !== null && volBreakout > 0) - ? Math.round(volToday / volBreakout * 100) / 100 : null, - follow_through_state: state, - follow_through_result: result, - reason_codes: reasons, - formula_id: 'FOLLOW_THROUGH_DAY_CONFIRM_V1', - version: '2026-05-20_HARNESS_V5' - }; -} - - -function calcApexExecutionHarness_(holdings, dfMap, sectorFlowData, kospiRet5d, h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime) { - var alphaLead = []; - var followThrough = []; - var distribution = []; - var profitPreservation = []; - var entryFreshness = []; - var cashRaisePlan = []; - var reboundTriggers = []; - var smartSellQty = []; - var sellValuePreservation = []; - var executionQuality = []; - var buyPermission = []; - var limitPolicy = []; - var benchmarkRelativeRows = []; - var indexRelativeHealthRows = []; - var saqgRows = []; - var cashCreationLockRows = []; - // ── [2026-05-20_HARNESS_V5] 신규 V5 게이트 결과 배열 - var breakoutQualityGate = []; - var antiWhipsawGate = []; - var smartCashRaiseV2 = []; - var followThroughConfirm = []; - var blockCount = 0; - var regime = marketRegime || 'UNKNOWN'; - - var priceMap = {}; - (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; }); - var sellQtyMap = {}; - (h3.sellQty || []).forEach(function(s) { sellQtyMap[s.ticker] = s; }); - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var distRow = calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData); - // [PROPOSAL50] P1-B: DSD V1.1 — SIG_7/SIG_8 추가, weighted_sum 5.0/3.0 상향 - applyDsdV1_1Signals_([distRow], dfMap); - var alphaRow = calcAlphaLeadRow_(h, df, sectorFlowData, distRow); - var ftRow = calcFollowThroughRow_(h, df); - var priceRow = priceMap[h.ticker] || {}; - var profitRow = calcProfitPreservationRow_(h, df, priceRow, distRow); - var orderRow = findOrderBlueprintRow_(orderBlueprint, h.ticker) || {}; - var eqRow = calcExecutionQualityRow_(h.ticker, orderRow, df); - var saqgState = df.saqg_v1 || (h.position_type === 'core' ? 'EXEMPT' : 'WATCHLIST_ONLY'); - var cand = findCandidateByTicker_(h2.candidates, h.ticker) || {}; - var sq = sellQtyMap[h.ticker] || {}; - var tradePlan = calcApexTradePlan_( - h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState - ); - var buyState = tradePlan.buyState; - var buyReasons = tradePlan.buyReasons; - if (buyState === 'BLOCKED') blockCount++; - var style = tradePlan.style; - var immediateQty = tradePlan.immediateQty; - var reboundQty = tradePlan.reboundQty; - var k2Emergency = tradePlan.k2Emergency; - var tranchePhase = tradePlan.tranchePhase; - var currentTrancheAllowedPct = tradePlan.currentTrancheAllowedPct; - var nextTrancheCondition = tradePlan.nextTrancheCondition; - var normalizedSellPrice = tradePlan.normalizedSellPrice; - var normalizedBuyPrice = tradePlan.normalizedBuyPrice; - var htsLimitPrice = tradePlan.htsLimitPrice; - var close = h.close || df.close || 0; - var atr20 = df.atr20 || 0; - var holdingQty = h.holdingQty || 0; - var prevClose = df.prevClose || close; - - // ── [2026-05-20_HARNESS_V5] V5 게이트 산출 ────────────────────────────── - var bqRow = calcBreakoutQualityGate_(h, df, alphaRow, distRow); - var awRow = calcAntiWhipsawGate_(h, df, kospiRet5d); - var scrV2 = calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo); - var ftdRow = calcFollowThroughDayConfirm_(h, df); - - // H6: 뒷박 차단 — BUY 상태 override - if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE') { - if (buyState !== 'BLOCKED') { buyState = 'BLOCKED'; } - buyReasons.push('breakout_quality_BLOCKED_LATE_CHASE'); - blockCount++; - } - - // Gate 4b: FTD 미확인 — BUY 차단 (돌파 당일 즉시 매수 금지, 데이터 부재 시 WATCH로 후퇴) - if (ftdRow.follow_through_result === 'WATCH_FOLLOW_THROUGH_PENDING' - || ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') { - if (buyState === 'ALLOW_PILOT') { - buyState = 'WATCH'; // PILOT → WATCH (BLOCKED 아님 — 관찰 유지) - buyReasons.push('ftd_' + ftdRow.follow_through_result); - } - } else if (ftdRow.follow_through_result === 'WATCH_TOO_LATE') { - if (buyState === 'ALLOW_PILOT') { - buyState = 'WATCH'; - buyReasons.push('ftd_WATCH_TOO_LATE'); - } - } - - // H7: 가짜 매도 차단 — V1.1: CONFIRMED/WEAKENING만 보류 표기 (AUTO_RELEASED 제외) - if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') { - buyReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd'); - } - - distribution.push(distRow); - alphaLead.push(alphaRow); - followThrough.push(ftRow); - profitPreservation.push(profitRow); - benchmarkRelativeRows.push({ - ticker: h.ticker, - name: h.name || df.name || '', - stock_drawdown_from_high_pct: typeof df.stock_drawdown_from_high_pct === 'number' ? df.stock_drawdown_from_high_pct : null, - excess_drawdown_pctp: typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null, - recovery_ratio_5d: typeof df.recovery_ratio_5d === 'number' ? df.recovery_ratio_5d : null, - recovery_ratio_20d: typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null, - downside_beta: typeof df.downside_beta === 'number' ? df.downside_beta : null, - rs_line_20d_slope: typeof df.rs_line_20d_slope === 'number' ? df.rs_line_20d_slope : null, - rs_line_60d_slope: typeof df.rs_line_60d_slope === 'number' ? df.rs_line_60d_slope : null, - brt_verdict: df.brt_verdict || 'UNKNOWN', - brt_method: df.brt_method || 'DATA_MISSING', - formula_id: 'BENCHMARK_RELATIVE_TIMESERIES_V1' - }); - var indexRelRow = calcIndexRelativeHealthGate_(h, df, kospiRet5d); - indexRelativeHealthRows.push(indexRelRow); - saqgRows.push({ - ticker: h.ticker, - name: h.name || df.name || '', - position_type: h.position_type || 'unknown', - saqg_v1: saqgState, - saqg_penalty: typeof df.saqg_penalty === 'number' ? df.saqg_penalty : null, - saqg_failed_filters: df.saqg_failed_filters || '', - hts_allowed: saqgState === 'ELIGIBLE' || saqgState === 'EXEMPT', - formula_id: 'SATELLITE_ALPHA_QUALITY_GATE_V1' - }); - breakoutQualityGate.push(bqRow); - antiWhipsawGate.push(awRow); - smartCashRaiseV2.push(scrV2); - followThroughConfirm.push(ftdRow); - executionQuality.push(eqRow); - - // ── 진입 신선도 게이트 (ENTRY_FRESHNESS_GATE_V1) ─────────────────────── - var freshnessState = 'FRESH_PILOT'; - var freshnessReasons = []; - if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE' || alphaRow["late_chase_risk_score"] >= 70) { - freshnessState = 'BLOCK_LATE_CHASE'; - freshnessReasons.push('late_chase'); - } else if (ftRow.follow_through_state === 'WAIT_PULLBACK' || ftdRow.follow_through_result === 'WATCH_TOO_LATE' || ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') { - freshnessState = 'PULLBACK_WAIT'; - freshnessReasons.push('follow_through_wait'); - } else if (distRow.pre_distribution_warning === 'EARLY_WARNING') { - freshnessState = 'STALE_REVIEW'; - freshnessReasons.push('pre_distribution_warning'); - } else if (buyState === 'WATCH' || buyState === 'BLOCKED') { - freshnessState = 'WATCH_FRESHNESS'; - freshnessReasons.push('buy_state_' + buyState.toLowerCase()); - } - if (indexRelRow.relative_health_state === 'DECOUPLED' || indexRelRow.relative_health_state === 'OVER_EXTENDED') { - freshnessState = freshnessState === 'FRESH_PILOT' ? 'WATCH_FRESHNESS' : freshnessState; - freshnessReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase()); - if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') { - buyState = 'WATCH'; - buyReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase()); - } - } else if (indexRelRow.relative_health_state === 'UNDERPERFORMING') { - if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') { - buyState = 'WATCH'; - } - freshnessReasons.push('index_relative_underperforming'); - } - entryFreshness.push({ - ticker: h.ticker, - name: h.name || df.name || '', - alpha_lead_score: alphaRow.alpha_lead_score != null ? alphaRow.alpha_lead_score : null, - ["late_chase_risk_score"]: alphaRow["late_chase_risk_score"] != null ? alphaRow["late_chase_risk_score"] : null, - follow_through_state: ftRow.follow_through_state || null, - breakout_quality_gate: bqRow.breakout_quality_gate || null, - pre_distribution_warning: distRow.pre_distribution_warning || 'NONE', - t20_alpha_gate: null, - freshness_state: freshnessState, - reason_codes: freshnessReasons, - formula_id: 'ENTRY_FRESHNESS_GATE_V1' - }); - - // ── 회복 보존 매도 게이트 (SELL_VALUE_PRESERVATION_GATE_V1) ───────────── - var sellPreserveState = 'HOLD'; - var sellPreserveReasons = []; - if (scrV2.smart_cash_raise_route === 'ROUTE_D' || k2Emergency || scrV2.stop_breach_gate === 'BREACH') { - sellPreserveState = 'EMERGENCY_EXIT'; - sellPreserveReasons.push('route_d_or_breach'); - } else if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') { - sellPreserveState = 'REBOUND_CONFIRM_HOLD'; - sellPreserveReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd'); - } else if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) { - sellPreserveState = 'STAGED_REBOUND'; - sellPreserveReasons.push('rebound_wait_qty'); - } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10' - || profitRow.profit_preservation_state === 'PROFIT_LOCK_20' - || profitRow.profit_preservation_state === 'PROFIT_LOCK_30' - || profitRow.profit_preservation_state === 'APEX_TRAILING') { - sellPreserveState = 'PRESERVE_TIERED'; - sellPreserveReasons.push('profit_lock'); - } else if (distRow.anti_distribution_state === 'BLOCK_BUY') { - sellPreserveState = 'TRIM_ONLY'; - sellPreserveReasons.push('distribution_exit'); - } else if (indexRelRow.relative_health_state === 'OVER_EXTENDED' || indexRelRow.relative_health_state === 'DECOUPLED') { - if (style !== 'OVERSOLD_REBOUND_SELL') { - sellPreserveState = 'TRIM_ONLY'; - } - sellPreserveReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase()); - } - sellValuePreservation.push({ - ticker: h.ticker, - name: h.name || df.name || '', - profit_preservation_state: profitRow.profit_preservation_state || 'NORMAL', - cash_raise_group: style, - anti_whipsaw_gate: awRow.anti_whipsaw_gate || null, - immediate_qty: immediateQty > 0 ? immediateQty : null, - rebound_wait_qty: reboundQty > 0 ? reboundQty : null, - auto_trailing_stop: profitRow.auto_trailing_stop || null, - sell_value_preservation_state: sellPreserveState, - reason_codes: sellPreserveReasons, - formula_id: 'SELL_VALUE_PRESERVATION_GATE_V1' - }); - - // K1: 트랜치 엔진 결과 포함 buy_permission_json - buyPermission.push({ - ticker: h.ticker, - name: h.name || df.name || '', - buy_permission_state: buyState, - max_tranche_pct: buyState === 'ALLOW_PILOT' ? 30 : buyState === 'ALLOW_ADD_ON' ? 60 : 0, - tranche_phase: tranchePhase, - current_tranche_allowed_pct: currentTrancheAllowedPct, - next_tranche_condition: nextTrancheCondition, - blocked_reason_codes: buyReasons, - position_type: h.position_type || 'unknown', - brt_verdict: df.brt_verdict || null, - saqg_v1: saqgState, - rs_verdict: df.rs_verdict || null, - composite_verdict: df.composite_verdict || null, - rag_v1: df.rag_v1 || null, - formula_id: 'BUY_PERMISSION_MATRIX_V1+STAGED_ENTRY_TRANCHE_V1' - }); - - // K2: 반등 대기 분할 매도 결과 포함 cash_raise_plan_json - cashRaisePlan.push({ - ticker: h.ticker, - name: h.name || df.name || '', - rank: cand.rank || null, - execution_style: style, - immediate_qty: immediateQty > 0 ? immediateQty : null, - rebound_wait_qty: reboundQty > 0 ? reboundQty : null, - emergency_full_sell: k2Emergency, - max_daily_qty: Math.floor(holdingQty * 0.50), - expected_immediate_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0, - cash_shortfall_min_krw: (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0, - formula_id: 'SMART_CASH_RAISE_PLAN_V1+K2_STAGED_REBOUND_SELL' - }); - - // K2: 반등 트리거 조건부 잔여 수량 - var reboundTriggerPrice = null; - if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) { - // 반등 트리거: prevClose + 0.5×ATR 또는 단순 close + 0.3×ATR - reboundTriggerPrice = atr20 > 0 - ? tickNormalize_((prevClose > 0 ? prevClose : close) + atr20 * 0.5) - : null; - } - reboundTriggers.push({ - ticker: h.ticker, - rebound_trigger_state: (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) - ? 'WAIT_REBOUND_TRIGGER' : 'NOT_APPLICABLE', - trigger_price: reboundTriggerPrice, - rebound_sell_qty: reboundQty > 0 ? reboundQty : null, - emergency_override: k2Emergency, - formula_id: 'REBOUND_SELL_TRIGGER_V1' - }); - - smartSellQty.push({ - ticker: h.ticker, - immediate_sell_qty: immediateQty > 0 ? immediateQty : null, - staged_total_qty: (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) ? sq.sell_qty : null, - rebound_wait_qty: reboundQty > 0 ? reboundQty : null, - emergency_full_sell: k2Emergency, - expected_cash_recovered_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0, - formula_id: 'SELL_QUANTITY_ALLOCATOR_V1+K2_STAGED_REBOUND_SELL' - }); - - // J5: 스타일별 실제 지정가 산출 결과 포함 limit_price_policy_json - limitPolicy.push({ - ticker: h.ticker, - execution_style: style, - sell_limit_price: normalizedSellPrice, - buy_limit_price: normalizedBuyPrice, - hts_limit_price: htsLimitPrice, - tick_status: htsLimitPrice ? 'TICK_OK' : 'NO_EXECUTION_PRICE', - sell_price_basis: style === 'URGENT_LIQUIDITY_TRIM' ? 'min(close,prevClose×0.998)' - : style === 'OVERSOLD_REBOUND_SELL' ? 'close_no_undercut' - : style === 'DISTRIBUTION_EXIT' ? 'close-0.25×ATR20' - : style === 'PROFIT_PROTECT_TRIM' ? 'ratchet_stop_or_close×0.999' - : 'close', - formula_id: 'LIMIT_PRICE_POLICY_V1' - }); - }); - - // K3: 국면·섹터 연계 H2 동적 우선순위 - var regimeAdjPriority = calcRegimeAdjustedSellPriority_( - h2.candidates, regime, dfMap, kospiRet5d - ); - - // ── [2026-05-21_CLA_HARNESS_V1] SATELLITE_FAILURE_GATE_V1 ──────────────────── - var satelliteRowsForSFG = []; - holdings.forEach(function(h) { - if (h.position_type !== 'core') { - var df = dfMap[h.ticker] || {}; - satelliteRowsForSFG.push({ - composite_verdict: df.composite_verdict || null, - rs_verdict: df.rs_verdict || null, - ret20d: typeof df.ret20d === 'number' ? df.ret20d : null, - excess_ret_10d: typeof df.excess_ret_10d === 'number' ? df.excess_ret_10d : null - }); - } - }); - var sfgResult = calcSatelliteFailureGate_(satelliteRowsForSFG); - var sapgResult = calcSatelliteAggregatePnlGate_(holdings); - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - cashCreationLockRows.push(calcCashCreationPurposeLockRow_(h, df, sfgResult)); - }); - - - // ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ────────────────────────── - var aewRows = calcAlphaEvaluationWindow_(holdings, dfMap); - - // SFG-1: TRIGGERED 시 위성 BUY 전면 차단 (post-processing) - if (sfgResult.sfg_v1 === 'TRIGGERED' || sapgResult.sapg_status === 'SAPG_CRITICAL') { - buyPermission.forEach(function(bp) { - var h = holdings.find(function(x) { return x.ticker === bp.ticker; }); - if (h && h.position_type !== 'core') { - if (bp.buy_permission_state !== 'BLOCKED') { - bp.buy_permission_state = 'BLOCKED'; - bp.blocked_reason_codes = (bp.blocked_reason_codes || []).concat([ - sfgResult.sfg_v1 === 'TRIGGERED' ? 'sfg_v1_TRIGGERED' : 'sapg_CRITICAL' - ]); - } - } - }); - } - - // ── [QEH010] WHIPSAW V1.1 → order_blueprint validation_status 소급 차단 ── - // V1.1: WHIPSAW_CONFIRMED(hold_3d) + WHIPSAW_WEAKENING(hold_1d) 차단 - // WHIPSAW_AUTO_RELEASED(hold_0d)은 자동 해제 — 차단 안 함 - var whipsawTickers_ = {}; - antiWhipsawGate.forEach(function(aw) { - if (aw.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || aw.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') { - whipsawTickers_[aw.ticker] = aw.anti_whipsaw_hold_days || 1; - } - }); - var SELL_ORDER_TYPES_ = { SELL: 1, TRIM: 1, EXIT_100: 1, EXIT_FULL: 1 }; - orderBlueprint.forEach(function(bp) { - var wHoldDays = whipsawTickers_[bp.ticker]; - if (wHoldDays - && SELL_ORDER_TYPES_[bp.order_type] - && bp.validation_status === 'PASS') { - bp.validation_status = 'BLOCKED'; - bp.rationale_code = 'WHIPSAW_V1_1:hold_' + wHoldDays + 'd'; - } - }); - - // ── [2026-05-20_HARNESS_V5] V5 포트폴리오 레벨 집계 - var smartCashRaiseRoute = 'NO_ACTION'; - for (var sci = 0; sci < smartCashRaiseV2.length; sci++) { - if (smartCashRaiseV2[sci].smart_cash_raise_route !== 'NO_ACTION') { - smartCashRaiseRoute = smartCashRaiseV2[sci].smart_cash_raise_route; - break; // 첫 번째 실제 경로를 포트폴리오 레벨 대표 경로로 설정 - } - } - - return { - alpha_lead_json: alphaLead, - follow_through_json: followThrough, - distribution_risk_json: distribution, - profit_preservation_json: profitPreservation, - entry_freshness_json: entryFreshness, - cash_raise_plan_json: cashRaisePlan, - rebound_sell_trigger_json: reboundTriggers, - smart_sell_quantities_json: smartSellQty, - sell_value_preservation_json: sellValuePreservation, - execution_quality_json: executionQuality, - buy_permission_json: buyPermission, - limit_price_policy_json: limitPolicy, - regime_adjusted_sell_priority_json: regimeAdjPriority, - benchmark_relative_timeseries_json: benchmarkRelativeRows, - index_relative_health_json: indexRelativeHealthRows, - saqg_json: saqgRows, - cash_creation_purpose_lock_json: cashCreationLockRows, - // ── [2026-05-20_HARNESS_V5] 신규 V5 출력 ────────────────────────────── - breakout_quality_gate_json: breakoutQualityGate, - anti_whipsaw_gate_json: antiWhipsawGate, - smart_cash_raise_json: smartCashRaiseV2, - smart_cash_raise_route: smartCashRaiseRoute, - follow_through_confirm_json: followThroughConfirm, - breakout_quality_gate_lock: true, - anti_whipsaw_gate_lock: true, - follow_through_lock: true, - follow_through_confirm_lock: true, - apex_block_count: blockCount, - // ── [2026-05-21_CLA_HARNESS_V1] 신규 하네스 출력 ────────────────────────── - satellite_failure_gate_json: sfgResult, - sapg_json: sapgResult, - // ── [2026-05-21_AEW_V1] ───────────────────────────────────────────────────── - alpha_evaluation_window_json: aewRows, - sfg_v1_lock: true - }; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// [2026-05-23_PROPOSAL46] PA1~PA5 신규 하네스 calc 함수 -// spec/13b_harness_formulas.yaml: PA1 PREDICTIVE_ALPHA_ENGINE_V1 -// PA2 ANTI_LATE_ENTRY_GATE_V2 -// PA3 CASH_PRESERVATION_SELL_ENGINE_V2 -// PA4 MACRO_EVENT_SYNCHRONIZER_V1 -// PA5 CONSISTENCY_VALIDATOR_V2 -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * [PROPOSAL47_B6 / PROPOSAL48_B6_FALLBACK] prediction_accuracy_rate 읽기. - * 우선순위: ① monthly_history.prediction_accuracy_rate - * ② settings.prediction_accuracy_rate - * ③ 상수 기본값 48.48 (운영 중 실측값으로 교체 예정) - * 값이 0~1 범위면 *100 변환, 0~100 범위면 그대로 사용. - */ -var PREDICTION_ACCURACY_RATE_DEFAULT_ = 48.48; // 2026-05-23 실측, 매월 갱신 - -function getPredictionAccuracyRate_() { - function parseAccuracy_(val) { - if (val === '' || val === null || val === undefined) return null; - var num = typeof val === 'number' ? val : parseFloat(String(val)); - if (isNaN(num)) return null; - return num <= 1 ? Math.round(num * 1000) / 10 : num; - } - - try { - var ss = getSpreadsheet_(); - - // ① monthly_history 시트 - var sh = ss.getSheetByName('monthly_history'); - if (sh) { - var mhData = sh.getDataRange().getValues(); - if (mhData && mhData.length >= 2) { - var header = mhData[0] || []; - var colIdx = -1; - for (var i = 0; i < header.length; i++) { - if (String(header[i]).trim().toLowerCase() === 'prediction_accuracy_rate') { - colIdx = i; break; - } - } - if (colIdx >= 0) { - for (var r = mhData.length - 1; r >= 1; r--) { - var parsed = parseAccuracy_(mhData[r][colIdx]); - if (parsed !== null) return parsed; - } - } - } - } - - // ② settings 시트 (Key-Value 구조) - var settingsSh = ss.getSheetByName('settings'); - if (settingsSh) { - var sData = settingsSh.getDataRange().getValues(); - for (var si = 0; si < sData.length; si++) { - var key = String(sData[si][0] || '').trim().toLowerCase(); - if (key === 'prediction_accuracy_rate') { - var parsed2 = parseAccuracy_(sData[si][1]); - if (parsed2 !== null) return parsed2; - } - } - } - } catch(e) { /* fallback to default */ } - - // ③ 상수 기본값 - return PREDICTION_ACCURACY_RATE_DEFAULT_; -} - - -/** - * [PA1 V1.2] 팩터 가중치 오버라이드 읽�� - * settings 시트의 pa1_w_ 키-값을 읽어 기본값과 병합. - * 오버라이드가 존재하면 _source='DYNAMIC', 없으면 'STATIC'. - */ -function getPa1WeightOverrides_() { - var defaults = { - pullback_entry: 20, flow_strong: 20, rs_leader: 15, - volume_confirm: 15, rsi_healthy: 15, brt_leader: 15, - chase_risk: 25, distribution: 20, rsi_overbought: 20, - foreign_sell: 15, usd_krw_weak: 10, stale_position: 10, - _source: 'STATIC' - }; - try { - var ss = getSpreadsheet_(); - var sh = ss.getSheetByName('settings'); - if (!sh) return defaults; - var data = sh.getDataRange().getValues(); - var overrides = {}; - for (var i = 0; i < data.length; i++) { - var key = String(data[i][0] || '').trim(); - if (key.indexOf('pa1_w_') !== 0) continue; - var factorName = key.slice(6); // 'pa1_w_' = 6자 - var val = parseFloat(String(data[i][1] || '')); - if (!isNaN(val) && val >= 0 && val <= 50) overrides[factorName] = val; - } - if (Object.keys(overrides).length === 0) return defaults; - var merged = {}; - for (var k in defaults) merged[k] = defaults[k]; - for (var k in overrides) merged[k] = overrides[k]; - merged._source = 'DYNAMIC'; - return merged; - } catch(e) { - return defaults; - } -} - - -/** - * [PA1 V1.3] T+5 피드백 기록 - * STRONG_BUY_SIGNAL / EXIT_SIGNAL / TRIM_SIGNAL 예측 → pa1_feedback 시트 기록. - * V1.3: TRIM_SIGNAL 추가, signal_type 컬럼 추가 (BUY/SELL 분리 정확도 추적) - * evaluatePa1FeedbackBatch_() 주간 배치에서 결과를 평가. - */ -function recordPa1FeedbackEntry_(paeRows, dfMap) { - if (!paeRows || !paeRows.length) return; - // [V1.3] TRIM_SIGNAL 추가 - var RECORD_VERDICTS = { STRONG_BUY_SIGNAL: 1, EXIT_SIGNAL: 1, TRIM_SIGNAL: 1 }; - var toRecord = paeRows.filter(function(pa) { return !!RECORD_VERDICTS[pa.synthesis_verdict]; }); - if (!toRecord.length) return; - try { - var ss = getSpreadsheet_(); - var sh = ss.getSheetByName('pa1_feedback'); - if (!sh) { - sh = ss.insertSheet('pa1_feedback'); - sh.appendRow(['date','ticker','synthesis_verdict','direction_confidence', - 'close_at_record','signal_type','t5_evaluated','t5_return_pct','t5_correct']); - } else { - // [V1.3] signal_type 컬럼 없으면 헤더 확인 — 없어도 appendRow는 동작함 - } - var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd'); - toRecord.forEach(function(pa) { - var df = dfMap[pa.ticker] || {}; - var closeNow = df.close || 0; - var signalType = (pa.synthesis_verdict === 'STRONG_BUY_SIGNAL') ? 'BUY' : 'SELL'; - sh.appendRow([today, pa.ticker, pa.synthesis_verdict, - pa.direction_confidence, closeNow, signalType, false, '', '']); - }); - } catch(e) { - Logger.log('[PA1_FEEDBACK] recordPa1FeedbackEntry_ error: ' + e.message); - } -} - - -/** - * [PA1 V1.3] 매도 PASS 정확도 조회 - * pa1_feedback 시트에서 signal_type=SELL + t5_evaluated=true 행의 정확도 산출. - * @return {number|null} sell_pass_accuracy_rate (0~100) or null if insufficient data - */ -function getSellPassAccuracyRate_() { - try { - var ss = getSpreadsheet_(); - var fbSh = ss.getSheetByName('pa1_feedback'); - if (!fbSh) return null; - var data = fbSh.getDataRange().getValues(); - if (data.length < 2) return null; - var header = data[0]; - var COL = {}; - header.forEach(function(h, i) { COL[String(h)] = i; }); - if (COL['signal_type'] == null || COL['t5_evaluated'] == null || COL['t5_correct'] == null) return null; - var sellRows = data.slice(1).filter(function(row) { - return String(row[COL['signal_type']] || '').toUpperCase() === 'SELL' - && (row[COL['t5_evaluated']] === true || String(row[COL['t5_evaluated']]).toUpperCase() === 'TRUE'); - }); - if (sellRows.length < 5) return null; - var correct = sellRows.filter(function(row) { - return row[COL['t5_correct']] === true || String(row[COL['t5_correct']]).toUpperCase() === 'TRUE'; - }).length; - return Math.round(correct / sellRows.length * 1000) / 10; - } catch(e) { - Logger.log('[PA1_V1.3] getSellPassAccuracyRate_ error: ' + e.message); - return null; - } -} - - -/** - * [PA1 V1.2] 주간 배치 — T+5(7캘린더일) 결과 평가 + prediction_accuracy_rate 갱신 - * GAS 트리거에 주 1회 등록해 사용 (매주 월요일 권장). - */ -function evaluatePa1FeedbackBatch_() { - try { - var ss = getSpreadsheet_(); - var fbSh = ss.getSheetByName('pa1_feedback'); - if (!fbSh) { Logger.log('[PA1_V1.2] pa1_feedback 시트 없음'); return; } - - var data = fbSh.getDataRange().getValues(); - if (data.length < 2) return; - var header = data[0]; - var COL = {}; - header.forEach(function(h, i) { COL[String(h)] = i; }); - var reqCols = ['date','ticker','synthesis_verdict','close_at_record','t5_evaluated','t5_return_pct','t5_correct']; - for (var ci = 0; ci < reqCols.length; ci++) { - if (COL[reqCols[ci]] == null) { Logger.log('[PA1_V1.2] 컬럼 누락: ' + reqCols[ci]); return; } - } - - // 현재 종가 맵 (data_feed 시트) - var priceMap = {}; - var dfSheet = ss.getSheetByName('data_feed'); - if (dfSheet) { - var dfData = dfSheet.getDataRange().getValues(); - if (dfData.length > 1) { - var dfHeader = dfData[0]; - var tCol = dfHeader.indexOf('Ticker'); - var cCol = dfHeader.indexOf('Close'); - if (tCol >= 0 && cCol >= 0) { - for (var ri = 1; ri < dfData.length; ri++) { - var t = String(dfData[ri][tCol] || '').trim(); - var c = parseFloat(String(dfData[ri][cCol] || '')); - if (t && !isNaN(c) && c > 0) priceMap[t] = c; - } - } - } - } - - var todayMs = new Date().getTime(); - var evalThisRun = 0; - for (var i = 1; i < data.length; i++) { - var row = data[i]; - var evaled = row[COL['t5_evaluated']]; - if (evaled === true || String(evaled).toUpperCase() === 'TRUE') continue; - var daysDiff = (todayMs - new Date(row[COL['date']]).getTime()) / 86400000; - if (daysDiff < 7) continue; - var ticker = String(row[COL['ticker']] || ''); - var verdict = String(row[COL['synthesis_verdict']] || ''); - var closeAt = parseFloat(String(row[COL['close_at_record']] || '')); - var closeNow = priceMap[ticker] || 0; - if (closeAt <= 0 || closeNow <= 0) continue; - var t5Ret = Math.round((closeNow - closeAt) / closeAt * 10000) / 100; - var isCorrect = (verdict === 'STRONG_BUY_SIGNAL') ? (t5Ret > 0) : (t5Ret < 0); - fbSh.getRange(i + 1, COL['t5_evaluated'] + 1).setValue(true); - fbSh.getRange(i + 1, COL['t5_return_pct'] + 1).setValue(t5Ret); - fbSh.getRange(i + 1, COL['t5_correct'] + 1).setValue(isCorrect ? 'CORRECT' : 'WRONG'); - evalThisRun++; - } - - // prediction_accuracy_rate 갱신 (최소 10건 평가 완료 후) - var freshData = fbSh.getDataRange().getValues(); - var allEval = 0, allCorrect = 0; - for (var j = 1; j < freshData.length; j++) { - var ev = freshData[j][COL['t5_evaluated']]; - if (ev !== true && String(ev).toUpperCase() !== 'TRUE') continue; - allEval++; - if (String(freshData[j][COL['t5_correct']] || '') === 'CORRECT') allCorrect++; - } - if (allEval >= 10) { - var newRate = Math.round(allCorrect / allEval * 1000) / 10; - var settingSh = ss.getSheetByName('settings'); - if (settingSh) { - var sData = settingSh.getDataRange().getValues(); - var updated = false; - for (var si = 0; si < sData.length; si++) { - if (String(sData[si][0] || '').trim().toLowerCase() === 'prediction_accuracy_rate') { - settingSh.getRange(si + 1, 2).setValue(newRate); - updated = true; - break; - } - } - if (!updated) settingSh.appendRow(['prediction_accuracy_rate', newRate]); - Logger.log('[PA1_V1.2] prediction_accuracy_rate=' + newRate + '% (' + allCorrect + '/' + allEval + ')'); - } - } - Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 완료: 이번 평가=' + evalThisRun + '건'); - - // [PA1 V1.2] 정확도 기반 가중치 자동 조정 (평가 완료 후) - if (allEval >= 10) { - var accuracy7d = allCorrect / allEval; - adjustPaeWeights_(); - } - } catch(e) { - Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 오류: ' + e.message); - } -} - - -/** - * [PA1 V1.2] adjustPaeWeights_ - * T+5 예측 정확도(7일) 기반으로 thesis/antithesis 가중치 자동 조정. - * 조정값을 settings 시트에 pa1_w_ 형태로 기록 → 다음 실행 시 반영. - */ -function adjustPaeWeights_() { - try { - // 현재 precision 읽기 - var accRate = getPredictionAccuracyRate_(); - if (accRate === null) return; // 데이터 부족 시 조정 안 함 - var accuracy = accRate / 100; // 0~1 범위로 변환 - - var ss = getSpreadsheet_(); - var settingSh = ss.getSheetByName('settings'); - if (!settingSh) return; - - var sData = settingSh.getDataRange().getValues(); - var currentWeights = {}; - var rowIndex = {}; - sData.forEach(function(row, i) { - var key = String(row[0] || '').trim().toLowerCase(); - if (key.indexOf('pa1_w_') === 0) { - currentWeights[key] = parseFloat(String(row[1] || '')) || null; - rowIndex[key] = i + 1; // 1-based - } - }); - - // 기본 thesis/antithesis 총합 (12개 팩터 기본 가중치 합) - var DEFAULT_THESIS_TOTAL = 100; // 20+20+15+15+15+15 - var DEFAULT_ANTI_TOTAL = 100; // 25+20+20+15+10+10 - - // 조정 방향 결정 - var adjustThesis = 0; - var adjustAnti = 0; - if (accuracy < 0.55) { - // 정확도 낮음 → antithesis 강화 (+5% of base) - adjustThesis = -5; - adjustAnti = +5; - } else if (accuracy > 0.75) { - // 정확도 높음 → thesis 강화 (+3% of base) - adjustThesis = +3; - adjustAnti = 0; - } else { - Logger.log('[PA1_V1.2] adjustPaeWeights_: 정확도 정상범위(' + Math.round(accuracy*100) + '%) — 조정 불필요'); - return; - } - - // thesis 팩터 가중치 조정 (각 비례 분배) - var thesisFactors = ['pullback_entry','flow_strong','rs_leader','volume_confirm','rsi_healthy','brt_leader']; - var thesisDefaults = { pullback_entry: 20, flow_strong: 20, rs_leader: 15, volume_confirm: 15, rsi_healthy: 15, brt_leader: 15 }; - thesisFactors.forEach(function(f) { - var key = 'pa1_w_' + f; - var baseW = thesisDefaults[f] || 0; - var currentW = currentWeights[key] != null ? currentWeights[key] : baseW; - var delta = Math.round(baseW / DEFAULT_THESIS_TOTAL * adjustThesis); - var newW = Math.max(5, Math.min(35, currentW + delta)); - if (rowIndex[key]) { - settingSh.getRange(rowIndex[key], 2).setValue(newW); - } else { - settingSh.appendRow([key, newW]); - } - }); - - // antithesis 팩터 가중치 조정 - var antiFactors = ['chase_risk','distribution','rsi_overbought','foreign_sell','usd_krw_weak','stale_position']; - var antiDefaults = { chase_risk: 25, distribution: 20, rsi_overbought: 20, foreign_sell: 15, usd_krw_weak: 10, stale_position: 10 }; - antiFactors.forEach(function(f) { - var key = 'pa1_w_' + f; - var baseW = antiDefaults[f] || 0; - var currentW = currentWeights[key] != null ? currentWeights[key] : baseW; - var delta = Math.round(baseW / DEFAULT_ANTI_TOTAL * adjustAnti); - var newW = Math.max(5, Math.min(40, currentW + delta)); - if (rowIndex[key]) { - settingSh.getRange(rowIndex[key], 2).setValue(newW); - } else { - settingSh.appendRow([key, newW]); - } - }); - - Logger.log('[PA1_V1.2] adjustPaeWeights_ 완료: accuracy=' + Math.round(accuracy*100) + '% adjustThesis=' + adjustThesis + ' adjustAnti=' + adjustAnti); - } catch(e) { - Logger.log('[PA1_V1.2] adjustPaeWeights_ 오류: ' + e.message); - } -} - -/** - * updatePa1WeightsManual_ - * PA1 팩터 가중치를 Work-1 승인값으로 settings 시트에 직접 기록. - * 근거: 기존 8.0x 획일 비율(thesis=30, anti=240) → 2.6x 차별화(thesis=70, anti=185) - * 효과: 모든 종목이 EXIT(-83~-95)로 획일화됐던 synthesis가 종목별 차별화됨 - * (예: 000270 기아 +20 BULLISH / 005930 삼성전자 -18 BEARISH 등) - * 사용법: GAS 에디터 → updatePa1WeightsManual_ 선택 → 실행 - */ -function updatePa1WeightsManual_() { - try { - var ss = SpreadsheetApp.getActiveSpreadsheet(); - var settingSh = ss.getSheetByName(SETTINGS_SHEET_NAME); - if (!settingSh) { - Logger.log('[updatePa1WeightsManual_] settings 시트를 찾을 수 없음'); - return; - } - - // Work-1 승인 PA1 가중치 (thesis 70pt, antithesis 185pt, ratio=2.6x) - var APPROVED_WEIGHTS = { - // Thesis 팩터 (개별종목 차별화 강화): 5→10~15 - pa1_w_pullback_entry: 15, // 눌림목 진입 — 핵심 타이밍 - pa1_w_flow_strong: 15, // 수급 강세 - pa1_w_rs_leader: 10, // 상대강도 선도 - pa1_w_volume_confirm: 10, // 거래량 확인 - pa1_w_rsi_healthy: 10, // RSI 여력 - pa1_w_brt_leader: 10, // BRT 선도 - // Antithesis 팩터 (핵심만 유지, 획일화 해소): 일부 완화 - pa1_w_chase_risk: 40, // 뒷박 위험 — 유지 - pa1_w_distribution: 40, // 분배 신호 — 유지 - pa1_w_rsi_overbought: 40, // RSI 과열 — 유지 - pa1_w_foreign_sell: 30, // 외인 매도 — 완화 (단기 노이즈) - pa1_w_usd_krw_weak: 15, // 환율 약세 — 대폭 완화 (전 종목 동일 페널티 방지) - pa1_w_stale_position: 20 // 장기보유 페널티 — 완화 - }; - - // settings 시트에서 기존 pa1_w_* 행 인덱스 수집 - var data = settingSh.getDataRange().getValues(); - var rowIndex = {}; - data.forEach(function(row, i) { - var key = String(row[0] || '').trim().toLowerCase(); - if (key.indexOf('pa1_w_') === 0) { - rowIndex[key] = i + 1; // 1-based - } - }); - - // 값 쓰기 (존재하면 업데이트, 없으면 추가) - var updated = []; var added = []; - Object.keys(APPROVED_WEIGHTS).forEach(function(key) { - var val = APPROVED_WEIGHTS[key]; - if (rowIndex[key]) { - settingSh.getRange(rowIndex[key], 2).setValue(val); - updated.push(key + '=' + val); - } else { - settingSh.appendRow([key, val]); - added.push(key + '=' + val); - } - }); - - var thesisTotal = 15+15+10+10+10+10; - var antiTotal = 40+40+40+30+15+20; - Logger.log('[updatePa1WeightsManual_] 완료'); - Logger.log(' 업데이트: ' + updated.join(', ')); - Logger.log(' 신규 추가: ' + (added.length ? added.join(', ') : '없음')); - Logger.log(' thesis합=' + thesisTotal + 'pt antithesis합=' + antiTotal + 'pt ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x'); - SpreadsheetApp.getUi().alert( - 'PA1 가중치 업데이트 완료\n' + - 'thesis합=' + thesisTotal + 'pt / antithesis합=' + antiTotal + 'pt (ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x)\n' + - '업데이트: ' + updated.length + '개 / 추가: ' + added.length + '개\n\n' + - '다음 runDataFeed 실행 시 새 가중치가 PA1 계산에 반영됩니다.' - ); - } catch(e) { - Logger.log('[updatePa1WeightsManual_] 오류: ' + e.message); - SpreadsheetApp.getUi().alert('오류: ' + e.message); - } -} - - -/** - * PA4 — MACRO_EVENT_SYNCHRONIZER_V1 - * 외국인 순매도 연속일·USD/KRW·FOMC·VIX 등 거시 변수를 macro_risk_score로 환산. - * heat_gate_adj(-3/-1/0/+1) 및 mega_sell_alert 산출. - * @param {Object} macroJson getMacroJson() 반환값 - * @param {Array} eventRows getEventRiskJson().events (DaysLeft, Type 컬럼) - */ -function calcMacroEventSynchronizerV1_(macroJson, eventRows) { - return calcMacroEventSynchronizerV1Impl_(macroJson, eventRows); -} - -/** - * PA1 — PREDICTIVE_ALPHA_ENGINE_V1 - * 正(thesis) + 反(antithesis) = 合(direction_confidence) 3계층 점수. - * synthesis_verdict=BEARISH(EXIT/TRIM) → BUY 차단 근거. - * @param {Array} holdings - * @param {Object} dfMap - * @param {Object} macroJson getMacroJson() 반환값 - * @param {Object} mesResult calcMacroEventSynchronizerV1_ 반환값 - */ -function calcPredictiveAlphaEngineV1_(holdings, dfMap, macroJson, mesResult, weightOverrides) { - return calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides); -} - - -/** - * PA2 — ANTI_LATE_ENTRY_GATE_V2 - * 3중 AND 게이트: velocity_1d / velocity_5d / distribution_weighted_sum. - * ANTI_CHASING_VELOCITY_V1을 완전 대체. - * @param {Array} holdings - * @param {Object} dfMap - */ -function calcAntiLateEntryGateV2_(holdings, dfMap) { - return calcAntiLateEntryGateV2Impl_(holdings, dfMap); -} - - -/** - * PA3 — CASH_PRESERVATION_SELL_ENGINE_V2 - * K2(분할) + C1(폭포수) + C2(타이밍)를 통합. 매도 스타일 결정 + value_preservation_score. - * h3.sellQty에 수량이 있는 종목만 처리. - * @param {Array} holdings - * @param {Object} dfMap - * @param {Object} cashShortfallInfo calcCashShortfallHarness_ 반환값 - * @param {Object} h3 calcQuantities_ 반환값 (.sellQty 배열) - */ -function calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3) { - var shortfallKrw = (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0; - - var sellQtyMap = {}; - ((h3 && h3.sellQty) || []).forEach(function(sq) { - if (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) { - sellQtyMap[sq.ticker] = Math.floor(sq.sell_qty); - } - }); - - var rows = []; - - holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - var baseQty = sellQtyMap[h.ticker] || 0; - - if (baseQty <= 0 && shortfallKrw <= 0) return; - - var close = h.close || df.close || 0; - var prevClose = df.prevClose || close; - var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : 50; - var atr20 = typeof df.atr20 === 'number' ? df.atr20 : (close * 0.02); - var stopPrice = h.stopPrice || 0; - var frg5d = typeof df.frg5d === 'number' ? df.frg5d : 0; - var inst5d = typeof df.inst5d === 'number' ? df.inst5d : 0; - var volume = typeof df.volume === 'number' ? df.volume : 0; - var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0; - - // 현금 부족 시 baseQty 추정 (h3 미포함 종목) - if (baseQty <= 0 && shortfallKrw > 0 && close > 0) { - baseQty = Math.min(Math.floor(shortfallKrw / close), h.holdingQty || 0); - } - if (baseQty <= 0) return; - - // distribution weighted_sum (inline) - var distWS = 0; - if (frg5d < 0) distWS += 2.0; - if (inst5d < 0) distWS += 2.0; - if (avgVol5d > 0 && volume > avgVol5d * 1.3) distWS += 1.5; - if (prevClose > 0 && close < prevClose) distWS += 1.5; - if (rsi14 > 70) distWS += 1.0; - if (df.acGate === 'BLOCK') distWS += 1.0; - - var emergencyFullSell = h.stopBreach === true; - - // ── execution_style 결정 ───────────────────────────────────────────────── - var execStyle; - if (emergencyFullSell) execStyle = 'EMERGENCY_FULL_EXIT'; - else if (rsi14 < 30) execStyle = 'OVERSOLD_REBOUND_SELL'; - else execStyle = 'STAGED_WATERFALL'; - - // ── 수량 산출 ──────────────────────────────────────────────────────────── - var immediateQty = 0, reboundWaitQty = 0, reboundTriggerPrice = 0, reboundDeadlineDays = 0; - - if (execStyle === 'OVERSOLD_REBOUND_SELL') { - immediateQty = Math.floor(baseQty * 0.50); - reboundWaitQty = baseQty - immediateQty; - // TICK_NORMALIZER_V1 간소화: 10원 단위 반올림 - reboundTriggerPrice = Math.round((prevClose + 0.5 * atr20) / 10) * 10; - reboundDeadlineDays = 3; - } else if (execStyle === 'EMERGENCY_FULL_EXIT') { - immediateQty = baseQty; - reboundWaitQty = 0; - reboundTriggerPrice = 0; - reboundDeadlineDays = 0; - } else { - immediateQty = Math.floor(baseQty * 0.50); - reboundWaitQty = baseQty - immediateQty; - reboundTriggerPrice = prevClose > 0 ? prevClose : close; - reboundDeadlineDays = 5; - } - - // ── rebound_scenario ───────────────────────────────────────────────────── - var limitPrice = prevClose > 0 ? prevClose : close; - var immediateKrw = immediateQty * limitPrice; - var reboundUpsideKrw = reboundWaitQty * (reboundTriggerPrice > 0 ? reboundTriggerPrice : limitPrice); - var downsideRiskKrw = reboundWaitQty * (stopPrice > 0 ? stopPrice : close * 0.92); - var rrNum = reboundUpsideKrw - immediateKrw; - var rrDen = Math.max(1, immediateKrw - downsideRiskKrw); - var riskRewardRatio = round2_(rrNum / rrDen); - - // ── value_preservation_score ───────────────────────────────────────────── - var vpScore = 100; - if (immediateQty >= baseQty && rsi14 < 30) vpScore -= 30; // full_sell_oversold - if (distWS >= 3.0) vpScore -= 15; // distribution_high - if (reboundWaitQty > 0) vpScore += 15; // rebound_wait_exists - if (reboundTriggerPrice > 0 && limitPrice > 0 - && reboundTriggerPrice <= limitPrice * 1.03) vpScore += 10; // tight_trigger - vpScore = Math.max(0, Math.min(100, Math.round(vpScore))); - - rows.push({ - ticker: h.ticker, - name: h.name || df.name || '', - execution_style: execStyle, - base_qty: baseQty, - immediate_qty: immediateQty, - rebound_wait_qty: reboundWaitQty, - rebound_trigger_price: reboundTriggerPrice, - rebound_deadline_days: reboundDeadlineDays, - risk_reward_ratio: riskRewardRatio, - value_preservation_score: vpScore, - immediate_sell_krw: Math.round(immediateKrw), - rebound_upside_krw: Math.round(reboundUpsideKrw), - emergency_full_sell_flag: emergencyFullSell, - sell_value_damage_warning: vpScore < 50, - dist_weighted_sum: Math.round(distWS * 10) / 10, - formula_id: 'CASH_PRESERVATION_SELL_ENGINE_V2' - }); - }); - - return rows; -} - -/** - * PA5 — CONSISTENCY_VALIDATOR_V2 - * 12개 논리 검증 항목으로 hApex 일관성 점검. score < 90 → cv_verdict=BLOCK. - * Sprint C 마지막에 실행 — 이전 PA1~PA4 결과까지 모두 포함한 hApex 검증. - * @param {Object} hApex - * @param {Object} asResult - * @param {Object} cashFloorInfo - * @param {string} capturedAtIso - * @param {Date} now - */ -function calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now) { - return calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now); -} - - -/** - * [PROPOSAL47_A1] WATCH_BREAKOUT_REALTIME_GATE_V1 - * REVIEW / EXIT 라이프사이클 단계의 보유 종목 중 velocity_1d >= 2.0% 급등 탐지. - * 감시 중 급등 누락(49건 근본 원인) 해결 — 당일 급등 감지 시 후보 승격 검토 신호 생성. - * anti_late_entry_grade가 F(BLOCK)인 경우 승격 제외 (추격 매수 방지). - * - * @param {Array} holdings asResult.holdings - * @param {Object} dfMap 종목별 데이터 피드 - * @param {Array} slgRows satellite_lifecycle_gate_json (lifecycle_stage 포함) - * @param {Array} aleRows anti_late_entry_json (entry_grade 포함, F면 제외) - * @returns {Array} watch_breakout_candidates_json - */ -function calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows) { - var VELOCITY_THRESHOLD = 2.0; - var REVIEW_STAGES = ['REVIEW', 'EXIT']; - - var slgMap = {}; - (slgRows || []).forEach(function(r) { - slgMap[String(r.ticker || '')] = String(r.lifecycle_stage || ''); - }); - var aleMap = {}; - (aleRows || []).forEach(function(r) { - aleMap[String(r.ticker || '')] = r; - }); - - var results = []; - (holdings || []).forEach(function(h) { - var ticker = String(h.ticker || ''); - var stage = slgMap[ticker] || ''; - if (REVIEW_STAGES.indexOf(stage) < 0) return; - - var df = dfMap[ticker] || {}; - var close = Number(df.close || h.close || 0); - var prevClose = Number(df.prevClose || 0); - if (close <= 0 || prevClose <= 0) return; - - var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100; - if (velocity1d < VELOCITY_THRESHOLD) return; - - var aleEntry = aleMap[ticker] || {}; - var aleGrade = aleEntry.entry_grade || 'B'; - if (aleGrade === 'F') return; // 추격매수 방지: anti_late_entry_grade F 제외 - - results.push({ - ticker: ticker, - name: h.name || df.name || '', - lifecycle_stage: stage, - velocity_1d: velocity1d, - promotion_signal: 'WATCH_BREAKOUT', - anti_late_entry_grade: aleGrade, - formula_id: 'WATCH_BREAKOUT_REALTIME_GATE_V1' - }); - }); - - return results; -} - -/** - * [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1 - * 매도 압박(tier=1/2) 종목이 당일 +3% 이상 급반등 시 REENTRY_CANDIDATE 마킹. - * 9건 "매도 신호 후 반등" 패턴 처리. 매도 실행 전 재검토 신호 제공. - * - * @param {Array} sellCandidates hApex.sell_candidates_json (tier, ticker, action 포함) - * @param {Object} dfMap 종목별 데이터 피드 - * @param {Array} holdings asResult.holdings - * @returns {Array} anti_whipsaw_reentry_json - */ -function calcAntiWhipsawReentryGateV1_(sellCandidates, dfMap, holdings) { - var REENTRY_VELOCITY_THRESHOLD = 3.0; // 재진입 급반등 기준: +3% - var WHIPSAW_TIERS = [1, 2]; // 즉시·단계 매도 압박 대상 - - var results = []; - (sellCandidates || []).forEach(function(cand) { - var tier = typeof cand.tier === 'number' ? cand.tier : parseInt(cand.tier) || 99; - if (WHIPSAW_TIERS.indexOf(tier) < 0) return; - - var ticker = cand.ticker; - var df = dfMap[ticker] || {}; - var h = (holdings || []).find(function(x) { return x.ticker === ticker; }) || {}; - var close = h.close || df.close || 0; - var prevClose = df.prevClose || 0; - - if (close <= 0 || prevClose <= 0) return; - var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100; - if (velocity1d < REENTRY_VELOCITY_THRESHOLD) return; - - var profitPct = h.avgCost > 0 - ? Math.round((close - h.avgCost) / h.avgCost * 1000) / 10 - : null; - var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null; - - // 재진입 등급: A(rsi<50 + rs_leader), B(rsi<60), C(기본) - var reentryGrade = 'C'; - if (rsi14 !== null && rsi14 < 50 && df.rs_verdict === 'LEADER') reentryGrade = 'A'; - else if (rsi14 !== null && rsi14 < 60) reentryGrade = 'B'; - - results.push({ - ticker: ticker, - name: h.name || df.name || '', - sell_tier: tier, - sell_action: cand.action || '', - velocity_1d: velocity1d, - close: close, - prev_close: prevClose, - rsi14: rsi14, - rs_verdict: df.rs_verdict || '', - profit_pct: profitPct, - reentry_grade: reentryGrade, - reentry_signal: 'REENTRY_CANDIDATE', - whipsaw_warning: '매도 압박 중 반등 — 실행 전 재검토 권고', - formula_id: 'ANTI_WHIPSAW_REENTRY_GATE_V1' - }); - }); - - return results; -} - - -/** - * [PROPOSAL48_C7] getAlphaHistorySummary_ - * alpha_history 시트의 T20/T60 alpha gate 결과를 집계. - * 위성 종목의 장기 알파 생성 능력 추적 — T+5 피드백 루프 대용 지표. - * DATA_INSUFFICIENT 상태에서도 구조를 갖춰 LLM 참조 가능하게 유지. - */ -function getAlphaHistorySummary_() { - try { - var ss = getSpreadsheet_(); - var sh = ss.getSheetByName('alpha_history'); - if (!sh) return { status: 'NO_SHEET', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' }; - - var rows = sh.getDataRange().getValues(); - if (!rows || rows.length < 2) return { status: 'EMPTY', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' }; - - var header = rows[0].map(function(h) { return String(h).trim(); }); - var idx = {}; - ['Ticker','T20_Alpha_Gate','T60_Alpha_Gate','T20_Vs_Core_Pctp','T60_Vs_Core_Pctp','SAQG_Grade_At_Entry'].forEach(function(k) { - idx[k] = header.indexOf(k); - }); - - var t20 = { total: 0, pass: 0, fail: 0, missing: 0 }; - var t60 = { total: 0, pass: 0, fail: 0, missing: 0 }; - var gradeCount = {}; - - for (var r = 1; r < rows.length; r++) { - var row = rows[r]; - var g20 = idx['T20_Alpha_Gate'] >= 0 ? String(row[idx['T20_Alpha_Gate']] || '') : ''; - var g60 = idx['T60_Alpha_Gate'] >= 0 ? String(row[idx['T60_Alpha_Gate']] || '') : ''; - var grade = idx['SAQG_Grade_At_Entry'] >= 0 ? String(row[idx['SAQG_Grade_At_Entry']] || '') : ''; - - if (g20 && g20 !== 'PENDING') { - t20.total++; - if (g20 === 'PASS') t20.pass++; - else if (g20 === 'FAIL') t20.fail++; - else t20.missing++; - } - if (g60 && g60 !== 'PENDING') { - t60.total++; - if (g60 === 'PASS') t60.pass++; - else if (g60 === 'FAIL') t60.fail++; - else t60.missing++; - } - if (grade) gradeCount[grade] = (gradeCount[grade] || 0) + 1; - } - - var t20Rate = t20.total > 0 ? Math.round(t20.pass / t20.total * 1000) / 10 : null; - var t60Rate = t60.total > 0 ? Math.round(t60.pass / t60.total * 1000) / 10 : null; - - return { - status: (t20.total > 0 || t60.total > 0) ? 'OK' : 'DATA_INSUFFICIENT', - t20_total: t20.total, - t20_pass_rate: t20Rate, - t20_pass: t20.pass, - t20_fail: t20.fail, - t60_total: t60.total, - t60_pass_rate: t60Rate, - t60_pass: t60.pass, - t60_fail: t60.fail, - grade_count: gradeCount, - total_rows: rows.length - 1, - formula_id: 'ALPHA_HISTORY_SUMMARY_V1' - }; - } catch(e) { - return { status: 'ERROR', error: e.message, formula_id: 'ALPHA_HISTORY_SUMMARY_V1' }; - } -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단 -// Direction G5: PENDING_EXPORT 원인 진단 의무 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcExportGate_ - * 5개 체크리스트 자동 평가 → EXPORT_READY / PENDING_EXPORT - * PASS 전 HTS 입력 금지 조건을 결정론적으로 산출. - */ -function calcExportGate_(hApex, asResult, cashFloorInfo) { - var checks = []; - - // CHECK_1: account_snapshot 캡처 완료 여부 - var captureRequired = !(asResult && asResult.holdings && asResult.holdings.length > 0 - && asResult.settlementCashD2Krw > 0); - checks.push({ - check_id: 'CHECK_1_SNAPSHOT_CAPTURED', - status: captureRequired ? 'FAIL' : 'PASS', - message: captureRequired - ? 'account_snapshot 미캡처 — HTS 화면 캡처 후 재실행 필요' - : 'account_snapshot OK' - }); - - // CHECK_2: 데이터 완성도 (buy_permission_json 기준 전 종목 존재) - var bpJson = (hApex && hApex.buy_permission_json) || []; - var holdingCount = (asResult && asResult.holdings) ? asResult.holdings.length : 0; - var dataOk = holdingCount > 0 && bpJson.length >= holdingCount; - checks.push({ - check_id: 'CHECK_2_DATA_COMPLETENESS', - status: dataOk ? 'PASS' : 'FAIL', - message: dataOk - ? 'data_feed 완성도 OK (' + bpJson.length + '/' + holdingCount + ')' - : 'data_feed 누락 — npm run convert-data-json 후 재실행' - }); - - // CHECK_3: 하네스 무결성 체크섬 (consistency_score 기준) - var cvScore = (hApex && typeof hApex.consistency_score === 'number') ? hApex.consistency_score : null; - var cvOk = cvScore !== null && cvScore >= 70; - checks.push({ - check_id: 'CHECK_3_HARNESS_INTEGRITY', - status: cvOk ? 'PASS' : 'FAIL', - message: cvOk - ? 'consistency_score=' + cvScore + ' 무결성 OK' - : 'consistency_score=' + (cvScore !== null ? cvScore : 'null') + ' — 70 미만 또는 미산출' - }); - - // CHECK_4: SELL_PRICE_SANITY — INVALID 주문 없음 - var blueprint = (hApex && hApex.order_blueprint_json) || []; - var invalidPrices = blueprint.filter(function(b) { - return String(b.validation_status || '').indexOf('INVALID') >= 0; - }); - checks.push({ - check_id: 'CHECK_4_NO_INVALID_PRICES', - status: invalidPrices.length === 0 ? 'PASS' : 'FAIL', - message: invalidPrices.length === 0 - ? 'SELL_PRICE_SANITY 이상 없음' - : 'INVALID 가격 ' + invalidPrices.length + '건: ' + - invalidPrices.map(function(b) { return b.ticker; }).join(',') - }); - - // CHECK_5: cashFloor 블록 상태 확인 (HARD_BLOCK 시 현금 부족 경보) - var cashStatus = (cashFloorInfo && cashFloorInfo.status) || 'UNKNOWN'; - var cashOk = cashStatus !== 'UNKNOWN'; - checks.push({ - check_id: 'CHECK_5_CASH_LEDGER', - status: cashOk ? 'PASS' : 'WARN', - message: cashOk - ? 'cash_floor_status=' + cashStatus + ' (기록됨)' - : 'cash_floor_status=UNKNOWN — settlement_cash_d2_krw 확인 필요' - }); - - // [PROPOSAL51] P1-A: CHECK_6 — SCRS_RENDER 검증 (immediate_sell_qty 유효값 필수) - var scrsV2 = (hApex && hApex.scrs_v2_json) || {}; - // [PROPOSAL51-FIX] GAS는 immediate_qty 반환 (calcSmartCashRecoverySell_ 확인) - var scrsRows = scrsV2.selected_combo || scrsV2.candidates || scrsV2.rows || []; - var scrsRenderOk = scrsRows.length === 0 || scrsRows.every(function(r) { - var qty = r.immediate_qty !== undefined ? r.immediate_qty : r.immediate_sell_qty; - return qty !== null && qty !== undefined && qty !== '-' && qty !== ''; - }); - checks.push({ - check_id: 'CHECK_6_SCRS_RENDER', - status: scrsRenderOk ? 'PASS' : 'WARN', - message: scrsRenderOk - ? 'SCRS-V2 immediate_sell_qty 렌더링 OK' - : 'SCRS-V2 immediate_sell_qty 누락 — render_operational_report 키 불일치 확인 필요' - }); - - // [PROPOSAL51] P1-A: CHECK_7 — PORTFOLIO_HEALTH_SCORE 타입 (Boolean 금지) - var healthScore = hApex && hApex.portfolio_health_score; - var healthTypeOk = (typeof healthScore === 'number' && !isNaN(healthScore)); - checks.push({ - check_id: 'CHECK_7_HEALTH_SCORE_TYPE', - status: healthTypeOk ? 'PASS' : 'WARN', - message: healthTypeOk - ? 'portfolio_health_score=' + healthScore + ' (숫자 OK)' - : 'portfolio_health_score=' + JSON.stringify(healthScore) + ' — 숫자여야 함 (Boolean/null 금지)' - }); - - // [PROPOSAL51] P1-A: CHECK_8 — CLUSTER_SYNC 교정 없음 확인 - var clusterSync = (hApex && hApex.cluster_sync_result_json) || {}; - var clusterSyncOk = clusterSync.status === 'SYNCED' || !clusterSync.status; - checks.push({ - check_id: 'CHECK_8_CLUSTER_SYNC', - status: clusterSyncOk ? 'PASS' : 'WARN', - message: clusterSyncOk - ? 'SEMICONDUCTOR_CLUSTER_SYNC: 정합성 OK' - : 'CLUSTER_SYNC 교정 발생 (cluster_pct=' + (clusterSync.cluster_pct || '?') - + '%, threshold=' + (clusterSync.threshold_pct || '?') + '%)' - }); - - var failChecks = checks.filter(function(c) { return c.status === 'FAIL'; }); - var warnChecks = checks.filter(function(c) { return c.status === 'WARN'; }); - - var exportStatus; - if (failChecks.length > 0) exportStatus = 'PENDING_EXPORT'; - else if (warnChecks.length > 0) exportStatus = 'REVIEW_ONLY'; - else exportStatus = 'EXPORT_READY'; - - var htsAllowed = exportStatus === 'EXPORT_READY'; - var nonPassChecks = checks.filter(function(c) { return c.status !== 'PASS'; }); - var resolutionGuide = nonPassChecks.map(function(c) { - return '[' + c.check_id + '] ' + c.message; - }); - - return { - json_validation_status: exportStatus, - export_gate_status: exportStatus, - all_checks_passed: failChecks.length === 0 && warnChecks.length === 0, - checks: checks, - failed_checks: failChecks.map(function(c) { return c.check_id; }), - warn_checks: warnChecks.map(function(c) { return c.check_id; }), - resolution_guide: resolutionGuide, - hts_entry_allowed: htsAllowed, - formula_id: 'EXPORT_GATE_V2' - }; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — 라우팅 Trace 필수 출력 (Direction G4) -// ═══════════════════════════════════════════════════════════════════════ - -/** - * buildRoutingTrace_ - * 모든 보고서 선행 출력 의무 — request_route, bundle, prompt, 검증 상태 etc. - * 누락 시 보고서 전체 INCOMPLETE_REPORT. - */ -function buildRoutingTrace_(intradayLock, cashFloorInfo, hApex, capturedAtIso) { - var scope = intradayLock ? 'TRIM_ONLY' : 'FULL_ANALYSIS'; - var bundleSelected = (function() { - var cv = (hApex && hApex.consistency_score); - if (cv === null || cv === undefined) return 'retirement_portfolio_ultra_compact'; - if (cv < 70) return 'retirement_portfolio_ultra_compact'; - return 'retirement_portfolio_compact'; - })(); - - var exportGate = (hApex && hApex.export_gate_json) || {}; - var jsonValStatus = exportGate.json_validation_status || 'PENDING_EXPORT'; - var captureRequired = exportGate.checks - ? !exportGate.checks.some(function(c) { - return c.check_id === 'CHECK_1_SNAPSHOT_CAPTURED' && c.status === 'PASS'; - }) - : true; - - var cashLedgerBasis = 'D2_ONLY'; - var snapshotExecGate = (cashFloorInfo && cashFloorInfo.status === 'PASS') - ? 'FULL_EXECUTION' : 'REVIEW_ONLY'; - - return { - request_route: 'PIPELINE_EOD_BATCH', - bundle_selected: bundleSelected, - prompt_entrypoint: 'prompts/analysis_prompt.md', - json_validation_status: jsonValStatus, - capture_required: captureRequired, - intraday_scope: scope, - snapshot_execution_gate: snapshotExecGate, - price_basis: capturedAtIso || 'UNKNOWN', - cash_ledger_basis: cashLedgerBasis, - routing_trace_complete: true, - formula_id: 'ROUTING_TRACE_V1' - }; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — WATCH 감시 원장 (Direction I4) -// HTS 입력 금지 컬럼명만 허용 — 주문표와 물리적 분리 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * buildWatchLedger_ - * order_blueprint_json에서 validation_status != PASS 행을 분리. - * 허용 컬럼: ticker/name, reference_stop_price, reference_tp_state, hts_allowed, reason_code - * 금지 컬럼: 지정가, 손절가, 익절가, 주문가, 주문수량 등 (INVALID_COLUMN) - */ -function buildWatchLedger_(orderBlueprint, h4) { - var priceMap = {}; - ((h4 && h4.prices) || []).forEach(function(p) { priceMap[p.ticker] = p; }); - var blueprintRows = Array.isArray(orderBlueprint) ? orderBlueprint : []; - - var watchRows = blueprintRows.filter(function(b) { - return b.validation_status !== 'PASS'; - }); - - return watchRows.map(function(b) { - var p = priceMap[b.ticker] || {}; - var tpState = (function() { - if (!p.tp1_price) return 'INVALID_TP_STALE'; - if (p.tp_state === 'TP1_ALREADY_TRIGGERED') return 'TP1_ALREADY_TRIGGERED'; - return 'PENDING'; - })(); - return { - ticker: b.ticker, - name: b.name || '', - reference_stop_price: p.stop_price || null, - reference_tp_state: tpState, - hts_allowed: false, - reason_code: b.validation_status || 'NO_EXECUTION:WATCH', - note: '주문 아님. HTS 입력 금지.' - }; - }); -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P1-1: EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1 (EJCE-V1) -// 30년 전문가 수준 3관점(애널리스트·트레이더·퀀트) 합의 게이트 -// Direction EJ1: consensus_result=NO_BUY 시 BUY 절대 금지 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcExpertJudgmentConsensus_ - * 3관점 독립 채점 → majority_rule → final_allowed_action 고착화 - * LLM "분위기 좋으니까" 판단을 결정론적 합의로 대체. - */ -function calcExpertJudgmentConsensus_(ticker, df, paeRow, h1, hApex, dfMap) { - df = df || {}; - paeRow = paeRow || {}; - - // ── ANALYST_VIEW: 펀더멘털·밸류에이션 ───────────────────────────────────── - var compositeScore = toNumber_(df['SS001_Score'] || df['composite_score']) || 0; - var pegScore = toNumber_(df['PEG_Score'] || df['peg_score']) || 0; - var upsidePct = toNumber_(df['Upside_Pct'] || df['upside_pct']) || 0; - var epsMiss = toNumber_(df['EPS_Revision_Status'] === 'MISS' ? 1 : 0); - var dartRisk = String(df['DART_Risk'] || '').toUpperCase() === 'Y'; - - var analystScore = 0; - if (compositeScore >= 70) analystScore += 30; - else if (compositeScore >= 50) analystScore += 15; - if (pegScore >= 8 || upsidePct > 15) analystScore += 20; - if (upsidePct > 15) analystScore += 5; - if (epsMiss >= 2) analystScore -= 30; - if (dartRisk) analystScore -= 20; - - var analystVerdict = analystScore >= 30 ? 'BULLISH' - : analystScore >= -10 ? 'NEUTRAL' - : 'BEARISH'; - - // ── TRADER_VIEW: 타이밍·수급·추세 ───────────────────────────────────────── - var flowCredit = toNumber_(df['Flow_Credit'] || df['flow_credit']) || 0; - var rsVerdict = String(df['RS_Verdict'] || df['rs_verdict'] || '').toUpperCase(); - var velocity1d = toNumber_(df['Ret5D'] != null ? df['Close'] / (df['Close'] / (1 + toNumber_(df['Ret5D']) / 100)) - 1 : 0) * 100; - // 더 단순하게: Ret5D/5 근사 - var ret5d = toNumber_(df['Ret5D'] || df['ret5d']) || 0; - var vel1d_approx = ret5d / 5; - var paeAnti = toNumber_(paeRow.antithesis_score) || 0; - var distCount = toNumber_(df['Dist_Signals'] || df['distribution_signals_count']) || 0; - var ma20 = toNumber_(df['MA20']) || 0; - var close = toNumber_(df['Close'] || df['close']) || 0; - var atr20 = toNumber_(df['ATR20']) || 0; - var inPullback = (ma20 > 0 && close > 0) ? close <= ma20 * 1.03 : false; - - var traderScore = 0; - if (flowCredit >= 0.55 && rsVerdict === 'LEADER') traderScore += 25; - if (inPullback) traderScore += 20; - if (vel1d_approx < 1.5 && ret5d > 0) traderScore += 20; - if (vel1d_approx >= 3.0) traderScore -= 30; // 뒷박 강한 패널티 - if (paeAnti >= 50) traderScore -= 25; // 설거지 경보 - if (distCount >= 2) traderScore -= 25; - - var traderVerdict = traderScore >= 20 ? 'ENTRY_OK' - : traderScore >= -10 ? 'WAIT' - : 'BLOCK_ENTRY'; - - // ── QUANT_VIEW: 통계·팩터·리스크예산 ───────────────────────────────────── - var pacVal = toNumber_((hApex && hApex.portfolio_alpha_confidence)) || 0; - var heatGate = String((hApex && hApex.heat_gate_status) || '').toUpperCase(); - var ddGuard = String((hApex && hApex.drawdown_guard_state) || '').toUpperCase(); - var expectedEdge = toNumber_(df['Expected_Edge'] || df['expected_edge']) || 0; - var atrAvail = atr20 > 0; - - var quantScore = 0; - if (expectedEdge > 0 && atrAvail) quantScore += 25; - if (atrAvail) quantScore += 10; - if (pacVal > 20) quantScore += 20; - if (pacVal < -20) quantScore -= 30; // 전체 알파 신뢰도 BLOCK - if (heatGate === 'BLOCK_NEW_BUY') quantScore -= 20; - if (ddGuard === 'NO_BUY') quantScore -= 15; - - var quantVerdict = quantScore >= 20 ? 'APPROVED' - : quantScore >= -10 ? 'REDUCED' - : 'REJECTED'; - - // ── CONSENSUS_MATRIX: 2/3 이상 BLOCK → NO_BUY ─────────────────────────── - var blockCount = 0; - if (analystVerdict === 'BEARISH') blockCount++; - if (traderVerdict === 'BLOCK_ENTRY') blockCount++; - if (quantVerdict === 'REJECTED') blockCount++; - - var consensusResult, finalAllowedAction; - if (blockCount >= 2) { - consensusResult = 'NO_BUY'; - finalAllowedAction = 'HOLD'; - } else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK' && quantVerdict === 'APPROVED') { - consensusResult = 'STRONG_BUY'; - finalAllowedAction = 'BUY'; - } else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK') { - consensusResult = 'BUY_HALF'; - finalAllowedAction = 'BUY_HALF'; - } else if (analystVerdict === 'BULLISH' && traderVerdict === 'WAIT') { - consensusResult = 'BUY_PULLBACK'; - finalAllowedAction = 'WAIT_PULLBACK'; - } else if (analystVerdict === 'NEUTRAL' && traderVerdict === 'ENTRY_OK') { - consensusResult = 'BUY_PILOT'; - finalAllowedAction = 'PILOT'; - } else { - consensusResult = 'HOLD_WATCH'; - finalAllowedAction = 'WATCH'; - } - - var blockReasons = []; - if (analystVerdict === 'BEARISH') blockReasons.push('ANALYST_BEARISH'); - if (traderVerdict === 'BLOCK_ENTRY') blockReasons.push('TRADER_BLOCK_ENTRY_vel=' + vel1d_approx.toFixed(1) + '%'); - if (quantVerdict === 'REJECTED') blockReasons.push('QUANT_REJECTED_pac=' + pacVal.toFixed(1)); - - return { - ticker: ticker, - analyst_score: analystScore, - analyst_verdict: analystVerdict, - trader_score: traderScore, - trader_verdict: traderVerdict, - quant_score: quantScore, - quant_verdict: quantVerdict, - block_count: blockCount, - consensus_result: consensusResult, - final_allowed_action: finalAllowedAction, - block_reasons: blockReasons, - override_required: blockCount >= 2, - formula_id: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1' - }; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P1-2: SMART_CASH_RECOVERY_SELL_ENGINE_V2 (SCRS-V2) -// 세련된 현금확보 매도 — 주식가치 보호 + 반등 포착 통합 엔진 -// Direction C3: SCRS-V2 selected_combo만 HTS 주문표 기재 허용 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcSmartCashRecoverySell_ - * 현금 부족액을 최소 주식가치 훼손으로 회수. - * 반등 기대 수익(expected_rebound_gain_krw) 사전 산출. - * "현금 급함" 이유로 Stage_2 우회 원천 차단. - */ -function calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex) { - var shortfall = toNumber_((cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw)) || 0; - var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || (cashShortfallInfo && cashShortfallInfo.total_asset_krw)) || 1; - var emergencyScore = shortfall / totalAsset * 100; - - var level = emergencyScore >= 15 ? 'EMERGENCY' - : emergencyScore >= 8 ? 'URGENT' - : emergencyScore >= 3 ? 'NORMAL' - : 'TRIM_ONLY'; - - var holdMap = {}; - (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; }); - - var sellQtyMap = {}; - ((hApex && hApex.sell_quantities_json) || []).forEach(function(sq) { - sellQtyMap[sq.ticker] = sq; - }); - - var candidates = ((h2 && h2.candidates) || []).slice(); - - // [Phase 3] SMART_CASH_RECOVERY_V6: value_damage_score(가치 훼손 점수) 기준 오름차순 정렬 - candidates.forEach(function(c) { - var h = holdMap[c.ticker] || {}; - var df = dfMap[c.ticker] || {}; - var close = toNumber_(h.close || df['Close'] || df.close) || 0; - var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02); - // 가치 훼손 점수: 슬리피지 및 낙폭 리스크를 수치화 (낮을수록 매도 유리) - c.value_damage_score = close > 0 ? ((atr20 * 0.3) / close) * 100 : 100; - }); - candidates.sort(function(a, b) { - return (a.value_damage_score || 0) - (b.value_damage_score || 0); - }); - - var cumulative = 0; - var combo = []; - - for (var i = 0; i < candidates.length; i++) { - if (shortfall > 0 && cumulative >= shortfall) break; - var c = candidates[i]; - var h = holdMap[c.ticker] || {}; - var df = dfMap[c.ticker] || {}; - var close = toNumber_(h.close || df['Close'] || df.close) || 0; - var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02); - var holding = toNumber_(h.holdingQty || h.holding_qty) || 0; - var sqRow = sellQtyMap[c.ticker] || {}; - var baseQty = toNumber_(sqRow.sell_qty) || Math.floor(holding * 0.33); - - if (close <= 0 || baseQty <= 0) continue; - - var currentValue = holding * close; - var immediateQty = Math.floor(baseQty * 0.50); - var reboundWaitQty = baseQty - immediateQty; - var slippage = atr20 * 0.3; - var immediateKrw = immediateQty * Math.max(0, close - slippage); - var damagePct = currentValue > 0 ? immediateKrw / currentValue * 100 : 100; - - if (damagePct > 30 && level !== 'EMERGENCY') continue; - - var reboundTrigger = tickNormalize_(close + atr20 * 0.5, close); - var expectedReboundKrw = reboundWaitQty * Math.max(0, reboundTrigger - close); - - // [Phase 3] 유동성 기준 exec_mode 강제 지정 - var avgTradeValue = toNumber_(df['AvgTradeValue_20D_M'] || df.avgTradeVal20d) || 10000000000; - var execMode = 'LIMIT_NEAR_BID'; - if (avgTradeValue < 5000000000) { - execMode = 'TWAP_5_SPLIT'; - } else if (avgTradeValue > 50000000000) { - execMode = 'MARKET'; - } - - cumulative += immediateKrw; - combo.push({ - rank: c.rank, - ticker: c.ticker, - name: c.name || (h.name || ''), - exec_mode: execMode, - value_damage_score: Math.round(c.value_damage_score * 10) / 10, - immediate_qty: immediateQty, - rebound_wait_qty: reboundWaitQty, - immediate_krw: Math.round(immediateKrw), - rebound_trigger_price: reboundTrigger, - expected_rebound_krw: Math.round(expectedReboundKrw), - value_damage_pct: Math.round(damagePct * 10) / 10, - rebound_deadline_date: addBusinessDays_(new Date(), 3) - }); - } - - var totalReboundGain = combo.reduce(function(s, c) { return s + c.expected_rebound_krw; }, 0); - var avgDamage = combo.length > 0 - ? combo.reduce(function(s, c) { return s + c.value_damage_pct; }, 0) / combo.length : 0; - - var emergencyFullSell = combo.length > 0 - && combo[0].immediate_krw * 2 < shortfall - && level === 'EMERGENCY'; - - return { - emergency_level: level, - shortfall_krw: Math.round(shortfall), - selected_combo: combo, - total_immediate_sell_krw: Math.round(cumulative), - expected_rebound_gain_krw: Math.round(totalReboundGain), - value_damage_pct_avg: Math.round(avgDamage * 10) / 10, - emergency_full_sell: emergencyFullSell, - shortfall_covered: shortfall <= 0 || cumulative >= shortfall, - formula_id: 'SMART_CASH_RECOVERY_SELL_ENGINE_V6' - }; -} - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL51] P1-C: CASH_RECOVERY_DISPLAY_LOCK_V1 (CRDL-V1) -// 현금회복 금액 3분리 표시 잠금 — 207억 과대표시 차단 -// min_required / optimal_combo / reference_total (주문 아님) -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcCashRecoveryDisplayLock_ - * 현금회복 금액을 3분리(최소필요/최적조합/전체후보) 표시 잠금. - * reference_total_krw는 "주문 아님" 레이블 필수. - */ -function calcCashRecoveryDisplayLock_(scrsJson, trimPlanJson, cashInfo) { - function normalizeRows_(v) { - if (Array.isArray(v)) return v; - if (!v) return []; - if (typeof v === 'string') { - try { return normalizeRows_(JSON.parse(v)); } catch (e) { return []; } - } - if (typeof v === 'object') { - var vals = []; - for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) vals.push(v[k]); - return vals; - } - return []; - } - - var scrs = scrsJson || {}; - if (typeof scrs === 'string') { - try { scrs = JSON.parse(scrs); } catch (e0) { scrs = {}; } - } - var trim = normalizeRows_(trimPlanJson); - var cash = cashInfo || {}; - - var minRequired = toNumber_(cash.cash_shortfall_min_krw) || 0; - var combo = normalizeRows_(scrs.selected_combo); - var optimalCombo = combo.reduce(function(s, r) { return s + (toNumber_(r.immediate_krw) || 0); }, 0); - var refTotal = trim.reduce(function(s, r) { - return s + (toNumber_(r.sell_amount_krw || r.trim_amount_krw || r.trimming_krw) || 0); - }, 0); - - var coverageStatus; - if (minRequired <= 0) coverageStatus = 'NO_SHORTFALL'; - else if (optimalCombo < minRequired) coverageStatus = 'UNCOVERED'; - else if (optimalCombo > minRequired * 2) coverageStatus = 'OVER_SELL'; - else coverageStatus = 'COVERED'; - - return { - formula_id: 'CASH_RECOVERY_DISPLAY_LOCK_V1', - min_required_krw: Math.round(minRequired), - optimal_combo_krw: Math.round(optimalCombo), - reference_total_krw: Math.round(refTotal), - coverage_status: coverageStatus, - display_mode: 'SHOW_MIN_OPTIMAL', - reference_label: '참고용 전체 후보 누적 — 주문 아님', - over_sell_warning: coverageStatus === 'OVER_SELL' - ? 'OVER_SELL_WARNING: 최적조합(' + Math.round(optimalCombo/10000) + '만원)이 최소필요(' + Math.round(minRequired/10000) + '만원)의 2배 초과' : null, - shortfall_uncovered: coverageStatus === 'UNCOVERED' - ? 'CASH_SHORTFALL_UNCOVERED: SCRS-V2 재실행 필요' : null - }; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL51] P1-B: DATA_QUALITY_GATE_V2 (DQG-V2) -// 데이터 완성도 필드충족률 기반 게이트 — 행수 카운트 폐기 -// COMPLETE(≥90%) / PARTIAL(≥60%) / INSUFFICIENT(<60%) -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcDataQualityGateV2_ - * 핵심 필드 충족률로 데이터 완성도 등급 산출. - * T+20=0건, trade_quality=0건 시 특수 경고 발동. - */ -function calcDataQualityGateV2_(hApex) { - var h = hApex || {}; - - var pa1 = ((h.alpha_lead_json || [])[0]) || {}; - var tradeQualRecords = ((h.trade_quality_report_json || {}).records || []); - var tqFirst = tradeQualRecords[0] || {}; - var alphaHist = (h.alpha_history_summary_json) || {}; - var scrsV2 = (h.scrs_v2_json) || {}; - var combo = scrsV2.selected_combo || []; - var cluster = (h.semiconductor_cluster_json) || {}; - var alphaEval = (h.alpha_evaluation_window_json || []); - var firstAlpha = alphaEval[0] || {}; - var pp0 = ((h.profit_preservation_json) || [])[0] || {}; - - var isValid = function(v) { - return v !== null && v !== undefined && v !== '-' && v !== 'PENDING' && v !== ''; - }; - - // [R2-1c] 필드경로 버그 수정: 실재 데이터를 0으로 깔던 false-negative 제거. - // prediction: alpha_lead_json[0] → pa1_report_json(PA1 진짜 필드). - // cash: cash_shortfall_json.cash_shortfall_min_krw(None) → 직접키 h.cash_shortfall_min_krw. - // cluster: h.semiconductor_cluster_json → h.semiconductor_cluster_gate_json 또는 직접 필드. - // stop_loss: final_stop_price/stop_price(없는 키) → protected_stop_price/auto_trailing_stop. - // trade_quality/alpha_eval/pattern: 표본 필요 → PENDING 값으로 명시(분모 제외). - var pa1Report = h.pa1_report_json || {}; - if (typeof pa1Report === 'string') { try { pa1Report = JSON.parse(pa1Report); } catch(e) { pa1Report = {}; } } - var pa1Rows = Array.isArray(pa1Report) ? pa1Report : (pa1Report.rows || []); - var pa1Row0 = pa1Rows[0] || {}; - - var clusterDirect = h.semiconductor_cluster_json || {}; - if (typeof clusterDirect === 'string') { try { clusterDirect = JSON.parse(clusterDirect); } catch(e) { clusterDirect = {}; } } - - var CATEGORIES = { - prediction: [pa1Row0.direction_confidence, pa1Row0.synthesis_verdict, pa1Row0.thesis_score, pa1Row0.antithesis_score], - trade_quality: [tqFirst.grade || 'PENDING', tqFirst.feedback_tag || 'PENDING', tqFirst.t5_return_pct, tqFirst.t20_vs_core_pct], - pattern: [(h.pattern_blacklist_auto_json || {}).status || 'PENDING', (h.pattern_blacklist_auto_json || {}).accumulated_poor_count], - ["stop_loss"]: [pp0.auto_trailing_stop, pp0.protected_stop_price, pp0.profit_preservation_state], - cash: [h.settlement_cash_d2_krw, h.cash_floor_status, h.cash_shortfall_min_krw], - sell_engine: [scrsV2.emergency_level, (combo[0] || {}).immediate_qty, (combo[0] || {}).rebound_wait_qty], - cluster: [clusterDirect.cluster_state, clusterDirect.combined_pct], - alpha_eval: [firstAlpha.alpha_gate_verdict || 'PENDING', alphaHist.prediction_accuracy_rate] - }; - - var categoryScores = {}; - Object.keys(CATEGORIES).forEach(function(cat) { - var fields = CATEGORIES[cat]; - var filled = fields.filter(isValid).length; - categoryScores[cat] = Math.round(filled / fields.length * 100); - }); - - var catVals = Object.keys(categoryScores).map(function(k) { return categoryScores[k]; }); - var overallPct = catVals.length > 0 - ? Math.round(catVals.reduce(function(s, v) { return s + v; }, 0) / catVals.length) : 0; - - var grade = overallPct >= 90 ? 'COMPLETE' : overallPct >= 60 ? 'PARTIAL' : 'INSUFFICIENT'; - - var warnings = []; - var t20Count = toNumber_((alphaHist).t20_evaluation_count) || 0; - var tqCount = tradeQualRecords.length; - var accRate = alphaHist.prediction_accuracy_rate; - var t5Count = toNumber_(alphaHist.t5_match_count) || 0; - - if (t20Count === 0) warnings.push('warn_t20_zero: T+20 평가 0건 — 장기 예측 신뢰도 미검증'); - if (tqCount === 0) warnings.push('warn_quality_unverified: 거래 품질 기록 0건'); - if (!isValid(accRate)) warnings.push('warn_accuracy_unknown: 예측 정확도 미산출(PENDING)'); - if (t5Count < 5) warnings.push('warn_insufficient_samples: T+5 표본 ' + t5Count + '건(최소 5건 미달)'); - - return { - formula_id: 'DATA_QUALITY_GATE_V2', - overall_completeness_pct: overallPct, - completeness_grade: grade, - category_scores: categoryScores, - special_warnings: warnings, - t20_evaluation_count: t20Count, - trade_quality_record_count: tqCount, - prediction_accuracy_rate: accRate || null, - confidence_ceiling: grade === 'INSUFFICIENT' - ? 'BUY_SELL_CONFIDENCE_LIMITED: 핵심 데이터 부족 — 신호 신뢰도 상한 경고' : null - }; -} - - -/** - * addBusinessDays_: 영업일 기준 날짜 계산 (토·일 제외) - */ -function addBusinessDays_(startDate, days) { - var d = new Date(startDate.getTime()); - var added = 0; - while (added < days) { - d.setDate(d.getDate() + 1); - var dow = d.getDay(); - if (dow !== 0 && dow !== 6) added++; - } - return Utilities.formatDate(d, 'Asia/Seoul', 'yyyy-MM-dd'); -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P2-1: DETERMINISTIC_SERVING_LOCK_ENGINE_V1 (DSLE-V1) -// 11단계 stage_token 잠금 + LLM 수치 생성 = 0 강제 -// Direction D3: LLM 서빙 수치 생성 절대 금지 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcDeterministicServingLock_ - * 11단계 파이프라인 각 단계의 status·checksum을 토큰으로 기록. - * integrity_checksum 불일치 시 INVALID_SERVING_OVERRIDE 자동 표시. - */ -function calcDeterministicServingLock_(hApex, capturedAtIso, now) { - var stages = [ - { id: 'Stage_01_freshness', key: 'data_freshness_status' }, - { id: 'Stage_02_intraday', key: 'intraday_scope' }, - { id: 'Stage_03_portfolio', key: 'cash_floor_status' }, - { id: 'Stage_04_macro', key: 'macro_risk_score' }, - { id: 'Stage_05_sell_radar', key: 'distribution_sell_detector_json' }, - { id: 'Stage_06_buy_gate', key: 'anti_late_entry_json' }, - { id: 'Stage_07_sell_priority', key: 'sell_candidates_json' }, - { id: 'Stage_08_cash_recovery', key: 'scrs_v2_json' }, - { id: 'Stage_09_rs_quality', key: 'rs_verdict' }, - { id: 'Stage_10_tick_norm', key: 'tick_normalized_prices_json' }, - { id: 'Stage_11_serving', key: 'order_blueprint_json' }, - ]; - - var tokens = []; - var blockDetected = false; - var blockReason = null; - - for (var i = 0; i < stages.length; i++) { - var s = stages[i]; - var value = hApex ? hApex[s.key] : null; - var status = (value !== null && value !== undefined) ? 'OK' : 'MISSING'; - if (status === 'MISSING' && i < 4) { - blockDetected = true; - blockReason = blockReason || (s.id + '_MISSING'); - } - tokens.push({ - stage_id: s.id, - key: s.key, - status: status, - checksum: computeStringChecksum_(safeStringifyForChecksum_(value)) - }); - } - - var tokenChecksum = computeStringChecksum_(safeStringifyForChecksum_(tokens)); - - return { - route_lock_status: blockDetected ? 'PARTIALLY_LOCKED' : 'FULLY_LOCKED', - stage_tokens: tokens, - integrity_checksum: tokenChecksum, - llm_serving_budget: { - max_tokens: 1000, - numeric_generation_allowed: 0, - constraint: 'LLM_SERVING_CONSTRAINT_V1' - }, - block_reason: blockReason, - captured_at: capturedAtIso || null, - generated_at: now ? Utilities.formatDate(now, 'Asia/Seoul', 'yyyy-MM-dd HH:mm') : null, - formula_id: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1' - }; -} - -// ============================================================ -// WBS-4.4 일별 성과 대시보드 (포트폴리오 수익률 vs KOSPI 알파) -// ============================================================ - -/** - * 매일 runDataFeed 이후 호출. evaluation_dashboard 시트에 - * 포트폴리오 수익률·KOSPI 수익률·알파·누적알파·MDD 를 기록. - * - * 설계 원칙: - * - daily_history → total_asset, mdd_pct - * - macro 시트 → KOSPI Close (어제/오늘 Close 차이로 1D 수익률 계산) - * - evaluation_dashboard 시트의 직전 행을 기준 자산·KOSPI Close 로 사용 - * - 시트 없으면 자동 생성, 오늘 행이 이미 있으면 덮어쓰기 - */ -function updateEvaluationDashboard_() { - var ss = getSpreadsheet_(); - var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd'); - - // ── 1. daily_history에서 오늘 total_asset, mdd_pct 읽기 ────────────────── - var histSheet = ss.getSheetByName('daily_history'); - if (!histSheet) { - Logger.log('[EVAL_DASH] daily_history 시트 없음, 건너뜀'); - return; - } - var histData = histSheet.getDataRange().getValues(); - if (histData.length < 2) { - Logger.log('[EVAL_DASH] daily_history 데이터 부족'); - return; - } - var hHdr = histData[0].map(function(c) { return String(c).trim().toLowerCase(); }); - var hDateIdx = hHdr.indexOf('date'); - var hAssetIdx = hHdr.indexOf('total_asset'); - if (hAssetIdx < 0) { - hAssetIdx = hHdr.indexOf('total_asset_krw'); - } - var hMddIdx = hHdr.indexOf('mdd_pct'); - if (hDateIdx < 0 || hAssetIdx < 0) { - Logger.log('[EVAL_DASH] daily_history 헤더 불일치: ' + histData[0].join(',')); - return; - } - var todayHistRow = null; - for (var r = 1; r < histData.length; r++) { - if (String(histData[r][hDateIdx]).trim() === today) { - todayHistRow = histData[r]; - break; - } - } - if (!todayHistRow) { - Logger.log('[EVAL_DASH] daily_history에 오늘 행 없음: ' + today); - return; - } - var todayAsset = parseFloat(todayHistRow[hAssetIdx]) || 0; - var todayMdd = hMddIdx >= 0 ? (parseFloat(todayHistRow[hMddIdx]) || 0) : 0; - - // ── 2. macro 시트에서 KOSPI Close 읽기 ──────────────────────────────────── - var todayKospiClose = null; - var macroSheet = ss.getSheetByName('macro'); - if (macroSheet) { - var mData = macroSheet.getDataRange().getValues(); - var mHdrRowIdx = 0; - for (var i = 0; i < Math.min(5, mData.length); i++) { - if (mData[i].join(',').indexOf('Name') >= 0) { mHdrRowIdx = i; break; } - } - var mHdr = mData[mHdrRowIdx].map(function(c) { return String(c).trim(); }); - var mNameIdx = mHdr.indexOf('Name'); - var mCloseIdx = mHdr.indexOf('Close'); - for (var j = mHdrRowIdx + 1; j < mData.length; j++) { - if (mNameIdx >= 0 && String(mData[j][mNameIdx]).trim() === 'KOSPI') { - if (mCloseIdx >= 0) todayKospiClose = parseFloat(mData[j][mCloseIdx]) || null; - break; - } - } - } - - // ── 3. evaluation_dashboard 시트 가져오기/생성 ─────────────────────────── - var EVD_HDRS = [ - 'Date', 'Total_Asset', 'KOSPI_Close', - 'Portfolio_Return_1D_Pct', 'KOSPI_Return_1D_Pct', - 'Alpha_1D_Pct', 'Cumulative_Alpha_Pct', 'MDD_Pct' - ]; - var evdSheet = ss.getSheetByName('evaluation_dashboard'); - if (!evdSheet) { - evdSheet = ss.insertSheet('evaluation_dashboard'); - evdSheet.getRange(1, 1, 1, EVD_HDRS.length).setValues([EVD_HDRS]); - evdSheet.setFrozenRows(1); - } - - // ── 4. 직전 행(prev) 및 오늘 행 위치 파악 ────────────────────────────── - var evdData = evdSheet.getDataRange().getValues(); - var eHdr = evdData.length > 0 - ? evdData[0].map(function(c) { return String(c).trim(); }) - : EVD_HDRS; - var eDateIdx = eHdr.indexOf('Date'); - var eAssetIdx = eHdr.indexOf('Total_Asset'); - var eKospiIdx = eHdr.indexOf('KOSPI_Close'); - var eCumAlphaIdx = eHdr.indexOf('Cumulative_Alpha_Pct'); - - var prevAsset = null; - var prevKospi = null; - var prevCumAlpha = 0; - var todayRowIdx = -1; // 1-based sheet row index (0 = not found) - - for (var k = 1; k < evdData.length; k++) { - var rowDate = eDateIdx >= 0 ? String(evdData[k][eDateIdx]).trim() : ''; - if (rowDate === today) { - todayRowIdx = k + 1; // getRange은 1-based - } else if (rowDate !== '' && rowDate < today) { - prevAsset = eAssetIdx >= 0 ? (parseFloat(evdData[k][eAssetIdx]) || null) : null; - prevKospi = eKospiIdx >= 0 ? (parseFloat(evdData[k][eKospiIdx]) || null) : null; - prevCumAlpha = eCumAlphaIdx >= 0 ? (parseFloat(evdData[k][eCumAlphaIdx]) || 0) : 0; - } - } - - // ── 5. 수익률·알파 계산 ──────────────────────────────────────────────── - var portfolioRet1D = null; - if (prevAsset !== null && prevAsset > 0 && todayAsset > 0) { - portfolioRet1D = Math.round(((todayAsset - prevAsset) / prevAsset * 100) * 100) / 100; - } - var kospiRet1D = null; - if (prevKospi !== null && prevKospi > 0 && todayKospiClose !== null && todayKospiClose > 0) { - kospiRet1D = Math.round(((todayKospiClose - prevKospi) / prevKospi * 100) * 100) / 100; - } - var alpha1D = (portfolioRet1D !== null && kospiRet1D !== null) - ? Math.round((portfolioRet1D - kospiRet1D) * 100) / 100 - : null; - var cumAlpha = alpha1D !== null - ? Math.round((prevCumAlpha + alpha1D) * 100) / 100 - : prevCumAlpha; - - var newRow = [ - today, todayAsset, todayKospiClose, - portfolioRet1D, kospiRet1D, - alpha1D, cumAlpha, todayMdd - ]; - - // ── 6. 오늘 행 덮어쓰기 또는 추가 ──────────────────────────────────── - if (todayRowIdx > 0) { - evdSheet.getRange(todayRowIdx, 1, 1, newRow.length).setValues([newRow]); - Logger.log('[EVAL_DASH] 오늘 행 업데이트 date=' + today - + ' portfolio_ret=' + portfolioRet1D - + ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha); - } else { - evdSheet.appendRow(newRow); - Logger.log('[EVAL_DASH] 오늘 행 추가 date=' + today - + ' portfolio_ret=' + portfolioRet1D - + ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha); - } -} diff --git a/src/gas/engines/gdf_05_alpha_engines.gs b/src/gas/engines/gdf_05_alpha_engines.gs deleted file mode 100644 index c1e604c..0000000 --- a/src/gas/engines/gdf_05_alpha_engines.gs +++ /dev/null @@ -1,1287 +0,0 @@ -function safeStringifyForChecksum_(value) { - var s = JSON.stringify(value); - return (s === undefined || s === null) ? '' : s; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P2-2: YAML_GAS_COVERAGE_AUDIT_ENGINE_V1 (YGCA-V1) -// YAML 지침 ↔ GAS 함수 커버리지 감사 — settings 탭에 결과 기록 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * auditYamlGasCoverage_ - * 필수 함수 목록과 실제 정의를 비교해 커버리지 % 산출. - * GAS에서는 typeof 로 함수 존재 여부를 확인한다. - */ -function auditYamlGasCoverage_() { - var REQUIRED = [ - // Stage 0 - { yaml: 'HARNESS_DATA_FRESHNESS_GATE_V1', gs: 'calcHarnessDataFreshnessGate_' }, - { yaml: 'INTRADAY_ACTION_MATRIX_V1', gs: 'calcIntradayLock_' }, - // Stage 1 - { yaml: 'FLOW_CREDIT_V1', gs: 'buildAllowedAction' }, - { yaml: 'TARGET_CASH_PCT_V1', gs: 'calcCashFloor_' }, - { yaml: 'TOTAL_HEAT_V1', gs: 'calcHarnessPortfolioGuardState_' }, - { yaml: 'CASH_SHORTFALL_V1', gs: 'calcCashShortfallHarness_' }, - { yaml: 'CASH_RECOVERY_OPTIMIZER_V1', gs: 'calcCashPreservationPlan_' }, - // Stage 2 - { yaml: 'POSITION_SIZE_V1', gs: 'calcQuantities_' }, - { yaml: 'STOP_PRICE_CORE_V1', gs: 'calcPrices_' }, - { yaml: 'PROFIT_RATCHET_TIERED_V2', gs: 'calcProfitPreservationRow_' }, - { yaml: 'TAKE_PROFIT_LADDER_V1', gs: 'calcTpQuantityLadder_' }, - // Stage 3 - { yaml: 'DISTRIBUTION_SELL_DETECTOR_V1', gs: 'calcDistributionRiskRow_' }, - { yaml: 'DIVERGENCE_SCORE_V1', gs: 'calcSellConflictScore_' }, - { yaml: 'OVERHANG_PRESSURE_V1', gs: 'calcReboundHoldbackScore_' }, - { yaml: 'FLOW_ACCELERATION_V1', gs: 'calcAlphaShield_' }, - { yaml: 'PRE_DISTRIBUTION_EARLY_WARNING_V1', gs: 'calcDistributionRiskRow_' }, - // Stage 4 - { yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' }, - { yaml: 'PULLBACK_ENTRY_TRIGGER_V1', gs: 'calcEntryTimingSignal_' }, - { yaml: 'BREAKOUT_QUALITY_GATE_V2', gs: 'calcBreakoutQualityGate_' }, - { yaml: 'STAGED_ENTRY_TRANCHE_V1', gs: 'calcCoreSatelliteExecutionState_' }, - // Stage 5 - { yaml: 'SELL_WATERFALL_ENGINE_V1', gs: 'calcSmartCashRaiseV2_' }, - { yaml: 'SELL_EXECUTION_TIMING_V1', gs: 'calcExitSellAction_' }, - { yaml: 'SELL_VALUE_PRESERVATION_TIERED_V2', gs: 'calcCashPreservationSellEngineV2_' }, - { yaml: 'SELL_PRICE_SANITY_V1', gs: 'calcSellSignalSanityScore_' }, - { yaml: 'K2_STAGED_REBOUND_SELL_V1', gs: 'calcAntiWhipsawGate_' }, - // Stage 6 - { yaml: 'TICK_NORMALIZER_V1', gs: 'tickNormalize_' }, - // Stage 7-8 - { yaml: 'RS_VERDICT_V2', gs: 'calcIndexRelativeHealthGate_' }, - { yaml: 'BENCHMARK_RELATIVE_TIMESERIES_V1', gs: 'calcIndexRelativeHealthGate_' }, - { yaml: 'SATELLITE_ALPHA_QUALITY_GATE_V1', gs: 'calcCoreCandidateQualityGrade_' }, - { yaml: 'SATELLITE_LIFECYCLE_GATE_V1', gs: 'calcSatelliteLifecycleGate_' }, - { yaml: 'PORTFOLIO_CORRELATION_GATE_V1', gs: 'calcPortfolioCorrelationGate_' }, - // Stage 9 - { yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcDeterministicServingLock_' }, - { yaml: 'DETERMINISTIC_ROUTING_ENGINE_V1', gs: 'buildHarnessContext_' }, - // Portfolio risk - { yaml: 'DRAWDOWN_GUARD_V1', gs: 'calcDrawdownGuard_' }, - { yaml: 'PORTFOLIO_BETA_GATE_V1', gs: 'calcPortfolioBetaGate_' }, - { yaml: 'SECTOR_CONCENTRATION_LIMIT_V1', gs: 'calcSectorConcentrationGate_' }, - { yaml: 'POSITION_COUNT_LIMIT_V1', gs: 'calcPositionCountLimit_' }, - { yaml: 'SINGLE_POSITION_WEIGHT_CAP_V1', gs: 'calcSinglePositionWeightCap_' }, - { yaml: 'SEMICONDUCTOR_CLUSTER_GATE_V1', gs: 'calcSemiconductorClusterGate_' }, - { yaml: 'PORTFOLIO_DRAWDOWN_GATE_V1', gs: 'calcPortfolioDrawdownGate_' }, - { yaml: 'WIN_LOSS_STREAK_GUARD_V1', gs: 'calcWinLossStreakGuard_' }, - // Alerts - { yaml: 'STOP_BREACH_ALERT_V1', gs: 'calcStopBreachAlert_' }, - { yaml: 'RELATIVE_STOP_SIGNAL_V1', gs: 'calcRelativeStopSignal_' }, - { yaml: 'TP_TRIGGER_ALERT_V1', gs: 'calcTpTriggerAlert_' }, - { yaml: 'HEAT_CONCENTRATION_ALERT_V1', gs: 'calcHeatConcentrationAlert_' }, - { yaml: 'REGIME_TRANSITION_ALERT_V1', gs: 'calcRegimeTransitionAlert_' }, - { yaml: 'PORTFOLIO_HEALTH_SCORE_V1', gs: 'calcPortfolioHealthScore_' }, - // Proposal50 신규 - { yaml: 'EXPORT_GATE_V1', gs: 'calcExportGate_' }, - { yaml: 'ROUTING_TRACE_V1', gs: 'buildRoutingTrace_' }, - { yaml: 'WATCH_LEDGER_V1', gs: 'buildWatchLedger_' }, - { yaml: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1', gs: 'calcExpertJudgmentConsensus_' }, - { yaml: 'SMART_CASH_RECOVERY_SELL_ENGINE_V2', gs: 'calcSmartCashRecoverySell_' }, - { yaml: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1', gs: 'calcDeterministicServingLock_' }, - { yaml: 'MACRO_REGIME_ADAPTIVE_GATE_V2', gs: 'calcMacroRegimeAdaptiveGate_' }, - { yaml: 'MANDATORY_REDUCTION_PLAN_V1', gs: 'calcMandatoryReductionPlan_' }, - // Proposal50 P0 Gap 해소 함수 - { yaml: 'VALIDATE_ORDER_CONDITION_V1', gs: 'validateOrderCondition_' }, - { yaml: 'SHADOW_LEDGER_V1', gs: 'buildShadowLedger_' }, - { yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcLlmServingConstraint_' }, - { yaml: 'AVG_TRADE_VALUE_SIGNAL_V1', gs: 'calcAvgTradeValueSignal_' }, - { yaml: 'TRIM_PLAN_MIN_CASH_V1', gs: 'calcTrimPlanMinCash_' }, - { yaml: 'PREDICTIVE_ALPHA_ENGINE_V1', gs: 'calcPredictiveAlphaEngineV1_' }, - { yaml: 'MACRO_EVENT_SYNCHRONIZER_V1', gs: 'calcMacroEventSynchronizerV1_' }, - { yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' }, - { yaml: 'CONSISTENCY_VALIDATOR_V2', gs: 'calcConsistencyValidatorV2_' }, - { yaml: 'SATELLITE_FAILURE_GATE_V1', gs: 'calcSatelliteFailureGate_' }, - { yaml: 'SATELLITE_AGGREGATE_PNL_GATE_V1', gs: 'calcSatelliteAggregatePnlGate_' }, - { yaml: 'CLA_REGIME_EXIT_CONDITION_V1', gs: 'calcClaRegimeExitCondition_' }, - { yaml: 'EVENT_RISK_HOLD_GATE_V1', gs: 'calcEventRiskHoldGate_' }, - { yaml: 'SECTOR_ROTATION_MOMENTUM_V1', gs: 'calcSectorRotationMomentum_' }, - // Monthly Batch 피드백 루프 - { yaml: 'TRADE_QUALITY_SCORER_V1', gs: 'calcTradeQualityScorer_' }, - { yaml: 'PATTERN_BLACKLIST_AUTO_V1', gs: 'calcPatternBlacklistAuto_' }, - { yaml: 'ALPHA_FEEDBACK_LOOP_V1', gs: 'calcAlphaFeedbackLoop_' }, - // Proposal51 신규 - { yaml: 'SELL_PRICE_SANITY_V2', gs: 'calcSellPriceSanityV2_' }, - { yaml: 'EXPORT_GATE_V2', gs: 'calcExportGate_' }, - { yaml: 'SEMICONDUCTOR_CLUSTER_SYNC_V1', gs: 'syncSemiconductorCluster_' }, - { yaml: 'PROACTIVE_SELL_RADAR_V2', gs: 'calcProactiveSellRadarV2_' }, - { yaml: 'ANTI_LATE_ENTRY_GATE_V3', gs: 'applyAlegGate4And5_' }, - { yaml: 'PRICE_HIERARCHY_LOCK_V1', gs: 'applyPriceHierarchyLockAll_' }, - { yaml: 'DATA_QUALITY_GATE_V2', gs: 'calcDataQualityGateV2_' }, - { yaml: 'CASH_RECOVERY_DISPLAY_LOCK_V1', gs: 'calcCashRecoveryDisplayLock_' }, - // Proposal53 신규 - { yaml: 'FUNDAMENTAL_QUALITY_GATE_V1', gs: 'calcFundamentalQualityGateV1_' }, - { yaml: 'HORIZON_ALLOCATION_LOCK_V1', gs: 'calcHorizonAllocationLockV1_' }, - { yaml: 'SMART_MONEY_LIQUIDITY_GATE_V1', gs: 'calcSmartMoneyLiquidityGateV1_' }, - { yaml: 'ROUTING_SERVING_DECISION_TRACE_V2', gs: 'buildRoutingServingTraceV2_' }, - { yaml: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', gs: 'calcFundamentalMultiFactorScoreV2_' }, - { yaml: 'EARNINGS_GROWTH_QUALITY_GATE_V1', gs: 'calcEarningsGrowthQualityGateV1_' }, - { yaml: 'MARKET_SHARE_MOMENTUM_PROXY_V1', gs: 'calcMarketShareMomentumProxyV1_' }, - { yaml: 'CASHFLOW_STABILITY_GATE_V1', gs: 'calcCashflowStabilityGateV1_' }, - { yaml: 'ROUTING_DECISION_EXPLAIN_LOCK_V1', gs: 'calcRoutingExplainLockV1_' }, - ]; - - var implemented = REQUIRED.filter(function(req) { - try { return typeof eval(req.gs) === 'function'; } catch(e) { return false; } - }); - // eval 대신 안전한 방법으로 확인 (GAS에서는 this 대신 globalThis 또는 eval 허용) - // GAS 환경: 전역 함수 → typeof functionName 으로 확인 불가 → 이름 기반 hardlist 사용 - var IMPLEMENTED_HARDLIST = [ - 'calcHarnessDataFreshnessGate_','calcIntradayLock_','buildAllowedAction', - 'calcCashFloor_','calcHarnessPortfolioGuardState_','calcCashShortfallHarness_', - 'calcCashPreservationPlan_','calcQuantities_','calcPrices_', - 'calcProfitPreservationRow_','calcTpQuantityLadder_','calcDistributionRiskRow_', - 'calcSellConflictScore_','calcReboundHoldbackScore_','calcAlphaShield_', - 'calcAntiLateEntryGateV2_','calcEntryTimingSignal_','calcBreakoutQualityGate_', - 'calcCoreSatelliteExecutionState_','calcSmartCashRaiseV2_','calcExitSellAction_', - 'calcCashPreservationSellEngineV2_','calcSellSignalSanityScore_','calcAntiWhipsawGate_', - 'tickNormalize_','calcIndexRelativeHealthGate_','calcCoreCandidateQualityGrade_', - 'calcSatelliteLifecycleGate_','calcPortfolioCorrelationGate_', - 'calcDeterministicServingLock_','buildHarnessContext_', - 'calcDrawdownGuard_','calcPortfolioBetaGate_','calcSectorConcentrationGate_', - 'calcPositionCountLimit_','calcSinglePositionWeightCap_','calcSemiconductorClusterGate_', - 'calcPortfolioDrawdownGate_','calcWinLossStreakGuard_', - 'calcStopBreachAlert_','calcTpTriggerAlert_','calcHeatConcentrationAlert_', - 'calcRegimeTransitionAlert_','calcPortfolioHealthScore_', - 'calcExportGate_','buildRoutingTrace_','buildWatchLedger_', - 'calcExpertJudgmentConsensus_','calcSmartCashRecoverySell_', - 'calcMacroRegimeAdaptiveGate_','calcMandatoryReductionPlan_', - 'validateOrderCondition_','buildShadowLedger_','calcLlmServingConstraint_', - 'calcAvgTradeValueSignal_','calcTrimPlanMinCash_', - 'applyAlegGate4And5_','applyDsdV1_1Signals_', - 'calcPredictiveAlphaEngineV1_','calcMacroEventSynchronizerV1_', - 'calcAntiLateEntryGateV2_','calcConsistencyValidatorV2_', - 'calcSatelliteFailureGate_','calcSatelliteAggregatePnlGate_', - 'calcClaRegimeExitCondition_','calcEventRiskHoldGate_', - 'calcSectorRotationMomentum_','calcAlphaShield_', - 'calcTradeQualityScorer_','calcPatternBlacklistAuto_','calcAlphaFeedbackLoop_', - 'calcRelativeStopSignal_', - // Proposal51 신규 - 'calcSellPriceSanityV2_','syncSemiconductorCluster_', - 'calcProactiveSellRadarV2_', - 'applyPriceHierarchyLockAll_','calcDataQualityGateV2_','calcCashRecoveryDisplayLock_', - 'calcFundamentalQualityGateV1_','calcHorizonAllocationLockV1_', - 'calcSmartMoneyLiquidityGateV1_','buildRoutingServingTraceV2_', - 'calcFundamentalMultiFactorScoreV2_','calcEarningsGrowthQualityGateV1_', - 'calcMarketShareMomentumProxyV1_','calcCashflowStabilityGateV1_', - 'calcRoutingExplainLockV1_', - ]; - - var implSet = {}; - IMPLEMENTED_HARDLIST.forEach(function(f) { implSet[f] = true; }); - - var gaps = REQUIRED.filter(function(req) { return !implSet[req.gs]; }); - var implCount = REQUIRED.length - gaps.length; - var coveragePct = Math.round(implCount / REQUIRED.length * 1000) / 10; - - var result = { - total_required: REQUIRED.length, - implemented: implCount, - coverage_pct: coveragePct, - gaps: gaps.map(function(g) { return { yaml: g.yaml, gs: g.gs }; }), - coverage_label: coveragePct >= 95 ? 'FULL' - : coveragePct >= 80 ? 'HIGH' - : coveragePct >= 60 ? 'MEDIUM' - : 'LOW', - formula_id: 'YAML_GAS_COVERAGE_AUDIT_ENGINE_V1' - }; - - Logger.log('[COVERAGE_AUDIT] ' + coveragePct + '% (' + implCount + '/' + REQUIRED.length + ')' - + (gaps.length > 0 ? ' GAPS: ' + gaps.map(function(g){ return g.yaml; }).join(',') : '')); - - // settings 탭에 기록 - try { - var ss = getSpreadsheet_(); - var sh = ss.getSheetByName('settings'); - if (sh) { - var data = sh.getDataRange().getValues(); - var found = false; - for (var i = 0; i < data.length; i++) { - if (String(data[i][0]) === 'coverage_pct') { - sh.getRange(i + 1, 2).setValue(coveragePct); - found = true; - break; - } - } - if (!found) { - sh.appendRow(['coverage_pct', coveragePct, 'YAML↔GAS 커버리지 %', new Date().toISOString()]); - } - } - } catch(e) { - Logger.log('[COVERAGE_AUDIT] settings 탭 기록 실패: ' + e.message); - } - - return result; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P0-B: MACRO_REGIME_ADAPTIVE_GATE_V2 (MRAG-V2) -// 거시·이벤트 위험도 4레이어 → heat_gate_threshold / position_size_scale 동적 조정 -// Direction ME2: effective_heat_gate_threshold = ME1 + MRAG-V2 중 더 엄격한 값 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcMacroRegimeAdaptiveGate_ - * LAYER_1 미시(Market Internals) + LAYER_2 거시(Macro) + LAYER_3 글로벌 + LAYER_4 이벤트 - * total_mrag_score 0~100 → heat_gate_threshold / position_size_scale 결정론적 조정 - */ -function calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex) { - return calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex); -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P1-A: ANTI_LATE_ENTRY_GATE V2.1 — GATE_4/GATE_5 추가 -// 뒷박 원천 차단 5게이트 완성 (기존 V2의 3게이트 → 5게이트) -// ═══════════════════════════════════════════════════════════════════════ - -/** - * applyAlegGate4And5_ - * alegRows에 GATE_4(PAE연동) + GATE_5(블랙리스트) 추가. - * Direction A2: BLOCK if ANY gate(1~5)=BLOCK - */ -function applyAlegGate4And5_(alegRows, paeRows, hApex) { - return applyAlegGate4And5Impl_(alegRows, paeRows, hApex); -} - - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P1-B: DISTRIBUTION_SELL_DETECTOR V1.1 — SIG_7/SIG_8 -// 설거지 신호 6개 → 8개, weighted_sum 임계값 5.0/3.0 상향 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * applyDsdV1_1Signals_ - * dsdRows에 SIG_7/SIG_8 추가 적용. - * Direction B3: weighted_sum >= 5.0 → DISTRIBUTION_CONFIRMED - */ -function applyDsdV1_1Signals_(dsdRows, dfMap) { - (dsdRows || []).forEach(function(dsdRow) { - var df = dfMap[dsdRow.ticker] || {}; - var close_ = toNumber_(df['Close'] || df.close) || 0; - var open_ = toNumber_(df['Open'] || df.open) || 0; - - // SIG_7: 연속 양봉 후 음봉 반전 (w=1.5) - var prev3Bull = df['Prev3D_AllBullish'] === true - || String(df['Prev3D_AllBullish'] || '').toUpperCase() === 'TRUE'; - var todayBear = close_ < open_ && close_ > 0 && open_ > 0; - var sig7 = prev3Bull && todayBear; - dsdRow.sig_7_reversal = sig7; - if (sig7) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5; - - // SIG_8: 개인집중유입 + 기관매도 (w=1.5) — 데이터 없으면 w=0 - var retailR = toNumber_(df['Retail_Buy_Ratio_5D'] || df.retail_buy_ratio_5d) || 0; - var instS = toNumber_(df['Inst_5D'] || df.inst_5d) || 0; - var sig8 = retailR > 0.70 && instS < 0; - dsdRow.sig_8_retail_inflow = sig8; - if (sig8) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5; - - // V1.1 임계값 재적용 - var ws = toNumber_(dsdRow.weighted_sum) || 0; - dsdRow.distribution_verdict = ws >= 5.0 ? 'DISTRIBUTION_CONFIRMED' - : ws >= 3.0 ? 'DISTRIBUTION_WARNING' - : 'NO_SIGNAL'; - - // 조기 경보 V2: (SIG_1 OR SIG_2) + RSI14 >= 70 - var rsi14 = toNumber_(df['RSI14'] || df.rsi14) || 0; - dsdRow.early_warning_v2 = (dsdRow.sig_1 || dsdRow.sig_2) && rsi14 >= 70; - dsdRow.dsd_version = 'V1.1'; - }); - return dsdRows; -} - - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] P1-C: MANDATORY_REDUCTION_PLAN_V1 -// 반도체 클러스터 한도 2배 초과 → 4주 의무 감축 계획 결정론적 산출 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * calcMandatoryReductionPlan_ - * Direction O2: mandatory_reduction_json을 하네스 확정값으로 잠금. - */ -function calcMandatoryReductionPlan_(semiconductorClusterGate, holdings, dfMap, h3, totalAsset) { - function toDateYmd_(v) { - if (!v) return null; - if (typeof v === 'string') return v.slice(0, 10); - if (Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime())) { - return Utilities.formatDate(v, 'Asia/Seoul', 'yyyy-MM-dd'); - } - return null; - } - - // [PROPOSAL51-FIX] calcSemiconductorClusterGate_ 반환키는 combined_pct (cluster_pct 아님) - var clusterPct = toNumber_((semiconductorClusterGate || {}).combined_pct - || (semiconductorClusterGate || {}).cluster_pct) || 0; - var clusterLimit = toNumber_((semiconductorClusterGate || {}).cap_pct - || (semiconductorClusterGate || {}).cluster_limit_pct) || 25; - - if (clusterPct <= clusterLimit * 2.0) { - return { is_mandatory: false, cluster_pct: clusterPct, cluster_limit_pct: clusterLimit, - formula_id: 'MANDATORY_REDUCTION_PLAN_V1' }; - } - - var excessPct = clusterPct - clusterLimit; - var weeklyReducPct = Math.ceil(excessPct / 4 * 10) / 10; - var weeklyReducKrw = Math.round(totalAsset * weeklyReducPct / 100); - var SEMI_TICKERS = ['005930','000660','229200','091160']; - var holdMap = {}; - (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; }); - var sellQtyMap = {}; - ((h3 && h3.sellQty) || []).forEach(function(sq) { sellQtyMap[sq.ticker] = sq; }); - - var reduction = []; - // 1순위: RS_BROKEN - (holdings || []).filter(function(h) { - var df = dfMap[h.ticker] || {}; - return SEMI_TICKERS.indexOf(h.ticker) >= 0 - && String(df['RS_Verdict'] || df.rs_verdict || '').toUpperCase() === 'BROKEN'; - }).forEach(function(h) { - reduction.push({ priority: 1, reason: 'RS_BROKEN', ticker: h.ticker, name: h.name || '', - suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null }); - }); - // 2순위: ETF - (holdings || []).filter(function(h) { - return SEMI_TICKERS.indexOf(h.ticker) >= 0 - && (h.name && (h.name.indexOf('KODEX') >= 0 || h.name.indexOf('TIGER') >= 0 - || h.name.indexOf('ETF') >= 0 || h.ticker === '229200')); - }).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); }) - .forEach(function(h) { - reduction.push({ priority: 2, reason: 'ETF_PREFERRED', ticker: h.ticker, name: h.name || '', - suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null }); - }); - // 3순위: APEX_SUPER - (holdings || []).filter(function(h) { - var df = dfMap[h.ticker] || {}; - return SEMI_TICKERS.indexOf(h.ticker) >= 0 - && String(df['Profit_Lock_Stage'] || df.profit_lock_stage || '').toUpperCase() === 'APEX_SUPER'; - }).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); }) - .forEach(function(h) { - reduction.push({ priority: 3, reason: 'APEX_SUPER_TRAILING', ticker: h.ticker, name: h.name || '', - suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null }); - }); - - var completeDate = addBusinessDays_(new Date(), 20); // 4주 × 5영업일 - - return { - is_mandatory: true, - cluster_pct: clusterPct, - cluster_limit_pct: clusterLimit, - current_excess_pct: Math.round(excessPct * 10) / 10, - weekly_reduction_target_pct: weeklyReducPct, - weekly_reduction_target_krw: weeklyReducKrw, - weeks_to_normalize: 4, - estimated_completion_date: toDateYmd_(completeDate), - reduction_priority: reduction, - formula_id: 'MANDATORY_REDUCTION_PLAN_V1' - }; -} - -// ═══════════════════════════════════════════════════════════════════════ -// [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1 -// cluster gate ↔ mandatory_reduction_plan 단일 소스 동기화 -// ═══════════════════════════════════════════════════════════════════════ - -/** - * syncSemiconductorCluster_ - * SEMICONDUCTOR_CLUSTER_SYNC_V1: cluster_gate ↔ mandatory_reduction_json 정합성 검증 및 자동 교정 - * - combined_pct > cap_pct * 2이면 is_mandatory=true 강제 - * - combined_pct <= cap_pct * 2이면 is_mandatory=false 강제 - * @param {Object} hApex — mandatory_reduction_json 포함 - * @return {{ status, corrected, before_is_mandatory, after_is_mandatory, cluster_pct, threshold_pct }} - */ -function syncSemiconductorCluster_(hApex) { - var mrj = (hApex && hApex.mandatory_reduction_json) || {}; - var clusterPct = toNumber_(mrj.cluster_pct) || 0; - var clusterLimit = toNumber_(mrj.cluster_limit_pct) || 25; - var threshold = clusterLimit * 2.0; - var shouldBeMandatory = clusterPct > threshold; - var wasMandatory = mrj.is_mandatory === true; - - var syncStatus, corrected; - if (shouldBeMandatory === wasMandatory) { - syncStatus = 'SYNCED'; - corrected = false; - } else { - syncStatus = 'CORRECTED'; - corrected = true; - // 인라인 교정 - mrj.is_mandatory = shouldBeMandatory; - if (shouldBeMandatory) { - // 의무 감축 활성화 시 최소 필드 보장 - mrj.current_excess_pct = Math.round((clusterPct - clusterLimit) * 10) / 10; - } else { - // 의무 감축 비활성화 — 세부 필드 제거 - delete mrj.current_excess_pct; - delete mrj.weekly_reduction_target_pct; - delete mrj.weekly_reduction_target_krw; - delete mrj.reduction_priority; - } - hApex.mandatory_reduction_json = mrj; - Logger.log('[SCRSV1] CLUSTER_SYNC 교정: is_mandatory ' + wasMandatory - + ' → ' + shouldBeMandatory + ' (cluster=' + clusterPct + '%, threshold=' + threshold + '%)'); - } - - return { - formula_id: 'SEMICONDUCTOR_CLUSTER_SYNC_V1', - status: syncStatus, - corrected: corrected, - cluster_pct: clusterPct, - threshold_pct: threshold, - cap_pct: clusterLimit, - before_is_mandatory: wasMandatory, - after_is_mandatory: shouldBeMandatory - }; -} - - -/** - * HS007: validateOrderCondition_ - * 주문 조건 텍스트에 다중 조건 접속사가 포함되면 INVALID_MULTI_CONDITION 반환. - * HTS 자동주문은 단일 지정가만 허용 — 접속사 복합 조건은 HTS 오입력 원인. - */ -function validateOrderCondition_(text) { - if (!text || typeof text !== 'string') { - return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' }; - } - var MULTI_CONDITION_PATTERNS = [ - '또는', '혹은', '동시 충족', '동시충족', - '실패 시', '실패시', '회복 실패', '회복실패', - '돌파 실패', '돌파실패', '이탈 또는', '초과 또는', - '또는 이하', '또는 이상', '이거나', '이면서' - ]; - var matched = MULTI_CONDITION_PATTERNS.filter(function(p) { - return text.indexOf(p) >= 0; - }); - if (matched.length > 0) { - return { - valid: false, - status: 'INVALID_MULTI_CONDITION', - matched_conjunctions: matched, - resolution: '단일 가격 조건만 기재 (예: "종가 196,500원 이탈 시")', - formula_id: 'VALIDATE_ORDER_CONDITION_V1' - }; - } - return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' }; -} - -/** - * H10 (HS010_REVISED): buildShadowLedger_ - * BLOCKED/INVALID 블루프린트를 그림자 원장으로 분리. - * 차단 여부와 무관하게 산출 지표를 투명하게 보존 — 사용자의 사후 평가·오버라이드 지원. - */ -function buildShadowLedger_(blueprints, dfMap) { - dfMap = dfMap || {}; - var ledger = []; - var bpRows = Array.isArray(blueprints) ? blueprints : []; - bpRows.forEach(function(bp) { - var isBlocked = bp.validation_status === 'BLOCKED' - || bp.validation_status === 'INVALID' - || String(bp.validation_status || '').indexOf('INVALID') === 0; - if (!isBlocked) return; - var df = dfMap[bp.ticker] || {}; - ledger.push({ - ticker: bp.ticker, - name: bp.name || df.name || '', - block_reason: bp.rationale_code || bp.validation_status || 'BLOCKED', - order_type: bp.order_type || '', - limit_price_calc: bp.limit_price || null, - ["stop_loss_calc"]: bp["stop_loss"] || df["stop_loss_price"] || null, - ["take_profit_calc"]: bp["take_profit"] || df["tp1_price"] || null, - base_qty_calc: bp.qty || df.base_qty || null, - value_at_risk_krw: bp.value_at_risk_krw || null, - override_possible: true, - formula_id: 'SHADOW_LEDGER_V1' - }); - }); - return { - shadow_ledger: ledger, - blocked_count: ledger.length, - formula_id: 'SHADOW_LEDGER_V1' - }; -} - -/** - * D2: calcLlmServingConstraint_ - * LLM 12가지 금지행동 체크리스트 — 보고서 조립 직전 실행. - * 하나라도 위반 가능성이 있으면 INVALID_LLM_OVERRIDE 태그를 반환하여 보고서에 표기. - */ -function calcLlmServingConstraint_(hApex) { - var h = hApex || {}; - var violations = []; - - // Check 1: 미등록 공식 사용 가능성 — serving_lock_json numeric_generation_allowed - var sLock = h.serving_lock_json || {}; - var budget = sLock.llm_serving_budget || {}; - if (budget.numeric_generation_allowed !== 0) { - violations.push({ check: 1, rule: '미등록 공식으로 지정가/수량 산출', status: 'WARN_NOT_LOCKED' }); - } - - // Check 2: BLOCK 판정 우회 — hts_entry_allowed=false인데 blueprint PASS 존재 불가 - var exportGate = h.export_gate_json || {}; - if (exportGate.hts_entry_allowed === false) { - var blueprints = h.order_blueprint_json || []; - var passCount = (Array.isArray(blueprints) ? blueprints : []).filter(function(b) { - return b.validation_status === 'PASS'; - }).length; - if (passCount > 0) { - violations.push({ check: 2, rule: 'hts_entry_allowed=false 상태에서 PASS blueprint 존재', status: 'VIOLATION' }); - } - } - - // Check 3: SELL_PRICE_SANITY INVALID 가격 복원 위험 — INVALID 종목이 shadow_ledger에 없으면 경고 - var shadowLedger = h.shadow_ledger_json || {}; - var invalidBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []) - .filter(function(b) { return String(b.validation_status || '').indexOf('INVALID') === 0; }); - if (invalidBlueprints.length > 0 && (!shadowLedger.blocked_count || shadowLedger.blocked_count === 0)) { - violations.push({ check: 3, rule: 'INVALID blueprint가 Shadow Ledger에 미포함', status: 'VIOLATION' }); - } - - // Check 5: K2 반등 대기 수량 — scrs_v2_json에 rebound_wait_qty가 있으면 분리 표기 의무 - var scrs = h.scrs_v2_json || {}; - var selectedCombo = Array.isArray(scrs.selected_combo) ? scrs.selected_combo : []; - if (selectedCombo.length > 0) { - var hasRebound = selectedCombo.some(function(c) { return c.rebound_wait_qty > 0; }); - if (hasRebound && !scrs._display_split_confirmed) { - violations.push({ check: 5, rule: 'K2 rebound_wait_qty 분리 미표기 위험', status: 'WARN' }); - } - } - - // Check 9: consistency_score < 90이면 보고서 계속 생성 금지 - var asResult = h.account_snapshot_result || {}; - var cScore = asResult.consistency_score; - if (typeof cScore === 'number' && cScore < 90) { - violations.push({ check: 9, rule: 'consistency_score=' + cScore + ' < 90 (ABORT 필요)', status: 'VIOLATION' }); - } - - // Check 10: mega_sell_alert=TRUE이면 BUY/ADD_ON 금지 - var macroJson = h.macro_event_json || {}; - if (macroJson.mega_sell_alert === true || macroJson.mega_sell_alert === 'TRUE') { - var buyBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []) - .filter(function(b) { return b.order_type === 'BUY' || b.order_type === 'ADD_ON'; }); - if (buyBlueprints.length > 0) { - violations.push({ check: 10, rule: 'mega_sell_alert=TRUE 상태에서 BUY/ADD_ON blueprint 존재', status: 'VIOLATION' }); - } - } - - // Check 11: synthesis_verdict=BEARISH 종목에 BUY 금지 - var paeRows = h.predictive_alpha_json || []; - var bearishTickers = (Array.isArray(paeRows) ? paeRows : []) - .filter(function(r) { return r.synthesis_verdict === 'BEARISH'; }) - .map(function(r) { return r.ticker; }); - if (bearishTickers.length > 0) { - (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []).forEach(function(b) { - if ((b.order_type === 'BUY' || b.order_type === 'ADD_ON') && bearishTickers.indexOf(b.ticker) >= 0) { - violations.push({ check: 11, rule: 'synthesis_verdict=BEARISH 종목 BUY blueprint: ' + b.ticker, status: 'VIOLATION' }); - } - }); - } - - var constraintStatus = violations.some(function(v) { return v.status === 'VIOLATION'; }) - ? 'INVALID_LLM_OVERRIDE' : violations.length > 0 ? 'WARN' : 'PASS'; - - return { - constraint_status: constraintStatus, - violations: violations, - violation_count: violations.filter(function(v) { return v.status === 'VIOLATION'; }).length, - warn_count: violations.filter(function(v) { return v.status === 'WARN' || v.status === 'WARN_NOT_LOCKED'; }).length, - total_checks: 12, - formula_id: 'LLM_SERVING_CONSTRAINT_V1' - }; -} - -/** - * H6: calcAvgTradeValueSignal_ - * secular_leader(005930·000660) PROFIT_LOCK_STAGE_20 구간에서 - * 5일 평균 거래대금 > 20일 평균 × 3.0이면 과열신호 +1 판정. - */ -function calcAvgTradeValueSignal_(ticker, df) { - df = df || {}; - var SECULAR_TICKERS = ['005930', '000660']; - var isSecular = SECULAR_TICKERS.indexOf(String(ticker || '')) >= 0; - var stage = String(df.profit_lock_stage || df.Profit_Lock_Stage || '').toUpperCase(); - var avgVal5d = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0; - var avgVal20d = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0; - - if (!isSecular || stage !== 'PROFIT_LOCK_20' || avgVal20d <= 0) { - return { - ticker: ticker, - applicable: false, - signal: 'NOT_APPLICABLE', - avg_trade_val_5d: avgVal5d, - avg_trade_val_20d: avgVal20d, - overheat_triggered: false, - formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1' - }; - } - - var ratio = avgVal5d / avgVal20d; - var overheat = ratio >= 3.0; - return { - ticker: ticker, - applicable: true, - signal: overheat ? 'OVERHEAT_TRADE_VALUE' : 'NORMAL', - avg_trade_val_5d: avgVal5d, - avg_trade_val_20d: avgVal20d, - ratio_5d_vs_20d: Math.round(ratio * 100) / 100, - overheat_triggered: overheat, - overheat_score_add: overheat ? 1 : 0, - threshold: 3.0, - formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1' - }; -} - -/** - * G2: calcTrimPlanMinCash_ - * 최소 현금(cash_floor) 달성을 위한 결정론적 TRIM 계획 산출. - * H2 매도후보 순위(sell_priority) 그대로 종목 순서를 결정 — LLM 임의 선택 금지. - */ -function calcTrimPlanMinCash_(holdings, dfMap, cashShortfallInfo, sellPriorityList) { - dfMap = dfMap || {}; - var shortfall = toNumber_((cashShortfallInfo || {}).cash_shortfall_min_krw) || 0; - var plan = []; - var accumulatedKrw = 0; - var holdingRows = Array.isArray(holdings) ? holdings : []; - var priorityRows = Array.isArray(sellPriorityList) ? sellPriorityList : []; - - priorityRows.forEach(function(sp) { - if (accumulatedKrw >= shortfall) return; - var h = holdingRows.find(function(x) { return x.ticker === sp.ticker; }) || {}; - var df = dfMap[sp.ticker] || {}; - var avgCost = toNumber_(h.avg_cost || h.average_cost) || 0; - var qty = toNumber_(h.qty || h.quantity) || 0; - - if (qty === 0 || avgCost === 0) { - plan.push({ - priority: sp.priority || plan.length + 1, - ticker: sp.ticker, - name: sp.name || df.name || '', - sell_qty: 'CAPTURE_REQUIRED', - estimated_sell_krw: 0, - sell_price_ref: null, - accumulated_krw: accumulatedKrw, - shortfall_covered: false, - note: 'CAPTURE_REQUIRED: qty/cost 미확정' - }); - return; - } - - var closePrice = toNumber_(df.close || df.close_price) || avgCost; - var remaining = shortfall - accumulatedKrw; - var neededQty = Math.ceil(remaining / closePrice); - var sellQty = Math.min(neededQty, qty); - var estimatedKrw = sellQty * closePrice; - accumulatedKrw += estimatedKrw; - - plan.push({ - priority: sp.priority || plan.length + 1, - ticker: sp.ticker, - name: sp.name || df.name || '', - sell_qty: sellQty, - estimated_sell_krw: Math.round(estimatedKrw), - sell_price_ref: closePrice, - accumulated_krw: Math.round(accumulatedKrw), - shortfall_covered: accumulatedKrw >= shortfall, - note: accumulatedKrw >= shortfall ? 'SHORTFALL_MET' : 'PARTIAL' - }); - }); - - return { - cash_shortfall_min_krw: Math.round(shortfall), - plan: plan, - total_plan_krw: Math.round(accumulatedKrw), - shortfall_fully_covered: accumulatedKrw >= shortfall, - is_plan_only: true, - hts_order_required: 'order_blueprint_json.validation_status 기준으로만 판단', - formula_id: 'TRIM_PLAN_MIN_CASH_V1' - }; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] F1 — TRADE_QUALITY_SCORER_V1 -// 실행된 매수·매도를 T+5/T+20 기준으로 자동 채점. -// trade_quality_history 시트를 읽어 미채점 레코드를 업데이트하고 결과 배열 반환. -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * calcTradeQualityScorer_ - * trade_quality_history 시트에서 미채점 레코드를 배치 처리. - * BUY: velocity/ma20/volume/t5/t20 각 20점 합산 (100점 만점) - * SELL: above_ma20/above_cost/not_too_early/cash_goal_met 각 25점 합산 (100점 만점) - */ -function calcTradeQualityScorer_(ss) { - try { - ss = ss || getSpreadsheet_(); - var sh = ss.getSheetByName('trade_quality_history'); - if (!sh) { - Logger.log('[F1] trade_quality_history 시트 없음'); - return { status: 'SHEET_NOT_FOUND', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; - } - - var data = sh.getDataRange().getValues(); - if (data.length < 2) { - return { status: 'NO_DATA', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; - } - - var header = data[0]; - var COL = {}; - header.forEach(function(h, i) { COL[String(h).trim()] = i; }); - - // 필수 컬럼 확인 - var REQ = ['ticker', 'action', 'scored']; - for (var ri = 0; ri < REQ.length; ri++) { - if (COL[REQ[ri]] == null) { - Logger.log('[F1] 필수 컬럼 누락: ' + REQ[ri]); - return { status: 'COLUMN_MISSING', missing: REQ[ri], scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; - } - } - - // 현재 종가 맵 (T+5/T+20 평가용) - var priceMap = {}; - var dfSheet = ss.getSheetByName('data_feed'); - if (dfSheet) { - var dfData = dfSheet.getDataRange().getValues(); - if (dfData.length > 1) { - var dfHeader = dfData[0]; - var tCol = dfHeader.indexOf('Ticker'); - var cCol = dfHeader.indexOf('Close'); - if (tCol >= 0 && cCol >= 0) { - for (var dri = 1; dri < dfData.length; dri++) { - var tk = String(dfData[dri][tCol] || '').trim(); - var cl = parseFloat(String(dfData[dri][cCol] || '')); - if (tk && !isNaN(cl) && cl > 0) priceMap[tk] = cl; - } - } - } - } - - var todayMs = new Date().getTime(); - var scoredResults = []; - var scoredThisRun = 0; - - for (var i = 1; i < data.length; i++) { - var row = data[i]; - var alreadyScored = String(row[COL['scored']] || '').toUpperCase(); - if (alreadyScored === 'TRUE' || alreadyScored === 'SCORED') continue; - - var ticker = String(row[COL['ticker']] || '').trim(); - var action = String(row[COL['action']] || '').toUpperCase(); - if (!ticker) continue; - - var entryDate = row[COL['entry_date'] != null ? COL['entry_date'] : -1]; - var daysSinceEntry = entryDate ? (todayMs - new Date(entryDate).getTime()) / 86400000 : 0; - - // T+5 이상 경과해야 채점 (T+20 필드는 optional) - if (COL['entry_date'] != null && daysSinceEntry < 7) continue; - - var score = 0; - var subscores = {}; - var feedbackTag = 'GOOD_EXECUTION'; - - if (action === 'BUY') { - // 매수 품질 채점 - var velocity1d = parseFloat(String(row[COL['velocity_1d_at_entry'] != null ? COL['velocity_1d_at_entry'] : -1] || '')); - var entryPrice = parseFloat(String(row[COL['entry_price'] != null ? COL['entry_price'] : -1] || '')); - var ma20Entry = parseFloat(String(row[COL['ma20_at_entry'] != null ? COL['ma20_at_entry'] : -1] || '')); - var volRatio = parseFloat(String(row[COL['volume_ratio_at_entry'] != null ? COL['volume_ratio_at_entry'] : -1] || '')); - var t5RetPct = parseFloat(String(row[COL['t5_return_pct'] != null ? COL['t5_return_pct'] : -1] || '')); - var t20VsCore = parseFloat(String(row[COL['t20_vs_core_pctp'] != null ? COL['t20_vs_core_pctp'] : -1] || '')); - - // velocity_ok: 진입일 속도 < 1% (추격 아님) - if (!isNaN(velocity1d) && velocity1d < 1) { score += 20; subscores.velocity_ok = 20; } - else subscores.velocity_ok = 0; - - // ma20_proximity: 진입가 ≤ MA20 × 1.01 - if (!isNaN(entryPrice) && !isNaN(ma20Entry) && ma20Entry > 0 && entryPrice <= ma20Entry * 1.01) { - score += 20; subscores.ma20_proximity = 20; - } else subscores.ma20_proximity = 0; - - // volume_confirm: 거래량비율 ≥ 1.2 - if (!isNaN(volRatio) && volRatio >= 1.2) { score += 20; subscores.volume_confirm = 20; } - else subscores.volume_confirm = 0; - - // t5_positive: T+5 수익률 > 0 - if (!isNaN(t5RetPct) && t5RetPct > 0) { score += 20; subscores.t5_positive = 20; } - else subscores.t5_positive = 0; - - // t20_alpha: T+20 대비 코어 초과 > 0 - if (!isNaN(t20VsCore) && t20VsCore > 0) { score += 20; subscores.t20_alpha = 20; } - else subscores.t20_alpha = 0; - - // 피드백 태그 - if (subscores.velocity_ok === 0 && subscores.ma20_proximity === 0) feedbackTag = 'CHASE_ENTRY'; - else if (subscores.t5_positive === 0 && subscores.t20_alpha === 0) feedbackTag = 'DISTRIBUTION_ENTRY'; - - } else if (action === 'SELL') { - // 매도 품질 채점 - var sellPrice = parseFloat(String(row[COL['sell_price'] != null ? COL['sell_price'] : -1] || '')); - var ma20Sell = parseFloat(String(row[COL['ma20_at_sell'] != null ? COL['ma20_at_sell'] : -1] || '')); - var avgCost = parseFloat(String(row[COL['average_cost'] != null ? COL['average_cost'] : -1] || '')); - var priceT5After = parseFloat(String(row[COL['price_t5_after_sell'] != null ? COL['price_t5_after_sell'] : -1] || '')); - var cashRecov = parseFloat(String(row[COL['cash_recovered_krw'] != null ? COL['cash_recovered_krw'] : -1] || '')); - var cashGoal = parseFloat(String(row[COL['cash_shortfall_min_krw'] != null ? COL['cash_shortfall_min_krw'] : -1] || '')); - - // above_ma20: 매도가 ≥ MA20 × 0.99 - if (!isNaN(sellPrice) && !isNaN(ma20Sell) && ma20Sell > 0 && sellPrice >= ma20Sell * 0.99) { - score += 25; subscores.above_ma20 = 25; - } else subscores.above_ma20 = 0; - - // above_cost: 매도가 ≥ 평단 - if (!isNaN(sellPrice) && !isNaN(avgCost) && avgCost > 0 && sellPrice >= avgCost) { - score += 25; subscores.above_cost = 25; - } else subscores.above_cost = 0; - - // not_too_early: T+5 사후 종가가 없거나 매도가 이상 - if (isNaN(priceT5After) || priceT5After <= sellPrice) { - score += 25; subscores.not_too_early = 25; - } else subscores.not_too_early = 0; - - // cash_goal_met: 실제 회수액 ≥ 목표 부족분 - if (!isNaN(cashRecov) && !isNaN(cashGoal) && cashGoal > 0 && cashRecov >= cashGoal) { - score += 25; subscores.cash_goal_met = 25; - } else subscores.cash_goal_met = 0; - - // 피드백 태그 - if (subscores.above_cost === 0) feedbackTag = 'PANIC_EXIT'; - else if (subscores.not_too_early === 0) feedbackTag = 'OVERSOLD_PANIC'; - } else { - continue; // BUY/SELL 이외 레코드 스킵 - } - - // 등급 결정 - var grade; - if (score >= 90) grade = 'EXCELLENT'; - else if (score >= 75) grade = 'GOOD'; - else if (score >= 60) grade = 'ACCEPTABLE'; - else if (score >= 40) grade = 'POOR'; - else grade = 'CRITICAL'; - - if (grade === 'POOR' || grade === 'CRITICAL') { - feedbackTag = score < 40 ? 'PATTERN_ALERT' : 'CHASE_ENTRY_OR_PANIC_EXIT'; - } else if (grade === 'EXCELLENT' || grade === 'GOOD') { - feedbackTag = 'GOOD_EXECUTION'; - } - - // 시트 업데이트 - var scoreCol = COL['score'] != null ? COL['score'] + 1 : null; - var gradeCol = COL['grade'] != null ? COL['grade'] + 1 : null; - var fbTagCol = COL['feedback_tag'] != null ? COL['feedback_tag'] + 1 : null; - var scoredCol = COL['scored'] != null ? COL['scored'] + 1 : null; - - if (scoreCol) sh.getRange(i + 1, scoreCol).setValue(score); - if (gradeCol) sh.getRange(i + 1, gradeCol).setValue(grade); - if (fbTagCol) sh.getRange(i + 1, fbTagCol).setValue(feedbackTag); - if (scoredCol) sh.getRange(i + 1, scoredCol).setValue('SCORED'); - - scoredResults.push({ - row: i, - ticker: ticker, - action: action, - score: score, - grade: grade, - feedback_tag: feedbackTag, - subscores: subscores, - formula_id: 'TRADE_QUALITY_SCORER_V1' - }); - scoredThisRun++; - } - - // 전체 기록 집계 (기존 채점 포함) - var allResults = []; - var freshData = sh.getDataRange().getValues(); - for (var j = 1; j < freshData.length; j++) { - var r = freshData[j]; - var sc = String(r[COL['scored']] || '').toUpperCase(); - if (sc !== 'TRUE' && sc !== 'SCORED') continue; - allResults.push({ - ticker: String(r[COL['ticker']] || '').trim(), - action: String(r[COL['action']] || '').toUpperCase(), - score: parseFloat(String(r[COL['score']] || '')) || 0, - grade: String(r[COL['grade']] || 'UNKNOWN'), - feedback_tag: String(r[COL['feedback_tag']] || '') - }); - } - - Logger.log('[F1] calcTradeQualityScorer_ 완료: 이번 채점=' + scoredThisRun + '건, 전체=' + allResults.length + '건'); - - // F2: F1 완료 직후 블랙리스트 자동 갱신 (F1 → F2 파이프라인) - try { - calcPatternBlacklistAuto_(allResults); - } catch (pbErr) { - Logger.log('[F1] calcPatternBlacklistAuto_ 연동 오류: ' + pbErr.message); - } - - var f1Result = { - status: 'OK', - scored_count: scoredThisRun, - total_records: allResults.length, - trade_quality: allResults, - last_computed: new Date().toISOString(), - formula_id: 'TRADE_QUALITY_SCORER_V1' - }; - - // settings 시트에 trade_quality_json 캐시 저장 (harness_rows 일간 출력용) - // 셀 50K 한도 초과 방지: trade_quality 최근 100건만 저장 - try { - var setSh = ss.getSheetByName('settings'); - if (setSh) { - var sData = setSh.getDataRange().getValues(); - var updated = false; - var f1Slim = Object.assign({}, f1Result, - { trade_quality: (f1Result.trade_quality || []).slice(-100) }); - var serialized = JSON.stringify(f1Slim); - for (var si = 0; si < sData.length; si++) { - if (String(sData[si][0] || '').trim() === 'trade_quality_json') { - setSh.getRange(si + 1, 2).setValue(serialized); - updated = true; - break; - } - } - if (!updated) setSh.appendRow(['trade_quality_json', serialized]); - } - } catch(writeErr) { - Logger.log('[F1] settings 시트 기록 실패: ' + writeErr.message); - } - - return f1Result; - } catch(e) { - Logger.log('[F1] calcTradeQualityScorer_ 오류: ' + e.message); - return { status: 'ERROR', error: e.message, scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; - } -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] F2 — PATTERN_BLACKLIST_AUTO_V1 -// 동일 ticker POOR/CRITICAL 3회 누적 → PATTERN_BLACKLIST_TRIGGERED -// 3회 연속 GOOD(75+) 달성 시 해제 -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * calcPatternBlacklistAuto_ - * trade_quality_json 배열을 받아 ticker별 POOR/CRITICAL 누적 횟수를 계산. - * 3회 이상이면 PATTERN_BLACKLIST_TRIGGERED, 3회 연속 GOOD 이상이면 해제. - * 결과를 settings 시트의 pattern_blacklist_json에 기록. - */ -function calcPatternBlacklistAuto_(tradeQualityHistory) { - try { - var history = Array.isArray(tradeQualityHistory) ? tradeQualityHistory : []; - - // ticker별 그룹화 - var tickerMap = {}; - history.forEach(function(rec) { - var tk = String(rec.ticker || '').trim(); - if (!tk) return; - if (!tickerMap[tk]) tickerMap[tk] = []; - tickerMap[tk].push({ - grade: String(rec.grade || '').toUpperCase(), - score: typeof rec.score === 'number' ? rec.score : (parseFloat(String(rec.score || '')) || 0) - }); - }); - - var blacklistEntries = []; - var triggeredCount = 0; - - Object.keys(tickerMap).forEach(function(ticker) { - var records = tickerMap[ticker]; - - // POOR/CRITICAL 누적 카운트 - var poorCriticalCount = records.filter(function(r) { - return r.grade === 'POOR' || r.grade === 'CRITICAL'; - }).length; - - // 해제 조건: 마지막 3건이 모두 GOOD(75+) 이상 - var releaseMet = false; - if (records.length >= 3) { - var last3 = records.slice(-3); - releaseMet = last3.every(function(r) { - return (r.grade === 'GOOD' || r.grade === 'EXCELLENT') && r.score >= 75; - }); - } - - var status; - if (releaseMet && poorCriticalCount >= 3) { - status = 'CLEAR'; // 블랙리스트 해제 - } else if (poorCriticalCount >= 3) { - status = 'TRIGGERED'; - triggeredCount++; - } else { - status = 'CLEAR'; - } - - blacklistEntries.push({ - ticker: ticker, - pattern_blacklist_status: status, - accumulated_poor_count: poorCriticalCount, - total_records: records.length, - release_condition_met: releaseMet, - saqg_override: status === 'TRIGGERED' ? 'EXCLUDED' : 'NO_CHANGE', - alpha_score_cap: status === 'TRIGGERED' ? 50 : null, - formula_id: 'PATTERN_BLACKLIST_AUTO_V1' - }); - }); - - // settings 시트에 pattern_blacklist_json 기록 (wrapper 객체 형태로 저장) - try { - var ss = getSpreadsheet_(); - var settingSh = ss.getSheetByName('settings'); - if (settingSh) { - var sData = settingSh.getDataRange().getValues(); - var updated = false; - var wrapperObj = { - status: 'OK', - triggered_count: triggeredCount, - total_tickers: blacklistEntries.length, - patterns: blacklistEntries, - pattern_count: blacklistEntries.length, - computed_at: new Date().toISOString(), - formula_id: 'PATTERN_BLACKLIST_AUTO_V1' - }; - var serialized = JSON.stringify(wrapperObj); - for (var si = 0; si < sData.length; si++) { - if (String(sData[si][0] || '').trim() === 'pattern_blacklist_json') { - settingSh.getRange(si + 1, 2).setValue(serialized); - updated = true; - break; - } - } - if (!updated) settingSh.appendRow(['pattern_blacklist_json', serialized]); - } - } catch(writeErr) { - Logger.log('[F2] settings 시트 기록 실패: ' + writeErr.message); - } - - Logger.log('[F2] calcPatternBlacklistAuto_ 완료: TRIGGERED=' + triggeredCount + '/' + blacklistEntries.length + '건'); - return { - status: 'OK', - triggered_count: triggeredCount, - total_tickers: blacklistEntries.length, - patterns: blacklistEntries, - pattern_count: blacklistEntries.length, - formula_id: 'PATTERN_BLACKLIST_AUTO_V1' - }; - } catch(e) { - Logger.log('[F2] calcPatternBlacklistAuto_ 오류: ' + e.message); - return { status: 'ERROR', error: e.message, triggered_count: 0, patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }; - } -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// [PROPOSAL50] ALPHA_FEEDBACK_LOOP_V1 -// monthly_history의 AEW_V1 성과 데이터를 분석해 SAQG_V1 필터 임계값 조정 권고 생성. -// 임계값 자동 변경 금지 — 권고(RECOMMENDATION)만 출력. -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * calcAlphaFeedbackLoop_ - * alpha_evaluation_window_json (AEW_V1 결과) 에서 ELIGIBLE 케이스를 분석해 - * SAQG F1/F2/F3 임계값 조정 권고를 생성한다. - * 10건 미만이면 DATA_INSUFFICIENT — 권고 생성 금지. - */ -function calcAlphaFeedbackLoop_() { - try { - var ss = getSpreadsheet_(); - var aewRows = []; - - // monthly_history 시트에서 AEW 데이터 수집 - var mhSh = ss.getSheetByName('monthly_history'); - if (mhSh) { - var mhData = mhSh.getDataRange().getValues(); - if (mhData.length > 1) { - var mhHeader = mhData[0]; - var COL = {}; - mhHeader.forEach(function(h, i) { COL[String(h).trim()] = i; }); - - for (var i = 1; i < mhData.length; i++) { - var row = mhData[i]; - var saqg = String(row[COL['saqg_v1'] != null ? COL['saqg_v1'] : -1] || '').toUpperCase(); - var t20Sam = parseFloat(String(row[COL['t20_vs_samsung_pctp'] != null ? COL['t20_vs_samsung_pctp'] : -1] || '')); - var brtV = String(row[COL['brt_verdict'] != null ? COL['brt_verdict'] : -1] || '').toUpperCase(); - var regime = String(row[COL['market_regime'] != null ? COL['market_regime'] : -1] || ''); - if (!saqg) continue; - aewRows.push({ saqg_v1: saqg, t20_vs_samsung_pctp: isNaN(t20Sam) ? null : t20Sam, brt_verdict: brtV, market_regime: regime }); - } - } - } - - var eligibleRows = aewRows.filter(function(r) { return r.saqg_v1 === 'ELIGIBLE'; }); - var casesAnalyzed = eligibleRows.length; - - var now = new Date(); - var asOf = now.toISOString().split('T')[0]; - var analysisPeriod = asOf.substring(0, 7); // 'YYYY-MM' - - if (casesAnalyzed < 10) { - Logger.log('[AFL] calcAlphaFeedbackLoop_: 데이터 부족(' + casesAnalyzed + '건) — 권고 생성 건너뜀'); - return { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: asOf, - analysis_period: analysisPeriod, - status: 'DATA_INSUFFICIENT', - cases_analyzed: casesAnalyzed, - grade_count: 0, - eligible_t20_fail_rate: null, - eligible_t60_fail_rate: null, - recommended_filter_adjustments: [], - grade_summary: [] - }; - } - - // T+20 알파 실패율 계산 (t20_vs_samsung_pctp < -3) - var t20WithData = eligibleRows.filter(function(r) { return r.t20_vs_samsung_pctp !== null; }); - var t20FailRows = t20WithData.filter(function(r) { return r.t20_vs_samsung_pctp < -3; }); - var t20PassRows = t20WithData.length - t20FailRows.length; - var t20FailRate = t20WithData.length > 0 - ? Math.round(t20FailRows.length / t20WithData.length * 1000) / 10 - : null; - var t20PassRate = t20WithData.length > 0 - ? Math.round(t20PassRows / t20WithData.length * 1000) / 10 - : null; - - // BRT_VERDICT=BROKEN 케이스 비율 - var brokenCount = eligibleRows.filter(function(r) { return r.brt_verdict === 'BROKEN'; }).length; - var brokenRate = eligibleRows.length > 0 - ? Math.round(brokenCount / eligibleRows.length * 1000) / 10 : 0; - - // grade_summary — saqg_v1 값별로 집계 - var gradeCounts = {}; - aewRows.forEach(function(r) { - var g = r.saqg_v1 || 'UNKNOWN'; - if (!gradeCounts[g]) gradeCounts[g] = { t20_total: 0, t20_pass: 0, t20_fail: 0 }; - if (r.t20_vs_samsung_pctp !== null) { - gradeCounts[g].t20_total++; - if (r.t20_vs_samsung_pctp >= 0) gradeCounts[g].t20_pass++; - else gradeCounts[g].t20_fail++; - } - }); - var gradeSummary = Object.keys(gradeCounts).map(function(g) { - var gd = gradeCounts[g]; - var passRate = gd.t20_total > 0 ? Math.round(gd.t20_pass / gd.t20_total * 1000) / 10 : null; - var failRate = gd.t20_total > 0 ? Math.round(gd.t20_fail / gd.t20_total * 1000) / 10 : null; - return { - grade: g, - t20_total: gd.t20_total, - t20_pass: gd.t20_pass, - t20_pass_rate: passRate, - t20_fail_rate: failRate, - t60_total: 0, // T+60 데이터 미수집 — 향후 확장 - t60_pass: 0, - t60_pass_rate: null, - t60_fail_rate: null, - status: gd.t20_total === 0 ? 'DATA_INSUFFICIENT' : 'OK' - }; - }); - - // 권고 생성 — 렌더러 계약 필드명: filter_id, current, recommended, action, rationale - var recommendations = []; - - if (t20FailRate !== null && t20FailRate > 50) { - recommendations.push({ - filter_id: 'SAQG_F1_F2_F3', - current: 'CURRENT_THRESHOLDS', - recommended: 'TIGHTEN: F2 recovery_ratio 1.20 → 1.35', - action: 'TIGHTEN', - rationale: 'ELIGIBLE T+20 알파 실패율 ' + t20FailRate + '% > 50% 기준 초과' - }); - } - - if (t20PassRate !== null && t20PassRate > 70 && casesAnalyzed >= 12) { - recommendations.push({ - filter_id: 'SAQG_F3', - current: 'excess_drawdown 5%p', - recommended: 'RELAX: excess_drawdown 5%p → 7%p', - action: 'RELAX', - rationale: 'ELIGIBLE T+20 성공률 ' + t20PassRate + '% > 70% (케이스 ' + casesAnalyzed + '건)' - }); - } - - if (brokenRate > 30) { - recommendations.push({ - filter_id: 'BRT_VERDICT_GATE', - current: 'CURRENT_THRESHOLDS', - recommended: 'TIGHTEN: BRT_BROKEN 진입 차단 강화', - action: 'TIGHTEN', - rationale: 'ELIGIBLE 중 BRT_BROKEN 비율 ' + brokenRate + '% > 30%' - }); - } - - Logger.log('[AFL] calcAlphaFeedbackLoop_ 완료: cases=' + casesAnalyzed + ' t20FailRate=' + t20FailRate + '% recs=' + recommendations.length); - - var result = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: asOf, - analysis_period: analysisPeriod, - status: 'OK', - cases_analyzed: casesAnalyzed, - grade_count: gradeSummary.length, - eligible_t20_fail_rate: t20FailRate, - eligible_t60_fail_rate: null, - t20_pass_rate: t20PassRate, - brt_broken_rate: brokenRate, - recommended_filter_adjustments: recommendations, - grade_summary: gradeSummary, - note: '임계값 자동 변경 금지 — 사용자 확인 후 settings 수동 반영' - }; - - // settings 시트에 기록 - try { - var settingSh = ss.getSheetByName('settings'); - if (settingSh) { - var sData = settingSh.getDataRange().getValues(); - var updated = false; - var serialized = JSON.stringify(result); - for (var si = 0; si < sData.length; si++) { - if (String(sData[si][0] || '').trim() === 'alpha_feedback_json') { - settingSh.getRange(si + 1, 2).setValue(serialized); - updated = true; - break; - } - } - if (!updated) settingSh.appendRow(['alpha_feedback_json', serialized]); - } - } catch(writeErr) { - Logger.log('[AFL] settings 시트 기록 실패: ' + writeErr.message); - } - - return result; - } catch(e) { - Logger.log('[AFL] calcAlphaFeedbackLoop_ 오류: ' + e.message); - return { status: 'ERROR', error: e.message, cases_analyzed: 0, recommended_filter_adjustments: [], formula_id: 'ALPHA_FEEDBACK_LOOP_V1' }; - } -} - -/** AFL 일간 하네스 호출 래퍼 — calcAlphaFeedbackLoop_ 위임 */ -function runAlphaFeedbackLoop_() { - return calcAlphaFeedbackLoop_(); -} - -/** - * AFL 캐시 읽기 — settings 시트에서 마지막 저장된 alpha_feedback_json 반환. - * calcAlphaFeedbackLoop_ 오류 시 fallback으로 사용. - */ -function getAlphaFeedbackJson_() { - try { - var ss = getSpreadsheet_(); - var sh = ss.getSheetByName('settings'); - if (!sh) return { status: 'SETTINGS_NOT_FOUND', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' }; - var data = sh.getDataRange().getValues(); - for (var i = 0; i < data.length; i++) { - if (String(data[i][0] || '').trim() === 'alpha_feedback_json') { - var raw = data[i][1]; - if (!raw) break; - try { return JSON.parse(String(raw)); } catch(pe) { break; } - } - } - } catch(e) { - Logger.log('[AFL] getAlphaFeedbackJson_ 읽기 실패: ' + e.message); - } - return { status: 'CACHE_EMPTY', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' }; -} - - diff --git a/src/gas/engines/gdf_06_rebalance.gs b/src/gas/engines/gdf_06_rebalance.gs deleted file mode 100644 index 1c6e899..0000000 --- a/src/gas/engines/gdf_06_rebalance.gs +++ /dev/null @@ -1,419 +0,0 @@ -// gdf_06_rebalance.gs — REBALANCE_ENGINE_V1 (GAS) -// -// runRebalanceSheet_(): data_feed + account_snapshot 라이브 데이터 기반 -// bucket drift → 레짐 적응 밴드 → 비용효익 게이트 → 3단계 분할 실행 계획 -// GatherTradingData.xlsx > rebalance 시트에 4섹션(SUMMARY/BUCKETS/TICKERS/ORDERS) 출력. - -// ── 버킷 설정 (gdf_01_price_metrics.gs THRESHOLDS 와 동기화) ───────────────── -const RB_BUCKET_CONFIG = { - Core: { target: 66.0, min: 60.0, max: 72.0 }, - Satellite: { target: 17.5, min: 10.0, max: 25.0 }, - Cash: { target: 16.5, min: 10.0, max: 22.0 }, -}; - -// 코어 주도주 (isCoreLeader 기준, gdc_02_account_satellite.gs 와 일치) -const RB_CORE_TICKERS = new Set(["005930", "000660", "000270"]); - -// ── 레짐 적응 밴드 (P3) ────────────────────────────────────────────────────── -const RB_REGIME_BANDS = { - RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 }, - SECULAR_LEADER_RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 }, - NEUTRAL: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 }, - RISK_OFF_CANDIDATE: { label: "RISK_OFF_CANDIDATE +2/−10%p", expand: 2, contract: 10 }, - RISK_OFF: { label: "RISK_OFF +2/−10%p", expand: 2, contract: 10 }, - EVENT_SHOCK: { label: "RISK_OFF +2/−10%p", expand: 2, contract: 10 }, - _DEFAULT: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 }, -}; - -// ── 비용효익 게이트 (P4) ───────────────────────────────────────────────────── -const RB_TX_COST_ROUNDTRIP = 0.0070; // 0.35% × 2 -const RB_COST_BENEFIT_THRESHOLD = 0.0050; // 0.50%p -const RB_MIN_DRIFT_PCT = (RB_TX_COST_ROUNDTRIP + RB_COST_BENEFIT_THRESHOLD) * 100; // 1.20%p -const RB_LIMIT_PRICE_DISCOUNT = 0.002; // 매도 지정가 = 종가 × (1 - 0.2%) - -// ── 3단계 분할 비율 (P5) ───────────────────────────────────────────────────── -const RB_STAGE_RATIOS = [0.30, 0.30, 0.40]; - - -// ═══════════════════════════════════════════════════════════════════════════════ -// Public entry point -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * GatherTradingData.xlsx > rebalance 시트에 4섹션 리밸런싱 계획을 기록한다. - * 메뉴 또는 runDataFeed 후 자동 호출 가능. - */ -function runRebalanceSheet_() { - const tag = "runRebalanceSheet_"; - const startMs = Date.now(); - - try { - // 1. 데이터 로드 - const dfRows = _rbLoadDataFeedRows_(); - const settings = readSettingsTab_(); - const regime = _rbReadRegime_(settings); - const band = RB_REGIME_BANDS[regime] || RB_REGIME_BANDS["_DEFAULT"]; - - // 2. 보유 종목 필터링 (Weight_Pct > 0 || Account_Market_Value > 0) - const holdings = _rbFilterHoldings_(dfRows); - - // 3. 버킷별 현재 비중 집계 - const buckets = _rbComputeBuckets_(holdings, band); - - // 4. 종목별 분석 - const tickers = _rbComputeTickers_(holdings, band); - - // 5. ORDERS 생성 - const orders = _rbComputeOrders_(tickers); - - // 6. SUMMARY 생성 - const summary = _rbComputeSummary_(holdings, buckets, regime, band, orders.length); - - // 7. 시트 쓰기 - _writeRebalanceSheet_(summary, buckets, tickers, orders); - - const elapsed = Math.round((Date.now() - startMs) / 100) / 10; - Logger.log(`[${tag}] 완료: holdings=${holdings.length} orders=${orders.length} elapsed=${elapsed}s`); - - } catch (e) { - Logger.log(`[${tag}][ERROR] 오류: ${e.message}\n${e.stack}`); - throw e; - } -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// 데이터 로드 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbLoadDataFeedRows_() { - const raw = sheetToJson("data_feed"); - if (!Array.isArray(raw) || raw.length === 0) { - throw new Error("data_feed 시트가 비어 있거나 로드 실패"); - } - return raw; -} - -function _rbReadRegime_(settings) { - const raw = (settings["REGIME_PRELIM"] || settings["regime_prelim"] || "").trim().toUpperCase(); - return raw in RB_REGIME_BANDS ? raw : "_DEFAULT"; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// 보유 종목 필터링 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbFilterHoldings_(dfRows) { - return dfRows - .map(row => { - const ticker = String(row["Ticker"] ?? "").trim(); - if (!ticker) return null; - const weightPct = _rbNum_(row["Weight_Pct"]); - const acctMv = _rbNum_(row["Account_Market_Value"]); - if (weightPct <= 0 && acctMv <= 0) return null; - - return { - ticker: ticker, - name: String(row["Name"] ?? ""), - bucket: _rbAssignBucket_(ticker, row), - weightPct: weightPct, - acctMvKrw: acctMv, - holdingQty: _rbInt_(row["Account_Holding_Qty"]), - close: _rbNum_(row["Close"]), - finalAction: String(row["Final_Action"] ?? ""), - sellReason: String(row["Sell_Reason"] ?? ""), - forceSignal: _rbDetectForce_(row), - }; - }) - .filter(h => h !== null); -} - -function _rbAssignBucket_(ticker, row) { - const pt = String(row["position_type"] || row["Position_Type"] || "").trim().toLowerCase(); - if (pt === "core") return "Core"; - if (pt === "satellite") return "Satellite"; - return RB_CORE_TICKERS.has(ticker) ? "Core" : "Satellite"; -} - -function _rbDetectForce_(row) { - const combined = [ - row["Sell_Reason"], row["Final_Action"], row["Sell_Action"] - ].join(" ").toUpperCase(); - if (combined.includes("ABS_FLOOR")) return "ABS_FLOOR"; - if (combined.includes("TIME_STOP") || combined.includes("TIME_EXIT") || combined.includes("TIME_TRIM")) - return "TIME_STOP"; - return ""; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// 버킷 계산 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbComputeBuckets_(holdings, band) { - const corePct = holdings.filter(h => h.bucket === "Core").reduce((s, h) => s + h.weightPct, 0); - const satPct = holdings.filter(h => h.bucket === "Satellite").reduce((s, h) => s + h.weightPct, 0); - const cashPct = Math.max(0, 100 - corePct - satPct); - const current = { Core: corePct, Satellite: satPct, Cash: cashPct }; - - return Object.entries(RB_BUCKET_CONFIG).map(([bname, bcfg]) => { - const target = bcfg.target; - const cur = _rb2_(current[bname] || 0); - const drift = _rb2_(cur - target); - const bandMin = _rb2_(target - band.contract); - const bandMax = _rb2_(target + band.expand); - let driftStatus; - if (cur < bandMin) driftStatus = "BREACH_LOW"; - else if (cur > bandMax) driftStatus = "BREACH_HIGH"; - else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) driftStatus = "WARN"; - else driftStatus = "NORMAL"; - - return { bucket: bname, targetPct: target, currentPct: cur, driftPct: drift, - bandMin, bandMax, regimeBand: band.label, driftStatus }; - }); -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// 종목별 분석 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbComputeTickers_(holdings, band) { - // 버킷별 종목 수 집계 - const countMap = {}; - holdings.forEach(h => { countMap[h.bucket] = (countMap[h.bucket] || 0) + 1; }); - - return holdings.map(h => { - const bcfg = RB_BUCKET_CONFIG[h.bucket] || RB_BUCKET_CONFIG["Satellite"]; - const nTickers = countMap[h.bucket] || 1; - const targetPct = _rb2_(bcfg.target / nTickers); - const currentPct = _rb2_(h.weightPct); - const drift = _rb2_(currentPct - targetPct); - const bandMin = _rb2_(targetPct - band.contract); - const bandMax = _rb2_(targetPct + band.expand); - const force = h.forceSignal; - - let driftStatus, action, gateStatus; - if (force) { - driftStatus = "FORCE_" + force; - action = "SELL"; - gateStatus = "FORCE_OVERRIDE"; - } else if (currentPct > bandMax) { - driftStatus = "BREACH_HIGH"; - action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "SELL" : "WATCH"; - gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST"; - } else if (currentPct < bandMin) { - driftStatus = "BREACH_LOW"; - action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "BUY" : "WATCH"; - gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST"; - } else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) { - driftStatus = "WARN"; - action = "WATCH"; - gateStatus = "BLOCKED_BY_COST"; - } else { - driftStatus = "NORMAL"; - action = "HOLD"; - gateStatus = "BLOCKED_BY_COST"; - } - - // 3단계 수량 분할 (P5) - let s1q = 0, s1p = 0, s2q = 0, s2p = 0, s3q = 0, s3p = 0; - let tradeValueKrw = 0, costEstKrw = 0, netBenefitPct = 0; - - if ((action === "SELL" || action === "BUY") && h.holdingQty > 0 && h.close > 0) { - let adjustQty; - if (action === "SELL" && currentPct > 0) { - const adjustRatio = Math.min(Math.abs(drift) / currentPct, 1.0); - adjustQty = Math.max(1, Math.round(h.holdingQty * adjustRatio)); - } else { - adjustQty = Math.max(1, Math.round(h.holdingQty * 0.10)); - } - - const stages = _rbStageSplit_(adjustQty); - const limitP = _rbLimitPrice_(h.close, action); - [s1q, s2q, s3q] = stages; - [s1p, s2p, s3p] = [limitP, limitP, limitP]; - tradeValueKrw = _rb2_((s1q + s2q + s3q) * limitP); - costEstKrw = _rb2_(tradeValueKrw * RB_TX_COST_ROUNDTRIP); - netBenefitPct = _rb2_(Math.abs(drift) - RB_TX_COST_ROUNDTRIP * 100); - } - - return { ticker: h.ticker, name: h.name, bucket: h.bucket, - targetPct, currentPct, driftPct: drift, bandMin, bandMax, - regimeBand: band.label, driftStatus, forceSignal: force, - gateStatus, action, - stage1Qty: s1q, stage1Price: s1p, - stage2Qty: s2q, stage2Price: s2p, - stage3Qty: s3q, stage3Price: s3p, - tradeValueKrw, costEstKrw, netBenefitPct, close: h.close }; - }); -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// ORDERS 생성 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbComputeOrders_(tickers) { - const active = tickers - .filter(t => t.gateStatus === "PASS" || t.gateStatus === "FORCE_OVERRIDE") - .sort((a, b) => { - const pa = a.gateStatus === "FORCE_OVERRIDE" ? 0 : 1; - const pb = b.gateStatus === "FORCE_OVERRIDE" ? 0 : 1; - if (pa !== pb) return pa - pb; - return Math.abs(b.driftPct) - Math.abs(a.driftPct); - }); - - const orders = []; - let orderNo = 1; - active.forEach(t => { - const stageDefs = [ - { stage: 1, qty: t.stage1Qty, price: t.stage1Price }, - { stage: 2, qty: t.stage2Qty, price: t.stage2Price }, - { stage: 3, qty: t.stage3Qty, price: t.stage3Price }, - ]; - stageDefs.forEach(({ stage, qty, price }) => { - if (qty <= 0) return; - const reason = t.forceSignal || t.driftStatus; - orders.push({ - orderNo, ticker: t.ticker, name: t.name, bucket: t.bucket, - action: t.action, stage, qty, limitPriceKrw: price, - tradeValueKrw: qty * price, reason, - }); - orderNo++; - }); - }); - return orders; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// SUMMARY 생성 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbComputeSummary_(holdings, buckets, regime, band, ordersCount) { - const corePct = (buckets.find(b => b.bucket === "Core") || {}).currentPct || 0; - const satPct = (buckets.find(b => b.bucket === "Satellite") || {}).currentPct || 0; - const cashPct = (buckets.find(b => b.bucket === "Cash") || {}).currentPct || 0; - const rebalNeeded = buckets.some(b => b.driftStatus.startsWith("BREACH")); - const totalKrw = holdings.reduce((s, h) => s + h.acctMvKrw, 0); - const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); - - return { - Run_Date: nowKst, - Regime: regime, - Regime_Band: band.label, - Total_Portfolio_KRW: totalKrw, - Core_Pct: corePct, - Satellite_Pct: satPct, - Cash_Pct: cashPct, - Target_Core_Pct: RB_BUCKET_CONFIG.Core.target, - Target_Sat_Pct: RB_BUCKET_CONFIG.Satellite.target, - Target_Cash_Pct: RB_BUCKET_CONFIG.Cash.target, - Rebalance_Needed: rebalNeeded, - Holdings_Count: holdings.length, - Orders_Count: ordersCount, - Min_Actionable_Drift_Pct: RB_MIN_DRIFT_PCT, - }; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// 시트 쓰기 — 4섹션 멀티섹션 레이아웃 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _writeRebalanceSheet_(summary, buckets, tickers, orders) { - const ss = getSpreadsheet_(); - let sheet = ss.getSheetByName("rebalance"); - if (!sheet) { - sheet = ss.insertSheet("rebalance"); - } else { - sheet.clearContents(); - } - - const rows = []; - const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); - rows.push([`updated: ${nowKst} KST`]); - - // ── SUMMARY 섹션 ────────────────────────────────────────────────────────── - rows.push(["=== SUMMARY ==="]); - Object.entries(summary).forEach(([k, v]) => rows.push([k, v])); - rows.push([""]); - - // ── BUCKETS 섹션 ───────────────────────────────────────────────────────── - rows.push(["=== BUCKETS ==="]); - rows.push(["Bucket","Target_Pct","Current_Pct","Drift_Pct","Band_Min","Band_Max","Regime_Band","Drift_Status"]); - buckets.forEach(b => rows.push([ - b.bucket, b.targetPct, b.currentPct, b.driftPct, - b.bandMin, b.bandMax, b.regimeBand, b.driftStatus, - ])); - rows.push([""]); - - // ── TICKERS 섹션 ───────────────────────────────────────────────────────── - rows.push(["=== TICKERS ==="]); - rows.push([ - "Ticker","Name","Bucket","Target_Pct","Current_Pct","Drift_Pct", - "Band_Min","Band_Max","Regime_Band","Drift_Status","Force_Signal","Gate_Status","Action", - "Stage1_Qty","Stage1_Price","Stage2_Qty","Stage2_Price","Stage3_Qty","Stage3_Price", - "Trade_Value_KRW","Cost_Est_KRW","Net_Benefit_Pct","Close", - ]); - tickers.forEach(t => rows.push([ - t.ticker, t.name, t.bucket, t.targetPct, t.currentPct, t.driftPct, - t.bandMin, t.bandMax, t.regimeBand, t.driftStatus, t.forceSignal, t.gateStatus, t.action, - t.stage1Qty, t.stage1Price, t.stage2Qty, t.stage2Price, t.stage3Qty, t.stage3Price, - t.tradeValueKrw, t.costEstKrw, t.netBenefitPct, t.close, - ])); - rows.push([""]); - - // ── ORDERS 섹션 ────────────────────────────────────────────────────────── - rows.push(["=== ORDERS ==="]); - rows.push(["Order_No","Ticker","Name","Bucket","Action","Stage","Qty","Limit_Price_KRW","Trade_Value_KRW","Reason"]); - orders.forEach(o => rows.push([ - o.orderNo, o.ticker, o.name, o.bucket, o.action, - o.stage, o.qty, o.limitPriceKrw, o.tradeValueKrw, o.reason, - ])); - - // 한 번에 쓰기 - if (rows.length > 0) { - const maxCols = Math.max(...rows.map(r => r.length)); - const padded = rows.map(r => { - while (r.length < maxCols) r.push(""); - return r; - }); - sheet.getRange(1, 1, padded.length, maxCols).setValues(padded); - } -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// 내부 유틸 -// ═══════════════════════════════════════════════════════════════════════════════ - -function _rbNum_(v) { - const n = parseFloat(v); - return isNaN(n) ? 0 : n; -} - -function _rbInt_(v) { - const n = parseInt(v, 10); - return isNaN(n) ? 0 : n; -} - -function _rb2_(v) { - return Math.round(v * 100) / 100; -} - -function _rbStageSplit_(totalQty) { - if (totalQty <= 0) return [0, 0, 0]; - if (totalQty < 3) return [totalQty, 0, 0]; - const s1 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[0])); - const s2 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[1])); - const s3 = Math.max(0, totalQty - s1 - s2); - return [s1, s2, s3]; -} - -function _rbLimitPrice_(close, action) { - if (close <= 0) return 0; - return action === "SELL" ? Math.round(close * (1 - RB_LIMIT_PRICE_DISCOUNT)) : Math.round(close); -}