// ========================================================================= // GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY // Generated At: 2026-06-21 20:47:17 KST // Source Files: src/gas/core/gas_lib.gs // Source Hash: 966792cb99e2f85967c51295b063703fd4f7f279a90c841b5f11757f48df88b1 // ========================================================================= // --- Source: src/gas/core/gas_lib.gs --- // gas_lib.gs - Common utilities & static features // Last Updated: 2026-06-16 00:41:17 KST // Math/KRX utils, sheet I/O, sector flow, Web API, static runners // GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly // // Bridge markers for Python-backed formulas that are intentionally mirrored in tools/* // so YAML->GS direct coverage can be audited without changing runtime semantics. // ALPHA_FEEDBACK_LOOP_V2 // ALPHA_LEAD_THRESHOLD_OPTIMIZER_V1 // ANTI_WHIPSAW_GATE_V1 // BREAKEVEN_RATCHET_V1 // CANONICAL_METRICS_V1 // CAPITAL_STYLE_ALLOCATION_V1 // CAPITAL_STYLE_TIME_STOP_V1 // CASH_FLOOR_V1 // CROSS_SECTION_CONSISTENCY_V1 // DYNAMIC_VALUE_PRESERVATION_SELL_V6 // EJCE_DIVERGENCE_AUDIT_V1 // EXECUTION_INTEGRITY_GATE_V1 // FINAL_JUDGMENT_GATE_V1 // IMPUTED_DATA_EXPOSURE_GATE_V1 // INVESTMENT_QUALITY_HEADLINE_V1 // LLM_NARRATIVE_TEMPLATE_LOCK_V1 // MACRO_EVENT_TICKER_IMPACT_V1 // PREDICTION_ACCURACY_HARNESS_V2 // PREDICTIVE_ALPHA_DIALECTIC_ENGINE_V2 // PREDICTIVE_ALPHA_REPORT_LOCK_V2 // REGIME_TRIM_GUIDANCE_V1 // SELL_WATERFALL_ENGINE_V2 // TRADE_QUALITY_FROM_T5_V1 // VERDICT_CONSISTENCY_LOCK_V1 function calcValSurgeStatus(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"; } function calcLiquidityStatus(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"; } function calcSpreadStatus(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"; } function tradingValueM(row) { if (!row || !Number.isFinite(row.close) || !Number.isFinite(row.volume)) return null; return (row.close * row.volume) / 1000000; } function avgTradingValueM(rows, n) { if (!Array.isArray(rows) || rows.length < n) return null; const slice = rows.slice(-n); const vals = slice.map(tradingValueM).filter(v => Number.isFinite(v)); if (vals.length < n) return null; return vals.reduce((s, v) => s + v, 0) / n; } function avgNumber_(vals) { const nums = vals.filter(v => Number.isFinite(v)); if (nums.length !== vals.length || nums.length === 0) return null; return nums.reduce((s, v) => s + v, 0) / nums.length; } function pctReturn_(latestClose, priorClose) { if (!Number.isFinite(latestClose) || !Number.isFinite(priorClose) || priorClose === 0) return null; return ((latestClose / priorClose) - 1) * 100; } // 한국 숫자 문자열 파싱 — 쉼표 제거 후 parseFloat. null 반환(NaN/무한대). function parseKrNum_(s) { const v = parseFloat(String(s ?? "").replace(/,/g, "")); return Number.isFinite(v) ? v : null; } // ── 데이터 신선도 검증 헬퍼 ────────────────────────────────────────────────── // KRX 기준 영업일 차이 계산 (공휴일 미반영 — 토/일만 제외) // dateStr: "YYYY-MM-DD" 또는 "YYYY.MM.DD" // 반환: 0=당일, 1=전영업일, 2이상=스테일, 음수=미래 function calcKrxBizDaysDiff_(dateStr) { if (!dateStr) return 999; const norm = String(dateStr).replace(/\./g, "-"); if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999; // 오늘 KST 기준 날짜 (UTC+9) 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; } // OHLC·Flow 날짜가 스테일인지 판단 // bizDaysThreshold: 이 값 초과 시 stale (기본 1 — 전영업일까지 허용) function isStalePriceDate_(dateStr, bizDaysThreshold = 1) { const diff = calcKrxBizDaysDiff_(dateStr); return diff > bizDaysThreshold; } function calcAtr20(rows) { if (!Array.isArray(rows) || rows.length < 21) return null; const trs = []; for (let i = 1; i < rows.length; i++) { const cur = rows[i]; const prev = rows[i - 1]; const tr = Math.max( cur.high - cur.low, Math.abs(cur.high - prev.close), Math.abs(cur.low - prev.close) ); if (Number.isFinite(tr)) trs.push(tr); } const recent = trs.slice(-20); if (recent.length < 20) return null; return recent.reduce((s, v) => s + v, 0) / 20; } // ── Google Sheets 출력 ──────────────────────────────────────────────────── // TEXT_COLS: 앞자리 0이 있는 코드 컬럼을 문자열로 강제 저장 const TEXT_COLS = new Set([ "Ticker","ETF_Code","Symbol","Proxy_Ticker","Base_Ticker","Constituent_Code","ETF_Ticker", "Record_Date","Trade_ID","Signal_Date","Name","Account","Entry_Stage","Source_Origin", "Setup_Decision","Exit_Reason" ]); const NUM_COLS = new Set([ "Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_Rows", "Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM", "Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Rotation_Rank_W2", "Coverage_Weight","Sector_Ret5D","Sector_Ret20D","Sector_RS_20D", "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW", "SmartMoney_5D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count", "ETF_Liquidity_Score","Sector_Score","Sector_Rank", "NAV","iNAV","Premium_Discount_Pct","Tracking_Error","AUM","Bid","Ask","Spread_Pct", "ETF_Frg_5D_KRW","ETF_Inst_5D_KRW", "RS_Rank_20D","RS_Pct_20D","ChunkIdx", "Timing_Score_Entry","Timing_Score_Exit","T1_Forced_Sell_Risk_Score","Sell_Conflict_Score", "Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price", "Rule_Sell_Qty","Rebalance_Target_Cash_Pct","Rebalance_Need_KRW","Override_Sell_Qty", "Account_Holding_Qty","Account_Avg_Cost","Account_Market_Value", "Action_Priority","Priority_Score","Final_Rank", "Sell_Priority_Score" ]); // GAS 실행 컨텍스트 내 Spreadsheet 객체 캐시 (openById 중복 호출 방지) let _ssCache = null; function getSpreadsheet_() { if (!_ssCache) { let ssId = ""; try { // 1. Script Properties에서 SPREADSHEET_ID 로드 시도 ssId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'); } catch(e) {} // 만약 Properties에 없으면 하드코딩된 사용자 스프레드시트 ID 지정 (전역 변수 중복 에러 회피용) if (!ssId) { ssId = '1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM'; } if (ssId) { try { _ssCache = SpreadsheetApp.openById(ssId); } catch(e) { Logger.log('[WARN] openById(' + ssId + ') 실패: ' + e.message); } } // 2. 캐시가 없고 Bound Sheet로 열 수 있다면 로드 후 Properties에 자동 영구 저장 if (!_ssCache) { try { _ssCache = SpreadsheetApp.getActiveSpreadsheet(); if (_ssCache) { const activeId = _ssCache.getId(); if (activeId) { PropertiesService.getScriptProperties().setProperty('SPREADSHEET_ID', activeId); Logger.log('[INFO] SPREADSHEET_ID 자동 등록 완료: ' + activeId); } } } catch(e) { Logger.log('[ERROR] getActiveSpreadsheet() 실패: ' + e.message); } } // 3. 글로벌 변수로 SPREADSHEET_ID가 명시되어 있는 경우 최종 fallback if (!_ssCache) { try { if (typeof SPREADSHEET_ID !== 'undefined' && SPREADSHEET_ID) { _ssCache = SpreadsheetApp.openById(SPREADSHEET_ID); } } catch(e) {} } } return _ssCache; } // runDataFeed 루프가 계산한 버킷 할당 스냅샷 — runMacro에서 BUCKET_STATUS 행으로 기록 let _bucketSnapshot_ = null; // F4: 루프 내 trailing stop 갱신 대기열 — 루프 완료 후 account_snapshot에 일괄 기록 let _trailingStopUpdates_ = []; function writeToSheet(sheetName, headers, rows) { const ss = getSpreadsheet_(); let sheet = ss.getSheetByName(sheetName); if (!sheet) sheet = ss.insertSheet(sheetName); sheet.clearContents(); sheet.clearFormats(); // 코드 컬럼을 텍스트 형식으로 먼저 지정 — setValues 전에 해야 효과 있음 // 포맷 범위를 실제 데이터행+2로 제한. 3000행 예약 시 빈 행이 xlsx에 포함되어 // 파일 크기 ~7MB → ~200KB로 부풀어오르는 현상 방지 (95%+ 감축). const fmtRows = Math.max(rows.length + 2, 3); headers.forEach((h, i) => { if (TEXT_COLS.has(h)) { sheet.getRange(1, i+1, fmtRows, 1).setNumberFormat("@"); } if (NUM_COLS.has(h)) { sheet.getRange(1, i+1, fmtRows, 1).setNumberFormat("0"); } }); const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); sheet.getRange(1, 1).setValue(`updated: ${now} KST`); const safeHeaders = sanitizeSheetRow_(headers); sheet.getRange(2, 1, 1, headers.length).setValues([safeHeaders]); if (rows.length > 0) { const safeRows = rows.map(sanitizeSheetRow_); sheet.getRange(3, 1, rows.length, headers.length).setValues(safeRows); } } function sanitizeSheetCell_(value) { if (typeof value !== "string") return value; if (!value) return value; // Formula injection guard for spreadsheets. const first = value[0]; if (first === "=" || first === "+" || first === "-" || first === "@") { return "'" + value; } return value; } function sanitizeSheetRow_(row) { return (row || []).map(sanitizeSheetCell_); } // 누적형 시트용 업서트: row1 timestamp, row2 headers 유지, row3+ 데이터는 key 기준 병합 function upsertToSheetByKey(sheetName, headers, rows, keyHeader) { const ss = getSpreadsheet_(); let sheet = ss.getSheetByName(sheetName); if (!sheet) sheet = ss.insertSheet(sheetName); const keyIdx = headers.indexOf(keyHeader); if (keyIdx < 0) throw new Error(`upsertToSheetByKey: missing key header: ${keyHeader}`); // 헤더 보정 (행2) sheet.getRange(2, 1, 1, headers.length).setValues([headers]); // 기존 행 로드 const existingRowsCount = Math.max(0, sheet.getLastRow() - 2); const existingRows = existingRowsCount > 0 ? sheet.getRange(3, 1, existingRowsCount, headers.length).getValues() : []; const mergedByKey = {}; existingRows.forEach(function(r) { const k = String(r[keyIdx] || "").trim(); if (!k) return; mergedByKey[k] = r; }); (rows || []).forEach(function(r) { const k = String((r || [])[keyIdx] || "").trim(); if (!k) return; mergedByKey[k] = r; }); const merged = Object.keys(mergedByKey).map(function(k) { return mergedByKey[k]; }); // Record_Date desc, then Trade_ID asc const recordDateIdx = headers.indexOf("Record_Date"); merged.sort(function(a, b) { const ad = String((recordDateIdx >= 0 ? a[recordDateIdx] : "") || ""); const bd = String((recordDateIdx >= 0 ? b[recordDateIdx] : "") || ""); if (ad !== bd) return ad < bd ? 1 : -1; const ak = String(a[keyIdx] || ""); const bk = String(b[keyIdx] || ""); return ak.localeCompare(bk); }); // 기존 데이터 영역만 지우고 재기록 (시트 전체 clear 금지) if (existingRowsCount > 0) { sheet.getRange(3, 1, existingRowsCount, headers.length).clearContent(); } if (merged.length > 0) { sheet.getRange(3, 1, merged.length, headers.length).setValues(merged); } // 포맷 보정 const fmtRows = Math.max(merged.length + 2, 3); headers.forEach((h, i) => { if (TEXT_COLS.has(h)) sheet.getRange(1, i + 1, fmtRows, 1).setNumberFormat("@"); if (NUM_COLS.has(h)) sheet.getRange(1, i + 1, fmtRows, 1).setNumberFormat("0"); }); const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); sheet.getRange(1, 1).setValue(`updated: ${now} KST`); return merged.length; } function parseIsoDateYmd_(value) { if (!value) return null; if (value instanceof Date && !isNaN(value.getTime())) { return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd"); } const text = String(value).trim(); if (!text) return null; return text.substring(0, 10); } function daysBetweenIso_(startIso, endIso) { try { if (!startIso || !endIso) return null; const s = String(startIso).substring(0, 10).split("-").map(Number); const e = String(endIso).substring(0, 10).split("-").map(Number); if (s.length !== 3 || e.length !== 3 || s.some(n => !Number.isFinite(n)) || e.some(n => !Number.isFinite(n))) return null; const sMs = Date.UTC(s[0], s[1] - 1, s[2]); const eMs = Date.UTC(e[0], e[1] - 1, e[2]); return Math.round((eMs - sMs) / (1000 * 60 * 60 * 24)); } catch (e) { return null; } } // ── monthly_history 공유 헬퍼 ──────────────────────────────────────────────── // orbit(runOrbitGap)과 snapshot(runMonthlySnapshot) 두 호출처가 각자 컬럼만 갱신. // 나머지 컬럼은 기존 값 보존. Google Sheets가 "yyyy-MM" 셀을 Date로 변환해도 매칭. const MONTHLY_HDR_ = [ "Month", "Total_Asset", "Start_Asset", "Target_Asset", "Core_Pct", "Satellite_Pct", "Cash_Pct", "Target_Return_Pct", "Actual_Return_Pct", "MoM_Return_Pct", "YTD_Return_Pct", "Orbit_Gap_Pct", "Orbit_State", "Slot_Adj", "Cash_Floor_Adj", "Sat_T20_Pass_N", "Sat_T20_Fail_N", "Sat_T60_Pass_N", "Sat_Avg_T20_Alpha_Pct", "Updated" ]; const ALPHA_HISTORY_HDR_ = [ "Ticker", "Entry_Date", "SAQG_Grade_At_Entry", "BRT_Verdict_At_Entry", "Market_Regime_At_Entry", "T20_Check_Date", "T20_Vs_Core_Pctp", "T20_Alpha_Gate", "T60_Check_Date", "T60_Vs_Core_Pctp", "T60_Alpha_Gate", "Updated" ]; function upsertMonthlyRow_(monthKey, fields) { const ss = getSpreadsheet_(); let sheet = ss.getSheetByName("monthly_history"); if (!sheet) { sheet = ss.insertSheet("monthly_history"); sheet.getRange(1, 1, 1, MONTHLY_HDR_.length).setValues([MONTHLY_HDR_]); sheet.getRange(1, 1, 120, 1).setNumberFormat("@"); sheet.setFrozenRows(1); } const data = sheet.getDataRange().getValues(); const hdrMap = Object.fromEntries(MONTHLY_HDR_.map((h, i) => [h, i])); const normM = v => v instanceof Date && !isNaN(v.getTime()) ? Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM") : String(v ?? "").trim().substring(0, 7); let rowIdx = -1; let existing = new Array(MONTHLY_HDR_.length).fill(""); for (let i = 1; i < data.length; i++) { if (normM(data[i][0]) === monthKey) { rowIdx = i + 1; existing = data[i].map(v => v ?? ""); // 중복 행 제거 (역순) for (let j = data.length - 1; j > i; j--) { if (normM(data[j][0]) === monthKey) sheet.deleteRow(j + 1); } break; } } existing[hdrMap["Month"]] = monthKey; for (const [key, val] of Object.entries(fields)) { const idx = hdrMap[key]; if (idx !== undefined && val !== undefined && val !== null && val !== "") existing[idx] = val; } existing[hdrMap["Updated"]] = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); if (rowIdx > 0) { sheet.getRange(rowIdx, 1, 1, MONTHLY_HDR_.length).setValues([existing]); } else { sheet.appendRow(existing); } return sheet; } // ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- alpha history upsert ──────────── function appendAlphaHistory_(ss, aewRows, holdings, dfMap, marketRegime) { if (!aewRows || !aewRows.length) return; var sheet = ss.getSheetByName("alpha_history"); if (!sheet) { sheet = ss.insertSheet("alpha_history"); sheet.getRange(1, 1, 1, ALPHA_HISTORY_HDR_.length).setValues([ALPHA_HISTORY_HDR_]); sheet.setFrozenRows(1); } var data = sheet.getDataRange().getValues(); var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); var hdrMap = Object.fromEntries(ALPHA_HISTORY_HDR_.map(function(h, i) { return [h, i]; })); aewRows.forEach(function(r) { if (r.t20_alpha_gate === 'NOT_YET' && r.t60_alpha_gate === 'NOT_YET') return; var ticker = r.ticker; var df = dfMap[ticker] || {}; var rowIdx = -1; for (var i = 1; i < data.length; i++) { if (String(data[i][0]) === ticker && String(data[i][1]) === String(r.entry_date || '')) { rowIdx = i + 1; break; } } var row = rowIdx > 0 ? data[rowIdx - 1].map(function(v) { return v != null ? v : ''; }) : new Array(ALPHA_HISTORY_HDR_.length).fill(''); row[hdrMap['Ticker']] = ticker; row[hdrMap['Entry_Date']] = r.entry_date || ''; row[hdrMap['SAQG_Grade_At_Entry']] = df.saqg_v1 || ''; row[hdrMap['BRT_Verdict_At_Entry']] = df.brt_verdict || ''; row[hdrMap['Market_Regime_At_Entry']] = marketRegime || ''; if (r.t20_alpha_gate && r.t20_alpha_gate !== 'NOT_YET' && !row[hdrMap['T20_Check_Date']]) { row[hdrMap['T20_Check_Date']] = today; row[hdrMap['T20_Vs_Core_Pctp']] = (r.t20_vs_core_pctp !== undefined && r.t20_vs_core_pctp !== null) ? r.t20_vs_core_pctp : ''; row[hdrMap['T20_Alpha_Gate']] = r.t20_alpha_gate; } if (r.t60_alpha_gate && r.t60_alpha_gate !== 'NOT_YET' && !row[hdrMap['T60_Check_Date']]) { row[hdrMap['T60_Check_Date']] = today; row[hdrMap['T60_Vs_Core_Pctp']] = (r.t60_vs_core_pctp !== undefined && r.t60_vs_core_pctp !== null) ? r.t60_vs_core_pctp : ''; row[hdrMap['T60_Alpha_Gate']] = r.t60_alpha_gate; } row[hdrMap['Updated']] = today; if (rowIdx > 0) { sheet.getRange(rowIdx, 1, 1, ALPHA_HISTORY_HDR_.length).setValues([row]); } else { sheet.appendRow(row); } }); } // ── settings 탭 읽기 → 사용자 입력 파라미터 (total_asset 등) ──────────────── // settings 탭: row2=헤더(key|value|note), row3+=데이터 // 없으면 빈 객체 반환 (각 호출처에서 null 처리) function readSettingsTab_() { const result = {}; try { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName("settings"); if (!sheet) { Logger.log("readSettingsTab_: settings 탭 없음"); return result; } const data = sheet.getDataRange().getValues(); // 헤더·메타 행 자동 스킵 — "key", "updated", "date" 등 예약어 및 빈 셀 무시 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; } try { var verbose = String(PropertiesService.getScriptProperties().getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true'; if (verbose) Logger.log("readSettingsTab_ 로드됨: " + Object.keys(result).join(", ")); } catch (e) {} } catch(e) { handleFetchError_("readSettingsTab_", e, "CRITICAL"); } return result; } // ── performance 탭 읽기 → Bayesian multiplier 계산 ────────────────────────── // spec/17_performance_contract.yaml 구현. // performance 탭이 없거나 청산 완료 거래 5건 미만이면 medium_confidence(0.5×) 반환. function readPerformanceSheet_() { 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; // 청산 완료 거래만 (exit_date 있음) — 최신 30건 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(p => p > 0); const losses = recent.filter(p => p <= 0); const winRate = wins.length / n; const avgWin = wins.length > 0 ? wins.reduce((a,b)=>a+b,0)/wins.length : 0; const avgLoss = losses.length > 0 ? losses.reduce((a,b)=>a+Math.abs(b),0)/losses.length : 0; const netExp = winRate * avgWin - (1 - winRate) * avgLoss; // 연속 손절 체크 let consLoss = 0; for (const p of recent) { if (p <= 0) consLoss++; else break; } let multiplier, label; if (consLoss >= 5) { multiplier = 0.0; label = "no_bet"; } else if (winRate >= 0.60 && netExp >= 3.0) { multiplier = 1.0; label = "high_bet"; } else if (winRate >= 0.45 && netExp >= 0) { multiplier = 0.5; label = "medium_bet"; } else { multiplier = 0.25; label = "low_bet"; } return { bayesian_multiplier: multiplier, bayesian_label: label, trades_used: n, win_rate_30: parseFloat(winRate.toFixed(3)), net_expectancy_30: parseFloat(netExp.toFixed(2)), consecutive_losses: consLoss, bayesian_data_source: "actual", }; } catch(e) { handleFetchError_("readPerformanceSheet_", e, "WARN"); return DEFAULT; } } // ── 섹터 자금 흐름 ──────────────────────────────────────────────────────── const DEFAULT_SECTOR_UNIVERSE_V2 = [ { sector: "반도체", proxyTicker: "091160", proxyName: "KODEX 반도체", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "005930", name: "삼성전자", weight: 0.50 }, { code: "000660", name: "SK하이닉스", weight: 0.35 }, { code: "042700", name: "한미반도체", weight: 0.10 }, { code: "091160", name: "KODEX 반도체", weight: 0.05, isEtf: true }, ]}, { sector: "AI전력", proxyTicker: "0117V0", proxyName: "TIGER 코리아AI전력기기TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "010120", name: "LS ELECTRIC", weight: 0.30 }, { code: "267260", name: "HD현대일렉트릭", weight: 0.30 }, { code: "006260", name: "LS", weight: 0.20 }, { code: "062040", name: "산일전기", weight: 0.10 }, { code: "298040", name: "효성중공업", weight: 0.10 }, ]}, { sector: "전력설비", proxyTicker: "491820", proxyName: "HANARO 전력설비투자", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "010120", name: "LS ELECTRIC", weight: 0.28 }, { code: "267260", name: "HD현대일렉트릭", weight: 0.28 }, { code: "298040", name: "효성중공업", weight: 0.18 }, { code: "006260", name: "LS", weight: 0.14 }, { code: "099440", name: "두산에너빌리티", weight: 0.12 }, ]}, { sector: "방산", proxyTicker: "463250", proxyName: "TIGER K방산&우주", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "012450", name: "한화에어로스페이스", weight: 0.45 }, { code: "079550", name: "LIG넥스원", weight: 0.25 }, { code: "047810", name: "한국항공우주", weight: 0.15 }, { code: "064350", name: "현대로템", weight: 0.15 }, ]}, { sector: "조선", proxyTicker: "494670", proxyName: "TIGER 조선TOP10", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "329180", name: "HD현대중공업", weight: 0.35 }, { code: "042660", name: "한화오션", weight: 0.30 }, { code: "009540", name: "HD한국조선해양", weight: 0.20 }, { code: "494670", name: "TIGER 조선TOP10", weight: 0.15, isEtf: true }, ]}, { sector: "건설", proxyTicker: "117700", proxyName: "KODEX 건설", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "000720", name: "현대건설", weight: 0.35 }, { code: "006360", name: "GS건설", weight: 0.25 }, { code: "047040", name: "대우건설", weight: 0.20 }, { code: "294870", name: "HDC현대산업개발", weight: 0.20 }, ]}, { sector: "플랜트/EPC", proxyTicker: "454320", proxyName: "HANARO CAPEX설비투자iSelect", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "028050", name: "삼성E&A", weight: 0.35 }, { code: "010120", name: "LS ELECTRIC", weight: 0.20 }, { code: "267260", name: "HD현대일렉트릭", weight: 0.20 }, { code: "298040", name: "효성중공업", weight: 0.15 }, { code: "099440", name: "두산에너빌리티", weight: 0.10 }, ]}, { sector: "자동차", proxyTicker: "091180", proxyName: "TIGER 자동차", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "005380", name: "현대차", weight: 0.45 }, { code: "000270", name: "기아", weight: 0.40 }, { code: "012330", name: "현대모비스", weight: 0.15 }, ]}, { sector: "은행", proxyTicker: "091170", proxyName: "KODEX 은행", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "105560", name: "KB금융", weight: 0.30 }, { code: "055550", name: "신한지주", weight: 0.30 }, { code: "086790", name: "하나금융지주", weight: 0.20 }, { code: "316140", name: "우리금융지주", weight: 0.10 }, { code: "024110", name: "기업은행", weight: 0.10 }, ]}, { sector: "증권", proxyTicker: "0111J0", proxyName: "HANARO 증권고배당TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "071050", name: "한국금융지주", weight: 0.2135 }, { code: "006800", name: "미래에셋증권", weight: 0.1934 }, { code: "005940", name: "NH투자증권", weight: 0.1911 }, { code: "016360", name: "삼성증권", weight: 0.1434 }, { code: "039490", name: "키움증권", weight: 0.1373 }, ]}, { sector: "지주회사", proxyTicker: "307520", proxyName: "TIGER 지주회사", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "180640", name: "한진칼", weight: 0.1535 }, { code: "267250", name: "HD현대", weight: 0.0943 }, { code: "034730", name: "SK", weight: 0.0884 }, { code: "000150", name: "두산", weight: 0.0878 }, { code: "005490", name: "POSCO홀딩스", weight: 0.0763 }, { code: "003550", name: "LG", weight: 0.0752 }, { code: "006260", name: "LS", weight: 0.0705 }, { code: "078930", name: "GS", weight: 0.0498 }, { code: "001040", name: "CJ", weight: 0.0477 }, { code: "010060", name: "OCI홀딩스", weight: 0.0240 }, ]}, { sector: "2차전지", proxyTicker: "305720", proxyName: "KODEX 2차전지산업", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "373220", name: "LG에너지솔루션", weight: 0.40 }, { code: "006400", name: "삼성SDI", weight: 0.30 }, { code: "051910", name: "LG화학", weight: 0.20 }, { code: "096770", name: "SK이노베이션", weight: 0.10 }, ]}, { sector: "바이오", proxyTicker: "266410", proxyName: "KODEX 헬스케어", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "207940", name: "삼성바이오로직스", weight: 0.45 }, { code: "068270", name: "셀트리온", weight: 0.30 }, { code: "128940", name: "한미약품", weight: 0.15 }, { code: "000100", name: "유한양행", weight: 0.10 }, ]}, { sector: "원전", proxyTicker: "434730", proxyName: "HANARO 원자력iSelect", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "099440", name: "두산에너빌리티", weight: 0.45 }, { code: "023450", name: "한전기술", weight: 0.25 }, { code: "015760", name: "한국전력", weight: 0.20 }, { code: "071320", name: "지역난방공사", weight: 0.10 }, ]}, { sector: "로보틱스", proxyTicker: "0190C0", proxyName: "RISE 현대차고정피지컬AI", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "005380", name: "현대차", weight: 0.2402 }, { code: "012330", name: "현대모비스", weight: 0.1588 }, { code: "011070", name: "LG이노텍", weight: 0.1450 }, { code: "000270", name: "기아", weight: 0.1234 }, { code: "307950", name: "현대오토에버", weight: 0.0899 }, { code: "277810", name: "레인보우로보틱스", weight: 0.0673 }, { code: "064400", name: "LG씨엔에스", weight: 0.0519 }, { code: "454910", name: "두산로보틱스", weight: 0.0367 }, { code: "108490", name: "로보티즈", weight: 0.0240 }, { code: "058610", name: "에스피지", weight: 0.0173 }, { code: "010620", name: "현대미포", weight: 0.0135 }, { code: "009540", name: "HD한국조선해양", weight: 0.0135 }, { code: "011210", name: "현대위아", weight: 0.0109 }, { code: "121600", name: "나노신소재", weight: 0.0040 }, { code: "028050", name: "삼성E&A", weight: 0.0034 }, ]}, { sector: "소비재", proxyTicker: "139220", proxyName: "TIGER 생활소비재", proxyType: "ETF", baseTicker: "069500", constituents: [ { code: "028260", name: "삼성물산", weight: 0.35 }, { code: "097950", name: "CJ제일제당", weight: 0.25 }, { code: "004370", name: "농심", weight: 0.20 }, { code: "051900", name: "LG생활건강", weight: 0.20 }, ]}, ]; function runSectorFlow() { const rows = runSectorFlowV3(); writeLegacySectorFlowFromStage2_(rows); // 연쇄 실행: 매크로 지표 runMacro(); } function normalizeSectorName_(sector) { const s = String(sector ?? "").trim(); if (s === "AI전력/전력기기") return "AI전력"; if (s === "바이오/헬스케어") return "바이오"; if (s === "원전/에너지") return "원전"; if (s === "소비재/유통") return "소비재"; if (s === "건설/EPC") return "플랜트/EPC"; return s; } function boolFromSheet_(value, defaultValue) { if (value === true || value === false) return value; const s = String(value ?? "").trim().toUpperCase(); if (["TRUE","Y","YES","1","사용","사용함"].includes(s)) return true; if (["FALSE","N","NO","0","미사용","제외"].includes(s)) return false; return defaultValue; } function readSectorUniverse_() { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName("sector_universe"); if (!sheet) { writeDefaultSectorUniverseSheet_(); return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({ ...sector, source: sector.source || "DEFAULT_TEMPLATE", sourceUrl: sector.sourceUrl || "", sourceAsOf: sector.sourceAsOf || "", constituents: sector.constituents.map(c => ({ ...c, source: c.source || sector.source || "DEFAULT_TEMPLATE", sourceUrl: c.sourceUrl || sector.sourceUrl || "", sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "", })), })); } const data = sheet.getDataRange().getValues(); if (data.length < 3) { writeDefaultSectorUniverseSheet_(); return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({ ...sector, source: sector.source || "DEFAULT_TEMPLATE", sourceUrl: sector.sourceUrl || "", sourceAsOf: sector.sourceAsOf || "", constituents: sector.constituents.map(c => ({ ...c, source: c.source || sector.source || "DEFAULT_TEMPLATE", sourceUrl: c.sourceUrl || sector.sourceUrl || "", sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "", })), })); } const hdr = data[1].map(h => String(h).trim()); const idx = name => hdr.indexOf(name); const required = ["Sector","Proxy_Ticker","Constituent_Code","Weight"]; if (required.some(h => idx(h) < 0)) { return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({ ...sector, source: sector.source || "DEFAULT_TEMPLATE", sourceUrl: sector.sourceUrl || "", sourceAsOf: sector.sourceAsOf || "", constituents: sector.constituents.map(c => ({ ...c, source: c.source || sector.source || "DEFAULT_TEMPLATE", sourceUrl: c.sourceUrl || sector.sourceUrl || "", sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "", })), })); } const map = {}; for (let i = 2; i < data.length; i++) { const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true; if (!enabled) continue; const sector = normalizeSectorName_(data[i][idx("Sector")]); const code = normalizeTickerCode(data[i][idx("Constituent_Code")]); const weight = parseFloat(data[i][idx("Weight")]); if (!sector || !code || !Number.isFinite(weight) || weight <= 0) continue; if (!map[sector]) { map[sector] = { sector, proxyTicker: normalizeTickerCode(data[i][idx("Proxy_Ticker")]), proxyName: idx("Proxy_Name") >= 0 ? String(data[i][idx("Proxy_Name")] ?? "").trim() : "", proxyType: idx("Proxy_Type") >= 0 ? String(data[i][idx("Proxy_Type")] ?? "").trim() : "", baseTicker: idx("Base_Ticker") >= 0 ? normalizeTickerCode(data[i][idx("Base_Ticker")]) : "069500", source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "SHEET_INPUT", sourceUrl: idx("Source_URL") >= 0 ? String(data[i][idx("Source_URL")] ?? "").trim() : "", sourceAsOf: idx("Source_AsOf") >= 0 ? String(data[i][idx("Source_AsOf")] ?? "").trim() : "", constituents: [], }; } map[sector].constituents.push({ code, name: idx("Constituent_Name") >= 0 ? String(data[i][idx("Constituent_Name")] ?? "").trim() : "", weight, isEtf: idx("Is_ETF") >= 0 ? boolFromSheet_(data[i][idx("Is_ETF")], false) : false, source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "SHEET_INPUT", transportMode: idx("Transport_Mode") >= 0 ? String(data[i][idx("Transport_Mode")] ?? "").trim() : "", sourceUrl: idx("Source_URL") >= 0 ? String(data[i][idx("Source_URL")] ?? "").trim() : "", sourceAsOf: idx("Source_AsOf") >= 0 ? String(data[i][idx("Source_AsOf")] ?? "").trim() : "", }); } const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0); const sectorSet = new Set(sectors.map(s => s.sector)); for (const fallback of DEFAULT_SECTOR_UNIVERSE_V2) { if (!fallback || !fallback.sector || sectorSet.has(fallback.sector)) continue; sectors.push({ sector: fallback.sector, proxyTicker: fallback.proxyTicker, proxyName: fallback.proxyName, proxyType: fallback.proxyType, baseTicker: fallback.baseTicker || "069500", source: fallback.source || "DEFAULT_TEMPLATE", transportMode: fallback.transportMode || ((fallback.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (fallback.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"), sourceUrl: fallback.sourceUrl || "", sourceAsOf: fallback.sourceAsOf || "", constituents: fallback.constituents.map(c => ({ code: c.code, name: c.name || "", weight: c.weight, isEtf: Boolean(c.isEtf), source: c.source || fallback.source || "DEFAULT_TEMPLATE", transportMode: c.transportMode || ((c.source || fallback.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (c.source || fallback.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"), sourceUrl: c.sourceUrl || fallback.sourceUrl || "", sourceAsOf: c.sourceAsOf || fallback.sourceAsOf || "", })), }); } return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({ ...sector, source: sector.source || "DEFAULT_TEMPLATE", transportMode: sector.transportMode || ((sector.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (sector.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"), sourceUrl: sector.sourceUrl || "", sourceAsOf: sector.sourceAsOf || "", constituents: sector.constituents.map(c => ({ ...c, source: c.source || sector.source || "DEFAULT_TEMPLATE", transportMode: c.transportMode || ((c.source || sector.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (c.source || sector.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"), sourceUrl: c.sourceUrl || sector.sourceUrl || "", sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "", })), })); } function writeDefaultSectorUniverseSheet_() { const headers = [ "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker", "Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source","Transport_Mode", "Source_URL","Source_AsOf" ]; const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const rows = []; for (const sector of DEFAULT_SECTOR_UNIVERSE_V2) { for (const c of sector.constituents) { rows.push([ sector.sector, sector.proxyTicker, sector.proxyName, sector.proxyType || "대표주", sector.baseTicker || "069500", c.code, c.name || "", c.weight, c.isEtf ? "Y" : "N", "Y", today, sector.source || c.source || "DEFAULT_TEMPLATE", sector.transportMode || c.transportMode || (((sector.source || c.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (sector.source || c.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY") ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"), sector.sourceUrl || c.sourceUrl || "", sector.sourceAsOf || c.sourceAsOf || "", ]); } } writeToSheet("sector_universe", headers, rows); Logger.log(`sector_universe 기본 템플릿 생성: ${rows.length}행`); } function sectorDataQuality_(coverage, flowRowsMin, staleCount, proxyOk, hasNorm, weightSum) { if (!proxyOk || coverage <= 0 || !hasNorm) return "D"; if (coverage >= 0.80 && flowRowsMin >= 20 && staleCount === 0 && weightSum >= 0.70) return "A"; if (coverage >= 0.60 && flowRowsMin >= 5 && weightSum >= 0.60) return "B"; return "C"; } function sectorUseMode_(quality) { if (quality === "A" || quality === "B") return "TRADE_OK"; if (quality === "C") return "WATCH_ONLY"; return "INVALID"; } function parseDateOnly_(value) { const text = String(value ?? "").trim(); if (!text) return null; const norm = text.replace(/\./g, "-").slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return null; const parsed = new Date(norm + "T00:00:00+09:00"); return Number.isNaN(parsed.getTime()) ? null : parsed; } function calcSectorUniverseRefreshAudit_(universe) { const today = new Date(); const rows = []; const sourceKindCounts = { NAVER_ETF_PAGE: 0, NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED: 0, NAVER_ETF_PAGE_FAIL: 0, REPRESENTATIVE_STOCK_PROXY: 0, SHEET_INPUT: 0, DEFAULT_TEMPLATE: 0, OTHER: 0 }; const transportModeCounts = { HTML_SERVER_RENDERED: 0, MANUAL_OR_TEMPLATE: 0, LAYOUT_CHANGED: 0, UNKNOWN: 0 }; let currentCount = 0; let dueCount = 0; let overdueCount = 0; let missingCount = 0; let templateCount = 0; let sheetInputCount = 0; let naverSourceCount = 0; let layoutChangedCount = 0; let missingSourceUrlCount = 0; let staleSectorCount = 0; let oldestSourceAsOf = null; let newestSourceAsOf = null; for (const sector of universe || []) { const sectorRows = Array.isArray(sector?.constituents) ? sector.constituents : []; const sourceKind = String(sector?.source || "SHEET_INPUT").trim() || "SHEET_INPUT"; if (Object.prototype.hasOwnProperty.call(sourceKindCounts, sourceKind)) { sourceKindCounts[sourceKind] += 1; } else { sourceKindCounts.OTHER += 1; } const transportMode = String(sector?.transportMode || "").trim() || (sourceKind === "NAVER_ETF_PAGE" || sourceKind === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : sourceKind === "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED" ? "LAYOUT_CHANGED" : (sourceKind === "DEFAULT_TEMPLATE" || sourceKind === "SHEET_INPUT" ? "MANUAL_OR_TEMPLATE" : "UNKNOWN")); if (Object.prototype.hasOwnProperty.call(transportModeCounts, transportMode)) { transportModeCounts[transportMode] += 1; } else { transportModeCounts.UNKNOWN += 1; } const sourceUrl = String(sector?.sourceUrl || "").trim(); const sourceAsOf = String(sector?.sourceAsOf || "").trim(); const parsed = parseDateOnly_(sourceAsOf); const ageDays = parsed ? Math.floor((today.getTime() - parsed.getTime()) / 86400000) : null; if (parsed) { oldestSourceAsOf = oldestSourceAsOf && oldestSourceAsOf < parsed ? oldestSourceAsOf : parsed; newestSourceAsOf = newestSourceAsOf && newestSourceAsOf > parsed ? newestSourceAsOf : parsed; } let status = "INVALID"; const reasons = []; if (sourceKind === "DEFAULT_TEMPLATE") { status = "TEMPLATE"; templateCount += 1; reasons.push("DEFAULT_TEMPLATE"); } else if (sourceKind === "REPRESENTATIVE_STOCK_PROXY") { if (!sourceUrl) { status = "MISSING"; missingCount += 1; missingSourceUrlCount += 1; reasons.push("Source_URL_MISSING"); } else if (ageDays === null) { status = "MISSING"; missingCount += 1; reasons.push("Source_AsOf_MISSING"); } else if (ageDays <= 31) { status = "CURRENT"; currentCount += 1; } else if (ageDays <= 45) { status = "DUE"; dueCount += 1; staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } else { status = "OVERDUE"; overdueCount += 1; staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } } else if (sourceKind === "SHEET_INPUT") { sheetInputCount += 1; if (!sourceUrl) { status = "MISSING"; missingCount += 1; missingSourceUrlCount += 1; reasons.push("Source_URL_MISSING"); } else if (ageDays === null) { status = "MISSING"; missingCount += 1; reasons.push("Source_AsOf_MISSING"); } else if (ageDays <= 31) { status = "CURRENT"; currentCount += 1; } else if (ageDays <= 45) { status = "DUE"; dueCount += 1; staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } else { status = "OVERDUE"; overdueCount += 1; staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } } else if (sourceKind === "NAVER_ETF_PAGE") { naverSourceCount += 1; if (!sourceUrl) { status = "MISSING"; missingCount += 1; missingSourceUrlCount += 1; reasons.push("Source_URL_MISSING"); } else if (ageDays === null) { status = "MISSING"; missingCount += 1; reasons.push("Source_AsOf_MISSING"); } else if (ageDays <= 31) { status = "CURRENT"; currentCount += 1; } else if (ageDays <= 45) { status = "DUE"; dueCount += 1; staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } else { status = "OVERDUE"; overdueCount += 1; staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } } else if (sourceKind === "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED") { layoutChangedCount += 1; status = "LAYOUT_CHANGED"; if (!sourceUrl) { missingSourceUrlCount += 1; reasons.push("Source_URL_MISSING"); } if (ageDays === null) { reasons.push("Source_AsOf_MISSING"); } else { staleSectorCount += 1; reasons.push(`AgeDays=${ageDays}`); } } else { status = "INVALID"; reasons.push("SOURCE_KIND_UNKNOWN"); if (!sourceUrl) missingSourceUrlCount += 1; } if (!sourceUrl) reasons.push("Source_URL_MISSING"); if (ageDays !== null && ageDays < 0) reasons.push("FUTURE_DATE"); rows.push({ sector: sector.sector || "", proxy_ticker: sector.proxyTicker || "", proxy_name: sector.proxyName || "", proxy_type: sector.proxyType || "", source_kind: sourceKind, transport_mode: transportMode, source_url: sourceUrl, source_asof: sourceAsOf, age_days: ageDays === null ? "" : ageDays, constituent_count: sectorRows.length, stock_count: sectorRows.filter(c => !c.isEtf).length, etf_count: sectorRows.filter(c => c.isEtf).length, weight_sum: sectorRows.reduce((a, c) => a + (Number(c.weight) || 0), 0), status: status, refresh_reason: reasons.length ? reasons.join(";") : "OK", }); } rows.sort((a, b) => { if (a.status === "CURRENT" && b.status !== "CURRENT") return -1; if (a.status !== "CURRENT" && b.status === "CURRENT") return 1; return String(a.sector || "").localeCompare(String(b.sector || "")); }); return { formula_id: "sector_universe_refresh_audit_v1", gate: (templateCount > 0 || missingSourceUrlCount > 0 || overdueCount > 0 || staleSectorCount > 0) ? "FAIL" : (sheetInputCount > 0 ? "WARN" : "PASS"), summary: { sector_count: (universe || []).length, current_count: currentCount, due_count: dueCount, overdue_count: overdueCount, missing_count: missingCount, template_count: templateCount, sheet_input_count: sheetInputCount, naver_source_count: naverSourceCount, layout_changed_count: layoutChangedCount, missing_source_url_count: missingSourceUrlCount, stale_sector_count: staleSectorCount, oldest_source_asof: oldestSourceAsOf ? Utilities.formatDate(oldestSourceAsOf, "Asia/Seoul", "yyyy-MM-dd") : "", newest_source_asof: newestSourceAsOf ? Utilities.formatDate(newestSourceAsOf, "Asia/Seoul", "yyyy-MM-dd") : "", source_kind_counts: sourceKindCounts, transport_mode_counts: transportModeCounts, ajax_mode: "NO", transport_model: "HTML_SERVER_RENDERED", }, rows: rows, }; } function writeSectorUniverseRefreshAuditSheet_(audit) { if (!audit || typeof audit !== "object") return 0; const headers = [ "sector", "proxy_ticker", "proxy_name", "proxy_type", "source_kind", "transport_mode", "source_url", "source_asof", "age_days", "constituent_count", "stock_count", "etf_count", "weight_sum", "status", "refresh_reason", ]; const rows = Array.isArray(audit.rows) ? audit.rows.map(function(r) { return headers.map(function(h) { return r[h] ?? ""; }); }) : []; writeToSheet("sector_universe_refresh_audit", headers, rows); return rows.length; } function scoreSmartMoneyNorm_(v) { if (!Number.isFinite(v)) return 0; if (v >= 0.15) return 25; if (v >= 0.05) return 18; if (v > 0) return 10; if (v > -0.05) return 4; return 0; } function scoreBreadth_(v) { if (!Number.isFinite(v)) return 0; if (v >= 0.70) return 15; if (v >= 0.50) return 10; if (v >= 0.30) return 5; return 0; } function calcEtfLiquidityScore_(etf) { if (!etf || etf.proxyType !== "ETF") return 5; let score = 0; if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 1000000000) score += 4; else if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 300000000) score += 2; if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.25) score += 3; else if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.50) score += 1; if (etf.priceOk && !etf.isPriceStale) score += 2; if (etf.navRisk === "NAV_DATA_MISSING") score += 0; else if (etf.navRisk === "OK") score += 1; return Math.max(0, Math.min(10, score)); } function calcEtfLiquidityStatus_(etf) { if (!etf || etf.proxyType !== "ETF") return "NOT_ETF"; if (!etf.priceOk) return "BLOCK"; if (etf.isPriceStale) return "WARN"; if (Number.isFinite(etf.spreadPct) && etf.spreadPct > 0.80) return "BLOCK"; if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw < 300000000) return "WARN"; if (etf.navRisk === "NAV_DATA_MISSING") return "WARN"; return "OK"; } function calcEtfExecutionUse_(etf) { if (!etf || etf.proxyType !== "ETF") return "NOT_ETF"; if (etf.liquidityStatus === "BLOCK" || !etf.priceOk) return "BLOCK"; if (etf.navRisk !== "OK") return "WATCH_ONLY"; if (etf.liquidityStatus === "OK") return "TRADE_OK"; return "WATCH_ONLY"; } function readEtfNavManualMap_() { const result = {}; try { const sheet = getSpreadsheet_().getSheetByName("etf_nav_manual"); 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("ETF_Ticker"); if (tickerIdx < 0) return result; for (let i = 2; i < data.length; i++) { const ticker = normalizeTickerCode(data[i][tickerIdx]); if (!ticker) continue; const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true; if (!enabled) continue; const close = idx("Close") >= 0 ? parseFloat(data[i][idx("Close")]) : null; const nav = idx("NAV") >= 0 ? parseFloat(data[i][idx("NAV")]) : null; const inav = idx("iNAV") >= 0 ? parseFloat(data[i][idx("iNAV")]) : null; let premiumDiscountPct = idx("Premium_Discount_Pct") >= 0 ? parseFloat(data[i][idx("Premium_Discount_Pct")]) : null; const basisPrice = Number.isFinite(close) ? close : null; const basisNav = Number.isFinite(nav) ? nav : Number.isFinite(inav) ? inav : null; if (!Number.isFinite(premiumDiscountPct) && Number.isFinite(basisPrice) && Number.isFinite(basisNav) && basisNav > 0) { premiumDiscountPct = ((basisPrice / basisNav) - 1) * 100; } const sourceDate = idx("Source_Date") >= 0 ? normalizeSheetDateString_(data[i][idx("Source_Date")]) : ""; const trackingError = idx("Tracking_Error") >= 0 ? parseFloat(data[i][idx("Tracking_Error")]) : null; const aum = idx("AUM") >= 0 ? parseFloat(data[i][idx("AUM")]) : null; result[ticker] = { close: Number.isFinite(close) ? close : null, nav: Number.isFinite(nav) ? nav : null, inav: Number.isFinite(inav) ? inav : null, premiumDiscountPct: Number.isFinite(premiumDiscountPct) ? premiumDiscountPct : null, trackingError: Number.isFinite(trackingError) ? trackingError : null, aum: Number.isFinite(aum) ? aum : null, sourceDate, source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "etf_nav_manual", }; } } catch(e) { handleFetchError_("readEtfNavManualMap_", e, "WARN"); } return result; } function calcEtfNavRisk_(manual) { if (!manual) return "NAV_DATA_MISSING"; if (!Number.isFinite(manual.nav) && !Number.isFinite(manual.inav)) return "NAV_DATA_MISSING"; if (manual.sourceDate && isStalePriceDate_(manual.sourceDate, 2)) return "NAV_STALE"; if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 1.0) return "NAV_BLOCK"; if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 0.5) return "NAV_WARN"; return "OK"; } function buildEtfRawRows_(universe) { const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const navManual = readEtfNavManualMap_(); const etfMap = {}; for (const sector of universe) { if (sector.proxyType === "ETF") { etfMap[sector.proxyTicker] = { sector: sector.sector, ticker: sector.proxyTicker, name: sector.proxyName, proxyType: sector.proxyType, }; } for (const c of sector.constituents) { if (c.isEtf) { etfMap[c.code] = { sector: sector.sector, ticker: c.code, name: c.name || sector.proxyName, proxyType: "ETF", }; } } } const rows = []; for (const etf of Object.values(etfMap)) { const price = fetchYahooOhlcMetrics(etf.ticker); const flow = fetchNaverFlow(etf.ticker); const close = Number.isFinite(price.close) ? price.close : null; const frg5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0) : null; const inst5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0) : null; const frg5Krw = Number.isFinite(frg5Sh) && Number.isFinite(close) ? frg5Sh * close : null; const inst5Krw = Number.isFinite(inst5Sh) && Number.isFinite(close) ? inst5Sh * close : null; const avgTradeValue5DKrw = Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D * 1000000 : null; const avgTradeValue20DKrw = Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D * 1000000 : null; const manual = navManual[etf.ticker] ?? null; const raw = { ...etf, close: Number.isFinite(manual?.close) ? manual.close : close, nav: manual?.nav ?? null, inav: manual?.inav ?? null, premiumDiscountPct: manual?.premiumDiscountPct ?? null, trackingError: manual?.trackingError ?? null, aum: manual?.aum ?? null, bid: Number.isFinite(price.bid) ? price.bid : null, ask: Number.isFinite(price.ask) ? price.ask : null, spreadPct: Number.isFinite(price.spreadPct) ? price.spreadPct : null, avgTradeValue5DKrw, avgTradeValue20DKrw, etfFrg5Krw: frg5Krw, etfInst5Krw: inst5Krw, priceOk: Boolean(price.ok), isPriceStale: Boolean(price.isPriceStale), flowOk: Boolean(flow.ok), flowRows: Array.isArray(flow.rows) ? flow.rows.length : 0, navRisk: calcEtfNavRisk_(manual), navSource: manual?.source ?? "", navSourceDate: manual?.sourceDate ?? "", asOfDate: today, }; raw.liquidityScore = calcEtfLiquidityScore_(raw); raw.liquidityStatus = calcEtfLiquidityStatus_(raw); raw.executionUse = calcEtfExecutionUse_(raw); raw.lpQualityFlag = raw.liquidityStatus === "OK" ? "OK" : raw.liquidityStatus; raw.dataStatus = raw.priceOk ? (raw.flowOk ? "PARTIAL_NAV_MISSING" : "PARTIAL_FLOW_NAV_MISSING") : "FAIL"; rows.push(raw); Utilities.sleep(100); } return rows; } function buildEtfRawMap_(etfRows) { return Object.fromEntries(etfRows.map(r => [r.ticker, r])); } function calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, proxyType, etfLiquidityScore) { let score = 0; const rs = Number.isFinite(sectorRs20D) ? sectorRs20D : sectorRet20D; score += rs >= 8 ? 25 : rs >= 3 ? 18 : rs >= 0 ? 10 : rs >= -3 ? 5 : 0; score += Math.min(25, Math.round(scoreSmartMoneyNorm_(smart5Norm) * 0.7 + scoreSmartMoneyNorm_(smart20Norm) * 0.3)); score += scoreBreadth_(breadth5); score += tradeValueRatio >= 1.2 ? 15 : tradeValueRatio >= 0.8 ? 8 : 0; score += 5; // EPS revision/PER/PBR 정밀 축은 Phase 2에서 보수적 중립값만 부여. score += proxyType === "ETF" ? (Number.isFinite(etfLiquidityScore) ? etfLiquidityScore : 0) : 5; return Math.max(0, Math.min(100, score)); } function runSectorFlowV3() { const universe = readSectorUniverse_(); const etfRawMap = buildEtfRawMap_(buildEtfRawRows_(universe)); const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const headers = [ "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Universe_Source","Transport_Mode","Coverage_Weight", "Sector_Ret5D","Sector_Ret20D","Sector_RS_20D", "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW","SmartMoney_5D_Norm", "Flow_Breadth_5D","Flow_Rows_Min","Stale_Count", "ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use", "Sector_Median_PE","Sector_Median_PBR", "Sector_Score","Sector_Rank","Alert_Level","Data_Quality","Decision_Use","Reason","AsOfDate" ]; const rows = []; for (const sector of universe) { const proxy = fetchYahooOhlcMetrics(sector.proxyTicker); const base = sector.baseTicker ? fetchYahooOhlcMetrics(sector.baseTicker) : { ok: false }; const perVals = [], pbrVals = []; const eligibleConstituents = sector.constituents.filter(c => !c.isEtf); const weightSum = eligibleConstituents.reduce((a, c) => a + (Number(c.weight) || 0), 0); let coverage = 0, frg5Krw = 0, inst5Krw = 0, frg20Krw = 0, inst20Krw = 0; let avgTv20Krw = 0, avgTv5Krw = 0, ret5Weighted = 0, ret20Weighted = 0, breadth5 = 0; let flowRowsMin = 999, staleCount = 0; const reasons = []; for (const c of eligibleConstituents) { const w = Number(c.weight) || 0; const flow = fetchNaverFlow(c.code); const price = fetchYahooOhlcMetrics(c.code); const flowRows = Array.isArray(flow.rows) ? flow.rows.length : 0; if (!flow.ok || !price.ok || flowRows < 5 || !Number.isFinite(price.close)) { reasons.push(`${c.code}:DATA_PARTIAL`); Utilities.sleep(150); continue; } const frg5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0); const inst5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0); const frg20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.frgn, 0); const inst20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.inst, 0); const cFrg5Krw = frg5Sh * price.close; const cInst5Krw = inst5Sh * price.close; const cFrg20Krw = frg20Sh * price.close; const cInst20Krw = inst20Sh * price.close; coverage += w; frg5Krw += cFrg5Krw * w; inst5Krw += cInst5Krw * w; frg20Krw += cFrg20Krw * w; inst20Krw += cInst20Krw * w; if (Number.isFinite(price.avgTradingValue20D)) avgTv20Krw += price.avgTradingValue20D * 1000000 * w; if (Number.isFinite(price.avgTradingValue5D)) avgTv5Krw += price.avgTradingValue5D * 1000000 * w; if (Number.isFinite(price.ret5D)) ret5Weighted += price.ret5D * w; if (Number.isFinite(price.ret20D)) ret20Weighted += price.ret20D * w; if (cFrg5Krw + cInst5Krw > 0) breadth5 += w; flowRowsMin = Math.min(flowRowsMin, flowRows); if (flow.isFlowStale || price.isPriceStale) staleCount++; const qm = fetchNaverMarketMetrics(c.code); if (Number.isFinite(qm.per) && qm.per > 0) perVals.push(qm.per); if (Number.isFinite(qm.pbr) && qm.pbr > 0) pbrVals.push(qm.pbr); Utilities.sleep(150); } if (flowRowsMin === 999) flowRowsMin = 0; const smart5 = frg5Krw + inst5Krw; const smart20 = frg20Krw + inst20Krw; const smart5Norm = avgTv20Krw > 0 ? smart5 / avgTv20Krw : null; const smart20Norm = avgTv20Krw > 0 ? smart20 / avgTv20Krw : null; const sectorRet5D = coverage > 0 ? ret5Weighted / coverage : null; const sectorRet20D = coverage > 0 ? ret20Weighted / coverage : null; const sectorRs20D = Number.isFinite(sectorRet20D) && base.ok && Number.isFinite(base.ret20D) ? sectorRet20D - base.ret20D : null; const tradeValueRatio = avgTv20Krw > 0 && avgTv5Krw > 0 ? avgTv5Krw / avgTv20Krw : null; const medianPE = calcMedian_(perVals); const medianPBR = calcMedian_(pbrVals); const etfRaw = etfRawMap[sector.proxyTicker] ?? null; const etfLiquidityScore = sector.proxyType === "ETF" ? (etfRaw?.liquidityScore ?? 0) : 5; const etfNavRisk = sector.proxyType === "ETF" ? (etfRaw?.navRisk ?? "NAV_DATA_MISSING") : "NOT_ETF"; const etfLiquidityStatus = sector.proxyType === "ETF" ? (etfRaw?.liquidityStatus ?? "WARN") : "NOT_ETF"; const etfExecutionUse = sector.proxyType === "ETF" ? (etfRaw?.executionUse ?? "WATCH_ONLY") : "NOT_ETF"; const transportMode = sector.source === "NAVER_ETF_PAGE" ? "HTML_SERVER_RENDERED" : (sector.source === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : (sector.source === "DEFAULT_TEMPLATE" ? "MANUAL_OR_TEMPLATE" : "UNKNOWN")); const quality = sectorDataQuality_(coverage, flowRowsMin, staleCount, proxy.ok, Number.isFinite(smart5Norm), weightSum); const routeUse = sectorUseMode_(quality); let score = calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, sector.proxyType, etfLiquidityScore); if (quality === "C") score = Math.min(score, 49); if (quality === "D") score = Math.min(score, 20); const alert = score >= 70 && smart5 > 0 && breadth5 >= 0.50 ? "INFLOW_STRONG" : score >= 50 && smart5 > 0 ? "INFLOW_MODERATE" : score >= 30 ? "NEUTRAL" : smart5 < 0 && breadth5 < 0.40 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION"; if (quality === "C") reasons.push("Data_Quality=C:WATCH_ONLY"); if (quality === "D") reasons.push("Data_Quality=D:INVALID"); if (coverage < 0.60) reasons.push("Coverage<0.60"); if (sector.constituents.length !== eligibleConstituents.length) reasons.push("ETF_Constituent_Excluded_From_Sector_Flow"); if (staleCount > 0) reasons.push(`Stale_Count=${staleCount}`); if (!proxy.ok) reasons.push("Proxy_Price_FAIL"); if (!Number.isFinite(smart5Norm)) reasons.push("SmartMoney_Norm_MISSING"); if ((sector.source || "DEFAULT_TEMPLATE") === "DEFAULT_TEMPLATE") reasons.push("Universe_Source=DEFAULT_TEMPLATE"); if (sector.proxyType === "ETF" && etfNavRisk === "NAV_DATA_MISSING") reasons.push("ETF_NAV_DATA_MISSING"); if (sector.proxyType === "ETF" && etfLiquidityStatus !== "OK") reasons.push(`ETF_Liquidity=${etfLiquidityStatus}`); if (sector.proxyType === "ETF" && etfExecutionUse !== "TRADE_OK") reasons.push(`ETF_Execution=${etfExecutionUse}`); rows.push({ sector: sector.sector, proxyTicker: sector.proxyTicker, proxyName: sector.proxyName, proxyType: sector.proxyType || "대표주", universeSource: sector.source || "DEFAULT_TEMPLATE", transportMode: transportMode, coverage, sectorRet5D, sectorRet20D, sectorRs20D, frg5Krw, inst5Krw, frg20Krw, inst20Krw, smart5, smart20, avgTv20Krw, smart5Norm, breadth5, flowRowsMin, staleCount, etfLiquidityScore, etfNavRisk, etfLiquidityStatus, etfExecutionUse, medianPE, medianPBR, score, rank: 0, alert, quality, routeUse, reason: reasons.length ? reasons.join(" | ") : "OK", asOfDate: today, proxyRet5D: proxy.ok ? proxy.ret5D : null, proxyRet10D: proxy.ok ? proxy.ret10D : null, proxyRet20D: proxy.ok ? proxy.ret20D : null, }); } rows.sort((a, b) => Number(b.score) - Number(a.score)); rows.forEach((r, i) => { r.rank = i + 1; }); appendSectorFlowHistoryV2_(rows); return rows; } function appendSectorFlowHistoryV2_(rows) { // 주말(토·일)은 KRX 휴장 — 새 시장 데이터 없으므로 이력 저장 불필요 const dow = new Date().getDay(); // 0=일, 6=토 if (dow === 0 || dow === 6) { Logger.log("appendSectorFlowHistoryV2_: 주말 스킵 (dow=" + dow + ")"); return; } const headers = [ "Snapshot_Date","Sector","Sector_Score","Sector_Rank","SmartMoney_5D_KRW","SmartMoney_20D_KRW", "Flow_Breadth_5D","Alert_Level","Data_Quality","Decision_Use","ETF_Liquidity_Status","ETF_Execution_Use","Transport_Mode","Reason","Saved_At" ]; const ss = getSpreadsheet_(); let sheet = ss.getSheetByName("sector_flow_history"); if (!sheet) { sheet = ss.insertSheet("sector_flow_history"); sheet.getRange(1, 1).setValue("updated: sector_flow_history cumulative snapshots"); sheet.getRange(2, 1, 1, headers.length).setValues([headers]); } const data = sheet.getDataRange().getValues(); const hdr = data[1] ?? headers; const dateIdx = hdr.indexOf("Snapshot_Date"); const sectorIdx = hdr.indexOf("Sector"); const normalizeRow_ = (row) => { const outRow = Array.isArray(row) ? row.slice(0, headers.length) : []; while (outRow.length < headers.length) outRow.push(""); return outRow; }; const byKey = {}; for (let i = 2; i < data.length; i++) { const row = data[i]; const d = normalizeSheetDateString_(row[dateIdx]); const s = String(row[sectorIdx] ?? "").trim(); if (!d || !s) continue; byKey[`${d}|${s}`] = normalizeRow_(row); } const savedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); for (const r of rows) { byKey[`${r.asOfDate}|${r.sector}`] = normalizeRow_([ r.asOfDate, r.sector, r.score, r.rank, Math.round(r.smart5), Math.round(r.smart20), roundNum(r.breadth5, 4), r.alert, r.quality, r.routeUse, r.etfLiquidityStatus, r.etfExecutionUse, r.transportMode || "", r.reason, savedAt ]); } const out = Object.values(byKey).sort((a, b) => { const da = String(a[0]), db = String(b[0]); if (da !== db) return da.localeCompare(db); return String(a[1]).localeCompare(String(b[1])); }); sheet.clearContents(); sheet.getRange(1, 1).setValue(`updated: ${savedAt} KST`); sheet.getRange(2, 1, 1, headers.length).setValues([headers]); if (out.length) sheet.getRange(3, 1, out.length, headers.length).setValues(out.map(normalizeRow_)); } function normalizeSheetDateString_(value) { if (value instanceof Date && !isNaN(value.getTime())) { return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd"); } const raw = String(value ?? "").trim(); if (!raw) return ""; const normalized = raw.replace(/\./g, "-").replace(/\//g, "-"); const m = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); if (m) return `${m[1]}-${String(m[2]).padStart(2, "0")}-${String(m[3]).padStart(2, "0")}`; const d = new Date(raw); return isNaN(d.getTime()) ? "" : Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd"); } function readSectorFlowHistoryPrev_(currentDate) { const result = {}; try { const sheet = getSpreadsheet_().getSheetByName("sector_flow_history"); if (!sheet) return result; const data = sheet.getDataRange().getValues(); const hdr = data[1] ?? []; const dIdx = hdr.indexOf("Snapshot_Date"); const sIdx = hdr.indexOf("Sector"); const rankIdx = hdr.indexOf("Sector_Rank"); const sm5Idx = hdr.indexOf("SmartMoney_5D_KRW"); const breadthIdx = hdr.indexOf("Flow_Breadth_5D"); if (dIdx < 0 || sIdx < 0) return result; const grouped = {}; for (let i = 2; i < data.length; i++) { const d = normalizeSheetDateString_(data[i][dIdx]); const s = String(data[i][sIdx] ?? "").trim(); if (!d || !s || d === currentDate) continue; if (!grouped[s]) grouped[s] = []; grouped[s].push({ date: d, rank: rankIdx >= 0 ? parseInt(data[i][rankIdx]) : null, smart5: sm5Idx >= 0 ? parseFloat(data[i][sm5Idx]) : null, breadth5: breadthIdx >= 0 ? parseFloat(data[i][breadthIdx]) : null, }); } for (const [sector, items] of Object.entries(grouped)) { items.sort((a, b) => b.date.localeCompare(a.date)); result[sector] = { w1: items[0] ?? null, w2: items[1] ?? null }; } } catch(e) { handleFetchError_("readSectorFlowHistoryPrev_", e, "WARN"); } return result; } function readPrevLegacySectorFlow_() { const result = {}; try { const sfSheet = getSpreadsheet_().getSheetByName("sector_flow"); if (!sfSheet) return result; const data = sfSheet.getDataRange().getValues(); const hdr = data[1] ?? []; const sIdx = hdr.indexOf("Sector"); const rIdx = hdr.indexOf("Sector_Rank") >= 0 ? hdr.indexOf("Sector_Rank") : hdr.indexOf("Rotation_Rank"); const s5Idx = hdr.indexOf("SmartMoney_5D_KRW") >= 0 ? hdr.indexOf("SmartMoney_5D_KRW") : hdr.indexOf("Frg_5D_SUM"); const s20Idx = hdr.indexOf("SmartMoney_20D_KRW") >= 0 ? hdr.indexOf("SmartMoney_20D_KRW") : hdr.indexOf("Frg_20D_SUM"); if (sIdx < 0) return result; for (let i = 2; i < data.length; i++) { const s = String(data[i][sIdx]).trim(); if (!s || s === "Sector") continue; const smart5 = s5Idx >= 0 ? parseFloat(data[i][s5Idx]) : null; const smart20 = s20Idx >= 0 ? parseFloat(data[i][s20Idx]) : null; result[s] = { rank: rIdx >= 0 ? parseInt(data[i][rIdx]) : null, smart5: Number.isFinite(smart5) ? smart5 : null, smart20: Number.isFinite(smart20) ? smart20 : null, frg5: Number.isFinite(smart5) ? smart5 : null, inst5: Number.isFinite(smart5) ? smart5 : null, }; } } catch(e) { handleFetchError_("readPrevLegacySectorFlow_", e, "WARN"); } return result; } function readW2LegacySectorFlow_() { const result = {}; try { const props = PropertiesService.getScriptProperties(); const w2Json = props.getProperty("sf_w2_ranks_json"); if (w2Json) Object.assign(result, JSON.parse(w2Json).data ?? {}); } catch(e) { handleFetchError_("readW2LegacySectorFlow_", e, "INFO"); } return result; } function writeLegacySectorFlowFromStage2_(stage2Rows) { const headers = [ "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Universe_Source","Coverage_Weight", "Sector_Ret5D","Sector_Ret10D","Sector_Ret20D","Sector_RS_20D", "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW", "SmartMoney_5D_Norm","SmartMoney_20D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count", "ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use", "Sector_Median_PE","Sector_Median_PBR","Sector_Score","Sector_Rank", "Alert_Level","Data_Quality","Decision_Use","Reason","RW1","RW3","AsOfDate", "ETF_Code","Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM", "ETF_Ret5D","ETF_Ret10D","ETF_Ret20D", "Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Frg_5D_SUM","Prev_Inst_5D_SUM", "Prev_Rotation_Rank_W2","Prev_Frg_5D_SUM_W2","Prev_Inst_5D_SUM_W2","Smart_Money" ]; const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const prev = readPrevLegacySectorFlow_(); const w2 = readW2LegacySectorFlow_(); const historyPrev = readSectorFlowHistoryPrev_(today); try { const props = PropertiesService.getScriptProperties(); if (Object.keys(prev).length > 0) props.setProperty("sf_w2_ranks_json", JSON.stringify({ saved_at: today, data: prev })); } catch(e) { handleFetchError_("writeLegacySectorFlowFromStage2_:W2 save", e, "INFO"); } const rows = stage2Rows.map(r => { const p = prev[r.sector] ?? {}; const w = w2[r.sector] ?? {}; const hp = historyPrev[r.sector]?.w1 ?? null; const hw = historyPrev[r.sector]?.w2 ?? null; const w1Rank = Number.isFinite(hp?.rank) ? hp.rank : p.rank; const w2Rank = Number.isFinite(hw?.rank) ? hw.rank : w.rank; const rw1 = Number.isFinite(w1Rank) && Number.isFinite(w2Rank) && (r.rank - w1Rank >= 3) && (w1Rank - w2Rank >= 3) ? 1 : 0; const curOutflow = r.smart5 < 0 && r.breadth5 < 0.40; const prevOutflow = Number.isFinite(p.frg5) && p.frg5 < 0 && Number.isFinite(p.inst5) && p.inst5 < 0; const histOutflow = Number.isFinite(hp?.smart5) && hp.smart5 < 0 && Number.isFinite(hp?.breadth5) && hp.breadth5 < 0.40; const rw3 = curOutflow && (histOutflow || prevOutflow) ? 1 : 0; const smart = r.smart5 > 0 && r.breadth5 >= 0.70 ? "STRONG" : r.smart5 > 0 && r.breadth5 >= 0.40 ? "MODERATE" : r.smart5 > 0 ? "WEAK" : "ABSENT"; const smartMoneyHalf = Number.isFinite(r.smart5) ? r.smart5 / 2 : ""; const frg5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : ""; const inst5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : ""; const frg20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : ""; const inst20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : ""; return [ r.sector, r.proxyTicker, r.proxyName, r.proxyType, r.universeSource, r.coverage, r.sectorRet5D, r.proxyRet10D, r.sectorRet20D, r.sectorRs20D, r.smart5, r.smart20, r.avgTv20Krw, r.smart5Norm, r.smart20Norm, r.breadth5, r.flowRowsMin, r.staleCount, r.etfLiquidityScore, r.etfNavRisk, r.etfLiquidityStatus, r.etfExecutionUse, r.medianPE != null ? r.medianPE.toFixed(1) : "", r.medianPBR != null ? r.medianPBR.toFixed(2) : "", r.score, r.rank, r.alert, r.quality, r.routeUse, r.reason, rw1, rw3, r.asOfDate, r.proxyTicker, frg5Alias, inst5Alias, 0, frg20Alias, inst20Alias, Number.isFinite(r.proxyRet5D) ? r.proxyRet5D : "N/A", Number.isFinite(r.proxyRet10D) ? r.proxyRet10D : "N/A", Number.isFinite(r.proxyRet20D) ? r.proxyRet20D : "N/A", r.score, r.rank, Number.isFinite(w1Rank) ? w1Rank : "", Number.isFinite(p.frg5) ? p.frg5 : "", Number.isFinite(p.inst5) ? p.inst5 : "", Number.isFinite(w2Rank) ? w2Rank : "", Number.isFinite(w.frg5) ? w.frg5 : "", Number.isFinite(w.inst5) ? w.inst5 : "", smart ]; }); writeToSheet("sector_flow", headers, rows); Logger.log(`sector_flow 완료: ${rows.length}섹터`); } // ── F4: Trailing Stop account_snapshot 일괄 갱신 ──────────────────────────── // _trailingStopUpdates_ 배열을 소비해 account_snapshot의 highest_price/stop_price/last_updated 갱신. // 신규 최고가 경신 종목만 업데이트 — entry 없는 종목은 건드리지 않음. function applyTrailingStopUpdates_() { if (!_trailingStopUpdates_.length) return; try { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName("account_snapshot"); if (!sheet) { Logger.log("applyTrailingStopUpdates_: account_snapshot 탭 없음"); return; } const data = sheet.getDataRange().getValues(); const hdr = data[1] ?? []; // row2 = 헤더 const tkIdx = hdr.indexOf("ticker"); const highIdx= hdr.indexOf("highest_price_since_entry"); const stopIdx= hdr.indexOf("stop_price"); const updIdx = hdr.indexOf("last_updated"); if (tkIdx < 0 || highIdx < 0 || stopIdx < 0) { Logger.log("applyTrailingStopUpdates_: account_snapshot 컬럼 미발견"); return; } const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const updateMap = {}; _trailingStopUpdates_.forEach(u => { updateMap[u.ticker] = u; }); for (let i = 2; i < data.length; i++) { const tk = String(data[i][tkIdx] ?? "").trim(); if (!tk || !updateMap[tk]) continue; const upd = updateMap[tk]; sheet.getRange(i + 1, highIdx + 1).setValue(upd.new_highest); sheet.getRange(i + 1, stopIdx + 1).setValue(upd.new_stop); if (updIdx >= 0) sheet.getRange(i + 1, updIdx + 1).setValue(today); Logger.log(`TrailingStop 갱신: ${tk} highest=${upd.new_highest} stop=${upd.new_stop}`); } } catch(e) { handleFetchError_("applyTrailingStopUpdates_", e, "WARN"); } } // ── 버킷 할당 상태 계산 ───────────────────────────────────────────────────── // _bucketSnapshot_이 있어야 동작. runDataFeed() 실행 후 runMacro()에서 호출. // 목표 범위: core 60-72%, satellite 10-25%, cash 10-22% (spec/risk) function calcBucketStatus_() { if (!_bucketSnapshot_) return null; const { core_pct, satellite_pct } = _bucketSnapshot_; const cash_pct = parseFloat(Math.max(0, 100 - core_pct - satellite_pct).toFixed(2)); const coreStatus = core_pct < THRESHOLDS.BUCKET_CORE_MIN ? "UNDERWEIGHT" : core_pct > THRESHOLDS.BUCKET_CORE_MAX ? "OVERWEIGHT" : "OK"; const satStatus = satellite_pct < THRESHOLDS.BUCKET_SAT_MIN ? "UNDERWEIGHT" : satellite_pct > THRESHOLDS.BUCKET_SAT_MAX ? "OVERWEIGHT" : "OK"; const cashStatus = cash_pct < THRESHOLDS.BUCKET_CASH_MIN ? "LOW" : cash_pct > THRESHOLDS.BUCKET_CASH_MAX ? "HIGH" : "OK"; const issues = [ coreStatus !== "OK" ? `core_${coreStatus}` : null, satStatus !== "OK" ? `sat_${satStatus}` : null, cashStatus !== "OK" ? `cash_${cashStatus}` : null, ].filter(Boolean); return { core_pct, satellite_pct, cash_pct, core_status: coreStatus, satellite_status: satStatus, cash_status: cashStatus, overall: issues.length === 0 ? "BALANCED" : issues.join("|"), detail: `core=${core_pct}%(${coreStatus}) sat=${satellite_pct}%(${satStatus}) cash=${cash_pct}%(${cashStatus})`, }; } // ── 매크로 지표 수집 ───────────────────────────────────────────────────────── function runMacro() { const MACRO_TICKERS = [ { sym: "^KS11", name: "KOSPI", category: "Index" }, { sym: "^KQ11", name: "KOSDAQ", category: "Index" }, { sym: "^VIX", name: "VIX", category: "Risk" }, { sym: "KRW=X", name: "USD_KRW", category: "FX" }, { sym: "JPY=X", name: "USD_JPY", category: "FX" }, { sym: "DX-Y.NYB",name: "DXY", category: "FX" }, { sym: "GC=F", name: "Gold", category: "Commodity" }, { sym: "CL=F", name: "WTI_Oil", category: "Commodity" }, { sym: "^TNX", name: "US10Y_Yield",category: "Bond" }, { sym: "^TYX", name: "US30Y_Yield",category: "Bond" }, { sym: "^GSPC", name: "SP500", category: "Index" }, { sym: "^NDX", name: "NASDAQ100", category: "Index" }, // HYG: HY 회사채 ETF → Ret5D로 credit_stress_status 산출 (MRS 신용위험 입력값) { sym: "HYG", name: "HYG_HY_Bond",category: "CreditProxy" }, ]; const headers = ["Symbol","Name","Category","Close","Ret1D","Ret2D","Ret5D","Ret10D","Ret20D","MA20","MA60","AsOfDate","Status"]; const rows = []; const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); for (const m of MACRO_TICKERS) { const p = fetchYahooPrice(m.sym); let ma20 = "", ma60 = "", ret10D = "", ret2D = ""; if (m.category === "Index") { const ohlc = fetchYahooOhlcMetrics(m.sym); if (ohlc?.ok) { if (Number.isFinite(ohlc.ma20)) ma20 = ohlc.ma20.toFixed(2); if (Number.isFinite(ohlc.ma60)) ma60 = ohlc.ma60.toFixed(2); if (Number.isFinite(ohlc.ret10D)) ret10D = ohlc.ret10D.toFixed(2); if (Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2); } } else if (m.category === "FX" && m.name === "USD_JPY") { // USD/JPY Ret2D: MRS usd_jpy_score 전용 if (p.ok && Number.isFinite(parseFloat(p.ret5D))) { // 2일 변화율은 fetchYahooOhlcMetrics가 필요 — FX는 budget 여유 있으면 시도 const ohlc = fetchYahooOhlcMetrics(m.sym); if (ohlc?.ok && Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2); } } if (p.ok) { const p1d = fetchYahooPrice1D(m.sym); rows.push([m.sym, m.name, m.category, p.close, p1d, ret2D, p.ret5D, ret10D !== "" ? ret10D : (p.ok ? p.ret10D ?? "" : ""), p.ret20D, ma20, ma60, today, "OK"]); } else { rows.push([m.sym, m.name, m.category, "N/A", "N/A", "", "N/A", "", "N/A", ma20, ma60, today, "FAIL"]); } Utilities.sleep(300); } // ── MRS(시장위험점수) 자동 계산 후 summary 행 추가 ──────────────────────── const byName = {}; rows.forEach(r => { byName[r[1]] = r; }); // Name 기준 인덱싱 const vixClose = parseFloat(byName["VIX"]?.[3]); const kospiClose= parseFloat(byName["KOSPI"]?.[3]); const kospiMA20 = parseFloat(byName["KOSPI"]?.[9]); const usdKrw = parseFloat(byName["USD_KRW"]?.[3]); const usdJpyR2D = parseFloat(byName["USD_JPY"]?.[5]); // Ret2D const hygRet5D = parseFloat(byName["HYG_HY_Bond"]?.[6]); // Ret5D // credit_stress_status 산출 (HYG Ret5D 기반 proxy) const creditStress = Number.isFinite(hygRet5D) ? (hygRet5D < -2 ? "stress" : hygRet5D < -1 ? "caution" : "none") : "DATA_MISSING"; // MARKET_RISK_SCORE_V1 let mrs = 0; mrs += Number.isFinite(vixClose) ? (vixClose < 18 ? 0 : vixClose <= 25 ? 2 : vixClose <= 35 ? 3 : 4) : 4; mrs += Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) ? (kospiClose >= kospiMA20 ? 0 : 2) : 2; mrs += Number.isFinite(usdKrw) ? (usdKrw < 1400 ? 0 : usdKrw <= 1450 ? 1 : 2) : 2; mrs += Number.isFinite(usdJpyR2D) ? (usdJpyR2D > -1 ? 0 : 1) : 1; mrs += creditStress === "none" ? 0 : 1; // kosdaq_regime_supplement: KOSDAQ < MA20 이고 KOSPI >= MA20이면 MRS +1 const kosdaqClose = parseFloat(byName["KOSDAQ"]?.[3]); const kosdaqMA20 = parseFloat(byName["KOSDAQ"]?.[9]); const kosdaqSupp = Number.isFinite(kosdaqClose) && Number.isFinite(kosdaqMA20) && kosdaqClose < kosdaqMA20 && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose >= kospiMA20 ? 1 : 0; mrs = Math.min(10, mrs + kosdaqSupp); // TARGET_CASH_PCT_V1 const targetCashPct = (5 + (mrs / 10) * 15).toFixed(1); // ── sector_flow 읽기 → 완전 국면 판정용 데이터 수집 ───────────────────── // runSectorFlow()가 sector_flow 기록 완료 후 runMacro()가 실행되므로 최신값 읽기 가능 let sfTop1Score = 0, sfTop2Sum = 0, sfTop1AlertScore = 0, sfTop1Sector = ""; let sfSmart20Sum = 0; try { const sfSheet = getSpreadsheet_().getSheetByName("sector_flow"); if (sfSheet) { const sfData = sfSheet.getDataRange().getValues(); const sfHdr = sfData[1] ?? []; const sfRankIdx = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank"); const sfScoreIdx = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score"); const sfAlertIdx = sfHdr.indexOf("Alert_Level"); const sfSmart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM"); const sfSectorIdx = sfHdr.indexOf("Sector"); const sfEntries = []; for (let i = 2; i < sfData.length; i++) { const row = sfData[i]; const sec = String(row[sfSectorIdx] ?? "").trim(); if (!sec || sec === "Sector") continue; const score = parseFloat(row[sfScoreIdx]); const rank = parseInt(row[sfRankIdx]); const als = String(row[sfAlertIdx] ?? ""); const aScore = als === "INFLOW_STRONG" ? 3 : als === "INFLOW_MODERATE" ? 2 : als === "NEUTRAL" ? 1 : 0; const smart20 = parseFloat(row[sfSmart20Idx]); sfEntries.push({ rank, score, alertScore: aScore, sec, smart20 }); if (Number.isFinite(smart20)) sfSmart20Sum += smart20; } sfEntries.sort((a, b) => a.rank - b.rank); if (sfEntries.length >= 1) { sfTop1Score = sfEntries[0].score ?? 0; sfTop1AlertScore = sfEntries[0].alertScore ?? 0; sfTop1Sector = sfEntries[0].sec; } if (sfEntries.length >= 2) { sfTop2Sum = (sfEntries[0].score ?? 0) + (sfEntries[1].score ?? 0); } } } catch(e) { handleFetchError_("runMacro:sector_flow regime read", e, "WARN"); } // KOSPI MA60·Ret20D — byName column index (행 구조: [sym,name,cat,close,ret1d,ret2d,ret5d,ret10d,ret20d,ma20,ma60,...]) const kospiMA60 = parseFloat(byName["KOSPI"]?.[10]); const kospiRet20D = parseFloat(byName["KOSPI"]?.[8]); // ── MARKET_REGIME_V1 완전 판정 (spec/11_market_regime.yaml) ───────────── const leaderSectorFlag_ = SECTOR_TIER_MAP[sfTop1Sector] === "Tier_1" ? 1 : 0; const isRiskOff_ = mrs >= 7 || (Number.isFinite(vixClose) && vixClose >= 25 && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose < kospiMA20); const riskOnBase_ = !isRiskOff_ && Number.isFinite(vixClose) && vixClose < 18 && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20 && ((Number.isFinite(kospiMA60) && kospiMA20 >= kospiMA60) || (Number.isFinite(kospiRet20D) && kospiRet20D > 0)); const riskOnFlow_ = sfSmart20Sum > 0 || sfTop2Sum >= 100; const isLeader_ = !isRiskOff_ && sfTop2Sum >= 100 && sfTop1Score >= 55 && sfTop1AlertScore >= 2 && leaderSectorFlag_ === 1 && Number.isFinite(kospiRet20D) && kospiRet20D > 0 && Number.isFinite(vixClose) && vixClose < 25; const isSecularLeader_ = isLeader_ && sfTop1Sector === "반도체" && Number.isFinite(vixClose) && vixClose < 22 && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20; let marketRegime; if (isRiskOff_) marketRegime = "RISK_OFF"; else if (isSecularLeader_) marketRegime = "SECULAR_LEADER_RISK_ON"; else if (isLeader_) marketRegime = "LEADER_CONCENTRATION"; else if (riskOnBase_ && riskOnFlow_) marketRegime = "RISK_ON"; else if (mrs <= 5) marketRegime = "NEUTRAL"; else marketRegime = "RISK_OFF_CANDIDATE"; const mrsDetail = `score=${mrs}/10 cash=${targetCashPct}% regime=${marketRegime}` + `${kosdaqSupp ? " [KOSDAQ+1]" : ""} top1=${sfTop1Sector}(${sfTop1Score.toFixed(0)}) top2sum=${sfTop2Sum.toFixed(0)}`; // ── Bayesian multiplier ──────────────────────────────────────────────────── const bayesianInfo = readPerformanceSheet_(); const bayesianDetail = `${bayesianInfo.bayesian_label} (${bayesianInfo.bayesian_multiplier}×)` + (bayesianInfo.win_rate_30 != null ? ` wr=${(bayesianInfo.win_rate_30*100).toFixed(0)}%` : "") + (bayesianInfo.net_expectancy_30 != null ? ` ne=${bayesianInfo.net_expectancy_30.toFixed(1)}%` : "") + ` trades=${bayesianInfo.trades_used}`; // ── net_return_feedback 상태 (RISK_BUDGET_CASCADE_V1 입력) ──────────────── // spec/05_position_sizing.yaml:net_return_feedback const neTrades_ = bayesianInfo.trades_used; const ne30_ = bayesianInfo.net_expectancy_30; // %, e.g. 3.2 = 3.2% avg expectancy const consLoss_ = bayesianInfo.consecutive_losses; let netRF = "NORMAL", netRFDetail = ""; if (neTrades_ < 20) { netRFDetail = `trades<20(${neTrades_}건) — 규칙 미적용`; } else if (Number.isFinite(ne30_) && ne30_ <= -2) { netRF = "REDUCED"; netRFDetail = `ne=${ne30_.toFixed(1)}% — base_risk 0.007→0.003 삭감 권고`; } else if (Number.isFinite(ne30_) && ne30_ <= 0) { netRF = "CAUTION"; netRFDetail = `ne=${ne30_.toFixed(1)}% — high_confidence 금지, multiplier 0.5× 강제`; } else { netRFDetail = `ne=${Number.isFinite(ne30_) ? ne30_.toFixed(1) : "N/A"}% — 정상`; } if (consLoss_ >= 5 && netRF === "NORMAL") { netRF = "CAUTION"; netRFDetail = `연속손실 ${consLoss_}건 — high_confidence 금지`; } // ── TOTAL_HEAT_V1 계산 — account_snapshot 기반 ────────────────────────── const macroSettings = readSettingsTab_(); const totalAssetKrw = Number.isFinite(parseFloat(macroSettings["total_asset_krw"])) ? parseFloat(macroSettings["total_asset_krw"]) : null; const heatInfo = readAccountSnapshotHeat_(totalAssetKrw); // ── FC(탐색) 손실 예산 월별 집계 ──────────────────────────────────────── const fcBudgetPct = Number.isFinite(parseFloat(macroSettings["fc_budget_pct_override"])) ? parseFloat(macroSettings["fc_budget_pct_override"]) : null; const fcInfo = calcFcBudget_(totalAssetKrw, fcBudgetPct); // ── orbit_gap 계산 (spec/01_objective_profile.yaml:orbit_monthly_tracker) ── const orbitInfo = calcOrbitGap_(macroSettings); // summary 행 8개 (MRS / REGIME / BAYESIAN / TOTAL_HEAT / FC_BUDGET / NET_RETURN_FEEDBACK / ORBIT_GAP / ORBIT_STATE) rows.push(["MRS_COMPUTED", "Market_Risk_Score", "Computed", mrs, "", "", "", "", "", "", "", today, mrsDetail]); rows.push(["REGIME_PRELIM", "Market_Regime_Prelim", "Computed", marketRegime, "", "", "", "", "", "", "", today, `credit_stress=${creditStress} smart20=${sfSmart20Sum.toFixed(0)}`]); rows.push(["BAYESIAN_COMPUTED", "Bayesian_Multiplier", "Computed", bayesianInfo.bayesian_multiplier, "", "", "", "", "", "", "", today, bayesianDetail]); rows.push(["TOTAL_HEAT", "Total_Heat_Pct", "Computed", heatInfo.total_heat_pct ?? "N/A", "", "", "", "", "", "", "", today, `${heatInfo.hf005_status} account_snapshot=${heatInfo.positions_count}` + (heatInfo.total_heat_krw != null ? ` heat_krw=${Math.round(heatInfo.total_heat_krw).toLocaleString()}` : "")]); rows.push(["FC_BUDGET", "FC_Loss_Budget_Monthly", "Computed", fcInfo.fc_used_pct ?? "N/A", "", "", "", "", "", "", "", today, `${fcInfo.fc_status} trades=${fcInfo.trades}`]); rows.push(["NET_RETURN_FEEDBACK", "Net_Return_Feedback", "Computed", netRF, "", "", "", "", "", "", "", today, netRFDetail]); rows.push(["ORBIT_GAP", "Orbit_Gap_Pct", "Computed", orbitInfo.ok ? orbitInfo.orbit_gap_pct : "N/A", "", "", "", "", "", "", "", today, orbitInfo.detail]); rows.push(["ORBIT_STATE", "Orbit_State", "Computed", orbitInfo.ok ? orbitInfo.orbit_state : "N/A", "", "", "", "", "", "", "", today, orbitInfo.ok ? `slot_adj=${orbitInfo.offensive_slot_adj} cash_adj=${orbitInfo.cash_floor_adj} (${orbitInfo.elapsed_months}/${orbitInfo.total_months}개월)` : orbitInfo.detail]); const bucketInfo = calcBucketStatus_(); rows.push(["BUCKET_STATUS", "Bucket_Allocation_Status","Computed", bucketInfo ? bucketInfo.overall : "N/A", "", "", "", "", "", "", "", today, bucketInfo ? bucketInfo.detail : "data_feed 미실행 OR account_snapshot 없음"]); writeToSheet("macro", headers, rows); Logger.log(`macro 완료: ${rows.length - 9}종목 + MRS/REGIME/BAYESIAN/TOTAL_HEAT/FC_BUDGET/NET_RETURN_FEEDBACK/ORBIT_GAP/ORBIT_STATE/BUCKET_STATUS`); // orbit_gap 월별 이력 탭 갱신 (이미 계산된 macroSettings/orbitInfo 재사용) runOrbitGap(macroSettings, orbitInfo); // 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다. if (!isRunAllOrchestrated_()) { runEventRisk(); } } // ── 이벤트 리스크 ───────────────────────────────────────────────────────────── // event_calendar 탭을 source of truth로 읽어 event_risk 탭을 생성한다. // 날짜는 GAS 코드에 hardcode하지 않는다 — 운영자가 event_calendar 탭을 직접 관리. // 최초 실행 또는 탭이 비어 있으면 seedEventCalendar_()가 초기값을 채운다. // 탭 업데이트: GAS 편집기 → seedEventCalendar_ 또는 직접 시트 편집. // seed: FOMC / US_CPI / EARNINGS / EXPIRY / IPO 기준값 (빈 탭에만 기록) function seedEventCalendar_() { const ss = getSpreadsheet_(); let sheet = ss.getSheetByName("event_calendar"); if (!sheet) sheet = ss.insertSheet("event_calendar"); const SEED_HEADERS = ["Date", "Event", "Type", "Impact", "Alert"]; const SEED_ROWS = [ // FOMC — Federal Reserve 공식 일정 (연 8회). 업데이트: https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm ["2026-06-11", "FOMC 금리결정", "FOMC", "HIGH", "금리동결 시 KOSPI +1~2% 기대, 인상 시 원화 약세 압력"], ["2026-07-28", "FOMC 금리결정", "FOMC", "HIGH", ""], ["2026-09-16", "FOMC 금리결정", "FOMC", "HIGH", ""], // US CPI — BLS 발표일 (매월 1회). 업데이트: https://www.bls.gov/schedule/news_release/cpi.htm ["2026-06-11", "미국 CPI 발표 (5월)", "US_CPI", "HIGH", "예상치 상회 시 금리인상 우려 → 원화 약세·KOSPI 하방 압력. 당일 신규매수 자제"], ["2026-07-15", "미국 CPI 발표 (6월)", "US_CPI", "HIGH", "FOMC 전 마지막 CPI — 금리 경로 재평가 촉매"], ["2026-08-12", "미국 CPI 발표 (7월)", "US_CPI", "HIGH", ""], // EARNINGS ["2026-06-20", "삼성전자 1Q 잠정실적", "EARNINGS", "HIGH", "반도체 섹터 선행 지표"], // EXPIRY ["2026-06-15", "옵션만기일", "EXPIRY", "MEDIUM", "변동성 확대 구간 주의"], ["2026-07-15", "선물·옵션 동시만기", "EXPIRY", "HIGH", "트리플위칭 — 포지션 줄이기"], // IPO — 대형 IPO 확정 시 직접 추가. Type=IPO, Impact=HIGH // 예: ["2026-MM-DD", "XXX 상장", "IPO", "HIGH", "공모자금 수급 쏠림 → 보유 소형주 매도 압력"] ]; const existingData = sheet.getDataRange().getValues(); // 헤더만 있거나 완전히 비어 있으면 seed 기록 const dataRowCount = existingData.filter((r, i) => i > 0 && r[0] && String(r[0]).trim()).length; if (dataRowCount === 0) { sheet.clearContents(); sheet.appendRow(SEED_HEADERS); SEED_ROWS.forEach(r => sheet.appendRow(r)); Logger.log(`event_calendar seed 완료: ${SEED_ROWS.length}건`); } else { Logger.log(`event_calendar seed skip: 기존 데이터 ${dataRowCount}건 보존`); } } // event_calendar 탭을 읽어 DaysLeft 계산 후 event_risk 탭에 기록 function runEventRisk() { const ss = getSpreadsheet_(); let calSheet = ss.getSheetByName("event_calendar"); // 탭이 없거나 비어 있으면 seed 실행 if (!calSheet || calSheet.getLastRow() < 2) { seedEventCalendar_(); calSheet = ss.getSheetByName("event_calendar"); } const calData = calSheet.getDataRange().getValues(); if (!calData || calData.length < 2) { Logger.log("event_calendar 데이터 없음 — event_risk 업데이트 skip"); return; } // 헤더 인덱스 매핑 (대소문자 무관) const calHeaders = calData[0].map(h => String(h).trim().toLowerCase()); const idxDate = calHeaders.indexOf("date"); const idxEvent = calHeaders.indexOf("event"); const idxType = calHeaders.indexOf("type"); const idxImpact = calHeaders.indexOf("impact"); const idxAlert = calHeaders.indexOf("alert"); if (idxDate < 0 || idxEvent < 0) { Logger.log("event_calendar 헤더 누락 (Date/Event 필수) — seed 재실행 필요"); return; } const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); const todayParts = todayStr.split("-").map(Number); const todayMs = Date.UTC(todayParts[0], todayParts[1]-1, todayParts[2]); const outHeaders = ["Date","DaysLeft","Event","Type","Impact","Alert","AsOfDate"]; const rows = []; for (let i = 1; i < calData.length; i++) { const row = calData[i]; const rawDate = row[idxDate]; if (!rawDate || String(rawDate).trim() === "") continue; // Date 셀이 Date 객체이거나 "YYYY-MM-DD" 문자열 모두 지원 let dateStr; if (rawDate instanceof Date) { dateStr = Utilities.formatDate(rawDate, "Asia/Seoul", "yyyy-MM-dd"); } else { dateStr = String(rawDate).trim(); } if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue; const ep = dateStr.split("-").map(Number); const eventMs = Date.UTC(ep[0], ep[1]-1, ep[2]); const daysLeft = Math.round((eventMs - todayMs) / (1000*60*60*24)); if (daysLeft < -3) continue; // 3일 이전 경과 이벤트 제외 rows.push([ dateStr, daysLeft, idxEvent >= 0 ? row[idxEvent] : "", idxType >= 0 ? row[idxType] : "", idxImpact >= 0 ? row[idxImpact] : "", idxAlert >= 0 ? row[idxAlert] : "", todayStr ]); } rows.sort((a, b) => a[1] - b[1]); writeToSheet("event_risk", outHeaders, rows); Logger.log(`event_risk 완료: ${rows.length}건 (event_calendar 탭에서 읽음)`); // 매달 1일 실행 시 월별 자산 스냅샷 기록 (asset_history 탭) const dayOfMonth = parseInt(Utilities.formatDate(new Date(), "Asia/Seoul", "d"), 10); if (dayOfMonth === 1) runMonthlySnapshot(); // 하위 단계 연쇄는 개별 실행에서만 수행한다. run_all()에서는 최종 오케스트레이터가 한 번만 처리한다. if (!isRunAllOrchestrated_()) { runHarnessRefresh_(); cacheAllViews(); } } function runHarnessRefresh_() { if (typeof buildHarnessContext_ !== "function") { Logger.log("[HARNESS] buildHarnessContext_ missing - integrated code 손상 여부 확인 필요"); return; } try { buildHarnessContext_(); Logger.log("[HARNESS] buildHarnessContext_ completed"); } catch (e) { var msg = (e && e.message) ? e.message : String(e); var stack = (e && e.stack) ? String(e.stack) : 'NO_STACK'; Logger.log("[HARNESS][ERROR] runHarnessRefresh_ message=" + msg); Logger.log("[HARNESS][ERROR] runHarnessRefresh_ stack=" + stack); handleFetchError_("runHarnessRefresh_", e, "CRITICAL"); } } // ── All-in-one orchestration ──────────────────────────────────────────────── // 원하는 최종 결과를 한 번에 갱신하는 진입점. // 순서: // 1) data_feed // 2) sector_flow -> macro // 3) core_satellite // 4) event_risk // 5) harness 재생성 // 6) cache 재생성 var __RUN_ALL_ORCHESTRATED__ = false; function isRunAllOrchestrated_() { return __RUN_ALL_ORCHESTRATED__ === true; } function setRunAllOrchestrated_(value) { __RUN_ALL_ORCHESTRATED__ = value === true; } function clearRunAllState_() { const props = PropertiesService.getScriptProperties(); props.deleteProperty("run_all_step"); props.deleteProperty("run_all_start_time"); if (typeof clearFetchCache === "function") { try { clearFetchCache(); } catch (e) { Logger.log("[RUN_ALL] clearFetchCache failed: " + e.message); } } } function run_all() { const props = PropertiesService.getScriptProperties(); const runAllInvocationMode = String(props.getProperty("run_all_invocation_mode") || "external_scheduler"); const invocationStartTime = new Date().getTime(); clearRunAllState_(); if (typeof beginFetchSession_ === "function") { try { beginFetchSession_("run_all"); } catch (e) { Logger.log("[RUN_ALL] Failed to auto begin fetch session: " + e.message); } } Logger.log("[RUN_ALL] invocation_mode=" + runAllInvocationMode); const steps = [ { name: "runDaily (Calendar Scraping)", fn: function() { if (typeof runDaily === "function") { try { runDaily(); } catch(e) { Logger.log("[WARN] runDaily 실행 중 일부 단계 실패 (단, 스크래핑 및 정렬은 시도됨): " + e.message); } } else { Logger.log("[WARN] runDaily 함수가 정의되어 있지 않아 캘린더 스크래핑을 건너뜁니다."); } } }, { name: "runSectorFlow", fn: runSectorFlow }, { name: "runSectorUniverseRefreshAudit", fn: function() { const universe = readSectorUniverse_(); const audit = calcSectorUniverseRefreshAudit_(universe); writeSectorUniverseRefreshAuditSheet_(audit); Logger.log("[RUN_ALL] sector_universe_refresh_audit gate=" + audit.gate + " rows=" + (audit.rows || []).length); } }, { name: "runDataFeed", fn: runDataFeed }, { name: "runSellPriority", fn: runSellPriority }, { name: "runCoreSatelliteFlow_", fn: runCoreSatelliteFlow_ }, { name: "runEventRisk", fn: runEventRisk }, { name: "runHarnessRefresh_", fn: runHarnessRefresh_ }, { name: "runRebalanceSheet_", fn: function() { if (typeof runRebalanceSheet_ === "function") { runRebalanceSheet_(); } else { Logger.log("[WARN] runRebalanceSheet_ 함수가 정의되어 있지 않아 건너뜁니다. gdf_06_rebalance.gs 배포 여부 확인."); } } }, { name: "updateEvaluationDashboard_", fn: function() { if (typeof updateEvaluationDashboard_ === "function") { updateEvaluationDashboard_(); } else { Logger.log("[WARN] updateEvaluationDashboard_ 미정의 — gdf_04_execution_quality.gs 배포 여부 확인."); } } }, ]; Logger.log("[RUN_ALL] start"); setRunAllOrchestrated_(true); try { for (let i = 0; i < steps.length; i++) { const step = steps[i]; const elapsedBefore = (new Date().getTime() - invocationStartTime) / 1000; if (elapsedBefore > 240) { Logger.log("[RUN_ALL] 단계 [" + step.name + "] 시작 전 실행 한도 도달 직전 종료 (경과: " + elapsedBefore.toFixed(1) + "초)."); return; } try { Logger.log("[RUN_ALL] step=" + step.name + " start"); step.fn(); Logger.log("[RUN_ALL] step=" + step.name + " done"); } catch (e) { if (e.message === "PARTIAL_SAVE_REQUESTED") { Logger.log("[RUN_ALL] step=" + step.name + " partial save 요청 수신."); return; } Logger.log("[RUN_ALL][ERROR] step=" + step.name + " message=" + ((e && e.message) ? e.message : String(e))); handleFetchError_("run_all:" + step.name, e, "CRITICAL"); throw e; } } scheduleCacheAllViews_(); // 완료 시 Properties 정리 및 예약 트리거 청소 props.deleteProperty("run_all_invocation_mode"); ScriptApp.getProjectTriggers() .filter(t => t.getHandlerFunction() === "run_all") .forEach(t => ScriptApp.deleteTrigger(t)); } finally { setRunAllOrchestrated_(false); } Logger.log("[RUN_ALL] done"); } function scheduleCacheAllViews_() { ScriptApp.getProjectTriggers() .filter(t => t.getHandlerFunction() === "cacheAllViews") .forEach(t => ScriptApp.deleteTrigger(t)); ScriptApp.newTrigger("cacheAllViews").timeBased().after(60 * 1000).create(); Logger.log("[RUN_ALL] step=cacheAllViews scheduled (1min trigger)"); } function runCoreSatelliteFlow_() { const props = PropertiesService.getScriptProperties(); const universe = getCoreSatelliteUniverse(); const totalChunks = Math.max(1, Math.ceil(universe.length / CHUNK_SIZE)); const startTime = new Date().getTime(); for (let i = 0; i < totalChunks; i++) { let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10); if (chunkIdx >= totalChunks) { break; } const elapsed = (new Date().getTime() - startTime) / 1000; if (elapsed > 120) { Logger.log("[RUN_ALL] core_satellite 청크 " + chunkIdx + " 실행 전 한도 도달 직전 종료 (경과: " + elapsed.toFixed(1) + "초)."); throw new Error("PARTIAL_SAVE_REQUESTED"); } runCoreSatelliteBatch(); const statusRaw = props.getProperty("cs_status") || "{}"; let status = {}; try { status = JSON.parse(statusRaw); } catch (e) { status = {}; } const state = String(status.status || "").toUpperCase(); if (state === "COMPLETE" || state === "FINALIZED") { break; } } } // ── JSON 캐시 업데이트 ──────────────────────────────────────────────────────── // 매일 runEventRisk() 완료 후 호출. doGet()이 Sheets를 다시 읽지 않고 // CacheService 캐시만 반환하므로 응답 시간이 2~8s → <300ms로 단축됨. function cacheAllViews() { // one-shot 트리거로 실행된 경우 자신을 삭제 (누적 방지) ScriptApp.getProjectTriggers() .filter(t => t.getHandlerFunction() === "cacheAllViews") .forEach(t => ScriptApp.deleteTrigger(t)); const cache = CacheService.getScriptCache(); const generatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST"; const TTL = 3600; // 1시간 const MAX_CACHE_BYTES = 95 * 1024; // CacheService 실효 한계(100KB) 대비 여유 const sellPriorityView = runSellPriority(); const views = { health: getHealthJson_(), meta: getWorkbookMetaJson_(), data_feed: getDataFeedJson(), // backdata_feature_bank는 누적 운영으로 대용량이므로 캐시 제외 (요청 시 doGet에서 실시간 조회) backdata_feature_bank_compact: getBackdataFeatureBankJsonCompact(), portfolio: getPortfolioJson(), sectors: getSectorFlowJson(), macro: getMacroJson(), events: getEventRiskJson(), orbit_gap: getOrbitGapJson(), asset_history: getAssetHistoryJson(), brief: getDailyBrief(sellPriorityView), sell_priority: sellPriorityView, }; // summary는 위 뷰들을 조합 — 개별 결과 재활용 const port = views.portfolio; const sectors = views.sectors; const macro = views.macro; const events = views.events; const orbit = views.orbit_gap; const holdings = port.holdings; const totalFrg5 = holdings.reduce((s,h) => s + (parseFloat(h.Frg_5D) || 0), 0); const totalInst5 = holdings.reduce((s,h) => s + (parseFloat(h.Inst_5D) || 0), 0); const flowOkCount = holdings.filter(h => h.Flow_OK === "Y").length; const ss001Dist = { A: 0, B: 0, C: 0, D: 0 }; const actionDist = {}; holdings.forEach(h => { const g = h["SS001_Grade"]; if (g in ss001Dist) ss001Dist[g]++; const a = h["Allowed_Action"] || "UNKNOWN"; actionDist[a] = (actionDist[a] ?? 0) + 1; }); views.summary = { portfolio_flow_summary: { total_holdings: holdings.length, data_ok_count: flowOkCount, portfolio_frg_5d_total: roundNum(totalFrg5, 0), portfolio_inst_5d_total: roundNum(totalInst5, 0), portfolio_indiv_5d_total: roundNum(-(totalFrg5 + totalInst5), 0), }, ss001_grade_distribution: ss001Dist, action_distribution: actionDist, sector_summary: { total_sectors: sectors.count, top_inflow_sectors: sectors.top_inflow, outflow_warning_sectors: sectors.outflow_warning, strong_smart_money_sectors:sectors.strong_smart_money, }, macro_snapshot: { vix: macro.vix, usd_krw: macro.usd_krw, kospi: macro.kospi, sp500_5d_ret: macro.sp500_ret5d, market_regime: macro.market_regime, mrs_score: macro.mrs_score, bayesian_multiplier:macro.bayesian_multiplier, total_heat_pct: macro.total_heat_pct, fc_budget_pct: macro.fc_budget_pct, net_return_feedback:macro.net_return_feedback, orbit_gap_pct: macro.orbit_gap_pct, orbit_state: macro.orbit_state, orbit_slot_adj: macro.orbit_slot_adj, }, event_alerts: events.upcoming_7d, holdings_detail: holdings, sector_detail: sectors.sectors, macro_computed: macro.computed_summary, orbit_current: orbit.current, }; // 각 뷰를 CacheService에 저장 (최대 100KB/키) for (const [view, payload] of Object.entries(views)) { payload.view = view; payload.generated_at = generatedAt; try { const serialized = JSON.stringify(payload, null, 2); if (serialized.length > MAX_CACHE_BYTES) { Logger.log(`캐시 스킵 (${view}): payload too large ${serialized.length} bytes`); continue; } cache.put(`view_${view}`, serialized, TTL); } catch(e) { Logger.log(`캐시 저장 실패 (${view}): ${e.message}`); } } Logger.log(`cacheAllViews 완료 (TTL: ${TTL}s)`); } // ──────────────────────────────────────────────────────────────────────────── // Phase 3: Web App API (doGet) — Custom GPT Action 엔드포인트 // // 배포: script.google.com → 배포 → 웹 앱 → 실행 권한: "모든 사용자" // URL: https://script.google.com/macros/s/{DEPLOYMENT_ID}/exec // // Custom GPT에서 ?view=summary 로 호출 → 포트폴리오 분석 JSON 반환 // ──────────────────────────────────────────────────────────────────────────── const VIEW_GID_MAP = { "1835496032": "macro", "361215520": "events", "857909836": "sectors", "1266919040": "data_feed", "1490216937": "core_satellite", }; function doGet(e) { const rawView = String(e?.parameter?.view ?? "").trim().toLowerCase(); const rawGid = String(e?.parameter?.gid ?? "").trim(); const compactFlag_ = parseCompactFlag_(e?.parameter?.compact); const view = rawView || VIEW_GID_MAP[rawGid] || "summary"; // ① 캐시 우선 반환 — 매일 runEventRisk() 완료 시 cacheAllViews()가 채워 둠 // 캐시 HIT: <300ms, 캐시 MISS(만료·첫 호출): Sheets 직접 읽기(2~5s) const cache = CacheService.getScriptCache(); const cached = cache.get(`view_${view}`); if (cached) { return ContentService .createTextOutput(cached) .setMimeType(ContentService.MimeType.JSON); } // ② 캐시 MISS → Sheets에서 직접 읽어 반환 (기존 동작 유지) let payload; try { switch(view) { case "health": payload = getHealthJson_(); break; case "meta": payload = getWorkbookMetaJson_(); break; case "all": payload = getAllJson_(compactFlag_); break; case "raw_all": payload = getRawAllJson_(compactFlag_); break; case "data_feed": payload = getDataFeedJson(); break; case "backdata_feature_bank": payload = compactFlag_ ? getBackdataFeatureBankJsonCompact() : getBackdataFeatureBankJson(); break; case "backdata_feature_bank_compact": payload = getBackdataFeatureBankJsonCompact(); break; case "sectors": payload = getSectorFlowJson(); break; case "portfolio": payload = getPortfolioJson(); break; case "core_satellite": payload = getCoreSatelliteJson(compactFlag_); break; case "macro": payload = getMacroJson(); break; case "events": payload = getEventRiskJson(); break; case "orbit_gap": payload = getOrbitGapJson(); break; case "brief": payload = getDailyBrief(null); break; case "sell_priority": payload = runSellPriority(); break; case "asset_history": payload = getAssetHistoryJson(); break; case "source_health": payload = checkDataSourceHealth(); break; case "trade_template": payload = getTradeTemplate(String(e?.parameter?.ticker ?? "").trim()); break; case "init_account_snapshot": payload = initAccountSnapshotTemplate_(); break; case "summary": default: payload = getSummaryJson(); break; } payload.view = view; payload.generated_at = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST"; } catch(err) { payload = { error: err.message, view }; } return ContentService .createTextOutput(JSON.stringify(payload, null, 2)) .setMimeType(ContentService.MimeType.JSON); } function doPost(e) { const payload = parseJsonPostBody_(e); const action = String(payload.action || payload.view || "").trim().toLowerCase(); try { if (action === "sync_sector_insights") { const result = syncSectorInsightSheets_(payload); return ContentService .createTextOutput(JSON.stringify(result, null, 2)) .setMimeType(ContentService.MimeType.JSON); } if (action === "trigger_run_all") { // 외부(Gitea CI) 스케줄러가 run_all()을 원격 트리거할 수 있게 하는 진입점. // run_all은 매수/매도 주문을 실행하지 않는다(데이터 갱신·분석 전용) — governance // 06/07과 동일한 "조회/분석만, 주문 없음" 원칙을 따른다. 공유 비밀키로 무단 호출 차단. const expectedSecret = String(PropertiesService.getScriptProperties().getProperty("RUN_ALL_TRIGGER_SECRET") || ""); const providedSecret = String(payload.secret || ""); if (!expectedSecret || providedSecret !== expectedSecret) { return ContentService .createTextOutput(JSON.stringify({ status: "ERROR", message: "unauthorized" }, null, 2)) .setMimeType(ContentService.MimeType.JSON); } const startedAt = new Date().toISOString(); try { run_all(); return ContentService .createTextOutput(JSON.stringify({ status: "OK", started_at: startedAt, finished_at: new Date().toISOString() }, null, 2)) .setMimeType(ContentService.MimeType.JSON); } catch (runErr) { return ContentService .createTextOutput(JSON.stringify({ status: "ERROR", message: String(runErr && runErr.message ? runErr.message : runErr) }, null, 2)) .setMimeType(ContentService.MimeType.JSON); } } return ContentService .createTextOutput(JSON.stringify({ status: "ERROR", message: `unsupported action: ${action || "missing"}`, }, null, 2)) .setMimeType(ContentService.MimeType.JSON); } catch (err) { return ContentService .createTextOutput(JSON.stringify({ status: "ERROR", message: String(err && err.message ? err.message : err), }, null, 2)) .setMimeType(ContentService.MimeType.JSON); } } function parseJsonPostBody_(e) { try { const raw = String(e?.postData?.contents ?? "").trim(); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : {}; } catch (err) { return {}; } } function rowFromObject_(headers, obj) { return headers.map(function(h) { const v = obj && Object.prototype.hasOwnProperty.call(obj, h) ? obj[h] : ""; if (v === null || v === undefined) return ""; if (typeof v === "object") return JSON.stringify(v); return v; }); } function writeSummarySheet_(sheetName, rows) { const headers = ["section", "key", "value"]; const tableRows = (rows || []).map(function(r) { return [r.section || "", r.key || "", r.value || ""]; }); writeToSheet(sheetName, headers, tableRows); return tableRows.length; } function writeSectorTrendAnalysisSheet_(analysis) { if (!analysis || typeof analysis !== "object") return 0; const summary = analysis.summary || {}; const concentration = analysis.concentration || {}; const detailHeaders = [ "sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_code", "etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk", "proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score", "score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d", "sector_etf_ret_gap_5d", "sector_etf_ret_gap_20d", "smart_money_5d_krw_raw", "smart_money_20d_krw_raw", "smart_money_direction", "liquidity_direction", "flow_alignment_state", "momentum_state", "concentration_weight_pct" ]; const detailRows = Array.isArray(analysis.rows) ? analysis.rows.map(function(r) { return rowFromObject_(detailHeaders, r); }) : []; writeSummarySheet_("sector_trend_summary", [ { section: "summary", key: "formula_id", value: analysis.formula_id || "" }, { section: "summary", key: "gate", value: analysis.gate || "" }, { section: "summary", key: "latest_snapshot_date", value: analysis.latest_snapshot_date || "" }, { section: "summary", key: "previous_snapshot_date", value: analysis.previous_snapshot_date || "" }, { section: "summary", key: "sector_count", value: analysis.sector_count || 0 }, { section: "summary", key: "trend_posture", value: summary.trend_posture || "" }, { section: "summary", key: "rising_count", value: summary.rising_count || 0 }, { section: "summary", key: "fading_count", value: summary.fading_count || 0 }, { section: "summary", key: "stable_count", value: summary.stable_count || 0 }, { section: "summary", key: "etf_proxy_count", value: summary.etf_proxy_count || 0 }, { section: "summary", key: "smart_money_inflow_count", value: summary.smart_money_inflow_count || 0 }, { section: "summary", key: "smart_money_outflow_count", value: summary.smart_money_outflow_count || 0 }, { section: "concentration", key: "top_sector", value: concentration.top_sector || "" }, { section: "concentration", key: "top_sector_weight_pct", value: concentration.top_sector_weight_pct || 0 }, { section: "concentration", key: "top2_weight_pct", value: concentration.top2_weight_pct || 0 }, { section: "concentration", key: "concentration_gate", value: concentration.concentration_gate || "" }, ]); writeToSheet("sector_trend_analysis", detailHeaders, detailRows); const timelineHeaders = [ "snapshot_date", "sector_count", "avg_sector_score", "top_sector", "top_sector_score", "positive_breadth_count", "liquidity_warn_count", "net_smart_money_5d_krw" ]; const timelineRows = Array.isArray(analysis.timeline) ? analysis.timeline.map(function(r) { return rowFromObject_(timelineHeaders, r); }) : []; writeToSheet("sector_trend_timeline", timelineHeaders, timelineRows); return detailRows.length; } function writeEtfRepresentativeMonitorSheet_(monitor) { if (!monitor || typeof monitor !== "object") return 0; const summary = monitor.summary || {}; const detailHeaders = [ "sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "sector_rank", "sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count", "representative_ticker", "representative_name", "representative_basis", "representative_basis_detail", "constituent_weight", "basket_quality_state", "basket_coverage_pct", "basket_state", "basket_buy_review_count", "basket_track_count", "basket_watch_count", "basket_caution_count", "basket_aligned_count", "basket_missing_count", "basket_real_count", "selection_source", "selection_score", "monitor_reason", "representatives_json" ]; const detailRows = Array.isArray(monitor.rows) ? monitor.rows.map(function(r) { const repJson = Array.isArray(r.representatives) ? JSON.stringify(r.representatives) : ""; const base = Object.assign({}, r, { representatives_json: repJson }); return rowFromObject_(detailHeaders, base); }) : []; writeSummarySheet_("etf_representative_summary", [ { section: "summary", key: "formula_id", value: monitor.formula_id || "" }, { section: "summary", key: "gate", value: monitor.gate || "" }, { section: "summary", key: "etf_sector_count", value: monitor.etf_sector_count || 0 }, { section: "summary", key: "tracked_count", value: monitor.tracked_count || 0 }, { section: "summary", key: "buy_review_count", value: summary.buy_review_count || 0 }, { section: "summary", key: "track_count", value: summary.track_count || 0 }, { section: "summary", key: "watch_count", value: summary.watch_count || 0 }, { section: "summary", key: "caution_count", value: summary.caution_count || 0 }, { section: "summary", key: "aligned_count", value: summary.aligned_count || 0 }, { section: "summary", key: "weighted_basis_count", value: summary.weighted_basis_count || 0 }, { section: "summary", key: "fallback_basis_count", value: summary.fallback_basis_count || 0 }, { section: "summary", key: "complete_basket_count", value: summary.complete_basket_count || 0 }, { section: "summary", key: "partial_basket_count", value: summary.partial_basket_count || 0 }, { section: "summary", key: "basket_missing_total", value: summary.basket_missing_total || 0 }, ]); writeToSheet("etf_representative_monitor", detailHeaders, detailRows); return detailRows.length; } function syncSectorInsightSheets_(payload) { const trend = payload.sector_trend_analysis || payload.sectorTrendAnalysis || null; const etf = payload.etf_representative_monitor || payload.etfRepresentativeMonitor || null; const written = {}; if (trend) written.sector_trend_analysis = writeSectorTrendAnalysisSheet_(trend); if (etf) written.etf_representative_monitor = writeEtfRepresentativeMonitorSheet_(etf); return { status: "OK", action: "sync_sector_insights", written, generated_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST", }; } // ── Sheets → JSON 변환 헬퍼 ─────────────────────────────────────────────── function parseCompactFlag_(value) { const raw = String(value ?? "").trim().toLowerCase(); return raw === "1" || raw === "true" || raw === "yes" || raw === "y"; } function getHealthJson_() { return { status: "OK", mode: "health", app: "gas_data_feed", schema_version: SCHEMA_VERSION, spreadsheet_id: SPREADSHEET_ID, timezone: "Asia/Seoul", available_views: ["health","summary","brief","data_feed","backdata_feature_bank","backdata_feature_bank_compact","core_satellite","sell_priority","macro","events","sectors","portfolio","orbit_gap","asset_history","trade_template","all","raw_all"], transport_policy: { canonical_transport: "HTTP GET", canonical_client: "Invoke-WebRequest / curl / script fetch", direct_open: "may be blocked by session policy", }, }; } function getWorkbookMetaJson_() { const ss = getSpreadsheet_(); const sheets = ss.getSheets().map(sheet => { const data = sheet.getDataRange().getValues(); const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim(); const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null; const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : []; const rowCount = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).length : 0; return { sheet: sheet.getName(), gid: sheet.getSheetId(), hidden: sheet.isSheetHidden(), updated_at: updatedAt, count: rowCount, header_count: headers.length, }; }); return { mode: "meta", schema_version: SCHEMA_VERSION, sheet_count: sheets.length, sheets, }; } function getSheetEnvelopeJson_(sheetName, gid, options) { const compact = Boolean(options?.compact); const maxRows = Number.isFinite(Number(options?.maxRows)) ? Math.max(0, Number(options.maxRows)) : null; const ss = getSpreadsheet_(); const sheet = ss.getSheetByName(sheetName); if (!sheet) { return { sheet: sheetName, gid: gid ?? null, schema_version: SCHEMA_VERSION, updated_at: null, count: 0, headers: [], rows: [], compact: false, truncated: false, }; } const data = sheet.getDataRange().getValues(); const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim(); const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null; const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : []; const rowsFull = sheetToJson(sheetName); const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull; return { sheet: sheetName, gid: gid ?? null, schema_version: SCHEMA_VERSION, updated_at: updatedAt, count: rowsFull.length, headers, rows, compact, truncated: rows.length < rowsFull.length, }; } function sheetToJson(sheetName) { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName(sheetName); if (!sheet) return []; const data = sheet.getDataRange().getValues(); // row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터 if (data.length < 3) return []; const headers = data[1].map(h => String(h).trim()); // 날짜 컬럼 식별 (AsOfDate, Updated_At, Date, Price_Date) const dateCols = new Set(["AsOfDate","Updated_At","Date","Price_Date"]); return data.slice(2).filter(r => r.some(c => c !== "")).map(r => { const obj = {}; headers.forEach((h, i) => { const v = r[i]; // Date 객체 → "yyyy-MM-dd" 문자열로 직렬화 if (v instanceof Date && !isNaN(v)) { obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd"); } else { obj[h] = v; } }); return obj; }); } function getSectorFlowJson() { const sectors = sheetToJson("sector_flow"); return { sectors, top_inflow: sectors.filter(s => s.Alert_Level === "INFLOW_STRONG").map(s => s.Sector), outflow_warning: sectors.filter(s => ["OUTFLOW_ALERT","OUTFLOW_CAUTION"].includes(s.Alert_Level)).map(s => s.Sector), strong_smart_money: sectors.filter(s => s.Smart_Money === "STRONG").map(s => s.Sector), count: sectors.length }; } function getPortfolioJson() { const holdings = sheetToJson("data_feed"); return { holdings, count: holdings.length }; } function getDataFeedJson() { return getSheetEnvelopeJson_("data_feed", 1266919040, { compact: false }); } function getBackdataFeatureBankJson() { return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: false }); } function getBackdataFeatureBankJsonCompact() { return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: true, maxRows: 50 }); } function getCoreSatelliteJson(compact) { return getSheetEnvelopeJson_("core_satellite", 1490216937, { compact: Boolean(compact), maxRows: compact ? 20 : null, }); } function getAllJson_(compact) { return { data_feed: getDataFeedJson(), backdata_feature_bank: getBackdataFeatureBankJson(), core_satellite: getCoreSatelliteJson(compact), sector_flow: getSectorFlowJson(), macro: getMacroJson(), event_risk: getEventRiskJson(), summary: getSummaryJson(), }; } function getRawAllJson_(compact) { const ss = getSpreadsheet_(); const sheets = ss.getSheets(); const maxRows = compact ? 20 : null; const payloadSheets = sheets.map(sheet => { const name = sheet.getName(); const gid = sheet.getSheetId(); const data = sheet.getDataRange().getValues(); const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim(); const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null; const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : []; const rowsFull = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).map(r => { const obj = {}; headers.forEach((h, i) => { const v = r[i]; if (v instanceof Date && !isNaN(v)) { obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd"); } else { obj[h] = v; } }); return obj; }) : []; const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull; return { sheet: name, gid, sheet_id: gid, hidden: sheet.isSheetHidden(), updated_at: updatedAt, count: rowsFull.length, headers, rows, compact: Boolean(compact), truncated: rows.length < rowsFull.length, }; }); return { mode: "raw_all", schema_version: SCHEMA_VERSION, sheet_count: payloadSheets.length, compact: Boolean(compact), sheets: payloadSheets, }; } // 숫자 배열의 중앙값 (양수만, 빈 배열이면 null) function calcMedian_(arr) { const nums = arr.filter(v => Number.isFinite(v) && v > 0); if (!nums.length) return null; nums.sort((a, b) => a - b); const mid = Math.floor(nums.length / 2); return nums.length % 2 === 0 ? (nums[mid - 1] + nums[mid]) / 2 : nums[mid]; } // float32 → float64 노이즈 제거: 숫자 값을 소수점 4자리로 정리 function roundNum(v, digits) { if (typeof v !== "number" || isNaN(v)) return v; return parseFloat(v.toFixed(digits ?? 4)); } function getMacroJson() { const macro = sheetToJson("macro").map(m => ({ ...m, Close: roundNum(m.Close, 4), Ret1D: roundNum(m.Ret1D, 2), Ret5D: roundNum(m.Ret5D, 2), Ret20D: roundNum(m.Ret20D, 2), })); const byName = {}; macro.forEach(m => { byName[m.Name] = m; }); // MRS 요약 추출 const mrsRow = byName["Market_Risk_Score"] ?? {}; const regimeRow = byName["Market_Regime_Prelim"] ?? {}; const bayesRow = byName["Bayesian_Multiplier"] ?? {}; const heatRow = byName["Total_Heat_Pct"] ?? {}; const fcRow = byName["FC_Loss_Budget_Monthly"] ?? {}; const netRFRow = byName["Net_Return_Feedback"] ?? {}; const orbitGapRow = byName["Orbit_Gap_Pct"] ?? {}; const orbitStRow = byName["Orbit_State"] ?? {}; const bucketRow = byName["Bucket_Allocation_Status"] ?? {}; return { indicators: macro.filter(m => m.Category !== "Computed"), computed_summary: macro.filter(m => m.Category === "Computed"), vix: roundNum(byName["VIX"]?.Close, 2) ?? "N/A", usd_krw: roundNum(byName["USD_KRW"]?.Close, 2) ?? "N/A", kospi: roundNum(byName["KOSPI"]?.Close, 2) ?? "N/A", kospi_ma20: roundNum(byName["KOSPI"]?.MA20, 2) ?? "N/A", kospi_ma60: roundNum(byName["KOSPI"]?.MA60, 2) ?? "N/A", usd_jpy_ret2d: roundNum(byName["USD_JPY"]?.Ret2D, 2) ?? "N/A", hyg_ret5d: roundNum(byName["HYG_HY_Bond"]?.Ret5D, 2) ?? "N/A", sp500_ret5d: roundNum(byName["SP500"]?.Ret5D, 2) ?? "N/A", mrs_score: mrsRow.Close ?? "N/A", mrs_status: mrsRow.Status ?? "N/A", market_regime: regimeRow.Close ?? "N/A", credit_stress: String(regimeRow.Status ?? "").replace("credit_stress=", "") || "N/A", bayesian_multiplier: bayesRow.Close ?? "N/A", bayesian_label: bayesRow.Status ?? "N/A", // trades=0 이면 performance 탭 데이터 없는 기본값; 1건 이상이면 실제 거래 기반 bayesian_data_source: (String(bayesRow.Status ?? "").match(/trades=(\d+)/)?.[1] ?? "0") !== "0" ? "actual" : "default", total_heat_pct: heatRow.Close ?? "N/A", total_heat_gate: heatRow.Status ?? "N/A", fc_budget_pct: fcRow.Close ?? "N/A", fc_budget_status: fcRow.Status ?? "N/A", net_return_feedback: netRFRow.Close ?? "N/A", net_return_detail: netRFRow.Status ?? "N/A", orbit_gap_pct: orbitGapRow.Close ?? "N/A", orbit_gap_detail: orbitGapRow.Status ?? "N/A", orbit_state: orbitStRow.Close ?? "N/A", orbit_slot_adj: String(orbitStRow.Status ?? "").match(/slot_adj=(-?\d+)/)?.[1] ?? "N/A", orbit_cash_adj: String(orbitStRow.Status ?? "").match(/cash_adj=(-?\d+)/)?.[1] ?? "N/A", bucket_status: bucketRow.Close ?? "N/A", bucket_detail: bucketRow.Status ?? "N/A", }; } function getEventRiskJson() { const events = sheetToJson("event_risk"); const urgent = events.filter(e => +e.DaysLeft >= 0 && +e.DaysLeft <= 7); return { events, upcoming_7d: urgent }; } function getOrbitGapJson() { const history = sheetToJson("monthly_history"); if (!history.length) return { history: [], current: null }; const latest = history[history.length - 1]; return { history, current: { month: latest.Month, orbit_gap_pct: latest.Orbit_Gap_Pct, orbit_state: latest.Orbit_State, offensive_slot_adj: latest.Slot_Adj, cash_floor_adj: latest.Cash_Floor_Adj, target_return_pct: latest.Target_Return_Pct, actual_return_pct: latest.Actual_Return_Pct, }, }; } // ── E2: 월말 자산 스냅샷 → monthly_history 기록 ───────────────────────────── // 트리거: 매달 마지막 영업일 16:30 독립 실행 OR runDataFeed 완료 후 호출. function runMonthlySnapshot() { const settings = readSettingsTab_(); const totalAsset = parseFloat(settings["total_asset_krw"]); if (!Number.isFinite(totalAsset) || totalAsset <= 0) { Logger.log("runMonthlySnapshot 스킵: total_asset_krw 미설정"); return; } const month = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM"); // macro에서 버킷·orbit 읽기 const macro = getMacroJson(); const bDetail = String(macro.bucket_detail ?? ""); const corePct = parseFloat(bDetail.match(/core=([\d.]+)%/)?.[1] ?? "") || ""; const satPct = parseFloat(bDetail.match(/sat=([\d.]+)%/)?.[1] ?? "") || ""; const cashPct = parseFloat(bDetail.match(/cash=([\d.]+)%/)?.[1] ?? "") || ""; const orbitGap = macro.orbit_gap_pct !== "N/A" ? macro.orbit_gap_pct : ""; const orbitState = macro.orbit_state !== "N/A" ? macro.orbit_state : ""; // MoM/YTD: monthly_history에서 이전 자산 읽기 const ss = getSpreadsheet_(); const histSheet = ss.getSheetByName("monthly_history"); let prevAsset = null, jan1Asset = null; const thisYear = month.substring(0, 4); if (histSheet) { const hd = histSheet.getDataRange().getValues(); const hdr = hd[0] ?? []; const mIdx = hdr.indexOf("Month"); const aIdx = hdr.indexOf("Total_Asset"); if (mIdx >= 0 && aIdx >= 0) { for (let i = 1; i < hd.length; i++) { const raw = hd[i][mIdx]; const mStr = raw instanceof Date && !isNaN(raw.getTime()) ? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM") : String(raw ?? "").trim().substring(0, 7); if (mStr === month) continue; const a = parseFloat(hd[i][aIdx]); if (mStr && Number.isFinite(a)) { prevAsset = a; if (mStr === `${thisYear}-01`) jan1Asset = a; } } } } const momRet = (prevAsset && prevAsset > 0) ? parseFloat(((totalAsset / prevAsset - 1) * 100).toFixed(2)) : ""; const ytdRet = (jan1Asset && jan1Asset > 0) ? parseFloat(((totalAsset / jan1Asset - 1) * 100).toFixed(2)) : ""; // AEW aggregate: T+20/T+60 outcomes this month from alpha_history var satT20PassN = 0, satT20FailN = 0, satT60PassN = 0; var satT20AlphaSum = 0, satT20AlphaCount = 0; var alphaSheet = ss.getSheetByName("alpha_history"); if (alphaSheet) { var aData = alphaSheet.getDataRange().getValues(); if (aData.length > 1) { var aHdr = aData[0]; var aMap = {}; aHdr.forEach(function(h, i) { aMap[String(h)] = i; }); var skipSet = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 }; for (var ai = 1; ai < aData.length; ai++) { var ar = aData[ai]; var t20cd = String(ar[aMap['T20_Check_Date']] || ''); if (!t20cd || t20cd.substring(0, 7) !== month) continue; var t20g = String(ar[aMap['T20_Alpha_Gate']] || ''); var t60g = String(ar[aMap['T60_Alpha_Gate']] || ''); var t20v = parseFloat(ar[aMap['T20_Vs_Core_Pctp']]); if (t20g === 'T20_ALPHA_PASS') satT20PassN++; else if (t20g === 'T20_ALPHA_FAIL') satT20FailN++; if (t60g === 'T60_ALPHA_PASS') satT60PassN++; if (!skipSet[t20g] && Number.isFinite(t20v)) { satT20AlphaSum += t20v; satT20AlphaCount++; } } } } var satAvgT20Alpha = satT20AlphaCount > 0 ? parseFloat((satT20AlphaSum / satT20AlphaCount).toFixed(2)) : ''; try { runAlphaFeedbackLoop_(); } catch (e) { Logger.log('[AFL] runAlphaFeedbackLoop_ in runMonthlySnapshot error: ' + e.message); } upsertMonthlyRow_(month, { Total_Asset: totalAsset, Core_Pct: corePct, Satellite_Pct: satPct, Cash_Pct: cashPct, MoM_Return_Pct: momRet, YTD_Return_Pct: ytdRet, Orbit_Gap_Pct: orbitGap, Orbit_State: orbitState, Sat_T20_Pass_N: satT20PassN || '', Sat_T20_Fail_N: satT20FailN || '', Sat_T60_Pass_N: satT60PassN || '', Sat_Avg_T20_Alpha_Pct: satAvgT20Alpha, }); Logger.log(`monthly_history(snapshot): ${month} asset=${totalAsset.toLocaleString()} MoM=${momRet}% YTD=${ytdRet}%`); } // ── E4: 데이터 소스 정합성 주 1회 헬스체크 ────────────────────────────────── // 트리거: 주 1회 (매주 월요일 09:00) 독립 실행. // Naver 가격/수급 스크래핑 패턴 정상 여부를 확인하고 Logger에 리포트를 남긴다. // doGet(?view=source_health) 로도 조회 가능. function checkDataSourceHealth() { const PROBE_TICKER = Object.keys(TICKER_SECTOR_MAP)[0] ?? "005930"; // 첫 번째 종목(기본 삼성전자) const results = { checked_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm"), probe_ticker: PROBE_TICKER, checks: [] }; const ok = (name, detail) => { results.checks.push({ name, status: "OK", detail: detail ?? "" }); }; const fail = (name, detail) => { results.checks.push({ name, status: "FAIL", detail: detail ?? "" }); }; // 1. Naver 종목 시세 (Close 패턴) try { beginFetchSession_(); const url = `https://finance.naver.com/item/main.nhn?code=${PROBE_TICKER}`; const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); const html = resp.getContentText("EUC-KR"); const closeMatch = html.match(/

]*>([\d,]+)<\/p>/i) || html.match(/현재가\s+([\d,]+)/i); if (closeMatch) { const price = parseKrNum_(closeMatch[1]); price > 0 ? ok("naver_close", `${price.toLocaleString()}원`) : fail("naver_close", "값 0 또는 음수"); } else { fail("naver_close", "정규식 미매칭 — DOM 변경 가능성"); } // 2. Naver PER 패턴 const perMatch = html.match(/([\d,.]+)<\/em>/); perMatch ? ok("naver_per", `PER ${parseKrNum_(perMatch[1])}`) : fail("naver_per", "_per 패턴 미매칭"); // 3. Naver 52주 고저 패턴 const highMatch = html.match(/52주\s+최고\s*[:\s]*([\d,]+)/i); highMatch ? ok("naver_52w", "52주 고저 패턴 정상") : fail("naver_52w", "52주 패턴 미매칭"); } catch(e) { fail("naver_fetch", String(e)); } finally { endFetchSession_(); } // 4. Naver 수급 탭 패턴 try { beginFetchSession_(); const furl = `https://finance.naver.com/item/frgn.nhn?code=${PROBE_TICKER}`; const fhtml = UrlFetchApp.fetch(furl, { muteHttpExceptions: true }).getContentText("EUC-KR"); const trMatch = fhtml.match(/]*class="[^"]*"[^>]*>[\s\S]{0,300}?<\/tr>/g); trMatch && trMatch.length >= 5 ? ok("naver_flow", `tr행 ${trMatch.length}개`) : fail("naver_flow", "수급 테이블 구조 변경 가능성"); } catch(e) { fail("naver_flow_fetch", String(e)); } finally { endFetchSession_(); } // 5. Yahoo Finance 패턴 (EPS 성장률) try { beginFetchSession_(); const ysym = normalizeYahooSymbol(PROBE_TICKER); const yurl = `https://finance.yahoo.com/quote/${ysym}/analysis`; const yresp = UrlFetchApp.fetch(yurl, { muteHttpExceptions: true }); yresp.getResponseCode() < 400 ? ok("yahoo_analysis", `HTTP ${yresp.getResponseCode()}`) : fail("yahoo_analysis", `HTTP ${yresp.getResponseCode()}`); } catch(e) { fail("yahoo_fetch", String(e)); } finally { endFetchSession_(); } const failCount = results.checks.filter(c => c.status === "FAIL").length; results.overall = failCount === 0 ? "HEALTHY" : failCount <= 1 ? "DEGRADED" : "CRITICAL"; results.summary = `${results.checks.length}개 체크 중 ${failCount}개 실패 → ${results.overall}`; Logger.log(`[DataSourceHealth] ${results.summary}`); results.checks.forEach(c => Logger.log(` [${c.status}] ${c.name}: ${c.detail}`)); return results; } // ── E2: asset_history JSON 뷰 ──────────────────────────────────────────────── function getAssetHistoryJson() { const history = sheetToJson("monthly_history"); if (!history.length) return { history: [], current: null, mom_series: [] }; const latest = history[history.length - 1]; const momSeries = history .filter(r => r.MoM_Return_Pct !== "" && r.MoM_Return_Pct != null) .map(r => ({ month: r.Month, mom_ret: r.MoM_Return_Pct, ytd_ret: r.YTD_Return_Pct })); return { history, current: latest, mom_series: momSeries }; } function readSettings_(ss) { var result = {}; var sheet = ss.getSheetByName(SETTINGS_SHEET_NAME); if (!sheet) return result; var data = sheet.getDataRange().getValues(); data.forEach(function(row) { var key = String(row[0] || '').trim(); if (key) result[key] = row[1]; }); return result; } /** * settings 시트에서 특정 키의 값을 갱신하거나 신규 추가한다. * O3 PORTFOLIO_DRAWDOWN_GATE_V1의 portfolio_peak_krw 자동 갱신에 사용. */ function writeSettingValue_(ss, key, value) { var sheet = ss.getSheetByName(SETTINGS_SHEET_NAME); if (!sheet) return false; var data = sheet.getDataRange().getValues(); for (var i = 0; i < data.length; i++) { if (String(data[i][0] || '').trim() === key) { sheet.getRange(i + 1, 2).setValue(value); return true; } } sheet.appendRow([key, value]); return true; } // ── 유틸리티 ───────────────────────────────────────────────────────────────── /** * KRX 호가단위 정규화 — floor(raw / tick) * tick * spec/13_formula_registry.yaml:TICK_NORMALIZER_V1 */ function tickNormalize_(rawPrice) { var tick = getTickSize_(rawPrice); return Math.floor(rawPrice / tick) * tick; } function getTickSize_(price) { for (var k = 0; k < TICK_TABLE.length; k++) { if (price < TICK_TABLE[k].maxPrice) return TICK_TABLE[k].tick; } return 1000; // >= 500000원 } function writeHarnessSheet_(ss, rows, now) { var sheet = ss.getSheetByName(HARNESS_SHEET_NAME); if (!sheet) { sheet = ss.insertSheet(HARNESS_SHEET_NAME); } else { sheet.clearContents(); } sheet.getRange(1, 1).setValue( HARNESS_SHEET_NAME + ' — GAS computed guard values (HARNESS_AUTHORITATIVE)'); sheet.getRange(1, 2).setValue(formatIso_(now)); sheet.getRange(2, 1).setValue('key'); sheet.getRange(2, 2).setValue('value'); if (rows.length > 0) { var MAX_CELL = 49000; var safeRows = rows.map(function(r) { var v = r[1]; if (typeof v === 'string' && v.length > MAX_CELL) { Logger.log('[HARNESS] CELL_OVERSIZED key=' + r[0] + ' len=' + v.length + ' → trimmed placeholder'); return [r[0], JSON.stringify({ status: 'OVERSIZED', original_len: v.length, key: String(r[0]) })]; } return r; }); sheet.getRange(3, 1, safeRows.length, 2).setValues(safeRows); } } function buildColIdx_(headers) { var idx = {}; headers.forEach(function(h, i) { var key = String(h || '').trim(); if (key) idx[key] = i; }); return idx; } /** row[c[colName]] 숫자 읽기 — 컬럼 없거나 NaN이면 0 */ function numCol_(row, c, colName) { return c[colName] !== undefined ? toNumber_(row[c[colName]]) : 0; } /** row[c[colName]] 문자열 읽기 — 컬럼 없으면 '' */ function strCol_(row, c, colName) { return c[colName] !== undefined ? String(row[c[colName]] || '').trim() : ''; } /** * ticker 정규화 — 숫자 코드는 6자리 zero-pad * convert_xlsx_to_json.py:normalize_code 와 동일 로직 */ function normTicker_(raw) { var s = String(raw || '').trim(); if (!s) return ''; if (s.slice(-2) === '.0') s = s.slice(0, -2); var digits = s.replace('.', ''); if (/^\d+$/.test(digits) && digits.length <= 6) { var n = parseInt(digits, 10); var ns = String(n); while (ns.length < 6) ns = '0' + ns; return ns; } return s; } /** Array.prototype.indexOf 폴리필 래퍼 (GAS 호환) */ function indexOfArr_(arr, val) { for (var k = 0; k < arr.length; k++) { if (arr[k] === val) return k; } return -1; } function toNumber_(v) { if (v === null || v === undefined || v === '') return 0; var n = Number(v); return isNaN(n) ? 0 : n; } function round2_(v) { return Math.round(v * 100) / 100; } // ══════════════════════════════════════════════════════════════════════════════ // Alpha-Shield 선행 레이더 (2026-05-19-X1W1) // X1: MEAN_REVERSION_GATE_V1 | X3: RS_RATIO_V1 // W1: DIVERGENCE_SCORE_V1 | W2: OVERHANG_PRESSURE_V1 // W3: SECTOR_ROTATION_RADAR_V1 | W4: FLOW_ACCELERATION_V1 // ══════════════════════════════════════════════════════════════════════════════ /** * numColN_ — nullable 버전: 컬럼 없으면 null 반환 (numCol_ 은 0 반환) * Alpha-Shield 레이더는 0(값 없음)과 0(값=0)을 구분해야 한다. */ function numColN_(row, c, colName) { return c[colName] !== undefined ? toNumber_(row[c[colName]]) : null; } /** * macro 시트에서 KOSPI 5D 수익률 읽기 * RS_RATIO_V1 분모: kospi_5d_return */ function readKospiRet5d_(ss) { try { var macroSheet = ss.getSheetByName('macro'); if (!macroSheet) return null; var mData = macroSheet.getDataRange().getValues(); if (mData.length < 3) return null; var mHdr = mData[1] || []; var nameIdx = mHdr.indexOf('Name'); var r5dIdx = mHdr.indexOf('Ret5D'); if (nameIdx < 0 || r5dIdx < 0) return null; for (var i = 2; i < mData.length; i++) { if (String(mData[i][nameIdx] || '').trim() === 'KOSPI') { var v = parseFloat(mData[i][r5dIdx]); return Number.isFinite(v) ? v : null; } } } catch(e) { Logger.log('[HARNESS] readKospiRet5d_ error: ' + e); } return null; } /** * macro 시트에서 KOSPI 20D 수익률 읽기 * 상대 손절 베타 프록시 분모: kospi_20d_return */ function readKospiRet20d_(ss) { try { var macroSheet = ss.getSheetByName('macro'); if (!macroSheet) return null; var mData = macroSheet.getDataRange().getValues(); if (mData.length < 3) return null; var mHdr = mData[1] || []; var nameIdx = mHdr.indexOf('Name'); var r20dIdx = mHdr.indexOf('Ret20D'); if (nameIdx < 0 || r20dIdx < 0) return null; for (var i = 2; i < mData.length; i++) { if (String(mData[i][nameIdx] || '').trim() === 'KOSPI') { var v = parseFloat(mData[i][r20dIdx]); return Number.isFinite(v) ? v : null; } } } catch(e) { Logger.log('[HARNESS] readKospiRet20d_ error: ' + e); } return null; } /** * sector_flow 시트에서 W3 레이더용 데이터 읽기 * 반환: { sector_name → { rank, prevRank, prevRankW2, smart5, smart20 } } */ function readSectorFlowForRadar_(ss) { var result = {}; try { var sfSheet = ss.getSheetByName('sector_flow'); if (!sfSheet) return result; var sfData = sfSheet.getDataRange().getValues(); if (sfData.length < 3) return result; var sfHdr = sfData[1] || []; var sNameIdx = sfHdr.indexOf('Sector'); var rankIdx = sfHdr.indexOf('Sector_Rank') >= 0 ? sfHdr.indexOf('Sector_Rank') : sfHdr.indexOf('Rotation_Rank'); var prevRkIdx = sfHdr.indexOf('Prev_Rotation_Rank'); var prevRkW2Idx = sfHdr.indexOf('Prev_Rotation_Rank_W2'); var sm5Idx = sfHdr.indexOf('SmartMoney_5D_KRW') >= 0 ? sfHdr.indexOf('SmartMoney_5D_KRW') : sfHdr.indexOf('Frg_5D_SUM'); var sm20Idx = sfHdr.indexOf('SmartMoney_20D_KRW') >= 0 ? sfHdr.indexOf('SmartMoney_20D_KRW') : sfHdr.indexOf('Frg_20D_SUM'); if (sNameIdx < 0) return result; for (var i = 2; i < sfData.length; i++) { var sName = String(sfData[i][sNameIdx] || '').trim(); if (!sName || sName === 'Sector') continue; result[sName] = { rank: rankIdx >= 0 ? parseInt(sfData[i][rankIdx]) : null, prevRank: prevRkIdx >= 0 ? parseInt(sfData[i][prevRkIdx]) : null, prevRankW2: prevRkW2Idx >= 0 ? parseInt(sfData[i][prevRkW2Idx]) : null, smart5: sm5Idx >= 0 ? parseFloat(sfData[i][sm5Idx]) : null, smart20: sm20Idx >= 0 ? parseFloat(sfData[i][sm20Idx]) : null }; } } catch(e) { Logger.log('[HARNESS] readSectorFlowForRadar_ error: ' + e); } return result; } function formatIso_(d) { try { return d instanceof Date ? d.toISOString() : String(d); } catch (e) { return String(d); } } // ---- TASK-003: RAW_VS_ADJUSTED_DISCLOSURE_V1 ---- // [GAS_STUB_ONLY: requires Google Sheets deployment] function formatRawAdjustedPair_(rawVal, adjVal) { // raw 병기 없는 adjusted 단독 표시 금지 (RC3 수정) if (rawVal === null || rawVal === undefined) { return '[RAW_MISSING: adjusted=' + adjVal + ' — raw 없이 adjusted 단독 표시 금지]'; } return 'raw ' + rawVal + '% / adj ' + adjVal + '%'; }