// gas_lib.gs - Common utilities & static features
// Last Updated: 2026-06-14 13:11:22 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: "012450", proxyName: "한화에어로스페이스", proxyType: "대표주", 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: "건설/EPC", proxyTicker: "028050", proxyName: "삼성E&A", proxyType: "대표주", baseTicker: "069500", constituents: [
    { code: "028050", name: "삼성E&A", weight: 0.40 },
    { code: "000720", name: "현대건설", weight: 0.30 },
    { code: "006360", name: "GS건설", weight: 0.20 },
    { code: "047040", 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: "003540", name: "대신증권", weight: 0.10 },
  ]},
  { 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: "099440", proxyName: "두산에너빌리티", proxyType: "대표주", 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: "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 "소비재";
  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;
  }
  const data = sheet.getDataRange().getValues();
  if (data.length < 3) {
    writeDefaultSectorUniverseSheet_();
    return DEFAULT_SECTOR_UNIVERSE_V2;
  }
  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;

  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",
        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,
    });
  }
  const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0);
  return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2;
}

function writeDefaultSectorUniverseSheet_() {
  const headers = [
    "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker",
    "Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source"
  ];
  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_universe(DEFAULT_SECTOR_UNIVERSE_V2)",
      ]);
    }
  }
  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 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","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 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.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 || "대표주",
      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","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 existing = [];
  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}`] = row;
    existing.push(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}`] = [
      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.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);
}

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","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.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: "runDataFeed", fn: runDataFeed },
    { 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);
}

// ── 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(/<p id="nowVal"[^>]*>([\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(/<em id="_per">([\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(/<tr[^>]*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 + '%';
}
