// gas_data_collect.gs - Data collection & assembly layer
// Fetch infrastructure, data fetchers, buildTickerRow_, runDataFeed
// GAS global scope: functions in gas_data_feed.gs / gas_lib.gs callable directly

function beginFetchSession_(label = "manual") {
  const props = PropertiesService.getScriptProperties();

  try {
    const keys = props.getKeys();
    let budgetCleared = 0;
    let circuitExpired = 0;
    const now = Date.now();
    for (const k of keys) {
      if (k.startsWith("fetch_budget_")) {
        props.deleteProperty(k);
        budgetCleared++;
      } else if (k.startsWith("fetch_circuit_")) {
        // 만료된 circuit breaker 자동 정리: until < now인 경우 제거.
        // isFetchCircuitOpen_()도 자가 치유하지만, 세션 시작 시 선제 정리로
        // 불필요한 PropertiesService read를 줄이고 상태를 명시적으로 초기화.
        try {
          const raw = props.getProperty(k);
          if (raw) {
            const data = JSON.parse(raw);
            if (!data?.until || now >= Number(data.until)) {
              props.deleteProperty(k);
              const failKey = k.replace("fetch_circuit_", "fetch_fail_");
              props.deleteProperty(failKey);
              circuitExpired++;
            }
          }
        } catch (_) {
          props.deleteProperty(k);
          circuitExpired++;
        }
      }
    }
    if (budgetCleared > 0 || circuitExpired > 0) {
      Logger.log("[beginFetchSession_] budget_cleared=" + budgetCleared + " circuit_expired=" + circuitExpired);
    }
  } catch (e) {
    Logger.log("[beginFetchSession_] Error clearing old properties: " + e.message);
  }

  props.setProperty("fetch_session_id", Utilities.getUuid());
  props.setProperty("fetch_session_label", String(label ?? "manual"));
  props.setProperty("fetch_session_started_at", new Date().toISOString());
  props.setProperty("fetch_session_updated_at", new Date().toISOString());
}

function setFetchSessionLabel_(label = "manual") {
  const props = PropertiesService.getScriptProperties();
  let sid = props.getProperty("fetch_session_id");
  if (!sid) {
    beginFetchSession_(label);
    return;
  }
  props.setProperty("fetch_session_label", String(label ?? "manual"));
  props.setProperty("fetch_session_updated_at", new Date().toISOString());
}

function clearFetchCache() {
  const props = PropertiesService.getScriptProperties();
  const keys = props.getKeys();
  for (const k of keys) {
    if (k.startsWith("fetch_fail_") || k.startsWith("fetch_circuit_") || k.startsWith("fetch_budget_") || k.startsWith("cs_")) {
      props.deleteProperty(k);
    }
  }
  // Note: CacheService doesn't have a flushAll, but since we rely heavily on PropertiesService for circuit breakers,
  // clearing the circuits will force a fresh fetch attempt and overwrite the cache.
  Logger.log("Fetch cache and circuit breakers cleared.");
}

// 일부 배포본에서 gas_lib.gs 로딩이 누락돼도 runDataFeed 초기화를 살리기 위한 안전 경로.
// gas_lib.gs의 동일 함수가 존재하면 그 구현을 우선 사용한다.
const _gasCompatRoot_ = (typeof globalThis !== "undefined") ? globalThis : this;
function _installCompat_(name, fn) {
  if (typeof _gasCompatRoot_[name] !== "function") {
    _gasCompatRoot_[name] = fn;
    _gasCompatRoot_._gasCompatFallbackUsed_ = true;
  }
}

const _gasCompatFallbacks_ = {
  getSpreadsheet_: function() {
    let _ssCacheDataCollect_ = _gasCompatRoot_._ssCacheDataCollect_ || null;
    if (_ssCacheDataCollect_) return _ssCacheDataCollect_;
    try {
      if (typeof SPREADSHEET_ID !== "undefined" && SPREADSHEET_ID) {
        _ssCacheDataCollect_ = SpreadsheetApp.openById(SPREADSHEET_ID);
        _gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_;
        return _ssCacheDataCollect_;
      }
    } catch (e) {
      Logger.log(`getSpreadsheet_ fallback openById failed: ${e.message}`);
    }
    _ssCacheDataCollect_ = SpreadsheetApp.getActiveSpreadsheet();
    _gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_;
    return _ssCacheDataCollect_;
  },
  readSettingsTab_: function() {
    const result = {};
    try {
      const ss = getSpreadsheet_();
      const sheet = ss.getSheetByName("settings");
      if (!sheet) {
        Logger.log("readSettingsTab_: settings 탭 없음");
        return result;
      }
      const data = sheet.getDataRange().getValues();
      const SKIP_KEYS = new Set(["key", "updated", "date", "항목", "파라미터"]);
      for (let i = 0; i < data.length; i++) {
        const rawKey = String(data[i][0] ?? "").trim();
        if (!rawKey || SKIP_KEYS.has(rawKey.toLowerCase())) continue;
        const val = data[i][1];
        if (val !== "" && val != null) result[rawKey] = val;
      }
    } catch (e) {
      Logger.log(`readSettingsTab_ fallback error: ${e.message}`);
    }
    return result;
  },
  readPerformanceSheet_: function() {
    const DEFAULT = {
      bayesian_multiplier: 0.5,
      bayesian_label: "medium_confidence",
      trades_used: 0,
      win_rate_30: null,
      net_expectancy_30: null,
      consecutive_losses: 0,
      bayesian_data_source: "default",
    };
    try {
      const ss = getSpreadsheet_();
      const sheet = ss.getSheetByName("performance");
      if (!sheet) return DEFAULT;
      const data = sheet.getDataRange().getValues();
      if (data.length < 3) return DEFAULT;
      const hdr = data[1].map(h => String(h).trim());
      const pnlIdx  = hdr.indexOf("pnl_pct");
      const exitIdx = hdr.indexOf("exit_date");
      const exitDateIdx = hdr.indexOf("exit_date");
      if (pnlIdx < 0 || exitIdx < 0) return DEFAULT;

      const closed = [];
      for (let i = 2; i < data.length; i++) {
        const exitVal = data[i][exitIdx];
        if (!exitVal || String(exitVal).trim() === "") continue;
        const pnl = parseFloat(data[i][pnlIdx]);
        if (!Number.isFinite(pnl)) continue;
        const exitRaw = exitDateIdx >= 0 ? data[i][exitDateIdx] : exitVal;
        const exitMs = exitRaw instanceof Date && !isNaN(exitRaw.getTime())
          ? exitRaw.getTime()
          : new Date(exitRaw).getTime();
        closed.push({ pnl, exitMs: Number.isFinite(exitMs) ? exitMs : 0 });
      }
      if (closed.length === 0) return DEFAULT;
      closed.sort((a, b) => b.exitMs - a.exitMs);
      const recent = closed.slice(0, 30).map(r => r.pnl);
      const n = recent.length;
      if (n < 5) return DEFAULT;

      const wins = recent.filter(x => x > 0).length;
      const losses = recent.filter(x => x < 0).length;
      const sum = recent.reduce((a, b) => a + b, 0);
      const winRate = (wins / n) * 100;
      const avg = sum / n;
      const label = avg >= 2 ? "high_confidence" : avg >= 0 ? "medium_confidence" : "low_confidence";

      return {
        bayesian_multiplier: label === "high_confidence" ? 1.0 : label === "medium_confidence" ? 0.5 : 0.25,
        bayesian_label: label,
        trades_used: n,
        win_rate_30: winRate,
        net_expectancy_30: avg,
        consecutive_losses: losses,
        bayesian_data_source: "actual",
      };
    } catch (e) {
      Logger.log(`readPerformanceSheet_ fallback error: ${e.message}`);
      return DEFAULT;
    }
  },
  calcKrxBizDaysDiff_: function(dateStr) {
    if (!dateStr) return 999;
    const norm = String(dateStr).replace(/\./g, "-");
    if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999;

    const now = new Date();
    const kstMs = now.getTime() + 9 * 3600 * 1000;
    const kstNow = new Date(kstMs);
    const todayStr = kstNow.toISOString().slice(0, 10);

    let d = new Date(norm + "T00:00:00Z");
    const end = new Date(todayStr + "T00:00:00Z");
    if (d > end) return -1;
    if (d.toISOString().slice(0, 10) === todayStr) return 0;

    let count = 0;
    const cur = new Date(d);
    while (cur < end) {
      cur.setDate(cur.getDate() + 1);
      const dow = cur.getDay();
      if (dow !== 0 && dow !== 6) count++;
    }
    return count;
  },
  isStalePriceDate_: function(dateStr, bizDaysThreshold = 1) {
    const diff = calcKrxBizDaysDiff_(dateStr);
    return diff > bizDaysThreshold;
  },
  calcValSurgeStatus: function(valSurge) {
    if (!Number.isFinite(valSurge)) return "DATA_MISSING";
    if (valSurge < THRESHOLDS.VAL_SURGE_WATCH) return "OK";
    if (valSurge < THRESHOLDS.VAL_SURGE_HOT) return "WATCH";
    if (valSurge < THRESHOLDS.VAL_SURGE_EXHAUSTED) return "HOT";
    return "EXHAUSTED";
  },
  calcLiquidityStatus: function(avgTradingValue5D) {
    if (!Number.isFinite(avgTradingValue5D)) return "DATA_MISSING";
    if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_PREFERRED_M) return "PREFERRED";
    if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_OK_M) return "OK";
    return "LOW";
  },
  calcSpreadStatus: function(spreadPct) {
    if (!Number.isFinite(spreadPct)) return "QUOTE_NO_MATCH";
    if (spreadPct <= THRESHOLDS.SPREAD_OK_PCT) return "OK";
    if (spreadPct <= THRESHOLDS.SPREAD_WARN_PCT) return "WATCH";
    return "BLOCK";
  }
};

for (const [name, fn] of Object.entries(_gasCompatFallbacks_)) {
  _installCompat_(name, fn);
}

function getFetchSessionId_() {
  const props = PropertiesService.getScriptProperties();
  let sid = props.getProperty("fetch_session_id");
  if (!sid) {
    sid = Utilities.getUuid();
    props.setProperty("fetch_session_id", sid);
    props.setProperty("fetch_session_label", "auto");
    props.setProperty("fetch_session_started_at", new Date().toISOString());
    props.setProperty("fetch_session_updated_at", new Date().toISOString());
  }
  return sid;
}

function cacheJsonGet_(key) {
  const raw = CacheService.getScriptCache().get(key);
  if (!raw) return null;
  try {
    return JSON.parse(raw);
  } catch (_) {
    return null;
  }
}

function cacheJsonSet_(key, value, ttlSeconds) {
  try {
    CacheService.getScriptCache().put(key, JSON.stringify(value), ttlSeconds);
  } catch (_) {}
}

function fetchBudgetKey_(source, bucket) {
  const safeBucket = String(bucket ?? "global").replace(/[^A-Za-z0-9_.%-]/g, "_");
  return `fetch_budget_${getFetchSessionId_()}_${source}_${safeBucket}`;
}

function fetchFailureKey_(source) {
  return `fetch_fail_${source}`;
}

function fetchCircuitKey_(source) {
  return `fetch_circuit_${source}`;
}

function isFetchCircuitOpen_(source) {
  const props = PropertiesService.getScriptProperties();
  const raw = props.getProperty(fetchCircuitKey_(source));
  if (!raw) return false;
  try {
    const data = JSON.parse(raw);
    if (!data?.until) {
      props.deleteProperty(fetchCircuitKey_(source));
      return false;
    }
    if (Date.now() >= Number(data.until)) {
      props.deleteProperty(fetchCircuitKey_(source));
      props.deleteProperty(fetchFailureKey_(source));
      return false;
    }
    return true;
  } catch (_) {
    props.deleteProperty(fetchCircuitKey_(source));
    return false;
  }
}

function consumeFetchBudget_(source, bucket) {
  const props = PropertiesService.getScriptProperties();
  const budget = FETCH_GOVERNANCE.budget[source] ?? 1;
  const key = fetchBudgetKey_(source, bucket);
  const used = Number(props.getProperty(key) ?? "0") + 1;
  props.setProperty(key, String(used));
  return used <= budget;
}

function recordFetchSuccess_(source) {
  const props = PropertiesService.getScriptProperties();
  props.deleteProperty(fetchFailureKey_(source));
  props.deleteProperty(fetchCircuitKey_(source));
}

function recordFetchFailure_(source) {
  const props = PropertiesService.getScriptProperties();
  const key = fetchFailureKey_(source);
  const failures = Number(props.getProperty(key) ?? "0") + 1;
  props.setProperty(key, String(failures));
  if (failures >= FETCH_GOVERNANCE.failureLimit) {
    props.setProperty(fetchCircuitKey_(source), JSON.stringify({
      until: Date.now() + FETCH_GOVERNANCE.coolDownMs,
      failures,
    }));
  }
}

const CACHE_VERSION = "v3_";

function getCachedFetchResult_(cacheKey) {
  return cacheJsonGet_(CACHE_VERSION + cacheKey);
}

function setCachedFetchResult_(cacheKey, result, ok, ttlOkKey) {
  const ttl = ok ? (FETCH_GOVERNANCE.ttl[ttlOkKey] ?? FETCH_GOVERNANCE.ttl.naver_quote_ok) : FETCH_GOVERNANCE.ttl.failure;
  cacheJsonSet_(CACHE_VERSION + cacheKey, result, ttl);
}

function annotateFetchValue_(result, source, bucket) {
  const annotated = { ...(result || {}) };
  const now = new Date();
  const fetchedAt = annotated.fetched_at ? new Date(annotated.fetched_at) : now;
  annotated.fetched_at = Utilities.formatDate(fetchedAt, "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ss");
  const ageMinutes = Math.max(0, Math.round((now.getTime() - fetchedAt.getTime()) / 60000));
  annotated.value_age_minutes = ageMinutes;

  let dataStatus = "UNKNOWN";
  let stale = false;
  const dateCandidate = annotated.priceDate || annotated.date || annotated.updated_at || null;
  if (typeof dateCandidate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateCandidate)) {
    stale = isStalePriceDate_(dateCandidate);
    dataStatus = stale ? "STALE" : "FRESH";
    annotated.value_date = dateCandidate;
  }
  if (annotated.rows && Array.isArray(annotated.rows) && annotated.rows.length > 0) {
    const firstRow = annotated.rows[0] || {};
    const rowDate = firstRow.date || firstRow.Date || firstRow.priceDate || firstRow.updated_at;
    if (typeof rowDate === "string" && /^\d{4}[-.]\d{2}[-.]\d{2}$/.test(rowDate)) {
      const normalized = rowDate.replace(/\./g, "-");
      stale = stale || isStalePriceDate_(normalized);
      dataStatus = stale ? "STALE" : dataStatus === "UNKNOWN" ? "FRESH" : dataStatus;
      annotated.value_date = annotated.value_date || normalized;
    }
  }
  if (dataStatus === "UNKNOWN") {
    dataStatus = ageMinutes <= 180 ? "FRESH" : "STALE";
  }
  annotated.data_value_status = dataStatus;
  annotated.scrape_block_risk = source.startsWith("naver_") || source.startsWith("yahoo_")
    ? (dataStatus === "STALE" ? "HIGH" : ageMinutes > 720 ? "MEDIUM" : "LOW")
    : "LOW";
  annotated.used_for = dataStatus === "STALE" ? "REFERENCE_ONLY" : "EXECUTION";
  annotated.data_value_reason = dataStatus === "STALE"
    ? `stale_or_old:${source}/${bucket}`
    : `fresh:${source}/${bucket}`;
  return annotated;
}

// ── Fetch 공통 래퍼 (P2-C) ─────────────────────────────────────────────────
// cache 확인 → stale 재수집 판단 → circuit 확인 → budget 소비 → fetchFn 실행 → 결과 캐싱.
// source: FETCH_GOVERNANCE 의 source 키 (예: "naver_flow")
// bucket: consumeFetchBudget_ 의 bucket 파라미터 (종목코드 또는 심볼)
// emptyFallback: circuit/budget 차단 시 반환할 기본값 객체 ({ ok:false, ... })
// fetchFn: () → 결과 객체. try/catch 불필요 (래퍼가 처리). ok 필드로 성공/실패 판단.
function withFetchCache_(cacheKey, source, bucket, emptyFallback, fetchFn) {
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) {
    const annotated = annotateFetchValue_(cached, source, bucket);
    // Stale-revalidate: 캐시 데이터가 영업일 기준 오래됐으면 캐시 무효화 후 즉시 re-fetch.
    // 주가·수급 데이터는 D-1(STALE)이면 당일 데이터로 교체해야 의사결정에 사용 가능.
    // 호가(quote)는 30분 TTL이 짧아서 자연 만료되므로 stale revalidate 불필요.
    if (annotated.data_value_status === "STALE"
        && source !== "naver_quote"
        && source !== "yahoo_quote") {
      try { CacheService.getScriptCache().remove(CACHE_VERSION + cacheKey); } catch (_) {}
      Logger.log("[STALE_REVALIDATE] " + source + "/" + bucket + " — 캐시 무효화 후 re-fetch");
      // fall through to re-fetch below
    } else {
      return annotated;
    }
  }
  if (isFetchCircuitOpen_(source)) {
    return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_CIRCUIT_OPEN", source }, source, bucket);
  }
  if (!consumeFetchBudget_(source, bucket)) {
    return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_BUDGET_EXCEEDED", source }, source, bucket);
  }
  let result;
  try {
    result = fetchFn();
  } catch (e) {
    result = { ...emptyFallback, ok: false, error: e.message, source };
  }
  result = annotateFetchValue_(result, source, bucket);
  if (result.ok) {
    recordFetchSuccess_(source);
    setCachedFetchResult_(cacheKey, result, true, `${source}_ok`);
  } else {
    recordFetchFailure_(source);
    setCachedFetchResult_(cacheKey, result, false, "failure");
  }
  return result;
}

// ── Naver frgn.naver 파서 ─────────────────────────────────────────────────
function fetchNaverFlow(code) {
  const ticker = normalizeTickerCode(code);
  const cacheKey = `naver_flow_${ticker}`;
  return withFetchCache_(cacheKey, "naver_flow", ticker, { ok: false, rows: [], isFlowStale: false }, () => {
    const resp = UrlFetchApp.fetch(`https://finance.naver.com/item/frgn.naver?code=${code}&page=1`, {
      headers: { "Accept-Language": "ko-KR,ko;q=0.9" },
      muteHttpExceptions: true
    });
    const html = resp.getContentText("EUC-KR");
    const rows = [];
    const trPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
    let trMatch;
    while ((trMatch = trPattern.exec(html)) !== null) {
      const tds = [];
      const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
      let td;
      while ((td = tdPattern.exec(trMatch[1])) !== null) {
        tds.push(td[1].replace(/<[^>]+>/g, "").replace(/&nbsp;/g, "").trim());
      }
      if (tds.length < 7 || !/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue;
      const n = s => { const v = s.replace(/,/g,"").replace(/[+]/g,"").trim(); return isNaN(+v)||!v ? 0 : +v; };
      const inst = n(tds[5]), frgn = n(tds[6]);
      rows.push({ date: tds[0], inst, frgn, indiv: -(frgn + inst) });
      if (rows.length >= 20) break;
    }
    const isFlowStale = rows.length > 0 && isStalePriceDate_(rows[0].date.replace(/\./g, "-"));
    return { ok: rows.length >= 5, rows, source: "naver_flow", isFlowStale };
  });
}

// ── Yahoo Finance 가격 조회 ───────────────────────────────────────────────
function fetchYahooPrice(code) {
  // 한국 종목/ETF 코드: 6자리 알파뉴메릭 → .KS suffix. ^ 기호로 시작하는 글로벌 지수는 제외.
  const sym0 = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code;
  const sym = sym0.replace(/\^/g, "%5E");
  const cacheKey = `yahoo_price_${sym}`;
  return withFetchCache_(cacheKey, "yahoo_price", sym0, { ok: false }, () => {
    const resp = UrlFetchApp.fetch(`https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=3mo`, {
      muteHttpExceptions: true,
      headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
    });
    if (resp.getResponseCode() !== 200) return { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_price" };
    const closes = JSON.parse(resp.getContentText())
      ?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? [];
    if (closes.length < 5) return { ok: false, source: "yahoo_price" };
    const last = closes[closes.length-1];
    const d5  = closes[Math.max(0, closes.length-6)];
    const d10 = closes[Math.max(0, closes.length-11)];
    const d20 = closes[Math.max(0, closes.length-21)];
    return { ok: true, close: last,
      ret5D:  ((last/d5 -1)*100).toFixed(2),
      ret10D: ((last/d10-1)*100).toFixed(2),
      ret20D: ((last/d20-1)*100).toFixed(2),
      source: "yahoo_price" };
  });
}

function fetchYahooMarketMetrics(code) {
  const sym = normalizeYahooSymbol(code);
  const cacheKey = `yahoo_quote_${sym}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;
  if (isFetchCircuitOpen_("yahoo_quote")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_quote", quoteStatus: "QUOTE_CIRCUIT_OPEN" };
  if (!consumeFetchBudget_("yahoo_quote", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_quote", quoteStatus: "QUOTE_BUDGET_EXCEEDED" };
  const apiUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(sym)}`;
  let apiError = "";
  let apiHttpStatus = null;
  function extractQuotedNumber_(text, marker, limitChars) {
    const start = text.indexOf(marker);
    if (start < 0) return null;
    const segment = text.slice(start + marker.length, start + marker.length + limitChars);
    const match = segment.match(/([0-9,]+(?:\.[0-9]+)?)\s*x/i);
    return match ? parseKrNum_(match[1]) : null;
  }
  try {
    const resp = UrlFetchApp.fetch(apiUrl, {
      muteHttpExceptions: true,
      headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
    });
    apiHttpStatus = resp.getResponseCode();
    let data = null;
    if (apiHttpStatus === 200) {
      try {
        data = JSON.parse(resp.getContentText());
      } catch (e) {
        apiError = `JSON_${e.message}`;
      }
    } else {
      apiError = `HTTP ${apiHttpStatus}`;
    }

    const item = data?.quoteResponse?.result?.[0];
    const marketPrice = Number(item?.regularMarketPrice) || null;
    // Yahoo v7 quote API에서 추가 기본 지표 추출 (이미 수신된 응답 재활용)
    const yahooBeta   = Number.isFinite(Number(item?.beta))                         ? Number(item?.beta)                         : null;
    const yahooH52    = Number.isFinite(Number(item?.fiftyTwoWeekHigh))             ? Number(item?.fiftyTwoWeekHigh)             : null;
    const yahooL52    = Number.isFinite(Number(item?.fiftyTwoWeekLow))              ? Number(item?.fiftyTwoWeekLow)              : null;
    const yahooDiv    = Number.isFinite(Number(item?.trailingAnnualDividendYield))  ? Number(item?.trailingAnnualDividendYield) * 100 : null;
    let source = "yahoo_quote_api";
    let quoteStatus = "QUOTE_API_NO_MATCH";
    let resolvedBid = Number.isFinite(Number(item?.bid)) ? Number(item?.bid) : null;
    let resolvedAsk = Number.isFinite(Number(item?.ask)) ? Number(item?.ask) : null;

    if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) {
      const htmlUrl = `https://finance.yahoo.com/quote/${encodeURIComponent(sym)}?webview=1`;
      const htmlResp = UrlFetchApp.fetch(htmlUrl, {
        muteHttpExceptions: true,
        headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
      });
      if (htmlResp.getResponseCode() === 200) {
        const text = htmlResp.getContentText();
        const bidFromTitle = extractQuotedNumber_(text, 'title="Bid"', 160);
        const askFromTitle = extractQuotedNumber_(text, 'title="Ask"', 160);
        if (Number.isFinite(bidFromTitle) && Number.isFinite(askFromTitle) && bidFromTitle > 0 && askFromTitle > 0) {
          resolvedBid = bidFromTitle;
          resolvedAsk = askFromTitle;
          source = "yahoo_quote_html";
          quoteStatus = "QUOTE_HTML_FALLBACK";
        } else {
          const rawBid = text.match(/"bid"[^0-9]*([0-9.]+)/i);
          const rawAsk = text.match(/"ask"[^0-9]*([0-9.]+)/i);
          const candidateBid = rawBid ? parseKrNum_(rawBid[1]) : null;
          const candidateAsk = rawAsk ? parseKrNum_(rawAsk[1]) : null;
          if (Number.isFinite(candidateBid) && Number.isFinite(candidateAsk) && candidateBid > 0 && candidateAsk > 0) {
            resolvedBid = candidateBid;
            resolvedAsk = candidateAsk;
            source = "yahoo_quote_html";
            quoteStatus = "QUOTE_HTML_FALLBACK";
          }
        }
        if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) {
          quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_NO_MATCH";
        }
      } else {
        quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_BLOCKED";
      }
    }

    const spreadPct = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0
      ? ((resolvedAsk - resolvedBid) / ((resolvedAsk + resolvedBid) / 2)) * 100
      : null;
    const ok = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0;
    const result = {
      ok,
      source,
      quoteStatus,
      bid: resolvedBid,
      ask: resolvedAsk,
      spreadPct,
      marketPrice,
      beta:    yahooBeta,
      high52W: yahooH52,
      low52W:  yahooL52,
      divYield: yahooDiv,
      httpStatus: apiHttpStatus,
      error: apiError || ""
    };
    if (ok) {
      recordFetchSuccess_("yahoo_quote");
      setCachedFetchResult_(cacheKey, result, true, "yahoo_quote_ok");
    } else {
      recordFetchFailure_("yahoo_quote");
      setCachedFetchResult_(cacheKey, result, false, "failure");
    }
    return result;
  } catch (e) {
    const result = { ok: false, error: e.message, source: "yahoo_quote", quoteStatus: "QUOTE_ERROR" };
    recordFetchFailure_("yahoo_quote");
    setCachedFetchResult_(cacheKey, result, false, "failure");
    return result;
  }
}

function fetchNaverMarketMetrics(code) {
  const ticker = normalizeTickerCode(code);
  const cacheKey = `naver_quote_${ticker}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;
  if (isFetchCircuitOpen_("naver_quote")) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_CIRCUIT_OPEN", httpStatus: null };
  if (!consumeFetchBudget_("naver_quote", ticker)) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_BUDGET_EXCEEDED", httpStatus: null };
  const url = `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(code)}`;
  try {
    const resp = UrlFetchApp.fetch(url, {
      muteHttpExceptions: true,
      headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" }
    });
    const httpStatus = resp.getResponseCode();
    if (httpStatus !== 200) {
      const result = { ok: false, source: "naver_main", quoteStatus: `NAVER_QUOTE_HTTP_${httpStatus}`, httpStatus };
      recordFetchFailure_("naver_quote");
      setCachedFetchResult_(cacheKey, result, false, "failure");
      return result;
    }

    const html = resp.getContentText("EUC-KR");
    const currentMatch = html.match(/오늘의시세\s+([0-9,]+)\s+포인트/i) || html.match(/현재가\s+([0-9,]+)/i);
    const currentPrice = currentMatch ? parseKrNum_(currentMatch[1]) : null;

    const perMatch = html.match(/<em id="_per">([\d,.]+)<\/em>/);
    const pbrMatch = html.match(/<em id="_pbr">([\d,.]+)<\/em>/);
    const epsMatch = html.match(/<em id="_eps">([\d,.-]+)<\/em>/);
    const per = perMatch ? parseKrNum_(perMatch[1]) : null;
    const pbr = pbrMatch ? parseKrNum_(pbrMatch[1]) : null;
    const eps = epsMatch ? parseKrNum_(epsMatch[1]) : null;

    // 배당수익률 — Naver main 페이지 _dvr ID
    const dvrMatch = html.match(/<em id="_dvr">([\d,.]+)<\/em>/);
    const dvr = dvrMatch ? parseKrNum_(dvrMatch[1]) : null;

    // 52주 최고/최저 — Naver main 페이지 여러 패턴 시도
    const parseNum_ = s => { const v = parseFloat(String(s ?? "").replace(/,/g, "")); return Number.isFinite(v) && v > 0 ? v : null; };
    const h52m = html.match(/52[주週]최고[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) ||
                 html.match(/52주\s*최고가?[\s\S]{0,100}?<em[^>]*>([\d,]+)<\/em>/) ||
                 html.match(/high52[^>]*>\s*([\d,]+)/) ||
                 html.match(/<em id="_high52">([\d,]+)<\/em>/);
    const l52m = html.match(/52[주週]최저[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) ||
                 html.match(/52주\s*최저가?[\s\S]{0,100}?<em[^>]*>([\d,]+)<\/em>/) ||
                 html.match(/low52[^>]*>\s*([\d,]+)/) ||
                 html.match(/<em id="_low52">([\d,]+)<\/em>/);
    const naverHigh52W = h52m ? parseNum_(h52m[1]) : null;
    const naverLow52W  = l52m ? parseNum_(l52m[1]) : null;

    const askPrices = [];
    const bidPrices = [];
    const askRowPattern = /<tr class="f_down(?:\s+strong)?">[\s\S]*?<td>\s*([0-9,]+)\s*<\/td>\s*<td>\s*([0-9,]+)\s*<\/td>/g;
    const bidRowPattern = /<tr class="f_up(?:\s+strong)?">[\s\S]*?<td>\s*<\/td>\s*<td>\s*([0-9,]+)\s*<\/td>\s*<td class="td_r">\s*([0-9,]+)\s*<\/td>/g;
    let m;
    while ((m = askRowPattern.exec(html)) !== null) {
      const ask = parseKrNum_(m[2]);
      if (Number.isFinite(ask) && ask > 0) askPrices.push(ask);
    }
    while ((m = bidRowPattern.exec(html)) !== null) {
      const bid = parseKrNum_(m[1]);
      if (Number.isFinite(bid) && bid > 0) bidPrices.push(bid);
    }

    const bid = bidPrices.length ? Math.max(...bidPrices) : null;
    const ask = askPrices.length ? Math.min(...askPrices) : null;
    const spreadPct = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0
      ? ((ask - bid) / ((ask + bid) / 2)) * 100
      : null;
    const ok = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0;

    const result = {
      ok,
      source: "naver_main",
      quoteStatus: ok ? "NAVER_QUOTE_OK" : "NAVER_QUOTE_NO_MATCH",
      bid,
      ask,
      spreadPct,
      marketPrice: Number.isFinite(currentPrice) ? currentPrice : null,
      per,
      pbr,
      eps,
      dvr,
      high52W: naverHigh52W,
      low52W:  naverLow52W,
      httpStatus
    };
    if (ok) {
      recordFetchSuccess_("naver_quote");
      setCachedFetchResult_(cacheKey, result, true, "naver_quote_ok");
    } else {
      recordFetchFailure_("naver_quote");
      setCachedFetchResult_(cacheKey, result, false, "failure");
    }
    return result;
  } catch (e) {
    const result = { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_ERROR", error: e.message };
    recordFetchFailure_("naver_quote");
    setCachedFetchResult_(cacheKey, result, false, "failure");
    return result;
  }
}

// Backward-compatible thin wrapper.
// Older callers still expect fetchNaverQuoteMetrics().
function fetchNaverQuoteMetrics(code) {
  return fetchNaverMarketMetrics(code);
}

function fetchNaverOhlcMetrics(code) {
  const ticker = normalizeTickerCode(code);
  const cacheKey = `naver_ohlc_${ticker}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;
  if (isFetchCircuitOpen_("naver_ohlc")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "naver_ohlc" };
  if (!consumeFetchBudget_("naver_ohlc", ticker)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "naver_ohlc" };
  const rows = [];
  try {
    for (let page = 1; page <= 7 && rows.length < 65; page++) {
      const url = `https://finance.naver.com/item/sise_day.naver?code=${encodeURIComponent(ticker)}&page=${page}`;
      const resp = UrlFetchApp.fetch(url, {
        muteHttpExceptions: true,
        headers: {
          "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)",
          "Referer": `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(ticker)}`
        }
      });
      if (resp.getResponseCode() !== 200) continue;
      const html = resp.getContentText("EUC-KR");
      const trPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
      let trMatch;
      while ((trMatch = trPattern.exec(html)) !== null) {
        const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
        const tds = [];
        let td;
        while ((td = tdPattern.exec(trMatch[1])) !== null) {
          tds.push(td[1].replace(/<[^>]+>/g, "").replace(/&nbsp;/g, "").replace(/\s+/g, " ").trim());
        }
        if (tds.length < 7) continue;
        if (!/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue;
        const n = (s) => {
          const v = String(s ?? "").replace(/,/g, "").replace(/[+]/g, "").trim();
          return v && !isNaN(+v) ? +v : null;
        };
        const close = n(tds[1]);
        const open = n(tds[3]);
        const high = n(tds[4]);
        const low = n(tds[5]);
        const volume = n(tds[6]);
        if ([close, open, high, low, volume].some(v => v == null)) continue;
        rows.push({
          date: tds[0],
          open,
          high,
          low,
          close,
          volume
        });
        if (rows.length >= 65) break;
      }
    }
    if (rows.length < 21) {
      const result = { ok: false, error: `NAVER_OHLC_ROWS_${rows.length}`, source: "naver_ohlc" };
      recordFetchFailure_("naver_ohlc");
      setCachedFetchResult_(cacheKey, result, false, "failure");
      return result;
    }
    const latest = rows[0];
    const derived = calcDerivedPriceMetrics(rows, true);
    const atr20 = calcAtr20(rows.slice().reverse());
    const avg5 = avgTradingValueM(rows.slice(1).reverse(), 5);
    const avg20 = avgTradingValueM(rows.slice(1).reverse(), 20);
    const currentValue = tradingValueM(latest);
    const quote = fetchNaverMarketMetrics(ticker);
    const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0
      ? ((currentValue / avg5) - 1) * 100
      : null;
    const isPriceStale = isStalePriceDate_(latest.date);
    const result = {
      ok: true,
      source: "Naver Finance sise_day.naver",
      rows: rows.slice().reverse(),
      priceDate: latest.date,
      isPriceStale,
      close: latest.close,
      open: derived.open,
      high: derived.high,
      low: derived.low,
      volume: derived.volume,
      prevClose: derived.prevClose,
      avgVolume5D: derived.avgVolume5D,
      ma20: derived.ma20,
      ma60: derived.ma60,
      ret5D: derived.ret5D,
      ret10D: derived.ret10D,
      ret20D: derived.ret20D,
      ret60D: derived.ret60D,
      atr20,
      atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null,
      valSurge,
      avgTradingValue5D: avg5,
      avgTradingValue20D: avg20,
      bid: Number.isFinite(quote.bid) ? quote.bid : null,
      ask: Number.isFinite(quote.ask) ? quote.ask : null,
      spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null,
      marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : latest.close,
      quoteSource: quote.source ?? "naver_main",
      quoteStatus: quote.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
      quoteHttpStatus: quote.httpStatus ?? null
    };
    recordFetchSuccess_("naver_ohlc");
    setCachedFetchResult_(cacheKey, result, true, "naver_ohlc_ok");
    return result;
  } catch (e) {
    const result = { ok: false, error: e.message, source: "naver_ohlc" };
    recordFetchFailure_("naver_ohlc");
    setCachedFetchResult_(cacheKey, result, false, "failure");
    return result;
  }
}

// ── 에러 처리 레이어 ─────────────────────────────────────────────────────────
// severity: "CRITICAL" | "WARN" | "INFO"
// CRITICAL: 시트 쓰기 실패, pre-read 실패 → 전체 실행 영향
// WARN: 개별 종목 fetch 실패 → 해당 종목만 영향
// INFO: 캐시 관련 오류 → 무시 가능
function handleFetchError_(context, e, severity) {
  Logger.log(`[${severity}] ${context}: ${e}`);
}

function normalizeYahooSymbol(code) {
  let sym = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code;
  return sym.replace(/\^/g, "%5E");
}

function normalizeTickerCode(code) {
  const raw = String(code ?? "").trim();
  if (!raw) return "";
  if (/^[0-9]+$/.test(raw)) return raw.padStart(6, "0");
  if (/^[0-9A-Z]+$/i.test(raw) && raw.length < 6) return raw.padStart(6, "0");
  return raw;
}

function fetchYahooOhlcMetrics(code) {
  const sym = normalizeYahooSymbol(code);
  const cacheKey = `yahoo_chart_${sym}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;
  if (isFetchCircuitOpen_("yahoo_chart")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_chart" };
  if (!consumeFetchBudget_("yahoo_chart", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_chart" };
  const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=6mo`;
  try {
    const resp = UrlFetchApp.fetch(url, {
      muteHttpExceptions: true,
      headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
    });
    if (resp.getResponseCode() !== 200) {
      const result = { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_chart" };
      recordFetchFailure_("yahoo_chart");
      setCachedFetchResult_(cacheKey, result, false, "failure");
      return result;
    }
    const data = JSON.parse(resp.getContentText());
    const chartResult = data?.chart?.result?.[0];
    const ts = chartResult?.timestamp ?? [];
    const q = chartResult?.indicators?.quote?.[0] ?? {};
    const rows = [];
    for (let i = 0; i < ts.length; i++) {
      const open = q.open?.[i];
      const high = q.high?.[i];
      const low = q.low?.[i];
      const close = q.close?.[i];
      const volume = q.volume?.[i];
      if ([open, high, low, close, volume].some(v => v == null || isNaN(+v))) continue;
      const d = new Date(ts[i] * 1000);
      rows.push({
        date: Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd"),
        open: +open,
        high: +high,
        low: +low,
        close: +close,
        volume: +volume
      });
    }
    if (rows.length < 21) {
      const result = { ok: false, error: `OHLC_ROWS_${rows.length}`, source: "yahoo_chart" };
      recordFetchFailure_("yahoo_chart");
      setCachedFetchResult_(cacheKey, result, false, "failure");
      return result;
    }
    const latest = rows[rows.length - 1];
    const derived = calcDerivedPriceMetrics(rows, false);
    const atr20 = calcAtr20(rows);
    const avg5 = avgTradingValueM(rows.slice(0, -1), 5);
    const avg20 = avgTradingValueM(rows.slice(0, -1), 20);
    const currentValue = tradingValueM(latest);
    let quote = fetchNaverMarketMetrics(code);
    if (!quote.ok) quote = fetchYahooMarketMetrics(code);
    const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0
      ? ((currentValue / avg5) - 1) * 100
      : null;
    const result = {
      ok: true,
      source: "Yahoo Finance chart",
      rows,
      priceDate: latest.date,
      isPriceStale: isStalePriceDate_(latest.date),
      close: latest.close,
      open: derived.open,
      high: derived.high,
      low: derived.low,
      volume: derived.volume,
      prevClose: derived.prevClose,
      avgVolume5D: derived.avgVolume5D,
      ma20: derived.ma20,
      ma60: derived.ma60,
      ret5D:  derived.ret5D,
      ret10D: derived.ret10D,
      ret20D: derived.ret20D,
      ret60D: derived.ret60D,
      atr20,
      atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null,
      valSurge,
      avgTradingValue5D: avg5,
      avgTradingValue20D: avg20,
      bid: Number.isFinite(quote.bid) ? quote.bid : null,
      ask: Number.isFinite(quote.ask) ? quote.ask : null,
      spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null,
      marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : null,
      quoteSource: quote.source ?? "QUOTE_NO_MATCH",
      quoteStatus: quote.quoteStatus ?? "QUOTE_NO_MATCH",
      quoteHttpStatus: quote.httpStatus ?? null
    };
    recordFetchSuccess_("yahoo_chart");
    setCachedFetchResult_(cacheKey, result, true, "yahoo_chart_ok");
    return result;
  } catch (e) {
    const result = { ok: false, error: e.message, source: "yahoo_chart" };
    recordFetchFailure_("yahoo_chart");
    setCachedFetchResult_(cacheKey, result, false, "failure");
    return result;
  }
}

function fetchNaverDisclosureNotices(code) {
  const ticker = normalizeTickerCode(code);
  const cacheKey = `naver_notice_${ticker}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;
  if (isFetchCircuitOpen_("naver_notice")) return { status: "NAVER_NOTICE_CIRCUIT_OPEN", source: "Naver Finance news_notice.naver", list: [] };
  if (!consumeFetchBudget_("naver_notice", ticker)) return { status: "NAVER_NOTICE_BUDGET_EXCEEDED", source: "Naver Finance news_notice.naver", list: [] };
  const url = `https://finance.naver.com/item/news_notice.naver?code=${code}&page=1`;
  try {
    const resp = UrlFetchApp.fetch(url, {
      headers: {
        Referer: `https://finance.naver.com/item/main.naver?code=${code}`,
        Accept: "text/html,application/xhtml+xml"
      },
      muteHttpExceptions: true
    });
    if (resp.getResponseCode() !== 200) {
      const result = { status: `NAVER_NOTICE_HTTP_${resp.getResponseCode()}`, source: "Naver Finance news_notice.naver", list: [] };
      recordFetchFailure_("naver_notice");
      setCachedFetchResult_(cacheKey, result, false, "failure");
      return result;
    }
    const html = resp.getContentText("EUC-KR");
    const rows = [];
    const trMatches = html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
    for (const tr of trMatches) {
      const text = tr
        .replace(/<script[\s\S]*?<\/script>/gi, " ")
        .replace(/<style[\s\S]*?<\/style>/gi, " ")
        .replace(/<[^>]+>/g, " ")
        .replace(/&nbsp;/g, " ")
        .replace(/\s+/g, " ")
        .trim();
      const m = text.match(/^(.+?)\s+(KOSCOM|연합뉴스|이데일리|머니투데이|한국경제|매일경제|뉴스핌|아시아경제|서울경제|파이낸셜뉴스)\s+(\d{4}\.\d{2}\.\d{2})$/);
      if (!m) continue;
      rows.push({
        report_nm: m[1].trim(),
        source: m[2],
        rcept_dt: m[3].replace(/\./g, "")
      });
    }
    const result = {
      status: rows.length ? "NAVER_NOTICE_OK" : "NAVER_NOTICE_EMPTY",
      source: "Naver Finance news_notice.naver",
      list: rows
    };
    if (rows.length) {
      recordFetchSuccess_("naver_notice");
      setCachedFetchResult_(cacheKey, result, true, "naver_notice_ok");
    } else {
      recordFetchFailure_("naver_notice");
      setCachedFetchResult_(cacheKey, result, false, "failure");
    }
    return result;
  } catch (e) {
    const result = { status: `NAVER_NOTICE_ERROR:${e.message}`, source: "Naver Finance news_notice.naver", list: [] };
    recordFetchFailure_("naver_notice");
    setCachedFetchResult_(cacheKey, result, false, "failure");
    return result;
  }
}

function summarizeDisclosureNotices(disclosureResult) {
  const list = disclosureResult.list || [];
  const names = list.map(x => String(x.report_nm ?? ""));
  const catalyst = names.filter(nm => DART_CATALYST_KEYWORDS.some(k => nm.includes(k))).slice(0, 3);
  const risk = names.filter(nm => DART_RISK_KEYWORDS.some(k => nm.includes(k))).slice(0, 3);
  const recentDate = list[0]?.rcept_dt ? `${list[0].rcept_dt.slice(0,4)}-${list[0].rcept_dt.slice(4,6)}-${list[0].rcept_dt.slice(6,8)}` : "";
  return {
    status: disclosureResult.status,
    source: disclosureResult.source,
    recentDate,
    catalyst: catalyst.join(" | "),
    risk: risk.join(" | "),
    count: list.length
  };
}

function mapLatestPerformanceByTicker_(performanceRows) {
  const map = {};
  (performanceRows || []).forEach(function(row) {
    const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
    if (!ticker) return;
    const exitDate = parseIsoDateYmd_(row.exit_date || row.Exit_Date || row.exitDate);
    const exitMs = exitDate ? Date.parse(exitDate + "T00:00:00+09:00") : 0;
    const current = map[ticker];
    if (!current || exitMs >= current.exitMs) {
      map[ticker] = { row: row, exitMs: exitMs };
    }
  });
  return map;
}

function buildBackdataFeatureBankRowsV1_(nowIso, holdings, dfMap, hAlpha, hApex) {
  const perfMap = mapLatestPerformanceByTicker_(sheetToJson("performance"));
  const alphaMap = {};
  ((hAlpha || {}).per_holding || []).forEach(function(row) {
    const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
    if (ticker) alphaMap[ticker] = row;
  });
  const followMap = {};
  ((hApex || {}).follow_through_json || []).forEach(function(row) {
    const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
    if (ticker) followMap[ticker] = row;
  });
  const profitMap = {};
  ((hApex || {}).profit_preservation_json || []).forEach(function(row) {
    const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
    if (ticker) profitMap[ticker] = row;
  });
  const distributionMap = {};
  ((hApex || {}).distribution_risk_json || []).forEach(function(row) {
    const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
    if (ticker) distributionMap[ticker] = row;
  });

  const rows = [];
  (holdings || []).forEach(function(h) {
    const ticker = normalizeTickerCode(h.ticker || h.Ticker || "");
    if (!ticker) return;
    const df = dfMap[ticker] || {};
    const perf = perfMap[ticker] ? perfMap[ticker].row : {};
    const alpha = alphaMap[ticker] || {};
    const follow = followMap[ticker] || {};
    const profit = profitMap[ticker] || {};
    const dist = distributionMap[ticker] || {};

    const entryDate = parseIsoDateYmd_(h.entry_date || h.entryDate || h.entry_date_iso);
    const recordDate = parseIsoDateYmd_(perf.exit_date || perf.exitDate || h.last_updated || nowIso);
    const holdingDays = perf.holding_days != null && perf.holding_days !== ""
      ? parseInt(perf.holding_days, 10)
      : (entryDate ? daysBetweenIso_(entryDate, nowIso) : null);
    const sourceOrigin = "GAS_AUTO";
    const entryPrice = Number.isFinite(h.avgCost) ? h.avgCost
      : Number.isFinite(h.average_cost) ? h.average_cost
      : Number.isFinite(df.limitPriceEst) ? df.limitPriceEst : null;
    const closeAtEntry = Number.isFinite(df.close) ? df.close : null;
    const maePct = perf.max_adverse_excursion_pct != null ? parseFloat(perf.max_adverse_excursion_pct)
      : (perf.mae_pct != null ? parseFloat(perf.mae_pct) : null);
    const mfePct = perf.max_favorable_excursion_pct != null ? parseFloat(perf.max_favorable_excursion_pct)
      : (perf.mfe_pct != null ? parseFloat(perf.mfe_pct) : null);

    rows.push([
      recordDate,
      `BK-${ticker}-${String(recordDate || nowIso).replace(/-/g, "")}`,
      entryDate || parseIsoDateYmd_(df.priceDate || df.updatedAt || nowIso),
      ticker,
      h.name || df.name || "",
      h.account || h.account_type || "",
      h.entry_stage || h.entryStage || df.entryModeGate || "",
      sourceOrigin,
      entryPrice,
      closeAtEntry,
      Number.isFinite(df.ma20) ? df.ma20 : null,
      Number.isFinite(df.ma60) ? df.ma60 : null,
      Number.isFinite(df.atr20) ? df.atr20 : null,
      Number.isFinite(df.volumeRatio) ? df.volumeRatio : (Number.isFinite(df.avgVolume5d) && Number.isFinite(df.volume) && df.avgVolume5d > 0 ? round2_(df.volume / df.avgVolume5d) : null),
      Number.isFinite(df.flowCredit) ? round2_(df.flowCredit) : (Number.isFinite(alpha.flow_credit) ? round2_(alpha.flow_credit) : null),
      Number.isFinite(df.rsi14) ? round2_(df.rsi14) : null,
      Number.isFinite(alpha["late_chase_risk_score"]) ? alpha["late_chase_risk_score"] : null,
      Number.isFinite(follow["follow_through_score"]) ? follow["follow_through_score"] : null,
      Number.isFinite(df.breakoutScore) ? round2_(df.breakoutScore) : (Number.isFinite(df["breakout_score"]) ? round2_(df["breakout_score"]) : null),
      Number.isFinite(profit["rebound_preservation_score"]) ? profit["rebound_preservation_score"] : null,
      follow.follow_through_state || alpha.buy_permission_state || df.timingAction || df.Timing_Action || "",
      perf.exit_reason || perf.exitReason || df.sellReason || df.Sell_Reason || "",
      perf.pnl_pct != null ? parseFloat(perf.pnl_pct) : (Number.isFinite(h.return_pct) ? h.return_pct : (Number.isFinite(df.profitPct) ? df.profitPct : null)),
      holdingDays,
      maePct,
      mfePct
    ]);
  });

  rows.sort(function(a, b) {
    const ao = String(a[7] || "");
    const bo = String(b[7] || "");
    if (ao !== bo) return ao < bo ? -1 : 1;
    const ar = String(a[0] || "");
    const br = String(b[0] || "");
    if (ar !== br) return ar < br ? 1 : -1;
    return String(a[1] || "").localeCompare(String(b[1] || ""));
  });
  return rows;
}

function syncBackdataFeatureBank_(now, holdings, dfMap, hAlpha, hApex) {
  const rows = buildBackdataFeatureBankRowsV1_(formatIso_(now), holdings, dfMap, hAlpha, hApex);
  const headers = [
    "Record_Date","Trade_ID","Signal_Date","Ticker","Name","Account","Entry_Stage","Source_Origin",
    "Entry_Price","Close_At_Entry","MA20_At_Entry","MA60_At_Entry","ATR20_At_Entry",
    "Volume_Ratio_5D","Flow_Credit","RSI14_At_Entry","Late_Chase_Risk_Score","Follow_Through_Score",
    "Breakout_Score","Rebound_Preservation_Score","Setup_Decision","Exit_Reason","PnL_Pct",
    "Holding_Days","MAE_Pct","MFE_Pct"
  ];
  const totalRows = upsertToSheetByKey("backdata_feature_bank", headers, rows, "Trade_ID");
  Logger.log(`backdata_feature_bank 완료: 실행반영=${rows.length}행, 누적총계=${totalRows}행`);
  return rows;
}

// Naver 컨센서스 페이지에서 EPS 추정치 변화 방향(UP/FLAT/DOWN) 자동 판별
function fetchNaverConsensusData(code) {
  const ticker = normalizeTickerCode(code);
  const cacheKey = `naver_consensus_${ticker}`;
  return withFetchCache_(cacheKey, "naver_consensus", ticker, { ok: false, epsRevisionStatus: "DATA_MISSING" }, () => {
    const resp = UrlFetchApp.fetch(
      `https://finance.naver.com/item/coinfo.naver?code=${encodeURIComponent(code)}&target=estimate`,
      { muteHttpExceptions: true, followRedirects: true,
        headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" } }
    );
    if (resp.getResponseCode() !== 200) return { ok: false, epsRevisionStatus: "DATA_MISSING" };
    const html = resp.getContentText("EUC-KR");
    return { ok: true, epsRevisionStatus: parseNaverEpsRevision_(html), targetPrice: parseNaverTargetPrice_(html) };
  });
}

// Naver 컨센서스 HTML에서 EPS 개정 방향 파싱
// 전략1: EPS 행(현재) vs 1개월 전 행 수치 비교 (1% 이상 변화 기준)
// 전략2: 3개월 전 vs 현재 비교 (전략1 실패 시)
// 전략3: 방향 아이콘 클래스 감지 (ico_up / ico_dn 계열)
function parseNaverEpsRevision_(html) {
  // 전략1: 현재 vs 1개월 전 EPS 수치 직접 비교
  const epsRowMatch  = html.match(/EPS(?:\s*\(원\))?[^<]*(?:<[^>]+>)*[\s\S]*?<\/tr>/i);
  const prev1mMatch  = html.match(/1개월\s*전[\s\S]*?<\/tr>/);
  const prev3mMatch  = html.match(/3개월\s*전[\s\S]*?<\/tr>/);
  const compareEps_ = (row1, row2) => {
    if (!row1 || !row2) return null;
    const curr = extractLastTdNum_(row1);
    const prev = extractLastTdNum_(row2);
    if (!Number.isFinite(curr) || !Number.isFinite(prev) || prev === 0) return null;
    const chg = (curr - prev) / Math.abs(prev) * 100;
    if (chg >  1) return "UP";
    if (chg < -1) return "DOWN";
    return "FLAT";
  };
  const r1 = compareEps_(epsRowMatch?.[0], prev1mMatch?.[0]);
  if (r1) return r1;
  const r3 = compareEps_(epsRowMatch?.[0], prev3mMatch?.[0]);
  if (r3) return r3;

  // 전략2: 아이콘 클래스 패턴 (Naver HTML 버전별 차이 대응)
  if (/ico_up\b|class="up"|blind[^"]*상향|상향\s*조정/.test(html)) return "UP";
  if (/ico_dn\b|class="dn"|blind[^"]*하향|하향\s*조정/.test(html)) return "DOWN";

  // 전략3: 추정치 테이블에서 현재/이전 컬럼 비교 (th 기반)
  const tblMatch = html.match(/추정치[\s\S]{0,3000}?<\/table>/i);
  if (tblMatch) {
    const nums = [...tblMatch[0].matchAll(/<td[^>]*>\s*([-\d,]+)\s*<\/td>/g)]
      .map(m => parseKrNum_(m[1]))
      .filter(v => v !== null && Math.abs(v) > 10);
    if (nums.length >= 2) {
      const chg = (nums[0] - nums[1]) / Math.abs(nums[1]) * 100;
      if (chg >  1) return "UP";
      if (chg < -1) return "DOWN";
      return "FLAT";
    }
  }
  return "DATA_MISSING";
}

// Naver 컨센서스 HTML에서 목표주가 파싱
function parseNaverTargetPrice_(html) {
  const candidates = [
    /목표주가[\s\S]{0,400}?<em[^>]*>\s*([\d,]+)\s*<\/em>/,
    /목표주가[\s\S]{0,400}?<td[^>]*>\s*([\d,]+)\s*<\/td>/,
    /목표주가[\s\S]{0,300}?>\s*([\d,]+)\s*원/,
    /"targetPrice"[^>]*>\s*([\d,]+)/i,
  ];
  for (const pat of candidates) {
    const m = html.match(pat);
    if (m) {
      const val = parseKrNum_(m[1]);
      if (val !== null && val > 1000) return val;  // 1000원 이상만 유효
    }
  }
  return null;
}

function extractLastTdNum_(rowHtml) {
  const matches = [...rowHtml.matchAll(/<td[^>]*>\s*([-\d,]+)\s*<\/td>/g)];
  if (!matches.length) return null;
  return parseKrNum_(matches[matches.length - 1][1]);
}

// Yahoo Finance earningsTrend에서 EPS 추정치 변화 방향 판별 (Naver 실패 시 fallback)
function fetchYahooConsensusEps(code) {
  const sym = normalizeYahooSymbol(code);
  const cacheKey = `yahoo_consensus_${sym}`;
  return withFetchCache_(cacheKey, "yahoo_quote", sym, { ok: false, epsRevisionStatus: "DATA_MISSING" }, () => {
    const resp = UrlFetchApp.fetch(
      `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(sym)}?modules=earningsTrend`,
      { muteHttpExceptions: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } }
    );
    if (resp.getResponseCode() !== 200) return { ok: false, epsRevisionStatus: "DATA_MISSING" };
    const data = JSON.parse(resp.getContentText());
    const trends = data?.quoteSummary?.result?.[0]?.earningsTrend?.trend ?? [];
    // 가장 가까운 분기(0q) 우선, 없으면 내년(+1y)
    const trend = trends.find(t => t.period === "0q") || trends.find(t => t.period === "+1y") || trends[0];
    if (!trend) return { ok: false, epsRevisionStatus: "DATA_MISSING", epsGrowth1y: null };
    const current = trend.epsTrend?.current?.raw ?? null;
    const ago30  = trend.epsTrend?.["30daysAgo"]?.raw ?? null;
    let epsRevisionStatus = "DATA_MISSING";
    if (Number.isFinite(current) && Number.isFinite(ago30) && ago30 !== 0) {
      const chg = (current - ago30) / Math.abs(ago30) * 100;
      epsRevisionStatus = chg > 1 ? "UP" : chg < -1 ? "DOWN" : "FLAT";
    }
    // A2: EPS 1년 성장률 — KOSDAQ PEG 게이트 입력값
    // earningsTrend +1y 의 earningsEstimate.growth (직접 제공되는 경우)
    // 없으면 (+1y avg - 0y avg) / abs(0y avg) * 100 으로 계산
    let epsGrowth1y = null;
    const trend1y = trends.find(t => t.period === "+1y");
    const trend0y = trends.find(t => t.period === "0y");
    if (trend1y) {
      const directGrowth = trend1y.earningsEstimate?.growth?.raw;
      if (Number.isFinite(directGrowth)) {
        epsGrowth1y = parseFloat((directGrowth * 100).toFixed(1));
      } else {
        const eps1y = trend1y.earningsEstimate?.avg?.raw ?? null;
        const eps0y = trend0y?.earningsEstimate?.avg?.raw ?? null;
        if (Number.isFinite(eps1y) && Number.isFinite(eps0y) && eps0y !== 0) {
          epsGrowth1y = parseFloat(((eps1y - eps0y) / Math.abs(eps0y) * 100).toFixed(1));
        }
      }
    }
    return { ok: true, epsRevisionStatus, epsGrowth1y };
  });
}

// Yahoo Finance quoteSummary — 목표주가·Beta 폴백 (Naver 실패 시)
// financialData: targetMeanPrice / defaultKeyStatistics: beta
function fetchYahooTargetPrice(code) {
  const sym = normalizeYahooSymbol(code);
  const cacheKey = `yahoo_target_${sym}`;
  return withFetchCache_(cacheKey, "yahoo_financials", sym,
    { ok: false, targetPrice: null, beta: null, earningsDate: null, exDividendDate: null, dividendPerShare: null }, () => {
    const resp = UrlFetchApp.fetch(
      `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(sym)}?modules=financialData,defaultKeyStatistics,calendarEvents`,
      { muteHttpExceptions: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } }
    );
    if (resp.getResponseCode() !== 200) {
      return { ok: false, targetPrice: null, beta: null, earningsDate: null };
    }
    const data = JSON.parse(resp.getContentText());
    const qr  = data?.quoteSummary?.result?.[0] ?? {};
    const fd  = qr.financialData        ?? {};
    const dks = qr.defaultKeyStatistics ?? {};
    const cal = qr.calendarEvents       ?? {};
    const earningsDates = cal.earnings?.earningsDate ?? [];
    // ── 재무 건전성 필드 (2026-05-18_FINANCIAL_HEALTH_V1 + OCF 추가) ──────────
    const roePctRaw          = fd.returnOnEquity?.raw;
    const opMarginRaw        = fd.operatingMargins?.raw;
    const deRaw              = fd.debtToEquity?.raw ?? dks.debtToEquity?.raw;
    const currentRatioRaw    = fd.currentRatio?.raw;
    const fcfRaw             = fd.freeCashflow?.raw;
    const ocfRaw             = fd.operatingCashflow?.raw;   // OCF 추가
    const revGrowthRaw       = fd.revenueGrowth?.raw;
    return {
      ok: true,
      targetPrice:        fd.targetMeanPrice?.raw  ?? null,
      beta:               dks.beta?.raw            ?? null,
      earningsDate:       earningsDates.length > 0 ? (earningsDates[0].fmt ?? null) : null,
      exDividendDate:     cal.exDividendDate?.fmt  ?? null,
      dividendPerShare:   dks.lastDividendValue?.raw ?? null,
      // 재무 건전성 — Yahoo financialData / defaultKeyStatistics
      roePct:             Number.isFinite(roePctRaw)       ? roePctRaw * 100       : null,
      operatingMarginPct: Number.isFinite(opMarginRaw)     ? opMarginRaw * 100     : null,
      debtToEquity:       Number.isFinite(deRaw)           ? deRaw                 : null,
      currentRatio:       Number.isFinite(currentRatioRaw) ? currentRatioRaw       : null,
      fcfB:               Number.isFinite(fcfRaw)          ? fcfRaw / 1e8          : null,  // 억원
      ocfB:               Number.isFinite(ocfRaw)          ? ocfRaw / 1e8          : null,  // 억원 (OCF)
      revenueGrowthPct:   Number.isFinite(revGrowthRaw)    ? revGrowthRaw * 100    : null,
    };
  });
}

// ── 펀더멘털 7일 장기 캐시 레이어 ────────────────────────────────────────────
// 분기별 지표(ROE/OPM/OCF/FCF)는 자주 바뀌지 않으므로 7일 캐시로 호출 횟수를 절감해
// 스크래핑 차단 위험을 사전에 방지한다.
// PropertiesService를 사용(CacheService 최대 6시간 한계 극복).
const FUNDAMENTAL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;  // 7일(ms)

function getFundamentalCache_(ticker) {
  try {
    const props = PropertiesService.getScriptProperties();
    const raw = props.getProperty('fund_cache_' + ticker);
    if (!raw) return null;
    const entry = JSON.parse(raw);
    if (!entry || !entry.ts || !entry.data) return null;
    if (Date.now() - entry.ts > FUNDAMENTAL_CACHE_TTL_MS) {
      props.deleteProperty('fund_cache_' + ticker);
      return null;
    }
    return entry.data;
  } catch(_) { return null; }
}

function setFundamentalCache_(ticker, data) {
  try {
    PropertiesService.getScriptProperties().setProperty(
      'fund_cache_' + ticker,
      JSON.stringify({ ts: Date.now(), data: data })
    );
  } catch(_) {}
}

// ── 네이버 금융 ROE/OPM fallback ────────────────────────────────────────────
// 야후 Finance가 차단됐을 때 네이버 main.naver HTML에서 ROE/OPM을 보완한다.
// 호출 후 결과는 fundamentalCache에 병합되어 7일 캐시에 저장된다.
function fetchNaverFundamentals_(ticker) {
  const cacheKey = 'naver_fund_' + ticker;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;

  const emptyFallback = { ok: false, roePct: null, operatingMarginPct: null, source: 'naver_fund' };
  if (isFetchCircuitOpen_('naver_fund')) return emptyFallback;
  if (!consumeFetchBudget_('naver_fund', ticker)) return emptyFallback;

  try {
    const url = 'https://finance.naver.com/item/main.naver?code=' + encodeURIComponent(ticker);
    const resp = UrlFetchApp.fetch(url, {
      muteHttpExceptions: true,
      headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
      followRedirects: true
    });
    if (resp.getResponseCode() !== 200) {
      recordFetchFailure_('naver_fund');
      return emptyFallback;
    }
    const html = resp.getContentText('UTF-8');
    // ROE: 2024년 이후 네이버 "ROE(지배주주)" 키워드로 변경됨 (구: 자기자본이익률)
    // OPM: <strong>키워드</strong></th> 이후 여러 줄 아래 <td>에 수치 위치
    let roePct = null, opMarginPct = null;
    const roeM = html.match(/ROE\(지배주주\)<\/strong><\/th>[\s\S]*?<td[^>]*>[\s\S]*?([\d.-]+)/);
    if (roeM) roePct = parseFloat(roeM[1]);
    const opM = html.match(/영업이익률<\/strong><\/th>[\s\S]*?<td[^>]*>[\s\S]*?([\d.-]+)/);
    if (opM) opMarginPct = parseFloat(opM[1]);
    const result = {
      ok: roePct !== null || opMarginPct !== null,
      roePct: Number.isFinite(roePct) ? roePct : null,
      operatingMarginPct: Number.isFinite(opMarginPct) ? opMarginPct : null,
      source: 'naver_fund'
    };
    if (result.ok) {
      recordFetchSuccess_('naver_fund');
      setCachedFetchResult_(cacheKey, result, true, 'naver_fund_ok');
    } else {
      recordFetchFailure_('naver_fund');
    }
    return result;
  } catch(e) {
    recordFetchFailure_('naver_fund');
    return emptyFallback;
  }
}

// ── 펀더멘털 통합 수집 (캐시 → 야후 → 네이버 fallback) ──────────────────────
// 호출처: _addTickerFundamentals_ 내에서 t.code 기준 호출
// sym 인자는 사용되지 않으므로 무시 (하위 호환 유지)
function fetchFundamentalsWithCache_(ticker, sym, yahooFin) {
  // 1) 7일 캐시 확인
  const cached = getFundamentalCache_(ticker);
  if (cached) {
    Logger.log('[FUND_CACHE_HIT] ' + ticker + ' ttl=7d');
    return Object.assign({}, yahooFin, cached, { source: 'fund_cache' });
  }

  // 한국 종목코드 패턴 (6자리 숫자 기반) — Yahoo는 ROE/OPM 미제공 → 네이버 직접 호출
  const isKrMarket = /^\d{5,6}[A-Z0-9]*$/.test(ticker);
  // 숫자+문자 혼합의 국내 ETF/특수코드(예: 0117V0)는 Yahoo보다 Naver 우선 시도
  const isDomesticEtfLike = /^\d{4,6}[A-Z][A-Z0-9]*$/.test(ticker);
  const preferNaver = isKrMarket || isDomesticEtfLike;

  // 2) 야후 수집 판단 — 한국 종목은 스킵
  const hasYahoo = !preferNaver && yahooFin && yahooFin.ok &&
    (Number.isFinite(yahooFin.roePct) || Number.isFinite(yahooFin.operatingMarginPct));

  let fund = yahooFin || { ok: false };

  // 3) 야후 미보유 또는 한국 종목 → 네이버 직접 호출
  if (!hasYahoo) {
    if (isKrMarket) {
      Logger.log('[DEBUG][FUND_NAVER_KR] ' + ticker + ' — 한국 종목, 네이버 직접 호출');
    } else if (isDomesticEtfLike) {
      Logger.log('[DEBUG][FUND_NAVER_ETF] ' + ticker + ' — 국내 ETF/특수코드, 네이버 우선 호출');
    } else {
      Logger.log('[DEBUG][FUND_NAVER_FALLBACK] ' + ticker + ' — 야후 데이터 없음, 네이버 시도');
    }
    const naverFund = fetchNaverFundamentals_(ticker);
    if (naverFund.ok) {
      fund = Object.assign({}, fund, {
        roePct: fund.roePct ?? naverFund.roePct,
        operatingMarginPct: fund.operatingMarginPct ?? naverFund.operatingMarginPct,
        ok: true,
        source: isKrMarket ? 'naver_fund_kr' : (isDomesticEtfLike ? 'naver_fund_etf' : 'naver_fund_fallback')
      });
      Logger.log('[DEBUG][FUND_NAVER_OK] ' + ticker + ' source=' + fund.source + ' ROE=' + fund.roePct + ' OPM=' + fund.operatingMarginPct);
    } else if (isDomesticEtfLike) {
      Logger.log('[INFO][FUND_ETF_NO_DATA] ' + ticker + ' — ETF/특수코드, 재무제표 없음 (정상)');
    } else {
      Logger.log('[WARN][FUND_NAVER_FAIL] ' + ticker + ' source=' + (isKrMarket ? 'naver_fund_kr' : 'naver_fund_fallback') + ' reason=' + String(naverFund.error || naverFund.quoteStatus || 'NO_DATA'));
    }
  }

  // 4) 수집 결과를 7일 캐시에 저장 (ok=true인 경우만)
  if (fund.ok && (Number.isFinite(fund.roePct) || Number.isFinite(fund.operatingMarginPct))) {
    setFundamentalCache_(ticker, {
      roePct:             fund.roePct,
      operatingMarginPct: fund.operatingMarginPct,
      debtToEquity:       fund.debtToEquity,
      currentRatio:       fund.currentRatio,
      fcfB:               fund.fcfB,
      ocfB:               fund.ocfB,
      revenueGrowthPct:   fund.revenueGrowthPct,
      cached_at:          new Date().toISOString(),
    });
    Logger.log('[FUND_CACHE_SET] ' + ticker + ' ROE=' + fund.roePct + ' OPM=' + fund.operatingMarginPct);
  }
  return fund;
}

// EPS_Revision_Status 기존 입력값 보존 — writeToSheet의 clearContents 전에 호출
function readExistingEpsRevision_(sheetName) {
  const ss = getSpreadsheet_();
  const sheet = ss.getSheetByName(sheetName);
  if (!sheet || sheet.getLastRow() < 3) return {};
  const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();
  const headers = data[0];
  const tickerIdx = headers.indexOf("Ticker");
  const epsRevIdx = headers.indexOf("EPS_Revision_Status");
  if (tickerIdx < 0 || epsRevIdx < 0) return {};
  const valid = new Set(["UP", "FLAT", "DOWN"]);
  const result = {};
  for (let i = 1; i < data.length; i++) {
    const ticker = String(data[i][tickerIdx]).trim();
    const val    = String(data[i][epsRevIdx]).trim().toUpperCase();
    if (ticker && valid.has(val)) result[ticker] = val;
  }
  return result;
}

// ── FC(Frontier/탐색) 손실 예산 월별 집계 ────────────────────────────────────
// performance 탭의 fc_bucket=Y 거래 중 당월 청산 건의 손실합 계산.
// 반환: { fc_used_pct: number|null, fc_budget_pct: 2.5, fc_status: string, trades: integer }
function calcFcBudget_(totalAssetKrw, budgetPctOverride) {
  const BUDGET_PCT = Number.isFinite(budgetPctOverride) && budgetPctOverride > 0 ? budgetPctOverride : 2.5;
  const result = { fc_used_pct: null, fc_budget_pct: BUDGET_PCT, fc_status: "UNKNOWN (performance 탭 없음)", trades: 0 };
  try {
    const ss = getSpreadsheet_();
    const sheet = ss.getSheetByName("performance");
    if (!sheet) return result;
    const data = sheet.getDataRange().getValues();
    if (data.length < 3) return result;
    const hdr     = data[1].map(h => String(h).trim());
    const pnlIdx  = hdr.indexOf("pnl_pct");
    const exitIdx = hdr.indexOf("exit_date");
    const fcIdx   = hdr.indexOf("fc_bucket");
    const entryIdx= hdr.indexOf("entry_price");
    const qtyIdx  = hdr.indexOf("quantity");
    if (pnlIdx < 0 || exitIdx < 0 || fcIdx < 0) return { ...result, fc_status: "UNKNOWN (performance 탭 구조 불일치)" };

    const thisMonth = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM");
    let fcLossKrw = 0;
    let fcTrades = 0;

    for (let i = 2; i < data.length; i++) {
      const fcBucket = String(data[i][fcIdx]).trim().toUpperCase();
      if (fcBucket !== "Y") continue;
      const exitVal  = data[i][exitIdx];
      if (!exitVal || String(exitVal).trim() === "") continue;
      // 당월 청산 건만
      const exitStr = exitVal instanceof Date
        ? Utilities.formatDate(exitVal, "Asia/Seoul", "yyyy-MM")
        : String(exitVal).trim().substring(0, 7);
      if (exitStr !== thisMonth) continue;

      const pnl = parseFloat(data[i][pnlIdx]);
      if (!Number.isFinite(pnl) || pnl >= 0) continue; // 손실만 집계

      // KRW 손실 계산: entry_price × qty × |pnl_pct/100|
      const entry = parseFloat(data[i][entryIdx]);
      const qty   = parseInt(data[i][qtyIdx]);
      if (Number.isFinite(entry) && Number.isFinite(qty) && qty > 0) {
        fcLossKrw += entry * qty * Math.abs(pnl) / 100;
      }
      fcTrades++;
    }

    if (fcTrades === 0) {
      return { fc_used_pct: 0, fc_budget_pct: BUDGET_PCT, fc_status: `OK (0% / ${BUDGET_PCT}% 사용)`, trades: 0 };
    }

    if (!Number.isFinite(totalAssetKrw) || totalAssetKrw <= 0) {
      return { fc_used_pct: null, fc_budget_pct: BUDGET_PCT,
               fc_status: `UNKNOWN (총자산 미제공, 손실${fcTrades}건)`, trades: fcTrades };
    }

    const usedPct = (fcLossKrw / totalAssetKrw) * 100;
    const remaining = BUDGET_PCT - usedPct;
    const fcStatus = usedPct >= BUDGET_PCT
      ? `EXHAUSTED (${usedPct.toFixed(1)}% >= ${BUDGET_PCT}% 예산 초과)`
      : `OK (${usedPct.toFixed(1)}% / ${BUDGET_PCT}% — 잔여 ${remaining.toFixed(1)}%)`;

    return { fc_used_pct: parseFloat(usedPct.toFixed(2)), fc_budget_pct: BUDGET_PCT, fc_status: fcStatus, trades: fcTrades };
  } catch(e) {
    handleFetchError_("calcFcBudget_", e, "WARN");
    return { ...result, fc_status: "ERROR: " + e.message };
  }
}

// ── orbit_gap 계산 — spec/01_objective_profile.yaml:orbit_monthly_tracker ──
// orbit_gap(%) = 목표누적수익률_to_date - 실제누적수익률_to_date
// 목표누적수익률 = (target/start)^(elapsed_months/total_months) - 1  (기하평균 보간)
// 반환: { ok, orbit_gap_pct, orbit_state, offensive_slot_adj, cash_floor_adj, detail, ... }
function calcOrbitGap_(settings) {
  const NONE = (detail) => ({
    ok: false, orbit_gap_pct: null, orbit_state: "UNKNOWN",
    offensive_slot_adj: 0, cash_floor_adj: 0, detail,
  });

  const startAsset  = parseFloat(settings["orbit_start_asset_krw"]);
  const targetAsset = parseFloat(settings["orbit_target_asset_krw"]);
  const currentAsset = parseFloat(settings["total_asset_krw"]);
  const startYMRaw = settings["orbit_start_yyyymm"];  // "2026-01" or Sheets date
  const endYMRaw   = settings["orbit_end_yyyymm"];    // "2028-12" or Sheets date

  if (!Number.isFinite(startAsset)   || startAsset   <= 0) return NONE("orbit_start_asset_krw 미설정");
  if (!Number.isFinite(targetAsset)  || targetAsset  <= 0) return NONE("orbit_target_asset_krw 미설정");
  if (!Number.isFinite(currentAsset) || currentAsset <= 0) return NONE("total_asset_krw 미설정");

  const parseYM = value => {
    if (value instanceof Date && !isNaN(value.getTime())) {
      return value.getFullYear() * 12 + (value.getMonth() + 1);
    }
    const s = String(value ?? "").trim();
    if (!s) return null;
    const m = s.match(/^(\d{4})[-./년\s]*(\d{1,2})/);
    if (!m) return null;
    const y = parseInt(m[1], 10);
    const mo = parseInt(m[2], 10);
    if (!Number.isFinite(y) || !Number.isFinite(mo) || mo < 1 || mo > 12) return null;
    return y * 12 + mo;
  };
  const now = new Date();
  const nowYM       = now.getFullYear() * 12 + (now.getMonth() + 1);
  const startYM_int = parseYM(startYMRaw);
  const endYM_int   = parseYM(endYMRaw);
  if (startYM_int === null || endYM_int === null) return NONE("orbit_start/end_yyyymm 파싱 실패");
  const totalMonths   = endYM_int - startYM_int;
  const elapsedMonths = Math.max(0, nowYM - startYM_int);

  if (totalMonths <= 0) return NONE("orbit_end_yyyymm이 orbit_start_yyyymm 이전");

  const frac = Math.min(elapsedMonths / totalMonths, 1);
  const targetCumRet = Math.pow(targetAsset / startAsset, frac) - 1;
  const actualCumRet = currentAsset / startAsset - 1;
  const orbitGap = parseFloat(((targetCumRet - actualCumRet) * 100).toFixed(2));

  let orbitState, slotAdj, cashAdj;
  if (orbitGap > 3) {
    orbitState = "significantly_behind"; slotAdj = 2; cashAdj = -2;
  } else if (orbitGap > 1) {
    orbitState = "mild_behind";          slotAdj = 1; cashAdj = -1;
  } else if (orbitGap < -2) {
    orbitState = "ahead_of_target";      slotAdj = 0; cashAdj = 1;
  } else {
    orbitState = "on_track";             slotAdj = 0; cashAdj = 0;
  }

  return {
    ok: true,
    elapsed_months: elapsedMonths,
    total_months: totalMonths,
    target_cum_return_pct: parseFloat((targetCumRet * 100).toFixed(2)),
    actual_cum_return_pct: parseFloat((actualCumRet * 100).toFixed(2)),
    orbit_gap_pct:         orbitGap,
    orbit_state:           orbitState,
    offensive_slot_adj:    slotAdj,
    cash_floor_adj:        cashAdj,
    detail: `gap=${orbitGap}%p target=${(targetCumRet*100).toFixed(1)}% actual=${(actualCumRet*100).toFixed(1)}% (${elapsedMonths}/${totalMonths}개월)`,
  };
}

// ── orbit_gap → monthly_history 기록 ─────────────────────────────────────────
// settings/info를 인자로 받으면 재사용 (runMacro 연쇄 시), 없으면 직접 읽기 (독립 실행 시).
function runOrbitGap(settings, info) {
  if (!settings) settings = readSettingsTab_();
  if (!info)     info     = calcOrbitGap_(settings);
  if (!info.ok) {
    Logger.log("runOrbitGap 스킵: " + info.detail);
    return;
  }
  const currentMonth = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM");
  upsertMonthlyRow_(currentMonth, {
    Total_Asset:       parseFloat(settings["total_asset_krw"]),
    Start_Asset:       parseFloat(settings["orbit_start_asset_krw"]),
    Target_Asset:      parseFloat(settings["orbit_target_asset_krw"]),
    Target_Return_Pct: info.target_cum_return_pct,
    Actual_Return_Pct: info.actual_cum_return_pct,
    Orbit_Gap_Pct:     info.orbit_gap_pct,
    Orbit_State:       info.orbit_state,
    Slot_Adj:          info.offensive_slot_adj,
    Cash_Floor_Adj:    info.cash_floor_adj,
  });
  Logger.log(`monthly_history(orbit): ${currentMonth} gap=${info.orbit_gap_pct}%p (${info.orbit_state}) slot_adj=${info.offensive_slot_adj}`);
}

// 동일 티커 복수 행(소수 분리 등) 합산 — ex 를 in-place 갱신
function _mergePositionRecord_(ex, incoming) {
  const newQty   = ex.quantity + incoming.quantity;
  const newAvail = (ex.available_quantity || 0) + (incoming.available_quantity || 0);
  const newMV    = (ex.market_value  || 0) + (incoming.market_value  || 0);
  const newCost  = (ex.total_cost    || 0) + (incoming.total_cost    || 0);
  const newPL    = (ex.profit_loss   || 0) + (incoming.profit_loss   || 0);

  ex.quantity           = newQty;
  ex.available_quantity = newAvail;
  ex.market_value       = newMV;
  ex.total_cost         = newCost;
  ex.profit_loss        = newPL;
  ex.average_cost       = newQty > 0 ? Math.round(newCost / newQty) : ex.average_cost;
  ex.entry_price        = ex.average_cost;
  ex.return_pct         = newCost > 0
    ? parseFloat(((newPL / newCost) * 100).toFixed(2))
    : ex.return_pct;
  // 이름: "(소수)" 접미사 없는 쪽 우선
  if (ex.name.includes('소수') && !incoming.name.includes('소수')) {
    ex.name = incoming.name;
  }
  // 기타 스칼라 필드: 기존 값이 null 이면 incoming 값으로 채움
  ['stop_price','highest_price','entry_date','entry_stage','position_type'].forEach(k => {
    if (ex[k] == null && incoming[k] != null) ex[k] = incoming[k];
  });
}

// ── account_snapshot 탭 읽기 → 계좌 캡처 확정 원장 ─────────────────────────
// - 일반계좌: 종목별 보유수량·평단 + 현금 모두 제공 → Sell_Qty 직접 산출 가능
// - ISA/연금저축: 캡처 금액은 투자완료 계좌잔액 reference. 일반계좌 현금원장 합산 금지.
//                  개별 종목수량 미제공 시 Sell_Qty 산출 불가
// parse_status=CAPTURE_READ_OK + user_confirmed=Y 행만 실 데이터로 인정.
function readAccountSnapshotMap_() {
  const settings_ = readSettingsTab_();
  const confirmModeRaw_ = String((settings_ && settings_["account_snapshot_confirm_mode"]) || "STRICT_Y").trim().toUpperCase();
  const allowAutoConfirm_ = confirmModeRaw_ === "AUTO_IF_PARSE_OK";
  const makeCashBucket = () => ({ immediate_cash: null, settlement_cash_d2: null, available_cash: null, open_order_amount: 0 });
  const result = {
    positions: {},                        // 일반계좌 개별주 (Sell_Qty·stop_price 계산에 사용)
    isa_positions: {},                    // ISA 개별주 (PCL 카운트 전용 — Sell_Qty 미사용)
    cash: makeCashBucket(),               // 일반계좌 합산 (매수 가용 현금 기준)
    cash_by_account: {                    // 계좌 유형별 현금 분리 추적
      "일반계좌": makeCashBucket(),
      "ISA": makeCashBucket(),
      "연금저축": makeCashBucket(),
    },
    rows_read: 0,
    rows_confirmed: 0,
    rows_parse_ok_unconfirmed: 0,
    rows_auto_confirmed: 0,
    confirm_mode: confirmModeRaw_,
    account_types_seen: new Set(),        // 캡처된 계좌 유형 목록
  };
  try {
    const sheet = getSpreadsheet_().getSheetByName("account_snapshot");
    if (!sheet) return result;
    const data = sheet.getDataRange().getValues();
    if (data.length < 3) return result;
    const hdr = data[1].map(h => String(h ?? "").trim());
    const idx = name => hdr.indexOf(name);

    const tickerIdx    = idx("ticker");
    const nameIdx      = idx("name");
    const accountIdx   = idx("account");
    const acctTypeIdx  = idx("account_type");           // ISA / 연금저축 / 일반계좌 구분
    const qtyIdx       = idx("holding_quantity");
    const availQtyIdx  = idx("available_quantity");
    const avgIdx       = idx("average_cost");
    const totalCostIdx = idx("total_cost");
    const curIdx       = idx("current_price");
    const mvIdx        = idx("market_value");
    const profitIdx    = idx("profit_loss");
    const retPctIdx    = idx("return_pct");
    const immIdx       = idx("immediate_cash");
    const d2Idx        = idx("settlement_cash_d2");
    const availIdx     = idx("available_cash");
    const openIdx      = idx("open_order_amount");
    const statusIdx    = idx("parse_status");
    const confirmedIdx = idx("user_confirmed");
    const capturedIdx  = idx("captured_at");
    const stopIdx      = idx("stop_price");
    const highIdx      = idx("highest_price_since_entry");
    const entryDateIdx = idx("entry_date");
    const stageIdx     = idx("entry_stage");
    const posTypeIdx   = idx("position_type");
    const lastUpdIdx   = idx("last_updated");

    for (let i = 2; i < data.length; i++) {
      const row = data[i];
      const parseStatus = statusIdx >= 0 ? String(row[statusIdx] ?? "").trim() : "";
      const confirmed   = confirmedIdx >= 0 ? String(row[confirmedIdx] ?? "").trim().toUpperCase() : "";
      if (!parseStatus && !confirmed) continue;
      result.rows_read++;

      const isParseOk = parseStatus === "CAPTURE_READ_OK";
      const hasConfirm = ["Y", "YES", "TRUE", "1"].includes(confirmed);
      const isConfirmed = isParseOk && (hasConfirm || (allowAutoConfirm_ && !confirmed));
      if (isParseOk && !hasConfirm) {
        result.rows_parse_ok_unconfirmed++;
      }
      if (isParseOk && !hasConfirm && allowAutoConfirm_ && !confirmed) {
        result.rows_auto_confirmed++;
      }
      if (!isConfirmed) continue;
      result.rows_confirmed++;

      const acctType = acctTypeIdx >= 0 ? String(row[acctTypeIdx] ?? "").trim() : "일반계좌";
      const isRestrictedAcct = acctType === "ISA" || acctType === "연금저축";
      result.account_types_seen.add(acctType);

      // 현금/잔액 — 계좌 유형별 버킷에 기록
      const immediateCash   = immIdx  >= 0 ? parseFloat(row[immIdx])  : NaN;
      const settlementCash  = d2Idx   >= 0 ? parseFloat(row[d2Idx])   : NaN;
      const availableCash   = availIdx >= 0 ? parseFloat(row[availIdx]) : NaN;
      const openOrderAmount = openIdx  >= 0 ? parseFloat(row[openIdx]) : NaN;

      const cashBucket = result.cash_by_account[acctType] ?? makeCashBucket();
      if (!result.cash_by_account[acctType]) result.cash_by_account[acctType] = cashBucket;
      if (Number.isFinite(immediateCash))   cashBucket.immediate_cash   = (cashBucket.immediate_cash   ?? 0) + immediateCash;
      if (Number.isFinite(settlementCash))  cashBucket.settlement_cash_d2 = (cashBucket.settlement_cash_d2 ?? 0) + settlementCash;
      if (Number.isFinite(availableCash))   cashBucket.available_cash   = (cashBucket.available_cash   ?? 0) + availableCash;
      if (Number.isFinite(openOrderAmount)) cashBucket.open_order_amount = (cashBucket.open_order_amount ?? 0) + openOrderAmount;

      // result.cash: 일반계좌 현금만 포트폴리오 cash ledger로 사용
      // ISA/연금저축 값은 투자완료 계좌잔액 reference로만 보관
      if (!isRestrictedAcct) {
        if (Number.isFinite(immediateCash))   result.cash.immediate_cash   = (result.cash.immediate_cash   ?? 0) + immediateCash;
        if (Number.isFinite(settlementCash))  result.cash.settlement_cash_d2 = (result.cash.settlement_cash_d2 ?? 0) + settlementCash;
        if (Number.isFinite(availableCash))   result.cash.available_cash   = (result.cash.available_cash   ?? 0) + availableCash;
        if (Number.isFinite(openOrderAmount)) result.cash.open_order_amount = (result.cash.open_order_amount ?? 0) + openOrderAmount;
      }

      // 연금저축 = ETF 전용 계좌 → 개별주 포지션 매핑 완전 스킵
      if (acctType === "연금저축") continue;

      const ticker = tickerIdx >= 0 ? normalizeTickerCode(row[tickerIdx]) : "";
      const qty = qtyIdx >= 0 ? parseFloat(row[qtyIdx]) : NaN;
      if (!ticker || !Number.isFinite(qty) || qty <= 0) continue;

      const availQty   = availQtyIdx  >= 0 ? parseFloat(row[availQtyIdx]) : NaN;
      const totalCost  = totalCostIdx >= 0 ? parseFloat(row[totalCostIdx])  : NaN;
      const profitLoss = profitIdx    >= 0 ? parseFloat(row[profitIdx])     : NaN;
      const retPct     = retPctIdx    >= 0 ? parseFloat(row[retPctIdx])     : NaN;
      const stopPrice  = stopIdx      >= 0 ? parseFloat(row[stopIdx])       : NaN;
      const highPrice  = highIdx      >= 0 ? parseFloat(row[highIdx])       : NaN;
      const entryDateRaw = entryDateIdx >= 0 ? row[entryDateIdx] : "";
      const lastUpdRaw = lastUpdIdx >= 0 ? row[lastUpdIdx] : "";
      const normalizeDateCell = value => value instanceof Date
        ? Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd")
        : String(value ?? "").trim().substring(0, 10);

      const posRecord = {
        ticker,
        name:               nameIdx    >= 0 ? String(row[nameIdx] ?? "").trim() : "",
        account:            accountIdx >= 0 ? String(row[accountIdx] ?? "").trim() : "",
        account_type:       acctType,
        quantity:           qty,
        available_quantity: Number.isFinite(availQty)   ? availQty   : null,
        average_cost:       avgIdx >= 0 ? parseFloat(row[avgIdx]) : null,
        entry_price:         avgIdx >= 0 ? parseFloat(row[avgIdx]) : null,
        total_cost:         Number.isFinite(totalCost)  ? totalCost  : null,
        current_price:      curIdx >= 0 ? parseFloat(row[curIdx])  : null,
        market_value:       mvIdx  >= 0 ? parseFloat(row[mvIdx])   : null,
        profit_loss:        Number.isFinite(profitLoss) ? profitLoss : null,
        return_pct:         Number.isFinite(retPct)     ? retPct     : null,
        stop_price:          Number.isFinite(stopPrice) && stopPrice > 0 ? stopPrice : null,
        highest_price:       Number.isFinite(highPrice) && highPrice > 0 ? highPrice : null,
        entry_date:          entryDateRaw ? normalizeDateCell(entryDateRaw) : "",
        entry_stage:         stageIdx >= 0 ? String(row[stageIdx] ?? "").trim() : "",
        position_type:       posTypeIdx >= 0 && String(row[posTypeIdx] ?? "").trim().toLowerCase() === "core" ? "core" : "satellite",
        last_updated:        lastUpdRaw ? normalizeDateCell(lastUpdRaw) : "",
        parse_status:       parseStatus,
        user_confirmed:     "Y",
        captured_at:        capturedIdx >= 0 ? row[capturedIdx] : "",
      };

      if (acctType === "ISA") {
        // ISA 개별주: PCL 카운트 전용. Sell_Qty·stop_price·Total_Heat 계산에는 미사용.
        if (result.isa_positions[ticker]) {
          _mergePositionRecord_(result.isa_positions[ticker], posRecord);
        } else {
          result.isa_positions[ticker] = posRecord;
        }
      } else {
        // 일반계좌: 전체 기능(stop_price, Sell_Qty, Total_Heat 등) 활성
        // 동일 티커 중복 행(소수 분리 계좌 등) → 수량·평가액·원가 합산
        if (result.positions[ticker]) {
          _mergePositionRecord_(result.positions[ticker], posRecord);
        } else {
          result.positions[ticker] = posRecord;
        }
      }
    }
    result.account_types_seen = [...result.account_types_seen]; // Set → Array
  } catch(e) {
    handleFetchError_("readAccountSnapshotMap_", e, "WARN");
  }
  if (result.rows_read > 0 && result.rows_confirmed === 0 && result.rows_parse_ok_unconfirmed > 0) {
    Logger.log(
      "[ACCOUNT_SNAPSHOT_CONFIRMATION_BLOCK] parse_ok_unconfirmed=" + result.rows_parse_ok_unconfirmed
      + " mode=" + result.confirm_mode
      + " (hint: settings.account_snapshot_confirm_mode=AUTO_IF_PARSE_OK 또는 user_confirmed=Y 입력)"
    );
  }
  return result;
}

// ── account_snapshot 탭 초기화 (캡처 원장 + 선택 포지션 상태 컬럼) ───────────────
// runDataFeed() 시작 시 자동 호출 — 탭 없으면 생성, 있으면 헤더 점검 후 즉시 반환.
// 샘플 행(parse_status="SAMPLE")은 GAS가 읽지 않으므로 실 데이터에 영향 없음.
function initAccountSnapshotTemplate_() {
  const SS = getSpreadsheet_();
  const SHEET_NAME = "account_snapshot";

  const HEADERS = [
    "captured_at", "account", "account_type", "ticker", "name",
    "holding_quantity", "available_quantity", "average_cost", "total_cost",
    "current_price", "market_value", "profit_loss", "return_pct",
    "immediate_cash", "settlement_cash_d2", "available_cash", "open_order_amount",
    "monthly_contribution_limit", "monthly_contribution_used",
    "parse_status", "user_confirmed",
    "stop_price", "highest_price_since_entry", "entry_date", "entry_stage", "position_type", "last_updated",
  ];
  const TEXT_COLS = new Set(["captured_at","ticker","parse_status","user_confirmed","account","account_type","name","entry_date","entry_stage","position_type","last_updated"]);
  const H = {};  // 컬럼명 → 인덱스 빠른 참조
  HEADERS.forEach((h, i) => { H[h] = i; });
  const numCols = HEADERS.length;

  let sheet = SS.getSheetByName(SHEET_NAME);
  const existed = !!sheet;

  // ① 탭 존재 + 헤더 행이 있으면 → 누락 컬럼만 뒤에 추가하고 즉시 반환 (데이터 보호)
  if (existed) {
    const existingData = sheet.getDataRange().getValues();
    const hasHeader = existingData.length >= 2 && existingData[1].some(v => String(v).trim() !== "");
    if (hasHeader) {
      const existingHdr = existingData[1].map(h => String(h).trim());
      const missing = HEADERS.filter(h => !existingHdr.includes(h));
      if (missing.length > 0) {
        const startCol = existingHdr.length + 1;
        sheet.getRange(2, startCol, 1, missing.length).setValues([missing]);
        sheet.getRange(2, startCol, 1, missing.length).setFontWeight("bold").setBackground("#d9ead3");
        missing.forEach((h, i) => {
          if (TEXT_COLS.has(h)) sheet.getRange(2, startCol + i, 100, 1).setNumberFormat("@");
        });
        Logger.log(`initAccountSnapshotTemplate_: account_snapshot 누락컬럼 추가=${missing.join(",")}`);
      }
      return { action: "skipped_existing", sheet: SHEET_NAME, rows: existingData.length - 2, missing_headers: missing };
    }
  }

  // ② 탭 없으면 생성, 있지만 비어 있으면 초기화
  if (!sheet) sheet = SS.insertSheet(SHEET_NAME);
  sheet.clearContents();
  sheet.clearFormats();

  // 행1: 안내 메모
  const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
  sheet.getRange(1, 1).setValue(
    `[account_snapshot] HTS 캡처->ChatGPT 파싱->A3 붙여넣기 탭 | ${numCols}컬럼 | 초기화: ${now} KST | SAMPLE 행은 실제 캡처 후 삭제`
  );

  // 행2: 컬럼 헤더 (볼드)
  sheet.getRange(2, 1, 1, numCols).setValues([HEADERS]);
  sheet.getRange(2, 1, 1, numCols).setFontWeight("bold").setBackground("#d9ead3");

  // 텍스트 포맷 — setValues 전 적용 필수 (Ticker 등 숫자 변환 방지)
  HEADERS.forEach((h, i) => {
    if (TEXT_COLS.has(h)) sheet.getRange(2, i + 1, 100, 1).setNumberFormat("@");
  });

  // ── 샘플 데이터 (parse_status="SAMPLE" → GAS readAccountSnapshotMap_ 무시) ──
  const sampleDate = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd") + " 09:30";

  function makeRow(fields) {
    const row = new Array(numCols).fill("");
    Object.entries(fields).forEach(([k, v]) => { if (H[k] !== undefined) row[H[k]] = v; });
    return row;
  }

  const sampleRows = [
    makeRow({
      captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌",
      ticker: "005930", name: "삼성전자",
      holding_quantity: 100, available_quantity: 100,
      average_cost: 68000, total_cost: 6800000,
      current_price: 75000, market_value: 7500000,
      profit_loss: 700000, return_pct: 10.3,
      immediate_cash: 3500000, settlement_cash_d2: 4200000,
      available_cash: 3500000, open_order_amount: 0,
      parse_status: "SAMPLE", user_confirmed: "N",
    }),
    makeRow({
      captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌",
      ticker: "000660", name: "SK하이닉스",
      holding_quantity: 30, available_quantity: 30,
      average_cost: 180000, total_cost: 5400000,
      current_price: 210000, market_value: 6300000,
      profit_loss: 900000, return_pct: 16.7,
      parse_status: "SAMPLE", user_confirmed: "N",
    }),
    makeRow({
      captured_at: sampleDate, account: "ISA", account_type: "ISA",
      ticker: "012450", name: "한화에어로스페이스",
      holding_quantity: 10, available_quantity: 10,
      average_cost: 980000, total_cost: 9800000,
      current_price: 1216000, market_value: 12160000,
      profit_loss: 2360000, return_pct: 24.1,
      monthly_contribution_limit: 4000000, monthly_contribution_used: 1500000,
      parse_status: "SAMPLE", user_confirmed: "N",
    }),
    // 현금 전용 행 (보유종목 없음)
    makeRow({
      captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌",
      name: "현금",
      immediate_cash: 3500000, settlement_cash_d2: 4200000,
      available_cash: 3500000, open_order_amount: 0,
      parse_status: "SAMPLE", user_confirmed: "N",
    }),
  ];

  sheet.getRange(3, 1, sampleRows.length, numCols).setValues(sampleRows);
  // 샘플 행 배경색 — 실제 데이터와 구분
  sheet.getRange(3, 1, sampleRows.length, numCols).setBackground("#fff2cc");

  Logger.log(`initAccountSnapshotTemplate_: ${existed ? "재초기화" : "신규생성"} | 탭="${SHEET_NAME}" | 샘플행=${sampleRows.length}`);
  return {
    action: existed ? "reinit" : "created",
    sheet: SHEET_NAME,
    columns: numCols,
    sample_rows: sampleRows.length,
    note: "SAMPLE 행은 parse_status=SAMPLE → GAS가 무시. 실제 HTS 캡처 붙여넣기 후 삭제.",
    next_steps: [
      "HTS 보유종목 화면 캡처 → ChatGPT 첨부 (capture_parse_prompt.md 포함)",
      "ChatGPT TSV 출력 → account_snapshot 탭 A3 셀 선택 → Ctrl+V",
      "SAMPLE 행 삭제 후 runDataFeed() 재실행",
    ],
  };
}

function calcPerformanceBuyBias_(performance) {
  const p = performance || {};
  const multiplier = Number.isFinite(p.bayesian_multiplier) ? p.bayesian_multiplier : 0.5;
  const tradesUsed = Number.isFinite(p.trades_used) ? p.trades_used : 0;
  const netExp = Number.isFinite(p.net_expectancy_30) ? p.net_expectancy_30 : null;
  const consLoss = Number.isFinite(p.consecutive_losses) ? p.consecutive_losses : 0;
  
  // [Phase 4] CAPITAL_STYLE_ALLOCATION_V3 연계: 데이터 품질 갭 분석
  const legacyQuality = Number.isFinite(p.legacy_investment_quality_score) ? p.legacy_investment_quality_score : 13;
  const modernQuality = Number.isFinite(p.modern_investment_quality_score) ? p.modern_investment_quality_score : 69;
  const qualityGap = Math.abs(modernQuality - legacyQuality);
  
  let entryBlock = false;
  let quantityMult = multiplier;
  let reason = "performance_default";
  let confidenceCap = 1.0;

  if (qualityGap >= 20) {
    confidenceCap = 0.5;
    reason = "quality_gap_penalty";
    quantityMult = Math.min(quantityMult, 0.5);
  }

  if (consLoss >= 5) {
    entryBlock = true;
    quantityMult = 0;
    reason = "no_bet";
  } else if (tradesUsed < 5) {
    quantityMult = Math.min(quantityMult, 0.5);
    reason = (reason === "quality_gap_penalty") ? reason + "|data_short" : "data_short";
  } else if (Number.isFinite(netExp) && netExp < 0) {
    quantityMult = Math.min(quantityMult, 0.25);
    reason = "negative_expectancy";
  } else if (Number.isFinite(netExp) && netExp >= 3.0 && multiplier >= 1.0) {
    quantityMult = (confidenceCap < 1.0) ? confidenceCap : 1.0;
    reason = (reason === "quality_gap_penalty") ? reason + "|high_bet_capped" : "high_bet";
  } else if (multiplier >= 0.5) {
    reason = (reason === "quality_gap_penalty") ? reason : "standard";
  }

  return {
    entry_block: entryBlock,
    quantity_multiplier: quantityMult,
    effective_confidence_cap: confidenceCap,
    label: String(p.bayesian_label ?? "medium_confidence"),
    reason: reason,
  };
}

function runDataFeed() {
  if (typeof isRunAllOrchestrated_ === "function" && isRunAllOrchestrated_()) {
    setFetchSessionLabel_("runDataFeed");
  } else {
    beginFetchSession_("runDataFeed");
  }

  // [PROPOSAL50] P2-2: YAML-GAS 커버리지 감사 — 실행마다 커버리지 기록 갱신
  try { auditYamlGasCoverage_(); } catch(e) { Logger.log('[YGCA] audit error: ' + e.message); }
  if (_gasCompatRoot_._gasCompatFallbackUsed_) {
    Logger.log("[GAS_COMPAT_FALLBACK] gas_lib.gs helper fallback activated — redeploy full Apps Script project to restore canonical helpers.");
  }

  // account_snapshot 탭 없으면 자동 생성 (캡처 원장 + 선택 포지션 상태 헤더)
  initAccountSnapshotTemplate_();

  // settings 탭 — 사용자 입력 파라미터 (total_asset_krw, risk_budget_override 등)
  const settings = readSettingsTab_();
  ensureAccountSnapshotConfirmModeSetting_(settings);
  let totalAssetKrw_ = Number.isFinite(parseFloat(settings["total_asset_krw"]))
    ? parseFloat(settings["total_asset_krw"]) : null;
  const riskBudget_ = Number.isFinite(parseFloat(settings["risk_budget_override"]))
    ? Math.min(0.02, Math.max(0, parseFloat(settings["risk_budget_override"])))
    : 0.007; // POSITION_SIZE_V1 기본값

  // Bayesian multiplier — performance 탭 기반 자동 계산 (spec/17_performance_contract.yaml)
  const bayesian = readPerformanceSheet_();
  Logger.log(`Bayesian: ${bayesian.bayesian_label} (${bayesian.bayesian_multiplier}×) trades=${bayesian.trades_used}`);

  const accountSnapshot_ = readAccountSnapshotMap_();
  Logger.log(
    "[ACCOUNT_SNAPSHOT_STATUS] rows_read=" + accountSnapshot_.rows_read
    + " confirmed=" + accountSnapshot_.rows_confirmed
    + " parse_ok_unconfirmed=" + (accountSnapshot_.rows_parse_ok_unconfirmed || 0)
    + " auto_confirmed=" + (accountSnapshot_.rows_auto_confirmed || 0)
    + " mode=" + (accountSnapshot_.confirm_mode || "STRICT_Y")
  );
  if (accountSnapshot_.rows_read > 0 && accountSnapshot_.rows_confirmed === 0 && (accountSnapshot_.rows_parse_ok_unconfirmed || 0) > 0) {
    upsertOperationalWarningSetting_(
      "account_snapshot_confirmation_warning",
      "[ACCOUNT_CONFIRMATION_REQUIRED] parse_ok_unconfirmed=" + accountSnapshot_.rows_parse_ok_unconfirmed
      + ", mode=" + (accountSnapshot_.confirm_mode || "STRICT_Y")
      + ", action=user_confirmed=Y 입력 또는 account_snapshot_confirm_mode=AUTO_IF_PARSE_OK"
    );
  } else {
    upsertOperationalWarningSetting_("account_snapshot_confirmation_warning", "");
  }
  const settlementCashD2_ = Number.isFinite(parseFloat(settings["settlement_cash_d2_krw"]))
    ? parseFloat(settings["settlement_cash_d2_krw"])
    : accountSnapshot_.cash.settlement_cash_d2;
  if (String((settings["cash_floor_status_override"] || "")).trim()) {
    // no-op: 수동 오버라이드가 있으면 런타임 경고 판단을 덮지 않음
  }
  if (settlementCashD2_ === 0 || settlementCashD2_ === null || settlementCashD2_ === undefined) {
    // 현금 원장 정보 부족을 별도 경고로 남긴다.
    upsertOperationalWarningSetting_(
      "cash_ledger_warning",
      "[CASH_LEDGER_WARNING] settlement_cash_d2_krw가 0 또는 미입력 상태"
    );
  } else {
    upsertOperationalWarningSetting_("cash_ledger_warning", "");
  }
  const weeklyTargetCashPct_ = Number.isFinite(parseFloat(settings["weekly_target_cash_pct"]))
    ? parseFloat(settings["weekly_target_cash_pct"])
    : null;

  const headers = [
    // ── 기본 수급·가격 ─────────────────────────────────────────────────────
    "Ticker","Name","Price_Date","Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows","Updated_At",
    "Price_Status","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D",
    "MA20","MA60","Ret5D","Ret10D","Ret20D","Ret60D",
    "ATR20","ATR20_Pct","Val_Surge_Pct",
    "AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit",
    "Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status",
    "Flow5D_Status","Flow20D_Status","Ind5D_Status","Val_Surge_Status",
    "DART_Status","DART_Source","DART_Catalyst","DART_Risk",
    // ── 밸류에이션 ─────────────────────────────────────────────────────────
    "Forward_PE","PBR","EPS","EPS_Revision_Status","EPS_Growth_1Y_Pct",
    "DividendYield","DPS","Beta","High52W","Low52W","Pct_52W_High","Pct_From_52W_Low","Target_Price","Upside_Pct",
    "Earnings_Date","Days_To_Earnings","Ex_Dividend_Date","Days_To_Ex_Div",
    // ── 재무 건전성 (2026-05-18_FINANCIAL_HEALTH_V1 + OCF_B 추가) ────────────
    "ROE_Pct","Operating_Margin_Pct","Debt_To_Equity","Current_Ratio","FCF_B","OCF_B","Revenue_Growth_Pct",
    // ── 진입 가격·기대우위·수량 자동 추정 ───────────────────────────────────
    "Limit_Price_Est","Stop_Price_Est","Stop_Price_Source","EE_Est","Pos_Size_Qty","Pos_Size_Constraint",
    // ── 익절 사다리·타임스탑 자동 계산 (TAKE_PROFIT_LADDER_V1) ──────────────
    "TP1_Price","TP1_Qty","TP2_Price","TP2_Qty","Time_Stop_Date","Days_To_Time_Stop",
    // ── 포지션 모니터링 ──────────────────────────────────────────────────────
    "Weight_Pct","Profit_Pct","Unrealized_PnL","Stage2_Gate","Band_Status","Position_Count_Status",
    // ── F1 기술적 타이밍 지표 ────────────────────────────────────────────────
    "MA20_Slope","Disparity","RSI14","BB_Width","BB_Position","BB_Upper","BB_Lower",
    // ── F2 진입 모드 게이트 ──────────────────────────────────────────────────
    "Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason",
    // ── F3 매도 타이밍 신호 ──────────────────────────────────────────────────
    "Exit_Signal_Detail",
    // ── F5 타이밍 종합 액션 ────────────────────────────────────────────────
    "Timing_Score_Entry","Timing_Score_Exit","Timing_Action","Timing_Block_Reason",
    // ── F6 매도 액션·수량·가격 ───────────────────────────────────────────
    "Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Price_Source","Sell_Price_Basis",
    "Sell_Execution_Window","Sell_Order_Type","Sell_Reason","Sell_Validation",
    "Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
    // ── F6A 계좌 캡처·주간 리밸런싱 검증 ────────────────────────────────
    "Account_Holding_Qty","Account_Avg_Cost","Account_Market_Value","Account_Parse_Status",
    "Rule_Sell_Qty","Rebalance_Target_Cash_Pct","Rebalance_Need_KRW","Override_Sell_Qty","Override_Reason","Override_Validation",
    // ── F7 최종 룰엔진 액션·우선순위 ─────────────────────────────────────
    "Final_Action","Action_Priority","Priority_Score","Final_Rank","Decision_Source",
    // ── 수급·점수 자동 계산 ────────────────────────────────────────────────
    "Flow_Credit","Trailing_Stop_Price",
    "SS001_P","SS001_V","SS001_F","SS001_E","SS001_M","SS001_VAL","SS001_Total","SS001_Norm_Score","SS001_Grade",
    "PEG","PEG_Gate",
    // ── 돌파 파일럿 게이트 ─────────────────────────────────────────────────
    "Breakout_Score","Breakout_Gate",
    // ── anti_climax_buy_gate S1~S5 ────────────────────────────────────────
    "AC_S1","AC_S2","AC_S3","AC_S4","AC_S5","AC_Total","AC_Gate",
    // ── daily_leader_scan C1~C5 ────────────────────────────────────────────
    "C1_Price","C2_RelStr","C3_VolSurge","C4_Flow","C5_Sector","Leader_Scan_Total","Leader_Gate",
    // ── 상대약세 청산 신호 RW1~RW5 (RW1·RW3은 sector_flow 이력 기반) ─────
    "RW1","RW2","RW3","RW4","RW5","RW_Partial",
    // ── BRT_V1 + RS_VERDICT_V2 + COMPOSITE_VERDICT_V1 + SAQG/RAG ───────
    "Stock_Drawdown_From_High_Pct","Excess_Drawdown_PctP","Recovery_Ratio_5D","Recovery_Ratio_20D",
    "Downside_Beta","RS_Line_20D_Slope","RS_Line_60D_Slope","BRT_Verdict","BRT_Method",
    "Excess_Ret_10D","RS_Verdict_V1_Raw","RS_Verdict","Composite_Verdict",
    "SAQG_V1","SAQG_Penalty","SAQG_Failed_Filters","RAG_Verdict","RAG_Reason",
    // ── 데이터 품질 ────────────────────────────────────────────────────────
    "Missing_Fields","Next_Source_To_Check","Action_Reason","Action_Params","Allowed_Action",
    // ── 포트폴리오 레벨 매도 우선순위 (sell_priority_engine) ──────────────
    "Sell_Priority_Score"
  ];
  const rows = [];
  const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
  const savedEpsRevision = readExistingEpsRevision_("data_feed");

  // 버킷 할당 누산기 (루프 종료 후 _bucketSnapshot_에 기록)
  let _coreTotalPct = 0, _satTotalPct = 0;

  // F4 trailing stop 갱신 대기열 초기화
  _trailingStopUpdates_ = [];

  // account_snapshot pre-read — 보유수량·평단·선택 stop/highest/stage 상태의 단일 원장
  const positionStopMap_ = {};  // ticker → { stop_price, entry_price, quantity, entry_date, entry_stage, position_type, highest_price }
  Object.keys(accountSnapshot_.positions).forEach(ticker => {
    const snap = accountSnapshot_.positions[ticker];
    positionStopMap_[ticker] = {
      stop_price: Number.isFinite(snap.stop_price) && snap.stop_price > 0 ? snap.stop_price : null,
      entry_price: Number.isFinite(snap.average_cost) && snap.average_cost > 0 ? snap.average_cost : null,
      quantity: snap.quantity,
      highest_price: Number.isFinite(snap.highest_price) && snap.highest_price > 0 ? snap.highest_price : null,
      entry_date: snap.entry_date || snap.last_updated || null,
      entry_stage: snap.entry_stage || null,
      position_type: snap.position_type || "satellite",
      account_quantity: snap.quantity,
      account_average_cost: Number.isFinite(snap.average_cost) ? snap.average_cost : null,
      account_market_value: Number.isFinite(snap.market_value) ? snap.market_value : null,
      account_parse_status: snap.parse_status,
      account_user_confirmed: snap.user_confirmed,
      account: snap.account || "",
    };
  });

  // WBS-1.2: total_asset_krw 실시간 재계산 (2-pass update cycle)
  let liveTotalAssetKrw = Number.isFinite(settlementCashD2_) ? settlementCashD2_ : 0;
  for (const ticker of Object.keys(positionStopMap_)) {
    const priceMetrics = resolveDataFeedPriceMetrics(ticker);
    const qty = positionStopMap_[ticker].quantity;
    if (priceMetrics.ok && Number.isFinite(priceMetrics.close) && Number.isFinite(qty)) {
      liveTotalAssetKrw += priceMetrics.close * qty;
    }
  }
  if (liveTotalAssetKrw > 0) {
    totalAssetKrw_ = liveTotalAssetKrw;
    Logger.log(`[WBS-1.2] total_asset_krw 실시간 재계산 완료: ${totalAssetKrw_} KRW (현금: ${settlementCashD2_})`);
  }

  // Total_Heat 사전 계산 — HF005(≥10% 매수 차단) + caution(7~10% 수량 감액)에 사용
  // positionStopMap_ 완성 후 즉시 계산. ATR 추정 폴백: entry_price × 8% (보수적)
  let globalHeatPct_ = null;  // null = 계산 불가, number = heat%
  if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) {
    let heatKrw = 0;
    for (const [, pos] of Object.entries(positionStopMap_)) {
      const qty = pos.quantity;
      const ep  = pos.entry_price;
      if (!Number.isFinite(qty) || qty <= 0 || !Number.isFinite(ep) || ep <= 0) continue;
      const sp = Number.isFinite(pos.stop_price) && pos.stop_price > 0 && pos.stop_price < ep
        ? pos.stop_price : ep * 0.92;  // spec: 미설정 시 8% 고정(보수적 추정)
      heatKrw += (ep - sp) * qty;
    }
    globalHeatPct_ = parseFloat((heatKrw / totalAssetKrw_ * 100).toFixed(2));
    Logger.log(`Total_Heat pre-calc: ${globalHeatPct_}% (${Object.keys(positionStopMap_).length}개 포지션)`);
  }

  // ── 종목 수 집계 → PCL 상태 산출 (2026-05-18_POSITION_STRATEGY_V1) ──────────
  // positionStopMap_  = 일반계좌 개별주 (stop_price·Sell_Qty·Total_Heat 계산용)
  // accountSnapshot_.isa_positions = ISA 개별주 (PCL 카운트 전용)
  // 연금저축 = ETF 전용, 카운트 제외
  // 일반계좌 하드 상한: 8종목(ROTATE_REQUIRED), 경보: 7종목(CAUTION)
  // ISA      하드 상한: 4종목(ROTATE_REQUIRED), 경보: 3종목(CAUTION)
  const _taxCore_  = Object.values(positionStopMap_).filter(p => p.position_type === "core").length;
  const _taxSat_   = Object.values(positionStopMap_).filter(p => p.position_type !== "core").length;
  const _taxTotal_ = Object.keys(positionStopMap_).length;
  const _isaTotal_ = Object.keys(accountSnapshot_.isa_positions ?? {}).length;

  const _taxStatus_ = _taxTotal_ >= 8 ? "ROTATE_REQUIRED"
                    : _taxTotal_ === 7 ? "CAUTION"
                    : "PASS";
  const _isaStatus_ = _isaTotal_ >= 4 ? "ROTATE_REQUIRED"
                    : _isaTotal_ === 3 ? "CAUTION"
                    : "PASS";

  const positionCountStatus_ =
    `일반계좌:${_taxStatus_}(코어${_taxCore_}/위성${_taxSat_}/계${_taxTotal_}) | ISA:${_isaStatus_}(계${_isaTotal_})`;

  // macro 탭 pre-read — KOSPI Ret5/10/20/60D(BRT/RS용) + REGIME_PRELIM(SS001_M용)
  let globalKospiRet5D_ = null;
  let globalKospiRet10D_ = null;
  let globalKospiRet20D_ = null;
  let globalKospiRet60D_ = null;
  let globalKospiDrawdown_ = null;
  let globalRegimePrelim_ = null;
  try {
    const macroSheet = getSpreadsheet_().getSheetByName("macro");
    if (macroSheet) {
      const mData = macroSheet.getDataRange().getValues();
      let headerRowIdx = 0;
      for (let r = 0; r < Math.min(5, mData.length); r++) {
        const row = mData[r] ?? [];
        if (row.indexOf("Symbol") >= 0 && row.indexOf("Name") >= 0) {
          headerRowIdx = r;
          break;
        }
      }
      const mHdr  = mData[headerRowIdx] ?? [];
      const symIdx    = mHdr.indexOf("Symbol");
      const nameIdx   = mHdr.indexOf("Name");
      const closeIdx  = mHdr.indexOf("Close");
      const ma60Idx   = mHdr.indexOf("MA60");
      const ret5DIdx  = mHdr.indexOf("Ret5D");
      const ret10DIdx = mHdr.indexOf("Ret10D");
      const ret20DIdx = mHdr.indexOf("Ret20D");
      const ret60DIdx = mHdr.indexOf("Ret60D");
      for (let i = headerRowIdx + 1; i < mData.length; i++) {
        const sym  = symIdx  >= 0 ? String(mData[i][symIdx]).trim()  : "";
        const name = nameIdx >= 0 ? String(mData[i][nameIdx]).trim() : "";
        if (name === "KOSPI") {
          const r5 = ret5DIdx >= 0 ? parseFloat(mData[i][ret5DIdx]) : NaN;
          const r10 = ret10DIdx >= 0 ? parseFloat(mData[i][ret10DIdx]) : NaN;
          const r20 = ret20DIdx >= 0 ? parseFloat(mData[i][ret20DIdx]) : NaN;
          const r60 = ret60DIdx >= 0 ? parseFloat(mData[i][ret60DIdx]) : NaN;
          const close = closeIdx >= 0 ? parseFloat(mData[i][closeIdx]) : NaN;
          const ma60 = ma60Idx >= 0 ? parseFloat(mData[i][ma60Idx]) : NaN;
          if (Number.isFinite(r5)) globalKospiRet5D_ = r5;
          if (Number.isFinite(r10)) globalKospiRet10D_ = r10;
          if (Number.isFinite(r20)) globalKospiRet20D_ = r20;
          if (Number.isFinite(r60)) globalKospiRet60D_ = r60;
          if (Number.isFinite(close) && Number.isFinite(ma60) && ma60 > 0) {
            globalKospiDrawdown_ = Math.max(0, parseFloat(((1 - close / Math.max(close, ma60)) * 100).toFixed(2)));
          } else if (Number.isFinite(r60) && r60 < 0) {
            globalKospiDrawdown_ = Math.abs(r60);
          }
        }
        if (sym === "REGIME_PRELIM" && closeIdx >= 0) {
          const rv = String(mData[i][closeIdx]).trim();
          if (rv) globalRegimePrelim_ = rv;
        }
        if (globalKospiRet10D_ !== null && globalKospiRet20D_ !== null && globalRegimePrelim_ !== null) break;
      }
    }
  } catch(e) { handleFetchError_("runDataFeed:macro pre-read", e, "CRITICAL"); }

  // sector_flow 전회 실행 결과 통합 pre-read: rank, ETF수익률, 수급, RW1/RW3, 섹터 밸류에이션
  const sectorFlowData_ = {};  // sector_name → { rank, etfRet10D, smart5, smart20, rw1, rw3, medianPE, medianPBR }
  try {
    const sfSheet = getSpreadsheet_().getSheetByName("sector_flow");
    if (sfSheet) {
      const sfData = sfSheet.getDataRange().getValues();
      const sfHdr  = sfData[1] ?? [];
      const sNameIdx  = sfHdr.indexOf("Sector");
      const rankIdx   = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank");
      const scoreIdx  = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score");
      const etfR10Idx = sfHdr.indexOf("Sector_Ret10D") >= 0 ? sfHdr.indexOf("Sector_Ret10D") :
                        (sfHdr.indexOf("ETF_Ret10D") >= 0 ? sfHdr.indexOf("ETF_Ret10D") : sfHdr.indexOf("Sector_Ret20D"));
      const smart5Idx = sfHdr.indexOf("SmartMoney_5D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_5D_KRW") : sfHdr.indexOf("Frg_5D_SUM");
      const smart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM");
      const rw1Idx    = sfHdr.indexOf("RW1");
      const rw3Idx    = sfHdr.indexOf("RW3");
      const medPeIdx  = sfHdr.indexOf("Sector_Median_PE");
      const medPbrIdx = sfHdr.indexOf("Sector_Median_PBR");
      if (sNameIdx >= 0) {
        for (let i = 2; i < sfData.length; i++) {
          const sName = String(sfData[i][sNameIdx]).trim();
          if (!sName || sName === "Sector") continue;
          const rank = rankIdx >= 0 ? parseInt(sfData[i][rankIdx]) : null;
          sectorFlowData_[sName] = {
            rank:      Number.isFinite(rank) ? rank : null,
            score:     scoreIdx >= 0 ? parseFloat(sfData[i][scoreIdx]) : null,
            etfRet10D: etfR10Idx >= 0 ? parseFloat(sfData[i][etfR10Idx]) : null,
            smart5:    smart5Idx >= 0 ? parseFloat(sfData[i][smart5Idx]) : null,
            smart20:   smart20Idx >= 0 ? parseFloat(sfData[i][smart20Idx]) : null,
            rw1:       rw1Idx >= 0 ? parseInt(sfData[i][rw1Idx]) : 0,
            rw3:       rw3Idx >= 0 ? parseInt(sfData[i][rw3Idx]) : 0,
            medianPE:  medPeIdx  >= 0 ? parseFloat(sfData[i][medPeIdx])  : null,
            medianPBR: medPbrIdx >= 0 ? parseFloat(sfData[i][medPbrIdx]) : null,
          };
        }
      }
    }
  } catch(e) { handleFetchError_("runDataFeed:sector_flow pre-read", e, "CRITICAL"); }

  // core_satellite 탭 pre-read — RS_Pct_20D → SS001_P 상대강도 점수용
  const csRsPctMap_ = {};  // ticker → RS_Pct_20D (0~100)
  try {
    const csSheet = getSpreadsheet_().getSheetByName("core_satellite");
    if (csSheet) {
      const csData    = csSheet.getDataRange().getValues();
      const csHdr     = csData[1] ?? [];
      const csTkIdx   = csHdr.indexOf("Ticker");
      const csRsPctIdx = csHdr.indexOf("RS_Pct_20D");
      if (csTkIdx >= 0 && csRsPctIdx >= 0) {
        for (let i = 2; i < csData.length; i++) {
          const tk = String(csData[i][csTkIdx]).trim();
          if (!tk) continue;
          const rsPct = parseFloat(csData[i][csRsPctIdx]);
          if (Number.isFinite(rsPct)) csRsPctMap_[tk] = rsPct;
        }
      }
    }
  } catch(e) { handleFetchError_("runDataFeed:core_satellite pre-read", e, "CRITICAL"); }

  const preReads = {
    positionStopMap_, globalHeatPct_,
    globalKospiRet5D_, globalKospiRet10D_, globalKospiRet20D_, globalKospiRet60D_, globalKospiDrawdown_,
    globalRegimePrelim_,
    sectorFlowData_, csRsPctMap_, riskBudget_, totalAssetKrw_,
    weeklyTargetCashPct_, bayesian, savedEpsRevision, today,
    positionCountStatus_,
  };
  const activeTickers_ = (typeof getActiveTickers_ === "function") ? getActiveTickers_() : TICKERS;
  for (const t of activeTickers_) {
    const result = buildTickerRowV2_(t, preReads, _trailingStopUpdates_);
    rows.push(result.row);
    _coreTotalPct += result.corePctDelta;
    _satTotalPct  += result.satPctDelta;
    Utilities.sleep(400);
  }

  // ── 섹터별 총노출 집계 (duplicate_exposure_rule 판별 + Sell_Priority_Score 입력) ──
  // spec: spec/risk/portfolio_exposure.yaml:duplicate_exposure_rule
  const _sectorExpMap_ = {};
  {
    const wIdx_ = headers.indexOf("Weight_Pct");
    const tIdx_ = headers.indexOf("Ticker");
    rows.forEach(row => {
      const tk_ = String(row[tIdx_] ?? "");
      const w_  = parseFloat(row[wIdx_]);
      if (!tk_ || !Number.isFinite(w_) || w_ <= 0) return;
      const sec_ = TICKER_SECTOR_MAP[tk_] ?? "";
      if (sec_) _sectorExpMap_[sec_] = (_sectorExpMap_[sec_] || 0) + w_;
    });
  }

  // ── Sell_Priority_Score 일괄 계산 (post-loop, 섹터집계 완료 후) ───────────
  // spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring
  {
    const spIdx_ = headers.indexOf("Sell_Priority_Score");
    if (spIdx_ >= 0) {
      rows.forEach(row => {
        const res_ = calcSellPriorityScore_(row, headers, _sectorExpMap_);
        row[spIdx_] = res_.score;
      });
    }
  }

  const targetCashPctIdx = headers.indexOf("Rebalance_Target_Cash_Pct");
  const needKrwIdx = headers.indexOf("Rebalance_Need_KRW");
  const overrideQtyIdx = headers.indexOf("Override_Sell_Qty");
  const overrideReasonIdx = headers.indexOf("Override_Reason");
  const overrideValidationIdx = headers.indexOf("Override_Validation");
  const sellActionIdx = headers.indexOf("Sell_Action");
  const sellValidationIdx = headers.indexOf("Sell_Validation");
  const sellLimitIdx = headers.indexOf("Sell_Limit_Price");
  const ruleSellQtyIdx = headers.indexOf("Rule_Sell_Qty");
  const accountQtyIdx = headers.indexOf("Account_Holding_Qty");
  const finalActionIdx = headers.indexOf("Final_Action");
  const priorityScoreForRebalIdx = headers.indexOf("Priority_Score");
  if (
    Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0 &&
    Number.isFinite(settlementCashD2_) &&
    Number.isFinite(weeklyTargetCashPct_) && weeklyTargetCashPct_ > 0 &&
    targetCashPctIdx >= 0 && needKrwIdx >= 0 && overrideQtyIdx >= 0 &&
    overrideReasonIdx >= 0 && overrideValidationIdx >= 0
  ) {
    const targetCashKrw = totalAssetKrw_ * weeklyTargetCashPct_ / 100;
    const needKrw = Math.max(0, targetCashKrw - settlementCashD2_);
    rows.forEach(row => {
      row[targetCashPctIdx] = weeklyTargetCashPct_;
      row[needKrwIdx] = Math.round(needKrw);
      row[overrideValidationIdx] = needKrw > 0 ? "NOT_SELECTED" : "NO_REBALANCE_NEEDED";
    });

    if (needKrw > 0) {
      // ── 리밸런스 후보 확장 (sell_priority_engine 3단계 풀) ──────────────────
      // spec: portfolio_exposure.yaml:sell_priority_engine.hard_precedence
      // Tier 1: 기존 SELL_READY(매도신호 확정) → Tier 2: ETF(중복노출) → Tier 3: 손실위성(-10%↓)
      // 코어주도주(삼성전자·SK하이닉스)는 hard_stop 없으면 풀 제외 (spec:prohibition)
      const nameIdx_    = headers.indexOf("Name");
      const profitIdx_  = headers.indexOf("Profit_Pct");
      const tickerIdx_  = headers.indexOf("Ticker");
      const spScoreIdx_ = headers.indexOf("Sell_Priority_Score");

      let remaining = needKrw;
      rows
        .map((row, idx) => ({ row, idx }))
        .filter(item => {
          const row         = item.row;
          const finalAction = String(row[finalActionIdx] ?? "");
          const sellAction  = String(row[sellActionIdx]  ?? "");
          const sellVal     = String(row[sellValidationIdx] ?? "");
          const name__      = String(row[nameIdx_]  ?? "");
          const ticker__    = String(row[tickerIdx_] ?? "");
          const profitPct__ = parseFloat(row[profitIdx_]);
          const isEtf__     = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name__);
          const isCL__      = (ticker__ === "005930" || ticker__ === "000660");
          // hard_stop — core leader도 포함
          if (finalAction === "EXIT_SIGNAL" || sellAction === "EXIT_100") return true;
          // Tier 1: SELL_READY (기존 로직)
          if (sellAction && sellAction !== "HOLD" &&
              sellVal === "SIGNAL_CONFIRMED" && finalAction === "SELL_READY") return true;
          // Tier 2: ETF 중복노출 (코어리더 ETF 없으므로 isCL__ 체크 불필요)
          if (isEtf__) return true;
          // Tier 3: 손실 위성 -10% 이하, 코어리더 제외
          if (!isEtf__ && !isCL__ &&
              Number.isFinite(profitPct__) && profitPct__ <= -10) return true;
          return false;
        })
        // Sell_Priority_Score 내림차순 → 점수 높을수록 먼저 현금 확보 대상
        .sort((a, b) => {
          const as_ = parseFloat(a.row[spScoreIdx_]) || 0;
          const bs_ = parseFloat(b.row[spScoreIdx_]) || 0;
          return bs_ - as_;
        })
        .forEach(item => {
          if (remaining <= 0) return;
          const row   = item.row;
          const price = parseFloat(row[sellLimitIdx]);
          const name__= String(row[nameIdx_] ?? "");
          const isEtf__ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name__);
          const finalAction__ = String(row[finalActionIdx] ?? "");
          const tier__ =
            finalAction__ === "EXIT_SIGNAL" ? "①하드스탑" :
            finalAction__ === "SELL_READY"  ? "②매도신호" :
            isEtf__                         ? "③중복ETF"  : "④손실위성";
          // 방향 A: 수량 없음 — Override_Sell_Qty는 캡처 후 수동 계산
          if (!Number.isFinite(price) || price <= 0) {
            row[overrideValidationIdx] = "NO_PRICE";
            return;
          }
          row[overrideReasonIdx] = `[${tier__}] D+2 현금 ${weeklyTargetCashPct_}% 회복 — 수량 캡처 후 확인`;
          row[overrideValidationIdx] = "SIGNAL_ONLY_USER_CONFIRM";
        });
    }
  }

  const priorityIdx = headers.indexOf("Action_Priority");
  const scoreIdx = headers.indexOf("Priority_Score");
  const rankIdx = headers.indexOf("Final_Rank");
  if (priorityIdx >= 0 && scoreIdx >= 0 && rankIdx >= 0) {
    rows
      .map((row, idx) => ({ row, idx }))
      .sort((a, b) => {
        const ap = parseFloat(a.row[priorityIdx]);
        const bp = parseFloat(b.row[priorityIdx]);
        if (ap !== bp) return ap - bp;
        const as = parseFloat(a.row[scoreIdx]);
        const bs = parseFloat(b.row[scoreIdx]);
        if (as !== bs) return bs - as;
        return a.idx - b.idx;
      })
      .forEach((item, rank) => { item.row[rankIdx] = rank + 1; });
  }

  // ── Fetch 품질 진단 집계 (runDataFeed 완료 직전) ──────────────────────────
  // Price_Status / DART_Status 기반으로 STALE·MISSING 비율 집계 후 Logger 출력.
  // STALE 비율 > 50%이면 다음 실행 시 캐시 전체 강제 갱신을 위해 경고를 settings에 기록.
  {
    const psIdx_ = headers.indexOf("Price_Status");
    const dsIdx_ = headers.indexOf("DART_Status");
    let priceOk_ = 0, priceStale_ = 0, priceMissing_ = 0;
    rows.forEach(row => {
      const ps = String(row[psIdx_] ?? "");
      if (ps === "PRICE_OK") priceOk_++;
      else if (ps === "PRICE_STALE") priceStale_++;
      else priceMissing_++;
    });
    const stalePct_ = rows.length > 0 ? Math.round(priceStale_ / rows.length * 100) : 0;
    Logger.log(
      `[FETCH_DIAG] 총 ${rows.length}종목 | PRICE_OK=${priceOk_} PRICE_STALE=${priceStale_}(${stalePct_}%) MISSING=${priceMissing_}`
    );
    // STALE 과반수(>50%) — 다음 세션에서 캐시 전체 재수집 경고
    if (stalePct_ > 50) {
      upsertOperationalWarningSetting_(
        "data_freshness_warning",
        `[STALE_MAJORITY] price_stale=${stalePct_}% — 다음 runDataFeed 전 clearFetchCache() 실행 권장`
      );
      Logger.log("[FETCH_DIAG][WARN] STALE 과반수(" + stalePct_ + "%) — clearFetchCache() 자동 호출");
      try { clearFetchCache(); } catch (_) {}
    } else {
      upsertOperationalWarningSetting_("data_freshness_warning", "");
    }
  }

  writeToSheet("data_feed", headers, rows);
  Logger.log(`data_feed 완료: ${rows.length}종목`);

  // 버킷 스냅샷 저장 (runMacro → BUCKET_STATUS 행에 사용)
  _bucketSnapshot_ = {
    core_pct:      parseFloat(_coreTotalPct.toFixed(2)),
    satellite_pct: parseFloat(_satTotalPct.toFixed(2)),
    ts:            today,
  };

  // F4: account_snapshot trailing stop 일괄 갱신
  applyTrailingStopUpdates_();

  // [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록
  logDailyAssetHistory_(totalAssetKrw_, today);

  // 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다.
  if (!isRunAllOrchestrated_()) {
    runSectorFlow();
  }
}

/**
 * [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록한다.
 */
function logDailyAssetHistory_(totalAsset, todayStr) {
  try {
    if (!Number.isFinite(totalAsset) || totalAsset <= 0) {
      Logger.log("[MDD_GUARD] totalAsset이 유효하지 않아 일별 기록을 건너뜁니다.");
      return;
    }
    
    // daily_history 시트 획득 또는 생성
    var ss = getSpreadsheet_();
    var sheet = ss.getSheetByName("daily_history");
    if (!sheet) {
      sheet = ss.insertSheet("daily_history");
      // 헤더 작성
      sheet.appendRow(["Date", "Total_Asset_KRW", "Peak_Asset_KRW", "MDD_Pct"]);
      Logger.log("[MDD_GUARD] daily_history 시트를 신규 생성하고 헤더를 작성했습니다.");
    }
    
    // 기존 데이터 읽기
    var data = sheet.getDataRange().getValues();
    
    // 오늘 날짜가 이미 존재하는지 체크 (중복 기록 방지)
    var todayIndex = -1;
    for (var i = 1; i < data.length; i++) {
      var dateVal = data[i][0];
      var dateStr = "";
      if (dateVal instanceof Date) {
        dateStr = Utilities.formatDate(dateVal, "Asia/Seoul", "yyyy-MM-dd");
      } else {
        dateStr = String(dateVal).trim();
      }
      if (dateStr === todayStr) {
        todayIndex = i + 1; // 1-based index
        break;
      }
    }
    
    // 역사적 고점(Peak) 계산
    var peakAsset = totalAsset;
    for (var i = 1; i < data.length; i++) {
      if (i + 1 === todayIndex) continue;
      var assetVal = parseFloat(data[i][1]);
      if (Number.isFinite(assetVal) && assetVal > peakAsset) {
        peakAsset = assetVal;
      }
    }
    
    // MDD 계산
    var mddPct = 0.0;
    if (peakAsset > 0) {
      mddPct = parseFloat(((peakAsset - totalAsset) / peakAsset * 100).toFixed(2));
    }
    
    if (todayIndex > 0) {
      // 이미 오늘 날짜가 있으면 해당 행 업데이트
      sheet.getRange(todayIndex, 1, 1, 4).setValues([[todayStr, totalAsset, peakAsset, mddPct]]);
      Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 업데이트했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%");
    } else {
      // 없으면 새 행 추가
      sheet.appendRow([todayStr, totalAsset, peakAsset, mddPct]);
      Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 추가했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%");
    }
  } catch(e) {
    Logger.log("[MDD_GUARD] daily_history 기록 실패: " + e.message);
  }
}


