function ensureAccountSnapshotConfirmModeSetting_(settingsObj) {
  try {
    const settings = settingsObj || {};
    const raw = String(settings["account_snapshot_confirm_mode"] || "").trim();
    if (raw) return;
    const ss = getSpreadsheet_();
    const sh = ss.getSheetByName("settings");
    if (!sh) return;
    sh.appendRow(["account_snapshot_confirm_mode", "STRICT_Y", "STRICT_Y|AUTO_IF_PARSE_OK"]);
    settings["account_snapshot_confirm_mode"] = "STRICT_Y";
    Logger.log("[SETTINGS_DEFAULT] account_snapshot_confirm_mode=STRICT_Y");
  } catch (e) {
    Logger.log("[SETTINGS_DEFAULT][WARN] account_snapshot_confirm_mode 주입 실패: " + e.message);
  }
}

function upsertOperationalWarningSetting_(key, value) {
  try {
    const ss = getSpreadsheet_();
    const sh = ss.getSheetByName("settings");
    if (!sh) return;
    const data = sh.getDataRange().getValues();
    for (let i = 0; i < data.length; i++) {
      if (String(data[i][0] || "").trim() === key) {
        sh.getRange(i + 1, 2).setValue(value);
        return;
      }
    }
    sh.appendRow([key, value, "auto-generated operational warning"]);
  } catch (e) {
    Logger.log("[SETTINGS_WARNING][WARN] " + key + " 갱신 실패: " + e.message);
  }
}

// ── buildTickerRow_ sub-functions ──────────────────────────────────────────
function _tickerSetup_(t, preReads) {
  const {
    positionStopMap_, globalHeatPct_, globalKospiRet10D_, globalRegimePrelim_,
    sectorFlowData_, csRsPctMap_, riskBudget_, totalAssetKrw_,
    weeklyTargetCashPct_, bayesian, savedEpsRevision, today,
    positionCountStatus_,
  } = preReads;

  const isRiskOffRegime = globalRegimePrelim_ === "RISK_OFF" || globalRegimePrelim_ === "RISK_OFF_CANDIDATE";
  const heatBlock   = Number.isFinite(globalHeatPct_) && globalHeatPct_ >= 10;
  const heatCaution = Number.isFinite(globalHeatPct_) && globalHeatPct_ >= 7 && globalHeatPct_ < 10;

  const flow        = fetchNaverFlow(t.code);
  const price       = resolveDataFeedPriceMetrics(t.code);
  const valuation   = fetchNaverMarketMetrics(t.code);
  const consensus   = fetchNaverConsensusData(t.code);
  const notices     = fetchNaverDisclosureNotices(t.code);
  const dartSummary = summarizeDisclosureNotices(notices);
  const frg5   = flow.rows.slice(0,5).reduce((s,r) => s+r.frgn,  0);
  const inst5  = flow.rows.slice(0,5).reduce((s,r) => s+r.inst,  0);
  const frg20  = flow.rows.reduce((s,r) => s+r.frgn,  0);
  const inst20 = flow.rows.reduce((s,r) => s+r.inst,  0);
  const indiv5 = -(frg5+inst5);
  // priceStatus 4단계
  const priceStatus = !price.ok            ? "PRICE_MISSING"    :
                      price.isFallbackQuote ? "PRICE_QUOTE_ONLY" :
                      price.isPriceStale    ? "PRICE_STALE"      : "PRICE_OK";
  const flow5Status    = flow.ok ? `OK: ${frg5 > 0 ? "외국인 매수" : "외국인 매도"} / ${inst5 > 0 ? "기관 매수" : "기관 매도"}` : "DATA_MISSING";
  const flow20Status   = flow.ok ? "OK" : "DATA_MISSING";
  const ind5Status     = flow.ok ? "OK" : "DATA_MISSING";
  const valSurgeStatus  = calcValSurgeStatus(price.valSurge);
  const liquidityStatus = calcLiquidityStatus(Number(price.avgTradingValue5D));
  const spreadStatus    = calcSpreadStatus(Number(price.spreadPct));

  const missing = [];
  if (!flow.ok) missing.push("Flow5D/Flow20D");
  if (flow.ok && flow.isFlowStale) missing.push(`FLOW_STALE(${flow.rows[0]?.date ?? "?"})`);
  if (priceStatus === "PRICE_MISSING")    missing.push("ATR20/Val_Surge");
  if (priceStatus === "PRICE_QUOTE_ONLY") missing.push("PRICE_QUOTE_ONLY:MA/ATR결측");
  if (priceStatus === "PRICE_STALE")      missing.push(`PRICE_STALE(${price.priceDate})`);
  if (dartSummary.status === "NAVER_NOTICE_EMPTY" || String(dartSummary.status).startsWith("NAVER_NOTICE_ERROR")) missing.push("DART");
  if (heatBlock)   missing.push(`HF005:HEAT_BLOCK(${globalHeatPct_}%)`);
  if (heatCaution) missing.push(`HEAT_CAUTION(${globalHeatPct_}%→수량50%감액)`);
  if (isRiskOffRegime) missing.push(`REGIME_BLOCK(${globalRegimePrelim_})`);
  if (globalHeatPct_ === null) missing.push("TOTAL_HEAT_UNKNOWN");
  const next = [];
  if (priceStatus === "PRICE_MISSING" || priceStatus === "PRICE_QUOTE_ONLY") next.push("Yahoo Finance chart");
  if (missing.includes("DART")) next.push("Naver 공시공지");
  if (missing.includes("Flow5D/Flow20D")) next.push("Naver frgn.naver");

  const perfBias = calcPerformanceBuyBias_(bayesian);
  const posRec   = positionStopMap_[t.code];

  return {
    t, preReads,
    flow, price, valuation, consensus, dartSummary,
    frg5, inst5, frg20, inst20, indiv5,
    priceStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus, liquidityStatus, spreadStatus,
    missing, next, isRiskOffRegime, heatBlock, heatCaution, perfBias, posRec,
    today, positionCountStatus_, weeklyTargetCashPct_,
  };
}

// ── Fundamentals: EPS, 52W, target price, dividends, financial health ────────
function _addTickerFundamentals_(ctx) {
  const { t, price, valuation, consensus, preReads } = ctx;
  const { savedEpsRevision, today } = preReads;

  // ── EPS_Revision_Status: Naver 우선, Yahoo 폴백, 기존값 최후 보존 ──────
  let epsRevisionStatus = "";
  if (consensus.ok && consensus.epsRevisionStatus !== "DATA_MISSING") {
    epsRevisionStatus = consensus.epsRevisionStatus;
  } else {
    const yahooConsensus = fetchYahooConsensusEps(t.code);
    if (yahooConsensus.ok && yahooConsensus.epsRevisionStatus !== "DATA_MISSING") {
      epsRevisionStatus = yahooConsensus.epsRevisionStatus;
    } else {
      epsRevisionStatus = savedEpsRevision[t.code] ?? "";
    }
  }

  // ── 배당수익률: Naver main(_dvr) 우선, Yahoo quote 폴백 ──────────────
  const naverDvr   = Number.isFinite(valuation.dvr) ? valuation.dvr : null;
  const yahooQuote = fetchYahooMarketMetrics(t.code);
  const divYield   = naverDvr ?? (Number.isFinite(yahooQuote.divYield) ? yahooQuote.divYield : "");

  // ── Beta: Yahoo quote 우선, quoteSummary 폴백 ─────────────────────────
  let beta = Number.isFinite(yahooQuote.beta) ? yahooQuote.beta : null;

  // ── 52주 고저가: Naver main 우선, Yahoo quote 폴백 ────────────────────
  const high52W  = Number.isFinite(valuation.high52W) ? valuation.high52W
                 : Number.isFinite(yahooQuote.high52W) ? yahooQuote.high52W : null;
  const low52W   = Number.isFinite(valuation.low52W)  ? valuation.low52W
                 : Number.isFinite(yahooQuote.low52W)  ? yahooQuote.low52W  : null;
  const closeVal = price.ok && Number.isFinite(price.close) ? price.close : null;
  const pct52WHigh    = Number.isFinite(high52W) && Number.isFinite(closeVal) && high52W > 0
    ? ((closeVal / high52W - 1) * 100).toFixed(1) : "";
  const pctFrom52WLow = Number.isFinite(low52W)  && Number.isFinite(closeVal) && low52W  > 0
    ? ((closeVal / low52W  - 1) * 100).toFixed(1) : "";

  // ── 목표주가: Naver consensus 우선, Yahoo quoteSummary 폴백 ──────────
  let targetPrice = Number.isFinite(consensus.targetPrice) && consensus.targetPrice > 0
    ? consensus.targetPrice : null;
  const yahooFin = fetchYahooTargetPrice(t.code);
  if (!targetPrice && yahooFin.ok && Number.isFinite(yahooFin.targetPrice) && yahooFin.targetPrice > 0) {
    targetPrice = yahooFin.targetPrice;
  }
  if (!beta && Number.isFinite(yahooFin.beta)) beta = yahooFin.beta;
  const upsidePct = Number.isFinite(targetPrice) && Number.isFinite(closeVal) && closeVal > 0
    ? ((targetPrice / closeVal - 1) * 100).toFixed(1) : "";

  // ── EPS 1년 성장률 ────────────────────────────────────────────────────
  const epsGrowth1y = Number.isFinite(consensus.epsGrowth1y) ? consensus.epsGrowth1y : null;

  // ── DPS ───────────────────────────────────────────────────────────────
  const dps = Number.isFinite(yahooFin.dividendPerShare) ? yahooFin.dividendPerShare : null;

  // ── 재무 건전성 7개 필드 (FINANCIAL_HEALTH_V1 + OCF_B + 7일 캐시 통합) ─────
  // ETF는 개별 재무제표가 없으므로 수집 스킵
  const isEtfTicker_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(t.name ?? "");
  let fundResult;
  if (isEtfTicker_) {
    Logger.log('[INFO][FUND_SKIP_ETF] ' + t.code + ' (' + (t.name ?? '') + ') — ETF, 펀더멘털 수집 불필요');
    fundResult = { ok: false, source: 'etf_no_fundamentals' };
  } else {
    fundResult = (typeof fetchFundamentalsWithCache_ === 'function')
      ? fetchFundamentalsWithCache_(t.code, t.code, yahooFin)
      : yahooFin;
  }
  const roePct           = fundResult.ok && Number.isFinite(fundResult.roePct)             ? fundResult.roePct             : null;
  const opMarginPct      = fundResult.ok && Number.isFinite(fundResult.operatingMarginPct) ? fundResult.operatingMarginPct : null;
  const debtToEquity     = fundResult.ok && Number.isFinite(fundResult.debtToEquity)       ? fundResult.debtToEquity       : null;
  const currentRatio     = fundResult.ok && Number.isFinite(fundResult.currentRatio)       ? fundResult.currentRatio       : null;
  const fcfB             = fundResult.ok && Number.isFinite(fundResult.fcfB)               ? fundResult.fcfB               : null;
  const ocfB             = fundResult.ok && Number.isFinite(fundResult.ocfB)               ? fundResult.ocfB               : null;
  const revenueGrowthPct = fundResult.ok && Number.isFinite(fundResult.revenueGrowthPct)   ? fundResult.revenueGrowthPct   : null;

  // ── 실적 발표일 → 잔여 일수 ───────────────────────────────────────────
  const earningsDateStr = yahooFin?.earningsDate ?? null;
  const tp      = today.split("-").map(Number);
  const todayMs = Date.UTC(tp[0], tp[1] - 1, tp[2]);
  let daysToEarnings = "";
  if (earningsDateStr) {
    const ep = earningsDateStr.split("-").map(Number);
    daysToEarnings = Math.round((Date.UTC(ep[0], ep[1]-1, ep[2]) - todayMs) / (1000*60*60*24));
  }

  // ── 배당락일 → 잔여 일수 (A4) ──────────────────────────────────────────
  const exDividendDateStr = yahooFin?.exDividendDate ?? null;
  let daysToExDiv = "";
  if (exDividendDateStr) {
    const xp = exDividendDateStr.split("-").map(Number);
    daysToExDiv = Math.round((Date.UTC(xp[0], xp[1]-1, xp[2]) - todayMs) / (1000*60*60*24));
  }

  Object.assign(ctx, {
    epsRevisionStatus, epsGrowth1y, divYield, dps, beta,
    high52W, low52W, pct52WHigh, pctFrom52WLow,
    targetPrice, upsidePct, earningsDateStr, daysToEarnings,
    exDividendDateStr, daysToExDiv,
    roePct, opMarginPct, debtToEquity, currentRatio, fcfB, ocfB, revenueGrowthPct,
  });
}

// ── [2026-05-21_BRT_HARNESS_V1] BRT/SAQG helpers ─────────────────────────
function calcBenchmarkRelativeTimeseries_(price, high52W, preReads) {
  const k5 = preReads.globalKospiRet5D_;
  const k20 = preReads.globalKospiRet20D_;
  const k60 = preReads.globalKospiRet60D_;
  const kDrawdown = preReads.globalKospiDrawdown_;
  const close = price && price.ok && Number.isFinite(price.close) ? price.close : null;
  const stockDrawdown = Number.isFinite(high52W) && Number.isFinite(close) && high52W > 0
    ? parseFloat((Math.max(0, (1 - close / high52W) * 100)).toFixed(2)) : null;
  const excessDrawdown = stockDrawdown !== null && Number.isFinite(kDrawdown)
    ? parseFloat((stockDrawdown - kDrawdown).toFixed(2)) : null;
  const ret5 = price && Number.isFinite(price.ret5D) ? price.ret5D : null;
  const ret20 = price && Number.isFinite(price.ret20D) ? price.ret20D : null;
  const ret60 = price && Number.isFinite(price.ret60D) ? price.ret60D : null;
  const rec5 = ret5 !== null && Number.isFinite(k5) && k5 > 0 ? parseFloat((ret5 / k5).toFixed(3)) : null;
  const rec20 = ret20 !== null && Number.isFinite(k20) && k20 > 0 ? parseFloat((ret20 / k20).toFixed(3)) : null;
  const downsideBeta = ret20 !== null && Number.isFinite(k20) && k20 < 0 ? parseFloat((ret20 / k20).toFixed(3)) : null;
  // [C-2] RS ratio slopes: change in RS ratio per day across windows
  // slope = (rsRatio_longWindow - rsRatio_shortWindow) / daysBetween
  // Positive = relative strength improving; negative = deteriorating
  const rsRatio5d  = (ret5  !== null && Number.isFinite(k5)  && k5  !== 0) ? ret5  / k5  : null;
  const rsRatio20d = (ret20 !== null && Number.isFinite(k20) && k20 !== 0) ? ret20 / k20 : null;
  const rsRatio60d = (ret60 !== null && Number.isFinite(k60) && k60 !== 0) ? ret60 / k60 : null;
  const slope20 = (rsRatio5d !== null && rsRatio20d !== null)
    ? parseFloat(((rsRatio20d - rsRatio5d) / 15).toFixed(4))
    : (ret20 !== null && Number.isFinite(k20) ? parseFloat(((ret20 - k20) / 20).toFixed(4)) : null);
  const slope60 = (rsRatio20d !== null && rsRatio60d !== null)
    ? parseFloat(((rsRatio60d - rsRatio20d) / 40).toFixed(4))
    : (ret60 !== null && Number.isFinite(k60) ? parseFloat(((ret60 - k60) / 60).toFixed(4)) : null);
  const brtMethod = (rsRatio5d !== null && rsRatio20d !== null)
    ? "RS_RATIO_MULTI_WINDOW_PROXY" : "PROXY_FROM_RET20_RET60";

  let verdict = "UNKNOWN";
  if (excessDrawdown !== null && rec20 !== null && slope20 !== null) {
    if (excessDrawdown >= 10 && (rec20 < 0.50 || (slope60 !== null && slope60 < 0))) verdict = "BROKEN";
    else if (excessDrawdown <= 0 && rec20 >= 1.20 && slope20 > 0) verdict = "LEADER";
    else if (excessDrawdown >= 5 || rec20 < 0.80 || slope20 < 0) verdict = "LAGGARD";
    else verdict = "MARKET";
  }
  return {
    stock_drawdown_from_high_pct: stockDrawdown,
    excess_drawdown_pctp: excessDrawdown,
    recovery_ratio_5d: rec5,
    recovery_ratio_20d: rec20,
    downside_beta: downsideBeta,
    rs_ratio_5d:  rsRatio5d,
    rs_ratio_20d: rsRatio20d,
    rs_ratio_60d: rsRatio60d,
    rs_line_20d_slope: slope20,
    rs_line_60d_slope: slope60,
    brt_verdict: verdict,
    brt_method: brtMethod,
  };
}

function fuseRsVerdictV2_(rsV1, brtVerdict) {
  const v1 = rsV1 || "UNKNOWN";
  const brt = brtVerdict || "UNKNOWN";
  if (brt === "BROKEN" && v1 === "LEADER") return "LAGGARD";
  if (v1 === "BROKEN" || brt === "BROKEN") return "BROKEN";
  if (brt === "LEADER" && v1 === "LAGGARD") return "MARKET";
  if (v1 === "LAGGARD" || brt === "LAGGARD") return "LAGGARD";
  if (v1 === "LEADER" && brt === "LEADER") return "LEADER";
  if (v1 === "UNKNOWN" && brt === "UNKNOWN") return "UNKNOWN";
  return "MARKET";
}

function calcSatelliteAlphaQualityGate_(args) {
  if (args.position_type === "core") {
    return { saqg_v1: "EXEMPT", saqg_penalty: 0, saqg_failed_filters: "" };
  }
  if (args.ss001_grade === "D" || args.rs_verdict === "BROKEN") {
    return { saqg_v1: "EXCLUDED", saqg_penalty: 99, saqg_failed_filters: args.ss001_grade === "D" ? "D_GRADE" : "RS_BROKEN" };
  }
  const failed = [];
  let penalty = 0;
  const coreFailures = [];
  if (!(Number.isFinite(args.ret20D) && Number.isFinite(args.kospiRet20D) && args.ret20D > args.kospiRet20D)) {
    failed.push("F1_relative_return"); coreFailures.push("F1"); penalty += 2;
  }
  if (!((Number.isFinite(args.recovery_ratio_20d) && args.recovery_ratio_20d >= 1.20)
        || (Number.isFinite(args.recovery_ratio_5d) && args.recovery_ratio_5d >= 1.30))) {
    failed.push("F2_recovery_power"); coreFailures.push("F2"); penalty += 2;
  }
  if (!(Number.isFinite(args.excess_drawdown_pctp) && args.excess_drawdown_pctp <= 5)) {
    failed.push("F3_downside_protection"); coreFailures.push("F3"); penalty += 2;
  }
  if (!(Number.isFinite(args.frg5) && args.frg5 > 0 || Number.isFinite(args.inst5) && args.inst5 > 0)) {
    failed.push("F4_institutional_flow"); penalty += 1;
  }
  if (!["LEADER", "MARKET"].includes(args.rs_verdict)) {
    failed.push("F5_sector_leadership"); penalty += 1;
  }
  let status = "ELIGIBLE";
  if (penalty >= 3 || coreFailures.length >= 2) status = "EXCLUDED";
  else if (penalty > 0) status = "WATCHLIST_ONLY";
  return { saqg_v1: status, saqg_penalty: penalty, saqg_failed_filters: failed.join("|") };
}

// ── Gates & scores: entry sizing, breakout/anti-climax/leader/RW gates,
//    FLOW_CREDIT, SS001, TP ladder, position monitoring, F4 trailing stop ────
function _addTickerGates_(ctx, trailingStopUpdates) {
  const { t, price, flow, posRec, preReads,
          targetPrice, epsRevisionStatus, epsGrowth1y, valuation,
          frg5, inst5, frg20, inst20, indiv5, heatCaution, high52W } = ctx;
  const { positionStopMap_, riskBudget_, totalAssetKrw_, bayesian,
          globalRegimePrelim_, globalKospiRet10D_, globalKospiRet20D_, csRsPctMap_, sectorFlowData_,
          globalHeatPct_ } = preReads;

  // ── 진입가·손절가·기대우위 추정 (Bayesian multiplier) ─────────────────
  const limitPriceEst = price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)
    ? Math.round(price.close + price.atr20 * 0.05) : "";
  const stopPriceActual = posRec ? posRec.stop_price : null;
  const stopPriceEst = stopPriceActual != null
    ? stopPriceActual
    : (price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)
        ? Math.round(Math.max(price.close * 0.92, price.close - price.atr20 * THRESHOLDS.ATR_STOP_MULT)) : "");
  const stopPriceSource = stopPriceActual != null ? "account_snapshot" : "ATR추정";
  let eeEst = "";
  if (bayesian.bayesian_multiplier > 0
      && limitPriceEst !== "" && stopPriceEst !== "" && limitPriceEst > stopPriceEst
      && Number.isFinite(targetPrice) && targetPrice > limitPriceEst) {
    eeEst = ((targetPrice - limitPriceEst) / (limitPriceEst - stopPriceEst) * bayesian.bayesian_multiplier - 0.003).toFixed(2);
  } else if (bayesian.bayesian_multiplier === 0) {
    eeEst = "0 (no_bet)";
  }

  // ── Pos_Size_Qty 추정: POSITION_SIZE_V1 간략 버전 ─────────────────────
  let posSizeQty = "";
  let posConstraint = "";
  if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0
      && price.ok && Number.isFinite(price.atr20) && price.atr20 > 0
      && Number.isFinite(price.close) && price.close > 0
      && bayesian.bayesian_multiplier > 0) {
    const atrQty    = Math.floor(totalAssetKrw_ * riskBudget_ * bayesian.bayesian_multiplier / (price.atr20 * THRESHOLDS.ATR_STOP_MULT));
    const weightQty = Math.floor(totalAssetKrw_ * 0.05 / price.close);
    let rawQty = Math.max(0, Math.min(atrQty, weightQty));
    const bindingLabel = atrQty <= weightQty ? `ATR(${atrQty}주)` : `Weight(${weightQty}주)`;
    if (heatCaution && rawQty > 0) {
      rawQty = Math.max(1, Math.floor(rawQty * 0.5));
      posConstraint = `${bindingLabel}→Heat감액(${globalHeatPct_}%)`;
    } else {
      posConstraint = bindingLabel;
    }
    posSizeQty = rawQty;
  }

  // ── Breakout Pilot Score ───────────────────────────────────────────────
  const priceDev = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && price.ma20 > 0
    ? (price.close / price.ma20 - 1) * 100 : null;
  const vsTerm   = price.ok && Number.isFinite(price.valSurge) ? price.valSurge / 10 : 0;
  const netFlowRatio = flow.ok && Number.isFinite(price.avgVolume5D) && price.avgVolume5D > 0
    ? Math.min(5, Math.max(-5, (frg5 + inst5) / price.avgVolume5D)) : 0;
  const breakoutScore = Number.isFinite(priceDev) ? parseFloat((priceDev + vsTerm + netFlowRatio).toFixed(1)) : "";
  const breakoutGate  = breakoutScore !== "" ? (breakoutScore > 15 ? "ALLOW" : "WAIT") : "";

  // ── anti_climax_buy_gate S1~S5 ────────────────────────────────────────
  const ret5Dval = price.ok && Number.isFinite(parseFloat(price.ret5D)) ? parseFloat(price.ret5D) : null;
  const ac_s1 = Number.isFinite(ret5Dval) ? (ret5Dval >= 25 ? 1 : 0) : 0;
  const ac_s2 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0
    ? (price.avgTradingValue5D >= price.avgTradingValue20D * 3.0 ? 1 : 0) : 0;
  const hlRange = price.ok && Number.isFinite(price.high) && Number.isFinite(price.low) ? price.high - price.low : 0;
  const ac_s3 = price.ok && Number.isFinite(price.high) && Number.isFinite(price.close) && hlRange > 0
    ? ((price.high - price.close) / hlRange >= 0.35 ? 1 : 0) : 0;
  const ac_s4 = flow.ok && frg5 < 0 && inst5 < 0 ? 1 : 0;
  const ac_s5 = flow.ok && indiv5 > 0 && (frg5 < 0 || inst5 < 0) ? 1 : 0;
  const ac_total = ac_s1 + ac_s2 + ac_s3 + ac_s4 + ac_s5;
  const ac_gate  = ac_total >= 3 ? "BLOCK" : ac_total === 2 ? "CAUTION" : "CLEAR";

  // ── daily_leader_scan C1~C5 자동 계산 ─────────────────────────────────
  const c1 = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && Number.isFinite(price.high) && Number.isFinite(price.low)
    ? (price.close >= price.ma20 && price.close >= (price.high - (price.high - price.low) * 0.3) ? 1 : 0) : 0;
  const ret10DKospi = globalKospiRet10D_ ?? null;
  const c2 = price.ok && Number.isFinite(price.ret10D) && Number.isFinite(ret10DKospi)
    ? ((price.ret10D - ret10DKospi) >= 3 ? 1 : 0) : 0;
  const c3 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0
    ? (price.avgTradingValue5D >= price.avgTradingValue20D * 1.5 ? 1 : 0) : 0;
  const c4 = flow.ok && (frg5 > 0 || inst5 > 0) ? 1 : 0;
  // C5: Tier_1 + Rotation_Rank<=3 -> 1.0 / Tier_1+rank>3 or Tier_2 -> 0.5 / Tier_3 -> 0
  const tickerSector = TICKER_SECTOR_MAP[t.code] ?? null;
  const sfSector     = tickerSector ? (sectorFlowData_[tickerSector] ?? null) : null;
  const sectorRank   = sfSector?.rank ?? null;
  const sectorTier   = tickerSector ? (SECTOR_TIER_MAP[tickerSector] ?? "Tier_3") : "Tier_3";
  let c5;
  if (sectorTier === "Tier_1") {
    c5 = sectorRank !== null ? (sectorRank <= 3 ? 1.0 : 0.5) : 0.5;
  } else if (sectorTier === "Tier_2") {
    c5 = 0.5;
  } else {
    c5 = 0;
  }
  const leaderTotal = c1 + c2 + c3 + c4 + c5;
  const leaderGate  = leaderTotal >= 4 ? "EXPLORE_CANDIDATE" : leaderTotal >= 3 ? "WATCH_ONLY" : "BELOW_THRESHOLD";

  // ── 상대약세 청산 신호 RW1~RW5 자동 계산 ───────────────────────────────
  const etfRet10D = sfSector?.etfRet10D ?? null;
  const rw1 = sfSector?.rw1 ?? 0;
  const rw2 = price.ok && Number.isFinite(price.ret10D) && Number.isFinite(etfRet10D)
    ? ((price.ret10D - etfRet10D) <= -5 ? 1 : 0) : 0;
  const rw3 = sfSector?.rw3 ?? 0;
  const rw4 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0
    ? (price.avgTradingValue5D / price.avgTradingValue20D <= 0.60 ? 1 : 0) : 0;
  const rw5 = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && Number.isFinite(price.ma60)
    ? (price.close < price.ma20 && price.close < price.ma60 ? 1 : 0) : 0;
  const rw_partial = rw1 + rw2 + rw3 + rw4 + rw5;

  // ── FLOW_CREDIT_V1 ────────────────────────────────────────────────────
  const fc_c1 = price.ok && Number.isFinite(price.close) &&
    ((Number.isFinite(price.open) && price.close >= price.open) ||
     (Number.isFinite(price.prevClose) && price.close > price.prevClose)) ? 1 : 0;
  const fc_c2 = price.ok && Number.isFinite(price.volume) && Number.isFinite(price.avgVolume5D) && price.avgVolume5D > 0
    ? (price.volume >= price.avgVolume5D * 1.20 ? 1 : 0) : 0;
  const fc_c3 = flow.ok && (frg5 + inst5) > 0 ? 1 : 0;
  const flowCredit = (fc_c1 === 0 && fc_c2 === 0) ? 0
    : parseFloat((fc_c1 * 0.30 + fc_c2 * 0.30 + fc_c3 * 0.40).toFixed(2));

  // ── TRAILING_STOP_PRICE_V1 (포지션 탭 highest_price_since_entry 기반) ──
  const posHighest = positionStopMap_[t.code]?.highest_price ?? null;
  let trailingStopPrice = "";
  if (Number.isFinite(posHighest) && posHighest > 0 && price.ok && Number.isFinite(price.atr20)) {
    trailingStopPrice = Math.round(posHighest - price.atr20 * THRESHOLDS.ATR_STOP_MULT);
  }

  // ── SS001 종목 점수 자동 계산 ─────────────────────────────────────────
  const ss001 = calcSS001Score_({
    rsPct20D:     csRsPctMap_[t.code] ?? null,
    avgTV5D:      price.avgTradingValue5D,
    avgTV20D:     price.avgTradingValue20D,
    flowCredit,
    epsRevisionStatus,
    regimePrelim: globalRegimePrelim_,
    isKosdaq:     KOSDAQ_TICKERS.has(t.code),
    sfMedPE:      sfSector?.medianPE  ?? null,
    sfMedPBR:     sfSector?.medianPBR ?? null,
    forwardPE:    Number.isFinite(valuation.per) ? valuation.per : null,
    pbrVal:       Number.isFinite(valuation.pbr) ? valuation.pbr : null,
    epsGrowth1y,
  });
  const { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
          ss001_total, ss001_norm, ss001_grade, pegVal, pegGate } = ss001;

  // ── BENCHMARK_RELATIVE_TIMESERIES_V1 — KOSPI 대비 시계열 상대평가 ──────────
  const brt = calcBenchmarkRelativeTimeseries_(price, high52W, preReads);

  // ── RS_VERDICT_V1 → RS_VERDICT_V2 — 상대강도 판정 (spec/13_formula_registry.yaml) ──
  const kospiRet10DForRS = globalKospiRet10D_ ?? null;
  const stockRet10DForRS = price.ok && Number.isFinite(price.ret10D) ? price.ret10D : null;
  const excess_ret_10d = (stockRet10DForRS !== null && kospiRet10DForRS !== null)
    ? parseFloat((stockRet10DForRS - kospiRet10DForRS).toFixed(2)) : null;

  let rs_verdict_v1_raw;
  if (excess_ret_10d === null) {
    rs_verdict_v1_raw = "UNKNOWN";
  } else if (excess_ret_10d < -10 && rw_partial >= 3) {
    rs_verdict_v1_raw = "BROKEN";
  } else if (excess_ret_10d < -3 || (excess_ret_10d < 0 && rw_partial >= 3)) {
    rs_verdict_v1_raw = "LAGGARD";
  } else if (excess_ret_10d > 3 && flowCredit >= 0.6) {
    rs_verdict_v1_raw = "LEADER";
  } else {
    rs_verdict_v1_raw = "MARKET";
  }
  const rs_verdict = fuseRsVerdictV2_(rs_verdict_v1_raw, brt.brt_verdict);

  // ── COMPOSITE_VERDICT_V1 — SS001 × RS_VERDICT 매트릭스 ───────────────────
  const _cvMatrix = {
    A: { LEADER: "PRIME_CANDIDATE",   MARKET: "PRIME_CANDIDATE",
         LAGGARD: "WATCH_CANDIDATE",  BROKEN: "EXIT_REVIEW",    UNKNOWN: "WATCH_CANDIDATE" },
    B: { LEADER: "PRIME_CANDIDATE",   MARKET: "WATCH_CANDIDATE",
         LAGGARD: "REDUCE_CANDIDATE", BROKEN: "EXIT_REVIEW",    UNKNOWN: "WATCH_CANDIDATE" },
    C: { LEADER: "WATCH_CANDIDATE",   MARKET: "REDUCE_CANDIDATE",
         LAGGARD: "REDUCE_CANDIDATE", BROKEN: "CLOSE_POSITION", UNKNOWN: "REDUCE_CANDIDATE" },
    D: { LEADER: "REDUCE_CANDIDATE",  MARKET: "REDUCE_CANDIDATE",
         LAGGARD: "CLOSE_POSITION",   BROKEN: "CLOSE_POSITION", UNKNOWN: "REDUCE_CANDIDATE" },
  };
  const composite_verdict = _cvMatrix[ss001_grade]?.[rs_verdict] ?? "WATCH_CANDIDATE";

  const saqg = calcSatelliteAlphaQualityGate_({
    position_type: posRec?.position_type ?? "satellite",
    ss001_grade,
    ret20D: price.ok && Number.isFinite(price.ret20D) ? price.ret20D : null,
    kospiRet20D: globalKospiRet20D_,
    recovery_ratio_5d: brt.recovery_ratio_5d,
    recovery_ratio_20d: brt.recovery_ratio_20d,
    excess_drawdown_pctp: brt.excess_drawdown_pctp,
    frg5, inst5,
    rs_verdict,
  });

  // ── TAKE_PROFIT_LADDER_V1 ─────────────────────────────────────────────
  let tp1Price = "", tp1Qty = "", tp2Price = "", tp2Qty = "";
  let timeStopDate = "", daysToTimeStop = "";
  if (posRec && Number.isFinite(posRec.entry_price) && Number.isFinite(posRec.quantity) && posRec.quantity > 0) {
    const avgCost = posRec.entry_price;
    const heldQty = posRec.quantity;
    if (posRec.position_type === "core") {
      const q1 = Math.floor(heldQty * 0.25);
      const q2 = Math.floor((heldQty - q1) * 0.40);
      tp1Price = Math.round(avgCost * THRESHOLDS.TP_CORE_1);  tp1Qty = q1;
      tp2Price = Math.round(avgCost * THRESHOLDS.TP_CORE_2);  tp2Qty = q2;
    } else {
      const q1 = Math.floor(heldQty * 0.50);
      const q2 = Math.floor((heldQty - q1) * 0.50);
      tp1Price = Math.round(avgCost * THRESHOLDS.TP_SAT_1);  tp1Qty = q1;
      tp2Price = Math.round(avgCost * THRESHOLDS.TP_SAT_2);  tp2Qty = q2;
    }
    // Time_Stop: stage_1=60D, stage_2=30D
    const stageLimit = posRec.entry_stage === "stage_2" ? THRESHOLDS.TIME_STOP_STAGE2 : THRESHOLDS.TIME_STOP_STAGE1; // 기본 60일
    if (posRec.entry_date) {
      try {
        const entryMs = new Date(posRec.entry_date).getTime();
        if (!isNaN(entryMs)) {
          const tsMs  = entryMs + stageLimit * 86400000;
          timeStopDate   = Utilities.formatDate(new Date(tsMs), "Asia/Seoul", "yyyy-MM-dd");
          daysToTimeStop = Math.round((tsMs - Date.now()) / 86400000);
        }
      } catch(_) {}
    }
  }

  // ── 포지션 모니터링 (Weight_Pct / Profit_Pct / PnL / Stage2_Gate / Band_Status) ──
  let weightPct = "", profitPct = "", unrealizedPnl = "", stage2Gate = "", bandStatus = "";
  let corePctDelta = 0, satPctDelta = 0;
  if (posRec && Number.isFinite(posRec.entry_price) && Number.isFinite(posRec.quantity) && posRec.quantity > 0) {
    const ep  = posRec.entry_price;
    const qty = posRec.quantity;
    const cl  = price.ok && Number.isFinite(price.close) ? price.close : null;
    if (cl !== null && Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) {
      weightPct = parseFloat(((cl * qty) / totalAssetKrw_ * 100).toFixed(2));
    }
    if (cl !== null) {
      profitPct     = parseFloat(((cl - ep) / ep * 100).toFixed(2));
      unrealizedPnl = Math.round((cl - ep) * qty);
    }
    if (posRec.entry_stage === "stage_1" && cl !== null) {
      stage2Gate = ((cl - ep) / ep * 100 >= THRESHOLDS.STAGE2_GATE_MIN_PCT) ? "PASS" : "PENDING";
    } else if (posRec.entry_stage) {
      stage2Gate = "N/A";
    }
    if (weightPct !== "" && posRec.position_type !== "core") {
      bandStatus = weightPct > THRESHOLDS.SAT_BAND_MAX ? "OVERWEIGHT" : weightPct >= 3 ? "IN_BAND" : "UNDERWEIGHT";
    } else if (weightPct !== "") {
      bandStatus = "CORE_" + (weightPct > 10 ? "HIGH" : weightPct >= 3 ? "MID" : "LOW");
    }
    if (weightPct !== "") {
      if (posRec.position_type === "core") corePctDelta = weightPct;
      else satPctDelta = weightPct;
    }
  }

  // ── F4 Trailing Stop 갱신 대기열 ─────────────────────────────────────
  if (posRec && posRec.quantity > 0 && price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)) {
    const curHigh    = Number.isFinite(posRec.highest_price) ? posRec.highest_price : 0;
    const curStop    = Number.isFinite(posRec.stop_price)    ? posRec.stop_price    : 0;
    const entryPrice = Number.isFinite(posRec.entry_price)   ? posRec.entry_price   : 0;
    if (price.close > curHigh) {
      const newStop = parseFloat((price.close - price.atr20 * THRESHOLDS.ATR_STOP_MULT).toFixed(0));
      // PS002: 손절선 단조 상승 보장
      if (newStop > curStop && (entryPrice <= 0 || newStop < entryPrice)) {
        trailingStopUpdates.push({ ticker: t.code, new_highest: price.close, new_stop: newStop });
      }
    }
  }

  Object.assign(ctx, {
    limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint,
    breakoutScore, breakoutGate,
    ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate,
    c1, c2, c3, c4, c5, leaderTotal, leaderGate,
    rw1, rw2, rw3, rw4, rw5, rw_partial, flowCredit,
    trailingStopPrice,
    ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
    ss001_total, ss001_norm, ss001_grade, pegVal, pegGate,
    excess_ret_10d, rs_verdict, composite_verdict,
    tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop,
    weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus,
    corePctDelta, satPctDelta,
  });
}

// ── Decision: F1-F3 timing, sell decision, allowed/final action, reason/params
function _addTickerRoute_(ctx) {
  const { t, price, flow, dartSummary, posRec, preReads,
          priceStatus, isRiskOffRegime, heatBlock, heatCaution, perfBias,
          liquidityStatus, spreadStatus,
          stopPriceEst, trailingStopPrice, tp1Price, tp1Qty, tp2Price,
          profitPct, daysToTimeStop, ac_gate, rw_partial, rw1, rw2, rw3, rw4, rw5,
          flowCredit, leaderTotal, leaderGate, ss001_grade, ss001_norm, ss001_total,
          rs_verdict, excess_ret_10d,
          weightPct, posSizeQty } = ctx;
  const brt = ctx.brt || { brt_verdict: "UNKNOWN", brt_method: "DATA_MISSING" };
  const saqg = ctx.saqg || { saqg_v1: "EXEMPT" };
  const { globalRegimePrelim_, globalHeatPct_ } = preReads;

  // ── F1 기술적 타이밍 지표 ────────────────────────────────────────────
  const timing = (price.ok && Array.isArray(price.rows) && price.rows.length >= 21)
    ? calcTimingMetrics_(price.rows) : {};
  const ma20Slope  = timing.ma20Slope  ?? "";
  const disparity  = timing.disparity  ?? "";
  const rsi14      = timing.rsi14      ?? "";
  const bbWidth    = timing.bbWidth    ?? "";
  const bbPosition = timing.bbPosition ?? "";
  const bbUpper    = timing.bbUpper    ?? "";
  const bbLower    = timing.bbLower    ?? "";
  const cashFloorStatus_ = Number.isFinite(globalHeatPct_)
    ? (globalHeatPct_ >= 10 ? "HARD_BLOCK" : globalHeatPct_ >= 7 ? "TRIM_REQUIRED" : "PASS") : "PASS";

  // ── F2 진입 모드 게이트 ──────────────────────────────────────────────
  const entryModeResult = (price.ok && Object.keys(timing).length)
    ? calcEntryMode_(timing, price) : { mode: "NEUTRAL", gate: "PENDING", reason: "데이터부족" };
  const entryMode       = entryModeResult.mode;
  const entryModeGate   = entryModeResult.gate;
  const entryModeReason = entryModeResult.reason;

  // ── F3 매도 타이밍 신호 ──────────────────────────────────────────────
  const exitSignalDetail = (posRec && posRec.quantity > 0 && price.ok && Object.keys(timing).length)
    ? calcExitSignalDetail_(timing, price) : "";

  const timingRoute = calcTimingRoute_({
    priceStatus, atr20: price.atr20, flowCredit, leaderTotal, leaderGate,
    acGate: ac_gate, rwPartial: rw_partial, entryMode, entryModeGate, exitSignalDetail,
    rsi14, disparity, ma20Slope, spreadPct: price.spreadPct,
    avgTradeValue5D: price.avgTradingValue5D, profitPct, daysToTimeStop,
  });
  const timingScoreEntry  = timingRoute.entry_score;
  const timingScoreExit   = timingRoute.exit_score;
  const timingAction      = timingRoute.action;
  const timingBlockReason = timingRoute.reason;

  const isEtf_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(t.name ?? "");
  const sellRoute = calcSellRoute_({
    close: price.close, stopPrice: stopPriceEst, trailingStop: trailingStopPrice,
    tp1Price, tp2Price, profitPct, rwPartial: rw_partial,
    timingExitScore: timingScoreExit, daysToTimeStop, timingAction, exitSignalDetail,
    acGate: ac_gate, regime: globalRegimePrelim_ ?? "", atr20: price.atr20,
    cashFloorStatus: cashFloorStatus_,
    liquidityStatus: String(price.liquidityStatus ?? price.Liquidity_Status ?? ""),
    spreadStatus: String(price.spreadStatus ?? price.Spread_Status ?? ""),
    accountType: posRec?.account_type ?? "",
    isCoreLeader: indexOfArr_(CORE_TICKERS, t.code) >= 0,
    isEtf: isEtf_,
  });
  const cashPreservePlan = calcCashPreservationPlan_({
    sellAction: sellRoute.action, cashFloorStatus: cashFloorStatus_,
    regime: globalRegimePrelim_ ?? "",
    isCoreLeader: indexOfArr_(CORE_TICKERS, t.code) >= 0,
    isEtf: isEtf_,
    liquidityStatus: String(price.liquidityStatus ?? price.Liquidity_Status ?? ""),
    spreadStatus: String(price.spreadStatus ?? price.Spread_Status ?? ""),
    accountType: posRec?.account_type ?? "",
    profitPct, rwPartial: rw_partial, reboundHoldbackScore: 0,
  });
  let sellAction   = sellRoute.action;
  let sellRatioPct = sellRoute.ratio_pct;
  if (sellAction !== "EXIT_100" && sellAction !== "TRAILING_STOP_BREACH"
      && Number.isFinite(cashPreservePlan.recommended_ratio)
      && cashPreservePlan.recommended_ratio > 0
      && cashPreservePlan.recommended_ratio < sellRatioPct) {
    sellRatioPct = cashPreservePlan.recommended_ratio;
    if (sellRatioPct <= 25) sellAction = "TRIM_25";
    else if (sellRatioPct <= 33) sellAction = "TRIM_33";
    else sellAction = "TRIM_50";
    sellRoute.action     = sellAction;
    sellRoute.ratio_pct  = sellRatioPct;
    sellRoute.reason     = `${sellRoute.reason}|CASH_PRESERVE:${cashPreservePlan.style}`;
  }
  const sellLimitPrice      = sellRoute.limit_price;
  const sellPriceSource     = sellRoute.price_source;
  const sellPriceBasis      = sellRoute.price_basis;
  const sellExecutionWindow = sellRoute.execution_window;
  const sellOrderType       = sellRoute.order_type;
  const sellReason          = sellRoute.reason;
  const sellValidation      = sellRoute.validation;
  const accountHoldingQty   = Number.isFinite(posRec?.account_quantity)     ? posRec.account_quantity     : "";
  const accountAvgCost      = Number.isFinite(posRec?.account_average_cost) ? posRec.account_average_cost : "";
  const accountMarketValue  = Number.isFinite(posRec?.account_market_value) ? posRec.account_market_value : "";
  const accountParseStatus  = posRec?.account_parse_status ?? "";

  // account_snapshot CAPTURE_READ_OK 시 Sell_Qty 직접 산출
  const sellQtyCalc = (() => {
    if (accountParseStatus !== "CAPTURE_READ_OK") return "";
    const heldQty = Number.isFinite(posRec?.account_quantity) ? posRec.account_quantity : 0;
    if (heldQty <= 0 || !sellRatioPct || sellAction === "HOLD") return "";
    const availQty = Number.isFinite(posRec?.available_quantity) && posRec.available_quantity > 0
      ? posRec.available_quantity : heldQty;
    return Math.max(1, Math.min(Math.round(heldQty * sellRatioPct / 100), availQty));
  })();
  const ruleSellQty = (() => {
    const heldQty = Number.isFinite(accountHoldingQty) ? accountHoldingQty : 0;
    if (heldQty <= 0 || !sellRatioPct) return "";
    const calc = Math.floor(heldQty * sellRatioPct / 100);
    return calc > 0 ? calc : "";
  })();

  // ── Allowed_Action ───────────────────────────────────────────────────
  // 우선순위: 데이터 품질 → HF005 → DART → 유동성 → REGIME매수차단 → RW청산 → SS001
  let action;
  if (priceStatus !== "PRICE_OK" || !Number.isFinite(price.atr20)) {
    action = "OBSERVE_ONLY";
  } else if (heatBlock) {
    action = posRec?.quantity > 0 ? "HOLD" : "NO_ADD";
  } else if (dartSummary?.risk) {
    action = "HOLD_NO_ADD";
  } else if (!flow.ok
             || (Number.isFinite(price.avgTradingValue5D) && price.avgTradingValue5D < 50)
             || (Number.isFinite(price.spreadPct) && price.spreadPct > 0.8)) {
    action = "NO_ADD";
  } else if (timingAction === "STOP_OR_TIME_EXIT_READY" || rw_partial >= 3) {
    action = "EXIT_SIGNAL";
  } else if (timingAction === "EXIT_REVIEW" || rw_partial >= 2) {
    action = "REVIEW_EXIT";
  } else if (timingAction === "NO_BUY_OVERHEATED") {
    action = "HOLD_NO_ADD";
  } else if (isRiskOffRegime) {
    // Issue 3: RISK_OFF/RISK_OFF_CANDIDATE 레짐에서 신규 매수 차단
    action = posRec?.quantity > 0 ? "HOLD" : "NO_ADD";
  } else if (saqg.saqg_v1 === "EXCLUDED") {
    action = "HOLD_NO_ADD";
  } else if (saqg.saqg_v1 === "WATCHLIST_ONLY") {
    action = "WATCH_CANDIDATE";
  } else if (ss001_grade === "A" && (timingAction === "BUY_STAGE1_READY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY")) {
    action = (heatCaution || perfBias.entry_block || perfBias.quantity_multiplier <= 0)
      ? "WATCH_CANDIDATE" : timingAction;
  } else if (ss001_grade === "A") {
    action = "WATCH_CANDIDATE";
  } else if (ss001_grade === "B" && timingAction !== "HOLD_NO_TIMING_EDGE") {
    action = (heatCaution || perfBias.entry_block || perfBias.quantity_multiplier <= 0)
      ? "WATCH_CANDIDATE"
      : (timingAction === "WATCH_TIMING_SETUP" ? "WATCH_CANDIDATE" : timingAction);
  } else if (ss001_grade === "B") {
    action = "WATCH_CANDIDATE";
  } else if (ss001_grade === "C") {
    action = "HOLD";
  } else {
    action = "HOLD_NO_ADD";
  }

  // ── RAG_V1: CLA 레짐 위성 신규 BUY 알파 게이트 ─────────────────────────
  const _BUY_ACTIONS_ = new Set(["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY", "BUY_PULLBACK_WAIT"]);
  let rag_v1 = "EXEMPT", rag_reason = "no_buy_action";
  if (_BUY_ACTIONS_.has(action)) {
    const _rag = validateReplacementAlpha_({
      posRec, globalRegimePrelim_,
      rs_verdict, ss001_norm, excess_ret_10d,
    });
    rag_v1     = _rag.rag_v1;
    rag_reason = _rag.rag_reason;
    if (rag_v1 === 'FAIL') action = "HOLD";
  }
  if (saqg.saqg_v1 === "EXCLUDED" || saqg.saqg_v1 === "WATCHLIST_ONLY") {
    rag_reason = rag_reason === "no_buy_action" ? ("saqg_" + saqg.saqg_v1) : rag_reason + "|saqg_" + saqg.saqg_v1;
  }

  const finalRoute = calcFinalRoute_({
    sellAction, sellValidation, allowedAction: action, timingAction,
    timingScoreEntry, timingScoreExit, ss001Total: ss001_total,
    flowCredit, leaderTotal, rwPartial: rw_partial, profitPct, daysToTimeStop,
    weightPct, acGate: ac_gate, liquidityStatus, spreadStatus,
    dartRisk: !!(dartSummary?.risk),
    missingFields: ctx.missing.length ? ctx.missing.join(" | ") : "",
  });
  const finalAction    = finalRoute.final_action;
  const actionPriority = finalRoute.action_priority;
  const priorityScore  = finalRoute.priority_score;
  const routeSource    = finalRoute.route_source;

  // ── Action_Reason: "왜 이 액션인가" — B-2 ────────────────────────────
  const sellDetailLabel_ = {
    "EXIT_100":             "손절전량(100%)",
    "TRIM_70":              "RW강도매도(70%)",
    "TRAILING_STOP_BREACH": "트레일링이탈(70%)",
    "TRIM_50":              "RW부분매도(50%)",
    "TRIM_33":              "RW초기경보(33%)",
    "TRIM_25":              "RW약세감지(25%)",
    "PROFIT_TRIM_50":   "익절(50%)",
    "PROFIT_TRIM_35":   "익절(35%)",
    "PROFIT_TRIM_25":   "익절(25%)",
    "TAKE_PROFIT_TIER1":"TP1익절(25%)",
    "TIME_EXIT_100":    "타임스탑만료(100%)",
    "TIME_TRIM_50":     "타임스탑근접(50%)",
    "TIME_TRIM_25":     "타임스탑예고(25%)",
    "REGIME_TRIM_50":   "레짐포트축소(50%)",
  };
  let actionReason = "";
  if (finalAction === "SELL_READY") {
    const lbl_ = sellDetailLabel_[sellAction] ?? sellAction;
    actionReason = `${lbl_} ${sellRatioPct}% @${sellLimitPrice}원 [${sellReason}]`;
  } else if (finalAction === "EXIT_SIGNAL" || finalAction === "EXIT_REVIEW") {
    const rwItems_ = [rw1&&"RW1",rw2&&"RW2",rw3&&"RW3",rw4&&"RW4",rw5&&"RW5"].filter(Boolean);
    actionReason = `RW${rw_partial}(${rwItems_.join("+")})${exitSignalDetail ? " "+exitSignalDetail : ""}`;
  } else if (["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"].includes(finalAction)) {
    const r_ = Number.isFinite(rsi14)    ? rsi14.toFixed(0)    : "?";
    const d_ = Number.isFinite(disparity) ? disparity.toFixed(1) : "?";
    const f_ = Number.isFinite(flowCredit) ? flowCredit.toFixed(2) : "?";
    actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점 RSI${r_} 이격${d_}% FC${f_}`;
  } else if (finalAction === "WATCH_TIMING_SETUP" || action === "WATCH_CANDIDATE") {
    const why_ = timingBlockReason || entryModeReason || "-";
    const perf_ = perfBias.entry_block
      ? `PERF_BLOCK(${perfBias.reason})`
      : (perfBias.quantity_multiplier < 1 ? `PERF_CAUTION(${perfBias.reason})` : "");
    actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점 타이밍미충족(${why_})${perf_ ? " | " + perf_ : ""}`;
  } else if (action === "HOLD") {
    actionReason = heatBlock       ? `HeatBlock(${globalHeatPct_}%)`
                 : isRiskOffRegime ? globalRegimePrelim_
                 :                  `SS001:${ss001_grade}`;
  } else if (action === "NO_ADD") {
    const why_ = [];
    if (!flow.ok) why_.push("수급이탈");
    if (Number.isFinite(price.avgTradingValue5D) && price.avgTradingValue5D < 50)
      why_.push(`거래대금${price.avgTradingValue5D.toFixed(0)}억`);
    if (Number.isFinite(price.spreadPct) && price.spreadPct > 0.8)
      why_.push(`스프레드${price.spreadPct.toFixed(2)}%`);
    if (isRiskOffRegime) why_.push(globalRegimePrelim_);
    actionReason = why_.join("|") || "유동성차단";
  } else if (action === "HOLD_NO_ADD") {
    if (dartSummary?.risk) {
      actionReason = `DART:${dartSummary.risk}`;
    } else if (timingAction === "NO_BUY_OVERHEATED" || ac_gate === "BLOCK") {
      actionReason = `과열(${timingBlockReason||ac_gate||""})`;
    } else {
      actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점`;
    }
  } else if (action === "OBSERVE_ONLY") {
    actionReason = `DATA_UNAVAIL(${priceStatus})`;
  }

  // ── C-3: Action_Params — 실행 파라미터 압축 ──────────────────────────
  let actionParams = "";
  if (finalAction === "SELL_READY") {
    const ratio_ = Number.isFinite(sellRatioPct)   ? `${sellRatioPct}%`                    : "?%";
    const price_ = Number.isFinite(sellLimitPrice) ? `@${sellLimitPrice.toLocaleString()}원` : "@?원";
    const win_   = sellExecutionWindow || "";
    const ord_   = sellOrderType       || "";
    actionParams = [ratio_, price_, win_, ord_].filter(Boolean).join(" | ");
  } else if (finalAction === "EXIT_SIGNAL" || finalAction === "EXIT_REVIEW") {
    actionParams = "RW신호 — 캡처 후 수량 확인";
  } else if (["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"].includes(finalAction)) {
    const qty_  = Number.isFinite(posSizeQty)   ? `목표 ${posSizeQty}주`                  : "";
    const stop_ = Number.isFinite(stopPriceEst) ? `손절 ${stopPriceEst.toLocaleString()}원` : "";
    const tp1_  = Number.isFinite(tp1Price)     ? `TP1 ${tp1Price.toLocaleString()}원(${tp1Qty ?? "?"}주)` : "";
    const perf_ = perfBias.quantity_multiplier < 1 ? `PF_${perfBias.reason}:${perfBias.quantity_multiplier}x` : "";
    actionParams = [qty_, stop_, tp1_, perf_].filter(Boolean).join(" | ");
  } else if (finalAction === "WATCH_TIMING_SETUP") {
    const perf_ = perfBias.entry_block
      ? `PERF_BLOCK(${perfBias.reason})`
      : perfBias.quantity_multiplier < 1 ? `PERF_CAUTION(${perfBias.reason})` : "";
    actionParams = [`대기 — ${timingBlockReason || entryModeReason || "-"}`, perf_].filter(Boolean).join(" | ");
  }

  Object.assign(ctx, {
    ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower,
    entryMode, entryModeGate, entryModeReason, exitSignalDetail,
    timingScoreEntry, timingScoreExit, timingAction, timingBlockReason,
    sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis,
    sellExecutionWindow, sellOrderType, sellReason, sellValidation,
    cashPreservePlan, accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus,
    ruleSellQty,
    finalAction, actionPriority, priorityScore, routeSource,
    actionReason, actionParams, action,
    brt, saqg,
    rag_v1, rag_reason,
  });
}

function buildTickerRowV2_(t, preReads, trailingStopUpdates) {
  const ctx = _tickerSetup_(t, preReads);
  _addTickerFundamentals_(ctx);
  _addTickerGates_(ctx, trailingStopUpdates);
  _addTickerRoute_(ctx);

  const {
    flow, price, valuation, dartSummary,
    frg5, inst5, indiv5, frg20, inst20,
    priceStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus,
    liquidityStatus, spreadStatus, today, positionCountStatus_, weeklyTargetCashPct_,
    epsRevisionStatus, epsGrowth1y, divYield, dps, beta, high52W, low52W,
    pct52WHigh, pctFrom52WLow, targetPrice, upsidePct, earningsDateStr, daysToEarnings,
    exDividendDateStr, daysToExDiv, roePct, opMarginPct, debtToEquity, currentRatio, fcfB, ocfB, revenueGrowthPct,
    limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint,
    breakoutScore, breakoutGate, ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate,
    c1, c2, c3, c4, c5, leaderTotal, leaderGate,
    rw1, rw2, rw3, rw4, rw5, rw_partial, flowCredit,
    trailingStopPrice, ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
    ss001_total, ss001_norm, ss001_grade, pegVal, pegGate,
    excess_ret_10d, rs_verdict_v1_raw, rs_verdict, composite_verdict,
    brt, saqg,
    tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop,
    weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus, corePctDelta, satPctDelta,
    ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower,
    entryMode, entryModeGate, entryModeReason, exitSignalDetail,
    timingScoreEntry, timingScoreExit, timingAction, timingBlockReason,
    sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis,
    sellExecutionWindow, sellOrderType, sellReason, sellValidation,
    cashPreservePlan, accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus,
    ruleSellQty, finalAction, actionPriority, priorityScore, routeSource,
    actionReason, actionParams, action, missing, next,
    rag_v1, rag_reason,
  } = ctx;

  const row = [
    // ── 기본 수급·가격 (11 + 29 = 40) ──────────────────────────────────
    t.code, t.name, flow.rows[0]?.date ?? today, frg5, inst5, indiv5, frg20, inst20, flow.ok ? "Y" : "N", String(flow.rows.length), today,
    priceStatus,
    price.ok ? price.close : "",
    price.ok && Number.isFinite(price.open) ? price.open : "",
    price.ok && Number.isFinite(price.prevClose) ? price.prevClose : "",
    price.ok && Number.isFinite(price.high) ? price.high : "",
    price.ok && Number.isFinite(price.low) ? price.low : "",
    price.ok && Number.isFinite(price.volume) ? price.volume : "",
    price.ok && Number.isFinite(price.avgVolume5D) ? Math.round(price.avgVolume5D) : "",
    price.ok && Number.isFinite(price.ma20) ? price.ma20.toFixed(2) : "",
    price.ok && Number.isFinite(price.ma60) ? price.ma60.toFixed(2) : "",
    price.ok && Number.isFinite(price.ret5D) ? parseFloat(price.ret5D).toFixed(2) : "",  // Ret5D
    price.ok && Number.isFinite(price.ret10D) ? price.ret10D.toFixed(2) : "",
    price.ok && Number.isFinite(price.ret20D) ? price.ret20D.toFixed(2) : "",
    price.ok && Number.isFinite(price.ret60D) ? price.ret60D.toFixed(2) : "",
    price.ok ? Math.round(price.atr20) : "",
    price.ok && Number.isFinite(price.atr20Pct) ? price.atr20Pct.toFixed(2) : "",
    price.ok && Number.isFinite(price.valSurge) ? price.valSurge.toFixed(1) : "",
    Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D.toFixed(2) : "",
    Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D.toFixed(2) : "",
    Number.isFinite(price.avgTradingValue5D) ? Math.round(price.avgTradingValue5D * 1000000) : "",
    Number.isFinite(price.avgTradingValue20D) ? Math.round(price.avgTradingValue20D * 1000000) : "",
    "KRW",
    Number.isFinite(price.bid) ? price.bid : "N/A",
    Number.isFinite(price.ask) ? price.ask : "N/A",
    Number.isFinite(price.spreadPct) ? price.spreadPct.toFixed(2) : "N/A",
    spreadStatus, price.source ?? "N/A", price.quoteSource ?? "QUOTE_NO_MATCH",
    price.quoteStatus ?? "QUOTE_NO_MATCH", liquidityStatus,
    flow5Status, flow20Status, ind5Status, valSurgeStatus,
    dartSummary.status, dartSummary.source, dartSummary.catalyst, dartSummary.risk,
    // ── 밸류에이션 (5+9+4) ─────────────────────────────────────────────
    Number.isFinite(valuation.per) ? valuation.per : "",
    Number.isFinite(valuation.pbr) ? valuation.pbr : "",
    Number.isFinite(valuation.eps) ? valuation.eps : "",
    epsRevisionStatus,
    epsGrowth1y != null ? epsGrowth1y : "",          // EPS_Growth_1Y_Pct
    Number.isFinite(divYield) ? Number(divYield).toFixed(2) : (divYield !== "" ? divYield : ""),
    dps != null ? dps : "",                           // DPS
    Number.isFinite(beta) ? Number(beta).toFixed(2) : "",
    Number.isFinite(high52W) ? high52W : "",
    Number.isFinite(low52W)  ? low52W  : "",
    pct52WHigh, pctFrom52WLow,
    Number.isFinite(targetPrice) ? Math.round(targetPrice) : "",
    upsidePct,
    earningsDateStr ?? "",
    daysToEarnings !== "" ? daysToEarnings : "",
    exDividendDateStr ?? "",                          // Ex_Dividend_Date
    daysToExDiv !== "" ? daysToExDiv : "",            // Days_To_Ex_Div
    // ── 재무 건전성 (7) (FINANCIAL_HEALTH_V1 + OCF_B 추가, 7일 캐시) ─────────
    roePct          != null ? Number(roePct).toFixed(1)          : "",
    opMarginPct     != null ? Number(opMarginPct).toFixed(1)     : "",
    debtToEquity    != null ? Number(debtToEquity).toFixed(1)    : "",
    currentRatio    != null ? Number(currentRatio).toFixed(2)    : "",
    fcfB            != null ? Number(fcfB).toFixed(1)            : "",
    ocfB            != null ? Number(ocfB).toFixed(1)            : "",  // OCF_B (억원)
    revenueGrowthPct != null ? Number(revenueGrowthPct).toFixed(1) : "",
    // ── 진입가·손절가·기대우위·수량 추정 (5) ──────────────────────────
    limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint,
    // ── 익절 사다리·타임스탑 (6) ─────────────────────────────────────
    tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop,
    // ── 포지션 모니터링 (6) ───────────────────────────────────────────
    weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus,
    positionCountStatus_ ?? "",                         // Position_Count_Status
    // ── F1 기술적 타이밍 지표 (7) ────────────────────────────────────
    ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower,
    // ── F2 진입 모드 게이트 (3) ──────────────────────────────────────
    entryMode, entryModeGate, entryModeReason,
    // ── F3 매도 타이밍 신호 (1) ──────────────────────────────────────
    exitSignalDetail,
    // ── F5 타이밍 종합 액션 (4) ──────────────────────────────────────
    timingScoreEntry, timingScoreExit, timingAction, timingBlockReason,
    // ── F6 매도 액션·수량·가격 (10) ─────────────────────────────────
    sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis,
    sellExecutionWindow, sellOrderType, sellReason, sellValidation,
    cashPreservePlan.style, cashPreservePlan.recommended_ratio, cashPreservePlan.reasons,
    // ── F6A 계좌 캡처·주간 리밸런싱 검증 (10) ───────────────────────
    accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus,
    ruleSellQty, weeklyTargetCashPct_ ?? "", "", "", "", "",
    // ── F7 최종 룰엔진 액션·우선순위 (5) ────────────────────────────
    finalAction, actionPriority, priorityScore, "", routeSource,
    // ── 수급·점수 자동 계산 (13: SS001_Norm_Score 추가) ───────────────────
    flowCredit, trailingStopPrice,
    ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, parseFloat(ss001_norm.toFixed(1)), ss001_grade,
    pegVal, pegGate,
    // ── Breakout 게이트 (2) ────────────────────────────────────────────
    breakoutScore, breakoutGate,
    // ── anti_climax_buy_gate (7) ──────────────────────────────────────
    ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate,
    // ── daily_leader_scan C1~C5 (7) ───────────────────────────────────
    c1, c2, c3, c4, c5, leaderTotal, leaderGate,
    // ── RW 청산 신호 (6) ──────────────────────────────────────────────
    rw1, rw2, rw3, rw4, rw5, rw_partial,
    // ── BRT_V1 + RS_VERDICT_V2 + COMPOSITE_VERDICT_V1 + SAQG/RAG ──────
    brt.stock_drawdown_from_high_pct !== null ? brt.stock_drawdown_from_high_pct : "",
    brt.excess_drawdown_pctp !== null ? brt.excess_drawdown_pctp : "",
    brt.recovery_ratio_5d !== null ? brt.recovery_ratio_5d : "",
    brt.recovery_ratio_20d !== null ? brt.recovery_ratio_20d : "",
    brt.downside_beta !== null ? brt.downside_beta : "",
    brt.rs_line_20d_slope !== null ? brt.rs_line_20d_slope : "",
    brt.rs_line_60d_slope !== null ? brt.rs_line_60d_slope : "",
    brt.brt_verdict,
    brt.brt_method,
    excess_ret_10d !== null ? excess_ret_10d : "",
    rs_verdict_v1_raw,
    rs_verdict,
    composite_verdict,
    saqg.saqg_v1,
    saqg.saqg_penalty,
    saqg.saqg_failed_filters,
    rag_v1,
    rag_reason,
    // ── 데이터 품질 (5) ───────────────────────────────────────────────
    missing.length ? missing.join(" | ") : "",
    next.length ? next.join(" | ") : "",
    actionReason,
    actionParams,
    action
  ];

  return { row, corePctDelta, satPctDelta };
}


// ── 1일 수익률 보조 함수 ──────────────────────────────────────────────────────
function fetchYahooPrice1D(sym) {
  const cacheKey = `yahoo_price1d_${sym}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached) return cached;
  if (isFetchCircuitOpen_("yahoo_chart")) return "N/A";
  if (!consumeFetchBudget_("yahoo_chart", sym)) return "N/A";
  sym = sym.replace(/\^/g, "%5E");
  const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=5d`;
  try {
    const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    if (resp.getResponseCode() !== 200) {
      recordFetchFailure_("yahoo_chart");
      cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure);
      return "N/A";
    }
    const data = JSON.parse(resp.getContentText());
    const closes = data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? [];
    if (closes.length < 2) {
      recordFetchFailure_("yahoo_chart");
      cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure);
      return "N/A";
    }
    const last = closes[closes.length-1];
    const prev = closes[closes.length-2];
    const result = ((last/prev-1)*100).toFixed(2);
    cacheJsonSet_(cacheKey, result, FETCH_GOVERNANCE.ttl.yahoo_chart_ok);
    recordFetchSuccess_("yahoo_chart");
    return result;
  } catch(e) {
    recordFetchFailure_("yahoo_chart");
    cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure);
    return "N/A";
  }
}

// ── Core Satellite 청크 실행 ────────────────────────────────────────────
// 위성 후보군 스크리닝용 출력. 보유 종목 완성도 매트릭스는 data_feed가 본체.
// 100종목 이상 한 번에 실행하면 6분 제한 초과 위험 → 50종목씩 청크로 분할 (현재 유니버스 약 40여 개는 1번 실행에 완료됨)
// 트리거: runCoreSatelliteChunk → 매일 17:00~18:00, 별도 스크립트 프로젝트 권장
const CHUNK_SIZE = 50;

function runCoreSatelliteBatch() {
  if (typeof isRunAllOrchestrated_ === "function" && isRunAllOrchestrated_()) {
    if (typeof setFetchSessionLabel_ === "function") {
      setFetchSessionLabel_("runCoreSatelliteBatch");
    }
  } else {
    beginFetchSession_("runCoreSatelliteBatch");
  }
  const props = PropertiesService.getScriptProperties();
  const universe = getCoreSatelliteUniverse(); // 아래 함수 참조
  const totalChunks = Math.ceil(universe.length / CHUNK_SIZE);
  const TIMEOUT_BUDGET_SEC = 210;
  const startTime = new Date().getTime();

  let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10);
  let rowIdx = parseInt(props.getProperty("cs_row_idx") ?? "0", 10);
  const schemaVersion = props.getProperty("cs_schema_version") ?? "";
  if (chunkIdx === 0 || schemaVersion !== SCHEMA_VERSION) {
    resetCoreSatelliteChunks();
    props.setProperty("cs_schema_version", SCHEMA_VERSION);
    chunkIdx = 0;
    rowIdx = 0;
  }
  if (chunkIdx >= totalChunks) {
    // 모든 청크 완료 → unified 탭 업데이트 후 리셋
    runCoreSatelliteFinalize();
    props.setProperty("cs_chunk_idx", "0");
    writeCoreSatelliteStatus_("FINALIZED", universe.length, universe.length, totalChunks, 0, "all chunks already complete");
    Logger.log("core_satellite: 모든 청크 완료 → finalize");
    return;
  }

  const slice = universe.slice(chunkIdx * CHUNK_SIZE, (chunkIdx+1) * CHUNK_SIZE);
  const dataFeedMap = buildDataFeedPriceMap();
  const dataFeedSellMap_ = buildDataFeedSellMap_();
  const sheetName = `cs_chunk_${chunkIdx}`;
  const headers = [
    "Ticker","Name","Sector",
    "Price_Date","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D","MA20","MA60","Ret10D","Ret20D","Ret60D","Price_Source","ATR20","ATR20_Pct","Val_Surge_Pct","AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit","Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status",
    "Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows",
    "ETF_Ret5D","Rotation_Score","Alert_Level","Smart_Money",
    "DART_Status","DART_Source","DART_Catalyst","DART_Risk",
    "Missing_Fields","Next_Source_To_Check","Allowed_Action",
    "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation",
    "Action_Reason","Action_Params","Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
    "Timing_Action","Timing_Score_Entry","Timing_Score_Exit","Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason","Exit_Signal_Detail",
    "Candidate_Quality_Grade","T1_Forced_Sell_Risk_Score","T1_Forced_Sell_Risk_State","T1_Forced_Sell_Risk_Reason",
    "Sell_Conflict_Score","Sell_Conflict_State","Sell_Conflict_Reason","Execution_Recommendation_State","Execution_Recommendation_Reason",
    "ChunkIdx","AsOfDate",
    "RS_Rank_20D","RS_Pct_20D"
  ];
  const rows = [];
  const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");

  if (rowIdx > 0) {
    const existingSheet = getSpreadsheet_().getSheetByName(sheetName);
    if (existingSheet) {
      const existingData = existingSheet.getDataRange().getValues();
      if (existingData.length > 2) {
        for (const row of existingData.slice(2)) {
          const normalized = Array.isArray(row) ? row.slice(0, headers.length) : [];
          while (normalized.length < headers.length) normalized.push("");
          rows.push(normalized);
        }
      }
    }
  }

  if (rowIdx >= slice.length) {
    props.setProperty("cs_row_idx", "0");
    props.setProperty("cs_chunk_idx", String(chunkIdx + 1));
    return runCoreSatelliteBatch();
  }

  for (; rowIdx < slice.length; rowIdx++) {
    const t = slice[rowIdx];
    const flow = fetchNaverFlow(t.code);
    const price = resolveSatellitePriceMetrics(t.code, dataFeedMap);
    const notices = fetchNaverDisclosureNotices(t.code);
    const dartSummary = summarizeDisclosureNotices(notices);

    const frg5  = flow.rows.slice(0,5).reduce((s,r)=>s+r.frgn, 0);
    const inst5 = flow.rows.slice(0,5).reduce((s,r)=>s+r.inst, 0);
    const frg20 = flow.rows.reduce((s,r)=>s+r.frgn, 0);
    const inst20= flow.rows.reduce((s,r)=>s+r.inst, 0);
    const indiv5= -(frg5+inst5);
    Utilities.sleep(400);

    const score = calcRotationScore(frg5, inst5, frg20, inst20, indiv5, null);
    const alert = calcAlert(score, frg5, inst5);
    const smart = calcSmartMoney(frg5, inst5, indiv5);
    const priceStatus = price.ok ? "PRICE_OK" : "PRICE_MISSING";
    const liquidityStatus = calcLiquidityStatus(Number(price.avgTradingValue5D));
    const spreadStatus = calcSpreadStatus(Number(price.spreadPct));
    const valSurgeStatus = calcValSurgeStatus(price.valSurge);
    const missing = [];
    if (!flow.ok) missing.push("FLOW");
    if (!price.ok) missing.push("ATR20");
    if (dartSummary.status === "NAVER_NOTICE_EMPTY" || String(dartSummary.status).startsWith("NAVER_NOTICE_ERROR")) missing.push("DART");
    const next = [];
    if (missing.includes("ATR20")) next.push("Yahoo Finance chart");
    if (missing.includes("DART")) next.push("Naver 공시공지");
    if (missing.includes("FLOW")) next.push("Naver frgn.naver");
    const action = buildAllowedAction(score, priceStatus, price.atr20, dartSummary, flow.ok, price.avgTradingValue5D, price.spreadPct);
    const sellRow_ = dataFeedSellMap_[normalizeTickerCode(t.code)] ?? null;
    const sellFinal_ = String(sellRow_?.Final_Action ?? "").trim();
    const sellAction_ = String(sellRow_?.Sell_Action ?? "").trim();
    const sellHasSignal_ = sellFinal_ === "SELL_READY" || sellFinal_ === "EXIT_SIGNAL" || sellFinal_ === "EXIT_REVIEW";
    const sellValidation_ = String(sellRow_?.Sell_Validation ?? "").trim() || (sellHasSignal_ ? "SIGNAL_CONFIRMED" : "SIGNAL_ONLY");
    const sellRatio_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Ratio_Pct)) ? Number(sellRow_?.Sell_Ratio_Pct) : 0) : 0;
    const sellQty_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Qty)) ? Number(sellRow_?.Sell_Qty) : 0) : 0;
    const sellLimit_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Limit_Price)) ? Number(sellRow_?.Sell_Limit_Price) : "") : "";
    const actionReason_ = sellHasSignal_ ? String(sellRow_?.Action_Reason ?? "") : "";
    const actionParams_ = sellHasSignal_ ? String(sellRow_?.Action_Params ?? "") : "";
    const cashStyle_ = sellHasSignal_ ? String(sellRow_?.Cash_Preserve_Style ?? "NONE") : "NONE";
    const cashRatio_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Cash_Preserve_Ratio)) ? Number(sellRow_?.Cash_Preserve_Ratio) : 0) : 0;
    const cashReason_ = sellHasSignal_ ? String(sellRow_?.Cash_Preserve_Reason ?? "") : "";
    const timingLocal_ = (price.ok && Array.isArray(price.rows) && price.rows.length >= 21)
      ? calcTimingMetrics_(price.rows) : {};
    const entryLocal_ = (price.ok && Object.keys(timingLocal_).length)
      ? calcEntryMode_(timingLocal_, price) : { mode: "NEUTRAL", gate: "PENDING", reason: "데이터부족" };
    const localTimingRoute_ = calcTimingRoute_({
      priceStatus,
      atr20: price.atr20,
      flowCredit: "",
      leaderTotal: "",
      leaderGate: "",
      acGate: "",
      rwPartial: sellRow_?.RW_Partial,
      entryMode: entryLocal_.mode,
      entryModeGate: entryLocal_.gate,
      exitSignalDetail: "",
      rsi14: timingLocal_.rsi14,
      disparity: timingLocal_.disparity,
      ma20Slope: timingLocal_.ma20Slope,
      spreadPct: price.spreadPct,
      avgTradeValue5D: price.avgTradingValue5D,
      profitPct: sellRow_?.Profit_Pct,
      daysToTimeStop: sellRow_?.Days_To_Time_Stop,
    });
    const timingAction_ = String(sellRow_?.Timing_Action ?? localTimingRoute_.action ?? "");
    const timingEntry_ = Number.isFinite(Number(sellRow_?.Timing_Score_Entry))
      ? Number(sellRow_?.Timing_Score_Entry) : localTimingRoute_.entry_score;
    const timingExit_ = Number.isFinite(Number(sellRow_?.Timing_Score_Exit))
      ? Number(sellRow_?.Timing_Score_Exit) : localTimingRoute_.exit_score;
    const entryMode_ = String(sellRow_?.Entry_Mode ?? entryLocal_.mode ?? "");
    const entryGate_ = String(sellRow_?.Entry_Mode_Gate ?? entryLocal_.gate ?? "");
    const entryReason_ = String(sellRow_?.Entry_Mode_Reason ?? entryLocal_.reason ?? "");
    const exitSignalDetail_ = String(sellRow_?.Exit_Signal_Detail ?? "");
    const candidateQuality_ = calcCoreCandidateQualityGrade_({
      rotationScore: score,
      flowOk: flow.ok ? "Y" : "N",
      priceStatus,
      liquidityStatus,
      dartRisk: dartSummary.risk,
      missingFields: missing.join(" | "),
    });
    const t1Risk_ = calcT1ForcedSellRisk_({
      sellAction: sellAction_,
      sellValidation: sellValidation_,
      timingScoreExit: timingExit_,
      rwPartial: sellRow_?.RW_Partial,
      rsi14: timingLocal_.rsi14,
      disparity: timingLocal_.disparity,
      valSurgePct: price.valSurge,
      ret5D: price.ret5D,
      dartRisk: dartSummary.risk,
      lateChaseRiskScore: sellRow_ ? sellRow_["Late_Chase_Risk_Score"] : "",
      distributionRiskScore: sellRow_ ? sellRow_["Distribution_Risk_Score"] : "",
    });
    const sellConflict_ = calcSellConflictScore_({
      sellFinal: sellFinal_,
      sellAction: sellAction_,
      cashPreserveStyle: cashStyle_,
      allowedAction: action,
    });
    const executionState_ = calcCoreSatelliteExecutionState_({
      candidateQualityGrade: candidateQuality_,
      timingAction: timingAction_,
      entryModeGate: entryGate_,
      t1State: t1Risk_.state,
      sellConflictState: sellConflict_.state,
      allowedAction: action,
    });
    const executionReason_ = [
      `quality=${candidateQuality_}`,
      `timing=${timingAction_}`,
      `t1=${t1Risk_.state}`,
      `sell_conflict=${sellConflict_.state}`,
    ].join("|");

    rows.push([
      t.code, t.name, t.sector ?? "",
      price.ok ? price.priceDate : today,
      price.ok ? price.close : "N/A",
      price.ok && Number.isFinite(price.open) ? price.open : "N/A",
      price.ok && Number.isFinite(price.prevClose) ? price.prevClose : "N/A",
      price.ok && Number.isFinite(price.high) ? price.high : "N/A",
      price.ok && Number.isFinite(price.low) ? price.low : "N/A",
      price.ok && Number.isFinite(price.volume) ? price.volume : "N/A",
      price.ok && Number.isFinite(price.avgVolume5D) ? Math.round(price.avgVolume5D) : "N/A",
      price.ok && Number.isFinite(price.ma20) ? Number(price.ma20).toFixed(2) : "N/A",
      price.ok && Number.isFinite(price.ma60) ? Number(price.ma60).toFixed(2) : "N/A",
      price.ok && Number.isFinite(price.ret10D) ? Number(price.ret10D).toFixed(2) : "N/A",
      price.ok && Number.isFinite(price.ret20D) ? Number(price.ret20D).toFixed(2) : "N/A",
      price.ok && Number.isFinite(price.ret60D) ? Number(price.ret60D).toFixed(2) : "N/A",
      price.ok ? String(price.source ?? "") : "N/A",
      price.ok && Number.isFinite(price.atr20) ? Math.round(price.atr20) : "N/A",
      price.ok && Number.isFinite(price.atr20Pct) ? Number(price.atr20Pct).toFixed(2) : "N/A",
      price.ok && Number.isFinite(price.valSurge) ? Number(price.valSurge).toFixed(1) : "N/A",
      Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D.toFixed(2) : "N/A",
      Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D.toFixed(2) : "N/A",
      Number.isFinite(price.avgTradingValue5D) ? Math.round(price.avgTradingValue5D * 1000000) : "N/A",
      Number.isFinite(price.avgTradingValue20D) ? Math.round(price.avgTradingValue20D * 1000000) : "N/A",
      "KRW",
      Number.isFinite(price.bid) ? price.bid : "N/A",
      Number.isFinite(price.ask) ? price.ask : "N/A",
      Number.isFinite(price.spreadPct) ? price.spreadPct.toFixed(2) : "N/A",
      spreadStatus,
      price.source ?? "N/A",
      price.quoteSource ?? "QUOTE_NO_MATCH",
      price.quoteStatus ?? "QUOTE_NO_MATCH",
      liquidityStatus,
      frg5, inst5, indiv5, frg20, inst20, flow.ok ? "Y" : "N", String(flow.rows.length),
      "N/A", score, alert, smart,
      dartSummary.status,
      dartSummary.source,
      dartSummary.catalyst,
      dartSummary.risk,
      missing.length ? missing.join(" | ") : "",
      next.length ? next.join(" | ") : "",
      action,
      sellFinal_ || "HOLD",
      sellAction_ || "HOLD",
      sellRatio_,
      sellQty_,
      sellLimit_,
      sellValidation_,
      actionReason_,
      actionParams_,
      cashStyle_,
      cashRatio_,
      cashReason_,
      timingAction_,
      timingEntry_,
      timingExit_,
      entryMode_,
      entryGate_,
      entryReason_,
      exitSignalDetail_,
      candidateQuality_,
      t1Risk_.score,
      t1Risk_.state,
      t1Risk_.reason,
      sellConflict_.score,
      sellConflict_.state,
      sellConflict_.reason,
      executionState_,
      executionReason_,
      String(chunkIdx), today,
      "", ""  // RS_Rank_20D, RS_Pct_20D — finalize 단계에서 채워짐
    ]);

    const elapsedSec = (new Date().getTime() - startTime) / 1000;
    if (elapsedSec > TIMEOUT_BUDGET_SEC) {
      writeToSheet(sheetName, headers, rows);
      props.setProperty("cs_chunk_idx", String(chunkIdx));
      props.setProperty("cs_row_idx", String(rowIdx + 1));
      writeCoreSatelliteStatus_(
        "PARTIAL_SAVED",
        universe.length,
        Math.min(chunkIdx * CHUNK_SIZE + rowIdx + 1, universe.length),
        totalChunks,
        chunkIdx,
        `chunk ${chunkIdx + 1}/${totalChunks} partial saved at row ${rowIdx + 1}/${slice.length}`
      );
      Logger.log(`core_satellite chunk ${chunkIdx} partial saved at row ${rowIdx + 1}/${slice.length}`);
      throw new Error("PARTIAL_SAVE_REQUESTED");
    }
  }

  // 청크 데이터를 임시 시트에 누적 저장
  writeToSheet(sheetName, headers, rows);
  props.setProperty("cs_chunk_idx", String(chunkIdx + 1));
  props.setProperty("cs_row_idx", "0");
  writeCoreSatelliteStatus_(
    chunkIdx + 1 >= totalChunks ? "FINALIZING" : "IN_PROGRESS",
    universe.length,
    Math.min((chunkIdx + 1) * CHUNK_SIZE, universe.length),
    totalChunks,
    chunkIdx + 1,
    `chunk ${chunkIdx + 1}/${totalChunks} written`
  );
  Logger.log(`core_satellite chunk ${chunkIdx}/${totalChunks-1} 완료: ${rows.length}종목`);

  // 마지막 청크는 같은 실행에서 즉시 finalize한다.
  if (chunkIdx + 1 >= totalChunks) {
    runCoreSatelliteFinalize();
    props.setProperty("cs_chunk_idx", "0");
    props.setProperty("cs_row_idx", "0");
    writeCoreSatelliteStatus_("COMPLETE", universe.length, universe.length, totalChunks, 0, "finalize complete");
    Logger.log("core_satellite: 마지막 청크 완료 → finalize");
  }
}

function writeCoreSatelliteStatus_(status, universeCount, processedCount, totalChunks, nextChunkIdx, detail) {
  const updatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
  const coverage  = universeCount > 0 ? roundNum((processedCount / universeCount) * 100, 2) : 0;
  PropertiesService.getScriptProperties().setProperty("cs_status", JSON.stringify({
    status, universeCount, processedCount,
    coveragePct: coverage, chunkSize: CHUNK_SIZE,
    totalChunks, nextChunkIdx, updatedAt, detail: detail || ""
  }));
}

function runCoreSatelliteFinalize() {
  // 모든 cs_chunk_N 탭을 합쳐 core_satellite 탭에 기록
  const ss = getSpreadsheet_();
  const allRows = [];
  const headers = [
    "Ticker","Name","Sector",
    "Price_Date","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D","MA20","MA60","Ret10D","Ret20D","Ret60D","Price_Source","ATR20","ATR20_Pct","Val_Surge_Pct","AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit","Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status",
    "Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows",
    "ETF_Ret5D","Rotation_Score","Alert_Level","Smart_Money",
    "DART_Status","DART_Source","DART_Catalyst","DART_Risk",
    "Missing_Fields","Next_Source_To_Check","Allowed_Action",
    "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation",
    "Action_Reason","Action_Params","Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
    "Timing_Action","Timing_Score_Entry","Timing_Score_Exit","Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason","Exit_Signal_Detail",
    "Candidate_Quality_Grade","T1_Forced_Sell_Risk_Score","T1_Forced_Sell_Risk_State","T1_Forced_Sell_Risk_Reason",
    "Sell_Conflict_Score","Sell_Conflict_State","Sell_Conflict_Reason","Execution_Recommendation_State","Execution_Recommendation_Reason",
    "ChunkIdx","AsOfDate",
    "RS_Rank_20D","RS_Pct_20D"
  ];
  let chunkIdx = 0;
  while (true) {
    const s = ss.getSheetByName(`cs_chunk_${chunkIdx}`);
    if (!s) break;
    const data = s.getDataRange().getValues();
    // row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터
    if (data.length > 2) {
      for (const row of data.slice(2)) {
        const normalized = Array.isArray(row) ? row.slice(0, headers.length) : [];
        while (normalized.length < headers.length) normalized.push("");
        allRows.push(normalized);
      }
    }
    s.hideSheet(); // 임시 탭 숨김
    chunkIdx++;
  }

  if (allRows.length === 0) return;

  // ── 섹터별 Ret20D 상대강도 순위 계산 ──────────────────────────────────
  // Sector=index 2, Ret20D=index 14, RS_Rank_20D=index 53, RS_Pct_20D=index 54
  const SECTOR_IDX  = 2;
  const RET20D_IDX  = 14;
  const RS_RANK_IDX = headers.indexOf("RS_Rank_20D");
  const RS_PCT_IDX  = headers.indexOf("RS_Pct_20D");
  const TICKER_IDX  = headers.indexOf("Ticker");
  if (TICKER_IDX >= 0) {
    allRows.forEach(r => { r[TICKER_IDX] = normalizeTickerCode(r[TICKER_IDX]); });
  }
  const sectorGroups = {};
  allRows.forEach((r, i) => {
    const sector = String(r[SECTOR_IDX] ?? "").trim();
    const ret20D = parseFloat(r[RET20D_IDX]);
    if (!sector || !Number.isFinite(ret20D)) return;
    if (!sectorGroups[sector]) sectorGroups[sector] = [];
    sectorGroups[sector].push({ rowIdx: i, ret20D });
  });
  for (const group of Object.values(sectorGroups)) {
    group.sort((a, b) => b.ret20D - a.ret20D); // 수익률 높을수록 rank=1
    group.forEach((item, rankIdx) => {
      allRows[item.rowIdx][RS_RANK_IDX] = rankIdx + 1;
      // 백분위: 1위=100, 꼴찌=0 (섹터 내 상대 위치)
      allRows[item.rowIdx][RS_PCT_IDX]  = Math.round((1 - rankIdx / group.length) * 100);
    });
  }

  writeToSheet("core_satellite", headers, allRows);
  writeCoreSatelliteStatus_("COMPLETE", allRows.length, allRows.length, chunkIdx, 0, "core_satellite finalized from chunk sheets");
  deleteCoreSatelliteChunkSheets_("finalize complete");
  Logger.log(`core_satellite finalize 완료: ${allRows.length}종목`);
}

function deleteCoreSatelliteChunkSheets_(reason) {
  const ss = getSpreadsheet_();
  const sheets = ss.getSheets();
  let deleted = 0;
  for (const sheet of sheets) {
    const name = sheet.getName();
    if (/^cs_chunk_\d+$/.test(name)) {
      ss.deleteSheet(sheet);
      deleted++;
    }
  }
  if (deleted > 0) {
    Logger.log(`core_satellite 임시 청크 시트 삭제: ${deleted}개 (${reason || "cleanup"})`);
  }
  return deleted;
}

// ── Rotation Score / Alert / SmartMoney 공통 계산 ────────────────────────────
function calcRotationScore(frg5, inst5, frg20, inst20, indiv5, etf) {
  let score = 0;
  if (frg5>0 && inst5>0) score+=40; else if(frg5>0||inst5>0) score+=20;
  if (frg20>0 && inst20>0) score+=20; else if(frg20>0||inst20>0) score+=10;
  if (etf?.ok) { if(+etf.ret5D>0) score+=10; if(+etf.ret20D>0) score+=10; }
  if ((frg5>0||inst5>0) && indiv5<0) score+=10;
  return Math.max(0, Math.min(100, score));
}

function calcAlert(score, frg5, inst5) {
  return score>=70&&frg5>0&&inst5>0 ? "INFLOW_STRONG" :
         score>=50 ? "INFLOW_MODERATE" :
         score>=30 ? "NEUTRAL" :
         frg5<0&&inst5<0 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION";
}

function calcSmartMoney(frg5, inst5, indiv5) {
  return frg5>0&&inst5>0&&indiv5<0 ? "STRONG" :
         (frg5>0||inst5>0)&&indiv5<0 ? "MODERATE" :
         frg5>0||inst5>0 ? "WEAK" : "ABSENT";
}

function buildDataFeedPriceMap() {
  const holdings = sheetToJson("data_feed");
  const map = {};
  for (const row of holdings) {
    const ticker = normalizeTickerCode(row.Ticker);
    if (!ticker) continue;
    map[ticker] = row;
  }
  return map;
}

function buildDataFeedSellMap_() {
  const holdings = sheetToJson("data_feed");
  const map = {};
  for (const row of holdings) {
    const ticker = normalizeTickerCode(row.Ticker);
    if (!ticker) continue;
    map[ticker] = row;
  }
  return map;
}

function resolveDataFeedPriceMetrics(code) {
  const ticker = normalizeTickerCode(code);
  const price = fetchNaverOhlcMetrics(ticker);
  if (price.ok) return price;

  const yahooOhlc = fetchYahooOhlcMetrics(ticker);
  if (yahooOhlc.ok) return yahooOhlc;

  const naverFallback = fetchNaverMarketMetrics(ticker);
  if (naverFallback.ok) {
    return {
      ok: true,
      source: "Naver Finance main",
      isFallbackQuote: true,   // OHLC 없음 — MA/ATR/ValSurge 결측
      isPriceStale: false,     // 실시간 호가 — 날짜 스테일 아님
      priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
      close: Number(naverFallback.marketPrice),
      open: null,
      high: null,
      low: null,
      volume: null,
      prevClose: null,
      avgVolume5D: null,
      ma20: null,
      ma60: null,
      ret10D: null,
      ret20D: null,
      ret60D: null,
      atr20: null,
      atr20Pct: null,
      valSurge: null,
      avgTradingValue5D: null,
      avgTradingValue20D: null,
      ret5D: null,
      bid: Number.isFinite(naverFallback.bid) ? naverFallback.bid : null,
      ask: Number.isFinite(naverFallback.ask) ? naverFallback.ask : null,
      spreadPct: Number.isFinite(naverFallback.spreadPct) ? naverFallback.spreadPct : null,
      quoteSource: naverFallback.source ?? "naver_main",
      quoteStatus: naverFallback.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
      quoteHttpStatus: naverFallback.httpStatus ?? null,
      error: price.error || naverFallback.error || ""
    };
  }

  const fallback = fetchYahooPrice(ticker);
  if (fallback.ok) {
    return {
      ok: true,
      source: "Yahoo Finance close",
      isFallbackQuote: true,   // OHLC 없음 — MA/ATR/ValSurge 결측
      isPriceStale: false,     // 실시간 가격 — 날짜 스테일 아님
      priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
      close: Number(fallback.close),
      open: null,
      high: null,
      low: null,
      volume: null,
      prevClose: null,
      avgVolume5D: null,
      ma20: null,
      ma60: null,
      ret10D: null,
      ret20D: fallback.ret20D,
      ret60D: null,
      atr20: null,
      atr20Pct: null,
      valSurge: null,
      avgTradingValue5D: null,
      avgTradingValue20D: null,
      bid: null,
      ask: null,
      spreadPct: null,
      quoteSource: "QUOTE_NO_MATCH",
      quoteStatus: "QUOTE_NO_MATCH",
      ret5D: fallback.ret5D,
      ret20D: fallback.ret20D,
      error: price.error || fallback.error || ""
    };
  }
  return { ok: false, error: price.error || fallback.error || "PRICE_MISSING" };
}

function resolveSatellitePriceMetrics(code, dataFeedMap) {
  const ticker = normalizeTickerCode(code);
  const local = dataFeedMap?.[ticker] || null;
  if (local && String(local.Price_Status ?? "").toUpperCase() === "PRICE_OK") {
    const parseNum = (v) => (v === "" || v == null || isNaN(Number(v))) ? null : Number(v);
    const close = parseNum(local.Close);
    const atr20 = parseNum(local.ATR20);
    const avgTradingValue5D = parseNum(local.AvgTradingValue_5D_M ?? local.Avg_TradingValue_5D_M ?? local.AvgTradeValue_5D_M);
    const avgTradingValue20D = parseNum(local.AvgTradingValue_20D_M ?? local.Avg_TradingValue_20D_M ?? local.AvgTradeValue_20D_M);
    const bid = parseNum(local.Bid);
    const ask = parseNum(local.Ask);
    const spreadPct = parseNum(local.Spread_Pct ?? local.SpreadPct);
    return {
      ok: Number.isFinite(close),
      source: "data_feed",
      priceDate: String(local.Price_Date ?? ""),
      close: close !== null ? close : "",
      open: parseNum(local.Open),
      high: parseNum(local.High),
      low: parseNum(local.Low),
      volume: parseNum(local.Volume),
      prevClose: parseNum(local.PrevClose),
      avgVolume5D: parseNum(local.AvgVolume_5D),
      ma20: parseNum(local.MA20),
      ma60: parseNum(local.MA60),
      ret10D: parseNum(local.Ret10D),
      ret20D: parseNum(local.Ret20D),
      ret60D: parseNum(local.Ret60D),
      atr20: atr20,
      atr20Pct: parseNum(local.ATR20_Pct),
      valSurge: parseNum(local.Val_Surge_Pct),
      avgTradingValue5D: avgTradingValue5D,
      avgTradingValue20D: avgTradingValue20D,
      bid: bid,
      ask: ask,
      spreadPct: spreadPct,
      quoteSource: String(local.Quote_Source ?? local.quoteSource ?? local.Spread_Source ?? "data_feed"),
      quoteStatus: String(local.Quote_Status ?? local.quoteStatus ?? "QUOTE_NO_MATCH")
    };
  }

  const price = fetchNaverOhlcMetrics(ticker);
  if (price.ok) return price;

  const yahooOhlc = fetchYahooOhlcMetrics(ticker);
  if (yahooOhlc.ok) return yahooOhlc;

  const naverFallback = fetchNaverMarketMetrics(ticker);
  if (naverFallback.ok) {
    return {
      ok: true,
      source: "Naver Finance main",
      isFallbackQuote: true,
      isPriceStale: false,
      priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
      close: Number(naverFallback.marketPrice),
      open: null,
      high: null,
      low: null,
      volume: null,
      prevClose: null,
      avgVolume5D: null,
      ma20: null,
      ma60: null,
      ret10D: null,
      ret20D: null,
      ret60D: null,
      atr20: null,
      atr20Pct: null,
      valSurge: null,
      avgTradingValue5D: null,
      avgTradingValue20D: null,
      bid: Number.isFinite(naverFallback.bid) ? naverFallback.bid : null,
      ask: Number.isFinite(naverFallback.ask) ? naverFallback.ask : null,
      spreadPct: Number.isFinite(naverFallback.spreadPct) ? naverFallback.spreadPct : null,
      quoteSource: naverFallback.source ?? "naver_main",
      quoteStatus: naverFallback.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
      quoteHttpStatus: naverFallback.httpStatus ?? null,
      ret5D: null,
      ret20D: null
    };
  }

  const fallback = fetchYahooPrice(ticker);
  if (fallback.ok) {
    return {
      ok: true,
      source: "Yahoo Finance close",
      isFallbackQuote: true,
      isPriceStale: false,
      priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
      close: Number(fallback.close),
      atr20: null,
      atr20Pct: null,
      valSurge: null,
      avgTradingValue5D: null,
      avgTradingValue20D: null,
      bid: null,
      ask: null,
      spreadPct: null,
      quoteSource: "QUOTE_NO_MATCH",
      quoteStatus: "QUOTE_NO_MATCH",
      ret5D: fallback.ret5D,
      ret20D: fallback.ret20D
    };
  }
  return { ok: false, error: price.error || fallback.error || "PRICE_MISSING" };
}

function fetchTrendingTickers() {
  const cacheKey = `trending_tickers_${Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd")}`;
  const cached = getCachedFetchResult_(cacheKey);
  if (cached && Array.isArray(cached)) return cached;

  const url = "https://finance.naver.com/sise/sise_quant.naver"; // 거래상위 (Top Volume)
  const tickers = [];
  try {
    const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    if (resp.getResponseCode() === 200) {
      const html = resp.getContentText("EUC-KR");
      const pattern = /<a href="\/item\/main\.naver\?code=([0-9]{6})" class="tltle">([^<]+)<\/a>/g;
      let match;
      while ((match = pattern.exec(html)) !== null) {
        const name = match[2].trim();
        // ETF/ETN 등 제외
        if (!name.includes("KODEX") && !name.includes("TIGER") && !name.includes("인버스") && !name.includes("레버리지") && !name.includes("KOSEF") && !name.includes("HANARO") && !name.includes("KBSTAR") && !name.includes("ACE")) {
          tickers.push({ code: match[1], name: name, sector: "Dynamic(거래상위)" });
        }
        if (tickers.length >= 10) break; // Top 10 Dynamic stocks
      }
    }
  } catch(e) {
    handleFetchError_("fetchTrendingTickers", e, "WARN");
  }

  if (tickers.length > 0) {
    cacheJsonSet_(CACHE_VERSION + cacheKey, tickers, 12 * 60 * 60);
  }
  return tickers;
}

// ── Core Satellite 종목 유니버스 ──────────────────────────────────────────────
// 실제 운용 시 ScriptProperties 또는 별도 시트에서 로드 권장
function getCoreSatelliteUniverse() {
  const ss = getSpreadsheet_();
  let sheetUniverse = ss.getSheetByName("universe");
  let list = [];
  let purgedCount = 0;
  
  const nowMs = Date.now();
  const MAX_DAYS = 14; // 동적 발굴 종목의 기본 모니터링 수명 (14일간 눌림목 추적)
  const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");

  // 1. 기존 유니버스 시트가 있으면 읽어온다.
  if (sheetUniverse) {
    const data = sheetUniverse.getDataRange().getValues();
    if (data.length > 1) {
      for (let i = 1; i < data.length; i++) {
        const r = data[i];
        if (!r[0]) continue;
        const code = normalizeTickerCode(r[0]);
        const name = String(r[1]||"").trim();
        const sector = String(r[2]||"").trim();
        const addedDateStr = String(r[3]||"").trim() || todayStr; // 없으면 오늘로 간주
        
        // 14일 경과된 Dynamic 종목 자동 삭제 로직 (사용자가 섹터명을 바꾸면 영구보존)
        if (sector.toUpperCase().startsWith("DYNAMIC")) {
          const addedMs = Date.parse(addedDateStr);
          if (!isNaN(addedMs) && (nowMs - addedMs) / (1000 * 60 * 60 * 24) > MAX_DAYS) {
            purgedCount++;
            continue; // 리스트에 추가하지 않음 (삭제)
          }
        }
        list.push({ code, name, sector, addedDate: addedDateStr });
      }
    }
  } else {
    // 시트가 없으면 새로 생성하고 헤더를 쓴다.
    sheetUniverse = ss.insertSheet("universe");
    sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]);
    sheetUniverse.getRange("A:A").setNumberFormat("@"); // 코드 열 텍스트 지정
  }

  // 2. 읽어온 데이터가 아예 없으면 (최초 실행) 기본 AI 리스트로 초기화한다.
  if (list.length === 0) {
    const defaults = [
      // AI 반도체 & 메모리
      { code:"005930", name:"삼성전자",   sector:"반도체" },
      { code:"000660", name:"SK하이닉스", sector:"반도체" },
      { code:"042700", name:"한미반도체", sector:"반도체" },
      { code:"007660", name:"이수페타시스",sector:"반도체/PCB" },
      { code:"403870", name:"HPSP",       sector:"반도체/장비" },
      { code:"058470", name:"리노공업",   sector:"반도체/부품" },
      // AI 전력/인프라/발전
      { code:"010120", name:"LS ELECTRIC",sector:"AI전력/기기" },
      { code:"267260", name:"HD현대일렉트릭",sector:"AI전력/기기" },
      { code:"298040", name:"효성중공업", sector:"AI전력/기기" },
      { code:"006260", name:"LS",         sector:"AI전력/전선" },
      { code:"001440", name:"대한전선",   sector:"AI전력/전선" },
      { code:"034020", name:"두산에너빌리티",sector:"AI인프라/발전" },
      { code:"028050", name:"삼성E&A",    sector:"AI인프라/EPC" },
      // 방산
      { code:"012450", name:"한화에어로스페이스",sector:"방산" },
      { code:"064350", name:"현대로템",   sector:"방산" },
      { code:"079550", name:"LIG넥스원",  sector:"방산" },
      // 조선
      { code:"329180", name:"HD현대중공업",sector:"조선" },
      { code:"042660", name:"한화오션",   sector:"조선" },
      { code:"009540", name:"HD한국조선해양",sector:"조선" },
      // 자동차
      { code:"005380", name:"현대차",     sector:"자동차" },
      { code:"000270", name:"기아",       sector:"자동차" },
      // 밸류업/금융
      { code:"105560", name:"KB금융",     sector:"금융/은행" },
      { code:"055550", name:"신한지주",   sector:"금융/은행" },
      { code:"024110", name:"기업은행",   sector:"금융/은행" },
      // 바이오
      { code:"207940", name:"삼성바이오로직스",sector:"바이오" },
      { code:"068270", name:"셀트리온",   sector:"바이오" },
      { code:"128940", name:"한미약품",   sector:"바이오" },
      { code:"000100", name:"유한양행",   sector:"바이오" },
      // 2차전지
      { code:"373220", name:"LG에너지솔루션",sector:"2차전지" },
      { code:"006400", name:"삼성SDI",    sector:"2차전지" },
      { code:"003670", name:"포스코퓨처엠",sector:"2차전지" },
      // 지주/기타
      { code:"028260", name:"삼성물산",   sector:"지주" }
    ];
    
    list = defaults.map(t => ({ ...t, addedDate: todayStr }));
    const initialRows = list.map(t => [t.code, t.name, t.sector, t.addedDate]);
    
    // 헤더가 없을 수 있으므로 전체 초기화
    sheetUniverse.clearContents();
    sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]);
    sheetUniverse.getRange(2, 1, initialRows.length, 4).setValues(initialRows);
  }

  // 3. 만료된 종목이 있어서 정리(Purge)를 했다면 시트를 새로고침
  if (purgedCount > 0) {
    sheetUniverse.clearContents();
    sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]);
    if (list.length > 0) {
      const validRows = list.map(t => [t.code, t.name, t.sector, t.addedDate]);
      sheetUniverse.getRange(2, 1, validRows.length, 4).setValues(validRows);
    }
    Logger.log(`[Auto-Clean] 14일 경과된 노이즈 종목 ${purgedCount}개 자동 삭제 완료.`);
  }

  // 4. 동적 주도주(거래급증) 자동 발굴
  const dynamicTickers = fetchTrendingTickers();
  const existingCodes = new Set(list.map(x => x.code));
  const newDiscovered = [];

  for (const t of dynamicTickers) {
    if (!existingCodes.has(t.code)) {
      t.sector = "Dynamic";
      t.addedDate = todayStr;
      list.push(t);
      newDiscovered.push([t.code, t.name, t.sector, t.addedDate]);
      existingCodes.add(t.code);
    }
  }

  // 5. 새롭게 발견된 주도주 시트 바닥에 추가
  if (newDiscovered.length > 0) {
    const lastRow = sheetUniverse.getLastRow() || 1;
    sheetUniverse.getRange(lastRow + 1, 1, newDiscovered.length, 4).setValues(newDiscovered);
    Logger.log(`[Discovery] 새로운 주도주 ${newDiscovered.length}개 발견 및 universe 시트에 영구 저장 완료.`);
  }

  return list;
}

function resetCoreSatelliteChunks() {
  const ss = getSpreadsheet_();
  const props = PropertiesService.getScriptProperties();
  deleteCoreSatelliteChunkSheets_("reset before chunk run");
  props.setProperty("cs_chunk_idx", "0");
  props.setProperty("cs_row_idx", "0");
  writeCoreSatelliteStatus_("RESET", 0, 0, 0, 0, "chunk sheets cleared");
}

/**
 * data_feed 시트에서 ticker → 시장데이터 맵 구성
 * H2~H5에 필요한 컬럼을 모두 읽는다.
 */
function buildDataFeedMap_(ss) {
  var map = {};
  var sheet = ss.getSheetByName(DATA_FEED_SHEET_NAME);
  if (!sheet) return map;

  var data = sheet.getDataRange().getValues();
  if (data.length <= DF_HEADER_ROW_IDX) return map;

  var headers = data[DF_HEADER_ROW_IDX];
  var c = buildColIdx_(headers);

  for (var i = DF_HEADER_ROW_IDX + 1; i < data.length; i++) {
    var row    = data[i];
    var ticker = normTicker_(c['Ticker'] !== undefined ? row[c['Ticker']] : '');
    if (!ticker) continue;

    map[ticker] = {
      ticker:          ticker,
      name:            strCol_(row, c, 'Name'),
      atr20:           numCol_(row, c, 'ATR20'),
      close:           numCol_(row, c, 'Close') || numCol_(row, c, 'Close_Price'),
      ma20:            numCol_(row, c, 'MA20'),
      ma60:            numCol_(row, c, 'MA60'),
      ma20Slope:       numCol_(row, c, 'MA20_Slope'),
      rsi14:           numCol_(row, c, 'RSI14'),
      bbPosition:      numCol_(row, c, 'BB_Position'),
      leaderTotal:     numCol_(row, c, 'Leader_Scan_Total'),
      leaderGate:      strCol_(row, c, 'Leader_Gate'),
      bandStatus:      strCol_(row, c, 'Band_Status'),
      grade:           strCol_(row, c, 'SS001_Grade') || strCol_(row, c, 'Grade'),
      flowCredit:      numCol_(row, c, 'Flow_Credit'),
      flowOk:          strCol_(row, c, 'Flow_OK'),
      rwPartial:       numCol_(row, c, 'RW_Partial'),
      finalAction:     strCol_(row, c, 'Final_Action'),
      sellSignal:      strCol_(row, c, 'Sell_Signal'),
      sellRatioPct:    numCol_(row, c, 'Sell_Ratio_Pct'),
      sellLimitPrice:  numCol_(row, c, 'Sell_Limit_Price'),
      weightTargetPct: numCol_(row, c, 'Weight_Target_Pct')
                       || numCol_(row, c, 'Target_Weight_Pct'),
      avgTradeVal5d:   numCol_(row, c, 'AvgTradeValue_5D_M'),
      avgTradeVal20d:  numColN_(row, c, 'AvgTradeValue_20D_M'), // H6: secular_leader 과열신호 3번째 조건
      valSurgePct:     numColN_(row, c, 'Val_Surge_Pct'),
      targetPrice:     numColN_(row, c, 'Target_Price'),
      upsidePct:       numColN_(row, c, 'Upside_Pct'),
      positionClass:   strCol_(row, c, 'Position_Class')
                       || strCol_(row, c, 'position_class'),
      isDuplicateEtf:  strCol_(row, c, 'Duplicate_ETF') === 'Y'
                       || strCol_(row, c, 'Is_Duplicate_ETF') === 'Y',
      frg5d:           numColN_(row, c, 'Frg_5D'),
      inst5d:          numColN_(row, c, 'Inst_5D'),
      frg20d:          numColN_(row, c, 'Frg_20D'),
      inst20d:         numColN_(row, c, 'Inst_20D'),
      ret5d:           numColN_(row, c, 'Ret5D'),
      ret20d:          numColN_(row, c, 'Ret20D'),
      prevClose:       numColN_(row, c, 'PrevClose'),
      high:            numColN_(row, c, 'High'),
      low:             numColN_(row, c, 'Low'),
      volume:          numColN_(row, c, 'Volume'),
      avgVolume5d:     numColN_(row, c, 'AvgVolume_5D'),
      sellQty:         numColN_(row, c, 'Sell_Qty'),        // M3: 선행 계산값 직접 사용
      acTotal:         numColN_(row, c, 'AC_Total'),        // H3: secular_leader_gate
      acGate:          strCol_(row, c, 'AC_Gate'),          // H3: anti_climax 판정
      liquidityStatus: strCol_(row, c, 'Liquidity_Status'),
      spreadStatus:    strCol_(row, c, 'Spread_Status'),
      dartRiskStatus:  strCol_(row, c, 'DART_Risk') || strCol_(row, c, 'DART_Status'),
      dartRisk:        strCol_(row, c, 'DART_Risk'),
      eventHoldDays:   numColN_(row, c, 'Event_Hold_Days'),  // M4: 이벤트 홀드 잔여일
      high52w:         numColN_(row, c, 'High_52W') || numColN_(row, c, 'High52W'),  // L4
      // ── [2026-05-21_BRT_HARNESS_V1] BRT/RS/Composite 판정 ──────────────
      stock_drawdown_from_high_pct: numColN_(row, c, 'Stock_Drawdown_From_High_Pct'),
      excess_drawdown_pctp: numColN_(row, c, 'Excess_Drawdown_PctP'),
      recovery_ratio_5d: numColN_(row, c, 'Recovery_Ratio_5D'),
      recovery_ratio_20d: numColN_(row, c, 'Recovery_Ratio_20D'),
      downside_beta: numColN_(row, c, 'Downside_Beta'),
      rs_line_20d_slope: numColN_(row, c, 'RS_Line_20D_Slope'),
      rs_line_60d_slope: numColN_(row, c, 'RS_Line_60D_Slope'),
      brt_verdict:      strCol_(row, c, 'BRT_Verdict'),
      brt_method:       strCol_(row, c, 'BRT_Method'),
      excess_ret_10d:   numColN_(row, c, 'Excess_Ret_10D'),
      rs_verdict_v1_raw: strCol_(row, c, 'RS_Verdict_V1_Raw'),
      rs_verdict:       strCol_(row, c, 'RS_Verdict'),
      composite_verdict: strCol_(row, c, 'Composite_Verdict'),
      saqg_v1:          strCol_(row, c, 'SAQG_V1'),
      saqg_penalty:     numColN_(row, c, 'SAQG_Penalty'),
      saqg_failed_filters: strCol_(row, c, 'SAQG_Failed_Filters'),
      rag_v1:           strCol_(row, c, 'RAG_Verdict'),
      rag_reason:       strCol_(row, c, 'RAG_Reason'),
      // ── 재무 건전성 — FINANCIAL_HEALTH_V1 + OCF_B (7일 캐시 수집) ───────────
      roe_pct:              numColN_(row, c, 'ROE_Pct'),
      opm_pct:              numColN_(row, c, 'Operating_Margin_Pct'),
      debt_ratio_pct:       numColN_(row, c, 'Debt_To_Equity'),
      current_ratio:        numColN_(row, c, 'Current_Ratio'),
      free_cf_krw:          numColN_(row, c, 'FCF_B') != null
                              ? numColN_(row, c, 'FCF_B') * 1e8 : null,   // 억원 → 원
      operating_cf_krw:     numColN_(row, c, 'OCF_B') != null
                              ? numColN_(row, c, 'OCF_B') * 1e8 : null,   // 억원 → 원 (신규)
      revenue_growth_pct:   numColN_(row, c, 'Revenue_Growth_Pct'),
    };
  }
  return map;
}

/**
 * account_snapshot 파싱
 * 반환값: H1 집계값(aggregate) + H2~H5용 holdings 배열(per-holding)
 */
function parseAccountSnapshot_(ss, totalAssetKrw, dfMap) {
  var result = {
    capturedAt:          null,
    immediateCashKrw:    0,
    settlementCashD2Krw: 0,
    openOrderAmountKrw:  0,
    totalHeatKrw:        0,
    heatRowsCount:       0,
    heatAtrEstimated:    false,
    derivedTotalAsset:   0,
    holdings:            []
  };

  var sheet = ss.getSheetByName(AS_SHEET_NAME);
  if (!sheet) { Logger.log('[HARNESS] account_snapshot 시트 없음'); return result; }

  // ── 환율(USD_KRW) 로드 ──────────────────────────────────────────────────
  var usdKrw = 1400; // default fallback
  try {
    var macroSheet = ss.getSheetByName("macro");
    if (macroSheet) {
      var mData = macroSheet.getDataRange().getValues();
      var headerRowIdx = 0;
      for (var r = 0; r < Math.min(5, mData.length); r++) {
        var row = mData[r] ?? [];
        if (row.indexOf("Symbol") >= 0 && row.indexOf("Name") >= 0) {
          headerRowIdx = r;
          break;
        }
      }
      var nameIdx = mData[headerRowIdx].indexOf("Name");
      var closeIdx = mData[headerRowIdx].indexOf("Close");
      if (nameIdx >= 0 && closeIdx >= 0) {
        for (var i = headerRowIdx + 1; i < mData.length; i++) {
          if (String(mData[i][nameIdx]).trim() === "USD_KRW") {
            var val = parseFloat(mData[i][closeIdx]);
            if (Number.isFinite(val) && val > 0) {
              usdKrw = val;
              break;
            }
          }
        }
      }
    }
  } catch (e) {
    Logger.log('[WARN] parseAccountSnapshot_ 환율 로드 실패: ' + e.message);
  }

  var data = sheet.getDataRange().getValues();
  if (data.length <= AS_HEADER_ROW_IDX) return result;

  var headers = data[AS_HEADER_ROW_IDX];
  var c = buildColIdx_(headers);

  var marketValueSum = 0;

  for (var i = AS_HEADER_ROW_IDX + 1; i < data.length; i++) {
    var row         = data[i];
    var parseStatus = strCol_(row, c, 'parse_status');
    if (parseStatus !== 'CAPTURE_READ_OK') continue;

    // captured_at (첫 유효 행 기준)
    if (!result.capturedAt && c['captured_at'] !== undefined) {
      var rawTs = row[c['captured_at']];
      if (rawTs) result.capturedAt = rawTs instanceof Date ? rawTs : new Date(rawTs);
    }

    var accountType = strCol_(row, c, 'account_type') || strCol_(row, c, 'Account_Type') || '일반계좌';
    var isRestrictedAcct = accountType === 'ISA' || accountType === '연금저축';
    var holdingQty = numCol_(row, c, 'holding_quantity');
    var immCash    = numCol_(row, c, 'immediate_cash');
    var d2Cash     = numCol_(row, c, 'settlement_cash_d2');
    var openOrder  = numCol_(row, c, 'open_order_amount');
    var mktValue   = numCol_(row, c, 'market_value');
    var ticker     = normTicker_(c['ticker'] !== undefined ? row[c['ticker']] : '');
    var isUsTicker = /^[A-Z]+$/.test(ticker);

    if (isUsTicker) {
      var currPrice = numCol_(row, c, 'current_price');
      if (currPrice > 0 && holdingQty > 0) {
        mktValue = Math.round(currPrice * holdingQty * usdKrw);
      }
    }

    if (!isRestrictedAcct) {
      if (immCash > 0)   result.immediateCashKrw    += immCash;
      if (d2Cash > 0)    result.settlementCashD2Krw += d2Cash;
      if (openOrder > 0) result.openOrderAmountKrw  += openOrder;
    }
    if (mktValue > 0)  marketValueSum             += mktValue;

    var userConfirmed = strCol_(row, c, 'user_confirmed');
    if (holdingQty <= 0 || userConfirmed !== 'Y') continue;

    // 보유 포지션 처리
    var avgCost  = numCol_(row, c, 'average_cost');
    if (isUsTicker && avgCost > 0) {
      avgCost = round2_(avgCost * usdKrw);
    }
    var stopPrice = numCol_(row, c, 'stop_price');
    if (isUsTicker && stopPrice > 0) {
      stopPrice = round2_(stopPrice * usdKrw);
    }
    var dfRow    = dfMap[ticker] || {};
    var atr20    = dfRow.atr20 || 0;
    var stopSrc  = 'MANUAL';

    // stop_price 미입력 또는 비정상 → STOP_PRICE_CORE_V1 계산
    if (stopPrice <= 0 || stopPrice >= avgCost) {
      if (atr20 > 0 && avgCost > 0) {
        var atrPct = atr20 / avgCost * 100;
        var atrMul = atrPct >= 8 ? 2.0 : 1.5;
        stopPrice  = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul);
        stopSrc    = 'COMPUTED_ATR';
      } else {
        stopPrice = avgCost * 0.92;
        stopSrc   = 'COMPUTED_PCT';
      }
      result.heatAtrEstimated = true;
    }

    // Total Heat (H1)
    var heatI = (avgCost - stopPrice) * holdingQty;
    if (heatI > 0) {
      result.totalHeatKrw += heatI;
      result.heatRowsCount++;
    }

    // stop_price 이탈 감지
    var close      = dfRow.close || 0;
    if (isUsTicker) {
      var currPrice = numCol_(row, c, 'current_price');
      close = currPrice > 0 ? round2_(currPrice * usdKrw) : round2_(close * usdKrw);
    }
    var stopBreach = close > 0 && stopPrice > 0 && close <= stopPrice;

    // weight_pct: settings의 total_asset 기준으로 계산, 없으면 account_snapshot 컬럼
    var weightPct = totalAssetKrw > 0 && mktValue > 0
      ? round2_(mktValue / totalAssetKrw * 100)
      : numCol_(row, c, 'weight_pct') || numCol_(row, c, 'Weight_Pct');

    var highestPriceSinceEntry = numCol_(row, c, 'highest_price_since_entry');
    var returnPct  = numColN_(row, c, 'return_pct');
    var entryDateStr = strCol_(row, c, 'entry_date') || '';
    var holdingDays = 0;
    if (entryDateStr) {
      var entryMs = new Date(entryDateStr).getTime();
      if (!isNaN(entryMs)) holdingDays = Math.floor((Date.now() - entryMs) / 86400000);
    }
    result.holdings.push({
      ticker:       ticker,
      name:         strCol_(row, c, 'name') || strCol_(row, c, 'Name') || dfRow.name || '',
      account:      accountType,
      holdingQty:   holdingQty,
      avgCost:      avgCost,
      stopPrice:    stopPrice,
      stopPriceSrc: stopSrc,
      stopBreach:   stopBreach,
      marketValue:  mktValue,
      weightPct:    weightPct,
      close:        close,
      profitPct:    Number.isFinite(returnPct) ? returnPct : null,
      holdingDays:  holdingDays,
      highestPriceSinceEntry: highestPriceSinceEntry > 0 ? highestPriceSinceEntry : null,
      entryDate:    entryDateStr,
      parseStatus:  parseStatus
    });
  }

  result.derivedTotalAsset = result.settlementCashD2Krw + marketValueSum;
  return result;
}
